Files
memby/admin-ui/src/pages/Logs.tsx
T

195 lines
7.5 KiB
TypeScript
Raw Normal View History

2026-08-14 09:40:03 +12:00
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Field, PageHead } from '../components/ui';
import type { LogEvent, LogResponse } from '../api/types';
/* The live server log.
*
* The ring buffer is drained in pages until it is caught up, so a console opened *after*
* an incident sees what happened rather than only what happens next. Everything the page
* has drained is held for filtering and export; only the visible tail is drawn, because
* rendering twenty thousand lines during an incident is how a browser tab stops
* responding at exactly the wrong moment. */
const RANKS: Record<string, number> = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
const RETAIN = 20_000;
const DRAW = 2_500;
const POLL_MS = 5_000;
/* The same order the server's own console lines use — who and where first, the reason for
the line last — so a log read here and a log read over SSH look alike. `version` is
dropped: it is the same on every line and is reported once in the top bar instead. */
const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration'];
function orderedFields(attributes: Record<string, unknown>): [string, unknown][] {
const rank = (key: string) => {
const at = FIELD_ORDER.indexOf(key);
if (at >= 0) return at;
return key === 'error' ? 1000 : 100;
};
return Object.entries(attributes)
.filter(([key]) => key !== 'version')
.sort((a, b) => rank(a[0]) - rank(b[0]));
}
const haystack = (event: LogEvent) =>
[event.message, ...Object.entries(event.attributes ?? {}).flat()].join(' ').toLowerCase();
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 cursor = useRef(0);
const fetching = useRef(false);
const view = useRef<HTMLDivElement>(null);
// Whether the reader is at the bottom is decided *before* the new lines are drawn: after
// they are, the measurement always says "not at the bottom" and the log would never
// follow. Hence the layout effect below rather than a check inside the fetch.
const pinned = useRef(true);
const drain = useCallback(async () => {
if (paused || fetching.current) return;
fetching.current = true;
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;
if (page.dropped) setDropped((current) => current + page.dropped);
const events = page.events ?? [];
if (events.length > 0) {
setRecords((current) => {
const next = [...current, ...events];
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
});
}
pages += 1;
} while (page.hasMore && pages < 20);
setError('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
fetching.current = false;
}
}, [paused]);
useEffect(() => {
void drain();
if (paused) return;
const timer = window.setInterval(() => void drain(), POLL_MS);
return () => window.clearInterval(timer);
}, [drain, paused]);
const filtered = useMemo(() => {
const minimum = RANKS[level] ?? 20;
const needle = search.trim().toLowerCase();
return records.filter(
(event) => (RANKS[event.level] ?? 0) >= minimum && (!needle || haystack(event).includes(needle)),
);
}, [records, level, search]);
const visible = filtered.slice(-DRAW);
useLayoutEffect(() => {
const node = view.current;
if (node && pinned.current) node.scrollTop = node.scrollHeight;
}, [visible.length]);
const onScroll = () => {
const node = view.current;
if (node) pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < 50;
};
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={() => {
setRecords([]);
setDropped(0);
}}
>
Clear view
</Button>
<Button onClick={exportJson} icon="download">
Export JSON
</Button>
</div>
</div>
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-live="polite">
{visible.length === 0 ? (
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
) : (
visible.map((event, index) => (
<div className="logline" key={`${event.occurredAt}:${index}`} data-level={event.level}>
<time>{when(event.occurredAt)}</time>
{/* The level is a class on the line as well as its own column: scrolling a
log is looking for the one line that is not INFO, and a coloured word
four columns in is easy to scroll past. */}
<span className="lvl">{event.level}</span>
<span className="msg">{event.message}</span>
<span className="attrs">
{orderedFields(event.attributes ?? {}).map(([key, value]) => (
<span key={key}>
{' '}
<b>{key}=</b>
{String(value)}
</span>
))}
</span>
</div>
))
)}
</div>
<p className="hint">
{num(records.length)} retained · {num(filtered.length)} matching
{visible.length < filtered.length ? ` · showing the latest ${num(visible.length)}` : ''}
{dropped ? ` · ${num(dropped)} overwritten before delivery` : ''}
{paused ? ' · paused' : ''}
</p>
</Card>
</>
);
}