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
@@ -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 {