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( `/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 ( <> {loading ? : No such integration.} ); } const failures = runs.filter((entry) => entry.status === 'failed').length; return ( <> All integrations } /> 0 ? 'bad' : undefined, }, { label: 'Last worked', value: service.lastSuccessAt ? ago(service.lastSuccessAt) : 'never', small: true, icon: 'check', tone: service.lastSuccessAt ? 'ok' : undefined, }, ]} /> {service.statusLabel} } footer={ service.configured && service.probed ? ( ) : undefined } > {service.detail ? {service.detail} : 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. 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. ) : ( <> void setEnabled(enabled)} /> ({ label: fact.label, value: fact.tone ? ( {fact.value} ) : ( 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 ? ( {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. ) : null} )} {service.powers.length > 0 ? ( {/* The console's list vocabulary rather than a bare
    : 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. */}
    {service.powers.map((power) => (
    {power}
    ))}
    ) : null} {service.tasks.length === 0 ? ( This service has no scheduled work: Memby calls it when a television asks for something rather than on a timer. ) : ( service.tasks.map((task) => ( )) )}
    Task Schedule Last run Next run
    {task.name} {task.description} {interval(task.intervalSeconds)} {task.lastRun ? ago(task.lastRun.startedAt) : 'never'} {!task.enabled || !service.enabled ? 'off' : task.running ? 'now' : until(task.nextRun)}
    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. */ Open the logs ) : undefined } > {runs.length === 0 ? ( Nothing has run for {service.name} yet. ) : ( runs.map((entry) => ) )}
    Started Task Trigger Result Outcome Checked Changed Skipped Failed Took
    ); } 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 ( {ago(run.startedAt)} {task?.name ?? run.taskId} {run.trigger} {run.status} {runOutcome(run)} {run.error && countSummary(run.counts) ? ( {countSummary(run.counts)} ) : null} {figure(run.counts?.processed ?? 0)} {figure(run.counts?.changed ?? 0)} {figure(run.counts?.skipped ?? 0)} {figure(run.counts?.failed ?? 0)} {duration(run.durationMs)} ); } /* 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 ; case 'sonarr': return ; case 'radarr': return ; default: return null; } }