97 lines
4.0 KiB
TypeScript
97 lines
4.0 KiB
TypeScript
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<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().catch(() => null) as LogResponse | null;
|
|
if (!response.ok || !body?.ok) throw new Error(body?.reason ?? `HTTP ${response.status}: ${body ? 'Log request failed.' : 'Expected a JSON log response.'}`);
|
|
if (!Array.isArray(body.lines) || !body.lines.every(line => typeof line === 'string')) throw new Error('Invalid log response: expected an array of lines.');
|
|
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 };
|
|
}
|