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 { onReset: () => void; 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 } | null = null; let generation = 0; let disposed = false; function cancel() { generation++; pending?.controller.abort(); pending = null; options.onLoading(false); } function refresh(): Promise { 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(); // 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); // 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 }; }