Fix pausing log auto-refresh and cancel stale reads

This commit is contained in:
2026-10-04 17:19:15 -04:00
parent 2adadb0d05
commit 2413c29fc0
3 changed files with 268 additions and 48 deletions
+22 -48
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { tick, untrack, onMount } from 'svelte'; import { tick, untrack, onMount, onDestroy } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { toast } from '$ui/toast.js'; import { toast } from '$ui/toast.js';
import { pollVisible } from '$ui/poll.js'; import { createLogRefresh, type LogResponse } from '$ui/log-refresh.js';
import Tabs from './primitives/Tabs.svelte'; import Tabs from './primitives/Tabs.svelte';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
const uid = $props.id(); const uid = $props.id();
@@ -21,18 +21,6 @@
} }
let { server, tabs, lines = 1000, pollMs = 5000 }: Props = $props(); let { server, tabs, lines = 1000, pollMs = 5000 }: Props = $props();
interface LogResponse {
ok: boolean;
key?: string;
path?: string;
lines?: string[];
size?: number;
mtime?: string | null;
truncated?: boolean;
missing?: boolean;
reason?: string;
}
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? ''); const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? '');
let data = $state<Record<string, LogResponse | null>>({}); let data = $state<Record<string, LogResponse | null>>({});
@@ -45,33 +33,21 @@
let loading = $state(false); let loading = $state(false);
let autoRefresh = $state(true); let autoRefresh = $state(true);
let refreshError = $state(''); let refreshError = $state('');
const pending = new Set<string>();
let bodyEl = $state<HTMLPreElement | null>(null); let bodyEl = $state<HTMLPreElement | null>(null);
let stickToBottom = $state(true); let stickToBottom = $state(true);
async function fetchLog(key: string): Promise<void> { const refresh = createLogRefresh({
const currentServer = server; onLoading: value => { loading = value; },
const requestKey = currentServer + ':' + key; onError: message => { refreshError = message; },
if (pending.has(requestKey)) return; onData: (key, body) => {
pending.add(requestKey); loading = true; data = { ...data, [key]: body };
try { const currentServer = server;
const res = await fetch( void tick().then(() => {
`/api/servers/${encodeURIComponent(server)}/logs/${encodeURIComponent(key)}?lines=${lines}` if (server === currentServer && active === key && stickToBottom && !query && bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
); });
const body = (await res.json()) as LogResponse;
if (!res.ok || !body.ok) throw new Error(body.reason ?? 'Could not refresh log.');
if (server === currentServer) { data = { ...data, [key]: body }; if (key === active) refreshError = ''; }
} catch (err) {
if (server === currentServer && key === active) refreshError = (err as Error).message;
} finally {
pending.delete(requestKey);
if (server === currentServer && key === active) loading = false;
if (stickToBottom && !query && server === currentServer && key === active) {
await tick();
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
}
} }
} });
onDestroy(() => refresh.dispose());
function onScroll(): void { function onScroll(): void {
if (!bodyEl) return; if (!bodyEl) return;
@@ -81,7 +57,10 @@
stickToBottom = nearBottom; stickToBottom = nearBottom;
} }
function toggleAuto() { autoRefresh = !autoRefresh; } function toggleAuto(event: Event) {
autoRefresh = (event.currentTarget as HTMLInputElement).checked;
refresh.configure({ server, key: active, lines }, autoRefresh, pollMs);
}
async function jumpToLatest() { async function jumpToLatest() {
query = ''; stickToBottom = true; query = ''; stickToBottom = true;
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight; await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
@@ -111,15 +90,10 @@
} }
$effect(() => { server; data = {}; refreshError = ''; }); $effect(() => { server; data = {}; refreshError = ''; });
$effect(() => { $effect(() => {
server; const target = { server, key: active, lines };
const key = active;
const auto = autoRefresh; const auto = autoRefresh;
const interval = pollMs; const interval = pollMs;
if (!key) return; untrack(() => refresh.configure(target, auto, interval));
return untrack(() => {
if (auto) return pollVisible(() => fetchLog(key), interval);
void fetchLog(key);
});
}); });
function ago(iso: string | null | undefined): string { function ago(iso: string | null | undefined): string {
@@ -154,7 +128,7 @@
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} /> <input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
Auto-refresh · {pollMs / 1000}s Auto-refresh · {pollMs / 1000}s
</label> </label>
<button class="refresh" onclick={() => fetchLog(active)} disabled={loading}> <button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
{loading ? 'Refreshing…' : 'Refresh'} {loading ? 'Refreshing…' : 'Refresh'}
</button> </button>
</div> </div>
@@ -163,9 +137,9 @@
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div> <div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}> <div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
{#if refreshError}<div class="ui-error" role="alert">{refreshError} {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => fetchLog(active)} disabled={loading}>Retry</Button></div>{/if} {#if refreshError}<div class="ui-error" role="alert">{refreshError} {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => refresh.refresh()} disabled={loading}>Retry</Button></div>{/if}
{#if !current && !refreshError} {#if !current && !refreshError}
<div class="empty">loading…</div> <div class="empty">{loading ? 'loading…' : 'No log loaded. Select Refresh to load it.'}</div>
{:else if current && !current.ok} {:else if current && !current.ok}
<div class="error">error: {current.reason ?? 'unknown'}</div> <div class="error">error: {current.reason ?? 'unknown'}</div>
{:else if current?.missing} {:else if current?.missing}
+91
View File
@@ -0,0 +1,91 @@
import { pollVisible } from './poll.js';
export interface LogResponse {
ok: boolean;
key?: string;
path?: string;
lines?: string[];
size?: number;
mtime?: string | null;
truncated?: boolean;
missing?: boolean;
reason?: string;
}
interface Selection { server: string; key: string; lines: number }
interface Options {
onData: (key: string, response: LogResponse) => void;
onError: (message: string) => void;
onLoading: (loading: boolean) => void;
fetcher?: typeof fetch;
}
/** Pausing freezes the displayed tail, including replies already in flight. */
export function createLogRefresh(options: Options) {
const request = options.fetcher ?? fetch;
let selection: Selection | null = null;
let automatic = true;
let interval = 5000;
let stopPolling: (() => void) | undefined;
let pending: { controller: AbortController; promise: Promise<void> } | null = null;
let generation = 0;
let disposed = false;
function cancel() {
generation++;
pending?.controller.abort();
pending = null;
options.onLoading(false);
}
function refresh(): Promise<void> {
if (disposed || !selection?.key) return Promise.resolve();
if (pending) return pending.promise;
const target = selection;
const version = generation;
const current = () => !disposed && generation === version && selection === target;
const entry = { controller: new AbortController(), promise: Promise.resolve() };
pending = entry;
options.onLoading(true);
entry.promise = (async () => {
try {
const response = await request(`/api/servers/${encodeURIComponent(target.server)}/logs/${encodeURIComponent(target.key)}?lines=${target.lines}`,
{ signal: entry.controller.signal, cache: 'no-store' });
const body = await response.json() as LogResponse;
if (!response.ok || !body.ok) throw new Error(body.reason ?? 'Could not refresh log.');
if (current()) { options.onError(''); options.onData(target.key, body); }
} catch (error) {
if (current() && !entry.controller.signal.aborted) options.onError(error instanceof Error ? error.message : 'Could not refresh log.');
} finally {
if (pending === entry) pending = null;
if (current()) options.onLoading(false);
}
})();
return entry.promise;
}
function configure(target: Selection, enabled: boolean, pollMs: number) {
if (disposed) return;
const changed = !selection || selection.server !== target.server || selection.key !== target.key || selection.lines !== target.lines;
if (!changed && automatic === enabled && interval === pollMs) return;
stopPolling?.(); stopPolling = undefined;
const pausing = automatic && !enabled;
automatic = enabled;
interval = pollMs;
if (changed || pausing) cancel();
if (changed) { selection = target; options.onError(''); }
if (!selection?.key) return;
if (automatic) stopPolling = pollVisible(refresh, interval);
// Changing the selected log is an explicit load, even while paused.
else if (changed) void refresh();
}
function dispose() {
if (disposed) return;
disposed = true;
stopPolling?.(); stopPolling = undefined;
cancel();
}
return { configure, refresh, dispose };
}
+155
View File
@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createLogRefresh } from '../../src/lib/ui/log-refresh.js';
const target = { server: 't6_zombie_town_1', key: 'console', lines: 1000 };
const response = (line: string) => new Response(JSON.stringify({ ok: true, lines: [line] }));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
let documentStub: EventTarget & { visibilityState: string };
let controller: ReturnType<typeof createLogRefresh>;
beforeEach(() => {
vi.useFakeTimers();
documentStub = Object.assign(new EventTarget(), { visibilityState: 'visible' });
vi.stubGlobal('document', documentStub);
});
afterEach(() => { controller?.dispose(); vi.useRealTimers(); vi.unstubAllGlobals(); });
function setup() {
const fetcher = vi.fn<typeof fetch>();
const onData = vi.fn(); const onError = vi.fn(); const onLoading = vi.fn();
controller = createLogRefresh({ fetcher, onData, onError, onLoading });
return { fetcher, onData, onError, onLoading };
}
const visibility = (state: string) => {
documentStub.visibilityState = state;
documentStub.dispatchEvent(new Event('visibilitychange'));
};
describe('log refresh controls', () => {
it('pauses without fetching again and remains paused across intervals and visibility changes', async () => {
const { fetcher, onData, onLoading } = setup();
fetcher.mockResolvedValueOnce(response('saved log'));
controller.configure(target, true, 5000);
await controller.refresh();
controller.configure(target, false, 5000);
await vi.advanceTimersByTimeAsync(30_000);
visibility('hidden'); visibility('visible');
await vi.advanceTimersByTimeAsync(30_000);
expect(fetcher).toHaveBeenCalledTimes(1);
expect(onData).toHaveBeenCalledTimes(1);
expect(onLoading).toHaveBeenLastCalledWith(false);
});
it('aborts a pending refresh immediately and ignores its late data and loading updates after resume', async () => {
const { fetcher, onData, onLoading, onError } = setup();
const old = deferred<Response>(); const fresh = deferred<Response>();
fetcher.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
controller.configure(target, true, 5000);
const pending = controller.refresh();
const signal = fetcher.mock.calls[0][1]!.signal!;
controller.configure(target, false, 5000);
expect(signal.aborted).toBe(true);
expect(onLoading).toHaveBeenLastCalledWith(false);
expect(fetcher).toHaveBeenCalledTimes(1);
controller.configure(target, true, 5000);
const resumed = controller.refresh();
expect(fetcher).toHaveBeenCalledTimes(2);
onData.mockClear(); onError.mockClear(); onLoading.mockClear();
// Simulate a transport that delivers a response despite cancellation.
old.resolve(response('obsolete'));
await pending;
expect(onData).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
expect(onLoading).not.toHaveBeenCalled();
fresh.resolve(response('current'));
await resumed;
expect(onData).toHaveBeenCalledWith('console', expect.objectContaining({ lines: ['current'] }));
expect(onLoading).toHaveBeenLastCalledWith(false);
});
it('preserves the loaded tail and suppresses a pending refresh error on pause', async () => {
const { fetcher, onData, onError } = setup();
const next = deferred<Response>();
fetcher.mockResolvedValueOnce(response('saved')).mockReturnValueOnce(next.promise);
controller.configure(target, true, 5000);
await controller.refresh();
await vi.advanceTimersByTimeAsync(5000);
const pending = controller.refresh();
controller.configure(target, false, 5000);
onError.mockClear();
next.reject(new Error('cancelled response'));
await pending;
expect(onData).toHaveBeenCalledTimes(1);
expect(onData).toHaveBeenLastCalledWith('console', expect.objectContaining({ lines: ['saved'] }));
expect(onError).not.toHaveBeenCalled();
});
it('allows a manual refresh while paused without restarting automatic polling', async () => {
const { fetcher, onData } = setup();
fetcher.mockResolvedValueOnce(response('initial')).mockResolvedValueOnce(response('manual'));
controller.configure(target, true, 5000);
await controller.refresh();
controller.configure(target, false, 5000);
await controller.refresh();
await vi.advanceTimersByTimeAsync(30_000);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(onData).toHaveBeenLastCalledWith('console', expect.objectContaining({ lines: ['manual'] }));
});
it('loads a selected log while paused and never accepts a reply from the prior server or tab', async () => {
const { fetcher, onData } = setup();
const old = deferred<Response>();
fetcher.mockReturnValueOnce(old.promise).mockResolvedValueOnce(response('selected log'));
controller.configure(target, false, 5000);
const pending = controller.refresh();
controller.configure({ ...target, server: 'another-server', key: 'games_zm' }, false, 5000);
await controller.refresh();
old.resolve(response('prior server'));
await pending;
await vi.advanceTimersByTimeAsync(30_000);
expect(fetcher.mock.calls[0][1]!.signal!.aborted).toBe(true);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher.mock.calls[1][0]).toContain('/another-server/logs/games_zm?lines=1000');
expect(onData).toHaveBeenCalledTimes(1);
expect(onData).toHaveBeenLastCalledWith('games_zm', expect.objectContaining({ lines: ['selected log'] }));
});
it('resumes one polling loop and coalesces manual and scheduled reads', async () => {
const { fetcher } = setup();
fetcher.mockImplementation(async () => response('log'));
controller.configure(target, false, 5000);
await controller.refresh();
controller.configure(target, true, 5000);
controller.configure({ ...target }, true, 5000);
await Promise.all([controller.refresh(), controller.refresh()]);
expect(fetcher).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(5000);
expect(fetcher).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(5000);
expect(fetcher).toHaveBeenCalledTimes(4);
});
it('cancels outstanding work and polling on unmount, including a late response body', async () => {
const { fetcher, onData, onError, onLoading } = setup();
const body = deferred<unknown>();
fetcher.mockResolvedValue({ ok: true, json: () => body.promise } as Response);
controller.configure(target, true, 5000);
const pending = controller.refresh();
await Promise.resolve();
controller.dispose();
expect(fetcher.mock.calls[0][1]!.signal!.aborted).toBe(true);
onData.mockClear(); onError.mockClear(); onLoading.mockClear();
body.resolve({ ok: true, lines: ['late body'] });
await pending;
visibility('visible');
await vi.advanceTimersByTimeAsync(30_000);
expect(fetcher).toHaveBeenCalledTimes(1);
expect(onData).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
expect(onLoading).not.toHaveBeenCalled();
});
});