feat: search progress and completion indicator (#4769) (#4775)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-06-02 09:36:28 -07:00
committed by GitHub
parent fd0a4850f0
commit 2abcf3480e
27 changed files with 578 additions and 5 deletions
+1 -1
View File
@@ -420,7 +420,7 @@ declare global {
export type { DrawerWidth } from './composable/drawer'
import('./composable/drawer')
// @ts-ignore
export type { LogStreamSource } from './composable/eventStreams'
export type { SearchStatus, LogStreamSource } from './composable/eventStreams'
import('./composable/eventStreams')
// @ts-ignore
export type { ExprEditorOptions } from './composable/exprEditor'
+1
View File
@@ -182,6 +182,7 @@ declare module 'vue' {
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
Search: typeof import('./components/Search.vue')['default']
SearchStatus: typeof import('./components/LogViewer/SearchStatus.vue')['default']
ServiceLog: typeof import('./components/ServiceViewer/ServiceLog.vue')['default']
SideDrawer: typeof import('./components/common/SideDrawer.vue')['default']
SideMenu: typeof import('./components/SideMenu.vue')['default']
@@ -9,6 +9,7 @@ import { computed, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { createRouter, createWebHistory } from "vue-router";
import { default as Component } from "./EventSource.vue";
import SearchStatus from "./SearchStatus.vue";
import LogViewer from "@/components/LogViewer/LogViewer.vue";
import { Container } from "@/models/Container";
import { Level } from "@/models/LogEntry";
@@ -172,6 +173,32 @@ describe("<ContainerEventSource />", () => {
expect(message).toMatchSnapshot();
});
describe("search status", () => {
test("shows no-logs when not searching and the stream is empty", async () => {
const wrapper = createLogEventSource();
sources[sourceUrl].emitOpen();
await vi.advanceTimersByTimeAsync(3500);
await nextTick();
expect(wrapper.find('[data-testid="no-logs"]').exists()).toBe(true);
});
test("suppresses no-logs while a search is still running", async () => {
const wrapper = createLogEventSource();
sources[sourceUrl].emitOpen();
sources[sourceUrl].emit("search-status", {
data: JSON.stringify({ scannedTo: "2026-06-01T14:31:00Z", matches: 0, done: false }),
});
vi.advanceTimersByTime(3000);
await nextTick();
expect(wrapper.find('[data-testid="no-logs"]').exists()).toBe(false);
expect(wrapper.findComponent(SearchStatus).exists()).toBe(true);
});
});
describe("render html correctly", () => {
test("should render messages", async () => {
const wrapper = createLogEventSource();
+8 -3
View File
@@ -1,12 +1,13 @@
<template>
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog)">
<SearchStatus :status="searchStatus" class="sticky top-0 z-10" />
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog && !inSearch)">
<div class="flex flex-row gap-2" v-for="size in sizes">
<div class="bg-base-content/50 h-3 w-40 shrink-0 rounded-full opacity-50"></div>
<div class="bg-base-content/50 h-3 rounded-full opacity-50" :class="size"></div>
</div>
<span class="sr-only">Loading...</span>
</ul>
<div v-else-if="noLogs && !waitingForMoreLog" class="p-4">
<div v-else-if="noLogs && !waitingForMoreLog && !inSearch" class="p-4" data-testid="no-logs">
{{ $t("label.no-logs") }}
</div>
<slot :messages="messages" v-else></slot>
@@ -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";
@@ -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<string, unknown> = {}) {
return mount(SearchStatus, {
global: { plugins: [i18n] },
props: {
status: { active: false, done: false, matches: 0, scannedTo: undefined, reason: undefined, ...overrides },
},
});
}
describe("<SearchStatus />", () => {
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);
});
});
@@ -0,0 +1,69 @@
<template>
<div
v-if="state"
:data-state="state"
class="bg-base-200/80 text-base-content/70 flex items-center gap-2 px-4 py-1.5 text-xs backdrop-blur"
>
<template v-if="state === 'searching'">
<span>{{
status.scannedTo ? $t("label.search-status.searching-to", { time }) : $t("label.search-status.searching")
}}</span>
<IndeterminateBar color="primary" class="ml-auto" />
</template>
<span v-else-if="state === 'empty'">{{ $t("label.search-status.empty") }}</span>
<span v-else-if="state === 'capped'" class="tabular-nums">
{{ $t("label.search-status.capped", { count: status.matches, time }) }}
</span>
<span v-else-if="state === 'exhausted'" class="tabular-nums">
{{ $t("label.search-status.exhausted", { count: status.matches }) }}
</span>
</div>
</template>
<script lang="ts" setup>
import { type SearchStatus } from "@/composable/eventStreams";
const props = defineProps<{ status: SearchStatus }>();
// Reveal the in-progress bar only after a short delay so fast searches (the
// common case, which return almost instantly) never flash it. Slow searches
// — sparse matches over a large log — are the only ones that surface it.
const showSearching = ref(false);
// Remember whether this search ever ran slow, so the completion summary only
// shows for searches that actually made the user wait.
const wasSlow = ref(false);
// Watch the boolean, not the whole status object: a slow search replaces the
// status object on every progress event, and re-arming the timer each time
// would keep the bar from ever appearing. The delay must measure from when the
// search started.
const active = computed(() => props.status.active);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
active,
(isActive) => {
clearTimeout(timer);
if (isActive) {
timer = setTimeout(() => {
showSearching.value = true;
wasSlow.value = true;
}, 400);
} else {
showSearching.value = false;
}
},
{ immediate: true },
);
onScopeDispose(() => clearTimeout(timer));
const time = computed(() => (props.status.scannedTo ? new Date(props.status.scannedTo).toLocaleString() : ""));
const state = computed<"searching" | "empty" | "capped" | "exhausted" | null>(() => {
if (showSearching.value) return "searching";
if (!props.status.active && props.status.done) {
if (props.status.matches === 0) return "empty";
if (wasSlow.value) return props.status.reason === "capped" ? "capped" : "exhausted";
}
return null;
});
</script>
+27
View File
@@ -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<typeof useLogStream>;
function useLogStream(url: Ref<string>, container?: Ref<Container>) {
@@ -73,6 +81,7 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
const opened = ref(false);
const loading = ref(true);
const error = ref(false);
const searchStatus = ref<SearchStatus>({ 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<string>, container?: Ref<Container>) {
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<string>, container?: Ref<Container>) {
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<string>, container?: Ref<Container>) {
opened,
error,
loading,
searchStatus,
};
}
+3
View File
@@ -8,6 +8,8 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
const opened = ref(false);
const loading = ref(true);
const error = ref(false);
// Historical views are a fixed window around a log id, never a running search.
const searchStatus = ref<SearchStatus>({ 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<HistoricalCon
opened,
error,
loading,
searchStatus,
};
}
+42 -1
View File
@@ -49,6 +49,15 @@ func matchesFilter(event *container.LogEvent, regex *regexp.Regexp, levels map[s
return ok
}
// searchStatus reports progress of the filtered backfill walk to the frontend.
// scannedTo is the oldest boundary scanned so far; reason is only set when done.
type searchStatus struct {
ScannedTo time.Time `json:"scannedTo"`
Matches int `json:"matches"`
Done bool `json:"done"`
Reason string `json:"reason,omitempty"`
}
func (h *handler) resolveLabels(r *http.Request) container.ContainerLabels {
labels := h.config.Labels
if h.config.Authorization.Provider != NONE {
@@ -361,6 +370,7 @@ func (h *handler) streamLogsForContainers(w http.ResponseWriter, r *http.Request
liveLogs := make(chan *container.LogEvent)
events := make(chan *container.ContainerEvent, 1)
backfill := make(chan []*container.LogEvent)
searchStatusCh := make(chan searchStatus)
levels := make(map[string]struct{})
for _, level := range r.URL.Query()["levels"] {
@@ -391,8 +401,25 @@ func (h *handler) streamLogsForContainers(w http.ResponseWriter, r *http.Request
go func() {
minimum := 50
found := 0
delta := -10 * time.Second
to := absoluteTime
// ctx-guarded send so the goroutine never blocks after the client disconnects
send := func(s searchStatus) {
select {
case searchStatusCh <- s:
case <-r.Context().Done():
}
}
// Always emit exactly one terminal status, whatever exit fires (ran out
// of logs, hit the cap, or errored). Without this the frontend would keep
// suppressing the empty state and spin forever. "exhausted" is the default
// for running out of logs and for error/early returns; "capped" is set only
// when the loop completes by reaching the match cap.
reason := "exhausted"
defer func() {
send(searchStatus{ScannedTo: to, Matches: found, Done: true, Reason: reason})
}()
for minimum > 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()
+88
View File
@@ -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())
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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: 그룹 접기
+6
View File
@@ -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
+6
View File
@@ -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ę
+6
View File
@@ -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
+6
View File
@@ -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: Свернуть группу
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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: 摺疊群組
+6
View File
@@ -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: 折叠组
+108
View File
@@ -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 `<SearchStatus :status="searchStatus" class="sticky top-0 z-10" />` 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.