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