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
+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;
}