mirror of
https://github.com/amir20/dozzle.git
synced 2026-06-22 20:00:11 +00:00
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vendored
+1
-1
@@ -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'
|
||||
|
||||
Vendored
+1
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 그룹 접기
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ę
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: Свернуть группу
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 摺疊群組
|
||||
|
||||
@@ -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: 折叠组
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user