0.2.77
This commit is contained in:
-11
File diff suppressed because one or more lines are too long
+1
-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-BmnCg8np.js"></script>
|
||||
<script type="module" crossorigin src="/admin/assets/index-KrrVPZvy.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-cNUhbl7V.css">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-C5rUVO6U.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -35,6 +35,7 @@ import { EngagementPage } from './pages/Engagement';
|
||||
import { SearchesPage } from './pages/Searches';
|
||||
import { ViewsPage } from './pages/Views';
|
||||
import { MediaReportsPage } from './pages/MediaReports';
|
||||
import { NotificationsPage } from './pages/Notifications';
|
||||
import { CreditsPage } from './pages/Credits';
|
||||
|
||||
/* The console's routing table.
|
||||
@@ -97,6 +98,7 @@ export function App() {
|
||||
<Route path="engagement" element={<EngagementPage />} />
|
||||
<Route path="searches" element={<SearchesPage />} />
|
||||
<Route path="media-reports" element={<MediaReportsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
|
||||
{/* The old console redirected /admin/ to /admin/overview. Anything that
|
||||
still links there lands on the overview rather than on a 404. */}
|
||||
|
||||
@@ -601,3 +601,82 @@ export interface IngestResponse {
|
||||
counts: { pending: number; done: number; failed: number };
|
||||
recent: IngestJob[];
|
||||
}
|
||||
|
||||
/* The outbound notification history — /admin/api/notification-log.
|
||||
*
|
||||
* Distinct from the administrative activity feed above: that is the operator's own bell,
|
||||
* this is the record of what Memby sent to viewers and to external services, whichever
|
||||
* feature produced it. One request carries the page, its totals, its daily shape and the
|
||||
* filter options, because they all describe the same filtered window and two requests
|
||||
* could disagree with each other while a filter was being typed. */
|
||||
|
||||
export type NotificationChannel = 'in-app' | 'broadcast' | 'webhook';
|
||||
|
||||
export type NotificationStatus = 'sent' | 'delivered' | 'failed' | 'pending' | 'skipped';
|
||||
|
||||
export interface NotificationLogEntry {
|
||||
id: number;
|
||||
occurredAt: string;
|
||||
channel: NotificationChannel | string;
|
||||
kind: string;
|
||||
source: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
itemId?: string;
|
||||
/** A destination's name — an integration, never its address. */
|
||||
target?: string;
|
||||
sourceKey?: string;
|
||||
status: NotificationStatus | string;
|
||||
/** The failure, or the reason a notification was deliberately not delivered. */
|
||||
detail?: string;
|
||||
durationMs: number;
|
||||
eventAt?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NotificationTotals {
|
||||
total: number;
|
||||
sent: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
skipped: number;
|
||||
users: number;
|
||||
}
|
||||
|
||||
export interface NotificationDay {
|
||||
day: string;
|
||||
sent: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
delivered: number;
|
||||
}
|
||||
|
||||
export interface NotificationFacet {
|
||||
value: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/* Built from what has actually been sent rather than from a list of constants, so the
|
||||
filters can neither offer a type that matches nothing nor miss one a feature added
|
||||
after this page was written. */
|
||||
export interface NotificationFacets {
|
||||
kinds: NotificationFacet[];
|
||||
channels: NotificationFacet[];
|
||||
statuses: NotificationFacet[];
|
||||
sources: NotificationFacet[];
|
||||
}
|
||||
|
||||
export interface NotificationLogResponse {
|
||||
entries: NotificationLogEntry[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
totals: NotificationTotals;
|
||||
days: NotificationDay[];
|
||||
facets: NotificationFacets;
|
||||
users: KnownUser[];
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const icons = {
|
||||
play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14',
|
||||
list: 'M4 7h16M4 12h16M4 17h10',
|
||||
inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5',
|
||||
send: 'M21 3 10.5 13.5M21 3l-6.8 18-3.7-7.5L3 10z',
|
||||
history: 'M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8',
|
||||
check: 'm5 12.5 4.5 4.5L19 7.5',
|
||||
alert: 'M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z',
|
||||
|
||||
@@ -117,8 +117,8 @@ export function OmniSearch() {
|
||||
ref={input}
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Search pages, users and devices…"
|
||||
aria-label="Search pages, users and devices"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
aria-expanded={open}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(event) => {
|
||||
|
||||
@@ -32,6 +32,15 @@ export interface Shaped {
|
||||
context: string;
|
||||
/** The one line that explains a failure, printed under the row rather than hidden. */
|
||||
detail: string;
|
||||
/* What the row actually prints under its summary, and what it prints at the end of the
|
||||
* summary line. They are separate fields rather than a rule the table re-derives,
|
||||
* because `tall` is the row's height and a height that disagrees with what is drawn is
|
||||
* text sliced through the middle — which is exactly what happened while the renderer
|
||||
* printed a context line the height rule had already decided against. */
|
||||
secondary: string;
|
||||
secondaryTone: 'error' | 'context' | '';
|
||||
/** Identity on a request row: the same fact, kept on the one line density depends on. */
|
||||
trail: string;
|
||||
result: { label: string; short: string; tone: LogTone } | null;
|
||||
durationMs: number | null;
|
||||
method: string;
|
||||
@@ -433,6 +442,15 @@ function derive(event: LogEvent): Shaped {
|
||||
const context = contextFor(attributes);
|
||||
const occurred = new Date(event.occurredAt);
|
||||
|
||||
// One decision, read twice. A failure explains itself under the summary; so does an
|
||||
// application event carrying a person or a position. Ordinary request traffic — which is
|
||||
// most of a log — keeps its identity at the end of its own line instead, because density
|
||||
// is the whole reason this page is worth watching and a second line on every request
|
||||
// would halve what an operator can see at once.
|
||||
const secondary = detail || (isRequest ? '' : context);
|
||||
const secondaryTone: Shaped['secondaryTone'] = detail ? 'error' : secondary ? 'context' : '';
|
||||
const trail = !detail && isRequest ? context : '';
|
||||
|
||||
const fields = Object.entries(attributes);
|
||||
const shaped: Shaped = {
|
||||
serviceKey,
|
||||
@@ -442,6 +460,9 @@ function derive(event: LogEvent): Shaped {
|
||||
summary,
|
||||
context,
|
||||
detail,
|
||||
secondary,
|
||||
secondaryTone,
|
||||
trail,
|
||||
result: resultFor(attributes, event.level, status),
|
||||
durationMs,
|
||||
method,
|
||||
@@ -451,10 +472,8 @@ function derive(event: LogEvent): Shaped {
|
||||
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),
|
||||
// The height follows what is printed, never a second guess at it.
|
||||
tall: Boolean(secondary),
|
||||
haystack: [
|
||||
message, service, component, summary, context, detail,
|
||||
...fields.flat().map(text),
|
||||
|
||||
+15
-4
@@ -64,7 +64,7 @@ export const nav: NavGroup[] = [
|
||||
id: 'accounts',
|
||||
path: '/admin/accounts',
|
||||
label: 'Users',
|
||||
title: 'Memby users',
|
||||
title: 'Users',
|
||||
intro: 'Who uses Memby, and the devices they are signed in on.',
|
||||
icon: 'people',
|
||||
},
|
||||
@@ -76,6 +76,17 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Who can ask for something the library does not have.',
|
||||
icon: 'inbox',
|
||||
},
|
||||
{
|
||||
/* The record of what Memby sent, which is a different question from the activity
|
||||
feed above it: that is the operator's own bell, this is every outbound
|
||||
notification to a viewer or an external service, whichever feature produced it. */
|
||||
id: 'notifications',
|
||||
path: '/admin/notifications',
|
||||
label: 'Notifications',
|
||||
title: 'Notifications',
|
||||
intro: 'Everything Memby sent: who it went to, over which channel, and whether it worked.',
|
||||
icon: 'send',
|
||||
},
|
||||
{
|
||||
id: 'media-reports',
|
||||
path: '/admin/media-reports',
|
||||
@@ -95,8 +106,8 @@ export const nav: NavGroup[] = [
|
||||
{
|
||||
id: 'logs',
|
||||
path: '/admin/logs',
|
||||
label: 'Server logs',
|
||||
title: 'Server logs',
|
||||
label: 'Logs',
|
||||
title: 'Logs',
|
||||
intro: 'Structured gateway events as they happen.',
|
||||
icon: 'list',
|
||||
},
|
||||
@@ -309,7 +320,7 @@ export const nav: NavGroup[] = [
|
||||
path: '/admin/searches',
|
||||
label: 'Searches',
|
||||
title: 'Searches',
|
||||
intro: 'What the household has been looking for, and what it searched just now.',
|
||||
intro: 'What viewers have been looking for, and what was searched just now.',
|
||||
icon: 'search',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,6 +40,12 @@ interface PreferenceDefinition {
|
||||
numbers?: number[];
|
||||
unit?: string;
|
||||
maxLength?: number;
|
||||
/* Whether a text value is folded to capitals, and what an empty field means. Both come
|
||||
from the catalogue rather than from this page: initials read as capitals and a person's
|
||||
name does not, and a console that decided that for itself would drift from the server
|
||||
the first time a text setting was added. */
|
||||
uppercase?: boolean;
|
||||
placeholder?: string;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -101,6 +107,7 @@ interface AccountDetail {
|
||||
id: string;
|
||||
username: string;
|
||||
initials: string;
|
||||
shortName: string;
|
||||
lastSeen: string;
|
||||
devices: AccountDevice[] | null;
|
||||
themes: string[] | null;
|
||||
@@ -229,7 +236,9 @@ export function AccountPage() {
|
||||
<>
|
||||
<PageHead
|
||||
title={account.username || 'Unnamed user'}
|
||||
intro={`Memby user · ${num(devices.length)} device${devices.length === 1 ? '' : 's'} · last seen ${when(account.lastSeen)}`}
|
||||
/* The short name is stated here as well as being editable below: it is what the
|
||||
launcher calls this person, and the settings editor is a long way down the page. */
|
||||
intro={`Memby user · ${account.shortName ? `greeted as ${account.shortName} · ` : ''}${num(devices.length)} device${devices.length === 1 ? '' : 's'} · last seen ${when(account.lastSeen)}`}
|
||||
crumbs={<Link to="/admin/accounts">← All users</Link>}
|
||||
actions={
|
||||
<>
|
||||
@@ -361,7 +370,7 @@ export function AccountPage() {
|
||||
{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."
|
||||
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 server's own timezone."
|
||||
icon="pulse"
|
||||
tone="data"
|
||||
actions={
|
||||
@@ -845,8 +854,14 @@ function SettingControl({
|
||||
type="text"
|
||||
value={String(value ?? '')}
|
||||
maxLength={definition.maxLength}
|
||||
placeholder="Generated from their name"
|
||||
onChange={(event) => onChange(event.target.value.toLocaleUpperCase('en-NZ'))}
|
||||
placeholder={definition.placeholder}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
definition.uppercase
|
||||
? event.target.value.toLocaleUpperCase('en-NZ')
|
||||
: event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ interface Account {
|
||||
id: string;
|
||||
username: string;
|
||||
initials: string;
|
||||
shortName: string;
|
||||
lastSeen: string;
|
||||
devices: KnownClient[] | null;
|
||||
recommendations?: { prompted?: boolean; completed?: boolean };
|
||||
@@ -100,7 +101,7 @@ export function AccountsPage() {
|
||||
{ label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' },
|
||||
{ label: 'signed-in devices', value: num(devices.length), icon: 'tv', tone: 'info' },
|
||||
{
|
||||
label: 'active in the last quarter hour',
|
||||
label: 'active in the last 15 mins',
|
||||
value: num(devices.filter((device) => recent(device.lastSeen)).length),
|
||||
icon: 'pulse',
|
||||
tone: 'ok',
|
||||
@@ -110,7 +111,7 @@ export function AccountsPage() {
|
||||
...(tracked.length
|
||||
? [
|
||||
{
|
||||
label: 'watched by the household this week',
|
||||
label: 'watch time this week',
|
||||
value: watchTime(weekMs),
|
||||
icon: 'pulse' as const,
|
||||
tone: 'data' as const,
|
||||
@@ -126,6 +127,7 @@ export function AccountsPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Person</th>
|
||||
<th>Short name</th>
|
||||
<th className="num">Devices</th>
|
||||
<th className="num">This week</th>
|
||||
<th className="num">This month</th>
|
||||
@@ -135,7 +137,7 @@ export function AccountsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyRow columns={6}>
|
||||
<EmptyRow columns={7}>
|
||||
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
|
||||
</EmptyRow>
|
||||
) : (
|
||||
@@ -163,6 +165,16 @@ export function AccountsPage() {
|
||||
</Link>
|
||||
</span>
|
||||
</td>
|
||||
{/* Blank is the ordinary state and not a gap: the launcher greets
|
||||
somebody by their account name unless an operator has given
|
||||
Memby a friendlier one, and saying so beats a bare dash. */}
|
||||
<td>
|
||||
{account.shortName || (
|
||||
<span className="muted" title="Memby greets them by their account name">
|
||||
account name
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="num">
|
||||
{num(list.length)}
|
||||
{/* Only where there is something to say. A sub-line under every
|
||||
|
||||
@@ -122,7 +122,7 @@ export function CreditsPage() {
|
||||
|
||||
<Card
|
||||
title="Waiting candidates"
|
||||
intro="The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as household viewing changes."
|
||||
intro="The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as viewing changes."
|
||||
icon="list"
|
||||
tone="info"
|
||||
>
|
||||
|
||||
@@ -239,7 +239,7 @@ function LogView({
|
||||
<Grid cols="wide">
|
||||
<Card
|
||||
title="Attempts per day"
|
||||
intro="Grouped in the household's own timezone, so an evening sign-in stays on the day it happened."
|
||||
intro="Grouped in the server's own timezone, so an evening sign-in stays on the day it happened."
|
||||
icon="chart"
|
||||
tone="info"
|
||||
>
|
||||
|
||||
@@ -47,6 +47,13 @@ import type { LogEvent, LogResponse } from '../api/types';
|
||||
* because density is the reason this page is worth watching; a failure or a real
|
||||
* application event earns a second. That means the virtual window is driven by a prefix
|
||||
* sum of row heights rather than by multiplication, computed once per filter change.
|
||||
* Which height a row gets and what it prints are one decision, made once, in
|
||||
* `lib/logmodel`'s `secondary` — the table renders that field and nothing else under
|
||||
* the summary. They were two, and they disagreed: every authenticated request line
|
||||
* carries the viewer and the television, so a context line was drawn on rows the height
|
||||
* rule had already ruled out, centred inside a box too short for it and sliced top and
|
||||
* bottom. A request's identity now sits at the end of its own line instead, which keeps
|
||||
* both the density and the fact.
|
||||
* - **Pause holds the view, not the connection.** Draining continues while paused and the
|
||||
* arrivals are held in a buffer, so the cursor keeps up with the server's ring buffer
|
||||
* and resuming is a flush rather than a stampede — the previous behaviour let the ring
|
||||
@@ -56,8 +63,20 @@ import type { LogEvent, LogResponse } from '../api/types';
|
||||
|
||||
const RETAIN = 20_000;
|
||||
const POLL_MS = 5_000;
|
||||
const ROW_COMPACT = 30;
|
||||
const ROW_TALL = 48;
|
||||
/* Row geometry, and it is arithmetic rather than a pair of round numbers: a row is
|
||||
* absolutely positioned at a height this file decides, so anything the stylesheet draws
|
||||
* that these figures do not account for is text clipped by a rule nobody can see from the
|
||||
* CSS. Every first-line cell in `.logrow` is given exactly ROW_LINE, the secondary line
|
||||
* exactly ROW_SECOND, and the padding and hairline below are the same on both heights —
|
||||
* which is what makes the columns line up whether an event printed one line or two.
|
||||
* Changing any figure here means changing its twin in `styles.css`. */
|
||||
const ROW_PAD = 7;
|
||||
const ROW_LINE = 16;
|
||||
const ROW_SECOND = 15;
|
||||
const ROW_SECOND_GAP = 2;
|
||||
const ROW_RULE = 1;
|
||||
const ROW_COMPACT = ROW_PAD + ROW_LINE + ROW_PAD + ROW_RULE;
|
||||
const ROW_TALL = ROW_COMPACT + ROW_SECOND_GAP + ROW_SECOND;
|
||||
const DAY_HEIGHT = 26;
|
||||
const HEADER_HEIGHT = 31;
|
||||
const OVERSCAN = 10;
|
||||
@@ -175,7 +194,7 @@ const LogRow = memo(function LogRow({
|
||||
<button
|
||||
type="button"
|
||||
className="logrow-summary"
|
||||
title={view.detail || view.summary}
|
||||
title={[view.summary, view.trail, view.secondary].filter(Boolean).join(' — ')}
|
||||
onClick={() => onInspect(event.sequence)}
|
||||
>
|
||||
<span className="logrow-line">
|
||||
@@ -185,11 +204,14 @@ const LogRow = memo(function LogRow({
|
||||
</b>
|
||||
) : null}
|
||||
<span className="logrow-text">{view.summary}</span>
|
||||
{view.trail ? <span className="logrow-trail">{view.trail}</span> : null}
|
||||
</span>
|
||||
{view.detail ? (
|
||||
<span className="logrow-error">↳ {view.detail}</span>
|
||||
) : view.context ? (
|
||||
<span className="logrow-context">{view.context}</span>
|
||||
{/* Printed if and only if the row was measured for it — see `secondary` in
|
||||
lib/logmodel. */}
|
||||
{view.secondary ? (
|
||||
<span className="logrow-second" data-tone={view.secondaryTone}>
|
||||
{view.secondaryTone === 'error' ? `↳ ${view.secondary}` : view.secondary}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export function MaintenancePage() {
|
||||
{!loading ? (
|
||||
<Card
|
||||
title="Quiet time"
|
||||
intro={`Pause new television requests and server background work every day in ${status?.quietTime?.timeZone ?? 'the household timezone'}. Work already under way finishes safely. The admin console and health checks stay available so the schedule can always be changed.`}
|
||||
intro={`Pause new television requests and server background work every day in ${status?.quietTime?.timeZone ?? 'the server timezone'}. Work already under way finishes safely. The admin console and health checks stay available so the schedule can always be changed.`}
|
||||
icon="clock"
|
||||
tone={status?.quietTime?.active ? 'warn' : 'info'}
|
||||
actions={status?.quietTime?.active ? <Tag tone="warn">active now</Tag> : quietEnabled ? <Tag tone="ok">scheduled</Tag> : <Tag>off</Tag>}
|
||||
@@ -123,7 +123,7 @@ export function MaintenancePage() {
|
||||
onChange={(next) => { setQuietEnabled(next); setQuietTouched(true); }}
|
||||
/>
|
||||
<div className="fields">
|
||||
<Field label="Starts" hint="Uses the household's 24-hour clock.">
|
||||
<Field label="Starts" hint="Uses the server's 24-hour clock.">
|
||||
<input type="time" value={quietStart} onChange={(event) => { setQuietStart(event.target.value); setQuietTouched(true); }} />
|
||||
</Field>
|
||||
<Field label="Ends" hint="May be on the following day, for example 23:00 to 07:00.">
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { query } from '../api/client';
|
||||
import { useQuery } from '../lib/hooks';
|
||||
import { num, when } from '../lib/format';
|
||||
import type { Tone } from '../lib/format';
|
||||
import {
|
||||
Banner,
|
||||
Bars,
|
||||
Button,
|
||||
Card,
|
||||
EmptyRow,
|
||||
Field,
|
||||
Loading,
|
||||
PageHead,
|
||||
Segments,
|
||||
TableWrap,
|
||||
Tag,
|
||||
Tiles,
|
||||
} from '../components/ui';
|
||||
import type {
|
||||
NotificationFacet,
|
||||
NotificationLogEntry,
|
||||
NotificationLogResponse,
|
||||
} from '../api/types';
|
||||
|
||||
/* Everything Memby sent.
|
||||
*
|
||||
* The page exists because that question used to be unanswerable without reading three
|
||||
* subsystems' log lines: each feature both decided to notify somebody and performed the
|
||||
* delivery itself, so there was no one place that knew a summary had gone out, or that a
|
||||
* viewer's own preferences had quietly refused it. Every producer now goes through
|
||||
* internal/notify, and this is the console's window on the trail that leaves behind.
|
||||
*
|
||||
* Its shape follows the sign-in history's, deliberately, because an operator arrives at
|
||||
* both with a *question* rather than a browsing intention — "did the weekly summary go
|
||||
* out", "why did nobody hear about that import". So the filters sit above the table and
|
||||
* are always visible, each control maps to one server-side filter, and the table stays
|
||||
* readable on its own: the drawer is for the full body and the delivery error, never for
|
||||
* working out who a row was about. */
|
||||
|
||||
interface Filters {
|
||||
user: string;
|
||||
kind: string;
|
||||
channel: string;
|
||||
status: string;
|
||||
source: string;
|
||||
q: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const EMPTY: Filters = {
|
||||
user: '',
|
||||
kind: '',
|
||||
channel: '',
|
||||
status: '',
|
||||
source: '',
|
||||
q: '',
|
||||
from: '',
|
||||
to: '',
|
||||
};
|
||||
|
||||
const WINDOWS = [
|
||||
{ value: 1, label: 'Today' },
|
||||
{ value: 7, label: '7 days' },
|
||||
{ value: 30, label: '30 days' },
|
||||
{ value: 90, label: '90 days' },
|
||||
] as const;
|
||||
|
||||
const LIMIT = 100;
|
||||
|
||||
/* One tone per status, and they mean what they mean everywhere else in the console: green
|
||||
is the verdict, amber is look at this, red is wrong. Skipped is deliberately *not* red —
|
||||
a notification a viewer's own preferences declined is Memby working correctly, and
|
||||
colouring it as a fault would send an operator to fix something nobody broke. */
|
||||
const STATUS_TONE: Record<string, Tone> = {
|
||||
sent: 'ok',
|
||||
delivered: 'ok',
|
||||
failed: 'bad',
|
||||
pending: 'warn',
|
||||
skipped: 'idle',
|
||||
};
|
||||
|
||||
/* The channel is the one column that says what *kind* of thing happened, so it carries a
|
||||
tone of its own from the console's neutral half: a person, the household, somebody
|
||||
else's service. None of the three is a judgement. */
|
||||
const CHANNEL_TONE: Record<string, Tone> = {
|
||||
'in-app': 'note',
|
||||
broadcast: 'info',
|
||||
webhook: 'data',
|
||||
};
|
||||
|
||||
const CHANNEL_LABEL: Record<string, string> = {
|
||||
'in-app': 'In-app',
|
||||
broadcast: 'Broadcast',
|
||||
webhook: 'Webhook',
|
||||
};
|
||||
|
||||
/** readable turns a stored slug into something an operator reads: "watch-time-week"
|
||||
* becomes "Watch time week". The slug is still the filter value — this is display only,
|
||||
* so a kind added tomorrow needs nothing here. */
|
||||
function readable(slug: string): string {
|
||||
if (!slug) return '—';
|
||||
const spaced = slug.replace(/[-_.:]+/g, ' ').trim();
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
/** options builds a dropdown from a facet list, so it can never offer a value that matches
|
||||
* nothing. The count rides in the label because "failed (0)" and a missing option are
|
||||
* different answers to "has anything failed". */
|
||||
function options(facets: NotificationFacet[] | undefined, label: (value: string) => string) {
|
||||
return (facets ?? []).map((facet) => (
|
||||
<option key={facet.value} value={facet.value}>
|
||||
{label(facet.value)} ({facet.count})
|
||||
</option>
|
||||
));
|
||||
}
|
||||
|
||||
export function NotificationsPage() {
|
||||
const [days, setDays] = useState<number>(7);
|
||||
const [filters, setFilters] = useState<Filters>(EMPTY);
|
||||
const [page, setPage] = useState(0);
|
||||
const [openId, setOpenId] = useState<number | null>(null);
|
||||
|
||||
// An explicit `from` wins over the window, the rule the sign-in history follows: an
|
||||
// operator who typed a date meant it.
|
||||
const params = useMemo(
|
||||
() =>
|
||||
query({
|
||||
...filters,
|
||||
days: filters.from ? undefined : days,
|
||||
limit: LIMIT,
|
||||
offset: page * LIMIT,
|
||||
}),
|
||||
[filters, days, page],
|
||||
);
|
||||
|
||||
const log = useQuery<NotificationLogResponse>(`/admin/api/notification-log${params}`);
|
||||
const data = log.data;
|
||||
|
||||
const update = (patch: Partial<Filters>) => {
|
||||
setFilters((current) => ({ ...current, ...patch }));
|
||||
setPage(0);
|
||||
// The open row belongs to the page that was on screen. Leaving it open across a filter
|
||||
// change would show a record the table underneath no longer contains.
|
||||
setOpenId(null);
|
||||
};
|
||||
|
||||
const active = Object.values(filters).some((value) => value !== '');
|
||||
const totals = data?.totals;
|
||||
const retention = data?.retentionDays ?? 90;
|
||||
const shown = data?.entries.length ?? 0;
|
||||
const from = !data || data.total === 0 ? 0 : page * LIMIT + 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="Notifications"
|
||||
intro="Everything Memby sent — a viewer's own news, the bar every television draws, and each outbound webhook — with what became of it. Every feature reports through one notification service, so this is the whole trail rather than whichever half a feature remembered to log."
|
||||
/>
|
||||
|
||||
<Banner message={log.error} />
|
||||
|
||||
{totals ? (
|
||||
<Tiles
|
||||
tiles={[
|
||||
{ label: 'Sent', value: num(totals.sent), icon: 'send', tone: 'ok' },
|
||||
{
|
||||
label: 'Confirmed',
|
||||
value: num(totals.delivered),
|
||||
icon: 'check',
|
||||
tone: 'ok',
|
||||
},
|
||||
{
|
||||
label: 'Failed',
|
||||
value: num(totals.failed),
|
||||
icon: 'alert',
|
||||
tone: totals.failed > 0 ? 'bad' : undefined,
|
||||
},
|
||||
{
|
||||
/* Skipped is on the tile row because it is the number that answers the
|
||||
complaint this page is usually opened for: somebody was not told, and
|
||||
Memby meant not to tell them. */
|
||||
label: 'Skipped',
|
||||
value: num(totals.skipped),
|
||||
icon: 'filter',
|
||||
tone: 'idle',
|
||||
},
|
||||
{ label: 'People reached', value: num(totals.users), icon: 'people', tone: 'note' },
|
||||
{ label: 'History kept', value: `${retention} days`, small: true, icon: 'clock' },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="filters">
|
||||
<Field label="Window">
|
||||
<Segments
|
||||
value={filters.from ? -1 : days}
|
||||
options={WINDOWS.map((entry) => ({ value: entry.value as number, label: entry.label }))}
|
||||
onChange={(next) => {
|
||||
setDays(next);
|
||||
update({ from: '', to: '' });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Person">
|
||||
<select value={filters.user} onChange={(event) => update({ user: event.target.value })}>
|
||||
<option value="">Anyone</option>
|
||||
{(data?.users ?? []).map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.username || user.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Type">
|
||||
<select value={filters.kind} onChange={(event) => update({ kind: event.target.value })}>
|
||||
<option value="">Any type</option>
|
||||
{options(data?.facets?.kinds, readable)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Channel">
|
||||
<select
|
||||
value={filters.channel}
|
||||
onChange={(event) => update({ channel: event.target.value })}
|
||||
>
|
||||
<option value="">Any channel</option>
|
||||
{options(data?.facets?.channels, (value) => CHANNEL_LABEL[value] ?? readable(value))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<select value={filters.status} onChange={(event) => update({ status: event.target.value })}>
|
||||
<option value="">Any status</option>
|
||||
{options(data?.facets?.statuses, readable)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Source">
|
||||
<select value={filters.source} onChange={(event) => update({ source: event.target.value })}>
|
||||
<option value="">Any service</option>
|
||||
{options(data?.facets?.sources, readable)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="From">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={(event) => update({ from: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="To">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
onChange={(event) => update({ to: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Search" grow>
|
||||
<input
|
||||
type="search"
|
||||
value={filters.q}
|
||||
placeholder="Title, message, error or person"
|
||||
onChange={(event) => update({ q: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="filter-actions">
|
||||
{active ? (
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFilters(EMPTY);
|
||||
setPage(0);
|
||||
setOpenId(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{log.loading && !data ? <Loading /> : null}
|
||||
|
||||
{data && data.days.length > 1 ? (
|
||||
<Card
|
||||
title="Notifications per day"
|
||||
intro="Failures and deliberate skips are counted beside the deliveries, because a quiet week and a week nothing was allowed to send look identical otherwise."
|
||||
icon="chart"
|
||||
tone="info"
|
||||
>
|
||||
<Bars
|
||||
data={data.days}
|
||||
labelOf={(row: (typeof data.days)[number]) => row.day}
|
||||
valueOf={(row: (typeof data.days)[number]) =>
|
||||
row.sent + row.delivered + row.failed + row.skipped
|
||||
}
|
||||
toneOf={(row: (typeof data.days)[number]) => (row.failed > 0 ? 'bad' : undefined)}
|
||||
title={(row: (typeof data.days)[number]) =>
|
||||
`${row.day}: ${row.sent + row.delivered} sent, ${row.failed} failed, ${row.skipped} skipped`
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
<Card
|
||||
title="History"
|
||||
intro="Newest first. A row says who, what and whether it worked on its own; open one for the whole message and the delivery response."
|
||||
icon="send"
|
||||
tone="ok"
|
||||
actions={
|
||||
<span className="filter-summary">
|
||||
{data.total === 0
|
||||
? 'nothing matches'
|
||||
: `${num(from)}–${num(from + shown - 1)} of ${num(data.total)}`}
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
data.total > LIMIT ? (
|
||||
<>
|
||||
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||||
Newer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={(page + 1) * LIMIT >= data.total}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Older
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="nowrap">When</th>
|
||||
<th>Recipient</th>
|
||||
<th>Type</th>
|
||||
<th>Title</th>
|
||||
<th>Channel</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th aria-label="Details" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.entries.length === 0 ? (
|
||||
<EmptyRow columns={8}>No notifications match these filters.</EmptyRow>
|
||||
) : (
|
||||
data.entries.map((entry) => (
|
||||
<Fragment key={entry.id}>
|
||||
<Row
|
||||
entry={entry}
|
||||
open={openId === entry.id}
|
||||
onToggle={() => setOpenId(openId === entry.id ? null : entry.id)}
|
||||
/>
|
||||
{openId === entry.id ? <DetailRow entry={entry} /> : null}
|
||||
</Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
entry,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: NotificationLogEntry;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<tr data-selected={open || undefined}>
|
||||
<td className="nowrap muted">{when(entry.occurredAt)}</td>
|
||||
<td>
|
||||
{entry.userId ? (
|
||||
<Link className="table-row-link" to={`/admin/accounts/${encodeURIComponent(entry.userId)}`}>
|
||||
{entry.username || entry.userId}
|
||||
</Link>
|
||||
) : entry.target ? (
|
||||
/* A webhook's row names the destination it went to. Its address is never sent to
|
||||
the console — the URL is the credential — so this is the only thing that can
|
||||
identify which channel a delivery landed in. */
|
||||
<span className="muted">{entry.target}</span>
|
||||
) : (
|
||||
/* No recipient is a real answer rather than a missing one: a service alert is
|
||||
the whole household being told something. */
|
||||
<span className="quiet">Everyone</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">{entry.kind || '—'}</td>
|
||||
<td>{entry.title || <span className="quiet">—</span>}</td>
|
||||
<td className="nowrap">
|
||||
<Tag tone={CHANNEL_TONE[entry.channel]}>
|
||||
{CHANNEL_LABEL[entry.channel] ?? entry.channel}
|
||||
</Tag>
|
||||
</td>
|
||||
<td className="muted">{readable(entry.source)}</td>
|
||||
<td className="nowrap">
|
||||
<Tag tone={STATUS_TONE[entry.status] ?? 'idle'}>{entry.status}</Tag>
|
||||
</td>
|
||||
<td className="nowrap">
|
||||
<Button size="sm" variant="quiet" onClick={onToggle} icon={open ? 'close' : 'list'}>
|
||||
{open ? 'Close' : 'Details'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/* The drawer is the whole record: the message as it was written, the delivery response,
|
||||
and the context the producer attached. It is a row inside the table rather than a panel
|
||||
beside it, so the record stays under the row it belongs to while an operator reads down
|
||||
a filtered list. */
|
||||
function DetailRow({ entry }: { entry: NotificationLogEntry }) {
|
||||
const metadata = Object.entries(entry.metadata ?? {}).filter(
|
||||
([, value]) => value !== null && value !== undefined && String(value) !== '',
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className="detail-row">
|
||||
<td colSpan={8}>
|
||||
<section className="logdrawer" aria-label={`Notification ${entry.id}`}>
|
||||
<header className="logdrawer-head">
|
||||
<div>
|
||||
<p className="logdrawer-place">
|
||||
<span>{CHANNEL_LABEL[entry.channel] ?? entry.channel}</span>
|
||||
<span className="logrow-sep" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
{readable(entry.source)}
|
||||
</p>
|
||||
<b>{entry.title || readable(entry.kind)}</b>
|
||||
{/* The failure and the reason share a line, because they are the same answer
|
||||
to "why did this not arrive" — one from the provider, one from Memby. */}
|
||||
{entry.detail ? <p className="logdrawer-error">{entry.detail}</p> : null}
|
||||
</div>
|
||||
<div className="logdrawer-actions">
|
||||
<Tag tone={STATUS_TONE[entry.status] ?? 'idle'}>{entry.status}</Tag>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="logdrawer-grid">
|
||||
<div className="logdrawer-section">
|
||||
<h4>Delivery</h4>
|
||||
<dl>
|
||||
<dt>Sent</dt>
|
||||
<dd>{when(entry.occurredAt)}</dd>
|
||||
{entry.eventAt ? (
|
||||
<>
|
||||
<dt>Event</dt>
|
||||
<dd>{when(entry.eventAt)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt>Channel</dt>
|
||||
<dd>{CHANNEL_LABEL[entry.channel] ?? entry.channel}</dd>
|
||||
<dt>Type</dt>
|
||||
<dd>{entry.kind || '—'}</dd>
|
||||
<dt>Service</dt>
|
||||
<dd>{readable(entry.source)}</dd>
|
||||
<dt>Took</dt>
|
||||
<dd>{entry.durationMs}ms</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="logdrawer-section">
|
||||
<h4>Recipient</h4>
|
||||
<dl>
|
||||
<dt>Person</dt>
|
||||
<dd>
|
||||
{entry.userId ? entry.username || entry.userId : 'the whole household'}
|
||||
</dd>
|
||||
{entry.target ? (
|
||||
<>
|
||||
<dt>Destination</dt>
|
||||
<dd>{entry.target}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{entry.itemId ? (
|
||||
<>
|
||||
<dt>Title id</dt>
|
||||
<dd>{entry.itemId}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{entry.sourceKey ? (
|
||||
<>
|
||||
{/* The idempotency key is what explains a skip as a repeat rather than
|
||||
as an unexplained gap, so it is printed rather than hidden. */}
|
||||
<dt>Source key</dt>
|
||||
<dd>{entry.sourceKey}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{metadata.length ? (
|
||||
<div className="logdrawer-section">
|
||||
<h4>Context</h4>
|
||||
<dl>
|
||||
{metadata.map(([key, value]) => (
|
||||
<Fragment key={key}>
|
||||
<dt>{readable(key)}</dt>
|
||||
<dd>{String(value)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{entry.body ? (
|
||||
<div className="logdrawer-raw">
|
||||
<h4>Message</h4>
|
||||
<p className="notification-body">{entry.body}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function SearchesPage() {
|
||||
<>
|
||||
<PageHead
|
||||
title="Searches"
|
||||
intro="What the household has been looking for, and what it searched just now."
|
||||
intro="What viewers have been looking for, and what was searched just now."
|
||||
/>
|
||||
<Banner message={error} />
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export function SettingsPage() {
|
||||
>
|
||||
<KeyValue
|
||||
rows={[
|
||||
{ label: 'Household timezone', value: effective.timezone || 'not set' },
|
||||
{ label: 'Server timezone', value: effective.timezone || 'not set' },
|
||||
{ label: 'Log level', value: effective.logLevel },
|
||||
{ label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') },
|
||||
{ label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') },
|
||||
@@ -176,7 +176,7 @@ export function SettingsPage() {
|
||||
>
|
||||
<div className="fields">
|
||||
<Field
|
||||
label="Household timezone"
|
||||
label="Server timezone"
|
||||
hint={`Deployed: ${deployed.timezone || 'not set'}. An IANA name, for example Pacific/Auckland. Decides what "today" means for the schedule rows, the home hero and the sign-in history.`}
|
||||
>
|
||||
<input
|
||||
|
||||
@@ -25,12 +25,12 @@ export function ViewsPage() {
|
||||
{ label: change(data?.today.viewers ?? 0, data?.lastWeek.viewers ?? 0), value: num(data?.today.viewers), icon: 'people', tone: 'note' },
|
||||
{ label: 'busiest time today', value: data?.busiestHour || '—', small: true, icon: 'clock', tone: 'info' },
|
||||
]} />
|
||||
<Card title="Visits by day" intro="One visit is a signed-in home-screen opening. Viewers are distinct household profiles." icon="chart" tone="data">
|
||||
<Card title="Visits by day" intro="One visit is a signed-in home-screen opening. Viewers are distinct signed-in profiles." icon="chart" tone="data">
|
||||
<TableWrap><table><thead><tr><th>Day</th><th className="num">Visits</th><th className="num">Viewers</th></tr></thead><tbody>
|
||||
{daily.length === 0 ? <EmptyRow columns={3}>No home-screen visits yet.</EmptyRow> : daily.map((row) => <tr key={row.label}><td>{row.label}</td><td className="num">{num(row.visits)}</td><td className="num">{num(row.viewers)}</td></tr>)}
|
||||
</tbody></table></TableWrap>
|
||||
</Card>
|
||||
<Card title="Today by hour" intro="Local New Zealand time. Use this to see when the household is opening Memby." icon="clock" tone="info">
|
||||
<Card title="Today by hour" intro="Local New Zealand time. Use this to see when viewers are opening Memby." icon="clock" tone="info">
|
||||
<TableWrap><table><thead><tr><th>Hour</th><th className="num">Visits</th><th className="num">Viewers</th></tr></thead><tbody>
|
||||
{hourly.length === 0 ? <EmptyRow columns={3}>No home-screen visits yet today.</EmptyRow> : hourly.map((row) => <tr key={row.label}><td>{row.label}</td><td className="num">{num(row.visits)}</td><td className="num">{num(row.viewers)}</td></tr>)}
|
||||
</tbody></table></TableWrap>
|
||||
|
||||
+94
-25
@@ -1881,12 +1881,19 @@ select {
|
||||
font: 12px/1.5 var(--sans);
|
||||
contain: layout paint style;
|
||||
}
|
||||
/* Every track but the event is a fixed width, and that is the whole of why the columns
|
||||
line up. A row is its own grid container — there are twenty thousand of them and no
|
||||
shared table — so a `minmax()` track sized from its own content gave each row a slightly
|
||||
different Service and Result column, and the Result column moved as the widest verdict
|
||||
on screen changed. Fixed tracks cannot move; content inside them truncates instead.
|
||||
|
||||
Vertical rhythm is set here too: cells start at the top of the row rather than being
|
||||
centred in it, so the Time, Level, Service and Result of a two-line event sit on the
|
||||
same line as its summary instead of dropping half a line to the middle of the pair. */
|
||||
.loghead,
|
||||
.logrow {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
92px 52px minmax(150px, 190px) minmax(240px, 1fr)
|
||||
minmax(96px, 150px) 68px;
|
||||
grid-template-columns: 92px 52px 190px minmax(240px, 1fr) 150px 68px;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
@@ -1942,15 +1949,26 @@ select {
|
||||
background: var(--line-soft);
|
||||
}
|
||||
|
||||
/* The figures below are the twin of the row geometry in `pages/Logs.tsx`: 7px of padding,
|
||||
a 16px first line, a 2px gap and a 15px second line, over a 1px hairline. The row's
|
||||
height is set inline from those constants, so a cell drawn taller than its share here is
|
||||
text sliced through the middle rather than a row that grows. Every first-line cell is
|
||||
therefore given `line-height: 16px` explicitly, whatever its font size. */
|
||||
.logrow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .03);
|
||||
contain: strict;
|
||||
}
|
||||
.logrow > *,
|
||||
.logrow-line {
|
||||
line-height: 16px;
|
||||
}
|
||||
.logrow:hover {
|
||||
background: rgba(255, 255, 255, .035);
|
||||
}
|
||||
@@ -1971,7 +1989,7 @@ select {
|
||||
|
||||
.logrow-time {
|
||||
color: var(--quiet);
|
||||
font: 11px/1 var(--mono);
|
||||
font: 11px/16px var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -2003,7 +2021,7 @@ select {
|
||||
|
||||
.logrow-level {
|
||||
color: var(--quiet);
|
||||
font: 700 10px/1 var(--sans);
|
||||
font: 700 10px/16px var(--sans);
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.logrow-level[data-level="ERROR"] { color: var(--danger-ink); }
|
||||
@@ -2019,16 +2037,18 @@ select {
|
||||
on every other page and must not start meaning "playback" on this one. */
|
||||
.logrow-place {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.logrow-service {
|
||||
flex: 0 0 auto;
|
||||
max-width: 96px;
|
||||
overflow: hidden;
|
||||
color: var(--quiet);
|
||||
font: 700 10px/1.4 var(--sans);
|
||||
font: 700 10px/16px var(--sans);
|
||||
letter-spacing: .07em;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
@@ -2047,6 +2067,7 @@ select {
|
||||
flex: 1 1 auto;
|
||||
color: var(--quiet);
|
||||
font-size: 11.5px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* The summary is the row. It is a button because selecting the row is what opens the
|
||||
@@ -2055,9 +2076,9 @@ select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@@ -2079,11 +2100,12 @@ select {
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
height: 16px;
|
||||
}
|
||||
.logrow-action {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
font: 600 10.5px/1.4 var(--mono);
|
||||
font: 600 10.5px/16px var(--mono);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.logrow-action[data-method="POST"],
|
||||
@@ -2098,15 +2120,13 @@ select {
|
||||
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 {
|
||||
/* Identity on a request row, kept on the summary's own line. It gives way before the path
|
||||
does — knowing which route was called matters more than which television called it, and
|
||||
the drawer holds both either way. */
|
||||
.logrow-trail {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 38%;
|
||||
overflow: hidden;
|
||||
color: var(--quiet);
|
||||
font-size: 11px;
|
||||
@@ -2114,21 +2134,42 @@ select {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The second line: the reason a failure needs no drawer, or the person and position that
|
||||
make an application event mean something. Exactly one line tall, 15px, which is the
|
||||
figure the row was measured with — it can truncate but it can never wrap, because a wrap
|
||||
is a row overflowing into the one below it. */
|
||||
.logrow-second {
|
||||
height: 15px;
|
||||
overflow: hidden;
|
||||
color: var(--quiet);
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logrow-second[data-tone="error"] {
|
||||
color: var(--danger-ink);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.logrow-verdict {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
color: var(--quiet);
|
||||
font: 500 11px/1.5 var(--mono);
|
||||
font: 500 11px/14px 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); }
|
||||
/* A fill still has to fit the row's line box: 14px of text and 1px either side is the
|
||||
16px every other cell on the line occupies. */
|
||||
.logrow-verdict[data-tone="warn"],
|
||||
.logrow-verdict[data-tone="bad"] {
|
||||
padding: 1px 6px;
|
||||
@@ -2146,7 +2187,7 @@ select {
|
||||
|
||||
.logrow-duration {
|
||||
color: var(--quiet);
|
||||
font: 11px/1 var(--mono);
|
||||
font: 11px/16px var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -2283,7 +2324,7 @@ select {
|
||||
@media (max-width: 1180px) {
|
||||
.loghead,
|
||||
.logrow {
|
||||
grid-template-columns: 84px 46px minmax(130px, 170px) minmax(200px, 1fr) minmax(88px, 130px);
|
||||
grid-template-columns: 84px 46px 170px minmax(200px, 1fr) 130px;
|
||||
gap: 10px;
|
||||
}
|
||||
.loghead > :last-child,
|
||||
@@ -2292,13 +2333,13 @@ select {
|
||||
}
|
||||
.loghead,
|
||||
.logbody {
|
||||
min-width: 640px;
|
||||
min-width: 680px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.loghead,
|
||||
.logrow {
|
||||
grid-template-columns: 46px minmax(110px, 140px) minmax(180px, 1fr) minmax(72px, 110px);
|
||||
grid-template-columns: 46px 140px minmax(180px, 1fr) 110px;
|
||||
gap: 8px;
|
||||
}
|
||||
.loghead > :first-child,
|
||||
@@ -3513,3 +3554,31 @@ details summary {
|
||||
background: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
/* The notification history's detail row.
|
||||
*
|
||||
* The drawer is a row inside the table rather than a panel beside it, so the record stays
|
||||
* under the row it belongs to while an operator reads down a filtered list. It borrows the
|
||||
* log viewer's drawer vocabulary wholesale — the two answer the same shape of question and
|
||||
* a second look for it would be a second thing to keep in step. */
|
||||
.detail-row > td {
|
||||
padding: 0 0 12px;
|
||||
background: var(--surface-sunken, transparent);
|
||||
}
|
||||
.logdrawer-raw h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--quiet);
|
||||
font: 700 10px/1 var(--sans);
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* The message is the one thing on the page rendered as prose rather than as a field: it is
|
||||
the sentence a viewer actually read, and setting it in the mono field type would make it
|
||||
look like a value rather than like the notification it is. */
|
||||
.notification-body {
|
||||
max-width: 70ch;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font: 13px/1.6 var(--sans);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user