0.3.49
This commit is contained in:
@@ -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 0–23 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 (0–23)"
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -44,6 +44,13 @@ export const nav: NavGroup[] = [
|
||||
item('journey-viewer', '/admin/journeys/:userId', 'Journey', 'User journey', 'One viewer’s 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 gateway’s 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 }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user