Files
memby/admin-ui/src/pages/Logs.tsx
T
2026-08-15 09:23:26 +12:00

335 lines
12 KiB
TypeScript

import {
Fragment,
memo,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { api } from '../api/client';
import { num } from '../lib/format';
import { Banner, Button, Card, Field, Note, PageHead } from '../components/ui';
import type { LogEvent, LogResponse } from '../api/types';
/* The live server log.
*
* Network delivery is already cursor based: each server record crosses the wire once.
* Rendering is virtualised as well, so retaining and filtering thousands of records does
* not mean mounting thousands of details trees. Only the rows around the viewport exist
* in the DOM; selecting one opens its complete structured data below the window. */
const RANKS: Record<string, number> = { TRACE: 5, DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
const RETAIN = 20_000;
const POLL_MS = 5_000;
const ROW_HEIGHT = 48;
const HEADER_HEIGHT = 31;
const OVERSCAN = 8;
const FIELD_ORDER = [
'component',
'user',
'device',
'client',
'protocol',
'method',
'path',
'status',
'duration',
'version',
'gateway_version',
];
const FIELD_RANK = new Map(FIELD_ORDER.map((key, index) => [key, index]));
const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' });
interface CachedEvent {
fields: [string, unknown][];
summary: string;
haystack: string;
occurred: string;
}
// API event objects remain stable for their retained lifetime. A WeakMap gives formatting
// and search indexing the same lifetime without adding private fields to JSON exports.
const eventCache = new WeakMap<LogEvent, CachedEvent>();
function cached(event: LogEvent): CachedEvent {
const existing = eventCache.get(event);
if (existing) return existing;
const fields = Object.entries(event.attributes ?? {}).sort((left, right) => {
const leftRank = FIELD_RANK.get(left[0]) ?? (left[0] === 'error' ? 1000 : 100);
const rightRank = FIELD_RANK.get(right[0]) ?? (right[0] === 'error' ? 1000 : 100);
return leftRank - rightRank || left[0].localeCompare(right[0]);
});
const value = {
fields,
summary: fields.map(([key, fieldValue]) => `${key}=${String(fieldValue)}`).join(' '),
haystack: [event.message, ...fields.flat()].join(' ').toLowerCase(),
occurred: dateTime.format(new Date(event.occurredAt)),
};
eventCache.set(event, value);
return value;
}
const readableKey = (key: string) => key.replace(/_/g, ' ');
const LogLine = memo(function LogLine({
event,
index,
onInspect,
}: {
event: LogEvent;
index: number;
onInspect: (sequence: number) => void;
}) {
const display = cached(event);
return (
<div
className="logline"
data-level={event.level}
data-virtual="true"
style={{ transform: `translateY(${index * ROW_HEIGHT}px)` }}
>
<time title={event.occurredAt}>
{display.occurred}
<small>#{event.sequence}</small>
</time>
<span className="lvl">{event.level}</span>
<span className="msg" title={event.message}>{event.message}</span>
<button
type="button"
className="logattrs-button"
title={display.summary || 'No structured details'}
onClick={() => onInspect(event.sequence)}
>
{display.summary || 'View record'}
</button>
</div>
);
});
export function LogsPage() {
const [records, setRecords] = useState<LogEvent[]>([]);
const [dropped, setDropped] = useState(0);
const [paused, setPaused] = useState(false);
const [level, setLevel] = useState('INFO');
const [search, setSearch] = useState('');
const [error, setError] = useState('');
const [viewport, setViewport] = useState({ top: 0, height: 600 });
const [selectedSequence, setSelectedSequence] = useState<number | null>(null);
const deferredSearch = useDeferredValue(search.trim().toLowerCase());
const cursor = useRef(0);
const fetching = useRef(false);
const viewGeneration = useRef(0);
const view = useRef<HTMLDivElement>(null);
const pinned = useRef(true);
const scrollFrame = useRef<number | undefined>(undefined);
const drain = useCallback(async () => {
if (paused || fetching.current || document.hidden) return;
fetching.current = true;
const generation = viewGeneration.current;
const batch: LogEvent[] = [];
let droppedInDrain = 0;
try {
let pages = 0;
let page: LogResponse;
do {
page = await api.get<LogResponse>(`/admin/api/events?after=${cursor.current}&limit=1000`);
cursor.current = page.next || cursor.current;
droppedInDrain += page.dropped || 0;
batch.push(...(page.events ?? []));
pages += 1;
} while (page.hasMore && pages < 20);
setError('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
// One React update for a complete catch-up prevents the initial 5,000-record drain
// from redrawing the page once per network page.
if (batch.length > 0 && generation === viewGeneration.current) {
setRecords((current) => {
const next = current.concat(batch);
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
});
}
if (droppedInDrain > 0 && generation === viewGeneration.current) {
setDropped((current) => current + droppedInDrain);
}
fetching.current = false;
}
}, [paused]);
useEffect(() => {
if (paused) return;
let timer: number | undefined;
const schedule = () => {
window.clearInterval(timer);
timer = document.hidden ? undefined : window.setInterval(() => void drain(), POLL_MS);
};
const visibilityChanged = () => {
schedule();
if (!document.hidden) void drain();
};
void drain();
schedule();
document.addEventListener('visibilitychange', visibilityChanged);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', visibilityChanged);
};
}, [drain, paused]);
const filtered = useMemo(() => {
const minimum = RANKS[level] ?? 20;
return records.filter(
(event) =>
(RANKS[event.level] ?? 0) >= minimum &&
(!deferredSearch || cached(event).haystack.includes(deferredSearch)),
);
}, [records, level, deferredSearch]);
const lastSequence = filtered.at(-1)?.sequence ?? 0;
const bodyTop = Math.max(0, viewport.top - HEADER_HEIGHT);
const count = Math.ceil(viewport.height / ROW_HEIGHT) + OVERSCAN * 2;
// A restrictive filter can make the old scroll offset larger than the new body before
// the browser dispatches its compensating scroll event. Clamp immediately so that
// transition never paints an apparently empty log.
const first = Math.min(
Math.max(0, Math.floor(bodyTop / ROW_HEIGHT) - OVERSCAN),
Math.max(0, filtered.length - count),
);
const windowed = filtered.slice(first, first + count);
const selected = useMemo(
() => records.find((event) => event.sequence === selectedSequence),
[records, selectedSequence],
);
useLayoutEffect(() => {
const node = view.current;
if (!node || !pinned.current) return;
node.scrollTop = node.scrollHeight;
setViewport({ top: node.scrollTop, height: node.clientHeight });
}, [lastSequence, deferredSearch, level]);
useEffect(() => () => window.cancelAnimationFrame(scrollFrame.current ?? 0), []);
const onScroll = () => {
const node = view.current;
if (!node) return;
pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < ROW_HEIGHT;
window.cancelAnimationFrame(scrollFrame.current ?? 0);
scrollFrame.current = window.requestAnimationFrame(() => {
setViewport({ top: node.scrollTop, height: node.clientHeight });
});
};
const exportJson = () => {
const blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `memby-events-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
link.click();
window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
};
return (
<>
<PageHead title="Server logs" intro="Structured gateway events as they happen." />
<Banner message={error} />
<Card>
<div className="filters">
<Field label="Level">
<select value={level} onChange={(event) => setLevel(event.target.value)}>
<option value="DEBUG">Debug and above</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
</Field>
<Field label="Filter" grow>
<input
type="search"
value={search}
placeholder="Person, television, title, component, path…"
onChange={(event) => setSearch(event.target.value)}
/>
</Field>
<div className="filter-actions">
<Button onClick={() => setPaused((current) => !current)} icon={paused ? 'play' : 'clock'}>
{paused ? 'Resume' : 'Pause'}
</Button>
<Button
onClick={() => {
// A page already in flight may finish after this click. Advancing the
// generation makes that batch part of the cleared past, not a flash of
// old lines reappearing after the view was emptied.
viewGeneration.current += 1;
setRecords([]);
setDropped(0);
setSelectedSequence(null);
}}
>
Clear view
</Button>
<Button onClick={exportJson} icon="download">Export JSON</Button>
</div>
</div>
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-label="Server events">
<div className="loghead" aria-hidden="true">
<span>Time</span>
<span>Level</span>
<span>Event</span>
<span>Details</span>
</div>
{filtered.length === 0 ? (
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
) : (
<div className="logbody" style={{ height: `${filtered.length * ROW_HEIGHT}px` }}>
{windowed.map((event, offset) => (
<LogLine key={event.sequence} event={event} index={first + offset} onInspect={setSelectedSequence} />
))}
</div>
)}
</div>
<p className="hint">
{num(records.length)} retained · {num(filtered.length)} matching
{filtered.length ? ` · ${num(windowed.length)} rows mounted` : ''}
{dropped ? ` · ${num(dropped)} overwritten before delivery` : ''}
{paused ? ' · paused' : ''}
</p>
{selected ? (
<section className="log-inspector" aria-label={`Log record ${selected.sequence}`}>
<div className="log-inspector-head">
<div>
<b>{selected.message}</b>
<span>{cached(selected).occurred} · {selected.level} · record #{selected.sequence}</span>
</div>
<Button size="sm" variant="quiet" onClick={() => setSelectedSequence(null)}>Close</Button>
</div>
{cached(selected).fields.length ? (
<dl>
{cached(selected).fields.map(([key, fieldValue]) => (
<Fragment key={key}>
<dt>{readableKey(key)}</dt>
<dd>{String(fieldValue)}</dd>
</Fragment>
))}
</dl>
) : (
<Note>No structured details were attached to this record.</Note>
)}
</section>
) : null}
</Card>
</>
);
}