0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+774
View File
@@ -0,0 +1,774 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { initials, num, presence, recent, when } from '../lib/format';
import {
Banner,
Button,
Card,
Chip,
Confirm,
Empty,
Field,
Grid,
Loading,
PageHead,
Tag,
Toggle,
} from '../components/ui';
import type { DeviceVersion } from '../api/types';
/* One person: their televisions, their recommendation setup and the settings that follow
them to every set. The identity is in the URL rather than in a query string so the page
can be linked, bookmarked and returned to after a sign-in. */
interface PreferenceOption {
value: string;
label: string;
}
interface PreferenceDefinition {
key: string;
name: string;
description: string;
area: string;
kind: 'toggle' | 'choice' | 'number' | 'multi' | 'list';
options?: PreferenceOption[];
numbers?: number[];
unit?: string;
}
interface ThemeDefinition {
id: string;
name: string;
description: string;
palette?: Record<string, string>;
}
interface AccountDevice {
id: string;
name: string;
version: string;
signedInAt: string;
lastSeen: string;
versions: DeviceVersion[] | null;
}
interface RecommendationState {
prompted?: boolean;
completed?: boolean;
ratings?: { title: string; rating: number }[];
genres?: string[];
studios?: string[];
actors?: string[];
actresses?: string[];
directors?: string[];
contentTypes?: string[];
}
interface AccountDetail {
id: string;
username: string;
lastSeen: string;
devices: AccountDevice[] | null;
themes: string[] | null;
recommendations?: RecommendationState;
settings?: {
saved?: boolean;
revision?: number;
source?: string;
updatedAt?: string;
preferences?: Record<string, unknown>;
};
}
interface AccountsPayload {
accounts: AccountDetail[] | null;
catalogue: PreferenceDefinition[] | null;
themes: ThemeDefinition[] | null;
}
/* The palette is written the way Android reads it, #AARRGGBB, and CSS reads #RRGGBBAA. The
conversion lives here rather than on the wire because the television is the end that has
to parse thousands of these and the console is the end that parses eight. */
function cssColour(value: string | undefined): string {
const hex = String(value ?? '').replace('#', '');
if (hex.length !== 8) return `#${hex}`;
return `#${hex.slice(2)}${hex.slice(0, 2)}`;
}
type Pending =
| { kind: 'remove-device'; deviceId: string; name: string }
| { kind: 'remove-account' }
| { kind: 'reset-recommendations' }
| { kind: 'cancel-prompt' }
| { kind: 'reset-preferences' }
| { kind: 'no-themes' };
export function AccountPage() {
const { userId = '' } = useParams();
const navigate = useNavigate();
const { wrap } = useToast();
const { busy, run } = useAction();
const base = `/admin/api/accounts/${encodeURIComponent(userId)}`;
const { data, error, loading, reload } = useQuery<AccountsPayload>('/admin/api/accounts', {
pollMs: 30_000,
});
/* True while the operator has edited a form without saving. The page polls, and a redraw
would take a half-finished change away mid-sentence — so a dirty form keeps what it has
until it is saved, discarded or reloaded. The two forms track this separately, so
saving one does not discard an unsaved edit to the other. */
const [prefs, setPrefs] = useState<Record<string, unknown> | null>(null);
const [themes, setThemes] = useState<string[] | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
const account = (data?.accounts ?? []).find((entry) => entry.id === userId);
const catalogue = data?.catalogue ?? [];
const themeCatalogue = data?.themes ?? [];
useEffect(() => {
if (prefs === null && account) setPrefs({ ...(account.settings?.preferences ?? {}) });
}, [account, prefs]);
useEffect(() => {
if (themes !== null || !account) return;
// An empty list from the server means unrestricted, so it draws as every box ticked.
// Storing "all" and "never configured" identically is deliberate — they are the same
// decision — and this is the one place an operator would notice if it were not.
const allowed = account.themes ?? [];
setThemes(allowed.length === 0 ? themeCatalogue.map((theme) => theme.id) : allowed);
}, [account, themes, themeCatalogue]);
const areas = useMemo(() => {
const grouped: { name: string; definitions: PreferenceDefinition[] }[] = [];
for (const definition of catalogue) {
let area = grouped.find((entry) => entry.name === definition.area);
if (!area) grouped.push((area = { name: definition.area, definitions: [] }));
area.definitions.push(definition);
}
return grouped;
}, [catalogue]);
const act = (key: string, fn: () => Promise<unknown>, message: string, after?: () => void) =>
run(key, async () => {
const ok = await wrap(fn, message);
setPending(null);
if (ok !== undefined) after?.();
await reload();
});
if (loading) {
return (
<>
<PageHead title="User" crumbs={<Link to="/admin/accounts"> All users</Link>} />
<Loading />
</>
);
}
if (!account) {
return (
<>
<PageHead title="User" crumbs={<Link to="/admin/accounts"> All users</Link>} />
<Banner message={error} />
<Card>
<Empty>This user is no longer signed in to Memby.</Empty>
</Card>
</>
);
}
const devices = account.devices ?? [];
const active = devices.filter((device) => recent(device.lastSeen)).length;
const settings = account.settings ?? {};
const prompt = account.recommendations ?? {};
return (
<>
<PageHead
title={account.username || 'Unnamed user'}
intro={`Memby user · ${num(devices.length)} device${devices.length === 1 ? '' : 's'} · last seen ${when(account.lastSeen)}`}
crumbs={<Link to="/admin/accounts"> All users</Link>}
actions={
<>
<span className="avatar">{initials(account.username)}</span>
{active ? <Tag tone="ok">{active} active now</Tag> : <Tag>idle</Tag>}
<Chip>{account.id}</Chip>
</>
}
/>
<Banner message={error} />
<Grid cols="wide">
<Card
title="Devices"
intro="Every build a set has been seen running is listed under it. Signing one out revokes its Memby session, drops that history and removes it from Emby's own device list. Its Emby account is not changed."
icon="tv"
tone="info"
>
{devices.length === 0 ? (
<Empty>No devices are signed in to this user.</Empty>
) : (
<div className="list">
{devices.map((device) => {
const seen = presence(device.lastSeen);
return (
<div className="list-item" key={device.id || device.name}>
<div className="list-body">
<b>
<span className="dot-state" data-tone={seen.tone} title={seen.label} />{' '}
<Link
className="table-row-link"
to={`/admin/devices/${encodeURIComponent(device.id)}`}
>
{device.name || 'Memby TV'}
</Link>
</b>
<p>
{device.version ? `Memby ${device.version}` : 'Legacy Memby client'} · {seen.label} ·
last seen {when(device.lastSeen)} · signed in {when(device.signedInAt)}
</p>
{/* Every build this set has been seen running. One television that has
been through four releases is a different thing from four
televisions, and the history is what says which of those this is. */}
{(device.versions ?? []).length > 0 ? (
<div className="chips">
{(device.versions ?? []).map((entry) => (
<Chip key={entry.version} tone={entry.version === device.version ? 'ok' : undefined}>
{entry.version}
{entry.version === device.version ? ' · now' : ''}
</Chip>
))}
</div>
) : null}
</div>
<div className="list-actions">
<Button
size="sm"
disabled={!device.id}
onClick={() => setRenaming({ id: device.id, name: device.name })}
>
Rename
</Button>
<Button
size="sm"
variant="danger"
disabled={!device.id}
onClick={() =>
setPending({ kind: 'remove-device', deviceId: device.id, name: device.name })
}
>
Sign out
</Button>
</div>
</div>
);
})}
</div>
)}
</Card>
<Card
title="Recommendation setup"
intro="The prompt appears the next time this person opens Memby on any of their televisions."
icon="sparkle"
tone="note"
footer={
prompt.completed ? (
<Button
busy={busy === 'reset-rec'}
onClick={() => setPending({ kind: 'reset-recommendations' })}
>
Clear stored choices
</Button>
) : prompt.prompted ? (
<Button onClick={() => setPending({ kind: 'cancel-prompt' })}>Cancel prompt</Button>
) : (
<Button
variant="primary"
busy={busy === 'prompt'}
onClick={() =>
void act(
'prompt',
() => api.put(`${base}/recommendations/prompt`),
'Setup prompt queued.',
)
}
>
Send setup prompt
</Button>
)
}
>
<div className="row tight">
{prompt.completed ? (
<Tag tone="ok">completed</Tag>
) : prompt.prompted ? (
<Tag tone="warn">prompt queued</Tag>
) : (
<Tag>not invited</Tag>
)}
</div>
<RecommendationChips prompt={prompt} />
</Card>
</Grid>
<Card
title="Settings"
intro="These live on the server and follow the person, so a change here reaches every television they use — usually within a few seconds, and on the next launch for a set that is switched off."
icon="sliders"
tone="ok"
actions={
settings.saved ? (
<Tag tone={settings.source === 'admin' ? 'warn' : 'ok'}>
r{num(settings.revision)} · {settings.source || 'device'} · {when(settings.updatedAt)}
</Tag>
) : (
<Tag>defaults · never synced</Tag>
)
}
footer={
<>
<Button
variant="primary"
busy={busy === 'push'}
onClick={() =>
void act(
'push',
() => api.put(`${base}/preferences`, { preferences: prefs ?? {} }),
'Pushed to their televisions.',
// Cleared so the form is redrawn from what the server actually stored
// rather than from what was submitted.
() => setPrefs(null),
)
}
>
Push to their televisions
</Button>
<Button
onClick={() => {
setPrefs(null);
void reload();
}}
>
Discard changes
</Button>
<Button onClick={() => setPending({ kind: 'reset-preferences' })}>Restore defaults</Button>
<Link className="crumb" to={`/admin/accounts/${encodeURIComponent(userId)}/settings`}>
History and rollback
</Link>
</>
}
>
{areas.map((area) => (
<div className="group" key={area.name}>
<p className="group-label">{area.name}</p>
{area.definitions.map((definition) => (
<SettingControl
key={definition.key}
definition={definition}
value={prefs?.[definition.key]}
onChange={(next) => setPrefs((current) => ({ ...(current ?? {}), [definition.key]: next }))}
/>
))}
</div>
))}
</Card>
<Card
title="Colour schemes"
intro="Which palettes this person may choose between in Settings → Appearance. Tick everything to leave them unrestricted. Their current choice is an ordinary setting above; withdrawing it here puts them back on Midnight."
icon="sparkle"
tone="note"
actions={
(account.themes ?? []).length === 0 ? (
<Tag>all schemes</Tag>
) : (
<Tag tone="note">
{num((account.themes ?? []).length)} of {num(themeCatalogue.length)}
</Tag>
)
}
footer={
<>
<Button
variant="primary"
busy={busy === 'themes'}
onClick={() =>
(themes ?? []).length === 0
? setPending({ kind: 'no-themes' })
: void act(
'themes',
() => api.put(`${base}/themes`, { themes: themes ?? [] }),
'Colour schemes saved.',
// Cleared so the boxes are redrawn from what was stored, which is how
// "every box ticked" comes back as unrestricted rather than as a list.
() => setThemes(null),
)
}
>
Save colour schemes
</Button>
<Button onClick={() => setThemes(themeCatalogue.map((theme) => theme.id))}>Allow all</Button>
<span className="hint">
Seasonal themes are not listed. They apply to every television in the house for their
dates and nobody can decline one the only switch is <em>Seasonal themes</em> on the
features page.
</span>
</>
}
>
<div className="checks columns">
{themeCatalogue.map((theme) => (
<label className="check" key={theme.id}>
<input
type="checkbox"
checked={(themes ?? []).includes(theme.id)}
onChange={(event) =>
setThemes((current) =>
event.target.checked
? [...(current ?? []), theme.id]
: (current ?? []).filter((entry) => entry !== theme.id),
)
}
/>
<span className="switch" />
<span
className="swatch"
style={
{
'--swatch-surface': cssColour(theme.palette?.surface),
'--swatch-accent': cssColour(theme.palette?.accent),
'--swatch-hairline': cssColour(theme.palette?.hairline),
} as React.CSSProperties
}
>
<i />
</span>
<span className="check-body">
<b>{theme.name}</b>
<p>{theme.description}</p>
</span>
</label>
))}
</div>
</Card>
<Card
title="Remove Memby access"
intro="Signs every one of this person's Memby devices out. Their Emby account, viewing history and library permissions are untouched."
icon="alert"
tone="bad"
>
<Button variant="danger" onClick={() => setPending({ kind: 'remove-account' })}>
Remove Memby access
</Button>
</Card>
{renaming ? (
<RenameDialog
initial={renaming.name}
busy={busy === 'rename'}
onCancel={() => setRenaming(null)}
onConfirm={(name) =>
void act(
'rename',
() => api.put(`${base}/devices/${encodeURIComponent(renaming.id)}`, { deviceName: name }),
'Device renamed.',
() => setRenaming(null),
)
}
/>
) : null}
{pending ? (
<PendingDialog
pending={pending}
busy={busy}
username={account.username}
onCancel={() => setPending(null)}
onConfirm={() => {
switch (pending.kind) {
case 'remove-device':
return void act(
'remove-device',
() => api.del(`${base}/devices/${encodeURIComponent(pending.deviceId)}`),
'Device signed out.',
);
case 'remove-account':
return void act(
'remove-account',
() => api.del(`${base}/sessions`),
'Memby access removed.',
() => navigate('/admin/accounts'),
);
case 'reset-recommendations':
case 'cancel-prompt':
return void act(
'reset-rec',
() => api.del(`${base}/recommendations`),
'Recommendation choices cleared.',
);
case 'reset-preferences':
return void act(
'reset-prefs',
() => api.del(`${base}/preferences`),
'Defaults restored.',
() => setPrefs(null),
);
case 'no-themes':
return void act(
'themes',
() => api.put(`${base}/themes`, { themes: [] }),
'Colour schemes saved.',
() => setThemes(null),
);
}
}}
/>
) : null}
</>
);
}
function RecommendationChips({ prompt }: { prompt: RecommendationState }) {
const ratings = prompt.ratings ?? [];
const dimensions: [string, string[] | undefined][] = [
['Genres', prompt.genres],
['Studios', prompt.studios],
['Actors', prompt.actors],
['Actresses', prompt.actresses],
['Directors', prompt.directors],
['Types', prompt.contentTypes],
];
const chips: ReactNode[] = [
...ratings.map((rating) => (
<Chip key={`r:${rating.title}`} tone="warn">
{rating.title} · {num(rating.rating)}
</Chip>
)),
...dimensions.flatMap(([label, values]) =>
(values ?? []).map((value) => (
<Chip key={`${label}:${value}`}>
{label}: {value}
</Chip>
)),
),
];
if (chips.length === 0) return <Empty>No recommendation selections have been saved.</Empty>;
return <div className="chips">{chips}</div>;
}
function SettingControl({
definition,
value,
onChange,
}: {
definition: PreferenceDefinition;
value: unknown;
onChange: (next: unknown) => void;
}) {
if (definition.kind === 'toggle') {
return (
<Toggle
label={definition.name}
hint={definition.description}
checked={Boolean(value)}
onChange={onChange}
/>
);
}
if (definition.kind === 'choice' || definition.kind === 'number') {
// A number's unit comes from the catalogue. Assuming minutes was safe while the only
// number was a time budget, and wrong the moment a second one counted anything else.
const unit = definition.unit ?? '';
const options =
definition.kind === 'number'
? (definition.numbers ?? []).map((amount) => ({
value: String(amount),
label: amount === 0 ? 'No limit' : unit ? `${amount} ${unit}` : String(amount),
}))
: (definition.options ?? []);
return (
<Field label={definition.name} hint={definition.description}>
<select
value={String(value ?? '')}
onChange={(event) =>
onChange(definition.kind === 'number' ? Number(event.target.value) : event.target.value)
}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
);
}
if (definition.kind === 'multi') {
// Rendered in the viewer's own order, then whatever they have not selected. The order
// of these rows is the order the launcher draws them in, so preserving it matters as
// much as which are ticked.
const selected = Array.isArray(value) ? (value as string[]) : [];
const ordered = [
...selected,
...(definition.options ?? []).map((option) => option.value).filter((option) => !selected.includes(option)),
];
return (
<div className="field">
<span>{definition.name}</span>
<small>{definition.description}</small>
<div className="checks">
{ordered.map((option) => {
const match = (definition.options ?? []).find((entry) => entry.value === option);
if (!match) return null;
return (
<Toggle
key={option}
label={match.label}
checked={selected.includes(option)}
onChange={(on) =>
onChange(on ? [...selected, option] : selected.filter((entry) => entry !== option))
}
/>
);
})}
</div>
</div>
);
}
// A free-form list of server row ids: one per line, which is also how the television
// stores them. There is no vocabulary to offer, because these ids ship from the gateway
// without an app release.
const entries = Array.isArray(value) ? (value as string[]) : [];
return (
<Field label={definition.name} hint={definition.description}>
<textarea
spellCheck={false}
placeholder="One row id per line"
value={entries.join('\n')}
onChange={(event) =>
onChange(
event.target.value
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</Field>
);
}
function RenameDialog({
initial,
busy,
onConfirm,
onCancel,
}: {
initial: string;
busy: boolean;
onConfirm: (name: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial || 'Memby TV');
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true">
<h2>Name this device</h2>
<p>The name a viewer sees in Settings Devices, and what the console calls it.</p>
<Field label="Device name">
<input
type="text"
value={name}
autoFocus
maxLength={80}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<div className="dialog-actions">
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" busy={busy} disabled={!name.trim()} onClick={() => onConfirm(name.trim())}>
Rename
</Button>
</div>
</div>
</div>
);
}
function PendingDialog({
pending,
busy,
username,
onConfirm,
onCancel,
}: {
pending: Pending;
busy: string | null;
username: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const copy: Record<Pending['kind'], { title: string; body: string; label: string; destructive: boolean }> = {
'remove-device': {
title: 'Sign this device out of Memby?',
body: "Its Emby account will not be changed. The set can sign in again at any time.",
label: 'Sign out',
destructive: true,
},
'remove-account': {
title: `Remove Memby access for ${username || 'this user'}?`,
body: 'Every Memby device will be signed out. Their Emby account, viewing history and library permissions are untouched.',
label: 'Remove access',
destructive: true,
},
'reset-recommendations': {
title: "Clear this person's stored recommendation choices?",
body: 'Viewing history remains intact; only the explicit setup answers are removed.',
label: 'Clear',
destructive: true,
},
'cancel-prompt': {
title: "Cancel this person's queued recommendation prompt?",
body: 'They will not be invited to set up recommendations on their next launch.',
label: 'Cancel prompt',
destructive: false,
},
'reset-preferences': {
title: 'Restore the Memby defaults for this person?',
body: 'Their televisions will pick the change up the next time they check in.',
label: 'Restore defaults',
destructive: true,
},
'no-themes': {
title: 'Allow this person no colour schemes?',
body: 'They will be left on Midnight with nothing to choose between.',
label: 'Save anyway',
destructive: true,
},
};
const chosen = copy[pending.kind];
return (
<Confirm
title={chosen.title}
body={chosen.body}
confirmLabel={chosen.label}
destructive={chosen.destructive}
busy={Boolean(busy)}
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
import { initials, num, presence, recent, when } from '../lib/format';
import { Banner, Empty, Loading, Note, PageHead, Tag, Tiles } from '../components/ui';
import type { KnownClient } from '../api/types';
/* A directory, and only a directory. Everything you can *do* to a person lives on their own
page: this list used to render a full seventeen-control settings editor for every account
at once, which meant the page grew with the household and an operator scrolled past four
other people's preferences to reach the one they came for. */
interface Account {
id: string;
username: string;
lastSeen: string;
devices: KnownClient[] | null;
recommendations?: { prompted?: boolean; completed?: boolean };
}
interface AccountsResponse {
accounts: Account[] | null;
}
export function AccountsPage() {
const { data, error, loading } = useQuery<AccountsResponse>('/admin/api/accounts', {
pollMs: 60_000,
});
const accounts = data?.accounts ?? [];
const devices = accounts.flatMap((account) => account.devices ?? []);
const completed = accounts.filter((account) => account.recommendations?.completed).length;
const queued = accounts.filter(
(account) => account.recommendations?.prompted && !account.recommendations?.completed,
).length;
return (
<>
<PageHead title="Memby users" intro="Who uses Memby, and the devices they are signed in on." />
<Banner message={error} />
<Note tone="info">
This is the Memby user list, not the Emby user directory. A person appears here only after
signing in to the Memby app. Removing access signs their Memby devices out and does not delete
or change their Emby account.
</Note>
{loading ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ 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',
value: num(devices.filter((device) => recent(device.lastSeen)).length),
icon: 'pulse',
tone: 'ok',
},
{ label: 'recommendation setups completed', value: num(completed), icon: 'check', tone: 'ok' },
{ label: 'setup prompts queued', value: num(queued), icon: 'sparkle', tone: 'note' },
]}
/>
<section className="card flush">
{accounts.length === 0 ? (
<Empty>
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
</Empty>
) : (
accounts.map((account) => {
const list = account.devices ?? [];
const active = list.filter((device) => recent(device.lastSeen)).length;
const state = account.recommendations?.completed
? { label: 'personalised', tone: 'ok' as const }
: account.recommendations?.prompted
? { label: 'prompt queued', tone: 'warn' as const }
: { label: 'not invited', tone: undefined };
const seen = presence(account.lastSeen);
return (
<Link className="list-row" key={account.id} to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
<span className="list-main">
<span className="avatar">{initials(account.username)}</span>
<span>
<span className="list-title">
{account.username || 'Unnamed user'}
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
</span>
<span className="list-meta">
{num(list.length)} device{list.length === 1 ? '' : 's'}
{active ? ` · ${active} active now` : ''} · last seen {when(account.lastSeen)}
</span>
</span>
</span>
<span className="list-actions">
<Tag tone={state.tone}>{state.label}</Tag>
<span className="crumb">Manage</span>
</span>
</Link>
);
})
)}
</section>
</>
)}
</>
);
}
/** The account page reads the same list to find the person it is about. */
export type { Account, AccountsResponse };
+255
View File
@@ -0,0 +1,255 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { ago, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
EmptyRow,
Field,
Loading,
PageHead,
Segments,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import { Glyph } from '../components/Icon';
import {
eventIcon,
eventTone,
eventTypeLabel,
useNotifications,
type AdminEvent,
type EventTypeCount,
} from '../lib/notifications';
/* The activity feed in full: the bell's dropdown with filters and no twenty-row cap.
*
* It reads its own window from the server rather than the provider's cached list, because
* the provider holds only the most recent fifty — enough for a badge and a dropdown, not
* enough to answer "what happened on Tuesday". The badge and the "mark all read" button
* still come from the provider, so this page and the bell can never disagree about how
* much is unread. */
interface FeedResponse {
events: AdminEvent[];
total: number;
unread: number;
types: EventTypeCount[];
subscribers: number;
}
const WINDOWS = [
{ value: 1, label: 'Today' },
{ value: 7, label: '7 days' },
{ value: 30, label: '30 days' },
];
export function ActivityPage() {
const { unread, connected, markAllRead, reload: reloadBell } = useNotifications();
const [days, setDays] = useState(7);
const [type, setType] = useState('');
const [severity, setSeverity] = useState('');
const [unreadOnly, setUnreadOnly] = useState(false);
const [page, setPage] = useState(0);
const limit = 100;
const path = useMemo(
() =>
`/admin/api/notifications${query({
days,
type,
severity,
unread: unreadOnly,
limit,
offset: page * limit,
})}`,
[days, type, severity, unreadOnly, page],
);
const { data, error, loading, reload } = useQuery<FeedResponse>(path);
const markAll = async () => {
await markAllRead();
await reload();
};
return (
<>
<PageHead
title="Activity"
intro="Every administrative event the gateway has published: sign-ins, devices, scheduled tasks, integrations and the server itself. The same feed the bell and every integration read from."
actions={
unread > 0 ? (
<Button onClick={() => void markAll()} icon="check">
Mark all read
</Button>
) : undefined
}
/>
<Banner message={error} />
<Tiles
tiles={[
{ label: 'Events in window', value: num(data?.total ?? 0), icon: 'bell', tone: 'info' },
{ label: 'Unread', value: num(unread), icon: 'alert', tone: unread > 0 ? 'warn' : undefined },
{ label: 'Kinds seen', value: num(data?.types.length ?? 0), icon: 'list', tone: 'note' },
{
label: 'Live feed',
value: connected ? 'connected' : 'reconnecting',
small: true,
icon: 'pulse',
tone: connected ? 'ok' : 'warn',
},
]}
/>
<div className="filters">
<Field label="Window">
<Segments
value={days}
options={WINDOWS.map((w) => ({ value: w.value, label: w.label }))}
onChange={(next) => {
setDays(next);
setPage(0);
}}
/>
</Field>
<Field label="Kind">
{/* Built from what has actually been published, so the filter can neither offer a
kind that matches nothing nor miss one a service added after this shipped. */}
<select
value={type}
onChange={(event) => {
setType(event.target.value);
setPage(0);
}}
>
<option value="">Everything</option>
{(data?.types ?? []).map((entry) => (
<option key={entry.type} value={entry.type}>
{eventTypeLabel(entry.type)} ({entry.count})
</option>
))}
</select>
</Field>
<Field label="Severity">
<select
value={severity}
onChange={(event) => {
setSeverity(event.target.value);
setPage(0);
}}
>
<option value="">Any</option>
<option value="info">Information</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</Field>
<Field label="Read state">
<select
value={unreadOnly ? 'unread' : ''}
onChange={(event) => {
setUnreadOnly(event.target.value === 'unread');
setPage(0);
}}
>
<option value="">All</option>
<option value="unread">Unread only</option>
</select>
</Field>
<div className="filter-actions">
<Button
variant="quiet"
size="sm"
icon="refresh"
onClick={() => {
void reload();
void reloadBell();
}}
>
Refresh
</Button>
</div>
</div>
{loading ? (
<Loading />
) : (
<Card
title="Events"
icon="bell"
tone="info"
footer={
(data?.total ?? 0) > limit ? (
<>
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Newer
</Button>
<Button
size="sm"
disabled={(page + 1) * limit >= (data?.total ?? 0)}
onClick={() => setPage(page + 1)}
>
Older
</Button>
</>
) : undefined
}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">When</th>
<th>Kind</th>
<th>What happened</th>
<th>Who</th>
<th>What</th>
<th />
</tr>
</thead>
<tbody>
{(data?.events.length ?? 0) === 0 ? (
<EmptyRow columns={6}>Nothing has happened in this window.</EmptyRow>
) : (
data?.events.map((event) => (
<tr key={event.id}>
<td className="nowrap muted" title={when(event.occurredAt)}>
{ago(event.occurredAt)}
</td>
<td className="nowrap">
<span className="row tight">
<Glyph name={eventIcon(event.type)} tone={eventTone(event)} />
{eventTypeLabel(event.type)}
</span>
</td>
<td>
<b>{event.title}</b>
{event.summary ? <div className="muted">{event.summary}</div> : null}
</td>
<td className="muted nowrap">{event.actor || '—'}</td>
<td className="muted nowrap">{event.target || '—'}</td>
<td className="nowrap">
{!event.readAt ? <Tag tone="ok">new</Tag> : null}
{event.link ? (
<Link className="table-row-link" to={event.link}>
Open
</Link>
) : null}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
)}
</>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { Link } from 'react-router-dom';
import { useGateway } from '../lib/gateway';
import { num, presence, recent, when } from '../lib/format';
import {
Banner,
Card,
Chip,
EmptyRow,
Loading,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import type { KnownClient } from '../api/types';
/* Every build this set has been seen running, newest first, with the one it is on now in
accent. The current version is already its own column; this is the column that says
whether a row is one television with a history or one of several rows a single set left
behind, which is the question duplicates used to make unanswerable. */
function VersionHistory({ client }: { client: KnownClient }) {
const versions = client.versions ?? [];
if (versions.length === 0) return <span className="muted"></span>;
return (
<span className="versions">
{versions.map((entry) => (
<Chip key={entry.version} tone={entry.version === client.version ? 'ok' : undefined}>
{entry.version}
</Chip>
))}
</span>
);
}
export function ClientsPage() {
const { status, error, loading } = useGateway();
const clients = status?.clients ?? [];
const capable = clients.filter((client) => (client.capabilities ?? []).includes('server_features_v1'));
const versions = new Set(clients.map((client) => client.version).filter(Boolean));
return (
<>
<PageHead
title="Devices"
intro="Which sets have reported in, what they are running and what their build understands."
/>
<Banner message={error} />
{loading ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'devices known', value: num(clients.length), icon: 'tv', tone: 'info' },
{
label: 'active in the last quarter hour',
value: num(clients.filter((client) => recent(client.lastSeen)).length),
icon: 'pulse',
tone: 'ok',
},
{ label: 'reporting their capabilities', value: num(capable.length), icon: 'sliders', tone: 'ok' },
{ label: 'app builds in service', value: num(versions.size), icon: 'download', tone: 'note' },
]}
/>
<Card
title="Devices"
intro="Every request carries what that build understands. A feature is only presented to a device that declares its contract, which is what lets an older set keep working while a new one gets the new behaviour. Status is whether the set is reporting that list at all; a build old enough to say nothing is served the fallback."
icon="tv"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Device</th>
<th>Person</th>
<th>App</th>
<th>Builds seen</th>
<th>Status</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{clients.length === 0 ? (
<EmptyRow columns={6}>No devices have signed in yet.</EmptyRow>
) : (
clients.map((client) => {
const declares = (client.capabilities ?? []).includes('server_features_v1');
const seen = presence(client.lastSeen);
return (
<tr key={`${client.deviceId}:${client.username}`}>
<td>
<span className="row tight">
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
{/* Through to the sign-in history for this set: "what is it
running" and "when does it actually connect" are the two
halves of the same question. */}
<Link
className="table-row-link"
to={`/admin/devices/${encodeURIComponent(client.deviceId)}`}
>
{client.deviceName || 'Memby TV'}
</Link>
</span>
</td>
<td className="muted">{client.username}</td>
<td className="mono">{client.version || 'legacy'}</td>
<td>
<VersionHistory client={client} />
</td>
<td>
<Tag tone={declares ? 'ok' : 'warn'}>{declares ? 'reported' : 'missing'}</Tag>
</td>
<td className="nowrap muted">{when(client.lastSeen)}</td>
</tr>
);
})
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}
+239
View File
@@ -0,0 +1,239 @@
import { useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { num, when } from '../lib/format';
import {
Banner,
Bars,
Card,
Chip,
Empty,
EmptyRow,
Grid,
Loading,
PageHead,
Segments,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import type { DeviceDetailResponse } from '../api/types';
/* One television.
*
* The page exists to answer a question that was previously unanswerable: "how many times
* did this set connect today, at what times, and from which addresses". Everything on it
* is in service of that sentence — the headline counts are over the device's whole
* history so they describe the television, and only the table and the chart move with the
* window control, so narrowing to today does not make the totals look like the set has
* only ever connected twice. */
const WINDOWS = [
{ value: 1, label: 'Today' },
{ value: 7, label: '7 days' },
{ value: 30, label: '30 days' },
{ value: 0, label: 'All' },
];
export function DevicePage() {
const { deviceId = '' } = useParams();
const [days, setDays] = useState(7);
const path = useMemo(
() =>
`/admin/api/logins/devices/${encodeURIComponent(deviceId)}${query({
days: days || undefined,
limit: 200,
})}`,
[deviceId, days],
);
const { data, error, loading } = useQuery<DeviceDetailResponse>(path, { enabled: Boolean(deviceId) });
const summary = data?.summary;
const name = summary?.deviceName || deviceId;
return (
<>
<PageHead
title={name}
intro="One television's whole relationship with the gateway."
crumbs={
<>
<Link to="/admin/clients">Devices</Link>
<span>/</span>
<Link to="/admin/logins">Sign-ins</Link>
<span>/</span>
<span>{name}</span>
</>
}
actions={
<Segments value={days} options={WINDOWS.map((w) => ({ value: w.value, label: w.label }))} onChange={setDays} />
}
/>
<Banner message={error} />
{loading ? (
<Loading />
) : !data ? null : (
<>
<Tiles
tiles={[
{ label: 'Sign-ins today', value: num(summary?.loginsToday ?? 0), icon: 'clock', tone: 'ok' },
{ label: 'Sign-ins in total', value: num(summary?.logins ?? 0), icon: 'key', tone: 'info' },
{
label: 'Refused',
value: num(summary?.failures ?? 0),
icon: 'shield',
tone: (summary?.failures ?? 0) > 0 ? 'warn' : undefined,
},
{ label: 'Addresses seen', value: num(summary?.distinctIps ?? 0), icon: 'globe', tone: 'data' },
{
label: 'First seen',
value: summary?.firstLogin ? when(summary.firstLogin) : '—',
small: true,
icon: 'history',
},
{
label: 'Last seen',
value: summary?.lastLogin ? when(summary.lastLogin) : '—',
small: true,
icon: 'pulse',
tone: 'note',
},
]}
/>
<Grid cols="wide">
<Card title="Connections per day" icon="chart" tone="info">
<Bars
data={data.days}
labelOf={(row: (typeof data.days)[number]) => row.day}
valueOf={(row: (typeof data.days)[number]) => row.logins + row.failures}
toneOf={(row: (typeof data.days)[number]) => (row.failures > row.logins ? 'bad' : undefined)}
title={(row: (typeof data.days)[number]) =>
`${row.day}: ${row.logins} in${row.failures ? `, ${row.failures} refused` : ''}`
}
/>
</Card>
<div className="stack">
<Card title="Identity" icon="tv" tone="info">
<div className="list">
<div className="list-item">
<div className="list-body">
<b>Person</b>
<p>{summary?.username || 'unknown'}</p>
</div>
</div>
<div className="list-item">
<div className="list-body">
<b>Device id</b>
<p className="mono">{data.deviceId}</p>
</div>
</div>
<div className="list-item">
<div className="list-body">
<b>Running</b>
<p className="mono">{summary?.clientVersion || 'unknown'}</p>
</div>
</div>
</div>
</Card>
<Card
title="Builds"
intro="Kept per television rather than per session, so it survives a sign-out."
icon="upload"
tone="note"
>
{data.versions.length === 0 ? (
<Empty>No build history for this television.</Empty>
) : (
<div className="list">
{data.versions.map((version) => (
<div className="list-item" key={version.version}>
<div className="list-body">
<b className="mono">{version.version}</b>
<p>
{when(version.firstSeen)} {when(version.lastSeen)}
</p>
</div>
</div>
))}
</div>
)}
</Card>
</div>
</Grid>
<Card title="Addresses" icon="globe" tone="data">
{data.addresses.length === 0 ? (
<Empty>No addresses recorded in this window.</Empty>
) : (
<div className="chips">
{data.addresses.map((address) => (
<Chip key={address.ipAddress} tone={address.failures > 0 ? 'warn' : 'data'}>
{address.ipAddress} · {num(address.logins)}
{address.failures > 0 ? ` (+${num(address.failures)} refused)` : ''}
</Chip>
))}
</div>
)}
</Card>
<Card
title="Every attempt"
icon="key"
tone="ok"
actions={<span className="filter-summary">{num(data.total)} in this window</span>}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">When</th>
<th>Person</th>
<th className="nowrap">Address</th>
<th>Build</th>
<th>Method</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
{data.events.length === 0 ? (
<EmptyRow columns={6}>
This television has not connected in the selected window.
</EmptyRow>
) : (
data.events.map((event) => (
<tr key={event.id}>
<td className="nowrap muted">{when(event.occurredAt)}</td>
<td>{event.username || <span className="quiet">unknown</span>}</td>
<td className="mono nowrap">{event.ipAddress || '—'}</td>
<td className="mono">{event.clientVersion || '—'}</td>
<td className="muted">{event.method}</td>
<td className="nowrap">
{event.success ? (
event.newDevice ? (
<Tag tone="info">first sign-in</Tag>
) : (
<Tag tone="ok">got in</Tag>
)
) : (
<Tag tone="bad">{event.failureReason || 'refused'}</Tag>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { useQuery } from '../lib/hooks';
import { duration, num, percent } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Field,
Loading,
PageHead,
TableWrap,
} from '../components/ui';
interface RowStat {
rowId: string;
rowKind: string;
impressions: number;
focuses: number;
selects: number;
selectRate: number;
dwellMs: number;
viewers: number;
}
/** The row ids are wire values; this is the render, so it is spelled. `favorites` stays
* `favorites` on both sides of the wire and reads "Favourites" on screen — the same
* boundary the launcher's own row title observes. */
function label(value: string | undefined): string {
const text = String(value || '—').replaceAll('_', ' ');
if (text === 'favorites') return 'Favourites';
if (text === 'abandoned') return 'Abandoned / interrupted';
return text;
}
export function EngagementPage() {
const [days, setDays] = useState(30);
const { data, error, loading } = useQuery<{ rows: RowStat[] | null }>(
`/admin/api/analytics?days=${days}`,
);
const rows = data?.rows ?? [];
return (
<>
<PageHead
title="Row engagement"
intro="Impressions, focus, dwell and selections per launcher row."
/>
<Banner message={error} />
<Card
title="Launcher rows"
intro="Impressions are rows drawn, focuses are rows the D-pad reached, and dwell is how long it stayed there. Open rate is what a row was worth."
icon="chart"
tone="info"
actions={
<Field label="Window">
<select value={days} onChange={(event) => setDays(Number(event.target.value))}>
<option value={1}>24 hours</option>
<option value={7}>7 days</option>
<option value={30}>30 days</option>
<option value={90}>90 days</option>
</select>
</Field>
}
>
{loading ? (
<Loading rows={1} />
) : (
<TableWrap>
<table>
<thead>
<tr>
<th>Row</th>
<th>Kind</th>
<th className="num">Dwell</th>
<th className="num">Impressions</th>
<th className="num">Focuses</th>
<th className="num">Opened</th>
<th className="num">Open rate</th>
<th className="num">Viewers</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyRow columns={8}>No events in this window.</EmptyRow>
) : (
rows.map((row) => (
<tr key={`${row.rowId}:${row.rowKind}`}>
<td>{label(row.rowId)}</td>
<td className="muted">{label(row.rowKind)}</td>
<td className="num">{duration(row.dwellMs)}</td>
<td className="num">{num(row.impressions)}</td>
<td className="num">{num(row.focuses)}</td>
<td className="num">{num(row.selects)}</td>
<td className="num">{percent(row.selectRate)}</td>
<td className="num">{num(row.viewers)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
)}
</Card>
</>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num } from '../lib/format';
import {
Banner,
Button,
Card,
Chip,
Confirm,
Empty,
Field,
Grid,
Loading,
PageHead,
PlainTiles,
Tag,
} from '../components/ui';
type Mode = 'default' | 'on' | 'off';
interface Pending {
action: 'safe-mode' | 'rollback' | 'reset';
title: string;
body: string;
label: string;
}
export function FeaturesPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
// The choices the operator has made but not published. Held apart from the server's
// answer so a poll landing mid-edit cannot take a half-made decision away — the rule the
// previous console needed `Admin.settled` for.
const [draft, setDraft] = useState<Record<string, Mode>>({});
const [pending, setPending] = useState<Pending | null>(null);
const policy = status?.features;
const features = policy?.features ?? [];
const clients = status?.clients ?? [];
const revision = policy?.revision ?? 0;
useEffect(() => {
// Adopt the server's state only where the operator has not expressed a preference.
if (!policy) return;
setDraft((current) => {
const next = { ...current };
for (const feature of policy.features ?? []) {
if (next[feature.key] === undefined) {
next[feature.key] = feature.source === 'override' ? (feature.enabled ? 'on' : 'off') : 'default';
}
}
return next;
});
}, [policy]);
const act = (action: string, key: string, message: string, overrides?: Record<string, boolean>) =>
run(key, async () => {
await wrap(
() =>
api.post('/admin/api/features', {
action,
// Carried on every mutation, so two operators working at once cannot silently
// overwrite each other's publish.
expectedRevision: revision,
overrides: overrides ?? {},
}),
message,
);
setPending(null);
if (action === 'save') {
// Published: the draft is now the server's state, so stop holding it.
setDraft({});
}
await reload();
});
const publish = () => {
const overrides: Record<string, boolean> = {};
for (const [key, mode] of Object.entries(draft)) {
if (mode === 'on') overrides[key] = true;
if (mode === 'off') overrides[key] = false;
}
return act('save', 'save', 'Published.', overrides);
};
const capable = clients.filter((client) => (client.capabilities ?? []).includes('server_features_v1')).length;
const safeMode = Boolean(policy?.safeMode);
return (
<>
<PageHead
title="Features"
intro="Roll out, stop and recover optional behaviour with no app release."
/>
<Banner message={error} />
{loading || !policy ? (
<Loading />
) : (
<>
<Card
title="Control plane"
intro="Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional."
icon="sliders"
tone="ok"
actions={
<Button
variant={safeMode ? undefined : 'danger'}
busy={busy === 'safe'}
onClick={() =>
safeMode
? void act('leave-safe-mode', 'safe', 'Safe mode ended.')
: setPending({
action: 'safe-mode',
title: 'Enable safe mode?',
body:
'Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.',
label: 'Enable safe mode',
})
}
>
{safeMode ? 'Leave safe mode' : 'Enable safe mode'}
</Button>
}
>
<PlainTiles
tiles={[
{
label: 'features active',
value: `${features.filter((feature) => feature.enabled).length} / ${features.length}`,
},
{
label: 'explicit overrides',
value: num(features.filter((feature) => feature.source === 'override').length),
},
{
label: 'televisions reporting the control plane',
value: `${capable} / ${clients.length}`,
},
{ label: 'published revision', value: `r${num(revision)}` },
]}
/>
</Card>
<Grid cols="2">
{features.length === 0 ? (
<Card title="Nothing registered" icon="sliders">
<Empty>No server features are registered.</Empty>
</Card>
) : (
features.map((feature) => (
<Card
key={feature.key}
title={feature.name}
intro={feature.description}
actions={<Tag tone={feature.enabled ? 'ok' : undefined}>{feature.enabled ? 'active' : 'off'}</Tag>}
footer={<span className="hint"> {feature.recovery}</span>}
>
<Field label="Mode">
<select
value={draft[feature.key] ?? 'default'}
onChange={(event) =>
setDraft((current) => ({ ...current, [feature.key]: event.target.value as Mode }))
}
>
<option value="default">Safe default</option>
<option value="on">Forced on</option>
<option value="off">Forced off</option>
</select>
</Field>
<div className="chips">
<Chip>{feature.key}</Chip>
<Chip>protocol {num(feature.minimumProtocol)}+</Chip>
<Chip tone={feature.compatible ? 'ok' : 'warn'}>
{feature.compatible ? 'server compatible' : 'compatibility blocked'}
</Chip>
<Chip tone="note">{feature.area}</Chip>
</div>
</Card>
))
)}
</Grid>
<Card>
<div className="row">
<Button variant="primary" busy={busy === 'save'} onClick={() => void publish()}>
Publish changes
</Button>
<Button
disabled={!policy.canRollback}
onClick={() =>
setPending({
action: 'rollback',
title: 'Roll back one revision?',
body: 'The previous published feature revision is restored on every television.',
label: 'Roll back',
})
}
>
Roll back one revision
</Button>
<Button
onClick={() =>
setPending({
action: 'reset',
title: 'Clear every override?',
body: 'All features return to their safe software defaults.',
label: 'Clear overrides',
})
}
>
Clear all overrides
</Button>
<span className="spacer" />
{safeMode ? (
<Tag tone="warn">safe mode · optional features off</Tag>
) : (
<Tag tone="ok">live · revision r{num(revision)}</Tag>
)}
</div>
</Card>
</>
)}
{pending ? (
<Confirm
title={pending.title}
body={pending.body}
confirmLabel={pending.label}
destructive={pending.action !== 'rollback'}
busy={busy === pending.action}
onConfirm={() => void act(pending.action, pending.action, `${pending.label} done.`)}
onCancel={() => setPending(null)}
/>
) : null}
</>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Empty, Field, Grid, Loading, PageHead } from '../components/ui';
import type { HeroItem, HeroSchedule } from '../api/types';
const MAX_PINS = 4;
export function HeroPage() {
const { status, error, loading, reload } = useGateway();
const { wrap, show } = useToast();
const { busy, run } = useAction();
const [pins, setPins] = useState<HeroItem[]>([]);
const [subtitle, setSubtitle] = useState('');
const [dirty, setDirty] = useState(false);
const [queryText, setQueryText] = useState('');
const [results, setResults] = useState<HeroItem[] | null>(null);
const [schedules, setSchedules] = useState<HeroSchedule[]>([]);
const policy = status?.heroPolicy;
useEffect(() => {
// The poll must not take an unsaved arrangement away, which is what `dirty` guards.
if (dirty || !policy) return;
setPins((policy.pinnedItems ?? []).slice(0, MAX_PINS));
setSubtitle(policy.primeSubtitle ?? '');
setSchedules(policy.schedules ?? []);
}, [policy, dirty]);
const search = () =>
run('search', async () => {
const needle = queryText.trim();
if (!needle) return;
const payload = await wrap(() =>
api.get<{ items: HeroItem[] | null }>(`/admin/api/hero/search?q=${encodeURIComponent(needle)}`),
);
if (payload) setResults(payload.items ?? []);
});
const add = (item: HeroItem) => {
if (pins.some((pin) => pin.id === item.id)) return;
if (pins.length >= MAX_PINS) {
show('Remove a pinned title before adding another.', 'bad');
return;
}
setPins((current) => [...current, item]);
setDirty(true);
};
const save = () =>
run('save', async () => {
await wrap(
() =>
api.post('/admin/api/hero-policy', {
pinnedItemIds: pins.map((item) => item.id),
primeSubtitle: subtitle.trim(),
schedules,
}),
'Hero saved.',
);
setDirty(false);
await reload();
});
return (
<>
<PageHead
title="Home hero"
intro="Choose films or television shows for the launcher spotlight while recent releases fill the remaining places."
/>
<Banner message={error} />
{loading ? (
<Loading rows={2} />
) : (
<>
<Card
title="Pinned titles"
intro="Pinned films and series lead the four-card launcher grid in this order. Empty places are filled by Memby's existing mix of recent digital releases, premieres and highly rated library titles. Pinning changes placement only; labels and reasons remain natural."
icon="star"
tone="note"
footer={
<>
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save hero
</Button>
<Button
onClick={() => {
setPins([]);
setDirty(true);
}}
>
Clear pins
</Button>
{dirty ? <span className="hint">Unsaved changes.</span> : null}
</>
}
>
{pins.length === 0 ? (
<Empty>No titles are pinned. The hero is entirely release-aware and automatic.</Empty>
) : (
<div className="chips">
{pins.map((item, index) => (
<Button
key={item.id}
variant="quiet"
size="sm"
icon="close"
onClick={() => {
setPins((current) => current.filter((pin) => pin.id !== item.id));
setDirty(true);
}}
>
{index + 1}. {item.name}
{item.year ? ` (${item.year})` : ''}
</Button>
))}
</div>
)}
<Field
label="Prime-card subtitle"
hint="Optional wording under the large first card. Leave blank to use Memby's natural release or rating reason."
>
<input
type="text"
maxLength={160}
value={subtitle}
placeholder="Leave blank for the automatic reason"
onChange={(event) => {
setSubtitle(event.target.value);
setDirty(true);
}}
/>
</Field>
</Card>
<Card title="Scheduled heroes" intro="Schedules are resolved by the gateway: manual pins still win, then the highest-priority eligible schedule, then Membys automatic hero." icon="clock" tone="info">
{schedules.length === 0 ? <Empty>No scheduled heroes yet.</Empty> : (
<div className="stack">{schedules.map((schedule) => {
const item = [...pins, ...(results ?? [])].find((candidate) => candidate.id === schedule.itemId);
return <div className="row" key={schedule.id}><b>{item?.name ?? schedule.itemId}</b><span className="muted">{new Date(schedule.startAt).toLocaleString()} {new Date(schedule.endAt).toLocaleString()} · priority {schedule.priority}</span><Button size="sm" variant="quiet" onClick={() => { setSchedules((current) => current.filter((entry) => entry.id !== schedule.id)); setDirty(true); }}>Remove</Button></div>;
})}</div>
)}
{pins.length > 0 ? <Button size="sm" icon="plus" onClick={() => {
const first = pins[0]; if (!first) return;
const start = new Date(); const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
setSchedules((current) => [...current, { id: crypto.randomUUID(), itemId: first.id, startAt: start.toISOString(), endAt: end.toISOString(), priority: 0, enabled: true }]); setDirty(true);
}}>Schedule first pinned title for two hours</Button> : <p className="hint">Pin or search for a title first, then add it to a schedule.</p>}
</Card>
<Card
title="Find a title"
intro="Search the imported Emby catalogue. Up to four films or series can be pinned."
icon="search"
tone="info"
>
<div className="field-row">
<Field label="Title" grow>
<input
type="search"
value={queryText}
placeholder="Search films and television shows"
onChange={(event) => setQueryText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void search();
}}
/>
</Field>
<Button busy={busy === 'search'} icon="search" onClick={() => void search()}>
Search
</Button>
</div>
{results === null ? null : results.length === 0 ? (
<Empty>No playable films or series matched that search.</Empty>
) : (
<Grid>
{results.map((item) => (
<Card key={item.id} title={item.name} intro={`${item.type || 'Title'} · ${item.year || 'Year unknown'}`}>
<Button
size="sm"
icon="plus"
disabled={pins.some((pin) => pin.id === item.id)}
onClick={() => add(item)}
>
{pins.some((pin) => pin.id === item.id) ? 'Pinned' : 'Add to hero'}
</Button>
</Card>
))}
</Grid>
)}
</Card>
</>
)}
</>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useGateway } from '../lib/gateway';
import { num, when } from '../lib/format';
import { Banner, Card, EmptyRow, Loading, PageHead, TableWrap, Tag } from '../components/ui';
export function ImportsPage() {
const { status, error, loading } = useGateway();
const runs = status?.runs ?? [];
return (
<>
<PageHead title="Imports" intro="Catalogue synchronisation history." />
<Banner message={error} />
{loading ? (
<Loading rows={1} />
) : (
<Card
title="Synchronisation history"
intro="A full import mark-and-sweeps the catalogue; an incremental one asks Emby for what changed, with a minute of overlap so nothing falls between two runs."
icon="sync"
tone="data"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Started</th>
<th>Kind</th>
<th>Trigger</th>
<th>Status</th>
<th className="num">Seen</th>
<th className="num">Written</th>
<th className="num">Removed</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{runs.length === 0 ? (
<EmptyRow columns={8}>Nothing has been imported yet.</EmptyRow>
) : (
runs.map((run) => (
<tr key={run.id || run.startedAt}>
<td className="nowrap muted">{when(run.startedAt)}</td>
<td>{run.kind}</td>
<td className="muted">{run.trigger}</td>
<td>
<Tag tone={run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad'}>
{run.status}
</Tag>
</td>
<td className="num">{num(run.itemsSeen)}</td>
<td className="num">{num(run.itemsUpserted)}</td>
<td className="num">{num(run.itemsRemoved)}</td>
<td className="muted">{run.error || ''}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
)}
</>
);
}
+298
View File
@@ -0,0 +1,298 @@
import { useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Chip, Empty, Field, Grid, PageHead, Tiles } from '../components/ui';
/* Nothing here changes anything. It re-runs the shared weighted scorer over one person's
prepared pool, after Emby permission and parental-control filtering, and shows every
component and evidence reason behind the order. */
interface Weighted {
weight?: number;
evidence?: number;
}
interface Profile {
genres?: Record<string, Weighted>;
studios?: Record<string, Weighted>;
actors?: Record<string, Weighted>;
directors?: Record<string, Weighted>;
franchises?: Record<string, Weighted>;
runtimeRanges?: Record<string, Weighted>;
ageRatings?: Record<string, Weighted>;
communityRatings?: Record<string, Weighted>;
releasePeriods?: Record<string, Weighted>;
contentTypes?: Record<string, Weighted>;
}
interface InspectorItem {
title: string;
type?: string;
year?: number;
runtimeMinutes?: number;
genres?: string[];
baseRank?: number;
baseScore?: number;
affinityScore?: number;
compatibilityScore?: number;
compatibilityLabel?: string;
preparedReason?: string;
preparedEvidenceTitle?: string;
eligibleRows?: string[];
exposure?: { impressions?: number; focuses?: number; selects?: number };
explanation?: { total?: number; components?: Record<string, number>; reasonCodes?: string[] };
}
interface InspectorResponse {
poolCandidates: number;
permissionEligible: number;
items: InspectorItem[] | null;
profile: Profile | null;
actions: { action: string; title?: string; itemId?: string }[] | null;
profileMeta?: { sourceEvents?: number; algorithmVersion?: string; poolBuiltAt?: string };
}
const DIMENSIONS: [string, keyof Profile][] = [
['Genre', 'genres'],
['Studio', 'studios'],
['Actor', 'actors'],
['Director', 'directors'],
['Franchise', 'franchises'],
['Runtime', 'runtimeRanges'],
['Age rating', 'ageRatings'],
['Community rating', 'communityRatings'],
['Release period', 'releasePeriods'],
['Content type', 'contentTypes'],
];
function affinities(profile: Profile | null) {
if (!profile) return [];
return DIMENSIONS.flatMap(([dimension, key]) =>
Object.entries(profile[key] ?? {}).map(([name, value]) => ({
dimension,
name,
weight: value.weight ?? 0,
evidence: value.evidence ?? 0,
})),
).sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight));
}
const signed = (value: number) => `${value >= 0 ? '+' : ''}${value.toFixed(3)}`;
export function InspectorPage() {
const { status } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [userId, setUserId] = useState('');
const [context, setContext] = useState('default');
const [minutes, setMinutes] = useState('0');
const [at, setAt] = useState('');
const [result, setResult] = useState<InspectorResponse | null>(null);
const [hint, setHint] = useState('Choose a person to inspect their recommendations.');
const [error, setError] = useState('');
const users = status?.requestUsers ?? [];
const runTest = () =>
run('run', async () => {
if (!userId) {
setError('Choose a person to pressure-test.');
return;
}
setError('');
setHint('Running the permission check and the scorer…');
const params = new URLSearchParams({ userId, context, minutes: minutes || '0', limit: '100' });
if (at) params.set('at', new Date(at).toISOString());
const payload = await wrap(() =>
api.get<InspectorResponse>(`/admin/api/recommendations?${params.toString()}`),
);
if (payload) {
setResult(payload);
setHint(`Scored at ${new Date().toLocaleTimeString()}.`);
} else {
setHint('Pressure test failed.');
}
});
const top = affinities(result?.profile ?? null).slice(0, 24);
const actions = result?.actions ?? [];
const items = result?.items ?? [];
const meta = result?.profileMeta ?? {};
return (
<>
<PageHead
title="Score inspector"
intro="Re-run the ranker for one person and read every component."
/>
<Banner message={error} />
<Card
title="Run a pressure test"
intro="Nothing is changed by running this. It scores the person's prepared pool as the launcher would, in the context you choose."
icon="search"
tone="info"
footer={
<>
<Button variant="primary" busy={busy === 'run'} onClick={() => void runTest()}>
Run pressure test
</Button>
<span className="hint">{hint}</span>
</>
}
>
<div className="fields">
<Field label="Person">
<select value={userId} onChange={(event) => setUserId(event.target.value)}>
<option value="">Choose a person</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
</Field>
<Field label="Context">
<select value={context} onChange={(event) => setContext(event.target.value)}>
<option value="default">Default</option>
<option value="bedtime">One episode before bed</option>
<option value="hidden">Hidden library</option>
<option value="new-releases">Recent new releases</option>
</select>
</Field>
<Field label="Available minutes">
<input
type="number"
min={0}
max={360}
value={minutes}
onChange={(event) => setMinutes(event.target.value)}
/>
</Field>
<Field label="Evaluate at">
<input type="datetime-local" value={at} onChange={(event) => setAt(event.target.value)} />
</Field>
</div>
</Card>
{result ? (
<>
<Tiles
tiles={[
{ label: 'prepared pool', value: num(result.poolCandidates), icon: 'database', tone: 'data' },
{ label: 'permission eligible', value: num(result.permissionEligible), icon: 'shield', tone: 'ok' },
{ label: 'ranked result', value: num(items.length), icon: 'sparkle', tone: 'note' },
{ label: 'source events', value: num(meta.sourceEvents ?? 0), icon: 'pulse', tone: 'info' },
{ label: 'algorithm', value: meta.algorithmVersion || '—', small: true, icon: 'chip' },
{ label: 'pool built', value: when(meta.poolBuiltAt), small: true, icon: 'clock' },
]}
/>
<Card
title="Profile evidence"
intro="The strongest learned affinities, and every explicit action this person has taken."
icon="sparkle"
tone="note"
>
{top.length === 0 ? (
<Empty>No repeated affinity evidence yet; cold-start priors apply.</Empty>
) : (
<div className="chips">
{top.map((entry) => (
<Chip key={`${entry.dimension}:${entry.name}`} tone={entry.weight < 0 ? 'bad' : undefined}>
{entry.dimension}: {entry.name} {signed(entry.weight)} · n={num(entry.evidence)}
</Chip>
))}
</div>
)}
{actions.length === 0 ? (
<Empty>No explicit recommendation actions.</Empty>
) : (
<div className="chips">
{actions.map((action, index) => (
<Chip key={`${action.action}:${index}`} tone="ok">
{action.action}: {action.title || action.itemId}
</Chip>
))}
</div>
)}
</Card>
{items.length === 0 ? (
<Card>
<Empty>
No candidates survived this context, the explicit exclusions and the permission filter.
</Empty>
</Card>
) : (
<Grid>
{items.map((item, index) => {
const explanation = item.explanation ?? {};
const components = Object.entries(explanation.components ?? {}).sort(
(a, b) => Math.abs(b[1]) - Math.abs(a[1]),
);
const exposure = item.exposure ?? {};
const facts = [
item.type,
item.year,
item.runtimeMinutes ? `${item.runtimeMinutes} min` : null,
...(item.genres ?? []),
]
.filter(Boolean)
.join(' · ');
return (
<Card
key={`${index}:${item.title}`}
title={`#${index + 1} · ${item.title}`}
intro={facts}
actions={<Chip tone="ok">{Number(explanation.total ?? 0).toFixed(3)}</Chip>}
>
<p className="hint">
{item.preparedReason || 'No legacy prepared explanation'}
{item.compatibilityLabel ? ` · ${item.compatibilityLabel}` : ''}
</p>
<div className="chips">
{(explanation.reasonCodes ?? []).map((code) => (
<Chip key={code} tone="ok">
{code}
</Chip>
))}
{components.map(([name, value]) => (
<Chip key={name} tone={value < 0 ? 'bad' : undefined}>
{name}={signed(value)}
</Chip>
))}
</div>
<details>
<summary className="muted">Pool, row and exposure detail</summary>
<p className="hint">
Base rank {num(item.baseRank ?? 0)} · base {Number(item.baseScore ?? 0).toFixed(3)} ·
affinity {Number(item.affinityScore ?? 0).toFixed(3)} · compatibility{' '}
{Number(item.compatibilityScore ?? 0).toFixed(3)} · impressions{' '}
{num(exposure.impressions ?? 0)} · focuses {num(exposure.focuses ?? 0)} · selects{' '}
{num(exposure.selects ?? 0)}
</p>
<div className="chips">
{(item.eligibleRows ?? []).map((row) => (
<Chip key={row} tone="ok">
{row}
</Chip>
))}
</div>
{item.preparedEvidenceTitle ? (
<p className="hint">Prepared evidence: {item.preparedEvidenceTitle}</p>
) : null}
</details>
</Card>
);
})}
</Grid>
)}
</>
) : null}
</>
);
}
+398
View File
@@ -0,0 +1,398 @@
import { useState } from 'react';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { ago, duration, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
Confirm,
Empty,
EmptyRow,
Field,
Loading,
Note,
PageHead,
TableWrap,
Tag,
Toggle,
} from '../components/ui';
import type { Integration, IntegrationEventOption, IntegrationsResponse } from '../api/types';
/* Integrations: administrative events going out to somewhere else.
*
* The whole page is written around one property of the backend, and it is worth stating
* because the form would otherwise look careless: the webhook address is never returned.
* It is the credential — anybody holding it can post into the channel — so the gateway
* sends back only whether one is set and the channel id from the middle of it. That is why
* the address field on an existing integration is blank with a placeholder saying so, and
* why saving with it blank leaves the stored one alone. */
interface Draft {
id: string;
name: string;
url: string;
enabled: boolean;
events: string[];
}
const NEW_DRAFT: Draft = { id: '', name: 'Discord', url: '', enabled: true, events: [] };
export function IntegrationsPage() {
const { wrap, show } = useToast();
const { busy, run } = useAction();
const { data, error, loading, reload } = useQuery<IntegrationsResponse>('/admin/api/integrations', {
pollMs: 60_000,
});
const [draft, setDraft] = useState<Draft | null>(null);
const [confirming, setConfirming] = useState<Integration | null>(null);
const catalogue = data?.catalogue ?? [];
const integrations = data?.integrations ?? [];
const edit = (integration: Integration) =>
setDraft({
id: integration.id,
name: integration.name,
url: '',
enabled: integration.enabled,
events: integration.events ?? [],
});
const save = () =>
run('save', async () => {
if (!draft) return;
const saved = await wrap(
() => api.post<IntegrationsResponse>('/admin/api/integrations', draft),
draft.id ? 'Integration saved.' : 'Integration added.',
);
if (saved) {
setDraft(null);
await reload();
}
});
const remove = (integration: Integration) =>
run('remove', async () => {
await wrap(
() => api.del(`/admin/api/integrations/${encodeURIComponent(integration.id)}`),
`${integration.name} removed.`,
);
setConfirming(null);
await reload();
});
const test = (integration: Integration) =>
run(`test:${integration.id}`, async () => {
const result = await wrap(() =>
api.post<{ ok: boolean; message: string }>(
`/admin/api/integrations/${encodeURIComponent(integration.id)}/test`,
),
);
// The test route answers 200 whether or not the webhook accepted it, because the
// *request* succeeded — so the verdict is in the body, and reporting it is this
// page's job rather than the transport's.
if (result) show(result.message, result.ok ? 'ok' : 'bad');
await reload();
});
return (
<>
<PageHead
title="Integrations"
intro="Send administrative events to somewhere you already look. Events pass through the gateway's own event layer, so nothing about authentication or scheduled tasks knows Discord exists — and a second kind of destination is a change here rather than everywhere."
actions={
<Button variant="primary" icon="plus" onClick={() => setDraft(NEW_DRAFT)}>
Add a webhook
</Button>
}
/>
<Banner message={error} />
{(data?.dropped ?? 0) > 0 ? (
<Note tone="warn">
{num(data?.dropped ?? 0)} events could not be queued for delivery. The queue is deliberately
lossy a slow endpoint must never hold up a television signing in but a number growing here
means a destination is not keeping up.
</Note>
) : null}
{loading ? (
<Loading />
) : integrations.length === 0 && !draft ? (
<Card title="Nothing configured" icon="plug" tone="note">
<Empty>
No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's
settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here.
</Empty>
</Card>
) : (
integrations.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
catalogue={catalogue}
busy={busy}
onEdit={() => edit(integration)}
onTest={() => void test(integration)}
onRemove={() => setConfirming(integration)}
/>
))
)}
{draft ? (
<DraftCard
draft={draft}
catalogue={catalogue}
busy={busy === 'save'}
onChange={setDraft}
onSave={() => void save()}
onCancel={() => setDraft(null)}
/>
) : null}
{confirming ? (
<Confirm
title={`Remove ${confirming.name}?`}
body="The webhook address and its delivery history go with it. Events already published stay in the activity feed."
confirmLabel="Remove"
destructive
busy={busy === 'remove'}
onConfirm={() => void remove(confirming)}
onCancel={() => setConfirming(null)}
/>
) : null}
</>
);
}
function IntegrationCard({
integration,
catalogue,
busy,
onEdit,
onTest,
onRemove,
}: {
integration: Integration;
catalogue: IntegrationEventOption[];
busy: string | null;
onEdit: () => void;
onTest: () => void;
onRemove: () => void;
}) {
const health = integration.health;
// "Is it working" is answered by the *last* attempt, not by a failure count: a webhook
// that failed once an hour ago and has worked since is healthy.
const healthy =
!health.lastFailure || (health.lastSuccess && health.lastSuccess > health.lastFailure);
const selected = integration.events ?? [];
return (
<Card
title={integration.name}
intro={integration.hint ? `Discord webhook ${integration.hint}` : 'Discord webhook'}
icon="plug"
tone={integration.enabled ? 'ok' : 'warn'}
actions={
<>
{integration.enabled ? <Tag tone="ok">on</Tag> : <Tag tone="warn">off</Tag>}
{health.deliveries > 0 ? (
<Tag tone={healthy ? 'ok' : 'bad'}>{healthy ? 'delivering' : 'failing'}</Tag>
) : (
<Tag>never used</Tag>
)}
<Button size="sm" icon="pulse" busy={busy === `test:${integration.id}`} onClick={onTest}>
Test
</Button>
<Button size="sm" onClick={onEdit}>
Edit
</Button>
<Button size="sm" variant="danger" icon="trash" onClick={onRemove} title="Remove" />
</>
}
>
<div className="list">
<div className="list-item">
<div className="list-body">
<b>Events sent</b>
<p>
{selected.length === 0
? 'None selected this destination is configured but will never post anything.'
: selected
.map((type) => catalogue.find((entry) => entry.type === type)?.label ?? type)
.join(', ')}
</p>
</div>
</div>
<div className="list-item">
<div className="list-body">
<b>Last delivered</b>
<p>{health.lastSuccess ? when(health.lastSuccess) : 'never'}</p>
</div>
<div className="list-actions">
{health.deliveries > 0 ? (
<span className="quiet">
{num(health.deliveries)} attempts, {num(health.failures)} failed
</span>
) : null}
</div>
</div>
{health.lastFailure ? (
<div className="list-item">
<div className="list-body">
<b>Last failure</b>
<p>
{when(health.lastFailure)}
{health.lastError ? ` — ${health.lastError}` : ''}
</p>
</div>
</div>
) : null}
</div>
{integration.deliveries.length > 0 ? (
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Attempted</th>
<th>Event</th>
<th>Result</th>
<th className="num">Took</th>
</tr>
</thead>
<tbody>
{integration.deliveries.map((delivery) => (
<tr key={delivery.id}>
<td className="nowrap muted" title={when(delivery.attemptedAt)}>
{ago(delivery.attemptedAt)}
</td>
<td className="muted">{delivery.eventType}</td>
<td>
{delivery.success ? (
<Tag tone="ok">{delivery.statusCode || 'ok'}</Tag>
) : (
<Tag tone="bad">{delivery.error || delivery.statusCode || 'failed'}</Tag>
)}
</td>
<td className="num muted">{duration(delivery.durationMs)}</td>
</tr>
))}
</tbody>
</table>
</TableWrap>
) : (
<TableWrap>
<table>
<tbody>
<EmptyRow columns={4}>Nothing has been delivered through this webhook yet.</EmptyRow>
</tbody>
</table>
</TableWrap>
)}
</Card>
);
}
function DraftCard({
draft,
catalogue,
busy,
onChange,
onSave,
onCancel,
}: {
draft: Draft;
catalogue: IntegrationEventOption[];
busy: boolean;
onChange: (next: Draft) => void;
onSave: () => void;
onCancel: () => void;
}) {
const groups = [...new Set(catalogue.map((entry) => entry.group))];
const toggleEvent = (type: string, on: boolean) =>
onChange({
...draft,
events: on ? [...draft.events, type] : draft.events.filter((entry) => entry !== type),
});
return (
<Card
title={draft.id ? `Edit ${draft.name}` : 'New Discord webhook'}
icon="plug"
tone="info"
footer={
<>
<Button variant="primary" busy={busy} onClick={onSave}>
{draft.id ? 'Save' : 'Add'}
</Button>
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<span className="spacer" />
{draft.events.length === 0 ? (
<span className="quiet">Nothing selected — this destination would never post.</span>
) : (
<span className="quiet">{draft.events.length} events selected</span>
)}
</>
}
>
<div className="fields">
<Field label="Name" hint="What this destination is called in the console.">
<input
type="text"
value={draft.name}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
/>
</Field>
<Field
label="Webhook address"
hint={
draft.id
? 'Leave blank to keep the address already saved it is a credential and is never sent back to this page.'
: 'Discord channel settings Integrations Webhooks New Webhook Copy Webhook URL.'
}
>
<input
type="url"
value={draft.url}
placeholder={draft.id ? 'unchanged' : 'https://discord.com/api/webhooks/…'}
onChange={(event) => onChange({ ...draft, url: event.target.value })}
/>
</Field>
</div>
<Toggle
label="Enabled"
hint="Off keeps the configuration and stops the posts."
checked={draft.enabled}
onChange={(next) => onChange({ ...draft, enabled: next })}
/>
{groups.map((group) => (
<div key={group}>
<div className="card-head" style={undefined}>
<div className="card-head-text">
<h2>{group}</h2>
</div>
</div>
{catalogue
.filter((entry) => entry.group === group)
.map((entry) => (
<Toggle
key={entry.type}
label={entry.label}
hint={entry.description}
checked={draft.events.includes(entry.type)}
onChange={(on) => toggleEvent(entry.type, on)}
/>
))}
</div>
))}
</Card>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { when } from '../lib/format';
import { Icon } from '../components/Icon';
import { Banner, Card, Loading, PageHead, Tag } from '../components/ui';
interface JourneyEvent {
journeyId: string;
sequence: number;
occurredAt: string;
category: string;
action: string;
screen?: string;
feature?: string;
source?: string;
target?: string;
itemName?: string;
itemType?: string;
outcome?: string;
}
interface JourneyResponse {
users: { userId: string; username: string }[] | null;
events: JourneyEvent[] | null;
}
const label = (value: string | undefined | null) => {
const text = String(value || '—').replaceAll('_', ' ');
return text === 'favorites' ? 'Favourites' : text;
};
const place = (event: JourneyEvent | undefined) => label(event?.target || event?.screen || event?.source || event?.feature);
const detail = (event: JourneyEvent) => event.itemName
? `${label(event.itemType)} · ${event.itemName}`
: event.source && event.target ? `${label(event.source)}${label(event.target)}` : place(event);
const verb = (event: JourneyEvent) => ({
journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session',
screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed',
request: event.category === 'playback' ? 'Started watching' : 'Requested',
stop: 'Stopped watching', start: 'Started', complete: 'Completed',
}[event.action] ?? label(event.action));
function outcome(events: JourneyEvent[]) {
const explicit = [...events].reverse().find((event) => event.outcome)?.outcome;
if (explicit === 'success' || explicit === 'completed') return { label: label(explicit), tone: 'ok' as const };
if (explicit === 'failure' || explicit === 'cancelled' || explicit === 'abandoned') return { label: label(explicit), tone: 'note' as const };
if (events.some((event) => event.action === 'stop' && event.category === 'playback')) return { label: 'watched', tone: 'ok' as const };
return { label: 'left before playback ended', tone: 'warn' as const };
}
/* A session is an app-opening UUID. A viewing journey is an intent that reaches playback.
* One session can contain several of them: search → watch a film → search → watch another
* is two journeys, which is the answer an operator needs without pretending it was two app
* launches. Events before each playback request belong to that request; the tail remains a
* non-playback journey so an unsuccessful search is visible rather than silently discarded. */
function splitViewingJourneys(events: JourneyEvent[]) {
const boundaries = events.reduce<number[]>((out, event, index) => {
if (event.category === 'playback' && event.action === 'request') out.push(index);
return out;
}, []);
if (boundaries.length === 0) return [events];
return boundaries.map((boundary, index) => events.slice(index === 0 ? 0 : boundary, (boundaries[index + 1] ?? events.length)));
}
export function JourneyViewerPage() {
const { userId = '' } = useParams();
const path = useMemo(() => `/admin/api/journeys${query({ days: 90, userId })}`, [userId]);
const { data, error, loading } = useQuery<JourneyResponse>(path);
const username = data?.users?.find((user) => user.userId === userId)?.username || userId;
const sessions = useMemo(() => {
const grouped = new Map<string, JourneyEvent[]>();
for (const event of data?.events ?? []) grouped.set(event.journeyId, [...(grouped.get(event.journeyId) ?? []), event]);
return [...grouped.values()]
.map((events) => events.sort((left, right) => left.sequence - right.sequence))
.sort((left, right) => (right[0]?.occurredAt ?? '').localeCompare(left[0]?.occurredAt ?? ''));
}, [data?.events]);
const journeys = sessions.flatMap((session) => splitViewingJourneys(session).map((events, index) => ({ events, key: `${session[0]?.journeyId}:${index}` })));
return <>
<PageHead title={`${username}'s journeys`} intro="Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome." crumbs={<Link className="crumb" to="/admin/journeys">Journeys</Link>} />
<Banner message={error} />
{loading ? <Loading /> : <Card title="Viewing journeys" intro={`${sessions.length} app session${sessions.length === 1 ? '' : 's'} · ${journeys.length} viewing journey${journeys.length === 1 ? '' : 's'} in the last 90 days.`} icon="journey" tone="info">
<div className="visits">
{journeys.length === 0 ? <p className="empty">No journeys recorded for this viewer.</p> : journeys.map((journey, index) => {
const events = journey.events;
const entry = events[0];
const selection = [...events].reverse().find((event) => event.itemName || event.action === 'select' || (event.category === 'playback' && event.action === 'request'));
const result = outcome(events);
return <article className="visit" key={journey.key}>
<header><div><b>{when(entry?.occurredAt)}</b><span>Journey {index + 1} · {events.length} recorded steps</span></div><Tag tone={result.tone}>{result.label}</Tag></header>
<div className="journey-answers">
<div className="journey-answer" data-kind="entry"><Icon name="journey" /><span>Entered from</span><b>{place(entry)}</b></div>
<div className="journey-answer" data-kind="selection"><Icon name="play" /><span>Selected</span><b>{selection ? detail(selection) : 'Nothing selected'}</b></div>
<div className="journey-answer" data-kind="outcome"><Icon name={result.tone === 'ok' ? 'check' : 'clock'} /><span>Outcome</span><b>{result.label}</b></div>
</div>
<ol className="journey-timeline">{events.map((event) => <li key={`${event.journeyId}:${event.sequence}`}><span className="timeline-dot" data-action={event.action} /><div><b>{verb(event)}</b><span>{detail(event)}</span></div><time>{when(event.occurredAt)}</time></li>)}</ol>
</article>;
})}
</div>
</Card>}
</>;
}
+299
View File
@@ -0,0 +1,299 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { duration, num, percent, when } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Field,
Grid,
Loading,
Meter,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
/* Memby's major feature catalogue, in the order it is presented.
*
* It is a list here rather than derived from what has been used, and that is the whole
* point of the table it feeds: a feature nobody has touched has no row in the analytics
* and would simply be absent, which is indistinguishable from a feature that does not
* exist. Listing it and showing a zero is what makes "not used" an answer. */
const FEATURE_CATALOGUE = [
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
];
/** The wire values are ids; this is the render, so it is spelled — the same boundary the
* launcher's own row titles observe. */
function label(value: string | undefined | null): string {
const text = String(value || '—').replaceAll('_', ' ');
if (text === 'favorites') return 'Favourites';
if (text === 'abandoned') return 'Abandoned / interrupted';
return text;
}
interface JourneysResponse {
days: number;
retentionDays: number;
stats: {
events: number;
journeys: number;
viewers: number;
completed: number;
abandoned: number;
active: number;
averageSteps: number;
averageTimeMs: number;
completionRate: number;
};
users: { userId: string; username: string }[] | null;
features: { feature: string; uses: number; lastUsedAt: string }[] | null;
actions: { category: string; action: string; events: number; journeys: number }[] | null;
paths: { from: string; to: string; count: number }[] | null;
}
export function JourneysPage() {
const [days, setDays] = useState(30);
const [userId, setUserId] = useState('');
const navigate = useNavigate();
const path = useMemo(() => `/admin/api/journeys${query({ days, userId })}`, [days, userId]);
const { data, error, loading } = useQuery<JourneysResponse>(path);
const stats = data?.stats;
const users = data?.users ?? [];
const actions = data?.actions ?? [];
const paths = data?.paths ?? [];
const topPath = paths[0];
const features = useMemo(() => {
const used = new Map((data?.features ?? []).map((feature) => [feature.feature, feature]));
const position = new Map(FEATURE_CATALOGUE.map((name, index) => [name, index]));
return [...new Set([...FEATURE_CATALOGUE, ...used.keys()])]
.map((name) => ({ name, stat: used.get(name) }))
.sort((left, right) => {
const byUse = (right.stat?.uses ?? 0) - (left.stat?.uses ?? 0);
if (byUse) return byUse;
return (
(position.get(left.name) ?? Number.MAX_SAFE_INTEGER) -
(position.get(right.name) ?? Number.MAX_SAFE_INTEGER)
);
});
}, [data?.features]);
// Individual visits are shown only for one person: across a household they are a wall
// of cards with nothing to compare against, and the question they answer is always
// "what did *they* do".
return (
<>
<PageHead
title="User journeys"
intro="How viewers move through Memby, use features and complete flows."
/>
<Banner message={error} />
<Card
title="Journey health"
intro="Server-derived foreground visits, completion and interruption. Search text, content titles and setting values are never stored."
icon="people"
tone="info"
actions={
<>
<Field label="Window">
<select value={days} onChange={(event) => setDays(Number(event.target.value))}>
<option value={1}>24 hours</option>
<option value={7}>7 days</option>
<option value={30}>30 days</option>
<option value={90}>90 days</option>
</select>
</Field>
<Field label="User">
<select
value={userId}
onChange={(event) => {
const next = event.target.value;
setUserId(next);
if (next) navigate(`/admin/journeys/${encodeURIComponent(next)}`);
}}
>
<option value="">All users</option>
{users.map((user) => (
<option key={user.userId} value={user.userId}>
{user.username || user.userId}
</option>
))}
</select>
</Field>
</>
}
>
{loading ? (
<Loading rows={1} />
) : (
<>
<Tiles
tiles={[
{ label: 'journeys', value: num(stats?.journeys), icon: 'list', tone: 'data' },
{ label: 'viewers', value: num(stats?.viewers), icon: 'people', tone: 'info' },
{ label: 'completion', value: percent(stats?.completionRate), icon: 'check', tone: 'ok' },
{ label: 'abandoned', value: num(stats?.abandoned), icon: 'alert', tone: 'note' },
{ label: 'active now', value: num(stats?.active), icon: 'pulse', tone: 'info' },
{ label: 'average steps', value: (stats?.averageSteps ?? 0).toFixed(1), icon: 'chart' },
{ label: 'average visit', value: duration(stats?.averageTimeMs), small: true, icon: 'clock' },
{ label: 'history kept', value: `${data?.retentionDays ?? 90} days`, small: true, icon: 'clock' },
]}
/>
<div className="summary-grid">
<div className="summary">
<div className="summary-head">
<b>Visit completion</b>
<strong>{percent(stats?.completionRate)}</strong>
</div>
<Meter value={stats?.completed ?? 0} total={stats?.journeys ?? 0} />
<p>
{num(stats?.completed)} completed · {num(stats?.abandoned)} abandoned ·{' '}
{num(stats?.active)} active
</p>
</div>
<div className="summary">
<div className="summary-head">
<b>Most common route</b>
</div>
<strong style={undefined}>
{topPath ? `${label(topPath.from)}${label(topPath.to)}` : 'Not enough data'}
</strong>
<p>
{topPath
? `${num(topPath.count)} times in this window`
: 'Journeys will appear here as viewers move through Memby.'}
</p>
</div>
</div>
</>
)}
</Card>
<Grid cols="2">
<Card
title="What people do"
intro="Actions show total use and how many separate visits included them."
icon="chart"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Action</th>
<th className="num">Uses</th>
<th className="num">Visits</th>
</tr>
</thead>
<tbody>
{actions.length === 0 ? (
<EmptyRow columns={3}>No significant actions in this window.</EmptyRow>
) : (
actions.map((action) => (
<tr key={`${action.category}:${action.action}`}>
<td>
<b>{label(action.action)}</b>
<span className="table-sub">{label(action.category)}</span>
</td>
<td className="num">{num(action.events)}</td>
<td className="num">{num(action.journeys)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="Where people go"
intro="The most common steps between screens, including where quiet visits ended."
icon="list"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Route</th>
<th className="num">Times</th>
</tr>
</thead>
<tbody>
{paths.length === 0 ? (
<EmptyRow columns={2}>No repeated paths in this window.</EmptyRow>
) : (
paths.map((entry, index) => (
<tr key={`${entry.from}:${entry.to}:${index}`}>
<td>
{label(entry.from)} <span className="route-arrow"></span> {label(entry.to)}
</td>
<td className="num">{num(entry.count)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</Grid>
<Card
title="Feature use"
intro="Rare and unused features are shown against Memby's major feature catalogue."
icon="pulse"
tone="data"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Feature</th>
<th className="num">Uses</th>
<th>Last used</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{features.map(({ name, stat }) => {
const uses = stat?.uses ?? 0;
return (
<tr key={name}>
<td>{label(name)}</td>
<td className="num">{num(uses)}</td>
<td className="muted nowrap">{stat ? when(stat.lastUsedAt) : '—'}</td>
<td>
{uses === 0 ? (
<Tag tone="warn">not used</Tag>
) : uses < 3 ? (
<Tag tone="note">rare</Tag>
) : (
<Tag tone="ok">used</Tag>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</TableWrap>
</Card>
{userId ? null : (
<Card title="Inspect a viewer" intro="Choose a person above to open their dedicated session and viewing-journey timeline." icon="journey" tone="info">
<p className="empty">A viewing journey follows one intent through to playback, so two films watched in a single app session appear as two separate journeys.</p>
</Card>
)}
</>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Confirm, Loading, PageHead, Tiles } from '../components/ui';
export function LibraryPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [confirming, setConfirming] = useState(false);
const byType = status?.library.byType ?? {};
const running = Boolean(status?.syncRunning);
const sync = (kind: 'incremental' | 'full') =>
run(kind, async () => {
await wrap(
() => api.post('/admin/api/sync', { kind }),
kind === 'full' ? 'Full re-import started.' : 'Import started.',
);
setConfirming(false);
await reload();
});
return (
<>
<PageHead title="Library" intro="Import and inspect the catalogue Memby ranks." />
<Banner message={error} />
{loading || !status ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'items', value: num(status.library.total), icon: 'library', tone: 'data' },
...Object.keys(byType)
.sort()
.map((type) => ({
label: type,
value: num(byType[type]),
icon: 'list' as const,
})),
{ label: 'last import', value: when(status.library.lastSynced), small: true, icon: 'clock' as const },
]}
/>
<Card
title="Import the catalogue"
intro="Emby's catalogue is copied here so search and the recommendation candidate pool can be answered from one indexed table. Watched, favourite and resume state is deliberately not stored — that is per person and still comes from Emby live."
icon="library"
tone="data"
footer={
<span className="hint">
{running
? 'Import running…'
: `An incremental import runs automatically every ${status.syncEvery}.`}
</span>
}
>
<div className="row">
<Button
variant="primary"
icon="sync"
disabled={running}
busy={busy === 'incremental'}
onClick={() => void sync('incremental')}
>
Sync new items
</Button>
<Button icon="database" disabled={running} onClick={() => setConfirming(true)}>
Full re-import
</Button>
</div>
</Card>
</>
)}
{confirming ? (
<Confirm
title="Re-import the entire library?"
body="A full pass mark-and-sweeps the catalogue and can take several minutes on a large library. Televisions keep reading the current table throughout."
confirmLabel="Re-import"
busy={busy === 'full'}
onConfirm={() => void sync('full')}
onCancel={() => setConfirming(false)}
/>
) : null}
</>
);
}
+419
View File
@@ -0,0 +1,419 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { daysAgo, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
Empty,
EmptyRow,
Field,
Grid,
Loading,
PageHead,
Segments,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import { Bars } from '../components/ui';
import type { LoginDevicesResponse, LoginsResponse } from '../api/types';
/* The sign-in history.
*
* This is the page the whole login-analytics feature exists for, and its shape follows
* from one observation: an operator arrives here with a *question*, not a browsing
* intention — "did the bedroom TV connect this morning", "who is that address", "why does
* this account keep failing" — so the filter bar is above the table and always visible,
* not behind a disclosure, and every control in it maps to one server-side filter.
*
* Two tables of the same rows, deliberately, the stance the searches page takes: the
* devices summary answers "which televisions are connecting", which is what you read when
* you do not yet know where to look, and the log is uncollapsed and newest-first, which is
* what you read when something has just happened. */
type View = 'log' | 'devices';
interface Filters {
user: string;
q: string;
ip: string;
outcome: '' | 'success' | 'failure';
from: string;
to: string;
}
const EMPTY: Filters = { user: '', q: '', ip: '', outcome: '', from: '', to: '' };
const WINDOWS = [
{ value: 1, label: 'Today' },
{ value: 7, label: '7 days' },
{ value: 30, label: '30 days' },
{ value: 0, label: 'All' },
] as const;
export function LoginsPage() {
const [view, setView] = useState<View>('log');
const [days, setDays] = useState<number>(7);
const [filters, setFilters] = useState<Filters>(EMPTY);
const [page, setPage] = useState(0);
const limit = 100;
// The window and the explicit date range are the same filter expressed two ways, and an
// explicit `from` wins: an operator who typed a date meant it, and silently narrowing it
// to the last seven days would answer a question they did not ask.
const params = useMemo(
() =>
query({
...filters,
days: filters.from ? undefined : days || undefined,
limit,
offset: page * limit,
}),
[filters, days, page],
);
const log = useQuery<LoginsResponse>(`/admin/api/logins${params}`, { enabled: view === 'log' });
const devices = useQuery<LoginDevicesResponse>(`/admin/api/logins/devices${params}`, {
enabled: view === 'devices',
});
const users = log.data?.users ?? devices.data?.users ?? [];
const totals = log.data?.totals ?? devices.data?.totals;
const retention = log.data?.retentionDays ?? devices.data?.retentionDays ?? 90;
const loading = view === 'log' ? log.loading : devices.loading;
const error = view === 'log' ? log.error : devices.error;
const update = (patch: Partial<Filters>) => {
setFilters((current) => ({ ...current, ...patch }));
setPage(0);
};
const active =
Object.entries(filters).some(([, value]) => value !== '') || Boolean(filters.from);
return (
<>
<PageHead
title="Sign-in history"
intro="Every connection attempt, kept as history rather than as the latest state of a device. A television that has since been removed still appears here, because it still connected."
actions={
<Segments
value={view}
options={[
{ value: 'log', label: 'Log' },
{ value: 'devices', label: 'By device' },
]}
onChange={setView}
/>
}
/>
<Banner message={error} />
{totals ? (
<Tiles
tiles={[
{ label: 'Successful sign-ins', value: num(totals.logins), icon: 'key', tone: 'ok' },
{
label: 'Refused',
value: num(totals.failures),
icon: 'shield',
tone: totals.failures > 0 ? 'warn' : undefined,
},
{ label: 'Televisions', value: num(totals.devices), icon: 'tv', tone: 'info' },
{ label: 'People', value: num(totals.users), icon: 'people', tone: 'note' },
{ label: 'Addresses', value: num(totals.addresses), icon: 'globe', tone: 'data' },
{
label: 'History kept',
value: `${retention} days`,
small: true,
icon: 'clock',
},
]}
/>
) : null}
{/* Fast filtering is most of what makes this table useful, so the controls sit above
it rather than behind a "filters" disclosure nobody opens. */}
<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>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username || user.id}
</option>
))}
</select>
</Field>
<Field label="Outcome">
<select
value={filters.outcome}
onChange={(event) => update({ outcome: event.target.value as Filters['outcome'] })}
>
<option value="">Both</option>
<option value="success">Got in</option>
<option value="failure">Refused</option>
</select>
</Field>
<Field label="Address">
<input
type="text"
value={filters.ip}
placeholder="10.0.0.4"
onChange={(event) => update({ ip: event.target.value })}
/>
</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="Name, device or address"
onChange={(event) => update({ q: event.target.value })}
/>
</Field>
<div className="filter-actions">
{active ? (
<Button
variant="quiet"
size="sm"
onClick={() => {
setFilters(EMPTY);
setPage(0);
}}
>
Clear
</Button>
) : null}
</div>
</div>
{loading ? (
<Loading />
) : view === 'log' ? (
<LogView data={log.data} page={page} limit={limit} onPage={setPage} />
) : (
<DeviceView data={devices.data} />
)}
</>
);
}
function LogView({
data,
page,
limit,
onPage,
}: {
data: LoginsResponse | undefined;
page: number;
limit: number;
onPage: (next: number) => void;
}) {
if (!data) return null;
const shown = data.events.length;
const from = data.total === 0 ? 0 : page * limit + 1;
return (
<>
<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."
icon="chart"
tone="info"
>
<Bars
data={data.days}
labelOf={(row: (typeof data.days)[number]) => row.day}
valueOf={(row: (typeof data.days)[number]) => row.logins + row.failures}
toneOf={(row: (typeof data.days)[number]) => (row.failures > row.logins ? 'bad' : undefined)}
title={(row: (typeof data.days)[number]) =>
`${row.day}: ${row.logins} in, ${row.failures} refused, ${row.devices} televisions`
}
/>
</Card>
<Card title="Where from" icon="globe" tone="data">
{data.addresses.length === 0 ? (
<Empty>No addresses in this window.</Empty>
) : (
<div className="list">
{data.addresses.slice(0, 8).map((address) => (
<div className="list-item" key={address.ipAddress}>
<div className="list-body">
<b className="mono">{address.ipAddress}</b>
<p>
{num(address.logins)} in
{address.failures > 0 ? ` · ${num(address.failures)} refused` : ''}
</p>
</div>
{address.failures > 0 && address.logins === 0 ? <Tag tone="bad">only refused</Tag> : null}
</div>
))}
</div>
)}
</Card>
</Grid>
<Card
title="Attempts"
intro="Uncollapsed and newest first: this is what to read when somebody says a television will not sign in."
icon="key"
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={() => onPage(page - 1)}>
Newer
</Button>
<Button
size="sm"
disabled={(page + 1) * limit >= data.total}
onClick={() => onPage(page + 1)}
>
Older
</Button>
</>
) : undefined
}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">When</th>
<th>Person</th>
<th>Television</th>
<th className="nowrap">Address</th>
<th>Build</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
{data.events.length === 0 ? (
<EmptyRow columns={6}>No sign-in attempts match these filters.</EmptyRow>
) : (
data.events.map((event) => (
<tr key={event.id}>
<td className="nowrap muted">{when(event.occurredAt)}</td>
<td>{event.username || <span className="quiet">unknown</span>}</td>
<td>
{event.deviceId ? (
<Link className="table-row-link" to={`/admin/devices/${encodeURIComponent(event.deviceId)}`}>
{event.deviceName || event.deviceId}
</Link>
) : (
<span className="quiet"></span>
)}
</td>
<td className="mono nowrap">{event.ipAddress || '—'}</td>
<td className="mono">{event.clientVersion || '—'}</td>
<td className="nowrap">
{event.success ? (
event.newDevice ? (
<Tag tone="info">first sign-in</Tag>
) : (
<Tag tone="ok">got in</Tag>
)
) : (
<Tag tone="bad">{event.failureReason || 'refused'}</Tag>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
);
}
function DeviceView({ data }: { data: LoginDevicesResponse | undefined }) {
if (!data) return null;
return (
<Card
title="Televisions"
intro="Grouped from the history, not from the session list — a set whose session has expired still connected, and this is the record of it."
icon="tv"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Television</th>
<th>Person</th>
<th className="num">Today</th>
<th className="num">Sign-ins</th>
<th className="num">Refused</th>
<th className="num">Addresses</th>
<th className="nowrap">Last address</th>
<th className="nowrap">Last sign-in</th>
<th>Build</th>
</tr>
</thead>
<tbody>
{data.devices.length === 0 ? (
<EmptyRow columns={9}>No television has connected in this window.</EmptyRow>
) : (
data.devices.map((device) => (
<tr key={device.deviceId}>
<td>
<Link className="table-row-link" to={`/admin/devices/${encodeURIComponent(device.deviceId)}`}>
{device.deviceName || device.deviceId}
</Link>
</td>
<td className="muted">{device.username || '—'}</td>
<td className="num">{device.loginsToday > 0 ? num(device.loginsToday) : '—'}</td>
<td className="num">{num(device.logins)}</td>
<td className="num">
{device.failures > 0 ? <span className="mono">{num(device.failures)}</span> : '—'}
</td>
<td className="num">{num(device.distinctIps)}</td>
<td className="mono nowrap">{device.lastIp || '—'}</td>
{/* A device with failures and no successes has no last sign-in, which is
a real answer rather than a zero one. */}
<td className="nowrap muted">{device.lastLogin ? when(device.lastLogin) : '—'}</td>
<td className="mono">{device.clientVersion || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
);
}
/** Exported for the device page, which offers the same seven-day default. */
export const defaultWindowFrom = () => daysAgo(7);
+194
View File
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Field, PageHead } from '../components/ui';
import type { LogEvent, LogResponse } from '../api/types';
/* The live server log.
*
* The ring buffer is drained in pages until it is caught up, so a console opened *after*
* an incident sees what happened rather than only what happens next. Everything the page
* has drained is held for filtering and export; only the visible tail is drawn, because
* rendering twenty thousand lines during an incident is how a browser tab stops
* responding at exactly the wrong moment. */
const RANKS: Record<string, number> = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
const RETAIN = 20_000;
const DRAW = 2_500;
const POLL_MS = 5_000;
/* The same order the server's own console lines use — who and where first, the reason for
the line last — so a log read here and a log read over SSH look alike. `version` is
dropped: it is the same on every line and is reported once in the top bar instead. */
const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration'];
function orderedFields(attributes: Record<string, unknown>): [string, unknown][] {
const rank = (key: string) => {
const at = FIELD_ORDER.indexOf(key);
if (at >= 0) return at;
return key === 'error' ? 1000 : 100;
};
return Object.entries(attributes)
.filter(([key]) => key !== 'version')
.sort((a, b) => rank(a[0]) - rank(b[0]));
}
const haystack = (event: LogEvent) =>
[event.message, ...Object.entries(event.attributes ?? {}).flat()].join(' ').toLowerCase();
export function LogsPage() {
const [records, setRecords] = useState<LogEvent[]>([]);
const [dropped, setDropped] = useState(0);
const [paused, setPaused] = useState(false);
const [level, setLevel] = useState('INFO');
const [search, setSearch] = useState('');
const [error, setError] = useState('');
const cursor = useRef(0);
const fetching = useRef(false);
const view = useRef<HTMLDivElement>(null);
// Whether the reader is at the bottom is decided *before* the new lines are drawn: after
// they are, the measurement always says "not at the bottom" and the log would never
// follow. Hence the layout effect below rather than a check inside the fetch.
const pinned = useRef(true);
const drain = useCallback(async () => {
if (paused || fetching.current) return;
fetching.current = true;
try {
let pages = 0;
let page: LogResponse;
do {
page = await api.get<LogResponse>(`/admin/api/events?after=${cursor.current}&limit=1000`);
cursor.current = page.next || cursor.current;
if (page.dropped) setDropped((current) => current + page.dropped);
const events = page.events ?? [];
if (events.length > 0) {
setRecords((current) => {
const next = [...current, ...events];
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
});
}
pages += 1;
} while (page.hasMore && pages < 20);
setError('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
fetching.current = false;
}
}, [paused]);
useEffect(() => {
void drain();
if (paused) return;
const timer = window.setInterval(() => void drain(), POLL_MS);
return () => window.clearInterval(timer);
}, [drain, paused]);
const filtered = useMemo(() => {
const minimum = RANKS[level] ?? 20;
const needle = search.trim().toLowerCase();
return records.filter(
(event) => (RANKS[event.level] ?? 0) >= minimum && (!needle || haystack(event).includes(needle)),
);
}, [records, level, search]);
const visible = filtered.slice(-DRAW);
useLayoutEffect(() => {
const node = view.current;
if (node && pinned.current) node.scrollTop = node.scrollHeight;
}, [visible.length]);
const onScroll = () => {
const node = view.current;
if (node) pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < 50;
};
const exportJson = () => {
const blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `memby-events-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
link.click();
window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
};
return (
<>
<PageHead title="Server logs" intro="Structured gateway events as they happen." />
<Banner message={error} />
<Card>
<div className="filters">
<Field label="Level">
<select value={level} onChange={(event) => setLevel(event.target.value)}>
<option value="DEBUG">Debug and above</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
</Field>
<Field label="Filter" grow>
<input
type="search"
value={search}
placeholder="Person, television, title, component, path…"
onChange={(event) => setSearch(event.target.value)}
/>
</Field>
<div className="filter-actions">
<Button onClick={() => setPaused((current) => !current)} icon={paused ? 'play' : 'clock'}>
{paused ? 'Resume' : 'Pause'}
</Button>
<Button
onClick={() => {
setRecords([]);
setDropped(0);
}}
>
Clear view
</Button>
<Button onClick={exportJson} icon="download">
Export JSON
</Button>
</div>
</div>
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-live="polite">
{visible.length === 0 ? (
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
) : (
visible.map((event, index) => (
<div className="logline" key={`${event.occurredAt}:${index}`} data-level={event.level}>
<time>{when(event.occurredAt)}</time>
{/* The level is a class on the line as well as its own column: scrolling a
log is looking for the one line that is not INFO, and a coloured word
four columns in is easy to scroll past. */}
<span className="lvl">{event.level}</span>
<span className="msg">{event.message}</span>
<span className="attrs">
{orderedFields(event.attributes ?? {}).map(([key, value]) => (
<span key={key}>
{' '}
<b>{key}=</b>
{String(value)}
</span>
))}
</span>
</div>
))
)}
</div>
<p className="hint">
{num(records.length)} retained · {num(filtered.length)} matching
{visible.length < filtered.length ? ` · showing the latest ${num(visible.length)}` : ''}
{dropped ? ` · ${num(dropped)} overwritten before delivery` : ''}
{paused ? ' · paused' : ''}
</p>
</Card>
</>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Confirm, Field, Loading, PageHead, Tag } from '../components/ui';
export function MaintenancePage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [message, setMessage] = useState('');
const [confirming, setConfirming] = useState(false);
const [touched, setTouched] = useState(false);
const enabled = Boolean(status?.maintenance?.enabled);
// The poll must never take a half-typed message away mid-edit, which was the one rule
// the previous console's `Admin.fill` existed to enforce. React holds the field's value
// itself, so the equivalent here is simply not to overwrite it once the operator has
// touched it.
useEffect(() => {
if (!touched && status) setMessage(status.maintenance?.message ?? '');
}, [status, touched]);
const set = (next: boolean) =>
run(next ? 'on' : 'off', async () => {
await wrap(
() => api.post('/admin/api/maintenance', { enabled: next, message }),
next ? 'Memby is offline for every television.' : 'Memby is back online.',
);
setConfirming(false);
setTouched(false);
await reload();
});
return (
<>
<PageHead title="Maintenance" intro="Take Memby offline for every television." />
<Banner message={error} />
{loading ? (
<Loading rows={1} />
) : (
<Card
title="Gateway availability"
intro="Takes Memby offline for every television, independently of Emby. Sign-in and all content calls answer 503 with the message below, and the television shows it in place of the launcher rows. This console keeps working."
icon="power"
tone={enabled ? 'bad' : 'warn'}
actions={enabled ? <Tag tone="bad">offline</Tag> : <Tag tone="ok">online</Tag>}
footer={
<>
<Button variant="danger" disabled={enabled} onClick={() => setConfirming(true)}>
Go offline
</Button>
<Button disabled={!enabled} busy={busy === 'off'} onClick={() => void set(false)}>
Bring back online
</Button>
</>
}
>
<Field
label="Message shown on the television"
hint="Say what is happening and when it will be back. It is the only thing the viewer is told."
>
<input
type="text"
value={message}
placeholder="Back shortly — upgrading the server"
onChange={(event) => {
setMessage(event.target.value);
setTouched(true);
}}
/>
</Field>
</Card>
)}
{confirming ? (
<Confirm
title="Take Memby offline?"
body="Every television will stop working immediately and show your message in place of the launcher. This console keeps working."
confirmLabel="Go offline"
destructive
busy={busy === 'on'}
onConfirm={() => void set(true)}
onCancel={() => setConfirming(false)}
/>
) : null}
</>
);
}
+268
View File
@@ -0,0 +1,268 @@
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { bytes, num, recent, when } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Grid,
KeyValue,
Loading,
PageHead,
PlainTiles,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import type { RuntimeStatus, ViewsReport } from '../api/types';
/* The page an operator lands on. It answers one question — is anything wrong — and hands
off to the page that can do something about it. Nothing here is editable on purpose:
somewhere that both summarises and changes state is where an accidental click lives. */
export function OverviewPage() {
const { status, error, loading } = useGateway();
// Process statistics are their own endpoint and their own tick: they are the one thing
// here that says nothing about the household and everything about the container.
const runtime = useQuery<RuntimeStatus>('/admin/api/runtime', { pollMs: 30_000 });
const views = useQuery<ViewsReport>('/admin/api/views', { pollMs: 60_000 });
if (loading || !status) {
return (
<>
<PageHead title="Overview" intro="What the gateway is doing right now." />
<Banner message={error} />
<Loading />
</>
);
}
const features = status.features ?? { features: [], revision: 0, safeMode: false };
const featureList = features.features ?? [];
const clients = status.clients ?? [];
const online = clients.filter((client) => recent(client.lastSeen)).length;
const policy = status.updatePolicy ?? ({} as typeof status.updatePolicy);
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
const playback = status.playbackPolicy;
const mdblist = status.mdblist;
const forYou = status.forYou;
const runs = (status.runs ?? []).slice(0, 5);
const memory = runtime.data;
return (
<>
<PageHead title="Overview" intro="What the gateway is doing right now." />
<Banner message={error} />
{/* The marks are the areas these numbers belong to, in the tones the rest of the
console uses for them: the library is teal wherever it is counted, a person is
violet, a television is blue. They are the same on the pages these link to. */}
<Tiles
tiles={[
{ label: 'items in the library', value: num(status.library.total), icon: 'library', tone: 'data' },
{
label: 'people signed in',
value: num((status.requestUsers ?? []).length),
icon: 'people',
tone: 'note',
},
{
label: `devices · ${online} active now`,
value: num(clients.length),
icon: 'tv',
tone: 'info',
},
{
label: `visits today · ${views.data?.lastWeek.visits ?? 0} this time last week`,
value: num(views.data?.today.visits),
icon: 'overview',
tone: 'data',
},
{
label: `viewers today · ${views.data?.lastWeek.viewers ?? 0} this time last week`,
value: num(views.data?.today.viewers),
icon: 'people',
tone: 'note',
},
{
label: 'optional features on',
value: `${featureList.filter((feature) => feature.enabled).length} / ${featureList.length}`,
icon: 'sliders',
tone: 'ok',
},
{ label: 'last import', value: when(status.library.lastSynced), small: true, icon: 'clock' },
]}
/>
<Grid cols="2">
<Card
title="What televisions are being told"
intro="The answers the gateway is giving every set right now."
icon="tv"
tone="info"
>
<KeyValue
rows={[
{
label: 'Availability',
value: status.maintenance?.enabled ? (
<Tag tone="bad">offline for maintenance</Tag>
) : (
<Tag tone="ok">online</Tag>
),
},
{
label: 'Feature control plane',
value: features.safeMode ? (
<Tag tone="warn">safe mode · optional features off</Tag>
) : (
<Tag tone="ok">revision r{num(features.revision)}</Tag>
),
},
{
label: 'App update prompt',
value: !policy.enabled ? (
<Tag>off</Tag>
) : (
<Tag tone={required ? 'warn' : 'ok'}>
{required ? 'required · ' : 'optional · '}
{policy.latestVersion}
</Tag>
),
},
{
label: 'Catalogue import',
value: status.syncRunning ? (
<Tag tone="warn">running</Tag>
) : (
<Tag>every {status.syncEvery}</Tag>
),
},
{
label: 'Playback preroll',
value: playback?.prerollEnabled === false ? (
<Tag>off</Tag>
) : (
<Tag tone="ok">{(playback?.prerollDurationMs ?? 6500) / 1000}s</Tag>
),
},
]}
/>
</Card>
<Card
title="Services"
intro="The services this gateway leans on, and whether they answered."
icon="wrench"
tone="note"
>
<KeyValue
rows={[
{
label: 'Movies (Radarr)',
value: status.radarrReady ? <Tag tone="ok">ready</Tag> : <Tag>not configured</Tag>,
},
{
label: 'Series (Sonarr)',
value: status.sonarrReady ? <Tag tone="ok">ready</Tag> : <Tag>not configured</Tag>,
},
{
label: 'MDBList ratings',
value: mdblist?.enabled ? (
<Tag tone="ok">{num(mdblist.cachedTitles)} titles stored</Tag>
) : (
<Tag>{mdblist?.apiKeyConfigured ? 'off · key saved' : 'off · no key'}</Tag>
),
},
{
label: 'For You pools',
value: status.forYouRunning ? (
<Tag tone="warn">rebuilding</Tag>
) : (
<Tag>{num(forYou?.candidates ?? 0)} ranked candidates</Tag>
),
},
{
label: 'Recommendation profiles',
value: <span className="mono">{num(forYou?.profiles ?? 0)}</span>,
},
]}
/>
</Card>
</Grid>
<Grid cols="2">
<Card
title="Latest imports"
intro="The last few catalogue synchronisations."
icon="sync"
tone="data"
actions={<Link to="/admin/imports">All imports</Link>}
>
<TableWrap>
<table>
<thead>
<tr>
<th>Started</th>
<th>Kind</th>
<th>Status</th>
<th className="num">Written</th>
</tr>
</thead>
<tbody>
{runs.length === 0 ? (
<EmptyRow columns={4}>No imports have run yet.</EmptyRow>
) : (
runs.map((run) => (
<tr key={run.id || run.startedAt}>
<td className="nowrap muted">{when(run.startedAt)}</td>
<td>{run.kind}</td>
<td>
<Tag
tone={run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad'}
>
{run.status}
</Tag>
</td>
<td className="num">{num(run.itemsUpserted)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="Process"
intro="The container the gateway is served from."
icon="chip"
tone="info"
>
{memory ? (
<>
<PlainTiles
tiles={[
{ label: 'goroutines', value: num(memory.goroutines) },
{ label: 'heap in use', value: bytes(memory.heapInuse) },
{ label: 'reserved', value: bytes(memory.sys) },
{ label: 'collections', value: num(memory.numGc) },
]}
/>
<p className="hint">
Next collection at {bytes(memory.nextGc)} · memory limit{' '}
{memory.memoryLimit > 0 && memory.memoryLimit < Number.MAX_SAFE_INTEGER
? `${bytes(memory.memoryLimit)}${memory.configuredLimit ? ' (GOMEMLIMIT)' : ''}`
: 'no limit set'}{' '}
· {memory.gomaxprocs} processors available.
</p>
</>
) : (
<p className="hint">Reading process statistics</p>
)}
</Card>
</Grid>
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Field, Loading, PageHead, Tag, Toggle } from '../components/ui';
export function PlaybackPage() {
const { status, error, loading, reload } = useGateway();
const { wrap, show } = useToast();
const { busy, run } = useAction();
const [enabled, setEnabled] = useState(true);
const [seconds, setSeconds] = useState('6.5');
const [touched, setTouched] = useState(false);
useEffect(() => {
if (touched || !status) return;
setEnabled(status.playbackPolicy?.prerollEnabled !== false);
setSeconds(String((status.playbackPolicy?.prerollDurationMs ?? 6500) / 1000));
}, [status, touched]);
const save = () =>
run('save', async () => {
const value = Number(seconds);
// Checked here as well as on the server because the server clamps rather than
// refusing, and a value silently corrected to 30 would look like the field not
// having saved.
if (!Number.isFinite(value) || value < 1 || value > 30) {
show('The preroll duration must be between 1 and 30 seconds.', 'bad');
return;
}
await wrap(
() =>
api.post('/admin/api/playback-policy', {
prerollEnabled: enabled,
prerollDurationMs: Math.round(value * 1000),
}),
'Playback policy saved.',
);
setTouched(false);
await reload();
});
return (
<>
<PageHead title="Playback" intro="Presentation policy sent with every playback launch." />
<Banner message={error} />
{loading ? (
<Loading rows={1} />
) : (
<Card
title="Upcoming-show preroll"
intro="Sent with every playback launch. A change applies to the next title opened on every gateway-connected television; no app release is required."
icon="play"
tone="info"
actions={enabled ? <Tag tone="ok">on · {seconds}s</Tag> : <Tag>off</Tag>}
footer={
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save playback policy
</Button>
}
>
<Toggle
label="Show the preroll before a title starts"
checked={enabled}
onChange={(next) => {
setEnabled(next);
setTouched(true);
}}
/>
<div className="fields">
<Field label="Duration" hint="Between 1 and 30 seconds. The stream is already playing behind it.">
<input
type="number"
min={1}
max={30}
step={0.5}
value={seconds}
onChange={(event) => {
setSeconds(event.target.value);
setTouched(true);
}}
/>
</Field>
</div>
</Card>
)}
</>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num } from '../lib/format';
import {
Banner,
Button,
Card,
Empty,
Field,
Grid,
Loading,
PageHead,
Tag,
Tiles,
Toggle,
} from '../components/ui';
/* Display names for the providers MDBList answers with. An unknown key is shown as itself
rather than hidden — a source the server offers and the console cannot name is still a
source the operator may want on. */
const sourceNames: Record<string, string> = {
imdb: 'IMDb',
tomatoes: 'Rotten Tomatoes',
audience: 'Rotten Tomatoes Audience',
metacritic: 'Metacritic',
letterboxd: 'Letterboxd',
rogerebert: 'Roger Ebert',
tmdb: 'TMDb',
trakt: 'Trakt',
mal: 'MyAnimeList',
anilist: 'AniList',
anidb: 'AniDB',
kitsu: 'Kitsu',
score: 'MDBList Score',
score_average: 'MDBList Average',
};
export function RatingsPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [enabled, setEnabled] = useState(false);
const [apiKey, setApiKey] = useState('');
const [clearKey, setClearKey] = useState(false);
const [sources, setSources] = useState<string[]>([]);
const [touched, setTouched] = useState(false);
const mdblist = status?.mdblist;
useEffect(() => {
if (touched || !mdblist) return;
setEnabled(mdblist.enabled);
setSources(mdblist.sources ?? []);
}, [mdblist, touched]);
const save = () =>
run('save', async () => {
await wrap(
() =>
api.post('/admin/api/mdblist-settings', {
enabled,
apiKey: apiKey.trim(),
clearApiKey: clearKey,
sources,
}),
'Ratings settings saved.',
);
// The key field is emptied after a save whether or not one was typed: it is a
// credential, and leaving it on screen is the one thing a shoulder can read.
setApiKey('');
setClearKey(false);
setTouched(false);
await reload();
});
const cached = mdblist?.cachedTitles ?? 0;
return (
<>
<PageHead title="Movie ratings" intro="Optional MDBList scores on films and shows." />
<Banner message={error} />
{loading || !mdblist ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'titles stored', value: num(cached), icon: 'database', tone: 'data' },
{ label: 'due to be re-checked', value: num(mdblist.staleTitles), icon: 'sync', tone: 'warn' },
{ label: 'sources shown', value: num((mdblist.sources ?? []).length), icon: 'star', tone: 'note' },
{
label: 'API key',
value: mdblist.apiKeyConfigured ? 'saved' : 'not set',
small: true,
icon: 'key',
tone: mdblist.apiKeyConfigured ? 'ok' : undefined,
},
]}
/>
<Grid cols="2">
<Card
title="MDBList connection"
intro="The key stays on this server and a failure never blocks a television. Every rating fetched is stored here permanently and re-checked about once a month, so browsing the library costs nothing after the first look at a title."
icon="star"
tone="note"
actions={
enabled ? (
<Tag tone="ok">on · {sources.length} sources</Tag>
) : (
<Tag>{mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key'}</Tag>
)
}
>
<Toggle
label="Show external ratings on televisions"
hint="Off leaves the stored ratings in place."
checked={enabled}
onChange={(next) => {
setEnabled(next);
setTouched(true);
}}
/>
<Field label="API key" hint="Leave blank to keep the key that is already saved.">
<input
type="password"
autoComplete="new-password"
value={apiKey}
placeholder={mdblist.apiKeyConfigured ? 'Saved key (leave blank to keep)' : 'Paste an API key'}
onChange={(event) => setApiKey(event.target.value)}
/>
</Field>
<Toggle label="Remove the saved key" checked={clearKey} onChange={setClearKey} />
</Card>
<Card
title="Sources shown on televisions"
intro="A title with none of these has no ratings strip at all, which is the honest answer — nothing stands in for a score that was never fetched."
icon="list"
tone="data"
>
{(mdblist.availableSources ?? []).length === 0 ? (
<Empty>No rating sources are available.</Empty>
) : (
<div className="checks columns">
{(mdblist.availableSources ?? []).map((source) => (
<Toggle
key={source}
label={sourceNames[source] ?? source}
checked={sources.includes(source)}
onChange={(on) => {
setTouched(true);
setSources((current) =>
on ? [...current, source] : current.filter((entry) => entry !== source),
);
}}
/>
))}
</div>
)}
</Card>
</Grid>
<Card>
<div className="row">
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save ratings settings
</Button>
<span className="hint">
{cached
? 'Ratings are fetched as televisions browse, never on the request path.'
: 'No ratings stored yet. They are saved as televisions browse the library.'}
</span>
</div>
</Card>
</>
)}
</>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Confirm, Loading, PageHead, Tiles } from '../components/ui';
export function RecommendationsPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [confirming, setConfirming] = useState(false);
const forYou = status?.forYou;
const running = Boolean(status?.forYouRunning);
const act = (action: string, key: string, message: string) =>
run(key, async () => {
await wrap(() => api.post('/admin/api/for-you', { action }), message);
setConfirming(false);
await reload();
});
return (
<>
<PageHead title="For You" intro="The prepared pools personalised rows are drawn from." />
<Banner message={error} />
{loading ? (
<Loading rows={2} />
) : (
<>
<Tiles
tiles={[
{ label: 'Tracearr sessions', value: num(forYou?.tracearrSessions ?? 0), icon: 'play', tone: 'info' },
{ label: 'user profiles', value: num(forYou?.profiles ?? 0), icon: 'people', tone: 'note' },
{ label: 'ranked candidates', value: num(forYou?.candidates ?? 0), icon: 'sparkle', tone: 'note' },
{ label: 'last full import', value: when(forYou?.lastFullImport), small: true, icon: 'clock' },
]}
/>
<Card
title="Pool maintenance"
intro="Prepared pools refresh in the background; these are the manual versions of the same work. A rebuild is safe at any time — televisions read the last finished pool until a new one lands."
icon="sparkle"
tone="note"
footer={
<span className="hint">
{running ? 'For You maintenance running…' : 'Prepared pools normally refresh in the background.'}
</span>
}
>
<div className="row">
<Button
variant="primary"
icon="download"
disabled={running}
busy={busy === 'import'}
onClick={() => void act('incremental-import', 'import', 'Import started.')}
>
Import recent sessions
</Button>
<Button icon="database" disabled={running} onClick={() => setConfirming(true)}>
Full Tracearr backfill
</Button>
<Button
icon="sync"
disabled={running}
busy={busy === 'rebuild'}
onClick={() => void act('rebuild-all', 'rebuild', 'Rebuild started.')}
>
Rebuild all pools
</Button>
</div>
</Card>
<Card
title="Reading a person's scores"
intro="The inspector re-runs the shared weighted scorer over one person's prepared pool, after Emby permission and parental-control filtering, and shows every component and evidence reason behind the order."
icon="search"
tone="info"
actions={<Link to="/admin/inspector">Open the inspector</Link>}
>
<></>
</Card>
</>
)}
{confirming ? (
<Confirm
title="Backfill all Tracearr history?"
body="Every session is re-read and every active user's pool is rebuilt. It is safe at any time — televisions keep reading the last finished pool — but on a long history it takes a while."
confirmLabel="Backfill"
busy={busy === 'full'}
onConfirm={() => void act('full-import', 'full', 'Backfill started.')}
onCancel={() => setConfirming(false)}
/>
) : null}
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useMemo } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { when } from '../lib/format';
import { Banner, Button, Card, Empty, Loading, PageHead, TableWrap, Tag } from '../components/ui';
export function RequestsPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const users = status?.requestUsers ?? [];
const allowed = status?.requestPolicy?.allowedUserIds ?? [];
const usage = useMemo(() => new Map((status?.requestUsage ?? []).map((entry) => [entry.userId, entry])), [status?.requestUsage]);
const toggle = (id: string) => run(`access-${id}`, async () => {
const next = allowed.includes(id) ? allowed.filter((entry) => entry !== id) : [...allowed, id];
await wrap(
() => api.post('/admin/api/request-policy', { allowedUserIds: next }),
next.includes(id) ? 'Request access granted.' : 'Request access removed.',
);
await reload();
});
return (
<>
<PageHead title="Media requests" intro="Who can ask for something the library does not have." />
<Banner message={error} />
{loading ? (
<Loading rows={2} />
) : (
<>
<Card
title="Where a request goes"
intro="A movie or series is monitored and searched for immediately. The configured download service handles it from there."
icon="inbox"
tone="info"
actions={
<>
<Tag tone={status?.radarrReady ? 'ok' : 'bad'}>
Movies {status?.radarrReady ? 'ready' : 'not configured'}
</Tag>
<Tag tone={status?.sonarrReady ? 'ok' : 'bad'}>
Series {status?.sonarrReady ? 'ready' : 'not configured'}
</Tag>
</>
}
>
{!status?.radarrReady && !status?.sonarrReady ? (
<Empty>
Neither Radarr nor Sonarr is configured, so a request would have nowhere to go. The
button stays hidden on every television until one of them is.
</Empty>
) : null}
</Card>
<Card
title="Request access and activity"
intro="One button grants or removes access. A recorded request has already been sent to Radarr or Sonarr; Memby does not duplicate their download state."
icon="people"
tone="note"
>
{users.length === 0 ? (
<Empty>No one has signed in yet.</Empty>
) : (
<TableWrap><table><thead><tr><th>User</th><th>Last seen</th><th className="num">Sent to services</th><th>Last request</th><th>Access</th></tr></thead><tbody>
{users.map((user) => { const userUsage = usage.get(user.id); const granted = allowed.includes(user.id); return <tr key={user.id}><td><b>{user.username}</b></td><td className="muted nowrap">{when(user.lastSeen)}</td><td className="num">{userUsage?.requests ?? 0}</td><td className="muted nowrap">{userUsage?.lastRequest ? when(userUsage.lastRequest) : '—'}</td><td><Button size="sm" variant={granted ? 'quiet' : 'primary'} busy={busy === `access-${user.id}`} onClick={() => void toggle(user.id)}>{granted ? 'Remove access' : 'Give access'}</Button></td></tr>; })}
</tbody></table></TableWrap>
)}
</Card>
</>
)}
</>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useState } from 'react';
import { useQuery } from '../lib/hooks';
import { num, when } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Field,
Loading,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
interface SearchTerm {
query: string;
searches: number;
viewers: number;
lastAt: string;
}
interface SearchEvent {
occurredAt: string;
userId: string;
username: string;
query: string;
}
interface SearchesResponse {
days: number;
retentionDays: number;
totals: { searches: number; queries: number; viewers: number };
terms: SearchTerm[] | null;
recent: SearchEvent[] | null;
}
/* Two tables of the same rows on purpose: the summary groups by query and answers "what
does this house look for", which is what a library is organised against; the log is
uncollapsed and newest-first and answers "what happened just now", which is the one to
read when somebody reports that search is not finding something. */
export function SearchesPage() {
const [days, setDays] = useState(7);
const { data, error, loading } = useQuery<SearchesResponse>(`/admin/api/searches?days=${days}`);
const terms = data?.terms ?? [];
const recent = data?.recent ?? [];
const totals = data?.totals;
return (
<>
<PageHead
title="Searches"
intro="What the household has been looking for, and what it searched just now."
/>
<Banner message={error} />
<Tiles
tiles={[
{ label: 'searches', value: num(totals?.searches ?? 0), icon: 'search', tone: 'info' },
{ label: 'distinct queries', value: num(totals?.queries ?? 0), icon: 'list', tone: 'data' },
{ label: 'viewers searching', value: num(totals?.viewers ?? 0), icon: 'people', tone: 'note' },
// Stated rather than assumed: every figure on this page is bounded by how long
// the table keeps a row, and an operator reading a quiet week has no other way to
// tell a household that stopped searching from one whose history has aged out.
{
label: 'history kept',
value: `${data?.retentionDays ?? 30} days`,
small: true,
icon: 'clock',
},
]}
/>
{loading ? (
<Loading />
) : (
<>
<Card
title="What the house looks for"
intro="Queries the search tab ran, grouped without regard to case and labelled with the most recent spelling. Instant search asks from the second character, so a title typed slowly leaves its prefixes here too."
icon="search"
tone="info"
actions={
<Field label="Window">
<select value={days} onChange={(event) => setDays(Number(event.target.value))}>
<option value={1}>24 hours</option>
<option value={7}>7 days</option>
<option value={30}>30 days</option>
</select>
</Field>
}
>
<TableWrap>
<table>
<thead>
<tr>
<th>Query</th>
<th className="num">Searches</th>
<th className="num">Viewers</th>
<th>Last searched</th>
</tr>
</thead>
<tbody>
{terms.length === 0 ? (
<EmptyRow columns={4}>Nothing searched in this window.</EmptyRow>
) : (
terms.map((term) => (
<tr key={term.query}>
<td>{term.query}</td>
<td className="num">{num(term.searches)}</td>
<td className="num">{num(term.viewers)}</td>
<td className="muted nowrap">{when(term.lastAt)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="As it happened"
intro="The log, newest first — the query exactly as it was typed, and who typed it. This is the one to read when somebody says search is not finding something."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th>When</th>
<th>Viewer</th>
<th>Query</th>
</tr>
</thead>
<tbody>
{recent.length === 0 ? (
<EmptyRow columns={3}>No searches in this window.</EmptyRow>
) : (
recent.map((event, index) => (
<tr key={`${event.occurredAt}:${index}`}>
<td className="muted nowrap">{when(event.occurredAt)}</td>
{/* An unattributed search keeps its row and shows the id: the query
is the point, and a viewer whose sessions have all expired is
still one searcher rather than nobody. */}
<td>
{event.username || <Tag tone="warn">{event.userId || 'unknown'}</Tag>}
</td>
<td>{event.query}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}
+319
View File
@@ -0,0 +1,319 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { num, when } from '../lib/format';
import {
Banner,
Button,
Card,
Chip,
Confirm,
Empty,
EmptyRow,
Loading,
PageHead,
TableWrap,
Tag,
} from '../components/ui';
/* One person's settings, over time.
*
* The two questions this answers are different and both need the whole page. "What did I
* change and can I undo it" is the table; "why is that television still wrong" is the
* device list above it, because a revision the server wrote is not a revision a set has
* taken. */
interface PreferenceDefinition {
key: string;
name: string;
kind: 'toggle' | 'choice' | 'number' | 'multi' | 'list';
options?: { value: string; label: string }[];
unit?: string;
}
interface Change {
name: string;
before: string;
after: string;
}
interface Revision {
revision: number;
createdAt: string;
author: string;
source: string;
current?: boolean;
initial?: boolean;
restoredFrom?: number;
changes: Change[] | null;
acks: { deviceId: string; deviceName: string }[] | null;
preferences?: Record<string, unknown>;
}
interface HistoryDevice {
name: string;
deviceId: string;
revision: number;
behind: number;
never: boolean;
signedOut: boolean;
ackedAt: string;
lastSeen: string;
clientVersion: string;
}
interface HistoryResponse {
username: string;
saved: boolean;
currentRevision: number;
currentSource: string;
devices: HistoryDevice[] | null;
revisions: Revision[] | null;
catalogue: PreferenceDefinition[] | null;
}
/** The same wording the server puts in a change line, so a document and a diff never
* describe one value two ways. */
function describe(definition: PreferenceDefinition, value: unknown): string {
if (definition.kind === 'toggle') return value ? 'On' : 'Off';
if (definition.kind === 'choice') {
const match = (definition.options ?? []).find((option) => option.value === value);
return match ? match.label : String(value ?? '');
}
if (definition.kind === 'number') {
if (Number(value) === 0 && definition.unit) return 'No limit';
return definition.unit ? `${value} ${definition.unit}` : String(value ?? '');
}
const entries = Array.isArray(value) ? (value as string[]) : [];
if (entries.length === 0) return 'None';
return entries
.map((entry) => (definition.options ?? []).find((option) => option.value === entry)?.label ?? entry)
.join(', ');
}
export function SettingsHistoryPage() {
const { userId = '' } = useParams();
const { wrap } = useToast();
const { busy, run } = useAction();
const base = `/admin/api/accounts/${encodeURIComponent(userId)}`;
const { data, error, loading, reload } = useQuery<HistoryResponse>(`${base}/preferences/history`, {
pollMs: 30_000,
});
// Which revisions have their full document open. Kept in state rather than in the DOM so
// the poll can redraw the table without closing what the operator opened.
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [restoring, setRestoring] = useState<number | null>(null);
const name = data?.username || 'this account';
const devices = data?.devices ?? [];
const revisions = data?.revisions ?? [];
const catalogue = data?.catalogue ?? [];
const toggle = (revision: number) =>
setExpanded((current) => {
const next = new Set(current);
if (next.has(revision)) next.delete(revision);
else next.add(revision);
return next;
});
const restore = (revision: number) =>
run('restore', async () => {
await wrap(
() => api.post(`${base}/preferences/revisions/${revision}/restore`),
`Restored r${revision}.`,
);
setRestoring(null);
await reload();
});
return (
<>
<PageHead
title="Settings history"
intro={`Every change to ${name}'s synced settings, and which of their televisions has taken it.`}
crumbs={<Link to={`/admin/accounts/${encodeURIComponent(userId)}`}> {name}</Link>}
/>
<Banner message={error} />
{loading ? (
<Loading />
) : (
<>
<Card
title="Where each device has got to"
intro="A set takes a change by fetching it, which it does within a few seconds of being told — so anything still behind is switched off, mid-film, or cannot reach the gateway."
icon="tv"
tone="info"
actions={
data?.saved ? (
<Tag tone={data.currentSource === 'admin' ? 'warn' : 'ok'}>
now on r{num(data.currentRevision)} · {data.currentSource || 'device'}
</Tag>
) : (
<Tag>defaults · never synced</Tag>
)
}
>
{devices.length === 0 ? (
<Empty>No television has been signed in to this account.</Empty>
) : (
<div className="list">
{devices.map((device) => {
const tone = device.never ? undefined : device.behind ? 'bad' : 'ok';
return (
<div className="list-item" key={device.deviceId || device.name}>
<div className="list-body">
<b>
<span className="dot-state" data-tone={tone} />{' '}
{device.name || 'Memby TV'}{' '}
{device.signedOut ? <Tag>signed out</Tag> : null}
</b>
<p>
{device.never
? 'Has not fetched these settings yet'
: `Holding r${num(device.revision)} · taken ${when(device.ackedAt)}`}
{device.clientVersion ? ` · Memby ${device.clientVersion}` : ''}
{device.signedOut ? '' : ` · last seen ${when(device.lastSeen)}`}
</p>
</div>
<div className="list-actions">
{device.never ? (
<Tag>never taken one</Tag>
) : device.behind ? (
<Tag tone="bad">{num(device.behind)} behind</Tag>
) : (
<Tag tone="ok">up to date</Tag>
)}
</div>
</div>
);
})}
</div>
)}
</Card>
<Card
title="Change history"
intro="Restoring puts an earlier version back as a new change, so the televisions notice it and the version it replaced stays here to return to."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th>When</th>
<th className="num">Rev</th>
<th>Changed by</th>
<th>What changed</th>
<th className="num">Taken by</th>
<th />
</tr>
</thead>
<tbody>
{revisions.length === 0 ? (
<EmptyRow columns={6}>Nothing has been changed on this account yet.</EmptyRow>
) : (
revisions.flatMap((revision) => {
const open = expanded.has(revision.revision);
const acks = revision.acks ?? [];
const changes = revision.changes ?? [];
const rows = [
<tr key={revision.revision}>
<td className="nowrap muted">{when(revision.createdAt)}</td>
<td className="num nowrap">
r{num(revision.revision)} {revision.current ? <Tag tone="ok">current</Tag> : null}
</td>
<td className="nowrap">
<Tag tone={revision.source === 'admin' ? 'warn' : 'ok'}>{revision.author}</Tag>
{revision.restoredFrom ? (
<span className="muted"> restored r{num(revision.restoredFrom)}</span>
) : null}
</td>
<td className="muted">
{revision.initial ? (
<span className="muted">First recorded settings</span>
) : changes.length === 0 ? (
// A write that changed nothing an operator can see: a
// television pushing the document it already held, usually.
// Saying so is more useful than an empty cell.
<span className="muted">No visible change</span>
) : (
<div className="chips">
{changes.map((change, index) => (
<Chip key={`${change.name}:${index}`}>
{change.name}: {change.before} {change.after}
</Chip>
))}
</div>
)}
</td>
<td className="num">
{acks.length === 0 ? (
<span className="muted"></span>
) : (
<span title={acks.map((ack) => ack.deviceName || ack.deviceId).join(', ')}>
{num(acks.length)}
</span>
)}
</td>
<td className="num nowrap">
<span className="list-actions">
<Button size="sm" onClick={() => toggle(revision.revision)}>
{open ? 'Hide' : 'Show'}
</Button>
{revision.current ? null : (
<Button size="sm" onClick={() => setRestoring(revision.revision)}>
Restore
</Button>
)}
</span>
</td>
</tr>,
];
if (open) {
// The whole document at one revision, in the catalogue's own order
// and wording. This is what makes a restore a decision rather than
// a guess.
rows.push(
<tr key={`${revision.revision}:detail`}>
<td colSpan={6} className="muted">
<div className="chips">
{catalogue.map((definition) => (
<Chip key={definition.key}>
{definition.name}:{' '}
{describe(definition, revision.preferences?.[definition.key])}
</Chip>
))}
</div>
</td>
</tr>,
);
}
return rows;
})
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
{restoring !== null ? (
<Confirm
title={`Restore revision ${restoring}?`}
body="It goes out as a new change, so every one of their televisions will pick it up — and the current version stays in this history to return to."
confirmLabel="Restore"
busy={busy === 'restore'}
onConfirm={() => void restore(restoring)}
onCancel={() => setRestoring(null)}
/>
) : null}
</>
);
}
+316
View File
@@ -0,0 +1,316 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { bytes, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
Confirm,
Empty,
Field,
Grid,
Loading,
PageHead,
Tag,
Tiles,
Toggle,
} from '../components/ui';
interface TestResult {
provider: string;
ok: boolean;
message: string;
}
interface Draft {
bazarr: boolean;
openSubtitles: boolean;
key: string;
clearKey: boolean;
username: string;
password: string;
clearLogin: boolean;
}
export function SubtitlesPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [draft, setDraft] = useState<Draft | null>(null);
const [results, setResults] = useState<TestResult[] | null>(null);
const [confirming, setConfirming] = useState(false);
const subtitles = status?.subtitles;
const stored = subtitles?.stored as { count?: number; bytes?: number; latest?: string } | undefined;
useEffect(() => {
if (draft || !subtitles) return;
setDraft({
bazarr: subtitles.bazarrEnabled,
openSubtitles: subtitles.openSubtitlesEnabled,
key: '',
clearKey: false,
username: subtitles.openSubtitlesUsername ?? '',
password: '',
clearLogin: false,
});
}, [subtitles, draft]);
const patch = (next: Partial<Draft>) => setDraft((current) => (current ? { ...current, ...next } : current));
const save = () =>
run('save', async () => {
if (!draft) return;
await wrap(
() =>
api.post('/admin/api/subtitle-settings', {
bazarrEnabled: draft.bazarr,
openSubtitlesEnabled: draft.openSubtitles,
openSubtitlesApiKey: draft.key.trim(),
clearOpenSubtitlesApiKey: draft.clearKey,
openSubtitlesUsername: draft.username.trim(),
openSubtitlesPassword: draft.password,
clearOpenSubtitlesLogin: draft.clearLogin,
}),
'Subtitle settings saved.',
);
// The credential fields are emptied on the way out, so a saved page never has a
// secret sitting in a form somebody could walk past.
setDraft(null);
await reload();
});
const test = () =>
run('test', async () => {
setResults(null);
const answer = await wrap(() =>
api.post<{ results: TestResult[] | null }>('/admin/api/subtitle-test'),
);
setResults(answer?.results ?? []);
});
const clearStored = () =>
run('clear', async () => {
await wrap(
() => api.post('/admin/api/subtitle-settings', { action: 'clear-stored' }),
'Stored subtitles deleted.',
);
setConfirming(false);
await reload();
});
return (
<>
<PageHead title="Subtitles" intro="Which providers a viewer may fetch a missing subtitle from." />
<Banner message={error} />
{loading || !subtitles || !draft ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{
label: 'offered on televisions',
value: subtitles.available ? 'yes' : 'no',
small: true,
icon: 'captions',
tone: subtitles.available ? 'ok' : undefined,
},
{
label: 'providers on',
value: num(
(subtitles.bazarrEnabled && subtitles.bazarrConfigured ? 1 : 0) +
(subtitles.openSubtitlesEnabled ? 1 : 0),
),
icon: 'list',
tone: 'note',
},
{ label: 'subtitles held', value: num(stored?.count ?? 0), icon: 'database', tone: 'data' },
{ label: 'last fetched', value: when(stored?.latest), small: true, icon: 'clock' },
]}
/>
<Grid cols="2">
<Card
title="Bazarr"
intro="Bazarr writes the subtitle file beside the media file, so Emby finds it and the track behaves like one that was always there. Its address is deployment configuration; this switch only decides whether viewers may use it."
icon="wrench"
tone="data"
actions={
!subtitles.bazarrConfigured ? (
<Tag>not configured</Tag>
) : draft.bazarr ? (
<Tag tone="ok">on</Tag>
) : (
<Tag>off</Tag>
)
}
footer={
// The address is worth printing: it is the one thing on this page an
// operator cannot change here, so seeing which Bazarr is meant is how they
// find out it is the wrong one.
<span className="hint">
{subtitles.bazarrConfigured
? `Configured at ${subtitles.bazarrUrl}`
: 'Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr.'}
</span>
}
>
<Toggle
label="Offer Bazarr in the player"
hint="Off leaves every subtitle it has already written in place."
checked={draft.bazarr}
disabled={!subtitles.bazarrConfigured}
onChange={(next) => patch({ bazarr: next })}
/>
</Card>
<Card
title="OpenSubtitles"
intro="OpenSubtitles hands back a file rather than writing one, so Memby keeps what it fetches and serves it to the television itself. Titles are matched on their IMDb or TMDb id, which is exact — there is no guessing at a name."
icon="captions"
tone="note"
actions={
subtitles.openSubtitlesEnabled ? (
<Tag tone={subtitles.openSubtitlesAccount ? 'ok' : 'warn'}>
{subtitles.openSubtitlesAccount ? 'on · signed in' : 'on · anonymous'}
</Tag>
) : (
<Tag>{subtitles.openSubtitlesKeyConfigured ? 'off · key saved' : 'off · no key'}</Tag>
)
}
>
<Toggle
label="Offer OpenSubtitles in the player"
hint="Needs an API key. It cannot be switched on without one."
checked={draft.openSubtitles}
onChange={(next) => patch({ openSubtitles: next })}
/>
<Field
label="API key"
hint="From your consumer at opensubtitles.com. Leave blank to keep the saved key."
>
<input
type="password"
autoComplete="new-password"
value={draft.key}
placeholder={
subtitles.openSubtitlesKeyConfigured ? 'Saved key (leave blank to keep)' : 'Paste an API key'
}
onChange={(event) => patch({ key: event.target.value })}
/>
</Field>
<Toggle
label="Remove the saved key"
checked={draft.clearKey}
onChange={(next) => patch({ clearKey: next })}
/>
<div className="fields">
<Field
label="Account username"
hint="Optional, and the difference between a working feature and one that stops after a few files: without an account, downloads come out of the small anonymous allowance."
>
<input
type="text"
autoComplete="off"
value={draft.username}
placeholder="Not signed in"
onChange={(event) => patch({ username: event.target.value })}
/>
</Field>
<Field label="Account password" hint="Leave blank to keep the saved one.">
<input
type="password"
autoComplete="new-password"
value={draft.password}
onChange={(event) => patch({ password: event.target.value })}
/>
</Field>
</div>
<Toggle
label="Sign out and forget the account"
checked={draft.clearLogin}
onChange={(next) => patch({ clearLogin: next })}
/>
</Card>
</Grid>
<Card>
<div className="row">
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save subtitle settings
</Button>
<Button busy={busy === 'test'} icon="pulse" onClick={() => void test()}>
Test the providers
</Button>
{/* The feature flag overrides both switches, so a page that stayed silent
about it would be showing two controls that visibly do nothing. */}
<span className="hint">
{subtitles.featureEnabled
? 'A change applies to the next title opened; no app release is required.'
: 'Downloading subtitles is switched off on the Features page, so nothing here is offered.'}
</span>
</div>
{results === null ? null : results.length === 0 ? (
<Empty>No provider is switched on, so there was nothing to ask.</Empty>
) : (
<div className="list">
{results.map((result) => (
<div className="list-item" key={result.provider}>
<div className="list-body">
<b>{result.provider}</b>
<p>{result.message}</p>
</div>
<div className="list-actions">
<Tag tone={result.ok ? 'ok' : 'bad'}>{result.ok ? 'reachable' : 'not reachable'}</Tag>
</div>
</div>
))}
</div>
)}
</Card>
<Card
title="Subtitles Memby is holding"
intro="Only files fetched from a provider that cannot write beside the media file are kept here; they are served to televisions as ordinary tracks on every later playback. Emptying this is safe — each one can be fetched again, at the cost of the download allowance that fetched it."
icon="database"
tone="data"
actions={
stored?.count ? (
<Tag tone="data">
{num(stored.count)} files · {bytes(stored.bytes)}
</Tag>
) : (
<Tag>nothing held</Tag>
)
}
footer={
<Button variant="danger" disabled={!stored?.count} onClick={() => setConfirming(true)}>
Delete every stored subtitle
</Button>
}
>
<></>
</Card>
</>
)}
{confirming ? (
<Confirm
title="Delete every stored subtitle?"
body="Each one can be fetched again, at the cost of the download allowance that fetched it. Subtitles Bazarr wrote beside the media are untouched — those belong to Emby."
confirmLabel="Delete"
destructive
busy={busy === 'clear'}
onConfirm={() => void clearStored()}
onCancel={() => setConfirming(false)}
/>
) : null}
</>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { useState } from 'react';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { ago, duration, interval, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
EmptyRow,
Loading,
Note,
PageHead,
TableWrap,
Tag,
Tiles,
Toggle,
} from '../components/ui';
import type { ScheduledTask, TaskRun, TasksResponse } from '../api/types';
import type { Tone } from '../lib/format';
/* Scheduled tasks: what the gateway does when nobody is watching.
*
* Polled faster than the console's own heartbeat while a task is running, because the one
* thing an operator does here is press Run now and then watch for the outcome — and a
* thirty-second poll makes a two-second job look like one that did nothing. */
const IDLE_POLL_MS = 20_000;
const BUSY_POLL_MS = 3_000;
function statusTone(status: TaskRun['status']): Tone {
if (status === 'failed') return 'bad';
if (status === 'running') return 'info';
if (status === 'skipped') return 'warn';
return 'ok';
}
export function TasksPage() {
const { wrap } = useToast();
const { busy, run } = useAction();
const [fast, setFast] = useState(false);
const { data, error, loading, reload } = useQuery<TasksResponse>('/admin/api/tasks?limit=60', {
pollMs: fast ? BUSY_POLL_MS : IDLE_POLL_MS,
});
const tasks = data?.tasks ?? [];
const anyRunning = tasks.some((task) => task.running);
if (anyRunning !== fast) setFast(anyRunning);
const runNow = (task: ScheduledTask) =>
run(task.id, async () => {
await wrap(
() => api.post(`/admin/api/tasks/${encodeURIComponent(task.id)}/run`),
`${task.name} started.`,
);
await reload();
});
const setEnabled = (task: ScheduledTask, enabled: boolean) =>
run(`${task.id}:enabled`, async () => {
await wrap(
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { enabled }),
enabled ? `${task.name} switched on.` : `${task.name} switched off.`,
);
await reload();
});
const failures = tasks.filter((task) => task.lastRun?.status === 'failed').length;
const disabled = tasks.filter((task) => !task.enabled).length;
const groups = data?.groups ?? [];
const ungrouped = tasks.filter((task) => !task.group);
return (
<>
<PageHead
title="Scheduled tasks"
intro="The gateway's background work: what it does, when it last ran, how long it took and whether it worked. Every one of these can be started by hand."
/>
<Banner message={error} />
{loading ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'Tasks', value: num(tasks.length), icon: 'clock', tone: 'info' },
{
label: 'Running now',
value: num(tasks.filter((task) => task.running).length),
icon: 'pulse',
tone: anyRunning ? 'ok' : undefined,
},
{
label: 'Last run failed',
value: num(failures),
icon: 'alert',
tone: failures > 0 ? 'bad' : undefined,
},
{
label: 'Switched off',
value: num(disabled),
icon: 'power',
tone: disabled > 0 ? 'warn' : undefined,
},
]}
/>
{failures > 0 ? (
<Note tone="bad">
A failed task publishes an administrative event, so the failure is in the activity feed and
wherever your integrations send it you did not have to be looking at this page.
</Note>
) : null}
{[...groups, ...(ungrouped.length > 0 ? [''] : [])].map((group) => {
const inGroup = tasks.filter((task) => task.group === group);
if (inGroup.length === 0) return null;
return (
<Card
key={group || 'other'}
title={group || 'Other'}
icon={group === 'System' ? 'chip' : group === 'Analytics' ? 'chart' : 'wrench'}
tone={group === 'System' ? 'info' : group === 'Analytics' ? 'data' : 'note'}
>
<div className="list">
{inGroup.map((task) => (
<div className="list-item" key={task.id}>
<div className="list-body">
<b>
{task.name}{' '}
{task.running ? <Tag tone="info">running</Tag> : null}
{!task.enabled ? <Tag tone="warn">off</Tag> : null}
</b>
<p>{task.description}</p>
<p className="quiet">
{interval(task.intervalSeconds)}
{task.enabled && task.nextRun ? ` · next ${ago(task.nextRun).replace(' ago', '')}` : ''}
{task.lastRun ? (
<>
{' · last '}
<span title={when(task.lastRun.startedAt)}>{ago(task.lastRun.startedAt)}</span>
{` in ${duration(task.lastRun.durationMs)}`}
{task.lastRun.detail ? `${task.lastRun.detail}` : ''}
</>
) : (
' · never run'
)}
</p>
{task.lastRun?.error ? (
<p className="mono" style={undefined}>
<Tag tone="bad">{task.lastRun.error}</Tag>
</p>
) : null}
</div>
<div className="list-actions">
{task.lastRun ? (
<Tag tone={statusTone(task.lastRun.status)}>{task.lastRun.status}</Tag>
) : (
<Tag>never run</Tag>
)}
<Toggle
label=""
checked={task.enabled}
disabled={busy === `${task.id}:enabled`}
onChange={(next) => void setEnabled(task, next)}
/>
<Button
size="sm"
icon="play"
busy={busy === task.id}
disabled={task.running}
onClick={() => void runNow(task)}
>
Run now
</Button>
</div>
</div>
))}
</div>
</Card>
);
})}
<Card
title="Recent runs"
intro="Every task together and in order, which is what shows two jobs interfering with each other."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Started</th>
<th>Task</th>
<th>Trigger</th>
<th>Result</th>
<th className="num">Took</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{(data?.runs.length ?? 0) === 0 ? (
<EmptyRow columns={6}>No task has run yet.</EmptyRow>
) : (
data?.runs.map((entry) => (
<tr key={entry.id}>
<td className="nowrap muted" title={when(entry.startedAt)}>
{ago(entry.startedAt)}
</td>
<td>{tasks.find((task) => task.id === entry.taskId)?.name ?? entry.taskId}</td>
<td className="muted">{entry.trigger}</td>
<td>
<Tag tone={statusTone(entry.status)}>{entry.status}</Tag>
</td>
<td className="num muted">{duration(entry.durationMs)}</td>
<td className="muted">{entry.error || entry.detail || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Confirm, Field, Loading, PageHead, Tag, Toggle } from '../components/ui';
interface Draft {
version: string;
url: string;
notes: string;
retireBelow: string;
required: boolean;
destructive: boolean;
}
export function UpdatesPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [draft, setDraft] = useState<Draft | null>(null);
const [confirming, setConfirming] = useState(false);
const policy = status?.updatePolicy;
useEffect(() => {
if (draft || !policy) return;
// "Required" is not a field of its own: it is the minimum and the latest being the
// same version, which is what the client compares against. Same for the destructive
// floor, which is the retire-below version having caught up with the latest.
setDraft({
version: policy.latestVersion ?? '',
url: policy.downloadUrl ?? '',
notes: policy.notes ?? '',
retireBelow: policy.retireBelowVersion ?? '',
required: Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion,
destructive:
Boolean(policy.retireBelowVersion) && policy.retireBelowVersion === policy.latestVersion,
});
}, [policy, draft]);
const save = (enabled: boolean) =>
run(enabled ? 'save' : 'off', async () => {
if (!draft) return;
await wrap(
() =>
api.post('/admin/api/update-policy', {
enabled,
latestVersion: draft.version.trim(),
downloadUrl: draft.url.trim(),
notes: draft.notes.trim(),
required: draft.required,
destructive: draft.destructive,
retireBelowVersion: draft.retireBelow.trim(),
}),
enabled ? 'Update policy saved.' : 'Update prompts turned off.',
);
setConfirming(false);
setDraft(null);
await reload();
});
const required = Boolean(policy?.minimumVersion) && policy?.minimumVersion === policy?.latestVersion;
const destructive =
Boolean(policy?.retireBelowVersion) && policy?.retireBelowVersion === policy?.latestVersion;
const patch = (next: Partial<Draft>) => setDraft((current) => (current ? { ...current, ...next } : current));
return (
<>
<PageHead title="App updates" intro="Publish an optional or a required client update." />
<Banner message={error} />
{loading || !draft ? (
<Loading rows={1} />
) : (
<Card
title="Update policy"
intro="Televisions check on every launch. An optional update is a prompt the viewer can dismiss; a required one covers the home screen until they update, so it needs a download URL that actually works."
icon="download"
tone="info"
actions={
!policy?.enabled ? (
<Tag>off</Tag>
) : (
<Tag tone={required ? 'warn' : 'ok'}>
{destructive ? 'sign-out · ' : required ? 'required · ' : 'optional · '}
{policy.latestVersion}
</Tag>
)
}
footer={
<>
<Button
variant="primary"
busy={busy === 'save'}
onClick={() => (draft.required ? setConfirming(true) : void save(true))}
>
Save policy
</Button>
<Button busy={busy === 'off'} onClick={() => void save(false)}>
Turn prompts off
</Button>
</>
}
>
<div className="fields">
<Field label="Latest version">
<input
type="text"
value={draft.version}
placeholder="0.2.63"
onChange={(event) => patch({ version: event.target.value })}
/>
</Field>
<Field label="APK URL">
<input
type="text"
value={draft.url}
placeholder="https://nas/memby/memby-0.2.63.apk"
onChange={(event) => patch({ url: event.target.value })}
/>
</Field>
</div>
<Field label="What's new" hint="Shown on the television above the update button.">
<input
type="text"
value={draft.notes}
placeholder="One line the viewer reads"
onChange={(event) => patch({ notes: event.target.value })}
/>
</Field>
<Field
label="Sign out builds below"
hint="The destructive compatibility floor. Leave blank to keep every supported viewer signed in."
>
<input
type="text"
value={draft.retireBelow}
placeholder="0.2.44"
onChange={(event) => patch({ retireBelow: event.target.value })}
/>
</Field>
<Toggle
label="Require this update"
hint="Blocks the home screen on every television below this version."
checked={draft.required}
onChange={(next) => patch({ required: next })}
/>
<Toggle
label="Set the destructive floor to this update"
hint="Deletes sessions on every older television when it next uses Memby, then shows the required update screen."
checked={draft.destructive}
onChange={(next) =>
// Turning it on implies requiring the update and sets the floor to it; turning
// it off clears the floor only if it was this version, so a floor typed by
// hand is not thrown away by an unrelated toggle.
patch(
next
? { destructive: true, required: true, retireBelow: draft.version.trim() }
: {
destructive: false,
retireBelow:
draft.retireBelow.trim() === draft.version.trim() ? '' : draft.retireBelow,
},
)
}
/>
</Card>
)}
{confirming && draft ? (
<Confirm
title={draft.destructive ? 'Sign every older television out?' : 'Require this update?'}
body={
draft.destructive
? 'This deletes sessions on every older television and forces viewers to sign in again after updating.'
: 'Required updates block the home screen on every television below this version until they update.'
}
confirmLabel="Publish"
destructive={draft.destructive}
busy={busy === 'save'}
onConfirm={() => void save(true)}
onCancel={() => setConfirming(false)}
/>
) : null}
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useQuery } from '../lib/hooks';
import { num } from '../lib/format';
import { Banner, Card, EmptyRow, Loading, PageHead, TableWrap, Tiles } from '../components/ui';
import type { ViewsReport } from '../api/types';
function change(today: number, previous: number): string {
if (previous === 0) return today > 0 ? 'new this week' : 'no change';
const amount = Math.round(((today - previous) / previous) * 100);
return `${amount > 0 ? '+' : ''}${amount}% vs last week`;
}
export function ViewsPage() {
const { data, error, loading } = useQuery<ViewsReport>('/admin/api/views', { pollMs: 60_000 });
const daily = data?.daily ?? [];
const hourly = data?.hourly ?? [];
return (
<>
<PageHead title="Views" intro="How often people reach Membys home screen. This measures app use, not playback streams." />
<Banner message={error} />
{loading ? <Loading /> : (
<>
<Tiles tiles={[
{ label: change(data?.today.visits ?? 0, data?.lastWeek.visits ?? 0), value: num(data?.today.visits), icon: 'overview', tone: 'data' },
{ 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">
<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">
<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>
</Card>
</>
)}
</>
);
}