From 19f1f4e279e54cc8570ead35e989d5f53e08c2f5 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 4 Oct 2026 17:32:18 -0400 Subject: [PATCH] Preserve loaded logs across same-server fleet updates --- src/lib/ui/components/LogPanel.svelte | 2 +- src/lib/ui/log-refresh.ts | 4 +++ tests/unit/log-refresh.test.ts | 35 +++++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/lib/ui/components/LogPanel.svelte b/src/lib/ui/components/LogPanel.svelte index a951534..c923f37 100644 --- a/src/lib/ui/components/LogPanel.svelte +++ b/src/lib/ui/components/LogPanel.svelte @@ -37,6 +37,7 @@ let stickToBottom = $state(true); const refresh = createLogRefresh({ + onReset: () => { data = {}; refreshError = ''; }, onLoading: value => { loading = value; }, onError: message => { refreshError = message; }, onData: (key, body) => { @@ -88,7 +89,6 @@ } parts.push({ text: line.slice(start), match: false }); return parts; } - $effect(() => { server; data = {}; refreshError = ''; }); $effect(() => { const target = { server, key: active, lines }; const auto = autoRefresh; diff --git a/src/lib/ui/log-refresh.ts b/src/lib/ui/log-refresh.ts index 4c6a45b..f750a63 100644 --- a/src/lib/ui/log-refresh.ts +++ b/src/lib/ui/log-refresh.ts @@ -14,6 +14,7 @@ export interface LogResponse { interface Selection { server: string; key: string; lines: number } interface Options { + onReset: () => void; onData: (key: string, response: LogResponse) => void; onError: (message: string) => void; onLoading: (loading: boolean) => void; @@ -73,6 +74,9 @@ export function createLogRefresh(options: Options) { automatic = enabled; interval = pollMs; if (changed || pausing) cancel(); + // Fleet updates can replace the parent server object without changing + // this identity. Only navigation to a different server invalidates logs. + if (selection?.server !== target.server) options.onReset(); if (changed) { selection = target; options.onError(''); } if (!selection?.key) return; if (automatic) stopPolling = pollVisible(refresh, interval); diff --git a/tests/unit/log-refresh.test.ts b/tests/unit/log-refresh.test.ts index e820381..0275ef7 100644 --- a/tests/unit/log-refresh.test.ts +++ b/tests/unit/log-refresh.test.ts @@ -20,9 +20,10 @@ afterEach(() => { controller?.dispose(); vi.useRealTimers(); vi.unstubAllGlobals function setup() { const fetcher = vi.fn(); + const onReset = vi.fn(); const onData = vi.fn(); const onError = vi.fn(); const onLoading = vi.fn(); - controller = createLogRefresh({ fetcher, onData, onError, onLoading }); - return { fetcher, onData, onError, onLoading }; + controller = createLogRefresh({ fetcher, onReset, onData, onError, onLoading }); + return { fetcher, onReset, onData, onError, onLoading }; } const visibility = (state: string) => { documentStub.visibilityState = state; @@ -30,6 +31,36 @@ const visibility = (state: string) => { }; describe('log refresh controls', () => { + it('retains a manually loaded log through same-server fleet updates and resets only on server navigation', async () => { + const fetcher = vi.fn().mockImplementation(async () => response('loaded log')); + let displayed: string[] = []; + const reset = vi.fn(() => { displayed = []; }); + controller = createLogRefresh({ fetcher, onReset: reset, + onData: (_key, body) => { displayed = body.lines ?? []; }, onError: vi.fn(), onLoading: vi.fn() }); + controller.configure(target, true, 5000); + await controller.refresh(); + controller.configure(target, false, 5000); + await controller.refresh(); + expect(displayed).toEqual(['loaded log']); + reset.mockClear(); + // The parent supplies fresh props whenever Fleet publishes a snapshot. + for (let i = 0; i < 3; i++) { + controller.configure({ ...target }, false, 5000); + await vi.advanceTimersByTimeAsync(5000); + expect(displayed).toEqual(['loaded log']); + } + expect(reset).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledTimes(2); + controller.configure({ ...target, key: 'games_zm' }, false, 5000); + await controller.refresh(); + expect(reset).not.toHaveBeenCalled(); + controller.configure({ ...target, server: 'different-server' }, false, 5000); + expect(reset).toHaveBeenCalledTimes(1); + expect(displayed).toEqual([]); + await controller.refresh(); + expect(displayed).toEqual(['loaded log']); + }); + it('pauses without fetching again and remains paused across intervals and visibility changes', async () => { const { fetcher, onData, onLoading } = setup(); fetcher.mockResolvedValueOnce(response('saved log'));