832 lines
29 KiB
TypeScript
832 lines
29 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|