0.1.38 gateway
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user