Files
memby/admin-ui/src/pages/Logs.tsx
T
2026-08-19 14:25:44 +12:00

832 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Fragment,
memo,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import { api } from '../api/client';
import { num } from '../lib/format';
import { Banner, Button, Card, Note, PageHead } from '../components/ui';
import { Icon } from '../components/Icon';
import {
DRAWER_SECTIONS,
EMPTY_FILTERS,
activeChips,
durationTone,
facets,
formatDuration,
isSectioned,
matches,
serviceTone,
shape,
} from '../lib/logmodel';
import type { LogFilters, Shaped } from '../lib/logmodel';
import type { LogEvent, LogResponse } from '../api/types';
/* The live server log.
*
* Network delivery is cursor based: each server record crosses the wire once. Rendering is
* virtualised, so retaining and filtering thousands of records does not mean mounting
* thousands of rows — only those around the viewport exist in the DOM.
*
* What changed, and why: the table used to print a record's attribute bag as one string,
* which is complete and unreadable. `lib/logmodel` turns a record into the four answers a
* person is actually after — when, which part of the server, what happened, did it work —
* and this file is only the table, the filters and the drawer over that. The drawer is
* where the evidence lives now, rather than where the meaning was.
*
* Three properties are easy to give back and worth keeping:
*
* - **Rows have two heights, not one.** Repetitive request traffic stays on one line,
* because density is the reason this page is worth watching; a failure or a real
* application event earns a second. That means the virtual window is driven by a prefix
* sum of row heights rather than by multiplication, computed once per filter change.
* Which height a row gets and what it prints are one decision, made once, in
* `lib/logmodel`'s `secondary` — the table renders that field and nothing else under
* the summary. They were two, and they disagreed: every authenticated request line
* carries the viewer and the television, so a context line was drawn on rows the height
* rule had already ruled out, centred inside a box too short for it and sliced top and
* bottom. A request's identity now sits at the end of its own line instead, which keeps
* both the density and the fact.
* - **Pause holds the view, not the connection.** Draining continues while paused and the
* arrivals are held in a buffer, so the cursor keeps up with the server's ring buffer
* and resuming is a flush rather than a stampede — the previous behaviour let the ring
* overwrite records the operator had paused specifically in order to read around.
* - **Nothing snaps.** The view follows the tail only while the operator is already at
* it; scrolling up hands them a `Jump to latest` instead. */
const RETAIN = 20_000;
const POLL_MS = 5_000;
/* Row geometry, and it is arithmetic rather than a pair of round numbers: a row is
* absolutely positioned at a height this file decides, so anything the stylesheet draws
* that these figures do not account for is text clipped by a rule nobody can see from the
* CSS. Every first-line cell in `.logrow` is given exactly ROW_LINE, the secondary line
* exactly ROW_SECOND, and the padding and hairline below are the same on both heights —
* which is what makes the columns line up whether an event printed one line or two.
* Changing any figure here means changing its twin in `styles.css`. */
const ROW_PAD = 7;
const ROW_LINE = 16;
const ROW_SECOND = 15;
const ROW_SECOND_GAP = 2;
const ROW_RULE = 1;
const ROW_COMPACT = ROW_PAD + ROW_LINE + ROW_PAD + ROW_RULE;
const ROW_TALL = ROW_COMPACT + ROW_SECOND_GAP + ROW_SECOND;
const DAY_HEIGHT = 26;
const HEADER_HEIGHT = 31;
const OVERSCAN = 10;
const LEVELS = [
{ value: 'TRACE', label: 'Everything' },
{ value: 'DEBUG', label: 'Debug+' },
{ value: 'INFO', label: 'Info+' },
{ value: 'WARN', label: 'Warnings+' },
{ value: 'ERROR', label: 'Errors only' },
];
const STATUS_BANDS = [
{ value: '', label: 'Any result' },
{ value: '2xx', label: 'Success (2xx)' },
{ value: '3xx', label: 'Redirect (3xx)' },
{ value: '4xx', label: 'Client error (4xx)' },
{ value: '5xx', label: 'Server error (5xx)' },
{ value: 'error', label: 'Failed (≥400)' },
];
const SLOWER = [
{ value: 0, label: 'Any duration' },
{ value: 100, label: 'Slower than 100 ms' },
{ value: 500, label: 'Slower than 500 ms' },
{ value: 1000, label: 'Slower than 1 s' },
{ value: 3000, label: 'Slower than 3 s' },
];
const readableKey = (key: string) => key.replace(/_/g, ' ');
/* ---------- one row ---------- */
/** A value in the table that is also a filter. Clicking a service, a level, a method or a
* status is by some way the fastest way into a subsystem, and it costs nothing to make
* the thing already printed be the control. */
function Facet({
onPick,
className,
title,
children,
...rest
}: {
onPick: () => void;
className: string;
title: string;
children: ReactNode;
} & Record<string, unknown>) {
return (
<button type="button" className={`logfacet ${className}`} title={title} onClick={onPick} {...rest}>
{children}
</button>
);
}
const LogRow = memo(function LogRow({
event,
view,
top,
height,
selected,
onInspect,
onFilter,
}: {
event: LogEvent;
view: Shaped;
top: number;
height: number;
selected: boolean;
onInspect: (sequence: number) => void;
onFilter: (patch: Partial<LogFilters>) => void;
}) {
const slow = view.durationMs !== null ? durationTone(view.durationMs) : null;
return (
<div
className="logrow"
data-level={view.level}
data-selected={selected || undefined}
style={{ transform: `translateY(${top}px)`, height: `${height}px` }}
>
<time className="logrow-time" title={event.occurredAt}>
{view.time}
</time>
<Facet
className="logrow-level"
data-level={view.level}
title={`Show ${view.level} and above`}
onPick={() => onFilter({ level: view.level })}
>
{view.level}
</Facet>
<span className="logrow-place">
<Facet
className="logrow-service"
data-tone={serviceTone(view.serviceKey)}
title={`Filter to ${view.service}`}
onPick={() => onFilter({ service: view.serviceKey, component: '' })}
>
{view.service}
</Facet>
<span className="logrow-sep" aria-hidden="true">
</span>
<Facet
className="logrow-component"
title={`Filter to ${view.component}`}
onPick={() => onFilter({ component: view.component })}
>
{view.component}
</Facet>
</span>
<button
type="button"
className="logrow-summary"
title={[view.summary, view.trail, view.secondary].filter(Boolean).join(' — ')}
onClick={() => onInspect(event.sequence)}
>
<span className="logrow-line">
{view.action ? (
<b className="logrow-action" data-method={view.method || undefined}>
{view.action}
</b>
) : null}
<span className="logrow-text">{view.summary}</span>
{view.trail ? <span className="logrow-trail">{view.trail}</span> : null}
</span>
{/* Printed if and only if the row was measured for it — see `secondary` in
lib/logmodel. */}
{view.secondary ? (
<span className="logrow-second" data-tone={view.secondaryTone}>
{view.secondaryTone === 'error' ? `↳ ${view.secondary}` : view.secondary}
</span>
) : null}
</button>
<span className="logrow-result">
{view.result ? (
<Facet
className="logrow-verdict"
data-tone={view.result.tone}
title={
view.status !== null ? `Filter to ${view.status}` : `Filter to ${view.eventKey}`
}
onPick={() =>
view.status !== null
? onFilter({ status: `${Math.floor(view.status / 100)}xx` })
: onFilter({ event: view.eventKey })
}
>
{view.result.label}
</Facet>
) : null}
</span>
<span className="logrow-duration" data-tone={slow ?? undefined}>
{view.durationMs !== null ? formatDuration(view.durationMs) : ''}
</span>
</div>
);
});
/* ---------- the drawer ---------- */
function Drawer({
event,
view,
onClose,
}: {
event: LogEvent;
view: Shaped;
onClose: () => void;
}) {
const [copied, setCopied] = useState(false);
const leftovers = view.fields.filter(([key]) => !isSectioned(key) && key !== 'component');
const copy = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(event, null, 2));
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
setCopied(false);
}
};
const sections = DRAWER_SECTIONS.map((section) => ({
title: section.title,
rows: section.keys
.map((key) => [key, view.attributes[key]] as [string, unknown])
.filter(([, value]) => value !== undefined && value !== null && String(value) !== ''),
})).filter((section) => section.rows.length > 0);
return (
<section className="logdrawer" aria-label={`Log record ${event.sequence}`}>
<header className="logdrawer-head">
<div>
<p className="logdrawer-place">
<span className="logrow-service" data-tone={serviceTone(view.serviceKey)}>
{view.service}
</span>
<span className="logrow-sep" aria-hidden="true">
</span>
{view.component}
</p>
<b>{view.summary}</b>
{view.detail ? <p className="logdrawer-error">{view.detail}</p> : null}
</div>
<div className="logdrawer-actions">
<Button size="sm" variant="quiet" onClick={copy} icon="download">
{copied ? 'Copied' : 'Copy JSON'}
</Button>
<Button size="sm" variant="quiet" onClick={onClose} icon="close">
Close
</Button>
</div>
</header>
<div className="logdrawer-grid">
<div className="logdrawer-section">
<h4>Overview</h4>
<dl>
<dt>Time</dt>
<dd>
{view.day} {view.time}
</dd>
<dt>Level</dt>
<dd>{view.level}</dd>
<dt>Service</dt>
<dd>
{view.service} {view.component}
</dd>
<dt>Event</dt>
<dd>{view.eventKey}</dd>
{view.result ? (
<>
<dt>Result</dt>
<dd>{view.result.label}</dd>
</>
) : null}
{view.durationMs !== null ? (
<>
<dt>Duration</dt>
<dd>{formatDuration(view.durationMs)}</dd>
</>
) : null}
<dt>Record</dt>
<dd>#{event.sequence}</dd>
</dl>
</div>
{sections.map((section) => (
<div className="logdrawer-section" key={section.title}>
<h4>{section.title}</h4>
<dl>
{section.rows.map(([key, value]) => (
<Fragment key={key}>
<dt>{readableKey(key)}</dt>
<dd>{String(value)}</dd>
</Fragment>
))}
</dl>
</div>
))}
{leftovers.length ? (
<div className="logdrawer-section">
<h4>Details</h4>
<dl>
{leftovers.map(([key, value]) => (
<Fragment key={key}>
<dt>{readableKey(key)}</dt>
<dd>{String(value)}</dd>
</Fragment>
))}
</dl>
</div>
) : null}
</div>
<details className="logdrawer-raw">
<summary>Raw event</summary>
<pre>{JSON.stringify(event, null, 2)}</pre>
</details>
</section>
);
}
/* ---------- the page ---------- */
export function LogsPage() {
const [records, setRecords] = useState<LogEvent[]>([]);
const [dropped, setDropped] = useState(0);
const [paused, setPaused] = useState(false);
const [held, setHeld] = useState(0);
/* A ?q= in the address seeds the text filter once, so a page that has diagnosed
something can hand the operator the logs already narrowed to it — which is what the
Integrations area's "open the logs" link does with a failing service's name. Seeded
on the initial state rather than in an effect: applying it later would fight whatever
the operator had already typed, and the whole point of a deep link is that it is where
they arrive rather than something that happens to them. */
const [filters, setFilters] = useState<LogFilters>(() => {
const seed = new URLSearchParams(window.location.search).get('q') ?? '';
return seed ? { ...EMPTY_FILTERS, text: seed } : EMPTY_FILTERS;
});
const [error, setError] = useState('');
const [viewport, setViewport] = useState({ top: 0, height: 600 });
const [atTail, setAtTail] = useState(true);
const [selectedSequence, setSelectedSequence] = useState<number | null>(null);
const deferredSearch = useDeferredValue(filters.text.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);
// Arrivals while paused. Held here rather than left on the server: the ring buffer is
// finite, and a pause taken in order to read something is exactly when it must not be
// overwritten underneath the operator.
const holding = useRef<LogEvent[]>([]);
// Read by `drain`, which is a timer callback rather than a render. Written in an effect
// rather than during render: a render that concurrent React discards must not be able to
// decide whether the next batch of arrivals is shown or held.
const pausedRef = useRef(paused);
useEffect(() => {
pausedRef.current = paused;
}, [paused]);
const admit = useCallback((batch: LogEvent[]) => {
setRecords((current) => {
const next = current.concat(batch);
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
});
}, []);
const drain = useCallback(async () => {
if (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 drain from redrawing
// the page once per network page.
if (batch.length > 0 && generation === viewGeneration.current) {
if (pausedRef.current) {
holding.current = holding.current.concat(batch);
if (holding.current.length > RETAIN) {
holding.current = holding.current.slice(holding.current.length - RETAIN);
}
setHeld(holding.current.length);
} else {
admit(batch);
}
}
if (droppedInDrain > 0 && generation === viewGeneration.current) {
setDropped((current) => current + droppedInDrain);
}
fetching.current = false;
}
}, [admit]);
useEffect(() => {
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]);
// Both halves set the ref straight away as well as the state. A drain landing between
// the click and the commit would otherwise file its batch on the wrong side of the
// pause — held records with the button already reading "Pause", which nothing would
// ever flush.
const pause = useCallback(() => {
pausedRef.current = true;
setPaused(true);
}, []);
const resume = useCallback(() => {
pausedRef.current = false;
const waiting = holding.current;
holding.current = [];
setHeld(0);
setPaused(false);
if (waiting.length) admit(waiting);
}, [admit]);
const patch = useCallback((next: Partial<LogFilters>) => {
setFilters((current) => ({ ...current, ...next }));
}, []);
const filtered = useMemo(
() => records.filter((event) => matches(event, filters, deferredSearch)),
[records, filters, deferredSearch],
);
/* Row geometry. Two heights and a day divider mean the window cannot be found by
* dividing a scroll offset, so heights are accumulated once per filter change and the
* first visible row is found by binary search. Twenty thousand records is one pass over
* a typed array — cheaper than the render it replaces. */
const layout = useMemo(() => {
const tops = new Float64Array(filtered.length + 1);
const heights = new Uint8Array(filtered.length);
const divider = new Uint8Array(filtered.length);
let y = 0;
let day = '';
for (let index = 0; index < filtered.length; index += 1) {
const record = filtered[index];
if (!record) continue;
const shaped = shape(record);
if (shaped.dayKey !== day) {
divider[index] = 1;
day = shaped.dayKey;
y += DAY_HEIGHT;
}
const height = shaped.tall ? ROW_TALL : ROW_COMPACT;
tops[index] = y;
heights[index] = height;
y += height;
}
tops[filtered.length] = y;
return { tops, heights, divider, total: y };
}, [filtered]);
const bodyTop = Math.max(0, viewport.top - HEADER_HEIGHT);
const first = useMemo(() => {
let low = 0;
let high = filtered.length;
while (low < high) {
const middle = (low + high) >> 1;
if ((layout.tops[middle] ?? 0) + (layout.heights[middle] ?? 0) <= bodyTop) low = middle + 1;
else high = middle;
}
return Math.max(0, low - OVERSCAN);
}, [layout, bodyTop, filtered.length]);
const last = useMemo(() => {
const limit = bodyTop + viewport.height;
let index = first;
while (index < filtered.length && (layout.tops[index] ?? 0) < limit) index += 1;
return Math.min(filtered.length, index + OVERSCAN);
}, [layout, bodyTop, viewport.height, first, filtered.length]);
const windowed = useMemo(() => {
const rows: { event: LogEvent; view: Shaped; index: number }[] = [];
for (let index = first; index < last; index += 1) {
const record = filtered[index];
if (record) rows.push({ event: record, view: shape(record), index });
}
return rows;
}, [filtered, first, last]);
const lastSequence = filtered.at(-1)?.sequence ?? 0;
const selected = useMemo(
() => records.find((event) => event.sequence === selectedSequence),
[records, selectedSequence],
);
const scrollToTail = useCallback(() => {
const node = view.current;
if (!node) return;
pinned.current = true;
node.scrollTop = node.scrollHeight;
setAtTail(true);
setViewport({ top: node.scrollTop, height: node.clientHeight });
}, []);
useLayoutEffect(() => {
const node = view.current;
if (!node || !pinned.current) return;
node.scrollTop = node.scrollHeight;
setViewport({ top: node.scrollTop, height: node.clientHeight });
}, [lastSequence, layout.total]);
useEffect(() => () => window.cancelAnimationFrame(scrollFrame.current ?? 0), []);
const onScroll = () => {
const node = view.current;
if (!node) return;
const tail = node.scrollHeight - node.scrollTop - node.clientHeight < ROW_TALL;
pinned.current = tail;
setAtTail(tail);
window.cancelAnimationFrame(scrollFrame.current ?? 0);
scrollFrame.current = window.requestAnimationFrame(() => {
setViewport({ top: node.scrollTop, height: node.clientHeight });
});
};
const exportJson = () => {
const blob = new Blob([JSON.stringify(filtered, 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);
};
const available = useMemo(() => facets(records), [records]);
const chips = activeChips(filters, available.services);
return (
<>
<PageHead title="Server logs" intro="Structured gateway events as they happen." />
<Banner message={error} />
<Card>
<div className="logbar">
<div className="logbar-filters">
<select
aria-label="Service"
value={filters.service}
onChange={(event) => patch({ service: event.target.value, component: '' })}
>
<option value="">All services</option>
{available.services.map((service) => (
<option key={service.key} value={service.key}>
{service.label}
</option>
))}
</select>
<select
aria-label="Component"
value={filters.component}
onChange={(event) => patch({ component: event.target.value })}
>
<option value="">All components</option>
{available.components.map((component) => (
<option key={component} value={component}>
{component}
</option>
))}
</select>
<select
aria-label="Level"
value={filters.level}
onChange={(event) => patch({ level: event.target.value })}
>
{LEVELS.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
<select
aria-label="Event"
value={filters.event}
onChange={(event) => patch({ event: event.target.value })}
>
<option value="">All events</option>
{available.events.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<select
aria-label="Result"
value={filters.status}
onChange={(event) => patch({ status: event.target.value })}
>
{STATUS_BANDS.map((band) => (
<option key={band.value} value={band.value}>
{band.label}
</option>
))}
</select>
<select
aria-label="Method"
value={filters.method}
onChange={(event) => patch({ method: event.target.value })}
>
<option value="">Any method</option>
{available.methods.map((method) => (
<option key={method} value={method}>
{method}
</option>
))}
</select>
<select
aria-label="Duration"
value={String(filters.slower)}
onChange={(event) => patch({ slower: Number(event.target.value) })}
>
{SLOWER.map((option) => (
<option key={option.value} value={String(option.value)}>
{option.label}
</option>
))}
</select>
<label className="logsearch">
<Icon name="search" />
<input
type="search"
value={filters.text}
aria-label="Search logs"
placeholder="Search person, title, service, component, path, request ID…"
onChange={(event) => patch({ text: event.target.value })}
/>
</label>
</div>
<div className="logbar-actions">
<Button
size="sm"
variant="quiet"
onClick={() => (paused ? resume() : pause())}
icon={paused ? 'play' : 'clock'}
>
{paused ? (held ? `Resume (${num(held)})` : 'Resume') : 'Pause'}
</Button>
<Button
size="sm"
variant="quiet"
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;
holding.current = [];
setHeld(0);
setRecords([]);
setDropped(0);
setSelectedSequence(null);
}}
>
Clear view
</Button>
<Button
size="sm"
variant="quiet"
onClick={exportJson}
icon="download"
title="Export the rows matching the current filters, as delivered by the gateway"
>
Export JSON
</Button>
</div>
</div>
{chips.length ? (
<div className="logchips">
{chips.map((chip) => (
<button
key={chip.key}
type="button"
className="logchip"
onClick={() => patch({ [chip.key]: EMPTY_FILTERS[chip.key] } as Partial<LogFilters>)}
>
{chip.label}
<Icon name="close" />
</button>
))}
<button type="button" className="logchip logchip-clear" onClick={() => setFilters(EMPTY_FILTERS)}>
Clear all
</button>
</div>
) : null}
<div className="logshell">
<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>Service</span>
<span>Event</span>
<span>Result</span>
<span>Duration</span>
</div>
{filtered.length === 0 ? (
<p className="empty">
{records.length === 0 ? 'Waiting for server events…' : 'No events match these filters.'}
</p>
) : (
<div className="logbody" style={{ height: `${layout.total}px` }}>
{windowed.map(({ event, view: shaped, index }) => (
<Fragment key={event.sequence}>
{layout.divider[index] ? (
<div
className="logday"
style={{ transform: `translateY(${(layout.tops[index] ?? 0) - DAY_HEIGHT}px)` }}
>
<span>{shaped.day}</span>
</div>
) : null}
<LogRow
event={event}
view={shaped}
top={layout.tops[index] ?? 0}
height={layout.heights[index] ?? ROW_COMPACT}
selected={event.sequence === selectedSequence}
onInspect={setSelectedSequence}
onFilter={patch}
/>
</Fragment>
))}
</div>
)}
</div>
{!atTail && filtered.length > 0 ? (
<button type="button" className="logtail" onClick={scrollToTail}>
<Icon name="caret" />
Jump to latest
</button>
) : null}
</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${held ? `, ${num(held)} held` : ''}` : ''}
</p>
{selected ? (
<Drawer event={selected} view={shape(selected)} onClose={() => setSelectedSequence(null)} />
) : records.length ? (
<Note>Select a row to see the full record request, context, diagnostics and raw event.</Note>
) : null}
</Card>
</>
);
}