0.1.52 Gateway

This commit is contained in:
ponzischeme89
2026-08-17 19:09:17 +12:00
parent d5a8d3ad88
commit 1da91e40a1
46 changed files with 1626 additions and 106 deletions
+2
View File
@@ -26,6 +26,7 @@ import { UpdatesPage } from './pages/Updates';
import { TasksPage } from './pages/Tasks';
import { IntegrationsPage } from './pages/Integrations';
import { MaintenancePage } from './pages/Maintenance';
import { SettingsPage } from './pages/Settings';
import { ImportsPage } from './pages/Imports';
import { LogsPage } from './pages/Logs';
import { JourneysPage } from './pages/Journeys';
@@ -86,6 +87,7 @@ export function App() {
<Route path="tasks" element={<TasksPage />} />
<Route path="integrations" element={<IntegrationsPage />} />
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="imports" element={<ImportsPage />} />
<Route path="logs" element={<LogsPage />} />
+35
View File
@@ -528,3 +528,38 @@ export interface RuntimeStatus {
memoryLimit: number;
configuredLimit?: string;
}
/* ---------- gateway settings ---------- */
/** GatewaySettings is the operator's overrides. Every field is optional in meaning: an
* empty string or a zero means "whatever the container was started with", and -1 means
* off for the three that can be switched off. */
export interface GatewaySettings {
timezone: string;
logLevel: string;
sessionIdleDays: number;
sonarrAlertMinutes: number;
radarrAlertMinutes: number;
embyHealthSeconds: number;
updatedAt?: string;
updatedBy?: string;
}
/** GatewaySettingValues is the same vocabulary as plain values, used twice: once for what
* the container was deployed with and once for what is actually in force. */
export interface GatewaySettingValues {
timezone: string;
logLevel: string;
sessionIdleDays: number;
sonarrAlertMinutes: number;
radarrAlertMinutes: number;
embyHealthSeconds: number;
}
export interface GatewaySettingsResponse {
settings: GatewaySettings;
deployed: GatewaySettingValues;
effective: GatewaySettingValues;
logLevels: string[] | null;
version: string;
}
+7
View File
@@ -255,6 +255,13 @@ 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. */}
<NavLink to="/admin/settings" role="menuitem" onClick={() => setAccountOpen(false)}>
<Icon name="sliders" />
Gateway settings
</NavLink>
<form method="post" action="/admin/logout">
<button type="submit" role="menuitem">
<Icon name="logout" />
+21 -2
View File
@@ -268,6 +268,19 @@ export const nav: NavGroup[] = [
intro: 'Take Memby offline now or schedule daily quiet time.',
icon: 'wrench',
},
{
/* Hidden because its way in is the account menu in the top bar, not the rail:
these are settings for the server process rather than for the household, and
they belong beside "signed in as". It is still declared here so the page search
can find it and the router has one place a page is named. */
id: 'gateway-settings',
path: '/admin/settings',
label: 'Gateway settings',
title: 'Gateway settings',
intro: 'Timezone, logging and the other server-level settings for this gateway.',
icon: 'sliders',
hidden: true,
},
{
id: 'integrations',
path: '/admin/integrations',
@@ -322,9 +335,15 @@ export const nav: NavGroup[] = [
export const allNavItems: NavItem[] = nav.flatMap((group) => group.items);
/** searchableNavItems is what the omni search offers: every page that has a URL of its
* own, which is every page that is not addressed by an id in the path. */
* own, which is every page that is not addressed by an id in the path. That test is the
* path rather than `hidden`, because the two answer different questions — a page can be
* off the rail (gateway settings is reached from the account menu) and still be a real
* address somebody would type. A page about one person or one television is the case
* this excludes: without knowing which person, the URL does not exist. */
export const searchableNavItems = nav.flatMap((group) =>
group.items.filter((item) => !item.hidden).map((item) => ({ ...item, group: group.label ?? '' })),
group.items
.filter((item) => !item.path.includes(':'))
.map((item) => ({ ...item, group: group.label ?? '' })),
);
export function navItem(id: string): NavItem | undefined {
+273
View File
@@ -0,0 +1,273 @@
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';
/* 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.
*
* The whole page is an amendment to `.env`. Every field is blank by default and blank
* means "whatever this container was started with", which is printed beside it — so an
* operator can always see what they are overriding, and clearing a field is a real undo
* rather than a value they have to remember. */
/** 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'}`;
}
interface Draft {
timezone: string;
logLevel: string;
sessionIdleDays: string;
sonarrAlertMinutes: string;
radarrAlertMinutes: string;
embyHealthSeconds: 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),
embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds),
};
}
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),
embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, 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,
}),
'Every setting is back to what this container was deployed with.',
);
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const deployed = data?.deployed;
const effective = data?.effective;
const levels = data?.logLevels ?? [];
return (
<>
<PageHead
title="Gateway settings"
intro="Server-level settings for this gateway, changeable without a redeployment."
/>
<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: 'Household 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: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') },
{ label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') },
]}
/>
</Card>
<Card
title="Overrides"
intro="Leave a field empty to use the value this container was deployed with, shown beneath it. 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 deployed values
</Button>
</>
}
>
<div className="fields">
<Field
label="Household 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="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="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 override the deployed configuration in the database, so they survive a
restart but a deployment rewrites <code>.env</code>, not this, and the two can
then disagree. Anything meant to be permanent belongs 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>
</>
)}
</>
);
}
+25 -1
View File
@@ -337,9 +337,33 @@ a {
text-overflow: ellipsis;
font-size: 13.5px;
}
.account-panel form {
/* A link and a submit button sit in this menu side by side, and they have to be one row
shape: the operator reads them as two entries of the same list, not as a link above a
form. */
.account-panel > a,
.account-panel form button {
display: flex;
align-items: center;
gap: 9px;
justify-content: flex-start;
width: 100%;
height: 38px;
padding: 0 10px;
border-radius: 8px;
color: var(--muted);
font-size: 13px;
text-decoration: none;
}
.account-panel > a:hover {
background: var(--surface-hi);
color: var(--text);
}
.account-panel > a {
margin-top: 6px;
}
.account-panel form {
margin-top: 2px;
}
.account-panel form button {
justify-content: flex-start;
width: 100%;