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
+74 -1
View File
@@ -3,7 +3,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { initials, num, presence, recent, when } from '../lib/format';
import { initials, num, presence, recent, watchTime, when } from '../lib/format';
import {
Banner,
Button,
@@ -16,6 +16,7 @@ import {
Loading,
PageHead,
Tag,
Tiles,
Toggle,
} from '../components/ui';
import type { DeviceVersion } from '../api/types';
@@ -78,9 +79,24 @@ interface NotificationPreferences {
updateAlerts: boolean;
libraryAlerts: boolean;
systemAlerts: boolean;
watchTimeDigest: boolean;
leadDays: number;
}
/* Tracearr's reading of this person. `matched` separates "no Tracearr, or nobody by this
name in it" from "has watched nothing", which are the same row of zeroes on the wire and
very different things for an operator to be told. */
interface WatchTime {
matched: boolean;
tracearrUsername?: string;
weekMs: number;
monthMs: number;
totalMs: number;
weekSessions: number;
monthSessions: number;
lastWatchedAt?: string;
}
interface AccountDetail {
id: string;
username: string;
@@ -97,6 +113,7 @@ interface AccountDetail {
preferences?: Record<string, unknown>;
};
notifications: NotificationPreferences;
watchTime?: WatchTime;
}
interface AccountsPayload {
@@ -338,6 +355,53 @@ export function AccountPage() {
</Card>
</Grid>
{/* Read from Tracearr and shown only when Tracearr has an answer. The card is absent
rather than empty for a household running none: a permanently blank panel on every
account page teaches an operator to scroll past that part of the screen. */}
{account.watchTime?.matched ? (
<Card
title="Watch time"
intro="From Tracearr, for this person across every client — not only Memby. The week runs from Monday and the month from the first, both in the household's own time."
icon="pulse"
tone="data"
actions={
account.watchTime.tracearrUsername ? (
<Chip>{account.watchTime.tracearrUsername}</Chip>
) : null
}
>
<Tiles
tiles={[
{
label: `this week · ${num(account.watchTime.weekSessions)} session${account.watchTime.weekSessions === 1 ? '' : 's'}`,
value: watchTime(account.watchTime.weekMs),
icon: 'pulse',
tone: 'data',
},
{
label: `this month · ${num(account.watchTime.monthSessions)} session${account.watchTime.monthSessions === 1 ? '' : 's'}`,
value: watchTime(account.watchTime.monthMs),
icon: 'calendar',
tone: 'info',
},
{
label: 'since Tracearr started recording',
value: watchTime(account.watchTime.totalMs),
icon: 'clock',
tone: 'note',
},
{
label: 'last watched',
value: when(account.watchTime.lastWatchedAt),
icon: 'history',
tone: undefined,
small: true,
},
]}
/>
</Card>
) : null}
<Card
title="Notifications"
intro="Choose what this person sees across every television. Changes apply through the gateway within a few seconds and do not require an app release."
@@ -424,6 +488,15 @@ export function AccountPage() {
setNotifications((current) => current && { ...current, libraryAlerts })
}
/>
<Toggle
label="Weekly watch-time summary"
hint="Send this person their week-to-date and month-to-date viewing on Sunday evening, and a summary of the month just gone once it ends. Needs Tracearr."
checked={notifications.watchTimeDigest}
disabled={!notifications.enabled}
onChange={(watchTimeDigest) =>
setNotifications((current) => current && { ...current, watchTimeDigest })
}
/>
<Toggle
label="Service status"
hint="Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies."
+39 -1
View File
@@ -1,6 +1,6 @@
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
import { initials, num, presence, recent, when } from '../lib/format';
import { initials, num, presence, recent, watchTime, when } from '../lib/format';
import { Banner, Empty, Loading, Note, PageHead, Tag, Tiles } from '../components/ui';
import type { KnownClient } from '../api/types';
@@ -9,6 +9,21 @@ import type { KnownClient } from '../api/types';
at once, which meant the page grew with the household and an operator scrolled past four
other people's preferences to reach the one they came for. */
/* Watch time comes from Tracearr, and `matched` is the field that matters: a household
running no Tracearr, and a person Tracearr has never seen, both arrive as zeroes. Drawing
those as "0 min this week" would have an operator asking why somebody stopped watching
when the real answer is that nothing was asked. */
interface WatchTime {
matched: boolean;
tracearrUsername?: string;
weekMs: number;
monthMs: number;
totalMs: number;
weekSessions: number;
monthSessions: number;
lastWatchedAt?: string;
}
interface Account {
id: string;
username: string;
@@ -16,6 +31,7 @@ interface Account {
lastSeen: string;
devices: KnownClient[] | null;
recommendations?: { prompted?: boolean; completed?: boolean };
watchTime?: WatchTime;
}
interface AccountsResponse {
@@ -32,6 +48,10 @@ export function AccountsPage() {
const queued = accounts.filter(
(account) => account.recommendations?.prompted && !account.recommendations?.completed,
).length;
/* Summed from the same rows the list draws, so the tile and the column underneath it can
never disagree — a separate total query is how those two come apart. */
const tracked = accounts.filter((account) => account.watchTime?.matched);
const weekMs = tracked.reduce((total, account) => total + (account.watchTime?.weekMs ?? 0), 0);
return (
<>
@@ -60,6 +80,16 @@ export function AccountsPage() {
},
{ label: 'recommendation setups completed', value: num(completed), icon: 'check', tone: 'ok' },
{ label: 'setup prompts queued', value: num(queued), icon: 'sparkle', tone: 'note' },
...(tracked.length
? [
{
label: 'watched by the household this week',
value: watchTime(weekMs),
icon: 'pulse' as const,
tone: 'data' as const,
},
]
: []),
]}
/>
@@ -78,6 +108,7 @@ export function AccountsPage() {
? { label: 'prompt queued', tone: 'warn' as const }
: { label: 'not invited', tone: undefined };
const seen = presence(account.lastSeen);
const watched = account.watchTime;
return (
<Link className="list-row" key={account.id} to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
<span className="list-main">
@@ -90,10 +121,17 @@ export function AccountsPage() {
<span className="list-meta">
{num(list.length)} device{list.length === 1 ? '' : 's'}
{active ? ` · ${active} active now` : ''} · last seen {when(account.lastSeen)}
{/* The month sits beside the week because a quiet week only means
something next to the month around it. Both are omitted rather
than zeroed for somebody Tracearr has never seen. */}
{watched?.matched
? ` · watched ${watchTime(watched.weekMs)} this week, ${watchTime(watched.monthMs)} this month`
: ''}
</span>
</span>
</span>
<span className="list-actions">
{watched?.matched ? <Tag tone="data">{watchTime(watched.weekMs)}</Tag> : null}
<Tag tone={state.tone}>{state.label}</Tag>
<span className="crumb">Manage</span>
</span>
+30 -3
View File
@@ -16,8 +16,10 @@ interface JourneyEvent {
feature?: string;
source?: string;
target?: string;
itemId?: string;
itemName?: string;
itemType?: string;
playSessionId?: string;
outcome?: string;
}
@@ -32,17 +34,42 @@ const label = (value: string | undefined | null) => {
};
const place = (event: JourneyEvent | undefined) => label(event?.target || event?.screen || event?.source || event?.feature);
/* Where a viewing journey began.
*
* The journey is cut at the playback request, so the first event's `target` is "player" for
* every one of them — which is why journeys used to read as somebody appearing in the player
* from nowhere. The request's `source` is the entry point the television stated
* (continue_watching, magic_movie, ...), and it is the answer to this question; the first
* event's own place is only the fallback for a journey that never reached playback. */
const entryPoint = (events: JourneyEvent[]) => {
const request = events.find((event) => event.category === 'playback' && event.action === 'request');
return request?.source ? label(request.source) : place(events[0]);
};
const detail = (event: JourneyEvent) => event.itemName
? `${label(event.itemType)} · ${event.itemName}`
: event.source && event.target ? `${label(event.source)}${label(event.target)}` : place(event);
const verb = (event: JourneyEvent) => ({
journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session',
screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed',
request: event.category === 'playback' ? 'Started watching' : 'Requested',
stop: 'Stopped watching', start: 'Started', complete: 'Completed',
request: event.category === 'playback' ? 'Asked to watch' : 'Requested',
stop: 'Left the player',
start: event.category === 'playback'
? (event.outcome === 'failure' ? 'Playback failed' : 'Started watching')
: 'Started',
complete: event.category === 'playback'
? (event.outcome === 'completed' ? 'Finished watching' : 'Stopped watching')
: 'Completed',
}[event.action] ?? label(event.action));
function outcome(events: JourneyEvent[]) {
/* A playback step is the verdict on a viewing journey, so it outranks whatever incidental
* outcome a favourite toggle or a settings change left behind on the way in. */
const playback = [...events].reverse().find((event) => event.category === 'playback' && event.outcome);
if (playback?.outcome === 'failure') return { label: 'playback failed', tone: 'warn' as const };
if (playback?.outcome === 'completed') return { label: 'watched', tone: 'ok' as const };
if (playback?.outcome === 'abandoned') return { label: 'stopped part-way', tone: 'note' as const };
if (playback?.outcome === 'success') return { label: 'watched', tone: 'ok' as const };
const explicit = [...events].reverse().find((event) => event.outcome)?.outcome;
if (explicit === 'success' || explicit === 'completed') return { label: label(explicit), tone: 'ok' as const };
if (explicit === 'failure' || explicit === 'cancelled' || explicit === 'abandoned') return { label: label(explicit), tone: 'note' as const };
@@ -91,7 +118,7 @@ export function JourneyViewerPage() {
return <article className="visit" key={journey.key}>
<header><div><b>{when(entry?.occurredAt)}</b><span>Journey {index + 1} · {events.length} recorded steps</span></div><Tag tone={result.tone}>{result.label}</Tag></header>
<div className="journey-answers">
<div className="journey-answer" data-kind="entry"><Icon name="journey" /><span>Entered from</span><b>{place(entry)}</b></div>
<div className="journey-answer" data-kind="entry"><Icon name="journey" /><span>Entered from</span><b>{entryPoint(events)}</b></div>
<div className="journey-answer" data-kind="selection"><Icon name="play" /><span>Selected</span><b>{selection ? detail(selection) : 'Nothing selected'}</b></div>
<div className="journey-answer" data-kind="outcome"><Icon name={result.tone === 'ok' ? 'check' : 'clock'} /><span>Outcome</span><b>{result.label}</b></div>
</div>
+2 -1
View File
@@ -26,7 +26,8 @@ import {
const FEATURE_CATALOGUE = [
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
'latest', 'my_shows', 'details', 'playback', 'magic_movie', 'notifications',
'profiles', 'settings',
];
/** The wire values are ids; this is the render, so it is spelled — the same boundary the
+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>
</>