Fix pausing log auto-refresh and cancel stale reads
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { tick, untrack, onMount } from 'svelte';
|
||||
import { tick, untrack, onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
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 Button from './primitives/Button.svelte';
|
||||
const uid = $props.id();
|
||||
@@ -21,18 +21,6 @@
|
||||
}
|
||||
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 ?? '');
|
||||
let data = $state<Record<string, LogResponse | null>>({});
|
||||
|
||||
@@ -45,33 +33,21 @@
|
||||
let loading = $state(false);
|
||||
let autoRefresh = $state(true);
|
||||
let refreshError = $state('');
|
||||
const pending = new Set<string>();
|
||||
let bodyEl = $state<HTMLPreElement | null>(null);
|
||||
let stickToBottom = $state(true);
|
||||
|
||||
async function fetchLog(key: string): Promise<void> {
|
||||
const currentServer = server;
|
||||
const requestKey = currentServer + ':' + key;
|
||||
if (pending.has(requestKey)) return;
|
||||
pending.add(requestKey); loading = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/servers/${encodeURIComponent(server)}/logs/${encodeURIComponent(key)}?lines=${lines}`
|
||||
);
|
||||
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;
|
||||
}
|
||||
const refresh = createLogRefresh({
|
||||
onLoading: value => { loading = value; },
|
||||
onError: message => { refreshError = message; },
|
||||
onData: (key, body) => {
|
||||
data = { ...data, [key]: body };
|
||||
const currentServer = server;
|
||||
void tick().then(() => {
|
||||
if (server === currentServer && active === key && stickToBottom && !query && bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
onDestroy(() => refresh.dispose());
|
||||
|
||||
function onScroll(): void {
|
||||
if (!bodyEl) return;
|
||||
@@ -81,7 +57,10 @@
|
||||
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() {
|
||||
query = ''; stickToBottom = true;
|
||||
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
@@ -111,15 +90,10 @@
|
||||
}
|
||||
$effect(() => { server; data = {}; refreshError = ''; });
|
||||
$effect(() => {
|
||||
server;
|
||||
const key = active;
|
||||
const target = { server, key: active, lines };
|
||||
const auto = autoRefresh;
|
||||
const interval = pollMs;
|
||||
if (!key) return;
|
||||
return untrack(() => {
|
||||
if (auto) return pollVisible(() => fetchLog(key), interval);
|
||||
void fetchLog(key);
|
||||
});
|
||||
untrack(() => refresh.configure(target, auto, interval));
|
||||
});
|
||||
|
||||
function ago(iso: string | null | undefined): string {
|
||||
@@ -154,7 +128,7 @@
|
||||
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
|
||||
Auto-refresh · {pollMs / 1000}s
|
||||
</label>
|
||||
<button class="refresh" onclick={() => fetchLog(active)} disabled={loading}>
|
||||
<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</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 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}
|
||||
<div class="empty">loading…</div>
|
||||
<div class="empty">{loading ? 'loading…' : 'No log loaded. Select Refresh to load it.'}</div>
|
||||
{:else if current && !current.ok}
|
||||
<div class="error">error: {current.reason ?? 'unknown'}</div>
|
||||
{:else if current?.missing}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user