Files
memby/admin-ui/src/pages/Integration.tsx
T
2026-08-19 14:25:44 +12:00

426 lines
15 KiB
TypeScript

import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { ago, duration, interval, num, until, when } from '../lib/format';
import {
countSummary,
dependencyWarning,
integrationDot,
integrationTone,
runOutcome,
runTone,
} from '../lib/integrations';
import { Glyph } from '../components/Icon';
import {
Banner,
Button,
Card,
EmptyRow,
KeyValue,
Loading,
Note,
PageHead,
TableWrap,
Tag,
Tiles,
Toggle,
} from '../components/ui';
import { RatingsSettingsCard } from './Ratings';
import { RadarrRequestCard, SonarrRequestCard } from './Webhooks';
import type { IntegrationService, IntegrationServiceResponse, TaskRun } from '../api/types';
/* One integration: its switch, its settings, its jobs and everything it has done.
*
* A page about one service belongs to the service rather than to the rail, the stance the
* user, device and task pages take — so it is a hidden destination addressed by an id in
* the path, and the overview is the way in.
*
* It is deliberately the only place a service can be configured. Before it, MDBList lived
* on a Movie ratings page, the Sonarr and Radarr switches lived on a page about Discord,
* their request policies lived beside those, and Tracearr could not be reached at all —
* so "is this integration set up correctly" was four pages and one impossible question.
*
* The run history here is not a log. Logs are the technical events behind a failure and
* they have their own page, which the failure notice links to; this is the operational
* record of what Memby attempted on this service's behalf and what came of it. They come
* from the same place — the scheduler's run table, read along the integration axis —
* which is what keeps them from disagreeing. */
const IDLE_POLL_MS = 20_000;
const BUSY_POLL_MS = 3_000;
export function IntegrationPage() {
const { integrationId = '' } = useParams();
const { wrap, show } = useToast();
const { busy, run } = useAction();
const [fast, setFast] = useState(false);
const { data, error, loading, reload } = useQuery<IntegrationServiceResponse>(
`/admin/api/integrations/services/${encodeURIComponent(integrationId)}?limit=80`,
{ pollMs: fast ? BUSY_POLL_MS : IDLE_POLL_MS, enabled: Boolean(integrationId) },
);
const service = data?.service;
const runs = data?.runs ?? [];
const running = Boolean(service?.running);
if (running !== fast) setFast(running);
const setEnabled = (enabled: boolean) =>
run('enabled', async () => {
await wrap(
() =>
api.post(
`/admin/api/integrations/services/${encodeURIComponent(integrationId)}/enabled`,
{ enabled },
),
`${service?.name ?? 'Integration'} ${enabled ? 'enabled' : 'disabled'}.`,
);
await reload();
});
const test = () =>
run('test', async () => {
const result = await wrap(() =>
api.post<{ reachable: boolean; error?: string; latencyMs: number }>(
`/admin/api/integrations/services/${encodeURIComponent(integrationId)}/test`,
),
);
// The probe answers 200 whether or not the service replied, because the *request*
// succeeded — so the verdict is in the body and reporting it is this page's job.
if (result) {
show(
result.reachable
? `${service?.name} answered in ${duration(result.latencyMs)}.`
: result.error || `${service?.name} did not answer.`,
result.reachable ? 'ok' : 'bad',
);
}
await reload();
});
const runTask = (taskId: string, name: string) =>
run(taskId, async () => {
await wrap(
() => api.post(`/admin/api/tasks/${encodeURIComponent(taskId)}/run`),
`${name} started.`,
);
await reload();
});
if (loading || !service) {
return (
<>
<PageHead title="Integration" intro="One external service and everything it has done." />
<Banner message={error} />
{loading ? <Loading /> : <Note tone="warn">No such integration.</Note>}
</>
);
}
const failures = runs.filter((entry) => entry.status === 'failed').length;
return (
<>
<PageHead
title={service.name}
intro={service.summary}
actions={
<Link className="table-row-link" to="/admin/integrations">
All integrations
</Link>
}
/>
<Banner message={error} />
<Tiles
tiles={[
{
label: 'Status',
value: service.statusLabel,
small: true,
icon: 'pulse',
tone: integrationTone(service.status),
},
{
label: 'Runs recorded',
value: num(service.runs),
icon: 'history',
tone: 'data',
},
{
label: 'Failed runs',
value: num(service.failures),
icon: 'alert',
tone: service.failures > 0 ? 'bad' : undefined,
},
{
label: 'Last worked',
value: service.lastSuccessAt ? ago(service.lastSuccessAt) : 'never',
small: true,
icon: 'check',
tone: service.lastSuccessAt ? 'ok' : undefined,
},
]}
/>
<Card
title="Connection"
intro="Address and credentials come from this gateway's environment; the switch is stored on the server and applies to the whole household."
icon="plug"
tone={integrationTone(service.status) ?? 'info'}
actions={
<span className="row tight">
<span className="dot-state" data-tone={integrationDot(service.status)} />
<Tag tone={integrationTone(service.status)}>{service.statusLabel}</Tag>
</span>
}
footer={
service.configured && service.probed ? (
<Button icon="sync" busy={busy === 'test'} onClick={() => void test()}>
Test connection
</Button>
) : undefined
}
>
{service.detail ? <Note tone={integrationTone(service.status)}>{service.detail}</Note> : null}
{!service.configured ? (
// Stated rather than offered. There is nothing on this page that could fix it:
// the address and key are environment variables on the container, so a switch
// here would be one that records a decision nothing ever reads.
<Note tone="warn">
This gateway has no address or credential for {service.name}. Set them in the
deployment's environment and restart the container; there is nothing to switch until
then.
</Note>
) : (
<>
<Toggle
label={`${service.name} enabled`}
hint={
service.enabled
? dependencyWarning(service)
: `Memby is not calling ${service.name} or scheduling any of its work.`
}
checked={service.enabled}
disabled={busy === 'enabled'}
onChange={(enabled) => void setEnabled(enabled)}
/>
<KeyValue
rows={[
{ label: 'Address', value: service.address || '' },
...(service.facts ?? []).map((fact) => ({
label: fact.label,
value: fact.tone ? (
<Tag tone={fact.tone === 'warn' ? 'warn' : 'ok'}>{fact.value}</Tag>
) : (
fact.value
),
})),
{
label: 'Last checked',
// "Never checked" and "cannot be checked" are different answers, and a
// service that is deliberately not probed must not read as one nobody has
// got round to looking at.
value: !service.probed
? 'Not probed see below'
: service.health
? `${ago(service.health.checkedAt)} · ${duration(service.health.latencyMs)}`
: 'not yet',
},
]}
/>
{!service.probed ? (
<Note>
{service.name} is not probed for reachability: its allowance is bought by the day,
and spending a request of it to draw a status would compete with the televisions
for the thing being reported on. Its health comes from the run history below.
</Note>
) : null}
</>
)}
</Card>
{service.powers.length > 0 ? (
<Card
title="What depends on this"
intro="Switching the service off stops all of it. Nothing below fails quietly — it stops being offered."
icon="journey"
tone="note"
>
{/* The console's list vocabulary rather than a bare <ul>: this is the same
shape every other enumeration on the console wears, and a page inventing its
own is how twelve screens stop reading as one. */}
<div className="list">
{service.powers.map((power) => (
<div className="list-item" key={power}>
<Glyph name="check" tone="ok" />
<span className="list-body">{power}</span>
</div>
))}
</div>
</Card>
) : null}
<IntegrationSettings id={service.id} />
<Card
title="Scheduled work"
intro="The background jobs belonging to this service. Cadence and the per-job switch live on each job's own page; this is where you start one by hand."
icon="clock"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Task</th>
<th>Schedule</th>
<th>Last run</th>
<th>Next run</th>
<th />
</tr>
</thead>
<tbody>
{service.tasks.length === 0 ? (
<EmptyRow columns={5}>
This service has no scheduled work: Memby calls it when a television asks for
something rather than on a timer.
</EmptyRow>
) : (
service.tasks.map((task) => (
<tr key={task.id}>
<td>
<Link className="table-row-link" to={`/admin/tasks/${encodeURIComponent(task.id)}`}>
{task.name}
</Link>
<span className="table-sub">{task.description}</span>
</td>
<td className="nowrap muted">{interval(task.intervalSeconds)}</td>
<td className="nowrap muted" title={task.lastRun ? when(task.lastRun.startedAt) : undefined}>
{task.lastRun ? ago(task.lastRun.startedAt) : 'never'}
</td>
<td className="nowrap muted">
{!task.enabled || !service.enabled ? 'off' : task.running ? 'now' : until(task.nextRun)}
</td>
<td className="nowrap">
<Button
size="sm"
icon="play"
busy={busy === task.id}
disabled={task.running}
title={`Run ${task.name} now`}
onClick={() => void runTask(task.id, task.name)}
/>
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="Run history"
intro="What Memby attempted on this service's behalf and what came of it. This is not the application log — a failure here gives the reason, and Logs is where the technical detail behind it lives."
icon="history"
tone="note"
actions={
failures > 0 ? (
/* Narrowed to this service by name, which is what makes the link worth
following: the run history says a request failed and the log says what the
request was. */
<Link
className="table-row-link"
to={`/admin/logs?q=${encodeURIComponent(service.id)}`}
>
Open the logs
</Link>
) : undefined
}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Started</th>
<th>Task</th>
<th>Trigger</th>
<th>Result</th>
<th>Outcome</th>
<th className="num">Checked</th>
<th className="num">Changed</th>
<th className="num">Skipped</th>
<th className="num">Failed</th>
<th className="num">Took</th>
</tr>
</thead>
<tbody>
{runs.length === 0 ? (
<EmptyRow columns={10}>
Nothing has run for {service.name} yet.
</EmptyRow>
) : (
runs.map((entry) => <RunRow key={entry.id} run={entry} service={service} />)
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
);
}
function RunRow({ run, service }: { run: TaskRun; service: IntegrationService }) {
const task = service.tasks.find((entry) => entry.id === run.taskId);
// A figure that is zero is drawn as an em dash rather than as 0: most runs count one or
// two of the four, and a wall of zeroes reads as a table reporting nothing happened
// rather than as one reporting what did.
const figure = (value: number) => (value ? num(value) : '—');
return (
<tr>
<td className="nowrap muted" title={when(run.startedAt)}>
{ago(run.startedAt)}
</td>
<td className="muted">{task?.name ?? run.taskId}</td>
<td className="muted">{run.trigger}</td>
<td>
<Tag tone={runTone(run.status)}>{run.status}</Tag>
</td>
<td className="muted">
{runOutcome(run)}
{run.error && countSummary(run.counts) ? (
<span className="table-sub">{countSummary(run.counts)}</span>
) : null}
</td>
<td className="num muted">{figure(run.counts?.processed ?? 0)}</td>
<td className="num muted">{figure(run.counts?.changed ?? 0)}</td>
<td className="num muted">{figure(run.counts?.skipped ?? 0)}</td>
<td className="num muted">{figure(run.counts?.failed ?? 0)}</td>
<td className="num muted">{duration(run.durationMs)}</td>
</tr>
);
}
/* The settings that belong to one service and nowhere else.
*
* A lookup rather than a field on the wire: what a service's settings *are* is markup, and
* the gateway has no business describing a React component. A service with nothing here
* renders nothing, which is the Tracearr case — everything about it is environment
* configuration, and the page says so above. */
function IntegrationSettings({ id }: { id: string }) {
switch (id) {
case 'mdblist':
return <RatingsSettingsCard />;
case 'sonarr':
return <SonarrRequestCard />;
case 'radarr':
return <RadarrRequestCard />;
default:
return null;
}
}