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