0.2.64 update
This commit is contained in:
+205
-72
@@ -1,41 +1,114 @@
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
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 { 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.
|
||||
*
|
||||
* 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. */
|
||||
* 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> = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
|
||||
const RANKS: Record<string, number> = { TRACE: 5, DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
|
||||
const RETAIN = 20_000;
|
||||
const DRAW = 2_500;
|
||||
const POLL_MS = 5_000;
|
||||
const ROW_HEIGHT = 48;
|
||||
const HEADER_HEIGHT = 31;
|
||||
const OVERSCAN = 8;
|
||||
|
||||
/* 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. The gateway
|
||||
version deliberately remains visible: a server-log export is often read away from the
|
||||
console whose top bar would otherwise supply it. */
|
||||
const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration', 'version', 'gateway_version'];
|
||||
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' });
|
||||
|
||||
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;
|
||||
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)),
|
||||
};
|
||||
return Object.entries(attributes).sort((a, b) => rank(a[0]) - rank(b[0]));
|
||||
eventCache.set(event, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
const readableKey = (key: string) => key.replace(/_/g, ' ');
|
||||
|
||||
const haystack = (event: LogEvent) =>
|
||||
[event.message, ...Object.entries(event.attributes ?? {}).flat()].join(' ').toLowerCase();
|
||||
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[]>([]);
|
||||
@@ -44,67 +117,114 @@ export function LogsPage() {
|
||||
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);
|
||||
// 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 scrollFrame = useRef<number | undefined>(undefined);
|
||||
|
||||
const drain = useCallback(async () => {
|
||||
if (paused || fetching.current) return;
|
||||
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;
|
||||
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;
|
||||
});
|
||||
}
|
||||
droppedInDrain += page.dropped || 0;
|
||||
batch.push(...(page.events ?? []));
|
||||
pages += 1;
|
||||
} while (page.hasMore && pages < 20);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} 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(() => {
|
||||
void drain();
|
||||
if (paused) return;
|
||||
const timer = window.setInterval(() => void drain(), POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
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;
|
||||
const needle = search.trim().toLowerCase();
|
||||
return records.filter(
|
||||
(event) => (RANKS[event.level] ?? 0) >= minimum && (!needle || haystack(event).includes(needle)),
|
||||
(event) =>
|
||||
(RANKS[event.level] ?? 0) >= minimum &&
|
||||
(!deferredSearch || cached(event).haystack.includes(deferredSearch)),
|
||||
);
|
||||
}, [records, level, search]);
|
||||
}, [records, level, deferredSearch]);
|
||||
|
||||
const visible = filtered.slice(-DRAW);
|
||||
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) node.scrollTop = node.scrollHeight;
|
||||
}, [visible.length]);
|
||||
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) pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < 50;
|
||||
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 = () => {
|
||||
@@ -145,56 +265,69 @@ export function LogsPage() {
|
||||
</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>
|
||||
<Button onClick={exportJson} icon="download">Export JSON</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-live="polite">
|
||||
<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>
|
||||
{visible.length === 0 ? (
|
||||
{filtered.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 title={`Log record ${event.sequence}`}>{when(event.occurredAt)}<small>#{event.sequence}</small></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">
|
||||
<details className="logdetails">
|
||||
<summary>{orderedFields(event.attributes ?? {}).map(([key, value]) => <span key={key}><b>{key}=</b>{String(value)} </span>)}</summary>
|
||||
<dl>
|
||||
<dt>Log record</dt><dd>{event.sequence}</dd>
|
||||
{orderedFields(event.attributes ?? {}).map(([key, value]) => <Fragment key={key}><dt>{readableKey(key)}</dt><dd>{String(value)}</dd></Fragment>)}
|
||||
</dl>
|
||||
</details>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
<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
|
||||
{visible.length < filtered.length ? ` · showing the latest ${num(visible.length)}` : ''}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user