+
{{ $t("label.no-logs") }}
@@ -24,7 +25,11 @@ const { entity, streamSource } = $defineProps<{
const { historical } = useLoggingContext();
-const { messages, opened, loading, error } = streamSource(toRef(() => entity));
+const { messages, opened, loading, error, searchStatus } = streamSource(toRef(() => entity));
+
+// While a search is running (or just finished), SearchStatus owns the empty
+// messaging, so suppress the generic "no logs" state to avoid the false signal.
+const inSearch = computed(() => searchStatus.value.active || searchStatus.value.done);
const color = computed(() => {
if (error.value) return "error";
diff --git a/assets/components/LogViewer/SearchStatus.spec.ts b/assets/components/LogViewer/SearchStatus.spec.ts
new file mode 100644
index 00000000..60777d30
--- /dev/null
+++ b/assets/components/LogViewer/SearchStatus.spec.ts
@@ -0,0 +1,108 @@
+import { mount } from "@vue/test-utils";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { nextTick } from "vue";
+import { createI18n } from "vue-i18n";
+import SearchStatus from "./SearchStatus.vue";
+import IndeterminateBar from "@/components/common/IndeterminateBar.vue";
+
+/**
+ * @vitest-environment jsdom
+ */
+const i18n = createI18n({
+ legacy: false,
+ locale: "en",
+ messages: {
+ en: {
+ label: {
+ "search-status": {
+ searching: "Searching older logs…",
+ "searching-to": "Searching older logs… back to {time}",
+ capped: "{count} matches · searched back to {time}",
+ exhausted: "Searched all logs · {count} matches",
+ empty: "No matches · searched all logs",
+ },
+ },
+ },
+ },
+});
+
+function createStatus(overrides: Record
= {}) {
+ return mount(SearchStatus, {
+ global: { plugins: [i18n] },
+ props: {
+ status: { active: false, done: false, matches: 0, scannedTo: undefined, reason: undefined, ...overrides },
+ },
+ });
+}
+
+describe("", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ test("stays hidden while a search is active but still fast (flash avoidance)", async () => {
+ const wrapper = createStatus({ active: true });
+ vi.advanceTimersByTime(100);
+ await nextTick();
+ expect(wrapper.find("[data-state]").exists()).toBe(false);
+ });
+
+ test("shows searching state once a search runs past the reveal delay", async () => {
+ const wrapper = createStatus({ active: true });
+ vi.advanceTimersByTime(400);
+ await nextTick();
+ expect(wrapper.find('[data-state="searching"]').exists()).toBe(true);
+ expect(wrapper.findComponent(IndeterminateBar).exists()).toBe(true);
+ });
+
+ test("reveals the searching bar even when progress events arrive faster than the delay", async () => {
+ const wrapper = createStatus({ active: true });
+ // a slow search emits a progress event each window; the reveal delay must
+ // measure from when the search started, not restart on every event
+ for (let i = 0; i < 5; i++) {
+ vi.advanceTimersByTime(100);
+ await wrapper.setProps({ status: { active: true, done: false, matches: i, scannedTo: `t${i}` } });
+ await nextTick();
+ }
+ expect(wrapper.find('[data-state="searching"]').exists()).toBe(true);
+ });
+
+ test("shows the empty state when a search finishes with no matches", async () => {
+ const wrapper = createStatus({ active: false, done: true, matches: 0, reason: "exhausted" });
+ await nextTick();
+ expect(wrapper.find('[data-state="empty"]').exists()).toBe(true);
+ });
+
+ test("shows a completion summary for a slow exhausted search", async () => {
+ const wrapper = createStatus({ active: true });
+ vi.advanceTimersByTime(400);
+ await nextTick();
+ await wrapper.setProps({ status: { active: false, done: true, matches: 3, reason: "exhausted" } });
+ await nextTick();
+ expect(wrapper.find('[data-state="exhausted"]').exists()).toBe(true);
+ expect(wrapper.text()).toContain("3");
+ });
+
+ test("shows a capped summary for a slow capped search", async () => {
+ const wrapper = createStatus({ active: true });
+ vi.advanceTimersByTime(400);
+ await nextTick();
+ await wrapper.setProps({
+ status: { active: false, done: true, matches: 50, reason: "capped", scannedTo: "2026-06-01T13:10:00Z" },
+ });
+ await nextTick();
+ expect(wrapper.find('[data-state="capped"]').exists()).toBe(true);
+ });
+
+ test("stays quiet for a fast search that returned matches", async () => {
+ const wrapper = createStatus({ active: true });
+ vi.advanceTimersByTime(100);
+ await nextTick();
+ await wrapper.setProps({ status: { active: false, done: true, matches: 5, reason: "capped" } });
+ await nextTick();
+ expect(wrapper.find("[data-state]").exists()).toBe(false);
+ });
+});
diff --git a/assets/components/LogViewer/SearchStatus.vue b/assets/components/LogViewer/SearchStatus.vue
new file mode 100644
index 00000000..ab1d885d
--- /dev/null
+++ b/assets/components/LogViewer/SearchStatus.vue
@@ -0,0 +1,69 @@
+
+
+
+ {{
+ status.scannedTo ? $t("label.search-status.searching-to", { time }) : $t("label.search-status.searching")
+ }}
+
+
+ {{ $t("label.search-status.empty") }}
+
+ {{ $t("label.search-status.capped", { count: status.matches, time }) }}
+
+
+ {{ $t("label.search-status.exhausted", { count: status.matches }) }}
+
+
+
+
+
diff --git a/assets/composable/eventStreams.ts b/assets/composable/eventStreams.ts
index 1121fb59..5dbcbb84 100644
--- a/assets/composable/eventStreams.ts
+++ b/assets/composable/eventStreams.ts
@@ -65,6 +65,14 @@ export function useOwnerStream(owner: Ref<{ name: string; kind: string }>): LogS
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
}
+export type SearchStatus = {
+ active: boolean;
+ done: boolean;
+ matches: number;
+ scannedTo?: string;
+ reason?: "capped" | "exhausted";
+};
+
export type LogStreamSource = ReturnType;
function useLogStream(url: Ref, container?: Ref) {
@@ -73,6 +81,7 @@ function useLogStream(url: Ref, container?: Ref) {
const opened = ref(false);
const loading = ref(true);
const error = ref(false);
+ const searchStatus = ref({ active: false, done: false, matches: 0 });
const { paused: scrollingPaused } = useScrollContext();
const { streamConfig, hasComplexLogs, levels, loadingMore, containers } = useLoggingContext();
let initial = true;
@@ -158,6 +167,7 @@ function useLogStream(url: Ref, container?: Ref) {
loading.value = true;
error.value = false;
initial = true;
+ searchStatus.value = { active: isSearching.value, done: false, matches: 0 };
es = new EventSource(urlWithParams.value);
es.addEventListener("container-event", (e) => {
const event = JSON.parse((e as MessageEvent).data) as {
@@ -183,6 +193,22 @@ function useLogStream(url: Ref, container?: Ref) {
messages.value = [...logs, ...messages.value];
});
+ es.addEventListener("search-status", (e) => {
+ const data = JSON.parse((e as MessageEvent).data) as {
+ scannedTo: string;
+ matches: number;
+ done: boolean;
+ reason?: "capped" | "exhausted";
+ };
+ searchStatus.value = {
+ active: !data.done,
+ done: data.done,
+ matches: data.matches,
+ scannedTo: data.scannedTo,
+ reason: data.reason,
+ };
+ });
+
es.onmessage = (e) => {
if (e.data) {
buffer.value = [...buffer.value, parseMessage(e.data)];
@@ -214,5 +240,6 @@ function useLogStream(url: Ref, container?: Ref) {
opened,
error,
loading,
+ searchStatus,
};
}
diff --git a/assets/composable/historicalLogs.ts b/assets/composable/historicalLogs.ts
index b9a19c53..91ab1ea1 100644
--- a/assets/composable/historicalLogs.ts
+++ b/assets/composable/historicalLogs.ts
@@ -8,6 +8,8 @@ export function useHistoricalContainerLog(historicalContainer: Ref({ active: false, done: false, matches: 0 });
const container = toRef(() => historicalContainer.value.container);
const { streamConfig, levels, loadingMore } = useLoggingContext();
@@ -125,5 +127,6 @@ export function useHistoricalContainerLog(historicalContainer: Ref 0 {
events := make([]*container.LogEvent, 0)
stillRunning := false
@@ -425,19 +452,28 @@ func (h *handler) streamLogsForContainers(w http.ResponseWriter, r *http.Request
}
if !stillRunning {
+ // scanned past the oldest container's birth: nothing older exists
return
}
to = to.Add(delta)
delta *= 2
minimum -= len(events)
+ found += len(events)
sort.Slice(events, func(i, j int) bool {
return events[i].Timestamp < events[j].Timestamp
})
if len(events) > 0 {
- backfill <- events
+ select {
+ case backfill <- events:
+ case <-r.Context().Done():
+ return
+ }
}
+ send(searchStatus{ScannedTo: to, Matches: found, Done: false})
}
+ // accumulated enough matches; more may exist further back
+ reason = "capped"
}()
}
@@ -508,6 +544,11 @@ loop:
log.Error().Err(err).Msg("error encoding container event")
}
+ case s := <-searchStatusCh:
+ if err := sseWriter.Event("search-status", s); err != nil {
+ log.Error().Err(err).Msg("error encoding search status")
+ }
+
case <-ticker.C:
sseWriter.Ping()
diff --git a/internal/web/logs_test.go b/internal/web/logs_test.go
index 9a1ef0d0..6d568b8b 100644
--- a/internal/web/logs_test.go
+++ b/internal/web/logs_test.go
@@ -163,6 +163,94 @@ func Test_handler_streamLogs_happy_container_stopped(t *testing.T) {
mockedClient.AssertExpectations(t)
}
+func Test_handler_streamLogs_search_status_exhausted(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+
+ id := "123456"
+ req, err := http.NewRequestWithContext(ctx, "GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil)
+ require.NoError(t, err, "NewRequest should not return an error.")
+
+ q := req.URL.Query()
+ q.Add("stdout", "true")
+ q.Add("stderr", "true")
+ q.Add("filter", "NOMATCH")
+ q.Add("levels", "info")
+ req.URL.RawQuery = q.Encode()
+
+ created := time.Now().Add(-5 * time.Second)
+
+ mockedClient := new(MockedClient)
+ mockedClient.On("FindContainer", mock.Anything, id).Return(container.Container{ID: id, Host: "localhost", Created: created, StartedAt: created}, nil)
+ mockedClient.On("ContainerLogsBetweenDates", mock.Anything, id, mock.Anything, mock.Anything, container.STDALL).
+ Return(io.NopCloser(strings.NewReader("")), nil)
+ mockedClient.On("ContainerLogs", mock.Anything, id, mock.Anything, container.STDALL).Return(io.NopCloser(strings.NewReader("")), io.EOF).
+ Run(func(args mock.Arguments) {
+ go func() {
+ time.Sleep(100 * time.Millisecond)
+ cancel()
+ }()
+ })
+ mockedClient.On("Host").Return(container.Host{ID: "localhost"})
+ mockedClient.On("ListContainers", mock.Anything, mock.Anything).Return([]container.Container{
+ {ID: id, Name: "test", Host: "localhost", State: "running"},
+ }, nil)
+ mockedClient.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil)
+
+ handler := createDefaultHandler(mockedClient)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ body := rr.Body.String()
+ assert.Contains(t, body, "event: search-status", "should emit a search-status event")
+ assert.Contains(t, body, `"done":true`, "should emit a terminal status")
+ assert.Contains(t, body, `"reason":"exhausted"`, "ran out of logs, so reason is exhausted")
+}
+
+func Test_handler_streamLogs_search_status_on_error(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+
+ id := "123456"
+ req, err := http.NewRequestWithContext(ctx, "GET", "/api/hosts/localhost/containers/"+id+"/logs/stream", nil)
+ require.NoError(t, err, "NewRequest should not return an error.")
+
+ q := req.URL.Query()
+ q.Add("stdout", "true")
+ q.Add("stderr", "true")
+ q.Add("filter", "needle")
+ q.Add("levels", "info")
+ req.URL.RawQuery = q.Encode()
+
+ created := time.Now().Add(-1 * time.Hour)
+
+ mockedClient := new(MockedClient)
+ mockedClient.On("FindContainer", mock.Anything, id).Return(container.Container{ID: id, Host: "localhost", Created: created, StartedAt: created}, nil)
+ // the backfill fetch fails partway through the walk
+ mockedClient.On("ContainerLogsBetweenDates", mock.Anything, id, mock.Anything, mock.Anything, container.STDALL).
+ Return(io.NopCloser(strings.NewReader("")), errors.New("boom"))
+ mockedClient.On("ContainerLogs", mock.Anything, id, mock.Anything, container.STDALL).Return(io.NopCloser(strings.NewReader("")), io.EOF).
+ Run(func(args mock.Arguments) {
+ go func() {
+ time.Sleep(100 * time.Millisecond)
+ cancel()
+ }()
+ })
+ mockedClient.On("Host").Return(container.Host{ID: "localhost"})
+ mockedClient.On("ListContainers", mock.Anything, mock.Anything).Return([]container.Container{
+ {ID: id, Name: "test", Host: "localhost", State: "running"},
+ }, nil)
+ mockedClient.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil)
+
+ handler := createDefaultHandler(mockedClient)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ body := rr.Body.String()
+ // even when the walk errors out, a terminal status must be sent so the
+ // frontend stops suppressing the empty state and the spinner clears
+ assert.Contains(t, body, "event: search-status", "should emit a search-status event")
+ assert.Contains(t, body, `"done":true`, "must emit a terminal status even on error")
+}
+
func Test_handler_streamLogs_error_reading(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
diff --git a/locales/da.yml b/locales/da.yml
index 409185aa..2bb8284c 100644
--- a/locales/da.yml
+++ b/locales/da.yml
@@ -55,6 +55,12 @@ label:
swarm-menu: Tjenester og Stacks
group-menu: Brugerdefinerede Grupper
no-logs: Container har ingen logs endnu
+ search-status:
+ searching: Søger i ældre logs…
+ searching-to: Søger i ældre logs… tilbage til {time}
+ capped: "{count} match · søgt tilbage til {time}"
+ exhausted: Søgt i alle logs · {count} match
+ empty: Ingen match · søgt i alle logs
show-all-containers: Vis alle containere
collapse-all: Kollaps alle
collapse-group: Kollaps gruppe
diff --git a/locales/de.yml b/locales/de.yml
index 01445e35..19fbb57a 100644
--- a/locales/de.yml
+++ b/locales/de.yml
@@ -55,6 +55,12 @@ label:
swarm-menu: Services und Stacks
group-menu: Benutzerdefinierte Gruppen
no-logs: Container hat noch keine Logs
+ search-status:
+ searching: Durchsuche ältere Logs…
+ searching-to: Durchsuche ältere Logs… zurück bis {time}
+ capped: "{count} Treffer · durchsucht bis {time}"
+ exhausted: Alle Logs durchsucht · {count} Treffer
+ empty: Keine Treffer · alle Logs durchsucht
show-all-containers: Zeige alle Container
collapse-all: Alle einklappen
collapse-group: Gruppe einklappen
diff --git a/locales/en.yml b/locales/en.yml
index 200fad8b..3842b506 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -59,6 +59,12 @@ label:
k8s-menu: Kubernetes
group-menu: Custom Groups
no-logs: Container has no logs yet
+ search-status:
+ searching: Searching older logs…
+ searching-to: Searching older logs… back to {time}
+ capped: "{count} matches · searched back to {time}"
+ exhausted: Searched all logs · {count} matches
+ empty: No matches · searched all logs
show-all-containers: Show all containers
collapse-all: Collapse all
collapse-group: Collapse group
diff --git a/locales/es.yml b/locales/es.yml
index f6530b8f..44d9fcf6 100644
--- a/locales/es.yml
+++ b/locales/es.yml
@@ -55,6 +55,12 @@ label:
swarm-menu: Servicios y Stacks
group-menu: Grupos Personalizados
no-logs: El contenedor aún no tiene registros
+ search-status:
+ searching: Buscando registros anteriores…
+ searching-to: Buscando registros anteriores… hasta {time}
+ capped: "{count} coincidencias · buscado hasta {time}"
+ exhausted: Todos los registros buscados · {count} coincidencias
+ empty: Sin coincidencias · todos los registros buscados
show-all-containers: Mostrar todos los contenedores
collapse-all: Colapsar todo
collapse-group: Colapsar grupo
diff --git a/locales/fr.yml b/locales/fr.yml
index 5c7a46f4..01df819d 100644
--- a/locales/fr.yml
+++ b/locales/fr.yml
@@ -55,6 +55,12 @@ label:
swarm-menu: Services et Stacks
group-menu: Groupes Personnalisés
no-logs: Le conteneur n'a pas encore de logs
+ search-status:
+ searching: Recherche dans les logs plus anciens…
+ searching-to: Recherche dans les logs plus anciens… jusqu'à {time}
+ capped: "{count} résultats · recherché jusqu'à {time}"
+ exhausted: Tous les logs recherchés · {count} résultats
+ empty: Aucun résultat · tous les logs recherchés
show-all-containers: Afficher tous les conteneurs
collapse-all: Réduire tout
collapse-group: Réduire le groupe
diff --git a/locales/id.yml b/locales/id.yml
index cc043ea9..01aa1495 100644
--- a/locales/id.yml
+++ b/locales/id.yml
@@ -56,6 +56,12 @@ label:
swarm-menu: Layanan dan Stack
group-menu: Grup Kustom
no-logs: Kontainer belum memiliki log
+ search-status:
+ searching: Mencari log lama…
+ searching-to: Mencari log lama… kembali ke {time}
+ capped: "{count} hasil · dicari kembali ke {time}"
+ exhausted: Semua log dicari · {count} hasil
+ empty: Tidak ada hasil · semua log dicari
show-all-containers: Tampilkan semua kontainer
collapse-all: Tutup semua
collapse-group: Tutup grup
diff --git a/locales/it.yml b/locales/it.yml
index 0bcfcdf8..1cff37df 100644
--- a/locales/it.yml
+++ b/locales/it.yml
@@ -55,6 +55,12 @@ label:
swarm-menu: Servizi e Stack
group-menu: Gruppi Personalizzati
no-logs: Il container non ha ancora log
+ search-status:
+ searching: Ricerca nei log meno recenti…
+ searching-to: Ricerca nei log meno recenti… fino a {time}
+ capped: "{count} risultati · cercato fino a {time}"
+ exhausted: Cercato in tutti i log · {count} risultati
+ empty: Nessun risultato · cercato in tutti i log
show-all-containers: Mostra tutti i container
collapse-all: Comprimi tutto
collapse-group: Comprimi gruppo
diff --git a/locales/ko.yml b/locales/ko.yml
index 34b8fc61..e8129047 100644
--- a/locales/ko.yml
+++ b/locales/ko.yml
@@ -54,6 +54,12 @@ label:
swarm-menu: 서비스 및 스택
group-menu: 사용자 그룹
no-logs: 아직 로그가 없습니다
+ search-status:
+ searching: 이전 로그 검색 중…
+ searching-to: 이전 로그 검색 중… {time}까지
+ capped: "{count}개 일치 · {time}까지 검색함"
+ exhausted: 모든 로그 검색 완료 · {count}개 일치
+ empty: 일치 항목 없음 · 모든 로그 검색함
show-all-containers: 전체 컨테이너 보기
collapse-all: 전체 접기
collapse-group: 그룹 접기
diff --git a/locales/nl.yml b/locales/nl.yml
index 051d2975..94f4dbde 100644
--- a/locales/nl.yml
+++ b/locales/nl.yml
@@ -55,6 +55,12 @@ label:
swarm-menu: Services en Stacks
group-menu: Aangepaste groepen
no-logs: Container heeft nog geen logbestanden
+ search-status:
+ searching: Oudere logs doorzoeken…
+ searching-to: Oudere logs doorzoeken… terug tot {time}
+ capped: "{count} overeenkomsten · doorzocht tot {time}"
+ exhausted: Alle logs doorzocht · {count} overeenkomsten
+ empty: Geen overeenkomsten · alle logs doorzocht
show-all-containers: Toon alle containers
collapse-all: Alles inklappen
collapse-group: Groep inklappen
diff --git a/locales/pl.yml b/locales/pl.yml
index b1024a16..6653d717 100644
--- a/locales/pl.yml
+++ b/locales/pl.yml
@@ -59,6 +59,12 @@ label:
swarm-menu: Usługi i Stosy
group-menu: Grupy Niestandardowe
no-logs: Kontener nie ma jeszcze logów
+ search-status:
+ searching: Przeszukiwanie starszych logów…
+ searching-to: Przeszukiwanie starszych logów… wstecz do {time}
+ capped: "{count} dopasowań · przeszukano do {time}"
+ exhausted: Przeszukano wszystkie logi · {count} dopasowań
+ empty: Brak dopasowań · przeszukano wszystkie logi
show-all-containers: Pokaż wszystkie kontenery
collapse-all: Zwiń wszystkie
collapse-group: Zwiń grupę
diff --git a/locales/pt.yml b/locales/pt.yml
index 29ffdfc2..c7e7608a 100644
--- a/locales/pt.yml
+++ b/locales/pt.yml
@@ -54,6 +54,12 @@ label:
swarm-menu: Serviços e Stacks
group-menu: Grupos Personalizados
no-logs: O container ainda não tem logs
+ search-status:
+ searching: Pesquisando logs mais antigos…
+ searching-to: Pesquisando logs mais antigos… até {time}
+ capped: "{count} correspondências · pesquisado até {time}"
+ exhausted: Todos os logs pesquisados · {count} correspondências
+ empty: Nenhuma correspondência · todos os logs pesquisados
show-all-containers: Mostrar todos os containers
collapse-all: Recolher tudo
collapse-group: Recolher grupo
diff --git a/locales/ru.yml b/locales/ru.yml
index 4e06e9d4..7c1f6866 100644
--- a/locales/ru.yml
+++ b/locales/ru.yml
@@ -55,6 +55,12 @@ label:
swarm-menu: Сервисы и Стеки
group-menu: Пользовательские Группы
no-logs: У контейнера еще нет логов
+ search-status:
+ searching: Поиск в старых логах…
+ searching-to: Поиск в старых логах… до {time}
+ capped: "{count} совпадений · поиск до {time}"
+ exhausted: Все логи просмотрены · {count} совпадений
+ empty: Нет совпадений · все логи просмотрены
show-all-containers: Показать все контейнеры
collapse-all: Свернуть все
collapse-group: Свернуть группу
diff --git a/locales/sl.yml b/locales/sl.yml
index 606b3c39..26ca443c 100644
--- a/locales/sl.yml
+++ b/locales/sl.yml
@@ -58,6 +58,12 @@ label:
avg-mem: Povprečje spomina (%)
name: Ime
no-logs: Zabojnik še nima dnevnika
+ search-status:
+ searching: Iskanje po starejših dnevnikih…
+ searching-to: Iskanje po starejših dnevnikih… nazaj do {time}
+ capped: "{count} zadetkov · iskano nazaj do {time}"
+ exhausted: Preiskani vsi dnevniki · {count} zadetkov
+ empty: Ni zadetkov · preiskani vsi dnevniki
tooltip:
search: Iskanje zabojnikov (⌘ + k, ⌃k)
pin-column: Pripni kot stolpec
diff --git a/locales/tr.yml b/locales/tr.yml
index 88e6d615..ea8757c0 100644
--- a/locales/tr.yml
+++ b/locales/tr.yml
@@ -60,6 +60,12 @@ label:
swarm-menu: Servisler ve Yığınlar
group-menu: Özel Gruplar
no-logs: Konteyner henüz log içermiyor
+ search-status:
+ searching: Eski loglar aranıyor…
+ searching-to: Eski loglar aranıyor… {time} tarihine kadar
+ capped: "{count} eşleşme · {time} tarihine kadar arandı"
+ exhausted: Tüm loglar arandı · {count} eşleşme
+ empty: Eşleşme yok · tüm loglar arandı
show-all-containers: Tüm konteynerleri göster
collapse-all: Hepsini daralt
collapse-group: Grubu daralt
diff --git a/locales/zh-tw.yml b/locales/zh-tw.yml
index dbc227ee..597180be 100644
--- a/locales/zh-tw.yml
+++ b/locales/zh-tw.yml
@@ -40,6 +40,12 @@ label:
all-containers: 所有容器
all-namespaces: 所有命名空間
no-logs: 容器尚無日誌
+ search-status:
+ searching: 正在搜尋較舊的日誌…
+ searching-to: 正在搜尋較舊的日誌…回溯至 {time}
+ capped: "{count} 筆符合 · 已搜尋至 {time}"
+ exhausted: 已搜尋所有日誌 · {count} 筆符合
+ empty: 無符合項目 · 已搜尋所有日誌
show-all-containers: 顯示所有容器
collapse-all: 摺疊全部
collapse-group: 摺疊群組
diff --git a/locales/zh.yml b/locales/zh.yml
index f5fa0300..febc2b41 100644
--- a/locales/zh.yml
+++ b/locales/zh.yml
@@ -40,6 +40,12 @@ label:
all-containers: 所有容器
all-namespaces: 所有命名空间
no-logs: 容器尚无日志
+ search-status:
+ searching: 正在搜索较旧的日志…
+ searching-to: 正在搜索较旧的日志…回溯至 {time}
+ capped: "{count} 条匹配 · 已搜索至 {time}"
+ exhausted: 已搜索所有日志 · {count} 条匹配
+ empty: 无匹配项 · 已搜索所有日志
show-all-containers: 显示所有容器
collapse-all: 折叠全部
collapse-group: 折叠组
diff --git a/plans/search-progress-indicator.md b/plans/search-progress-indicator.md
new file mode 100644
index 00000000..c738140b
--- /dev/null
+++ b/plans/search-progress-indicator.md
@@ -0,0 +1,108 @@
+# Search progress / completion indicator
+
+Status: built. Backend emits `search-status`, frontend gates the false "No logs" and renders a slim status bar.
+
+Two refinements added during implementation, beyond the original design:
+
+- The in-progress bar reveals only after a 400ms delay, so fast searches (the common case) never flash it. Only slow searches surface it.
+- The completion summary (capped/exhausted) shows only for searches that actually ran slow; fast searches stay quiet. Zero-match searches always show "No matches · searched all logs" (the real fix for the false "No logs").
+
+What the indicator actually delivers (verified end-to-end against a real busy container via the `amir20/echo -haystack` generator):
+
+- "No logs" no longer shows mid-search. Fixed.
+- A "Searching older logs…" indicator stays up for the whole search. Works.
+- The live "scanned back to {time}" readout does NOT animate. `ContainerLogsBetweenDates` uses Docker's `--since`/`--until`, and the json-file driver scans the whole log from the start to honor `--since`, so the first window pays a full-file cold read that is basically the entire search time; the remaining windows hit warm cache and finish in milliseconds. All `search-status` events therefore arrive bunched at the end, not spread across the walk. The component degrades gracefully (plain "Searching older logs…" during, completion summary after), but treat "how much was scanned" as a final value, not a live progress bar. This is inherent to how Docker serves `--since`; the backfill loop can't change it.
+
+## Problem (issue #4769)
+
+Regex search over a large log with sparse matches takes 10-15s. During that time the UI gives no signal that the search is still running, and can even render "No logs" mid-search, so the user can't tell whether it's done.
+
+Reporter's three asks:
+
+1. Search progress indication. The spinner exists but does not track the search.
+2. Faster partial results. Already streams per-window; not the real gap.
+3. "How much of the log was searched." Genuinely missing.
+
+## Root cause / mechanics
+
+`internal/web/logs.go:390-439`, the backfill goroutine:
+
+- Walks backward in expanding time windows. `delta` starts at `-10s` and doubles each iteration (`:392`, `:430`).
+- Loops until 50 matches accumulate (`minimum`) or `!stillRunning` (`:426`, reached the oldest log / no container still running).
+- Filtering is server-side Go regex (`matchesFilter` `:44-50`, applied `:416`). The Docker API only does time-range fetch (`since`/`until`); it has no text search, so Dozzle does the windowing and matching itself.
+- Per-window matches stream out via the `backfill` channel -> `logs-backfill` SSE (`:435-437`, emitted `:501-507`) -> frontend prepends them (`assets/composable/eventStreams.ts:180-184`).
+- No completion signal is ever sent. The goroutine just `return`s at both exits.
+
+The "No logs" bug: `loading` flips to false on `onopen` (`eventStreams.ts:196`), which fires the instant the SSE connection opens, before backfill does any work. The skeleton only persists 3s while empty (`EventSource.vue:2`, `:37-38`); after that, if no match has arrived yet, the UI shows "No logs" (`EventSource.vue:9`) while the search is still running.
+
+Everything the three asks need (`to` boundary, match count, which exit fired) is already computed in the goroutine and thrown away. The fix is to surface it.
+
+## Design: one new SSE event `search-status`
+
+```jsonc
+// each backfill iteration, plus once more at the end with done=true
+{ "scannedTo": "2026-06-01T14:31:00Z", "matches": 12, "done": false }
+{ "scannedTo": "2026-06-01T13:10:00Z", "matches": 50, "done": true, "reason": "capped" } // hit 50 cap, more may exist further back
+{ "scannedTo": "2026-04-02T00:00:00Z", "matches": 23, "done": true, "reason": "exhausted" } // ran out of logs, nothing older
+```
+
+`reason` only set when `done`.
+
+## Changes
+
+### 1. Backend `internal/web/logs.go` (~20 lines, no search-logic change)
+
+- Add a `searchStatus` struct and a `searchStatusCh` channel.
+- In the goroutine: track `found`; emit a progress event after `to = to.Add(delta)`; emit a terminal event at both exits (`exhausted` at `:426`, `capped` after the loop ends).
+- Use a ctx-guarded send to avoid blocking forever if the main loop already exited:
+ ```go
+ send := func(s searchStatus) {
+ select {
+ case searchStatusCh <- s:
+ case <-r.Context().Done():
+ }
+ }
+ ```
+ (The existing `backfill <-` send has the same latent leak; worth fixing in passing.)
+- In the select loop (`:479-515`) add: `case s := <-searchStatusCh: sseWriter.Event("search-status", s)`.
+
+### 2. Frontend `assets/composable/eventStreams.ts`
+
+- Add a `searchStatus` ref `{ active, done, matches, scannedTo?, reason? }`.
+- Reset it in `connect()` with `active: isSearching.value` so State 1 shows immediately, before the first event.
+- `es.addEventListener("search-status", ...)` to populate it.
+- Return `searchStatus` from `useLogStream`.
+
+### 3. Frontend `assets/components/LogViewer/EventSource.vue`
+
+- Pull `searchStatus` from the stream source.
+- Render `` when `searchStatus.active`.
+- The actual bug fix: gate the empty state with `&& !searchStatus.active` so "No logs" never shows during a search.
+
+### 4. New `assets/components/LogViewer/SearchStatus.vue` (~40 lines)
+
+Slim sticky bar, states: searching / capped / exhausted / empty. Reuse the `IndeterminateBar` idiom while active. `tabular-nums` on the count so it doesn't jitter. Primary check on done. Restrained styling, no banner or card.
+
+"Load older" is not new wiring: the existing `LoadMoreLogEntry` (`eventStreams.ts:127`, `assets/composable/logLoader.ts`) already re-runs the fetch with the active filter. On `capped`, the copy just points at it.
+
+### 5. i18n `locales/en.yml` under `label:` after `:61`
+
+```yaml
+search-status:
+ searching: Searching older logs…
+ searching-to: "Searching older logs… back to {time}"
+ matches: "no matches | {count} match | {count} matches"
+ capped: "{count} matches · searched back to {time}"
+ exhausted: "Searched all logs · {count} matches"
+ empty: No matches · searched all logs
+```
+
+## Decisions to lock before building
+
+1. Show the status for any filtered backfill (regex + level filters) or regex-only? Lean both: it's free and self-gates, since the goroutine only runs for filtered views (`:387`).
+2. ~~Optional: turn the bottom `IndeterminateBar` into a real fill bar, `(now - to) / (now - container.Created)`.~~ Dropped: progress events bunch at the end (see status note above), so a fill bar would sit at 0 then jump to 100. An indeterminate bar is the honest choice.
+3. Placement: sticky top of the scroll area (proposed) vs inline first row. Lean sticky so it stays visible while scrolling.
+
+## Scope
+
+~20 lines backend, ~15 lines across two existing frontend files, one ~40-line component, six i18n strings. No change to how search works.