0.2.63 update

This commit is contained in:
ponzischeme89
2026-08-14 11:47:32 +12:00
parent 9a8ecbdceb
commit 06b6490ac9
41 changed files with 921 additions and 179 deletions
+29 -52
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
@@ -11,31 +11,27 @@ import {
Chip,
Confirm,
Empty,
Field,
Grid,
Loading,
PageHead,
PlainTiles,
Tag,
Toggle,
} from '../components/ui';
type Mode = 'default' | 'on' | 'off';
interface Pending {
action: 'safe-mode' | 'rollback' | 'reset';
action: 'safe-mode' | 'rollback' | 'reset' | 'feature';
title: string;
body: string;
label: string;
key?: string;
enabled?: boolean;
}
export function FeaturesPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
// The choices the operator has made but not published. Held apart from the server's
// answer so a poll landing mid-edit cannot take a half-made decision away — the rule the
// previous console needed `Admin.settled` for.
const [draft, setDraft] = useState<Record<string, Mode>>({});
const [pending, setPending] = useState<Pending | null>(null);
const policy = status?.features;
@@ -43,20 +39,6 @@ export function FeaturesPage() {
const clients = status?.clients ?? [];
const revision = policy?.revision ?? 0;
useEffect(() => {
// Adopt the server's state only where the operator has not expressed a preference.
if (!policy) return;
setDraft((current) => {
const next = { ...current };
for (const feature of policy.features ?? []) {
if (next[feature.key] === undefined) {
next[feature.key] = feature.source === 'override' ? (feature.enabled ? 'on' : 'off') : 'default';
}
}
return next;
});
}, [policy]);
const act = (action: string, key: string, message: string, overrides?: Record<string, boolean>) =>
run(key, async () => {
await wrap(
@@ -71,24 +53,17 @@ export function FeaturesPage() {
message,
);
setPending(null);
if (action === 'save') {
// Published: the draft is now the server's state, so stop holding it.
setDraft({});
}
await reload();
});
const publish = () => {
const overrides: Record<string, boolean> = {};
for (const [key, mode] of Object.entries(draft)) {
if (mode === 'on') overrides[key] = true;
if (mode === 'off') overrides[key] = false;
}
return act('save', 'save', 'Published.', overrides);
};
const capable = clients.filter((client) => (client.capabilities ?? []).includes('server_features_v1')).length;
const safeMode = Boolean(policy?.safeMode);
const overridesFor = (key: string, enabled: boolean) => ({
...Object.fromEntries(
features.filter((feature) => feature.source === 'override').map((feature) => [feature.key, feature.enabled]),
),
[key]: enabled,
});
return (
<>
@@ -160,18 +135,18 @@ export function FeaturesPage() {
actions={<Tag tone={feature.enabled ? 'ok' : undefined}>{feature.enabled ? 'active' : 'off'}</Tag>}
footer={<span className="hint"> {feature.recovery}</span>}
>
<Field label="Mode">
<select
value={draft[feature.key] ?? 'default'}
onChange={(event) =>
setDraft((current) => ({ ...current, [feature.key]: event.target.value as Mode }))
}
>
<option value="default">Safe default</option>
<option value="on">Forced on</option>
<option value="off">Forced off</option>
</select>
</Field>
<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>
@@ -187,9 +162,6 @@ export function FeaturesPage() {
<Card>
<div className="row">
<Button variant="primary" busy={busy === 'save'} onClick={() => void publish()}>
Publish changes
</Button>
<Button
disabled={!policy.canRollback}
onClick={() =>
@@ -233,7 +205,12 @@ export function FeaturesPage() {
confirmLabel={pending.label}
destructive={pending.action !== 'rollback'}
busy={busy === pending.action}
onConfirm={() => void act(pending.action, pending.action, `${pending.label} done.`)}
onConfirm={() => void act(
pending.action === 'feature' ? 'save' : pending.action,
pending.action === 'feature' ? pending.key ?? 'feature' : pending.action,
`${pending.label} done.`,
pending.action === 'feature' && pending.key ? overridesFor(pending.key, Boolean(pending.enabled)) : undefined,
)}
onCancel={() => setPending(null)}
/>
) : null}
+62 -1
View File
@@ -18,7 +18,7 @@ import {
Tag,
Toggle,
} from '../components/ui';
import type { Integration, IntegrationEventOption, IntegrationsResponse, SonarrRequestPolicy } from '../api/types';
import type { ArrIntegrationStatus, Integration, IntegrationEventOption, IntegrationsResponse, RadarrRequestPolicy, SonarrRequestPolicy } from '../api/types';
/* Integrations: administrative events going out to somewhere else.
*
@@ -111,7 +111,9 @@ export function IntegrationsPage() {
<Banner message={error} />
<ArrIntegrationCard />
<SonarrRequestCard />
<RadarrRequestCard />
{(data?.dropped ?? 0) > 0 ? (
<Note tone="warn">
@@ -170,6 +172,27 @@ export function IntegrationsPage() {
);
}
function ArrIntegrationCard() {
const { wrap } = useToast();
const { busy, run } = useAction();
const { data, error, loading, reload } = useQuery<ArrIntegrationStatus>('/admin/api/arr-integrations');
const update = (next: Partial<ArrIntegrationStatus>) => run('arr-integrations', async () => {
if (!data) return;
await wrap(() => api.post('/admin/api/arr-integrations', {
sonarrEnabled: next.sonarrEnabled ?? data.sonarrEnabled,
radarrEnabled: next.radarrEnabled ?? data.radarrEnabled,
}), 'Integration settings saved.');
await reload();
});
return <Card title="Sonarr and Radarr" intro="Turn either service off without removing its address, API key or request policy. Disabled services are not offered for Memby requests." icon="plug" tone="info">
<Banner message={error ?? ''} />
{loading ? <Loading rows={2} /> : <>
<Toggle label="Sonarr enabled" hint={data?.sonarrConfigured ? 'Off stops Memby sending or looking up TV requests through Sonarr.' : 'Sonarr is not configured.'} checked={Boolean(data?.sonarrEnabled)} disabled={!data?.sonarrConfigured || busy === 'arr-integrations'} onChange={(sonarrEnabled) => void update({ sonarrEnabled })} />
<Toggle label="Radarr enabled" hint={data?.radarrConfigured ? 'Off stops Memby sending or looking up film requests through Radarr.' : 'Radarr is not configured.'} checked={Boolean(data?.radarrEnabled)} disabled={!data?.radarrConfigured || busy === 'arr-integrations'} onChange={(radarrEnabled) => void update({ radarrEnabled })} />
</>}
</Card>;
}
function SonarrRequestCard() {
const { wrap } = useToast();
const { busy, run } = useAction();
@@ -219,6 +242,44 @@ function SonarrRequestCard() {
);
}
function RadarrRequestCard() {
const { wrap } = useToast();
const { busy, run } = useAction();
const { data, error, loading, reload } = useQuery<RadarrRequestPolicy>('/admin/api/radarr-request-policy');
const [profileId, setProfileId] = useState(0);
const [searchImmediately, setSearchImmediately] = useState(false);
useEffect(() => {
if (data) { setProfileId(data.qualityProfileId); setSearchImmediately(data.searchImmediately); }
}, [data]);
const save = () => run('radarr-request-policy', async () => {
await wrap(() => api.post('/admin/api/radarr-request-policy', { qualityProfileId: profileId, searchImmediately }), 'Radarr movie request policy saved.');
await reload();
});
const selected = data?.profiles.find((profile) => profile.id === profileId);
return (
<Card
title="Radarr movie requests"
intro="The policy Memby uses when a viewer requests a film. The film remains monitored; an immediate Radarr search is an explicit choice."
icon="tv"
tone={data?.configured ? 'ok' : 'warn'}
actions={data?.configured ? <Tag tone="ok">configured</Tag> : <Tag tone="warn">needs attention</Tag>}
footer={<Button variant="primary" busy={busy === 'radarr-request-policy'} disabled={loading || profileId <= 0} onClick={() => void save()}>Save Radarr policy</Button>}
>
<Banner message={error ?? data?.error ?? ''} />
{loading ? <Loading rows={2} /> : <>
<div className="fields"><Field label="Request quality profile" hint="Memby stores this Radarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.">
<select value={profileId} onChange={(event) => setProfileId(Number(event.target.value))} disabled={!data?.profiles.length}>
<option value={0}>Choose a quality profile…</option>
{data?.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}{profile.recommended ? ' recommended (720p)' : ''}</option>)}
</select>
</Field></div>
<Toggle label="Search for the film immediately after request" hint="Off adds and monitors the film without asking Radarr to search. Enable only when requests should start an immediate search." checked={searchImmediately} onChange={setSearchImmediately} />
{selected ? <Note tone="info">Requested films will use <b>{selected.name}</b> (profile ID {selected.id}), remain monitored, and {searchImmediately ? 'start an immediate search.' : 'not start an immediate search.'}</Note> : null}
</>}
</Card>
);
}
function IntegrationCard({
integration,
catalogue,
+22 -15
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Field, PageHead } from '../components/ui';
@@ -18,9 +18,10 @@ const DRAW = 2_500;
const POLL_MS = 5_000;
/* The same order the server's own console lines use — who and where first, the reason for
the line last — so a log read here and a log read over SSH look alike. `version` is
dropped: it is the same on every line and is reported once in the top bar instead. */
const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration'];
the line last — so a log read here and a log read over SSH look alike. The gateway
version deliberately remains visible: a server-log export is often read away from the
console whose top bar would otherwise supply it. */
const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration', 'version', 'gateway_version'];
function orderedFields(attributes: Record<string, unknown>): [string, unknown][] {
const rank = (key: string) => {
@@ -28,11 +29,11 @@ function orderedFields(attributes: Record<string, unknown>): [string, unknown][]
if (at >= 0) return at;
return key === 'error' ? 1000 : 100;
};
return Object.entries(attributes)
.filter(([key]) => key !== 'version')
.sort((a, b) => rank(a[0]) - rank(b[0]));
return Object.entries(attributes).sort((a, b) => rank(a[0]) - rank(b[0]));
}
const readableKey = (key: string) => key.replace(/_/g, ' ');
const haystack = (event: LogEvent) =>
[event.message, ...Object.entries(event.attributes ?? {}).flat()].join(' ').toLowerCase();
@@ -157,25 +158,31 @@ export function LogsPage() {
</div>
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-live="polite">
<div className="loghead" aria-hidden="true">
<span>Time</span>
<span>Level</span>
<span>Event</span>
<span>Details</span>
</div>
{visible.length === 0 ? (
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
) : (
visible.map((event, index) => (
<div className="logline" key={`${event.occurredAt}:${index}`} data-level={event.level}>
<time>{when(event.occurredAt)}</time>
<time title={`Log record ${event.sequence}`}>{when(event.occurredAt)}<small>#{event.sequence}</small></time>
{/* The level is a class on the line as well as its own column: scrolling a
log is looking for the one line that is not INFO, and a coloured word
four columns in is easy to scroll past. */}
<span className="lvl">{event.level}</span>
<span className="msg">{event.message}</span>
<span className="attrs">
{orderedFields(event.attributes ?? {}).map(([key, value]) => (
<span key={key}>
{' '}
<b>{key}=</b>
{String(value)}
</span>
))}
<details className="logdetails">
<summary>{orderedFields(event.attributes ?? {}).map(([key, value]) => <span key={key}><b>{key}=</b>{String(value)} </span>)}</summary>
<dl>
<dt>Log record</dt><dd>{event.sequence}</dd>
{orderedFields(event.attributes ?? {}).map(([key, value]) => <Fragment key={key}><dt>{readableKey(key)}</dt><dd>{String(value)}</dd></Fragment>)}
</dl>
</details>
</span>
</div>
))