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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,9 +12,9 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-BQ_ZF8Re.js"></script>
<script type="module" crossorigin src="/admin/assets/index-BpHOHb2g.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-BwjLFE7Y.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-BIKdFbmA.css">
<link rel="stylesheet" crossorigin href="/admin/assets/index-CXwJRCVF.css">
</head>
<body>
<div id="root"></div>
+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;