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
+10
View File
@@ -392,9 +392,19 @@ export interface SonarrRequestPolicy {
error?: string;
}
export interface RadarrRequestPolicy extends SonarrRequestPolicy {}
export interface ArrIntegrationStatus {
sonarrConfigured: boolean;
radarrConfigured: boolean;
sonarrEnabled: boolean;
radarrEnabled: boolean;
}
/* ---------- logs ---------- */
export interface LogEvent {
sequence: number;
occurredAt: string;
level: string;
message: string;
+17 -2
View File
@@ -7,6 +7,7 @@ import { nav } from '../nav';
import { useNotifications } from '../lib/notifications';
import { useGateway } from '../lib/gateway';
import { time } from '../lib/format';
import { Confirm } from './ui';
/* The shell: a top bar spanning the width, a rail down the left, and the page.
*
@@ -105,12 +106,12 @@ export function Layout() {
const { version, online, checkedAt, status, setMaintenance } = useGateway();
const [railOpen, setRailOpen] = useState(false);
const [changingAvailability, setChangingAvailability] = useState(false);
const [confirmingOffline, setConfirmingOffline] = useState(false);
const location = useLocation();
const offline = Boolean(status?.maintenance?.enabled);
const toggleAvailability = async () => {
if (changingAvailability || !status) return;
if (!offline && !window.confirm('Take Memby offline for every television?')) return;
setChangingAvailability(true);
try {
await setMaintenance(!offline);
@@ -162,7 +163,7 @@ export function Layout() {
aria-pressed={offline}
disabled={!status || changingAvailability}
title={offline ? 'Bring Memby back online' : 'Take Memby offline'}
onClick={() => void toggleAvailability()}
onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)}
>
<span className="dot" />
<b>{offline ? 'offline' : online ? 'online' : 'not responding'}</b>
@@ -182,6 +183,20 @@ export function Layout() {
<main className="page" id="main">
<Outlet />
</main>
{confirmingOffline ? (
<Confirm
title="Take Memby offline?"
body="Every television will stop working immediately. Viewers will see the maintenance message configured on the Maintenance page, while this console remains available."
confirmLabel="Go offline"
destructive
busy={changingAvailability}
onConfirm={() => {
setConfirmingOffline(false);
void toggleAvailability();
}}
onCancel={() => setConfirmingOffline(false)}
/>
) : null}
</>
);
}
+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>
))
+58 -3
View File
@@ -744,9 +744,9 @@ a {
background: var(--surface);
}
.tile .glyph {
/* Tiles keep their figures left-aligned for quick scanning, but the visual glyph is a
standalone marker and follows the centred treatment used by glyph plates elsewhere. */
margin: 0 auto 9px;
/* Summary cards share tile padding, so their marker always starts at the same top-left
inset. The SVG itself remains centred by .glyph's grid treatment. */
margin: 0 0 9px;
}
.tile b {
display: block;
@@ -1442,6 +1442,22 @@ select {
background: #080a0e;
font: 12px/1.6 var(--mono);
}
.loghead {
position: sticky;
top: 0;
z-index: 1;
display: grid;
grid-template-columns: max-content 46px minmax(180px, 1fr) minmax(240px, 2fr);
gap: 12px;
padding: 7px 12px;
border-bottom: 1px solid var(--line);
background: #10131a;
color: var(--quiet);
font-size: 10px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
}
.logline {
display: flex;
gap: 12px;
@@ -1457,6 +1473,11 @@ select {
flex: 0 0 auto;
color: var(--quiet);
}
.logline time small {
display: block;
color: var(--muted);
font-size: 10px;
}
.logline .lvl {
flex: 0 0 46px;
font-weight: 700;
@@ -1480,10 +1501,44 @@ select {
.logline .attrs {
color: var(--quiet);
}
@media (min-width: 900px) {
.logline {
display: grid;
grid-template-columns: max-content 46px minmax(180px, 1fr) minmax(240px, 2fr);
}
}
.logline .attrs b {
color: var(--muted);
font-weight: 400;
}
.logdetails {
min-width: 0;
}
.logdetails summary {
cursor: pointer;
list-style: none;
overflow-wrap: anywhere;
}
.logdetails summary::-webkit-details-marker { display: none; }
.logdetails summary::before {
content: '';
display: inline-block;
margin-right: 6px;
color: var(--accent);
}
.logdetails[open] summary::before { transform: rotate(90deg); }
.logdetails dl {
display: grid;
grid-template-columns: minmax(130px, max-content) minmax(0, 1fr);
gap: 4px 12px;
margin: 7px 0 2px;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, .025);
}
.logdetails dt { color: var(--muted); }
.logdetails dd { min-width: 0; margin: 0; color: var(--text); overflow-wrap: anywhere; }
pre.code {
margin: 0;