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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-CahtXjpP.js"></script>
<script type="module" crossorigin src="/admin/assets/index-d9286FJI.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-Cg_z5PGS.css">
<link rel="stylesheet" crossorigin href="/admin/assets/index-cNUhbl7V.css">
</head>
<body>
<div id="root"></div>
+28 -4
View File
@@ -25,6 +25,25 @@ export function duration(ms: number | undefined | null): string {
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
/** watchTime words a span of viewing, where `duration` words a span of machine time.
*
* Two formatters rather than one because they answer different questions: a request that
* took 1400ms wants its milliseconds, and an evening in front of the television does not
* it is measured in hours and minutes and rounds to the minute. Nothing under a minute is a
* figure at all, and zero says so in words rather than printing "0m", which reads as a
* reading that failed rather than as an evening off. The wording deliberately matches the
* gateway's own formatWatchDuration, so the console and the summary a viewer receives
* cannot describe the same week two ways. */
export function watchTime(ms: number | undefined | null): string {
const minutes = Math.round(Math.max(0, ms ?? 0) / 60000);
if (minutes <= 0) return 'none';
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
if (rest === 0) return hours === 1 ? '1 hour' : `${hours} hours`;
return `${hours}h ${rest}m`;
}
/** interval describes a schedule, where "3600s" is a worse answer than "every hour". */
export function interval(seconds: number | undefined | null): string {
if (!seconds || seconds <= 0) return 'on request only';
@@ -84,12 +103,17 @@ const IDLE_MS = 3 * 60 * 60 * 1000;
export const recent = (value: string | undefined | null): boolean =>
Boolean(value) && Date.now() - new Date(value as string).getTime() < ACTIVE_MS;
export type Tone = 'ok' | 'warn' | 'bad' | 'info' | 'note' | 'data';
export type Tone = 'ok' | 'idle' | 'warn' | 'bad' | 'info' | 'note' | 'data';
/* Three states rather than two, because "not active this minute" covers both a set
somebody switched off after breakfast and one that has not been seen since lunchtime
and only the second is worth an operator's attention. Green is on now, amber is a set
in ordinary use that happens to be off, and red is one that has stopped checking in for
and only the second is worth an operator's attention.
The middle state is the quiet green rather than amber. Amber is the console's "look at
this", and a television that checked in an hour ago is the ordinary condition of every
set in a house at any given moment: a page of amber dots every evening is a page that
teaches an operator to ignore the colour, which is exactly what it must not do on the
evening one of them really has stopped. Two shades of green say what is true both are
fine, one is connected right now and red is kept for a set that has not been seen for
three hours. A device with no timestamp at all is red: never seen is the strongest
version of not seen. */
export function presence(value: string | undefined | null): { tone: Tone; label: string } {
@@ -97,7 +121,7 @@ export function presence(value: string | undefined | null): { tone: Tone; label:
if (!seen) return { tone: 'bad', label: 'never seen' };
const age = Date.now() - seen;
if (age < ACTIVE_MS) return { tone: 'ok', label: 'active now' };
if (age < IDLE_MS) return { tone: 'warn', label: 'seen recently' };
if (age < IDLE_MS) return { tone: 'idle', label: 'seen recently' };
return { tone: 'bad', label: 'not seen lately' };
}
+570
View File
@@ -0,0 +1,570 @@
import type { LogEvent } from '../api/types';
/* The shape of a server event, for a table rather than for a file.
*
* The gateway writes structured records a message and a bag of attributes and the old
* page rendered the bag as `component=admin client=unknown method=GET path=… status=200`,
* which is every fact and no hierarchy. A person reading a log is asking four questions in
* order: when, which part of the server, what happened, did it work. This module answers
* them, so the table can print the answers and the drawer can keep the evidence.
*
* It is deliberately *client*-side and derives everything from attributes the gateway
* already sends. The ring buffer is restored from an archive across deployments, so a
* record written by yesterday's build is in the window beside one written a minute ago;
* a shaping rule that lived on the server would render the older half as raw text for as
* long as the archive holds it. Nothing here asks the API for a field it does not have.
*
* Everything below is pure. `shape` is memoised on the event object itself, which is what
* lets the table filter and re-window twenty thousand records without re-deriving them. */
export type LogTone = 'ok' | 'warn' | 'bad' | 'info' | 'note' | 'data' | 'idle' | 'quiet';
export interface Shaped {
/** Stable identity for a service badge — also the value the service filter matches on. */
serviceKey: string;
service: string;
component: string;
/** The verb: an HTTP method, or a short word for an application event. */
action: string;
/** What happened, in words: "GET Notifications", "Started Blue Bloods · S04E08". */
summary: string;
/** Who or what it was for. Medium weight, beside the summary. */
context: string;
/** The one line that explains a failure, printed under the row rather than hidden. */
detail: string;
result: { label: string; short: string; tone: LogTone } | null;
durationMs: number | null;
method: string;
status: number | null;
/** The raw message, which is what the Event filter matches on. */
eventKey: string;
level: string;
time: string;
day: string;
dayKey: string;
/** Whether the row earns a second line. */
tall: boolean;
haystack: string;
fields: [string, unknown][];
attributes: Record<string, unknown>;
}
/* ---------- services ---------- */
/* A subsystem's identity has to be the same every time it appears or there is nothing to
* recognise. The label is the identity; the tone is a secondary cue and is drawn from the
* console's existing secondary palette, never from the verdict colours green, amber and
* red mean good, look and wrong on every other page and must not start meaning "playback"
* here. Related subsystems share a tone on purpose: five hues that mean something are
* worth more than a dozen that only mean "different". */
const SERVICE_TONE: Record<string, LogTone> = {
gateway: 'quiet',
auth: 'note',
playback: 'info',
media: 'info',
emby: 'data',
library: 'data',
subtitles: 'data',
search: 'note',
tracearr: 'idle',
credits: 'idle',
integrations: 'note',
requests: 'info',
home: 'quiet',
};
export const serviceTone = (key: string): LogTone => SERVICE_TONE[key] ?? 'quiet';
/* The component attribute is derived from the route by the gateway, so it is one flat
* token `admin`, `playback`, `details`. This is where it becomes a place in the server:
* a service worth recognising and the part of it that spoke. */
const PLACES: Record<string, [string, string, string]> = {
admin: ['gateway', 'Gateway', 'Admin'],
installer: ['gateway', 'Gateway', 'Installer'],
api: ['gateway', 'Gateway', 'API'],
health: ['gateway', 'Gateway', 'Health'],
status: ['gateway', 'Gateway', 'Status'],
maintenance: ['gateway', 'Gateway', 'Maintenance'],
'quiet-time': ['gateway', 'Gateway', 'Quiet time'],
webhooks: ['gateway', 'Gateway', 'Webhooks'],
scheduler: ['gateway', 'Gateway', 'Scheduler'],
settings: ['gateway', 'Gateway', 'Settings'],
updates: ['gateway', 'Gateway', 'Updates'],
analytics: ['gateway', 'Gateway', 'Analytics'],
auth: ['auth', 'Auth', 'Session'],
devices: ['auth', 'Auth', 'Devices'],
playback: ['playback', 'Playback', 'Session'],
screensaver: ['media', 'Media', 'Screensaver'],
artwork: ['media', 'Media', 'Artwork'],
details: ['media', 'Media', 'Details'],
search: ['search', 'Search', 'Query'],
home: ['home', 'Home', 'Rows'],
'my-shows': ['home', 'Home', 'My shows'],
recommendations: ['tracearr', 'Tracearr', 'Recommendations'],
'for-you': ['tracearr', 'Tracearr', 'For you'],
library: ['library', 'Library', 'Sync'],
credits: ['credits', 'Credits', 'Scanner'],
ratings: ['media', 'Media', 'Ratings'],
integrations: ['integrations', 'Integrations', 'Arr'],
requests: ['requests', 'Requests', 'Media'],
'emby-health': ['emby', 'Emby', 'Health'],
};
/* A message can be more specific than the route it arrived on. A subtitle search is a
* subtitle event whichever route asked for it, and an upstream failure belongs to Emby
* rather than to the screen that was unlucky enough to be waiting on it. These run after
* the route lookup and only ever narrow it. */
const REFINEMENTS: [RegExp, [string, string, string]][] = [
[/^emby |emby (reachable|unreachable|health)/, ['emby', 'Emby', 'API']],
[/subtitle/, ['subtitles', 'Subtitles', 'Provider']],
[/^sonarr|sonarr /, ['integrations', 'Integrations', 'Sonarr']],
[/^radarr|radarr /, ['integrations', 'Integrations', 'Radarr']],
[/^tracearr/, ['tracearr', 'Tracearr', 'Signals']],
[/^credits/, ['credits', 'Credits', 'Scanner']],
[/^library sync/, ['library', 'Library', 'Sync']],
[/^(signed in|signed out|sign-in rejected)/, ['auth', 'Auth', 'Session']],
[/^device /, ['auth', 'Auth', 'Devices']],
[/^(playback (requested|started|stopped|progress))/, ['playback', 'Playback', 'Session']],
[/^(next episode resolved|trailer playback|trickplay)/, ['playback', 'Playback', 'Player']],
[/^scheduled task/, ['gateway', 'Gateway', 'Scheduler']],
[/^(update offered|update policy)/, ['gateway', 'Gateway', 'Updates']],
];
function placeFor(component: string, message: string): [string, string, string] {
const lower = message.toLowerCase();
for (const [pattern, place] of REFINEMENTS) {
if (pattern.test(lower)) return place;
}
const known = PLACES[component];
if (known) return known;
if (!component) return ['gateway', 'Gateway', 'Server'];
return ['gateway', 'Gateway', titleCase(component.replace(/[-_]/g, ' '))];
}
/* ---------- durations ---------- */
const UNIT_MS: Record<string, number> = {
h: 3_600_000,
m: 60_000,
s: 1000,
ms: 1,
us: 0.001,
'µs': 0.001,
ns: 0.000001,
};
/** Go prints a duration as `2ms`, `1.482s`, `1m30s`, `418µs`. Sum whatever it wrote. */
export function parseDuration(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || !value) return null;
const matches = value.matchAll(/([0-9]*\.?[0-9]+)(ns|µs|us|ms|h|m|s)/g);
let total = 0;
let seen = false;
for (const match of matches) {
const unit = match[2] ? UNIT_MS[match[2]] : undefined;
if (unit === undefined) continue;
total += Number(match[1]) * unit;
seen = true;
}
return seen ? total : null;
}
export function formatDuration(ms: number): string {
if (ms < 1) return '<1 ms';
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 10_000) return `${(ms / 1000).toFixed(1)} s`;
if (ms < 60_000) return `${Math.round(ms / 1000)} s`;
const minutes = Math.floor(ms / 60_000);
return `${minutes}m ${Math.round((ms % 60_000) / 1000)}s`;
}
/* Two thresholds and no more. The point of tinting a duration is that a slow row can be
* found by eye down a column of hundreds; a gradient over every row would just be a
* second colour scheme nobody can read a value out of. */
export const durationTone = (ms: number): LogTone | null =>
ms >= 3000 ? 'bad' : ms >= 1000 ? 'warn' : null;
/* ---------- HTTP ---------- */
const STATUS_TEXT: Record<number, string> = {
200: 'OK',
201: 'Created',
202: 'Accepted',
204: 'No Content',
206: 'Partial Content',
301: 'Moved Permanently',
302: 'Found',
304: 'Not Modified',
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
409: 'Conflict',
412: 'Precondition Failed',
418: 'Client Closed Request',
426: 'Upgrade Required',
429: 'Too Many Requests',
499: 'Client Closed Request',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
};
export function statusText(code: number): string {
const known = STATUS_TEXT[code];
if (known) return known;
if (code >= 500) return 'Server Error';
if (code >= 400) return 'Client Error';
if (code >= 300) return 'Redirected';
if (code >= 200) return 'OK';
return 'Response';
}
const statusTone = (code: number): LogTone =>
code >= 500 ? 'bad' : code >= 400 ? 'warn' : code >= 300 ? 'quiet' : 'ok';
/* A few routes are named things rather than paths, and every one of them is high traffic:
* the health probe, the status poll and artwork are most of what a busy log contains, so
* they are the ones worth reading as words. */
const NAMED_PATHS: Record<string, string> = {
'/healthz': 'Health probe',
'/readyz': 'Readiness probe',
'/v1/status': 'Status poll',
'/v1/home': 'Home rows',
'/v1/features': 'Features',
'/v1/preferences': 'Preferences',
'/v1/theme': 'Theme',
'/v1/magic': 'Magic pick',
'/v1/calendar': 'TV calendar',
'/v1/search': 'Search',
'/v1/update': 'Update check',
};
/* An id is a segment with a number in it that is not a word every Emby id, every user id
* and every frame number, and none of `playback`, `trickplay` or `notifications`. Route
* segments in this gateway are lower-case words, so "contains a digit" is a sound test and
* a wrong answer costs a word in a summary rather than anything an operator acts on. */
const idLike = (segment: string) => /\d/.test(segment) || segment.length > 24;
/** `/admin/api/notifications` reads as "Notifications"; the path itself stays in the
* drawer, the tooltip and the export. An id is dropped rather than printed nobody can
* read one, and it is the thing that makes two rows of the same route look different. */
export function prettyPath(path: string): string {
const named = NAMED_PATHS[path];
if (named) return named;
const segments = path.split('/').filter((part) => part && part !== 'v1' && part !== 'api');
if (segments[0] === 'admin') segments.shift();
const words = segments.filter((part) => !idLike(part));
if (words.length === 0) return path;
return titleCase(words.join(' ').replace(/[-_.]/g, ' ').replace(/\s+/g, ' ').trim());
}
/* ---------- wording ---------- */
const titleCase = (value: string) =>
value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
const sentence = (message: string) => titleCase(message.replace(/_/g, ' '));
const text = (value: unknown): string =>
value === undefined || value === null ? '' : String(value);
/* `client=unknown` and `protocol=unknown` are what the gateway writes when a request did
* not say, which is most of them. They are facts and they belong in the export; they are
* not information and they must not take a column. */
const KNOWN_NOTHING = new Set(['', 'unknown', 'none', 'null', '<nil>', '0']);
const informative = (value: unknown) => !KNOWN_NOTHING.has(text(value).toLowerCase());
/* The one attribute that says what the row is *about*, in the order a person would look
* for it. A title beats an id every time, which is the whole reason the gateway logs one. */
const SUBJECT_KEYS = ['title', 'series', 'name', 'query', 'item_title', 'file'];
/* Non-HTTP outcomes worth a result chip. The key is the attribute; the tone is the
* verdict. Anything not listed simply has no result rather than an invented one. */
const PLAY_METHOD_TONE: Record<string, LogTone> = {
directplay: 'ok',
direct: 'ok',
directstream: 'ok',
transcode: 'warn',
transcoding: 'warn',
};
function resultFor(
attributes: Record<string, unknown>,
level: string,
status: number | null,
): Shaped['result'] {
if (status !== null) {
return { label: `${status} ${statusText(status)}`, short: String(status), tone: statusTone(status) };
}
if (informative(attributes.error)) {
return { label: 'Failed', short: 'Failed', tone: level === 'WARN' ? 'warn' : 'bad' };
}
const method = text(attributes.play_method).toLowerCase().replace(/[\s_-]/g, '');
if (method && !KNOWN_NOTHING.has(method)) {
// Emby writes `DirectPlay`; a person reads "Direct Play".
const label = titleCase(text(attributes.play_method).replace(/([a-z])([A-Z])/g, '$1 $2'));
return { label, short: label, tone: PLAY_METHOD_TONE[method] ?? 'info' };
}
if (informative(attributes.cache)) {
const hit = /hit|true|yes/i.test(text(attributes.cache));
return {
label: hit ? 'Cached' : 'Cache miss',
short: hit ? 'Cached' : 'Miss',
tone: hit ? 'data' : 'quiet',
};
}
if (level === 'ERROR') return { label: 'Failed', short: 'Failed', tone: 'bad' };
if (level === 'WARN') return { label: 'Warning', short: 'Warning', tone: 'warn' };
return null;
}
/* ---------- context line ---------- */
/* Who it was for, and the one or two facts that make an application event mean something.
* Kept short on purpose: this sits beside the summary, and a context line that wraps has
* stopped being context. */
function contextFor(attributes: Record<string, unknown>): string {
const parts: string[] = [];
if (informative(attributes.user)) parts.push(text(attributes.user));
if (informative(attributes.device)) parts.push(text(attributes.device));
const marker = parseDuration(attributes.marker_ms);
if (marker !== null && marker > 0) parts.push(`Start ${clock(marker)}`);
const position = parseDuration(attributes.position);
if (position !== null && position > 0) parts.push(`At ${clock(position)}`);
if (informative(attributes.watched)) parts.push(`${text(attributes.watched)} watched`);
if (informative(attributes.reason)) parts.push(text(attributes.reason));
return parts.slice(0, 3).join(' · ');
}
/** A position inside a programme is read as a time, not as a number of milliseconds. */
function clock(ms: number): string {
const total = Math.round(ms / 1000);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const pad = (value: number) => String(value).padStart(2, '0');
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
}
/* ---------- field grouping for the drawer ---------- */
/* The drawer answers "exactly how and why", and it answers it in sections rather than as
* one alphabetical dump. A key that belongs to no section still appears under Details
* because a record must never be able to hide a field from the person reading it. */
export const DRAWER_SECTIONS: { title: string; keys: string[] }[] = [
{ title: 'Request', keys: ['method', 'path', 'query_keys', 'status', 'cache', 'client', 'protocol', 'host'] },
{
title: 'Context',
keys: [
'user', 'user_id', 'device', 'device_id', 'item', 'title', 'series', 'type',
'play_method', 'play_session_id', 'media_source_id', 'position', 'resume', 'runtime',
'watched', 'subtitles', 'subtitle_track', 'subtitle_language', 'event_name',
],
},
{ title: 'Diagnostics', keys: ['error', 'stack', 'correlation', 'version', 'gateway_version', 'duration'] },
];
const SECTIONED = new Set(DRAWER_SECTIONS.flatMap((section) => section.keys));
export const isSectioned = (key: string) => SECTIONED.has(key);
/* ---------- shaping ---------- */
const timeFormat = new Intl.DateTimeFormat('en-NZ', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const dayFormat = new Intl.DateTimeFormat('en-NZ', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
// Records are immutable for their retained lifetime, so a WeakMap gives the derived view
// exactly that lifetime without adding private fields to anything the export writes out.
const cache = new WeakMap<LogEvent, Shaped>();
export function shape(event: LogEvent): Shaped {
const existing = cache.get(event);
if (existing) return existing;
const value = derive(event);
cache.set(event, value);
return value;
}
function derive(event: LogEvent): Shaped {
const attributes = event.attributes ?? {};
const message = event.message ?? '';
const [serviceKey, service, component] = placeFor(text(attributes.component), message);
const path = text(attributes.path);
// `method` is only an HTTP method when there is a route beside it: the credits scanner
// logs a detection method under the same key, and a filter offering VISUAL beside GET
// would be two different questions sharing a control.
const method = path ? text(attributes.method).toUpperCase() : '';
const statusRaw = Number(attributes.status);
const status = path && Number.isFinite(statusRaw) && statusRaw > 0 ? statusRaw : null;
const isRequest = message === 'request' && Boolean(path);
const subjectKey = SUBJECT_KEYS.find((key) => informative(attributes[key]));
const subject = subjectKey ? text(attributes[subjectKey]) : '';
let action: string;
let summary: string;
if (isRequest) {
action = method || 'HTTP';
summary = prettyPath(path);
} else if (path && method) {
action = method;
summary = subject ? `${sentence(message)} · ${subject}` : `${sentence(message)}${prettyPath(path)}`;
} else {
action = '';
summary = subject ? `${sentence(message)} · ${subject}` : sentence(message);
}
const durationMs = parseDuration(
attributes.duration ?? attributes.duration_ms ?? attributes.negotiation_duration,
);
const detail = informative(attributes.error) ? text(attributes.error) : '';
const context = contextFor(attributes);
const occurred = new Date(event.occurredAt);
const fields = Object.entries(attributes);
const shaped: Shaped = {
serviceKey,
service,
component,
action,
summary,
context,
detail,
result: resultFor(attributes, event.level, status),
durationMs,
method,
status,
eventKey: message,
level: event.level,
time: `${timeFormat.format(occurred)}.${String(occurred.getMilliseconds()).padStart(3, '0')}`,
day: dayFormat.format(occurred),
dayKey: occurred.toDateString(),
// An error explains itself on a second line; so does an application event carrying a
// person or a position. Ordinary request traffic — which is most of a log — stays on
// one, because density is the whole reason this page is worth watching.
tall: Boolean(detail) || (Boolean(context) && !isRequest),
haystack: [
message, service, component, summary, context, detail,
...fields.flat().map(text),
]
.join(' ')
.toLowerCase(),
fields,
attributes,
};
return shaped;
}
/* ---------- filtering ---------- */
export const LEVEL_RANK: Record<string, number> = { TRACE: 5, DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
export interface LogFilters {
level: string;
service: string;
component: string;
event: string;
method: string;
/** '' | '2xx' | '3xx' | '4xx' | '5xx' | 'error' (anything at or above 400). */
status: string;
/** Minimum duration in milliseconds; 0 means no duration filter. */
slower: number;
text: string;
}
export const EMPTY_FILTERS: LogFilters = {
level: 'INFO',
service: '',
component: '',
event: '',
method: '',
status: '',
slower: 0,
text: '',
};
function statusMatches(rule: string, status: number | null): boolean {
if (!rule) return true;
if (status === null) return false;
if (rule === 'error') return status >= 400;
const band = Number(rule[0]);
return Math.floor(status / 100) === band;
}
export function matches(event: LogEvent, filters: LogFilters, search: string): boolean {
if ((LEVEL_RANK[event.level] ?? 0) < (LEVEL_RANK[filters.level] ?? 20)) return false;
const view = shape(event);
if (filters.service && view.serviceKey !== filters.service) return false;
if (filters.component && view.component !== filters.component) return false;
if (filters.event && view.eventKey !== filters.event) return false;
if (filters.method && view.method !== filters.method) return false;
if (!statusMatches(filters.status, view.status)) return false;
if (filters.slower > 0 && (view.durationMs ?? 0) < filters.slower) return false;
if (search && !view.haystack.includes(search)) return false;
return true;
}
/** The facets actually present in what has been retained. Offering a service nothing has
* logged is a filter that can only ever empty the table. */
export function facets(records: LogEvent[]): {
services: { key: string; label: string }[];
components: string[];
events: string[];
methods: string[];
} {
const services = new Map<string, string>();
const components = new Set<string>();
const events = new Set<string>();
const methods = new Set<string>();
for (const record of records) {
const view = shape(record);
services.set(view.serviceKey, view.service);
components.add(view.component);
events.add(view.eventKey);
if (view.method) methods.add(view.method);
}
return {
services: [...services].map(([key, label]) => ({ key, label })).sort((a, b) => a.label.localeCompare(b.label)),
components: [...components].sort((a, b) => a.localeCompare(b)),
events: [...events].sort((a, b) => a.localeCompare(b)),
methods: [...methods].sort((a, b) => a.localeCompare(b)),
};
}
/** The chips shown above the table: one per narrowing in force, each removable. */
export function activeChips(
filters: LogFilters,
services: { key: string; label: string }[],
): { key: keyof LogFilters; label: string }[] {
const chips: { key: keyof LogFilters; label: string }[] = [];
if (filters.service) {
const label = services.find((entry) => entry.key === filters.service)?.label ?? filters.service;
chips.push({ key: 'service', label: `Service: ${label}` });
}
if (filters.component) chips.push({ key: 'component', label: `Component: ${filters.component}` });
if (filters.event) chips.push({ key: 'event', label: `Event: ${filters.event}` });
if (filters.method) chips.push({ key: 'method', label: `Method: ${filters.method}` });
if (filters.status) {
chips.push({
key: 'status',
label: `Status: ${filters.status === 'error' ? '≥400' : filters.status}`,
});
}
if (filters.slower > 0) {
chips.push({ key: 'slower', label: `Duration: >${formatDuration(filters.slower)}` });
}
if (filters.text) chips.push({ key: 'text', label: `Search: ${filters.text}` });
return chips;
}
+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>
</>
+513 -105
View File
@@ -22,7 +22,12 @@
which is what lets a page distinguish one kind of thing from another without every
coloured element reading as a warning. Green means good, amber means look, red means
wrong; the other three mean nothing at all, which is the point. A tone is passed, never
derived: the library is teal, a person violet, a television blue, on every page. */
derived: the library is teal, a person violet, a television blue, on every page.
Beside the accent is one quieter green, `idle`, which is not a seventh meaning but the
accent's meaning held at lower confidence: good, but not this minute. It exists so that
"fine and not connected right now" can stop borrowing amber, which is reserved for
something an operator should look at. Nothing derives it either. */
:root {
color-scheme: dark;
@@ -41,6 +46,13 @@
--accent: #2ea043;
--accent-ink: #56d364;
--accent-wash: rgba(46, 160, 67, 0.16);
/* The quiet green: fine, but not this minute. Same hue as the accent and deliberately
duller, because it is the accent's own meaning at lower confidence rather than a
verdict of its own a television in ordinary use that happens to be switched off is
not something an operator has to do anything about, and amber said it was. */
--idle: #4a8f60;
--idle-ink: #86c79a;
--idle-wash: rgba(74, 143, 96, 0.13);
--danger: #e5534b;
--danger-ink: #ff9b94;
--danger-wash: rgba(229, 83, 75, 0.13);
@@ -1030,6 +1042,10 @@ a.tile:hover {
background: var(--accent-wash);
color: var(--accent-ink);
}
.glyph[data-tone="idle"] {
background: var(--idle-wash);
color: var(--idle-ink);
}
.glyph[data-tone="warn"] {
background: var(--warn-wash);
color: var(--warn-ink);
@@ -1078,6 +1094,11 @@ a.tile:hover {
background: var(--accent-wash);
color: var(--accent-ink);
}
.tag[data-tone="idle"] {
border-color: rgba(74, 143, 96, 0.35);
background: var(--idle-wash);
color: var(--idle-ink);
}
.tag[data-tone="warn"] {
border-color: rgba(239, 196, 107, 0.35);
background: var(--warn-wash);
@@ -1119,6 +1140,9 @@ a.tile:hover {
.chip[data-tone="ok"] {
color: var(--accent-ink);
}
.chip[data-tone="idle"] {
color: var(--idle-ink);
}
.chip[data-tone="warn"] {
color: var(--warn-ink);
}
@@ -1744,6 +1768,109 @@ select {
/* ---------- log / code ---------- */
/* The log table.
Six columns, in the order the questions are asked: when, how bad, which part of the
server, what happened, did it work, how long did it take. The columns are the whole
design an operator learns where to look once and then reads down a column rather than
across a line, which is what makes thirty seconds of watching this page worth anything.
Monospace is spent deliberately rather than applied to the row. The time, the method,
the status and the duration are values to be compared down a column and are set in it;
the summary is a sentence and is set in the sans face, because a sentence in monospace
is slower to read and looks like a dump of something. */
.logbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-bottom: 10px;
}
.logbar-filters {
display: flex;
flex: 1 1 520px;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
.logbar-filters select {
width: auto;
min-width: 0;
height: 30px;
padding: 0 26px 0 9px;
font-size: 12px;
}
/* The actions are secondary to the filters and are marked so by weight, not by distance:
the previous bar gave Pause and Export the same prominence as the controls that decide
what the table contains. */
.logbar-actions {
display: flex;
flex: 0 0 auto;
gap: 6px;
margin-left: auto;
}
.logsearch {
position: relative;
display: flex;
flex: 1 1 260px;
align-items: center;
min-width: 200px;
}
.logsearch svg {
position: absolute;
left: 9px;
width: 14px;
height: 14px;
color: var(--quiet);
pointer-events: none;
}
.logsearch input {
width: 100%;
height: 30px;
padding-left: 28px;
font-size: 12px;
}
/* One chip per narrowing in force. They are the only place a filter can be removed one at
a time, which is what makes drilling in by clicking a value in the table reversible. */
.logchips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.logchip {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-lift);
color: var(--muted);
font: 500 11px/1 var(--sans);
}
.logchip svg {
width: 11px;
height: 11px;
opacity: .7;
}
.logchip:hover:not(:disabled) {
border-color: var(--danger);
background: var(--danger-wash);
color: var(--danger-ink);
}
.logchip-clear {
border-style: dashed;
color: var(--quiet);
}
.logshell {
position: relative;
}
.logview {
height: 62vh;
min-height: 320px;
@@ -1751,17 +1878,24 @@ select {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #080a0e;
font: 12px/1.6 var(--mono);
font: 12px/1.5 var(--sans);
contain: layout paint style;
}
.loghead,
.logrow {
display: grid;
grid-template-columns:
92px 52px minmax(150px, 190px) minmax(240px, 1fr)
minmax(96px, 150px) 68px;
gap: 12px;
padding: 0 12px;
}
.loghead {
position: sticky;
top: 0;
z-index: 1;
display: grid;
grid-template-columns: 150px 46px minmax(180px, 1fr) minmax(240px, 2fr);
gap: 12px;
padding: 7px 12px;
z-index: 2;
align-items: center;
height: 31px;
border-bottom: 1px solid var(--line);
background: #10131a;
color: var(--quiet);
@@ -1769,142 +1903,413 @@ select {
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
min-width: 760px;
min-width: 900px;
}
.loghead > :last-child {
text-align: right;
}
.logbody {
position: relative;
min-width: 760px;
min-width: 900px;
}
.logline {
display: flex;
gap: 12px;
padding: 3px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.logline[data-virtual="true"] {
/* The date is a divider between days rather than a column repeated on every row. A log
watched live is almost always one day; printing the date 20,000 times to cover the
handful of rows where it changes is the trade the compact timestamp exists to avoid. */
.logday {
position: absolute;
top: 0;
right: 0;
left: 0;
display: grid;
grid-template-columns: 150px 46px minmax(180px, 1fr) minmax(240px, 2fr);
display: flex;
align-items: center;
height: 48px;
padding-top: 4px;
padding-bottom: 4px;
white-space: nowrap;
height: 26px;
padding: 0 12px;
background: linear-gradient(to bottom, rgba(255, 255, 255, .03), transparent);
color: var(--quiet);
font: 700 10px/1 var(--sans);
letter-spacing: .08em;
text-transform: uppercase;
}
.logday span {
padding-right: 10px;
background: #080a0e;
}
.logday::after {
content: '';
flex: 1;
height: 1px;
background: var(--line-soft);
}
.logrow {
position: absolute;
top: 0;
right: 0;
left: 0;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, .03);
contain: strict;
}
.logline:hover {
background: rgba(255, 255, 255, 0.03);
.logrow:hover {
background: rgba(255, 255, 255, .035);
}
.logline time {
flex: 0 0 auto;
.logrow[data-selected] {
background: var(--accent-wash);
}
/* A failure is marked on the row and not only in a column, because the thing an operator
is scanning for is a row, and a hairline at the start of one is found faster than a word
two thirds of the way along it. INFO is deliberately unmarked: most of the table is INFO
and a mark every row carries is a mark that says nothing. */
.logrow[data-level="ERROR"] {
box-shadow: inset 2px 0 0 var(--danger);
background: rgba(229, 83, 75, .05);
}
.logrow[data-level="WARN"] {
box-shadow: inset 2px 0 0 var(--warn);
}
.logrow-time {
color: var(--quiet);
font: 11px/1 var(--mono);
font-variant-numeric: tabular-nums;
}
.logline time small {
display: block;
color: var(--muted);
font-size: 10px;
}
.logline .lvl {
flex: 0 0 46px;
font-weight: 700;
}
.logline[data-level="ERROR"] .lvl {
color: var(--danger-ink);
}
.logline[data-level="WARN"] .lvl {
color: var(--warn-ink);
}
.logline[data-level="INFO"] .lvl {
color: var(--info-ink);
}
.logline[data-level="DEBUG"] .lvl {
color: var(--quiet);
}
.logline .msg {
flex: 1 1 auto;
/* Every structured value in the table is also the control that filters to it. They are
buttons rather than links because nothing navigates: the table narrows in place. */
.logfacet {
height: auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.logline .attrs {
color: var(--quiet);
}
@media (min-width: 900px) {
.logline {
display: grid;
grid-template-columns: 150px 46px minmax(180px, 1fr) minmax(240px, 2fr);
}
}
.logline .attrs b {
color: var(--muted);
font-weight: 400;
}
.logattrs-button {
display: block;
width: 100%;
min-width: 0;
height: 28px;
padding: 0;
overflow: hidden;
border: 0;
background: transparent;
color: var(--quiet);
background: none;
color: inherit;
font: inherit;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.logattrs-button:hover:not(:disabled) {
.logfacet:hover:not(:disabled) {
border: 0;
background: transparent;
color: var(--text);
background: none;
text-decoration: underline;
text-underline-offset: 3px;
}
.logattrs-button:focus-visible {
.logfacet:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 2px;
}
.log-inspector {
.logrow-level {
color: var(--quiet);
font: 700 10px/1 var(--sans);
letter-spacing: .06em;
}
.logrow-level[data-level="ERROR"] { color: var(--danger-ink); }
.logrow-level[data-level="WARN"] { color: var(--warn-ink); }
.logrow-level[data-level="INFO"] { color: var(--muted); }
.logrow-level[data-level="DEBUG"],
.logrow-level[data-level="TRACE"] { color: var(--quiet); }
/* Service and component. The service is the recognisable half one word, upper case,
always the same colour for the same subsystem and the component sits beside it in the
ordinary text colour, so the pair reads as a place rather than as two tags. The tones
are the console's secondary palette only: green, amber and red mean good, look and wrong
on every other page and must not start meaning "playback" on this one. */
.logrow-place {
display: flex;
align-items: baseline;
gap: 5px;
min-width: 0;
}
.logrow-service {
flex: 0 0 auto;
max-width: 96px;
overflow: hidden;
color: var(--quiet);
font: 700 10px/1.4 var(--sans);
letter-spacing: .07em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.logrow-service[data-tone="info"] { color: var(--info-ink); }
.logrow-service[data-tone="note"] { color: var(--note-ink); }
.logrow-service[data-tone="data"] { color: var(--data-ink); }
.logrow-service[data-tone="idle"] { color: var(--idle-ink); }
.logrow-service[data-tone="quiet"] { color: var(--muted); }
.logrow-sep {
flex: 0 0 auto;
color: var(--line);
}
.logrow-component {
flex: 1 1 auto;
color: var(--quiet);
font-size: 11.5px;
}
/* The summary is the row. It is a button because selecting the row is what opens the
record, and the whole width of the column should be the target. */
.logrow-summary {
display: flex;
flex-direction: column;
gap: 2px;
justify-content: center;
width: 100%;
height: 100%;
min-width: 0;
padding: 0;
overflow: hidden;
border: 0;
background: none;
font: inherit;
text-align: left;
}
.logrow-summary:hover:not(:disabled) {
border: 0;
background: none;
}
.logrow-summary:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
.logrow-line {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.logrow-action {
flex: 0 0 auto;
color: var(--muted);
font: 600 10.5px/1.4 var(--mono);
letter-spacing: .04em;
}
.logrow-action[data-method="POST"],
.logrow-action[data-method="PUT"],
.logrow-action[data-method="PATCH"] { color: var(--info-ink); }
.logrow-action[data-method="DELETE"] { color: var(--danger-ink); }
.logrow-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--text);
text-overflow: ellipsis;
white-space: nowrap;
}
/* The reason a failure needs no drawer. */
.logrow-error {
overflow: hidden;
color: var(--danger-ink);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.logrow-context {
overflow: hidden;
color: var(--quiet);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A result is a word, not a badge. A success is understated to the point of being ignorable
which is the correct amount of attention for the four hundredth 200 in a row and only
a failure is given a fill. */
.logrow-result {
min-width: 0;
}
.logrow-verdict {
display: inline-block;
max-width: 100%;
color: var(--quiet);
font: 500 11px/1.5 var(--mono);
}
.logrow-verdict[data-tone="ok"] { color: var(--muted); }
.logrow-verdict[data-tone="info"] { color: var(--info-ink); }
.logrow-verdict[data-tone="data"] { color: var(--data-ink); }
.logrow-verdict[data-tone="warn"],
.logrow-verdict[data-tone="bad"] {
padding: 1px 6px;
border-radius: var(--radius-xs);
font-weight: 600;
}
.logrow-verdict[data-tone="warn"] {
background: var(--warn-wash);
color: var(--warn-ink);
}
.logrow-verdict[data-tone="bad"] {
background: var(--danger-wash);
color: var(--danger-ink);
}
.logrow-duration {
color: var(--quiet);
font: 11px/1 var(--mono);
font-variant-numeric: tabular-nums;
text-align: right;
}
.logrow-duration[data-tone="warn"] { color: var(--warn-ink); }
.logrow-duration[data-tone="bad"] {
color: var(--danger-ink);
font-weight: 600;
}
/* Offered only once the operator has left the tail, which is the only time following the
log automatically would be taking the page away from them. */
.logtail {
position: absolute;
right: 18px;
bottom: 14px;
z-index: 3;
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border: 1px solid var(--accent);
border-radius: 999px;
background: var(--surface-lift);
color: var(--accent-ink);
font: 600 11px/1 var(--sans);
box-shadow: 0 6px 16px rgba(0, 0, 0, .5);
}
.logtail svg {
width: 12px;
height: 12px;
transform: rotate(90deg);
}
.logtail:hover:not(:disabled) {
border-color: var(--accent);
background: var(--accent-wash);
color: var(--accent-ink);
}
/* The drawer answers "exactly how and why". Sections rather than one alphabetical list,
and a field with no value is omitted rather than printed as `unknown`. */
.logdrawer {
margin-top: 12px;
padding: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.log-inspector-head {
.logdrawer-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.log-inspector-head > div {
.logdrawer-head > div:first-child {
min-width: 0;
}
.log-inspector-head b,
.log-inspector-head span {
.logdrawer-head b {
display: block;
margin-top: 4px;
font-size: 14px;
overflow-wrap: anywhere;
}
.log-inspector-head span {
margin-top: 3px;
.logdrawer-place {
display: flex;
align-items: baseline;
gap: 5px;
margin: 0;
color: var(--quiet);
font: 11px/1.5 var(--mono);
font-size: 11px;
}
.log-inspector dl {
.logdrawer-error {
margin: 6px 0 0;
color: var(--danger-ink);
font: 12px/1.5 var(--mono);
overflow-wrap: anywhere;
}
.logdrawer-actions {
display: flex;
flex: 0 0 auto;
gap: 6px;
}
.logdrawer-grid {
display: grid;
grid-template-columns: minmax(130px, max-content) minmax(0, 1fr);
gap: 5px 14px;
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px solid var(--line);
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px 24px;
margin-top: 12px;
}
.logdrawer-section h4 {
margin: 0 0 6px;
color: var(--quiet);
font: 700 10px/1 var(--sans);
letter-spacing: .08em;
text-transform: uppercase;
}
.logdrawer-section dl {
display: grid;
grid-template-columns: minmax(88px, max-content) minmax(0, 1fr);
gap: 4px 12px;
margin: 0;
font: 12px/1.5 var(--mono);
}
.log-inspector dt { color: var(--muted); }
.log-inspector dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.logdrawer-section dt { color: var(--muted); }
.logdrawer-section dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.logdrawer-raw {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.logdrawer-raw summary {
cursor: pointer;
color: var(--quiet);
font: 700 10px/1 var(--sans);
letter-spacing: .08em;
text-transform: uppercase;
}
.logdrawer-raw pre {
max-height: 320px;
margin: 10px 0 0;
overflow: auto;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-xs);
background: #080a0e;
font: 11px/1.6 var(--mono);
}
/* Columns are given up in the order they are least missed: the duration first, since it is
the only one of the six whose absence costs nothing to understanding what happened, and
the timestamp after it. Level, service and the summary are never dropped a log that
cannot say how bad, where, or what is not a log. */
@media (max-width: 1180px) {
.loghead,
.logrow {
grid-template-columns: 84px 46px minmax(130px, 170px) minmax(200px, 1fr) minmax(88px, 130px);
gap: 10px;
}
.loghead > :last-child,
.logrow-duration {
display: none;
}
.loghead,
.logbody {
min-width: 640px;
}
}
@media (max-width: 900px) {
.loghead,
.logrow {
grid-template-columns: 46px minmax(110px, 140px) minmax(180px, 1fr) minmax(72px, 110px);
gap: 8px;
}
.loghead > :first-child,
.logrow-time {
display: none;
}
.loghead,
.logbody {
min-width: 520px;
}
}
.logdetails {
min-width: 0;
}
@@ -2243,7 +2648,11 @@ pre.code {
letter-spacing: 0.02em;
}
/* The presence dot: three-valued, and the tone is passed rather than derived. */
/* The presence dot: four-valued, and the tone is passed rather than derived. The two
greens are the point one set is connected now and the other was an hour ago, and both
are fine, so they read as the same answer at two strengths rather than as two answers.
The dot is 7px and colour alone is never the whole signal here: every one of these
carries the wording in its title. */
.dot-state {
width: 7px;
height: 7px;
@@ -2254,6 +2663,9 @@ pre.code {
.dot-state[data-tone="ok"] {
background: var(--accent);
}
.dot-state[data-tone="idle"] {
background: var(--idle);
}
.dot-state[data-tone="warn"] {
background: var(--warn);
}
@@ -2873,18 +3285,14 @@ details summary {
min-height: 60px;
padding: 13px 0;
}
.loghead,
.logbody {
min-width: 680px;
}
.loghead,
.logline[data-virtual="true"] {
grid-template-columns: 128px 44px minmax(160px, 1fr) minmax(220px, 1.4fr);
gap: 10px;
}
/* The column collapse itself is width-driven and lives beside the table's own rules;
what a touch device needs on top of it is room to scroll and controls it can reach. */
.logview {
height: min(66dvh, 720px);
}
.logbar-actions {
margin-left: 0;
}
}
@media (max-width: 820px) {