This commit is contained in:
ponzischeme89
2026-08-19 06:57:59 +12:00
parent 8c847c59b8
commit 2b43b9ef12
94 changed files with 6359 additions and 778 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-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>
+2
View File
@@ -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. */}
+79
View File
@@ -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;
}
+1
View File
@@ -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',
+2 -2
View File
@@ -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) => {
+23 -4
View File
@@ -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
View File
@@ -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',
},
{
+19 -4
View File
@@ -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>
);
+15 -3
View File
@@ -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
+1 -1
View File
@@ -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"
>
+1 -1
View File
@@ -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"
>
+29 -7
View File
@@ -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>
+2 -2
View File
@@ -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.">
+532
View File
@@ -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>
);
}
+1 -1
View File
@@ -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} />
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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;
}