0.1.38 gateway
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 Memby’s 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
</>;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 Memby’s 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user