0.2.77
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user