This commit is contained in:
ponzischeme89
2026-08-28 23:00:02 +12:00
parent 3e89036f7b
commit d5632e844a
66 changed files with 2870 additions and 689 deletions
+9
View File
@@ -257,6 +257,8 @@ export interface RemoteSectionDefinition {
component: string;
maxItems?: number;
destination?: string;
/** Card shape override for a mediaRow: "poster", "thumb", or absent for automatic. */
layout?: string;
settings?: Record<string, unknown>;
}
@@ -802,6 +804,10 @@ export interface GatewaySettings {
embyHealthSeconds: number;
slowRequestMillis: number;
librarySyncMinutes: number;
homeTtlSeconds: number;
recommendTtlHours: number;
/** null means "whatever was deployed"; 0 is a legitimate override (midnight). */
forYouRebuildHour: number | null;
updatedAt?: string;
updatedBy?: string;
}
@@ -818,6 +824,9 @@ export interface GatewaySettingValues {
embyHealthSeconds: number;
slowRequestMillis: number;
librarySyncMinutes: number;
homeTtlSeconds: number;
recommendTtlHours: number;
forYouRebuildHour: number;
}
export interface GatewaySettingsResponse {
@@ -0,0 +1,385 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { GatewaySettings, GatewaySettingsResponse } from '../api/types';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Field, KeyValue, Loading, Note, Tag } from './ui';
/* The gateway's own server-level settings, rendered as a section rather than a page so it
* can sit on Client configuration beside the thin-client control plane and still be
* reachable on its own at /admin/settings.
*
* Everything here is an *override* of the value the container was started with: blank (or
* zero) means "whatever .env says", which is printed beside each field. The two alert
* windows, the health probe and the slow-request threshold can additionally be switched
* off, which is a third state distinct from "deployed" — the word off in the field. */
const OVERRIDE_OFF = -1;
function numberFieldValue(value: number): string {
if (value === 0) return '';
if (value < 0) return 'off';
return String(value);
}
function parseNumberField(raw: string, offAllowed: boolean): number {
const text = raw.trim().toLowerCase();
if (text === '') return 0;
if (offAllowed && (text === 'off' || text === 'none' || text === '0')) return OVERRIDE_OFF;
const parsed = Number.parseInt(text, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
/** The For You rebuild hour is a plain 023 with no "off": an empty field is "deployed"
* (null on the wire) and every other value is the hour it runs at. */
function parseHourField(raw: string): number | null {
const text = raw.trim();
if (text === '') return null;
const parsed = Number.parseInt(text, 10);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 23) return null;
return parsed;
}
function describe(value: number, unit: string): string {
if (value <= 0) return 'off';
return `${value} ${unit}${value === 1 ? '' : 's'}`;
}
function notificationDisplayLabel(value: string): string {
switch (value) {
case 'everywhere': return 'Everywhere';
case 'off': return 'Off';
default: return 'Home only';
}
}
interface Draft {
timezone: string;
logLevel: string;
sessionIdleDays: string;
sonarrAlertMinutes: string;
radarrAlertMinutes: string;
notificationDisplay: string;
embyHealthSeconds: string;
slowRequestMillis: string;
librarySyncMinutes: string;
homeTtlSeconds: string;
recommendTtlHours: string;
forYouRebuildHour: string;
}
function draftFrom(settings: GatewaySettings): Draft {
return {
timezone: settings.timezone ?? '',
logLevel: settings.logLevel ?? '',
sessionIdleDays: numberFieldValue(settings.sessionIdleDays),
sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes),
radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes),
notificationDisplay: settings.notificationDisplay || 'home_only',
embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds),
slowRequestMillis: numberFieldValue(settings.slowRequestMillis),
librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes),
homeTtlSeconds: settings.homeTtlSeconds ? String(settings.homeTtlSeconds) : '',
recommendTtlHours: settings.recommendTtlHours ? String(settings.recommendTtlHours) : '',
forYouRebuildHour: settings.forYouRebuildHour == null ? '' : String(settings.forYouRebuildHour),
};
}
function bodyFrom(draft: Draft): GatewaySettings {
return {
timezone: draft.timezone.trim(),
logLevel: draft.logLevel.trim(),
sessionIdleDays: parseNumberField(draft.sessionIdleDays, false),
sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true),
radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true),
notificationDisplay: draft.notificationDisplay,
embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true),
slowRequestMillis: parseNumberField(draft.slowRequestMillis, true),
librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true),
homeTtlSeconds: parseNumberField(draft.homeTtlSeconds, false),
recommendTtlHours: parseNumberField(draft.recommendTtlHours, false),
forYouRebuildHour: parseHourField(draft.forYouRebuildHour),
};
}
export function GatewaySettingsSection() {
const { data, error, loading, reload } = useQuery<GatewaySettingsResponse>('/admin/api/gateway-settings');
const { wrap } = useToast();
const { busy, run } = useAction();
const [draft, setDraft] = useState<Draft | null>(null);
// A refresh must never take a half-typed field away: the draft is adopted once, and
// after that the operator owns it until they save or discard.
useEffect(() => {
if (!draft && data) setDraft(draftFrom(data.settings));
}, [data, draft]);
const set = <K extends keyof Draft>(key: K, value: string) =>
setDraft((current) => (current ? { ...current, [key]: value } : current));
const save = () =>
run('save', async () => {
if (!draft) return;
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', bodyFrom(draft)),
'Gateway settings saved.',
);
// Adopt what the server stored rather than what was typed — normalisation clamps and
// refuses. A failed save leaves the draft alone.
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const clearAll = () =>
run('clear', async () => {
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
timezone: '', logLevel: '', sessionIdleDays: 0,
sonarrAlertMinutes: 0, radarrAlertMinutes: 0, embyHealthSeconds: 0, slowRequestMillis: 0,
librarySyncMinutes: 0, notificationDisplay: 'home_only',
homeTtlSeconds: 0, recommendTtlHours: 0, forYouRebuildHour: null,
}),
'Every setting is back to its deployed or server default.',
);
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const deployed = data?.deployed;
const effective = data?.effective;
const levels = data?.logLevels ?? [];
const notificationDisplays = data?.notificationDisplays ?? ['everywhere', 'home_only', 'off'];
return (
<>
<Banner message={error} />
{loading || !draft || !deployed || !effective ? (
<Loading rows={2} />
) : (
<>
<Card
title="This gateway"
intro="The server process this console is served by, and what it currently believes."
icon="chip"
tone="info"
actions={<Tag tone="info">{data?.version ?? 'unknown'}</Tag>}
>
<KeyValue
rows={[
{ label: 'Server timezone', value: effective.timezone || 'not set' },
{ label: 'Log level', value: effective.logLevel },
{ label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') },
{ label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') },
{ label: 'Slow-request breakdown', value: describe(effective.slowRequestMillis, 'millisecond') },
{ label: 'Catalogue sweep', value: describe(effective.librarySyncMinutes, 'minute') },
{ label: 'Home cache lifetime', value: describe(effective.homeTtlSeconds, 'second') },
{ label: 'Recommendation cache lifetime', value: describe(effective.recommendTtlHours, 'hour') },
{ label: 'For You rebuild hour', value: `${effective.forYouRebuildHour}:00` },
{ label: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') },
{ label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') },
{ label: 'Notification display', value: notificationDisplayLabel(effective.notificationDisplay) },
]}
/>
</Card>
<Card
title="Server settings"
intro="Environment-backed fields can be left empty to use the deployed value shown beneath them. Changes take effect immediately — nothing here needs a restart."
icon="sliders"
tone="note"
footer={
<>
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save settings
</Button>
<Button busy={busy === 'clear'} onClick={() => void clearAll()}>
Use defaults
</Button>
</>
}
>
<div className="fields">
<Field
label="Server timezone"
hint={`Deployed: ${deployed.timezone || 'not set'}. An IANA name, for example Pacific/Auckland. Decides what "today" means for the schedule rows, the home hero and the sign-in history.`}
>
<input
type="text"
value={draft.timezone}
placeholder={deployed.timezone}
onChange={(event) => set('timezone', event.target.value)}
/>
</Field>
<Field
label="Log level"
hint={`Deployed: ${deployed.logLevel}. Applies to the running process at once, so debug can be turned on to watch something happen.`}
>
<select value={draft.logLevel} onChange={(event) => set('logLevel', event.target.value)}>
<option value="">Deployed ({deployed.logLevel})</option>
{levels.map((level) => (
<option key={level} value={level}>{level}</option>
))}
</select>
</Field>
</div>
<div className="fields">
<Field
label="Sign a television out after (days)"
hint={`Deployed: ${deployed.sessionIdleDays} days. A session row holds a live Emby token, so this is how long a set nobody uses keeps working credentials.`}
>
<input
type="text"
inputMode="numeric"
value={draft.sessionIdleDays}
placeholder={String(deployed.sessionIdleDays)}
onChange={(event) => set('sessionIdleDays', event.target.value)}
/>
</Field>
<Field
label="Slow-request breakdown above (ms)"
hint={`Deployed: ${deployed.slowRequestMillis || 'off'}. A request slower than this logs where its time went — Emby, Postgres, Redis, row assembly. Lower it while chasing one slow screen; type off to stop annotating altogether.`}
>
<input
type="text"
inputMode="numeric"
value={draft.slowRequestMillis}
placeholder={String(deployed.slowRequestMillis)}
onChange={(event) => set('slowRequestMillis', event.target.value)}
/>
</Field>
<Field
label="Emby health probe (seconds)"
hint={`Deployed: ${deployed.embyHealthSeconds || 'off'}. How often the gateway asks Emby whether it is answering. Type off to stop probing, which also removes the outage bar from every television.`}
>
<input
type="text"
inputMode="numeric"
value={draft.embyHealthSeconds}
placeholder={String(deployed.embyHealthSeconds)}
onChange={(event) => set('embyHealthSeconds', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Catalogue sweep (minutes)"
hint={`Deployed: ${deployed.librarySyncMinutes || 'off'}. How often the gateway asks Emby what has changed. With the Sonarr and Radarr webhooks wired up a new file is in the catalogue within a minute of landing, and this is only reconciliation for media they do not manage — 360 is a sensible choice then. Without them it is the only way anything is found.`}
>
<input
type="text"
inputMode="numeric"
value={draft.librarySyncMinutes}
placeholder={String(deployed.librarySyncMinutes)}
onChange={(event) => set('librarySyncMinutes', event.target.value)}
/>
</Field>
<Field
label="Home cache lifetime (seconds)"
hint={`Deployed: ${deployed.homeTtlSeconds}. How long a built launcher payload is served before it is rebuilt. Lower for fresher rows at the cost of more Emby traffic; it cannot be switched off.`}
>
<input
type="text"
inputMode="numeric"
value={draft.homeTtlSeconds}
placeholder={String(deployed.homeTtlSeconds)}
onChange={(event) => set('homeTtlSeconds', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Recommendation cache lifetime (hours)"
hint={`Deployed: ${deployed.recommendTtlHours}. How long a personalised recommendation pool stays warm. Long by design — the rotation within it is daily.`}
>
<input
type="text"
inputMode="numeric"
value={draft.recommendTtlHours}
placeholder={String(deployed.recommendTtlHours)}
onChange={(event) => set('recommendTtlHours', event.target.value)}
/>
</Field>
<Field
label="For You rebuild hour (023)"
hint={`Deployed: ${deployed.forYouRebuildHour}:00. The household-local hour the daily For You rebuild is due at. Leave empty for the deployed hour.`}
>
<input
type="text"
inputMode="numeric"
value={draft.forYouRebuildHour}
placeholder={String(deployed.forYouRebuildHour)}
onChange={(event) => set('forYouRebuildHour', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Notification display"
hint="Controls informational banners for every viewer and television. Everywhere also permits playback overlays; Home only keeps them off active movies, episodes and trailers; Off hides them throughout Memby."
>
<select
value={draft.notificationDisplay}
onChange={(event) => set('notificationDisplay', event.target.value)}
>
{notificationDisplays.map((value) => (
<option key={value} value={value}>{notificationDisplayLabel(value)}</option>
))}
</select>
</Field>
</div>
<div className="fields">
<Field
label="Episode alert window (minutes)"
hint={`Deployed: ${deployed.sonarrAlertMinutes || 'off'}. How long a "just aired" notice stays on offer to a set that was switched off at the time. Type off to stop announcing them.`}
>
<input
type="text"
inputMode="numeric"
value={draft.sonarrAlertMinutes}
placeholder={String(deployed.sonarrAlertMinutes)}
onChange={(event) => set('sonarrAlertMinutes', event.target.value)}
/>
</Field>
<Field
label="Film alert window (minutes)"
hint={`Deployed: ${deployed.radarrAlertMinutes || 'off'}. The same, for a film Radarr has just imported.`}
>
<input
type="text"
inputMode="numeric"
value={draft.radarrAlertMinutes}
placeholder={String(deployed.radarrAlertMinutes)}
onChange={(event) => set('radarrAlertMinutes', event.target.value)}
/>
</Field>
</div>
<Note tone="note">
These settings live in the database and survive a restart. A deployment rewrites
<code>.env</code>, not this document, so environment-backed values can then disagree;
permanent environment changes belong in <code>.env.example</code> as well.
</Note>
{data?.settings.updatedBy ? (
<Note>
Last changed by {data.settings.updatedBy}
{data.settings.updatedAt ? ` on ${new Date(data.settings.updatedAt).toLocaleString('en-NZ')}` : ''}.
</Note>
) : null}
</Card>
</>
)}
</>
);
}
+2 -3
View File
@@ -232,9 +232,8 @@ export function Layout() {
<span className="account-avatar account-avatar-large" aria-hidden="true">{initial}</span>
<span><small>Signed in as</small><b>{currentUser}</b></span>
</div>
{/* The gateway's own settings live here rather than on the rail: the rail
is the household — its users, its content, its televisions — and this
is the server process those pages are served by. */}
{/* Gateway settings now live on Client configuration in the rail; this is a
shortcut to the standalone view of the same editor. */}
<NavLink to="/admin/settings" role="menuitem" onClick={() => setAccountOpen(false)}>
<Icon name="sliders" />
Gateway settings
+31 -1
View File
@@ -53,6 +53,11 @@ function describeRow(row: RemoteSectionDefinition, rowTypes: RowTypeDefinition[]
return row.type ? `Custom · ${row.type}` : 'Custom';
}
const LAYOUT_LABEL: Record<string, string> = {
poster: 'Poster cards',
thumb: 'Thumbnail cards',
};
export function RowsEditor({
page,
rows,
@@ -142,7 +147,7 @@ export function RowsEditor({
isNew: true,
row: {
id: '', type: availableTypes[0]?.type ?? 'custom', title: '', enabled: true,
position: 0, dataSource: '', component: '', maxItems: 20, destination: '',
position: 0, dataSource: '', component: '', maxItems: 20, destination: '', layout: '',
},
})}
>
@@ -165,6 +170,7 @@ export function RowsEditor({
<div className="chips">
<Chip>{describeRow(row, rowTypes)}</Chip>
{row.maxItems ? <Chip>{row.maxItems} items</Chip> : null}
{row.layout && LAYOUT_LABEL[row.layout] ? <Chip tone="note">{LAYOUT_LABEL[row.layout]}</Chip> : null}
{row.destination ? <Chip tone="note">{row.destination}</Chip> : null}
</div>
</div>
@@ -290,6 +296,7 @@ function RowEditorDialog({
const [customDataSource, setCustomDataSource] = useState(row.dataSource);
const [customComponent, setCustomComponent] = useState(row.component || 'mediaRow');
const [customDestination, setCustomDestination] = useState(row.destination ?? '');
const [layout, setLayout] = useState(row.layout ?? '');
// A row saved by an older console, or through the raw JSON view, can carry a type this
// page's catalogue does not offer (moved off this page, or never catalogued at all). It
@@ -306,6 +313,12 @@ function RowEditorDialog({
const valid = trimmedTitle.length > 0 && validMaxItems &&
(!isCustom || (customDataSource.trim().length > 0 && customComponent.trim().length > 0));
// Layout only means something for a horizontal card row. A genre browser or a paged
// library grid draws neither poster nor thumbnail cards, so the control is hidden and any
// stray value is dropped on save.
const resolvedComponent = isCustom ? customComponent.trim() : rowType?.component ?? '';
const layoutApplies = resolvedComponent === 'mediaRow';
const submit = () => {
if (!valid) return;
const resolvedType = rowType?.type ?? 'custom';
@@ -324,6 +337,7 @@ function RowEditorDialog({
component: isCustom ? customComponent.trim() : rowType!.component,
maxItems,
destination,
...(layoutApplies && (layout === 'poster' || layout === 'thumb') ? { layout } : {}),
});
};
@@ -359,6 +373,22 @@ function RowEditorDialog({
</div>
{rowType?.description ? <p className="hint">{rowType.description}</p> : null}
{layoutApplies ? (
<div className="fields">
<Field
label="Card layout"
hint="Automatic shows episodes as wide thumbnails and everything else as upright posters. Override to force one shape — thumbnails are the same wide cards Continue Watching uses."
grow
>
<select value={layout} onChange={(event) => setLayout(event.target.value)}>
<option value="">Automatic</option>
<option value="poster">Poster cards</option>
<option value="thumb">Thumbnail cards</option>
</select>
</Field>
</div>
) : null}
{rowType?.requiresMatchingDestination ? (
<Note>Browses the {PAGE_LABEL[page]} catalogue this row can only ever point at the page it lives on.</Note>
) : null}
+32
View File
@@ -91,6 +91,38 @@ export function Grid({ cols, children }: { cols?: '2' | 'wide'; children: ReactN
);
}
/** Tabs splits one page into a handful of named views, anchored on a hairline under the
* page head. It is for a page that is genuinely several things about one subject — the
* client control plane, its rows, its server settings — where a single scroll buries the
* section somebody came for. Segments is the control for an exclusive *choice*; this is
* navigation within a page. The active tab rides `?tab=` so a view is linkable. */
export function Tabs<T extends string>({
tabs,
active,
onChange,
}: {
tabs: readonly { id: T; label: string; icon?: IconName }[];
active: T;
onChange: (id: T) => void;
}) {
return (
<nav className="tabs" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === active}
onClick={() => onChange(tab.id)}
>
{tab.icon ? <Icon name={tab.icon} /> : null}
{tab.label}
</button>
))}
</nav>
);
}
/* ---------- tiles ---------- */
export interface TileSpec {
+8 -2
View File
@@ -44,6 +44,13 @@ export const nav: NavGroup[] = [
item('journey-viewer', '/admin/journeys/:userId', 'Journey', 'User journey', 'One viewers journey through Memby.', 'journey', { hidden: true }),
],
},
{
id: 'config', label: 'Configuration', defaultCollapsed: false, collapsible: false,
items: [
item('features', '/admin/features', 'Client configuration', 'Client configuration',
'Everything the thin TV client renders, and the gateways own server-level settings.', 'sliders'),
],
},
{
id: 'history', label: 'History', defaultCollapsed: false,
items: [
@@ -98,7 +105,6 @@ export const nav: NavGroup[] = [
{
id: 'rules', label: 'Rules', defaultCollapsed: false,
items: [
item('features', '/admin/features', 'Client configuration', 'Client configuration', 'Control everything the thin TV client renders.', 'sliders'),
item('notification-settings', '/admin/notification-settings', 'Notification rules', 'TV notifications', 'Choose where notifications appear and who receives them.', 'bell'),
item('webhooks', '/admin/integrations/webhooks', 'Event webhooks', 'Event webhooks', 'Send administrative events to external services.', 'send'),
],
@@ -113,7 +119,7 @@ export const nav: NavGroup[] = [
item('integrations', '/admin/integrations', 'Integrations', 'Integrations', 'External services Memby depends on.', 'plug'),
item('integration', '/admin/integrations/:integrationId', 'Integration', 'Integration', 'One external service, its settings and run history.', 'plug', { hidden: true }),
item('maintenance', '/admin/maintenance', 'Maintenance', 'Maintenance', 'Take Memby offline or schedule quiet time.', 'wrench'),
item('gateway-settings', '/admin/settings', 'Gateway settings', 'Gateway settings', 'Timezone, logging and other server-level settings.', 'sliders', { hidden: true }),
item('gateway-settings', '/admin/settings', 'Gateway settings', 'Gateway settings', 'Timezone, logging and other server-level settings. Also editable on Client configuration.', 'sliders', { hidden: true }),
],
},
];
+15 -1
View File
@@ -103,6 +103,9 @@ interface WatchTime {
matched: boolean;
tracearrUsername?: string;
weekMs: number;
/* This week against the same point last week, so the card can say whether viewing is
up or down without the operator holding last week's figure in their head. */
weekTrend?: { direction: 'up' | 'down' | 'steady' | 'none'; priorMs: number; deltaMs: number };
monthMs: number;
totalMs: number;
weekSessions: number;
@@ -145,6 +148,17 @@ function cssColour(value: string | undefined): string {
return `#${hex.slice(2)}${hex.slice(0, 2)}`;
}
/* weekTrendClause is the " · ▲ 1h more than last week" tail on the this-week tile. Neutral
wording — more television is a fact, not a verdict — and empty when there is nothing to
compare against. */
function weekTrendClause(trend: WatchTime['weekTrend']): string {
if (!trend || trend.direction === 'none') return '';
if (trend.direction === 'steady') return ' · level with last week';
const glyph = trend.direction === 'up' ? '▲' : '▼';
const word = trend.direction === 'up' ? 'more' : 'less';
return ` · ${glyph} ${watchTime(Math.abs(trend.deltaMs))} ${word} than last week`;
}
type Pending =
| { kind: 'remove-device'; deviceId: string; name: string }
| { kind: 'remove-account' }
@@ -494,7 +508,7 @@ export function AccountPage() {
<Tiles
tiles={[
{
label: `this week · ${num(account.watchTime.weekSessions)} session${account.watchTime.weekSessions === 1 ? '' : 's'}`,
label: `this week · ${num(account.watchTime.weekSessions)} session${account.watchTime.weekSessions === 1 ? '' : 's'}${weekTrendClause(account.watchTime.weekTrend)}`,
value: watchTime(account.watchTime.weekMs),
icon: 'pulse',
tone: 'data',
+15 -2
View File
@@ -7,7 +7,8 @@ import { ago, initials, num, presence, recent, watchTime, when } from '../lib/fo
import { Banner, Button, Card, Confirm, EmptyRow, Loading, PageHead, TableWrap, Tag, Tiles, Toggle } from '../components/ui';
interface Device { id: string; name: string; version: string; lastSeen: string; lastIp?: string; }
interface WatchTime { matched: boolean; weekMs: number; monthMs: number; }
interface WeekTrend { direction: 'up' | 'down' | 'steady' | 'none'; priorMs: number; deltaMs: number; }
interface WatchTime { matched: boolean; weekMs: number; monthMs: number; weekTrend?: WeekTrend; }
interface Account {
id: string; username: string; initials: string; shortName: string; lastSeen: string; devices: Device[] | null;
enabled: boolean; lastIp?: string; notifications: { enabled: boolean; [key: string]: unknown }; watchTime?: WatchTime;
@@ -18,6 +19,18 @@ interface StatusResponse { updatePolicy?: { latestVersion?: string }; }
function seenAt(value: string | undefined): number { const at = value ? new Date(value).getTime() : 0; return Number.isFinite(at) ? at : 0; }
function NotMeasured() { return <span className="muted" title="No Tracearr sessions matched to this person"></span>; }
/* Whether this week's viewing is up or down on the same point last week. Neutral by
design — more television is not a verdict — so it is the quiet caption the version
column already uses, not a coloured tag. Absent when there is nothing to compare. */
function WeekTrend({ trend }: { trend?: WeekTrend }) {
if (!trend || trend.direction === 'none') return null;
if (trend.direction === 'steady') return <span className="table-sub" title="About the same as this point last week"> level with last week</span>;
const glyph = trend.direction === 'up' ? '▲' : '▼';
const word = trend.direction === 'up' ? 'more' : 'less';
const size = watchTime(Math.abs(trend.deltaMs));
return <span className="table-sub" title={`${size} ${word} than this point last week (was ${watchTime(trend.priorMs)})`}>{glyph} {size} vs last week</span>;
}
export function AccountsPage() {
const { data, error, loading, reload } = useQuery<AccountsResponse>('/admin/api/accounts', { pollMs: 60_000 });
const { data: status } = useQuery<StatusResponse>('/admin/api/status', { pollMs: 60_000 });
@@ -62,7 +75,7 @@ export function AccountsPage() {
<td className="num"><button type="button" className="link-button" onClick={() => setExpanded(open ? null : account.id)}>{num(list.length)}</button></td>
<td><span title={first?.version ? `Latest recorded version on ${first.name}` : 'Version not reported'}>{first?.version || <span className="muted">Unknown</span>}</span>{first?.version && current(first.version) ? <Tag tone="ok">current</Tag> : first?.version && latest ? <Tag tone="warn">update</Tag> : null}{list.length > 1 ? <span className="table-sub">{list.length - 1} more device{list.length === 2 ? '' : 's'}</span> : null}</td>
<td className="mono" title="Last recorded IP address">{first?.lastIp || account.lastIp || <span className="muted"></span>}</td>
<td className="num">{watched?.matched ? watchTime(watched.weekMs) : <NotMeasured />}</td><td className="num muted">{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}</td>
<td className="num">{watched?.matched ? <>{watchTime(watched.weekMs)}<WeekTrend trend={watched.weekTrend} /></> : <NotMeasured />}</td><td className="num muted">{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}</td>
<td className="nowrap muted" title={when(account.lastSeen)}>{ago(account.lastSeen)}</td>
<td><Toggle label={account.notifications?.enabled ? 'ON' : 'OFF'} checked={Boolean(account.notifications?.enabled)} disabled={busy === `notifications-${account.id}`} onChange={(next) => void saveNotifications(account, next)} /></td>
<td><Toggle label={account.enabled ? 'Enabled' : 'Disabled'} checked={account.enabled} disabled={busy === `enabled-${account.id}`} onChange={() => setConfirm(account)} /></td>
+269 -219
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
@@ -15,22 +16,39 @@ import {
Loading,
PageHead,
PlainTiles,
Tabs,
Tag,
Toggle,
} from '../components/ui';
import { RowsEditor } from '../components/RowsEditor';
import { GatewaySettingsSection } from '../components/GatewaySettingsSection';
import type { RemoteSectionDefinition } from '../api/types';
/* The three configuration keys that hold a page's row composition. They are edited
* visually through RowsEditor rather than through the generic configuration list below,
* which is why they are carved out of `configuration` before that list renders — a
* technical JSON textarea for these three is exactly what the visual editor replaces. */
/* One page for everything the household is served, split into four views so the section an
* operator came for is not at the bottom of a single scroll:
*
* Overview — is anything wrong, and the whole-plane actions (safe mode, rollback).
* Page rows — what Home, Movies and TV show and in what order.
* Features — the optional feature flags and the typed configuration values.
* Server — the gateway process's own settings (timezone, logging, cache lifetimes).
*
* The active view rides `?tab=` so a link goes straight to it. */
const ROW_CONFIGURATION_KEYS: Record<'home' | 'movies' | 'tv', string> = {
home: 'home.sectionDefinitions',
movies: 'movies.sectionDefinitions',
tv: 'tv.sectionDefinitions',
};
const TABS = [
{ id: 'overview', label: 'Overview', icon: 'overview' },
{ id: 'rows', label: 'Page rows', icon: 'list' },
{ id: 'features', label: 'Features', icon: 'sliders' },
{ id: 'server', label: 'Server settings', icon: 'chip' },
] as const;
type TabId = (typeof TABS)[number]['id'];
interface Pending {
action: 'safe-mode' | 'rollback' | 'reset' | 'feature';
title: string;
@@ -45,13 +63,21 @@ export function FeaturesPage() {
const { wrap } = useToast();
const { busy, run } = useAction();
const [pending, setPending] = useState<Pending | null>(null);
const [params, setParams] = useSearchParams();
const requested = params.get('tab');
const tab: TabId = TABS.some((entry) => entry.id === requested) ? (requested as TabId) : 'overview';
const setTab = (next: TabId) => {
const copy = new URLSearchParams(params);
if (next === 'overview') copy.delete('tab');
else copy.set('tab', next);
setParams(copy, { replace: true });
};
const policy = status?.features;
const features = policy?.features ?? [];
const rowTypes = policy?.rowTypes ?? [];
const rowConfiguration = policy?.configuration ?? [];
// Everything but the three row-composition values, which the visual editors below
// render instead of the generic list.
const rowKeys = new Set(Object.values(ROW_CONFIGURATION_KEYS));
const configuration = rowConfiguration.filter((item) => !rowKeys.has(item.key));
const rowsFor = (key: string) => (rowConfiguration.find((item) => item.key === key)?.value as RemoteSectionDefinition[] | undefined) ?? [];
@@ -104,238 +130,262 @@ export function FeaturesPage() {
});
};
const saveValue = (key: string, value: unknown) =>
void run(key, async () => {
await wrap(
() =>
api.post('/admin/api/features', {
action: 'save',
expectedRevision: revision,
values: valuesFor(key, value),
overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])),
}),
'Configuration saved.',
);
await reload();
});
return (
<>
<PageHead
title="Client configuration"
intro="One server-owned control plane for what Memby renders: flags, values, page composition and contextual discovery."
intro="Everything the household is served: the thin-client control plane, its page rows, and the gateway's own server-level settings."
/>
<Banner message={error} />
<Tabs tabs={TABS} active={tab} onChange={setTab} />
{loading || !policy ? (
<Loading />
) : (
<>
<Card
title="Thin client control plane"
intro="The gateway decides which sections, heroes and discovery rows are delivered. The TV remains a fast renderer of known components and safely ignores anything newer."
icon="tv"
>
<div className="chips">
<a className="chip" href="/admin/hero">Hero sources and pinned overrides</a>
<a className="chip" href="/admin/recommendations">For You and recommendation pools</a>
<a className="chip" href="/admin/engagement">Contextual row engagement signals</a>
<a className="chip" href="/admin/clients">Client versions and capabilities</a>
</div>
</Card>
{tab === 'overview' ? (
loading || !policy ? (
<Loading />
) : (
<>
<Card
title="Thin client control plane"
intro="The gateway decides which sections, heroes and discovery rows are delivered. The TV remains a fast renderer of known components and safely ignores anything newer."
icon="tv"
>
<div className="chips">
<a className="chip" href="/admin/hero">Hero sources and pinned overrides</a>
<a className="chip" href="/admin/recommendations">For You and recommendation pools</a>
<a className="chip" href="/admin/engagement">Contextual row engagement signals</a>
<a className="chip" href="/admin/clients">Client versions and capabilities</a>
</div>
</Card>
<RowsEditor
page="home"
rows={rowsFor(ROW_CONFIGURATION_KEYS.home)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.home}
onSave={saveRows(ROW_CONFIGURATION_KEYS.home)}
/>
<RowsEditor
page="movies"
rows={rowsFor(ROW_CONFIGURATION_KEYS.movies)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.movies}
onSave={saveRows(ROW_CONFIGURATION_KEYS.movies)}
/>
<RowsEditor
page="tv"
rows={rowsFor(ROW_CONFIGURATION_KEYS.tv)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.tv}
onSave={saveRows(ROW_CONFIGURATION_KEYS.tv)}
/>
<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>
<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>
<div className="row">
<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>
</>
)
) : null}
{tab === 'rows' ? (
loading || !policy ? (
<Loading />
) : (
<>
<RowsEditor
page="home"
rows={rowsFor(ROW_CONFIGURATION_KEYS.home)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.home}
onSave={saveRows(ROW_CONFIGURATION_KEYS.home)}
/>
</Card>
<RowsEditor
page="movies"
rows={rowsFor(ROW_CONFIGURATION_KEYS.movies)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.movies}
onSave={saveRows(ROW_CONFIGURATION_KEYS.movies)}
/>
<RowsEditor
page="tv"
rows={rowsFor(ROW_CONFIGURATION_KEYS.tv)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.tv}
onSave={saveRows(ROW_CONFIGURATION_KEYS.tv)}
/>
</>
)
) : null}
<Card
title="Central configuration"
intro="Typed values use the same revisioned control plane as feature flags. Global values are safe defaults; user, device and experimental scopes are available to the client contract as features grow."
icon="sliders"
>
<div className="stack">
{configuration.map((item) => {
const value = item.value;
return (
<div className="row" key={item.key}>
<div className="grow">
<strong>{item.name}</strong>
<div className="hint">{item.description} · {item.scopes.join(', ')}</div>
<Chip>{item.key}</Chip>
</div>
{item.type === 'boolean' ? (
<Toggle
label={value ? 'On' : 'Off'}
checked={Boolean(value)}
disabled={busy === item.key}
onChange={(next) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, next), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})}
/>
) : item.type === 'enum' ? (
<select value={String(value)} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, event.target.value), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})}>
{(item.options ?? []).map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : item.type === 'json' ? (
<textarea
rows={4}
value={JSON.stringify(value, null, 2)}
disabled={busy === item.key}
aria-label={item.name}
onChange={(event) => {
try {
const next = JSON.parse(event.target.value);
void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, next), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
});
} catch { /* wait for valid JSON before publishing */ }
}}
/>
) : item.type === 'string' ? (
<input type="text" value={String(value ?? '')} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, event.target.value), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})} />
) : (
<input type="number" value={Number(value)} min={item.min} max={item.max} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, Number(event.target.value)), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})} />
)}
</div>
);
})}
</div>
</Card>
<Grid cols="2">
{tab === 'features' ? (
loading || !policy ? (
<Loading />
) : (
<>
{features.length === 0 ? (
<Card title="Nothing registered" icon="sliders">
<Card title="Feature flags" 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>}
>
<Toggle
label={feature.enabled ? 'On' : 'Off'}
hint="Changing this applies the feature policy to every compatible television."
checked={feature.enabled}
disabled={busy === feature.key}
onChange={(enabled) => setPending({
action: 'feature', key: feature.key, enabled,
title: `${enabled ? 'Turn on' : 'Turn off'} ${feature.name}?`,
body: `${enabled ? 'Enable' : 'Disable'} this feature for every compatible television. ${feature.recovery}`,
label: enabled ? 'Turn on' : 'Turn off',
})}
/>
<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 cols="2">
{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>}
>
<Toggle
label={feature.enabled ? 'On' : 'Off'}
hint="Changing this applies the feature policy to every compatible television."
checked={feature.enabled}
disabled={busy === feature.key}
onChange={(enabled) => setPending({
action: 'feature', key: feature.key, enabled,
title: `${enabled ? 'Turn on' : 'Turn off'} ${feature.name}?`,
body: `${enabled ? 'Enable' : 'Disable'} this feature for every compatible television. ${feature.recovery}`,
label: enabled ? 'Turn on' : 'Turn off',
})}
/>
<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>
)}
</Grid>
<Card>
<div className="row">
<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>
<Card
title="Configuration values"
intro="Typed values on the same revisioned control plane as the flags. Global values are safe defaults; user, device and experimental scopes are available to the client contract as features grow."
icon="sliders"
>
{configuration.length === 0 ? (
<Empty>No configuration values are registered.</Empty>
) : (
<Tag tone="ok">live · revision r{num(revision)}</Tag>
<div className="stack">
{configuration.map((item) => {
const value = item.value;
return (
<div className="row" key={item.key}>
<div className="grow">
<strong>{item.name}</strong>
<div className="hint">{item.description} · {item.scopes.join(', ')}</div>
<Chip>{item.key}</Chip>
</div>
{item.type === 'boolean' ? (
<Toggle
label={value ? 'On' : 'Off'}
checked={Boolean(value)}
disabled={busy === item.key}
onChange={(next) => saveValue(item.key, next)}
/>
) : item.type === 'enum' ? (
<select value={String(value)} disabled={busy === item.key} onChange={(event) => saveValue(item.key, event.target.value)}>
{(item.options ?? []).map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : item.type === 'json' ? (
<textarea
rows={4}
value={JSON.stringify(value, null, 2)}
disabled={busy === item.key}
aria-label={item.name}
onChange={(event) => {
try {
saveValue(item.key, JSON.parse(event.target.value));
} catch { /* wait for valid JSON before publishing */ }
}}
/>
) : item.type === 'string' ? (
<input type="text" value={String(value ?? '')} disabled={busy === item.key} onChange={(event) => saveValue(item.key, event.target.value)} />
) : (
<input type="number" value={Number(value)} min={item.min} max={item.max} disabled={busy === item.key} onChange={(event) => saveValue(item.key, Number(event.target.value))} />
)}
</div>
);
})}
</div>
)}
</div>
</Card>
</>
)}
</Card>
</>
)
) : null}
{tab === 'server' ? <GatewaySettingsSection /> : null}
{pending ? (
<Confirm
+193 -9
View File
@@ -1,10 +1,67 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useAction, useQuery } 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';
import type { Tone } from '../lib/format';
import {
Banner,
Button,
Card,
Empty,
EmptyRow,
Field,
Loading,
Meter,
PageHead,
TableWrap,
Tag,
} from '../components/ui';
/* One row of media_requests, with the person attached and the same derived status the
viewer's own page shows — the console and the television read it from the same
decorateRequests pass, so they can never disagree about a title's state. */
interface AdminMediaRequest {
userId: string;
username?: string;
mediaType: string;
foreignId: number;
title: string;
year?: number;
requestedAt: string;
status: string;
statusLabel: string;
statusDetail?: string;
progress?: number;
embyItemId?: string;
}
interface AdminRequestsResponse {
requests: AdminMediaRequest[] | null;
}
/* The status slugs come from the gateway; an unknown one falls through to the quiet
treatment rather than picking a colour that claims something. */
function statusTone(status: string): Tone {
switch (status) {
case 'available':
return 'ok';
case 'downloading':
case 'found':
case 'searching':
return 'info';
case 'processing':
case 'pending':
return 'note';
case 'failed':
return 'bad';
case 'unavailable':
return 'warn';
default:
return 'idle';
}
}
export function RequestsPage() {
const { status, error, loading, reload } = useGateway();
@@ -12,8 +69,21 @@ export function RequestsPage() {
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 usage = useMemo(
() => new Map((status?.requestUsage ?? []).map((entry) => [entry.userId, entry])),
[status?.requestUsage],
);
const [owner, setOwner] = useState('');
const requests = useQuery<AdminRequestsResponse>(
`/admin/api/requests${owner ? `?userId=${encodeURIComponent(owner)}` : ''}`,
);
const rows = requests.data?.requests ?? [];
const nameFor = (id: string) =>
users.find((user) => user.id === id)?.username || id || 'unknown';
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 }),
@@ -24,8 +94,12 @@ export function RequestsPage() {
return (
<>
<PageHead title="Media requests" intro="Who can ask for something the library does not have." />
<PageHead
title="Media requests"
intro="Who can ask for something the library does not have, and what has been asked for."
/>
<Banner message={error} />
<Banner message={requests.error} />
{loading ? (
<Loading rows={2} />
@@ -64,9 +138,119 @@ export function RequestsPage() {
{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>
<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>
<Card
title="What has been asked for"
intro="Every recorded request across the household, newest first. The status is read live from Radarr, Sonarr and the library — the same answer the person who asked sees on their own page."
icon="list"
tone="data"
actions={
<Field label="Requester">
<select value={owner} onChange={(event) => setOwner(event.target.value)}>
<option value="">Everyone</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
</Field>
}
>
{requests.loading ? (
<Loading rows={3} />
) : (
<TableWrap>
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Requested by</th>
<th>Requested</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyRow columns={5}>
{owner ? 'This viewer has not asked for anything.' : 'Nothing has been requested yet.'}
</EmptyRow>
) : (
rows.map((request) => (
<tr key={`${request.userId}:${request.mediaType}:${request.foreignId}`}>
<td>
<b>{request.title || `#${request.foreignId}`}</b>
{request.year ? <span className="muted"> ({request.year})</span> : null}
</td>
<td>
<Tag tone={request.mediaType === 'series' ? 'info' : 'data'}>
{request.mediaType === 'series' ? 'Series' : 'Movie'}
</Tag>
</td>
<td>{request.username || <Tag tone="warn">{nameFor(request.userId)}</Tag>}</td>
<td className="muted nowrap">{when(request.requestedAt)}</td>
<td>
<Tag tone={statusTone(request.status)}>
{request.statusLabel}
{typeof request.progress === 'number' ? ` · ${request.progress}%` : ''}
</Tag>
{typeof request.progress === 'number' ? (
<Meter value={request.progress} total={100} tone="info" />
) : null}
{request.statusDetail ? (
<div className="muted">{request.statusDetail}</div>
) : null}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
)}
</Card>
</>
+7 -326
View File
@@ -1,337 +1,18 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { GatewaySettings, GatewaySettingsResponse } from '../api/types';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Field, KeyValue, Loading, Note, PageHead, Tag } from '../components/ui';
import { GatewaySettingsSection } from '../components/GatewaySettingsSection';
import { PageHead } from '../components/ui';
/* The gateway's own settings, as distinct from every other page in this console.
*
* Everything else here decides what the *televisions* do — which rows they draw, which
* features they are offered, who may request a film. This page is about the server
* process: what day it thinks it is, how loudly it logs, how long it remembers a set that
* has not been switched on. That is why it is reached from the account menu rather than
* from the rail: it belongs beside "signed in as", not beside the household's content.
*
* Most fields amend `.env`: blank means "whatever this container was started with", which
* is printed beside the field. Notification display is server-native instead and has a
* Home-only default. */
/** OVERRIDE_OFF is the wire value for "switched off", which the three window settings need
* to distinguish from an empty field. See store.GatewaySettingsOff. */
const OVERRIDE_OFF = -1;
/** numberFieldValue renders one of those three: empty for "deployed", the word off for the
* sentinel, and the number otherwise. */
function numberFieldValue(value: number): string {
if (value === 0) return '';
if (value < 0) return 'off';
return String(value);
}
/** parseNumberField is its inverse, and is deliberately forgiving: an operator typing
* anything the server would refuse gets the deployed value back rather than an error
* about a field they were in the middle of. */
function parseNumberField(raw: string, offAllowed: boolean): number {
const text = raw.trim().toLowerCase();
if (text === '') return 0;
if (offAllowed && (text === 'off' || text === 'none' || text === '0')) return OVERRIDE_OFF;
const parsed = Number.parseInt(text, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function describe(value: number, unit: string): string {
if (value <= 0) return 'off';
return `${value} ${unit}${value === 1 ? '' : 's'}`;
}
function notificationDisplayLabel(value: string): string {
switch (value) {
case 'everywhere': return 'Everywhere';
case 'off': return 'Off';
default: return 'Home only';
}
}
interface Draft {
timezone: string;
logLevel: string;
sessionIdleDays: string;
sonarrAlertMinutes: string;
radarrAlertMinutes: string;
notificationDisplay: string;
embyHealthSeconds: string;
slowRequestMillis: string;
librarySyncMinutes: string;
}
function draftFrom(settings: GatewaySettings): Draft {
return {
timezone: settings.timezone ?? '',
logLevel: settings.logLevel ?? '',
sessionIdleDays: numberFieldValue(settings.sessionIdleDays),
sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes),
radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes),
notificationDisplay: settings.notificationDisplay || 'home_only',
embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds),
slowRequestMillis: numberFieldValue(settings.slowRequestMillis),
librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes),
};
}
/* The gateway's own settings on their own page, at /admin/settings — kept as a standalone
* address for the account menu and for administrative-event deep links. The same editor is
* embedded in Client configuration, which is where it is discovered from the rail. */
export function SettingsPage() {
const { data, error, loading, reload } = useQuery<GatewaySettingsResponse>('/admin/api/gateway-settings');
const { wrap } = useToast();
const { busy, run } = useAction();
const [draft, setDraft] = useState<Draft | null>(null);
// The same rule the Maintenance page states: a refresh must never take a half-typed
// field away. The draft is adopted once, and after that the operator owns it until they
// save or discard.
useEffect(() => {
if (!draft && data) setDraft(draftFrom(data.settings));
}, [data, draft]);
const set = <K extends keyof Draft>(key: K, value: string) =>
setDraft((current) => (current ? { ...current, [key]: value } : current));
const save = () =>
run('save', async () => {
if (!draft) return;
const body: GatewaySettings = {
timezone: draft.timezone.trim(),
logLevel: draft.logLevel.trim(),
sessionIdleDays: parseNumberField(draft.sessionIdleDays, false),
sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true),
radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true),
notificationDisplay: draft.notificationDisplay,
embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true),
slowRequestMillis: parseNumberField(draft.slowRequestMillis, true),
librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true),
};
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', body),
'Gateway settings saved.',
);
// Adopt what the server stored rather than what was typed: normalisation clamps and
// refuses, and a page still showing the rejected value would be lying about what is
// in force. A failed save leaves the draft alone — the operator's work is the one
// thing that must survive it.
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const clearAll = () =>
run('clear', async () => {
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
timezone: '', logLevel: '', sessionIdleDays: 0,
sonarrAlertMinutes: 0, radarrAlertMinutes: 0, embyHealthSeconds: 0, slowRequestMillis: 0,
librarySyncMinutes: 0, notificationDisplay: 'home_only',
}),
'Every setting is back to its deployed or server default.',
);
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const deployed = data?.deployed;
const effective = data?.effective;
const levels = data?.logLevels ?? [];
const notificationDisplays = data?.notificationDisplays ?? ['everywhere', 'home_only', 'off'];
return (
<>
<PageHead
title="Gateway settings"
intro="Server-level settings for this gateway, changeable without a redeployment."
intro="Server-level settings for this gateway, changeable without a redeployment. Also editable on Client configuration."
/>
<Banner message={error} />
{loading || !draft || !deployed || !effective ? (
<Loading rows={2} />
) : (
<>
<Card
title="This gateway"
intro="What the process is running and what it currently believes."
icon="chip"
tone="info"
actions={<Tag tone="info">{data?.version ?? 'unknown'}</Tag>}
>
<KeyValue
rows={[
{ label: 'Server timezone', value: effective.timezone || 'not set' },
{ label: 'Log level', value: effective.logLevel },
{ label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') },
{ label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') },
{ label: 'Slow-request breakdown', value: describe(effective.slowRequestMillis, 'millisecond') },
{ label: 'Catalogue sweep', value: describe(effective.librarySyncMinutes, 'minute') },
{ label: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') },
{ label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') },
{ label: 'Notification display', value: notificationDisplayLabel(effective.notificationDisplay) },
]}
/>
</Card>
<Card
title="Server settings"
intro="Environment-backed fields can be left empty to use the deployed value shown beneath them. Changes take effect immediately — nothing here needs a restart."
icon="sliders"
tone="note"
footer={
<>
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save settings
</Button>
<Button busy={busy === 'clear'} onClick={() => void clearAll()}>
Use defaults
</Button>
</>
}
>
<div className="fields">
<Field
label="Server timezone"
hint={`Deployed: ${deployed.timezone || 'not set'}. An IANA name, for example Pacific/Auckland. Decides what "today" means for the schedule rows, the home hero and the sign-in history.`}
>
<input
type="text"
value={draft.timezone}
placeholder={deployed.timezone}
onChange={(event) => set('timezone', event.target.value)}
/>
</Field>
<Field
label="Log level"
hint={`Deployed: ${deployed.logLevel}. Applies to the running process at once, so debug can be turned on to watch something happen.`}
>
<select value={draft.logLevel} onChange={(event) => set('logLevel', event.target.value)}>
<option value="">Deployed ({deployed.logLevel})</option>
{levels.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</Field>
</div>
<div className="fields">
<Field
label="Sign a television out after (days)"
hint={`Deployed: ${deployed.sessionIdleDays} days. A session row holds a live Emby token, so this is how long a set nobody uses keeps working credentials.`}
>
<input
type="text"
inputMode="numeric"
value={draft.sessionIdleDays}
placeholder={String(deployed.sessionIdleDays)}
onChange={(event) => set('sessionIdleDays', event.target.value)}
/>
</Field>
<Field
label="Slow-request breakdown above (ms)"
hint={`Deployed: ${deployed.slowRequestMillis || 'off'}. A request slower than this logs where its time went — Emby, Postgres, Redis, row assembly. Lower it while chasing one slow screen; type off to stop annotating altogether.`}
>
<input
type="text"
inputMode="numeric"
value={draft.slowRequestMillis}
placeholder={String(deployed.slowRequestMillis)}
onChange={(event) => set('slowRequestMillis', event.target.value)}
/>
</Field>
<Field
label="Emby health probe (seconds)"
hint={`Deployed: ${deployed.embyHealthSeconds || 'off'}. How often the gateway asks Emby whether it is answering. Type off to stop probing, which also removes the outage bar from every television.`}
>
<input
type="text"
inputMode="numeric"
value={draft.embyHealthSeconds}
placeholder={String(deployed.embyHealthSeconds)}
onChange={(event) => set('embyHealthSeconds', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Catalogue sweep (minutes)"
hint={`Deployed: ${deployed.librarySyncMinutes || 'off'}. How often the gateway asks Emby what has changed. With the Sonarr and Radarr webhooks wired up a new file is in the catalogue within a minute of landing, and this is only reconciliation for media they do not manage — 360 is a sensible choice then. Without them it is the only way anything is found.`}
>
<input
type="text"
inputMode="numeric"
value={draft.librarySyncMinutes}
placeholder={String(deployed.librarySyncMinutes)}
onChange={(event) => set('librarySyncMinutes', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Notification display"
hint="Controls informational banners for every viewer and television. Everywhere also permits playback overlays; Home only keeps them off active movies, episodes and trailers; Off hides them throughout Memby."
>
<select
value={draft.notificationDisplay}
onChange={(event) => set('notificationDisplay', event.target.value)}
>
{notificationDisplays.map((value) => (
<option key={value} value={value}>{notificationDisplayLabel(value)}</option>
))}
</select>
</Field>
</div>
<div className="fields">
<Field
label="Episode alert window (minutes)"
hint={`Deployed: ${deployed.sonarrAlertMinutes || 'off'}. How long a "just aired" notice stays on offer to a set that was switched off at the time. Type off to stop announcing them.`}
>
<input
type="text"
inputMode="numeric"
value={draft.sonarrAlertMinutes}
placeholder={String(deployed.sonarrAlertMinutes)}
onChange={(event) => set('sonarrAlertMinutes', event.target.value)}
/>
</Field>
<Field
label="Film alert window (minutes)"
hint={`Deployed: ${deployed.radarrAlertMinutes || 'off'}. The same, for a film Radarr has just imported.`}
>
<input
type="text"
inputMode="numeric"
value={draft.radarrAlertMinutes}
placeholder={String(deployed.radarrAlertMinutes)}
onChange={(event) => set('radarrAlertMinutes', event.target.value)}
/>
</Field>
</div>
<Note tone="note">
These settings live in the database and survive a restart. A deployment rewrites
<code>.env</code>, not this document, so environment-backed values can then disagree;
permanent environment changes belong in <code>.env.example</code> as well.
</Note>
{data?.settings.updatedBy ? (
<Note>
Last changed by {data.settings.updatedBy}
{data.settings.updatedAt ? ` on ${new Date(data.settings.updatedAt).toLocaleString('en-NZ')}` : ''}.
</Note>
) : null}
</Card>
</>
)}
<GatewaySettingsSection />
</>
);
}
+41
View File
@@ -786,6 +786,47 @@ a {
color: var(--text);
}
/* Tabs split a page that is genuinely several things about one subject. They sit on a
hairline directly under the page head; the active tab carries an accent underline. On a
narrow console the strip scrolls rather than wrapping. */
.tabs {
display: flex;
gap: 2px;
margin: -4px 0 22px;
border-bottom: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tabs button {
display: inline-flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
padding: 9px 14px;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: none;
color: var(--muted);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.tabs button .ico {
width: 15px;
height: 15px;
}
.tabs button:hover {
color: var(--text);
}
.tabs button[aria-selected="true"] {
color: var(--text);
border-bottom-color: var(--accent);
}
@media (max-width: 1400px) {
:root {
--rail: 0px;