195 lines
7.5 KiB
TypeScript
195 lines
7.5 KiB
TypeScript
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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|