This commit is contained in:
ponzischeme89
2026-08-18 08:41:48 +12:00
parent 1da91e40a1
commit 36d171e51b
50 changed files with 4972 additions and 377 deletions
+623 -157
View File
@@ -9,127 +9,384 @@ import {
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import { api } from '../api/client';
import { num } from '../lib/format';
import { Banner, Button, Card, Field, Note, PageHead } from '../components/ui';
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 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. */
* 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.
* - **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 RANKS: Record<string, number> = { TRACE: 5, DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
const RETAIN = 20_000;
const POLL_MS = 5_000;
const ROW_HEIGHT = 48;
const ROW_COMPACT = 30;
const ROW_TALL = 48;
const DAY_HEIGHT = 26;
const HEADER_HEIGHT = 31;
const OVERSCAN = 8;
const OVERSCAN = 10;
const FIELD_ORDER = [
'component',
'user',
'device',
'client',
'protocol',
'method',
'path',
'status',
'duration',
'version',
'gateway_version',
const LEVELS = [
{ value: 'TRACE', label: 'Everything' },
{ value: 'DEBUG', label: 'Debug+' },
{ value: 'INFO', label: 'Info+' },
{ value: 'WARN', label: 'Warnings+' },
{ value: 'ERROR', label: 'Errors only' },
];
const FIELD_RANK = new Map(FIELD_ORDER.map((key, index) => [key, index]));
const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' });
interface CachedEvent {
fields: [string, unknown][];
summary: string;
haystack: string;
occurred: string;
}
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)' },
];
// 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)),
};
eventCache.set(event, value);
return value;
}
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, ' ');
const LogLine = memo(function LogLine({
/* ---------- 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,
index,
view,
top,
height,
selected,
onInspect,
onFilter,
}: {
event: LogEvent;
index: number;
view: Shaped;
top: number;
height: number;
selected: boolean;
onInspect: (sequence: number) => void;
onFilter: (patch: Partial<LogFilters>) => void;
}) {
const display = cached(event);
const slow = view.durationMs !== null ? durationTone(view.durationMs) : null;
return (
<div
className="logline"
data-level={event.level}
data-virtual="true"
style={{ transform: `translateY(${index * ROW_HEIGHT}px)` }}
className="logrow"
data-level={view.level}
data-selected={selected || undefined}
style={{ transform: `translateY(${top}px)`, height: `${height}px` }}
>
<time title={event.occurredAt}>
{display.occurred}
<small>#{event.sequence}</small>
<time className="logrow-time" title={event.occurredAt}>
{view.time}
</time>
<span className="lvl">{event.level}</span>
<span className="msg" title={event.message}>{event.message}</span>
<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="logattrs-button"
title={display.summary || 'No structured details'}
className="logrow-summary"
title={view.detail || view.summary}
onClick={() => onInspect(event.sequence)}
>
{display.summary || 'View record'}
<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>
</span>
{view.detail ? (
<span className="logrow-error"> {view.detail}</span>
) : view.context ? (
<span className="logrow-context">{view.context}</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 [level, setLevel] = useState('INFO');
const [search, setSearch] = useState('');
const [held, setHeld] = useState(0);
const [filters, setFilters] = useState<LogFilters>(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(search.trim().toLowerCase());
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 (paused || fetching.current || document.hidden) return;
if (fetching.current || document.hidden) return;
fetching.current = true;
const generation = viewGeneration.current;
const batch: LogEvent[] = [];
@@ -148,23 +405,27 @@ export function LogsPage() {
} 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.
// 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) {
setRecords((current) => {
const next = current.concat(batch);
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
});
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;
}
}, [paused]);
}, [admit]);
useEffect(() => {
if (paused) return;
let timer: number | undefined;
const schedule = () => {
window.clearInterval(timer);
@@ -181,46 +442,121 @@ export function LogsPage() {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', visibilityChanged);
};
}, [drain, paused]);
}, [drain]);
const filtered = useMemo(() => {
const minimum = RANKS[level] ?? 20;
return records.filter(
(event) =>
(RANKS[event.level] ?? 0) >= minimum &&
(!deferredSearch || cached(event).haystack.includes(deferredSearch)),
);
}, [records, level, deferredSearch]);
// 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 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],
);
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, deferredSearch, level]);
}, [lastSequence, layout.total]);
useEffect(() => () => window.cancelAnimationFrame(scrollFrame.current ?? 0), []);
const onScroll = () => {
const node = view.current;
if (!node) return;
pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < ROW_HEIGHT;
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 });
@@ -228,7 +564,7 @@ export function LogsPage() {
};
const exportJson = () => {
const blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json' });
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`;
@@ -236,39 +572,129 @@ export function LogsPage() {
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="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>
<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>
</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'}
<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.
// 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);
@@ -276,57 +702,97 @@ export function LogsPage() {
>
Clear view
</Button>
<Button onClick={exportJson} icon="download">Export JSON</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>
<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>
{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>
{filtered.length === 0 ? (
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
) : (
<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} />
))}
) : 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' : ''}
{paused ? ` · paused${held ? `, ${num(held)} held` : ''}` : ''}
</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>
<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>
</>