0.2.75
This commit is contained in:
-11
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user