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