0.1.52 Gateway
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user