426 lines
15 KiB
TypeScript
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;
|
||
|
|
}
|
||
|
|
}
|