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' export type { DrawerWidth } from './composable/drawer'
import('./composable/drawer') import('./composable/drawer')
// @ts-ignore // @ts-ignore
export type { LogStreamSource } from './composable/eventStreams' export type { SearchStatus, LogStreamSource } from './composable/eventStreams'
import('./composable/eventStreams') import('./composable/eventStreams')
// @ts-ignore // @ts-ignore
export type { ExprEditorOptions } from './composable/exprEditor' export type { ExprEditorOptions } from './composable/exprEditor'
+1
View File
@@ -182,6 +182,7 @@ declare module 'vue' {
ScrollableView: typeof import('./components/ScrollableView.vue')['default'] ScrollableView: typeof import('./components/ScrollableView.vue')['default']
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default'] ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
Search: typeof import('./components/Search.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'] ServiceLog: typeof import('./components/ServiceViewer/ServiceLog.vue')['default']
SideDrawer: typeof import('./components/common/SideDrawer.vue')['default'] SideDrawer: typeof import('./components/common/SideDrawer.vue')['default']
SideMenu: typeof import('./components/SideMenu.vue')['default'] SideMenu: typeof import('./components/SideMenu.vue')['default']
@@ -9,6 +9,7 @@ import { computed, nextTick } from "vue";
import { createI18n } from "vue-i18n"; import { createI18n } from "vue-i18n";
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import { default as Component } from "./EventSource.vue"; import { default as Component } from "./EventSource.vue";
import SearchStatus from "./SearchStatus.vue";
import LogViewer from "@/components/LogViewer/LogViewer.vue"; import LogViewer from "@/components/LogViewer/LogViewer.vue";
import { Container } from "@/models/Container"; import { Container } from "@/models/Container";
import { Level } from "@/models/LogEntry"; import { Level } from "@/models/LogEntry";
@@ -172,6 +173,32 @@ describe("<ContainerEventSource />", () => {
expect(message).toMatchSnapshot(); 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", () => { describe("render html correctly", () => {
test("should render messages", async () => { test("should render messages", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();
+8 -3
View File
@@ -1,12 +1,13 @@
<template> <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="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 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 class="bg-base-content/50 h-3 rounded-full opacity-50" :class="size"></div>
</div> </div>
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
</ul> </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") }} {{ $t("label.no-logs") }}
</div> </div>
<slot :messages="messages" v-else></slot> <slot :messages="messages" v-else></slot>
@@ -24,7 +25,11 @@ const { entity, streamSource } = $defineProps<{
const { historical } = useLoggingContext(); 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(() => { const color = computed(() => {
if (error.value) return "error"; 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`)); 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>; export type LogStreamSource = ReturnType<typeof useLogStream>;
function useLogStream(url: Ref<string>, container?: Ref<Container>) { 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 opened = ref(false);
const loading = ref(true); const loading = ref(true);
const error = ref(false); const error = ref(false);
const searchStatus = ref<SearchStatus>({ active: false, done: false, matches: 0 });
const { paused: scrollingPaused } = useScrollContext(); const { paused: scrollingPaused } = useScrollContext();
const { streamConfig, hasComplexLogs, levels, loadingMore, containers } = useLoggingContext(); const { streamConfig, hasComplexLogs, levels, loadingMore, containers } = useLoggingContext();
let initial = true; let initial = true;
@@ -158,6 +167,7 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
loading.value = true; loading.value = true;
error.value = false; error.value = false;
initial = true; initial = true;
searchStatus.value = { active: isSearching.value, done: false, matches: 0 };
es = new EventSource(urlWithParams.value); es = new EventSource(urlWithParams.value);
es.addEventListener("container-event", (e) => { es.addEventListener("container-event", (e) => {
const event = JSON.parse((e as MessageEvent).data) as { 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]; 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) => { es.onmessage = (e) => {
if (e.data) { if (e.data) {
buffer.value = [...buffer.value, parseMessage(e.data)]; buffer.value = [...buffer.value, parseMessage(e.data)];
@@ -214,5 +240,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
opened, opened,
error, error,
loading, loading,
searchStatus,
}; };
} }
+3
View File
@@ -8,6 +8,8 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
const opened = ref(false); const opened = ref(false);
const loading = ref(true); const loading = ref(true);
const error = ref(false); 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 container = toRef(() => historicalContainer.value.container);
const { streamConfig, levels, loadingMore } = useLoggingContext(); const { streamConfig, levels, loadingMore } = useLoggingContext();
@@ -125,5 +127,6 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
opened, opened,
error, error,
loading, loading,
searchStatus,
}; };
} }
+42 -1
View File
@@ -49,6 +49,15 @@ func matchesFilter(event *container.LogEvent, regex *regexp.Regexp, levels map[s
return ok 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 { func (h *handler) resolveLabels(r *http.Request) container.ContainerLabels {
labels := h.config.Labels labels := h.config.Labels
if h.config.Authorization.Provider != NONE { 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) liveLogs := make(chan *container.LogEvent)
events := make(chan *container.ContainerEvent, 1) events := make(chan *container.ContainerEvent, 1)
backfill := make(chan []*container.LogEvent) backfill := make(chan []*container.LogEvent)
searchStatusCh := make(chan searchStatus)
levels := make(map[string]struct{}) levels := make(map[string]struct{})
for _, level := range r.URL.Query()["levels"] { for _, level := range r.URL.Query()["levels"] {
@@ -391,8 +401,25 @@ func (h *handler) streamLogsForContainers(w http.ResponseWriter, r *http.Request
go func() { go func() {
minimum := 50 minimum := 50
found := 0
delta := -10 * time.Second delta := -10 * time.Second
to := absoluteTime 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 { for minimum > 0 {
events := make([]*container.LogEvent, 0) events := make([]*container.LogEvent, 0)
stillRunning := false stillRunning := false
@@ -425,19 +452,28 @@ func (h *handler) streamLogsForContainers(w http.ResponseWriter, r *http.Request
} }
if !stillRunning { if !stillRunning {
// scanned past the oldest container's birth: nothing older exists
return return
} }
to = to.Add(delta) to = to.Add(delta)
delta *= 2 delta *= 2
minimum -= len(events) minimum -= len(events)
found += len(events)
sort.Slice(events, func(i, j int) bool { sort.Slice(events, func(i, j int) bool {
return events[i].Timestamp < events[j].Timestamp return events[i].Timestamp < events[j].Timestamp
}) })
if len(events) > 0 { 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") 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: case <-ticker.C:
sseWriter.Ping() sseWriter.Ping()
+88
View File
@@ -163,6 +163,94 @@ func Test_handler_streamLogs_happy_container_stopped(t *testing.T) {
mockedClient.AssertExpectations(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) { func Test_handler_streamLogs_error_reading(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
+6
View File
@@ -55,6 +55,12 @@ label:
swarm-menu: Tjenester og Stacks swarm-menu: Tjenester og Stacks
group-menu: Brugerdefinerede Grupper group-menu: Brugerdefinerede Grupper
no-logs: Container har ingen logs endnu 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 show-all-containers: Vis alle containere
collapse-all: Kollaps alle collapse-all: Kollaps alle
collapse-group: Kollaps gruppe collapse-group: Kollaps gruppe
+6
View File
@@ -55,6 +55,12 @@ label:
swarm-menu: Services und Stacks swarm-menu: Services und Stacks
group-menu: Benutzerdefinierte Gruppen group-menu: Benutzerdefinierte Gruppen
no-logs: Container hat noch keine Logs 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 show-all-containers: Zeige alle Container
collapse-all: Alle einklappen collapse-all: Alle einklappen
collapse-group: Gruppe einklappen collapse-group: Gruppe einklappen
+6
View File
@@ -59,6 +59,12 @@ label:
k8s-menu: Kubernetes k8s-menu: Kubernetes
group-menu: Custom Groups group-menu: Custom Groups
no-logs: Container has no logs yet 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 show-all-containers: Show all containers
collapse-all: Collapse all collapse-all: Collapse all
collapse-group: Collapse group collapse-group: Collapse group
+6
View File
@@ -55,6 +55,12 @@ label:
swarm-menu: Servicios y Stacks swarm-menu: Servicios y Stacks
group-menu: Grupos Personalizados group-menu: Grupos Personalizados
no-logs: El contenedor aún no tiene registros 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 show-all-containers: Mostrar todos los contenedores
collapse-all: Colapsar todo collapse-all: Colapsar todo
collapse-group: Colapsar grupo collapse-group: Colapsar grupo
+6
View File
@@ -55,6 +55,12 @@ label:
swarm-menu: Services et Stacks swarm-menu: Services et Stacks
group-menu: Groupes Personnalisés group-menu: Groupes Personnalisés
no-logs: Le conteneur n'a pas encore de logs 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 show-all-containers: Afficher tous les conteneurs
collapse-all: Réduire tout collapse-all: Réduire tout
collapse-group: Réduire le groupe collapse-group: Réduire le groupe
+6
View File
@@ -56,6 +56,12 @@ label:
swarm-menu: Layanan dan Stack swarm-menu: Layanan dan Stack
group-menu: Grup Kustom group-menu: Grup Kustom
no-logs: Kontainer belum memiliki log 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 show-all-containers: Tampilkan semua kontainer
collapse-all: Tutup semua collapse-all: Tutup semua
collapse-group: Tutup grup collapse-group: Tutup grup
+6
View File
@@ -55,6 +55,12 @@ label:
swarm-menu: Servizi e Stack swarm-menu: Servizi e Stack
group-menu: Gruppi Personalizzati group-menu: Gruppi Personalizzati
no-logs: Il container non ha ancora log 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 show-all-containers: Mostra tutti i container
collapse-all: Comprimi tutto collapse-all: Comprimi tutto
collapse-group: Comprimi gruppo collapse-group: Comprimi gruppo
+6
View File
@@ -54,6 +54,12 @@ label:
swarm-menu: 서비스 및 스택 swarm-menu: 서비스 및 스택
group-menu: 사용자 그룹 group-menu: 사용자 그룹
no-logs: 아직 로그가 없습니다 no-logs: 아직 로그가 없습니다
search-status:
searching: 이전 로그 검색 중…
searching-to: 이전 로그 검색 중… {time}까지
capped: "{count}개 일치 · {time}까지 검색함"
exhausted: 모든 로그 검색 완료 · {count}개 일치
empty: 일치 항목 없음 · 모든 로그 검색함
show-all-containers: 전체 컨테이너 보기 show-all-containers: 전체 컨테이너 보기
collapse-all: 전체 접기 collapse-all: 전체 접기
collapse-group: 그룹 접기 collapse-group: 그룹 접기
+6
View File
@@ -55,6 +55,12 @@ label:
swarm-menu: Services en Stacks swarm-menu: Services en Stacks
group-menu: Aangepaste groepen group-menu: Aangepaste groepen
no-logs: Container heeft nog geen logbestanden 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 show-all-containers: Toon alle containers
collapse-all: Alles inklappen collapse-all: Alles inklappen
collapse-group: Groep inklappen collapse-group: Groep inklappen
+6
View File
@@ -59,6 +59,12 @@ label:
swarm-menu: Usługi i Stosy swarm-menu: Usługi i Stosy
group-menu: Grupy Niestandardowe group-menu: Grupy Niestandardowe
no-logs: Kontener nie ma jeszcze logów 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 show-all-containers: Pokaż wszystkie kontenery
collapse-all: Zwiń wszystkie collapse-all: Zwiń wszystkie
collapse-group: Zwiń grupę collapse-group: Zwiń grupę
+6
View File
@@ -54,6 +54,12 @@ label:
swarm-menu: Serviços e Stacks swarm-menu: Serviços e Stacks
group-menu: Grupos Personalizados group-menu: Grupos Personalizados
no-logs: O container ainda não tem logs 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 show-all-containers: Mostrar todos os containers
collapse-all: Recolher tudo collapse-all: Recolher tudo
collapse-group: Recolher grupo collapse-group: Recolher grupo
+6
View File
@@ -55,6 +55,12 @@ label:
swarm-menu: Сервисы и Стеки swarm-menu: Сервисы и Стеки
group-menu: Пользовательские Группы group-menu: Пользовательские Группы
no-logs: У контейнера еще нет логов no-logs: У контейнера еще нет логов
search-status:
searching: Поиск в старых логах…
searching-to: Поиск в старых логах… до {time}
capped: "{count} совпадений · поиск до {time}"
exhausted: Все логи просмотрены · {count} совпадений
empty: Нет совпадений · все логи просмотрены
show-all-containers: Показать все контейнеры show-all-containers: Показать все контейнеры
collapse-all: Свернуть все collapse-all: Свернуть все
collapse-group: Свернуть группу collapse-group: Свернуть группу
+6
View File
@@ -58,6 +58,12 @@ label:
avg-mem: Povprečje spomina (%) avg-mem: Povprečje spomina (%)
name: Ime name: Ime
no-logs: Zabojnik še nima dnevnika 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: tooltip:
search: Iskanje zabojnikov (⌘ + k, ⌃k) search: Iskanje zabojnikov (⌘ + k, ⌃k)
pin-column: Pripni kot stolpec pin-column: Pripni kot stolpec
+6
View File
@@ -60,6 +60,12 @@ label:
swarm-menu: Servisler ve Yığınlar swarm-menu: Servisler ve Yığınlar
group-menu: Özel Gruplar group-menu: Özel Gruplar
no-logs: Konteyner henüz log içermiyor 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 show-all-containers: Tüm konteynerleri göster
collapse-all: Hepsini daralt collapse-all: Hepsini daralt
collapse-group: Grubu daralt collapse-group: Grubu daralt
+6
View File
@@ -40,6 +40,12 @@ label:
all-containers: 所有容器 all-containers: 所有容器
all-namespaces: 所有命名空間 all-namespaces: 所有命名空間
no-logs: 容器尚無日誌 no-logs: 容器尚無日誌
search-status:
searching: 正在搜尋較舊的日誌…
searching-to: 正在搜尋較舊的日誌…回溯至 {time}
capped: "{count} 筆符合 · 已搜尋至 {time}"
exhausted: 已搜尋所有日誌 · {count} 筆符合
empty: 無符合項目 · 已搜尋所有日誌
show-all-containers: 顯示所有容器 show-all-containers: 顯示所有容器
collapse-all: 摺疊全部 collapse-all: 摺疊全部
collapse-group: 摺疊群組 collapse-group: 摺疊群組
+6
View File
@@ -40,6 +40,12 @@ label:
all-containers: 所有容器 all-containers: 所有容器
all-namespaces: 所有命名空间 all-namespaces: 所有命名空间
no-logs: 容器尚无日志 no-logs: 容器尚无日志
search-status:
searching: 正在搜索较旧的日志…
searching-to: 正在搜索较旧的日志…回溯至 {time}
capped: "{count} 条匹配 · 已搜索至 {time}"
exhausted: 已搜索所有日志 · {count} 条匹配
empty: 无匹配项 · 已搜索所有日志
show-all-containers: 显示所有容器 show-all-containers: 显示所有容器
collapse-all: 折叠全部 collapse-all: 折叠全部
collapse-group: 折叠组 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.