This commit is contained in:
ponzischeme89
2026-08-19 14:25:44 +12:00
parent 2b43b9ef12
commit 590e069366
83 changed files with 8948 additions and 1266 deletions
+3 -3
View File
@@ -89,7 +89,7 @@ export function AccountsPage() {
return (
<>
<PageHead title="Memby users" intro="Who uses Memby, and the devices they are signed in on." />
<PageHead title="Users" intro="Who uses Memby, and which devices they are signed in to." />
<Banner message={error} />
{loading ? (
@@ -121,12 +121,12 @@ export function AccountsPage() {
]}
/>
<Card title="People" icon="people" tone="note">
<Card title="Users" icon="people" tone="note">
<TableWrap>
<table>
<thead>
<tr>
<th>Person</th>
<th>User</th>
<th>Short name</th>
<th className="num">Devices</th>
<th className="num">This week</th>
+425
View File
@@ -0,0 +1,425 @@
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;
}
}
+218 -471
View File
@@ -1,510 +1,257 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { ago, duration, num, when } from '../lib/format';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
import { ago, duration, num, until, when } from '../lib/format';
import {
compareIntegrations,
countSummary,
integrationDot,
integrationTone,
runOutcome,
runTone,
} from '../lib/integrations';
import {
Banner,
Button,
Card,
Confirm,
Empty,
EmptyRow,
Field,
Loading,
Note,
PageHead,
TableWrap,
Tag,
Toggle,
Tiles,
} from '../components/ui';
import type { ArrIntegrationStatus, Integration, IntegrationEventOption, IntegrationsResponse, RadarrRequestPolicy, SonarrRequestPolicy } from '../api/types';
import type { IntegrationService, IntegrationServicesResponse } from '../api/types';
/* Integrations: administrative events going out to somewhere else.
/* Integrations: the external services Memby depends on.
*
* The whole page is written around one property of the backend, and it is worth stating
* because the form would otherwise look careless: the webhook address is never returned.
* It is the credential — anybody holding it can post into the channel — so the gateway
* sends back only whether one is set and the channel id from the middle of it. That is why
* the address field on an existing integration is blank with a placeholder saying so, and
* why saving with it blank leaves the stored one alone. */
* One row per service and very nearly only a directory, the shape the Users and Scheduled
* tasks pages take — because the question this page is opened with is "is anything
* broken", and that is answered by scanning a column rather than by reading four cards in
* turn. Everything you can *do* to a service beyond seeing its state lives on its own
* page, which is also where the settings that used to be scattered across Ratings, the old
* Integrations page and nowhere at all now live.
*
* The status word and the sentence under it are the gateway's, not this page's. See
* lib/integrations.ts: a console that decided for itself what "healthy" meant would have
* an older build disagreeing with a newer one about the same server.
*
* Polled faster while something is running, for the reason the tasks page is: having
* pressed Run now, the next thing an operator does is watch for the outcome, and a
* thirty-second poll makes a two-second job look like one that did nothing. */
interface Draft {
id: string;
name: string;
url: string;
enabled: boolean;
events: string[];
}
const NEW_DRAFT: Draft = { id: '', name: 'Discord', url: '', enabled: true, events: [] };
const IDLE_POLL_MS = 20_000;
const BUSY_POLL_MS = 3_000;
export function IntegrationsPage() {
const { wrap, show } = useToast();
const { busy, run } = useAction();
const { data, error, loading, reload } = useQuery<IntegrationsResponse>('/admin/api/integrations', {
pollMs: 60_000,
});
const [draft, setDraft] = useState<Draft | null>(null);
const [confirming, setConfirming] = useState<Integration | null>(null);
const [fast, setFast] = useState(false);
const { data, error, loading } = useQuery<IntegrationServicesResponse>(
'/admin/api/integrations/services?limit=40',
{ pollMs: fast ? BUSY_POLL_MS : IDLE_POLL_MS },
);
const catalogue = data?.catalogue ?? [];
const integrations = data?.integrations ?? [];
const anyRunning = (data?.services ?? []).some((service) => service.running);
if (anyRunning !== fast) setFast(anyRunning);
const edit = (integration: Integration) =>
setDraft({
id: integration.id,
name: integration.name,
url: '',
enabled: integration.enabled,
events: integration.events ?? [],
});
const services = [...(data?.services ?? [])].sort(compareIntegrations);
const runs = data?.runs ?? [];
const save = () =>
run('save', async () => {
if (!draft) return;
const saved = await wrap(
() => api.post<IntegrationsResponse>('/admin/api/integrations', draft),
draft.id ? 'Integration saved.' : 'Integration added.',
);
if (saved) {
setDraft(null);
await reload();
}
});
const remove = (integration: Integration) =>
run('remove', async () => {
await wrap(
() => api.del(`/admin/api/integrations/${encodeURIComponent(integration.id)}`),
`${integration.name} removed.`,
);
setConfirming(null);
await reload();
});
const test = (integration: Integration) =>
run(`test:${integration.id}`, async () => {
const result = await wrap(() =>
api.post<{ ok: boolean; message: string }>(
`/admin/api/integrations/${encodeURIComponent(integration.id)}/test`,
),
);
// The test route answers 200 whether or not the webhook accepted it, because the
// *request* succeeded — so the verdict is in the body, and reporting it is this
// page's job rather than the transport's.
if (result) show(result.message, result.ok ? 'ok' : 'bad');
await reload();
});
const broken = services.filter((service) => service.status === 'error');
const running = services.filter((service) => service.running);
const off = services.filter((service) => service.configured && !service.enabled);
const missing = services.filter((service) => !service.configured);
return (
<>
<PageHead
title="Integrations"
intro="Send administrative events to somewhere you already look. Events pass through the gateway's own event layer, so nothing about authentication or scheduled tasks knows Discord exists — and a second kind of destination is a change here rather than everywhere."
actions={
<Button variant="primary" icon="plus" onClick={() => setDraft(NEW_DRAFT)}>
Add a webhook
</Button>
}
intro="The services Memby depends on: whether each one is configured, whether it is working, what it is doing right now and what it last managed to do."
/>
<Banner message={error} />
<ArrIntegrationCard />
<SonarrRequestCard />
<RadarrRequestCard />
{(data?.dropped ?? 0) > 0 ? (
<Note tone="warn">
{num(data?.dropped ?? 0)} events could not be queued for delivery. The queue is deliberately
lossy a slow endpoint must never hold up a television signing in but a number growing here
means a destination is not keeping up.
</Note>
) : null}
{loading ? (
<Loading />
) : integrations.length === 0 && !draft ? (
<Card title="Nothing configured" icon="plug" tone="note">
<Empty>
No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's
settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here.
</Empty>
</Card>
) : (
integrations.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
catalogue={catalogue}
busy={busy}
onEdit={() => edit(integration)}
onTest={() => void test(integration)}
onRemove={() => setConfirming(integration)}
<>
<Tiles
tiles={[
{ label: 'Integrations', value: num(services.length), icon: 'plug', tone: 'info' },
{
label: 'Not working',
value: num(broken.length),
icon: 'alert',
tone: broken.length > 0 ? 'bad' : undefined,
},
{
label: 'Running now',
value: num(running.length),
icon: 'pulse',
tone: running.length > 0 ? 'ok' : undefined,
},
{
label: 'Switched off',
value: num(off.length),
icon: 'power',
tone: off.length > 0 ? 'warn' : undefined,
},
{
label: 'Not configured',
value: num(missing.length),
icon: 'sliders',
tone: missing.length > 0 ? 'note' : undefined,
},
]}
/>
))
{broken.length > 0 ? (
<Note tone="bad">
{broken.map((service) => service.name).join(', ')}{' '}
{broken.length === 1 ? 'is not answering' : 'are not answering'}. A failed run also
publishes an administrative event, so it is in the activity feed and wherever your
webhooks send it you did not have to be looking at this page.
</Note>
) : null}
<Card
title="Services"
intro="Anything wrong sorts to the top. Open a service to switch it off, read its settings, or see everything it has done."
icon="plug"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Integration</th>
<th>Status</th>
<th>Last run</th>
<th>Result</th>
<th>Next run</th>
<th>Enabled</th>
</tr>
</thead>
<tbody>
{services.length === 0 ? (
<EmptyRow columns={6}>This gateway has no integrations.</EmptyRow>
) : (
services.map((service) => <ServiceRow key={service.id} service={service} />)
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="Recent activity"
intro="Every integration's work together and in order, which is what shows two of them getting in each other's way. This is what Memby attempted and what came of it — the Logs page is where the technical detail behind a failure lives."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Started</th>
<th>Integration</th>
<th>Task</th>
<th>Result</th>
<th>Outcome</th>
<th className="num">Took</th>
</tr>
</thead>
<tbody>
{runs.length === 0 ? (
<EmptyRow columns={6}>No integration has run yet.</EmptyRow>
) : (
runs.map((run) => {
const service = services.find((entry) => entry.id === run.integrationId);
const task = service?.tasks.find((entry) => entry.id === run.taskId);
return (
<tr key={run.id}>
<td className="nowrap muted" title={when(run.startedAt)}>
{ago(run.startedAt)}
</td>
<td className="nowrap">
<Link
className="table-row-link"
to={`/admin/integrations/${encodeURIComponent(run.integrationId ?? '')}`}
>
{service?.name ?? run.integrationId}
</Link>
</td>
<td className="muted">{task?.name ?? run.taskId}</td>
<td>
<Tag tone={runTone(run.status)}>{run.status}</Tag>
</td>
<td className="muted">
{runOutcome(run)}
{/* The figures go under the sentence rather than replacing it:
the sentence says what happened and the numbers say how
much, and a run that failed needs its reason above both. */}
{run.error && countSummary(run.counts) ? (
<span className="table-sub">{countSummary(run.counts)}</span>
) : null}
</td>
<td className="num muted">{duration(run.durationMs)}</td>
</tr>
);
})
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
{draft ? (
<DraftCard
draft={draft}
catalogue={catalogue}
busy={busy === 'save'}
onChange={setDraft}
onSave={() => void save()}
onCancel={() => setDraft(null)}
/>
) : null}
{confirming ? (
<Confirm
title={`Remove ${confirming.name}?`}
body="The webhook address and its delivery history go with it. Events already published stay in the activity feed."
confirmLabel="Remove"
destructive
busy={busy === 'remove'}
onConfirm={() => void remove(confirming)}
onCancel={() => setConfirming(null)}
/>
) : null}
</>
);
}
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();
const { data, error, loading, reload } = useQuery<SonarrRequestPolicy>('/admin/api/sonarr-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('sonarr-request-policy', async () => {
await wrap(
() => api.post('/admin/api/sonarr-request-policy', { qualityProfileId: profileId, searchImmediately }),
'Sonarr TV request policy saved.',
);
await reload();
});
const selected = data?.profiles.find((profile) => profile.id === profileId);
function ServiceRow({ service }: { service: IntegrationService }) {
const href = `/admin/integrations/${encodeURIComponent(service.id)}`;
return (
<Card
title="Sonarr TV requests"
intro="The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes 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 === 'sonarr-request-policy'} disabled={loading || profileId <= 0} onClick={() => void save()}>Save Sonarr policy</Button>}
>
<Banner message={error ?? data?.error ?? ''} />
{loading ? <Loading rows={2} /> : (
<>
<div className="fields">
<Field label="Request quality profile" hint="Memby stores this Sonarr 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 episodes immediately after request" hint="Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search." checked={searchImmediately} onChange={setSearchImmediately} />
{selected ? <Note tone="info">Requested series will use <b>{selected.name}</b> (profile ID {selected.id}), be monitored using Membys existing all-episodes strategy, and {searchImmediately ? 'start an immediate search.' : 'not start an immediate search.'}</Note> : null}
</>
)}
</Card>
);
}
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,
busy,
onEdit,
onTest,
onRemove,
}: {
integration: Integration;
catalogue: IntegrationEventOption[];
busy: string | null;
onEdit: () => void;
onTest: () => void;
onRemove: () => void;
}) {
const health = integration.health;
// "Is it working" is answered by the *last* attempt, not by a failure count: a webhook
// that failed once an hour ago and has worked since is healthy.
const healthy =
!health.lastFailure || (health.lastSuccess && health.lastSuccess > health.lastFailure);
const selected = integration.events ?? [];
return (
<Card
title={integration.name}
intro={integration.hint ? `Discord webhook ${integration.hint}` : 'Discord webhook'}
icon="plug"
tone={integration.enabled ? 'ok' : 'warn'}
actions={
<>
{integration.enabled ? <Tag tone="ok">on</Tag> : <Tag tone="warn">off</Tag>}
{health.deliveries > 0 ? (
<Tag tone={healthy ? 'ok' : 'bad'}>{healthy ? 'delivering' : 'failing'}</Tag>
) : (
<Tag>never used</Tag>
)}
<Button size="sm" icon="pulse" busy={busy === `test:${integration.id}`} onClick={onTest}>
Test
</Button>
<Button size="sm" onClick={onEdit}>
Edit
</Button>
<Button size="sm" variant="danger" icon="trash" onClick={onRemove} title="Remove" />
</>
}
>
<div className="list">
<div className="list-item">
<div className="list-body">
<b>Events sent</b>
<p>
{selected.length === 0
? 'None selected this destination is configured but will never post anything.'
: selected
.map((type) => catalogue.find((entry) => entry.type === type)?.label ?? type)
.join(', ')}
</p>
</div>
</div>
<div className="list-item">
<div className="list-body">
<b>Last delivered</b>
<p>{health.lastSuccess ? when(health.lastSuccess) : 'never'}</p>
</div>
<div className="list-actions">
{health.deliveries > 0 ? (
<span className="quiet">
{num(health.deliveries)} attempts, {num(health.failures)} failed
</span>
) : null}
</div>
</div>
{health.lastFailure ? (
<div className="list-item">
<div className="list-body">
<b>Last failure</b>
<p>
{when(health.lastFailure)}
{health.lastError ? ` — ${health.lastError}` : ''}
</p>
</div>
</div>
) : null}
</div>
{integration.deliveries.length > 0 ? (
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Attempted</th>
<th>Event</th>
<th>Result</th>
<th className="num">Took</th>
</tr>
</thead>
<tbody>
{integration.deliveries.map((delivery) => (
<tr key={delivery.id}>
<td className="nowrap muted" title={when(delivery.attemptedAt)}>
{ago(delivery.attemptedAt)}
</td>
<td className="muted">{delivery.eventType}</td>
<td>
{delivery.success ? (
<Tag tone="ok">{delivery.statusCode || 'ok'}</Tag>
) : (
<Tag tone="bad">{delivery.error || delivery.statusCode || 'failed'}</Tag>
)}
</td>
<td className="num muted">{duration(delivery.durationMs)}</td>
</tr>
))}
</tbody>
</table>
</TableWrap>
) : (
<TableWrap>
<table>
<tbody>
<EmptyRow columns={4}>Nothing has been delivered through this webhook yet.</EmptyRow>
</tbody>
</table>
</TableWrap>
)}
</Card>
);
}
function DraftCard({
draft,
catalogue,
busy,
onChange,
onSave,
onCancel,
}: {
draft: Draft;
catalogue: IntegrationEventOption[];
busy: boolean;
onChange: (next: Draft) => void;
onSave: () => void;
onCancel: () => void;
}) {
const groups = [...new Set(catalogue.map((entry) => entry.group))];
const toggleEvent = (type: string, on: boolean) =>
onChange({
...draft,
events: on ? [...draft.events, type] : draft.events.filter((entry) => entry !== type),
});
return (
<Card
title={draft.id ? `Edit ${draft.name}` : 'New Discord webhook'}
icon="plug"
tone="info"
footer={
<>
<Button variant="primary" busy={busy} onClick={onSave}>
{draft.id ? 'Save' : 'Add'}
</Button>
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<span className="spacer" />
{draft.events.length === 0 ? (
<span className="quiet">Nothing selected — this destination would never post.</span>
) : (
<span className="quiet">{draft.events.length} events selected</span>
)}
</>
}
>
<div className="fields">
<Field label="Name" hint="What this destination is called in the console.">
<input
type="text"
value={draft.name}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
/>
</Field>
<Field
label="Webhook address"
hint={
draft.id
? 'Leave blank to keep the address already saved it is a credential and is never sent back to this page.'
: 'Discord channel settings Integrations Webhooks New Webhook Copy Webhook URL.'
}
>
<input
type="url"
value={draft.url}
placeholder={draft.id ? 'unchanged' : 'https://discord.com/api/webhooks/…'}
onChange={(event) => onChange({ ...draft, url: event.target.value })}
/>
</Field>
</div>
<Toggle
label="Enabled"
hint="Off keeps the configuration and stops the posts."
checked={draft.enabled}
onChange={(next) => onChange({ ...draft, enabled: next })}
/>
{groups.map((group) => (
<div key={group}>
<div className="card-head" style={undefined}>
<div className="card-head-text">
<h2>{group}</h2>
</div>
</div>
{catalogue
.filter((entry) => entry.group === group)
.map((entry) => (
<Toggle
key={entry.type}
label={entry.label}
hint={entry.description}
checked={draft.events.includes(entry.type)}
onChange={(on) => toggleEvent(entry.type, on)}
/>
))}
</div>
))}
</Card>
<tr>
{/* The name is the row's primary element and everything under it is quieter, so a
column of services reads as a list of names rather than as paragraphs. */}
<td>
<Link className="table-row-link" to={href}>
{service.name}
</Link>
<span className="table-sub">{service.address || service.summary}</span>
</td>
<td className="nowrap">
<span className="row tight">
<span className="dot-state" data-tone={integrationDot(service.status)} />
<Tag tone={integrationTone(service.status)}>
<span title={service.detail || service.statusLabel}>{service.statusLabel}</span>
</Tag>
</span>
</td>
<td
className="nowrap muted"
title={service.lastRun ? when(service.lastRun.startedAt) : undefined}
>
{service.lastRun ? ago(service.lastRun.startedAt) : 'never'}
</td>
<td className="muted">
{service.lastRun ? runOutcome(service.lastRun) : service.detail || '—'}
</td>
{/* A switched-off service still has a next run in the scheduler's records and it is
not going to do anything, so the column says so rather than printing a time that
will pass with nothing at the end of it. */}
<td className="nowrap muted">
{!service.configured ? '—' : !service.enabled ? 'off' : service.running ? 'now' : until(service.nextRun)}
</td>
<td className="nowrap">
{!service.configured ? (
<Tag>not configured</Tag>
) : service.enabled ? (
<Tag tone="ok">On</Tag>
) : (
<Tag tone="warn">Off</Tag>
)}
</td>
</tr>
);
}
+10 -1
View File
@@ -375,7 +375,16 @@ export function LogsPage() {
const [dropped, setDropped] = useState(0);
const [paused, setPaused] = useState(false);
const [held, setHeld] = useState(0);
const [filters, setFilters] = useState<LogFilters>(EMPTY_FILTERS);
/* A ?q= in the address seeds the text filter once, so a page that has diagnosed
something can hand the operator the logs already narrowed to it — which is what the
Integrations area's "open the logs" link does with a failing service's name. Seeded
on the initial state rather than in an effect: applying it later would fight whatever
the operator had already typed, and the whole point of a deep link is that it is where
they arrive rather than something that happens to them. */
const [filters, setFilters] = useState<LogFilters>(() => {
const seed = new URLSearchParams(window.location.search).get('q') ?? '';
return seed ? { ...EMPTY_FILTERS, text: seed } : EMPTY_FILTERS;
});
const [error, setError] = useState('');
const [viewport, setViewport] = useState({ top: 0, height: 600 });
const [atTail, setAtTail] = useState(true);
+46 -11
View File
@@ -4,6 +4,7 @@ import { useGateway } from '../lib/gateway';
import { bytes, num, recent, when } from '../lib/format';
import {
Banner,
Button,
Card,
EmptyRow,
Grid,
@@ -15,6 +16,7 @@ import {
Tag,
Tiles,
} from '../components/ui';
import { RuntimeVerdict, trendWord, uptime } from '../components/runtime';
import type { RuntimeStatus, ViewsReport } from '../api/types';
/* The page an operator lands on. It answers one question — is anything wrong — and hands
@@ -48,7 +50,10 @@ export function OverviewPage() {
const mdblist = status.mdblist;
const forYou = status.forYou;
const runs = (status.runs ?? []).slice(0, 5);
const memory = runtime.data;
const stats = runtime.data;
const limited = stats
? stats.memory.memoryLimit > 0 && stats.memory.memoryLimit < Number.MAX_SAFE_INTEGER
: false;
return (
<>
@@ -158,6 +163,10 @@ export function OverviewPage() {
intro="The services this gateway leans on, and whether they answered."
icon="wrench"
tone="note"
/* Summarised here, managed there — the rule this whole page follows. Whether a
service is switched on, what it last did and why it failed all live on the
Integrations page, which is also the only place any of it can be changed. */
actions={<Link to="/admin/integrations">Integrations</Link>}
>
<KeyValue
rows={[
@@ -241,23 +250,49 @@ export function OverviewPage() {
intro="The container the gateway is served from."
icon="chip"
tone="info"
actions={
<Link to="/admin/runtime">
<Button size="sm" variant="quiet" icon="external">
Runtime details
</Button>
</Link>
}
>
{memory ? (
{/* This card used to print "25 goroutines" and leave it there, which is a number
nobody can act on: it says nothing about what those goroutines are doing,
which part of Memby they belong to, or whether it has been climbing all week.
The verdict is the answer that count was standing in for, and it is the
gateway's own — see server/internal/runtimestats. The detail behind it lives
on the Runtime page rather than here, because this card is a signpost. */}
{stats ? (
<>
<RuntimeVerdict health={stats.health} compact />
<PlainTiles
tiles={[
{ label: 'goroutines', value: num(memory.goroutines) },
{ label: 'heap in use', value: bytes(memory.heapInuse) },
{ label: 'reserved', value: bytes(memory.sys) },
{ label: 'collections', value: num(memory.numGc) },
{
label: `heap used · ${trendWord(stats.heapTrend, bytes)}`,
value: bytes(stats.memory.heapInuse),
},
{
label: 'of the process limit',
value: limited
? `${(stats.memory.heapShare * 100).toFixed(1)}%`
: 'no limit',
},
{
label: `goroutines · ${trendWord(stats.goroutineTrend, (value) => num(Math.round(value)))}`,
value: num(stats.goroutines),
},
{ label: 'running for', value: uptime(stats.uptimeSeconds) },
]}
/>
<p className="hint">
Next collection at {bytes(memory.nextGc)} · memory limit{' '}
{memory.memoryLimit > 0 && memory.memoryLimit < Number.MAX_SAFE_INTEGER
? `${bytes(memory.memoryLimit)}${memory.configuredLimit ? ' (GOMEMLIMIT)' : ''}`
: 'no limit set'}{' '}
· {memory.gomaxprocs} processors available.
Heap in use is what the gateway is holding;{' '}
{bytes(stats.memory.sys)} is reserved from the operating system on its
behalf, which is always larger and is not a leak.
{limited
? ` The limit is ${bytes(stats.memory.memoryLimit)}${stats.memory.configuredLimit ? ' (GOMEMLIMIT)' : ''}.`
: ' No memory limit is set.'}
</p>
</>
) : (
+16 -20
View File
@@ -3,7 +3,6 @@ import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num } from '../lib/format';
import {
Banner,
Button,
@@ -12,9 +11,7 @@ import {
Field,
Grid,
Loading,
PageHead,
Tag,
Tiles,
Toggle,
} from '../components/ui';
@@ -38,7 +35,18 @@ const sourceNames: Record<string, string> = {
score_average: 'MDBList Average',
};
export function RatingsPage() {
/* MDBList's settings.
*
* Exported as a card group rather than kept as a page: the movie-ratings integration now
* lives in the Integrations area, beside its health, its switch and its run history, and
* the old /admin/ratings address redirects there. Splitting it out is what let it move
* without being rewritten — this is the same form it always was, in a different room.
*
* The whole thing is written around one property of the backend, and it is worth stating
* because the form would otherwise look careless: the API key is never returned. What
* comes back is whether one is saved, which is why the field is blank with a placeholder
* saying so, and why saving it blank leaves the stored key alone. */
export function RatingsSettingsCard() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
@@ -80,28 +88,16 @@ export function RatingsPage() {
return (
<>
<PageHead title="Movie ratings" intro="Optional MDBList scores on films and shows." />
<Banner message={error} />
{loading || !mdblist ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'titles stored', value: num(cached), icon: 'database', tone: 'data' },
{ label: 'due to be re-checked', value: num(mdblist.staleTitles), icon: 'sync', tone: 'warn' },
{ label: 'sources shown', value: num((mdblist.sources ?? []).length), icon: 'star', tone: 'note' },
{
label: 'API key',
value: mdblist.apiKeyConfigured ? 'saved' : 'not set',
small: true,
icon: 'key',
tone: mdblist.apiKeyConfigured ? 'ok' : undefined,
},
]}
/>
{/* The tiles that were here — titles stored, titles due, whether a key is
saved — are on the integration page above this card now, where they sit
beside the same facts for every other service. Repeating them would be the
same four numbers twice on one screen. */}
<Grid cols="2">
<Card
title="MDBList connection"
+458
View File
@@ -0,0 +1,458 @@
import { useCallback, useState } from 'react';
import { useQuery } from '../lib/hooks';
import { api } from '../api/client';
import { bytes, num, when } from '../lib/format';
import {
Banner,
Bars,
Button,
Card,
Empty,
EmptyRow,
Grid,
KeyValue,
Loading,
Meter,
Note,
PageHead,
PlainTiles,
Subhead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import { RuntimeVerdict, trendWord, uptime } from '../components/runtime';
import type { GoroutineReport, RuntimeStatus, RuntimeWorker } from '../api/types';
/* Where the detail that would clutter the overview lives.
*
* The overview's Process card answers "is anything wrong". This page answers "what is it,
* then" — and the split is by cost as much as by clutter. Everything above the breakdown
* is the same cheap snapshot the overview polls; the breakdown underneath walks every
* goroutine's stack, which stops the world, so it is a button rather than a poll. Nothing
* on this page is editable, the stance every insights page in the console takes. */
const componentTone = (component: string): 'data' | 'note' | 'info' | 'warn' =>
component === 'Unattributed' ? 'warn' : component === 'Go runtime' ? 'note' : 'info';
export function RuntimePage() {
const runtime = useQuery<RuntimeStatus>('/admin/api/runtime', { pollMs: 30_000 });
const [report, setReport] = useState<GoroutineReport>();
const [collecting, setCollecting] = useState(false);
const [breakdownError, setBreakdownError] = useState('');
// Asked for, never polled. The result is kept on screen with the time it was taken
// beside it, because a breakdown whose age is not stated is one an operator will read as
// current an hour later.
const collect = useCallback(async () => {
setCollecting(true);
setBreakdownError('');
try {
setReport(await api.get<GoroutineReport>('/admin/api/runtime/goroutines'));
} catch (err) {
setBreakdownError(err instanceof Error ? err.message : String(err));
} finally {
setCollecting(false);
}
}, []);
if (runtime.loading || !runtime.data) {
return (
<>
<PageHead title="Runtime" intro="What the gateway process is doing." />
<Banner message={runtime.error} />
<Loading />
</>
);
}
const data = runtime.data;
const memory = data.memory;
const samples = data.samples ?? [];
const workers = data.workers ?? [];
const running = workers.filter((worker) => worker.state === 'running').length;
const limited = memory.memoryLimit > 0 && memory.memoryLimit < Number.MAX_SAFE_INTEGER;
return (
<>
<PageHead
title="Runtime"
intro="What the gateway process is doing: the work it is holding open, the memory it is using, and how both have moved."
/>
<Banner message={runtime.error} />
<RuntimeVerdict health={data.health} />
<Tiles
tiles={[
{
label: `goroutines · ${trendWord(data.goroutineTrend, (value) => num(Math.round(value)))}`,
value: num(data.goroutines),
icon: 'pulse',
tone: data.goroutineTrend.direction === 'rising' ? 'warn' : 'info',
},
{ label: 'named workers running', value: num(running), icon: 'clock', tone: 'note' },
{
label: `heap in use · ${trendWord(data.heapTrend, bytes)}`,
value: bytes(memory.heapInuse),
icon: 'chip',
tone: memory.heapShare >= 0.75 ? 'warn' : 'data',
},
{ label: 'running for', value: uptime(data.uptimeSeconds), icon: 'history', tone: 'data' },
]}
/>
<Grid cols="2">
<Card
title="Memory"
intro="Three different figures that are routinely confused. Heap in use is what the gateway is actually holding. Reserved is what the Go runtime has taken from the operating system on its behalf — always larger, and not a leak. The limit is the one the container is stopped at."
icon="chip"
tone="data"
>
{limited ? (
<>
<Meter
value={memory.heapInuse}
total={memory.memoryLimit}
tone={memory.heapShare >= 0.9 ? 'bad' : memory.heapShare >= 0.75 ? 'warn' : 'ok'}
/>
<p className="hint">
The heap is using {(memory.heapShare * 100).toFixed(1)}% of the{' '}
{bytes(memory.memoryLimit)} limit
{memory.configuredLimit ? ` set by GOMEMLIMIT=${memory.configuredLimit}` : ''}.
</p>
</>
) : (
<Note tone="warn">
No memory limit is set, so the Go runtime will grow until the container is
killed by the host. Set GOMEMLIMIT to give it a ceiling to collect against.
</Note>
)}
<KeyValue
rows={[
{ label: 'Heap currently used', value: bytes(memory.heapInuse) },
{ label: 'Heap allocated to live objects', value: bytes(memory.heapAlloc) },
{ label: 'Heap held but idle', value: bytes(memory.heapIdle) },
{ label: 'Returned to the operating system', value: bytes(memory.heapReleased) },
{ label: 'Goroutine stacks', value: bytes(memory.stackInuse) },
{ label: 'Runtime / system reserved', value: bytes(memory.sys) },
{
label: 'Configured process limit',
value: limited ? bytes(memory.memoryLimit) : 'none',
},
{ label: 'Next collection at', value: bytes(memory.nextGc) },
]}
/>
</Card>
<Card
title="Collection"
intro="How often the garbage collector runs and how long it stops the gateway for. Everything the televisions ask for waits during a pause, so this is the figure that turns a memory problem into a slow one."
icon="refresh"
tone="note"
>
<PlainTiles
tiles={[
{ label: 'collections', value: num(memory.numGc) },
{
label: 'collections an hour',
value: memory.gcPerHour > 0 ? memory.gcPerHour.toFixed(0) : '—',
},
{ label: 'recent pause', value: `${memory.pauseRecentMs.toFixed(1)} ms` },
{
label: 'paused in total',
value: `${(memory.pauseTotalMs / 1000).toFixed(1)} s`,
},
]}
/>
<KeyValue
rows={[
{ label: 'Processors available', value: num(data.gomaxprocs) },
{
label: 'Operating-system threads',
value: num(data.threads),
},
{
label: 'Processor use',
value: data.process.cpuKnown
? `${(data.process.cpuPercent ?? 0).toFixed(1)}% of one processor · ${(data.process.cpuSeconds ?? 0).toFixed(0)}s used in total`
: 'not available on this host',
},
{
label: 'Open connections',
value: data.process.filesKnown
? `${num(data.process.openSockets)} sockets of ${num(data.process.openFiles)} open files${
data.process.fileLimit ? ` · limit ${num(data.process.fileLimit)}` : ''
}`
: 'not available on this host',
},
{ label: 'Go version', value: data.goVersion },
]}
/>
</Card>
</Grid>
<Grid cols="2">
<Card
title="Goroutines over time"
intro="A single instantaneous count cannot show a leak. This can: a line that climbs and never comes back down is work the gateway is not letting go of."
icon="chart"
tone="info"
>
<TrendChart
samples={samples}
valueOf={(sample) => sample.goroutines}
format={(value) => num(value)}
everySeconds={data.sampleEverySeconds}
/>
</Card>
<Card
title="Memory over time"
intro="Heap in use, sampled on the same tick. A saw-tooth that returns to roughly the same floor after each collection is healthy; a floor that keeps rising is not."
icon="chart"
tone="data"
>
<TrendChart
samples={samples}
valueOf={(sample) => sample.heapInuse}
format={bytes}
everySeconds={data.sampleEverySeconds}
/>
</Card>
</Grid>
<Card
title="Background workers"
intro="Memby's own long-running work, named where it is started rather than guessed at from a stack. A worker that has finished is not necessarily a fault — several are one-shot startup jobs — but one that keeps being started again is failing at something."
icon="clock"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Worker</th>
<th>Area</th>
<th>State</th>
<th>Since</th>
<th className="num">Starts</th>
</tr>
</thead>
<tbody>
{workers.length === 0 ? (
<EmptyRow columns={5}>
Nothing has registered. This gateway predates named workers.
</EmptyRow>
) : (
workers.map((worker) => <WorkerRow key={worker.name} worker={worker} />)
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="Goroutine breakdown"
intro="What those goroutines are actually doing, and which part of Memby they belong to. Collecting it means walking every stack, which stops the gateway for a few milliseconds — so it is taken when you ask rather than continuously."
icon="search"
tone="info"
>
<div className="row">
<Button onClick={collect} busy={collecting} icon="refresh" variant="primary">
{report ? 'Collect again' : 'Collect breakdown'}
</Button>
<a href="/admin/api/runtime/goroutines?format=text" download>
<Button icon="download" variant="quiet">
Download full stack dump
</Button>
</a>
</div>
<Banner message={breakdownError} />
{report ? (
<Breakdown report={report} />
) : (
<Empty>
Nothing collected yet. The snapshot is a moment in time, so it is taken on
request and stamped with when it was taken.
</Empty>
)}
</Card>
</>
);
}
function WorkerRow({ worker }: { worker: RuntimeWorker }) {
const finished = worker.state === 'finished';
return (
<tr>
<td>{worker.name}</td>
<td>
<Tag tone="info">{worker.component}</Tag>
</td>
<td>
<Tag tone={finished ? 'note' : 'ok'}>{finished ? 'finished' : 'running'}</Tag>
</td>
<td>{when(finished ? worker.stopped : worker.started)}</td>
<td className="num">{worker.starts > 1 ? <Tag tone="warn">{worker.starts}</Tag> : worker.starts}</td>
</tr>
);
}
/** TrendChart is the console's existing bar vocabulary rather than a chart library: the
* question is only ever "is this line going up", which bars answer, and the console's
* no-dependency rule is worth more here than a smooth curve. */
function TrendChart<T extends { at: string }>({
samples,
valueOf,
format,
everySeconds,
}: {
samples: T[];
valueOf: (sample: T) => number;
format: (value: number) => string;
everySeconds: number;
}) {
if (samples.length < 2) {
return (
<Empty>
Not enough history yet. Readings are taken every{' '}
{everySeconds >= 60 ? `${Math.round(everySeconds / 60)} minutes` : `${everySeconds}s`},
and a trend needs about fifteen minutes of them.
</Empty>
);
}
const label = (sample: T) => new Date(sample.at).toLocaleTimeString();
return (
<Bars
data={samples}
labelOf={(sample: never) => label(sample)}
valueOf={(sample: never) => valueOf(sample)}
title={(sample: never) => `${label(sample)}: ${format(valueOf(sample))}`}
/>
);
}
function Breakdown({ report }: { report: GoroutineReport }) {
const categories = report.categories ?? [];
const components = report.components ?? [];
const groups = report.groups ?? [];
return (
<>
<p className="hint">
{num(report.total)} goroutines, collected {when(report.at)} in{' '}
{report.collectedInMs.toFixed(1)} ms.
</p>
<Grid cols="2">
<div>
<Subhead>By what they are doing</Subhead>
<TableWrap>
<table>
<thead>
<tr>
<th>State</th>
<th className="num">Count</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<tr key={category.category}>
<td>
<b>{category.label}</b>
<p className="hint">{category.description}</p>
{/* The runtime's own state names are kept, quietly, underneath the
readable label: they are meaningless to most operators and are
the exact term to search for when one is not. */}
<p className="hint">
{(category.states ?? [])
.map((state) => `${state.state} (${state.count})`)
.join(' · ')}
</p>
</td>
<td className="num">{num(category.count)}</td>
</tr>
))}
</tbody>
</table>
</TableWrap>
</div>
<div>
<Subhead>By which part of Memby</Subhead>
<TableWrap>
<table>
<thead>
<tr>
<th>Area</th>
<th className="num">Count</th>
<th className="num">Oldest</th>
</tr>
</thead>
<tbody>
{components.map((component) => (
<tr key={component.component}>
<td>
<Tag tone={componentTone(component.component)}>{component.component}</Tag>
</td>
<td className="num">{num(component.count)}</td>
<td className="num">
{component.longestWaitMinutes > 0
? `${num(component.longestWaitMinutes)} min`
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</TableWrap>
</div>
</Grid>
<Subhead
aside={
report.groupsTotal > groups.length
? `the ${groups.length} largest of ${num(report.groupsTotal)}`
: undefined
}
>
Where they are waiting
</Subhead>
<TableWrap>
<table>
<thead>
<tr>
<th className="num">Count</th>
<th>Area</th>
<th>State</th>
<th>Function</th>
<th className="num">Oldest</th>
</tr>
</thead>
<tbody>
{groups.length === 0 ? (
<EmptyRow columns={5}>Nothing to group.</EmptyRow>
) : (
groups.map((group, index) => (
<tr key={`${group.component}-${group.function}-${group.state}-${index}`}>
<td className="num">{num(group.count)}</td>
<td>
<Tag tone={componentTone(group.component)}>{group.component}</Tag>
</td>
<td>{group.state}</td>
<td>
<span className="mono">{group.function}</span>
<p className="hint">{group.file}</p>
</td>
<td className="num">
{group.longestWaitMinutes > 0 ? `${num(group.longestWaitMinutes)} min` : '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</>
);
}
+306
View File
@@ -0,0 +1,306 @@
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 { cadenceChoices, retimed, runTone, taskStatus } from '../lib/tasks';
import {
Banner,
Button,
Card,
Empty,
EmptyRow,
Field,
Loading,
Note,
PageHead,
TableWrap,
Tag,
Tiles,
Toggle,
} from '../components/ui';
import type { ScheduledTask, TasksResponse } from '../api/types';
/* One scheduled task.
*
* The list is a directory and this is where a task is actually operated on: its cadence,
* its switch, and its own run history rather than the whole gateway's. That split is the
* Users/User one, and it is what let the list become a table an operator can scan — a
* select and two switches per row is exactly the clutter that made forty tasks unreadable.
*
* It reads the same /admin/api/tasks the list does, with `task=` set, which is why there
* is no second endpoint behind this page: the response already carries every task (so the
* page can find the one it is about, and print it with the same fields the list used) and
* the `task` parameter narrows only the run history. The limit is higher than the list's
* because history is the whole reason somebody comes here. */
const IDLE_POLL_MS = 20_000;
const BUSY_POLL_MS = 3_000;
const HISTORY_LIMIT = 100;
/** What the history says about the task, as opposed to what its last run says.
*
* A task that fails one run in twenty and a task that has failed every run since Tuesday
* look identical from a status column, and the second is the one worth being told about.
* Averaged over the window the page holds rather than over all time, because that is the
* only thing it has — and it says how many runs it is averaging, so a figure drawn from
* three runs cannot be mistaken for a settled one. */
function summarise(runs: { status: string; durationMs: number }[]) {
const finished = runs.filter((run) => run.status !== 'running');
const failed = finished.filter((run) => run.status === 'failed').length;
const timed = finished.filter((run) => run.durationMs > 0);
const averageMs = timed.length
? timed.reduce((total, run) => total + run.durationMs, 0) / timed.length
: 0;
return { runs: finished.length, failed, averageMs, timed: timed.length };
}
export function TaskPage() {
const { taskId = '' } = useParams();
const { wrap } = useToast();
const { busy, run } = useAction();
const [fast, setFast] = useState(false);
const { data, error, loading, reload } = useQuery<TasksResponse>(
`/admin/api/tasks?task=${encodeURIComponent(taskId)}&limit=${HISTORY_LIMIT}`,
{ pollMs: fast ? BUSY_POLL_MS : IDLE_POLL_MS, enabled: Boolean(taskId) },
);
const task = data?.tasks.find((entry) => entry.id === taskId);
const running = Boolean(task?.running);
if (running !== fast) setFast(running);
const runs = data?.runs ?? [];
const status = task ? taskStatus(task) : undefined;
const summary = summarise(runs);
const act = (key: string, message: string, body: Record<string, unknown>) =>
run(key, async () => {
await wrap(() => api.put(`/admin/api/tasks/${encodeURIComponent(taskId)}`, body), message);
await reload();
});
const runNow = (subject: ScheduledTask) =>
run('run', async () => {
await wrap(
() => api.post(`/admin/api/tasks/${encodeURIComponent(taskId)}/run`),
`${subject.name} started.`,
);
await reload();
});
return (
<>
<PageHead
title={task?.name || taskId}
intro={task?.description}
icon="clock"
crumbs={
<>
<Link to="/admin/tasks">Scheduled tasks</Link>
<span>/</span>
<span>{task?.name || taskId}</span>
</>
}
actions={
task ? (
<Button
variant="primary"
icon="play"
busy={busy === 'run'}
disabled={task.running}
onClick={() => void runNow(task)}
>
{task.running ? 'Running' : 'Run now'}
</Button>
) : undefined
}
/>
<Banner message={error} />
{loading ? (
<Loading />
) : !task ? (
/* A task id that no longer exists is an ordinary thing to arrive at — a bookmark,
or a job removed in a deployment — so it is stated rather than left as an empty
page, and the way back is named. */
<Card title="No such task" icon="alert" tone="bad">
<Empty>
This gateway has no task called <code>{taskId}</code>. It may have been renamed or removed.{' '}
<Link to="/admin/tasks">Back to scheduled tasks</Link>.
</Empty>
</Card>
) : (
<>
<Tiles
tiles={[
{
label: 'Status',
value: status?.label ?? '—',
small: true,
icon: 'pulse',
tone: status?.tone,
},
{ label: 'Service', value: task.group || 'Other', small: true, icon: 'chip', tone: 'info' },
{
label: 'Runs every',
value: interval(task.intervalSeconds),
small: true,
icon: 'clock',
tone: retimed(task) ? 'note' : undefined,
},
{
label: 'Next run',
value: task.enabled ? (task.running ? 'now' : until(task.nextRun)) : 'not scheduled',
small: true,
icon: 'history',
tone: task.enabled ? undefined : 'warn',
},
{
label: `Failed of the last ${num(summary.runs)}`,
value: num(summary.failed),
icon: 'alert',
tone: summary.failed > 0 ? 'bad' : undefined,
},
// Averaged only over runs that recorded a duration, and the label says so:
// a mean that quietly counted skipped runs as instant would understate
// every job whose ordinary answer is "nothing to do".
{
label: `Average of ${num(summary.timed)} timed runs`,
value: summary.timed ? duration(Math.round(summary.averageMs)) : '—',
small: true,
icon: 'chart',
tone: 'data',
},
]}
/>
{task.lastRun?.error ? (
<Note tone="bad">
The last run failed: {task.lastRun.error}
</Note>
) : null}
<Card
title="Schedule"
intro="How often the gateway runs this on its own, and whether it runs it at all."
icon="sliders"
tone="info"
>
<div className="row">
{/* Disabled while a run is in flight: changing the cadence reschedules from
now, and doing that underneath a running job is how one run silently
becomes two. */}
<Field label="How often it runs">
<select
aria-label={`How often ${task.name} runs`}
value={task.intervalSeconds}
disabled={busy === 'interval' || task.running}
onChange={(event) =>
void act(
'interval',
`${task.name} now runs ${interval(Number(event.target.value))}.`,
{ intervalSeconds: Number(event.target.value) },
)
}
>
{cadenceChoices(task).map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
{choice.value === task.defaultIntervalSeconds ? ' (default)' : ''}
</option>
))}
</select>
</Field>
{/* Sending zero is how the API is told to forget an override, so this is a
separate call from the select rather than an option inside it — see
cadenceChoices. */}
{retimed(task) ? (
<Button
icon="refresh"
busy={busy === 'interval'}
disabled={task.running}
onClick={() =>
void act('interval', `${task.name} back to its default cadence.`, {
intervalSeconds: 0,
})
}
>
Back to {interval(task.defaultIntervalSeconds)}
</Button>
) : null}
</div>
<Toggle
label="Run this on its schedule"
hint="Switched off, the gateway leaves it alone. You can still start it by hand."
checked={task.enabled}
disabled={busy === 'enabled'}
onChange={(next) =>
void act(
'enabled',
next ? `${task.name} switched on.` : `${task.name} switched off.`,
{ enabled: next },
)
}
/>
{retimed(task) ? (
<Note tone="note">
This task is retimed: its code asks for {interval(task.defaultIntervalSeconds)} and it is
set to {interval(task.intervalSeconds)}.
</Note>
) : null}
</Card>
<Card
title="Run history"
intro="This task alone, newest first — which is what separates a job that fails occasionally from one that has stopped working."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Started</th>
<th>Trigger</th>
<th>Result</th>
<th className="num">Took</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{runs.length === 0 ? (
<EmptyRow columns={5}>This task has not run yet.</EmptyRow>
) : (
runs.map((entry) => (
<tr key={entry.id}>
<td className="nowrap muted" title={when(entry.startedAt)}>
{ago(entry.startedAt)}
</td>
<td className="muted">{entry.trigger}</td>
{/* The tag alone here, with no dot beside it. The status column on
the list is scanned across forty unrelated rows and earns the
second signal; this is one task's own history, where every row
is already about the same thing. */}
<td>
<Tag tone={runTone(entry.status)}>{entry.status}</Tag>
</td>
<td className="num muted">{duration(entry.durationMs)}</td>
<td className="muted">{entry.error || entry.detail || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}
+128 -195
View File
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { Link } 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, when } from '../lib/format';
import { ago, duration, interval, num, until, when } from '../lib/format';
import { compareTasks, retimed, runTone, statusDot, taskStatus } from '../lib/tasks';
import {
Banner,
Button,
@@ -14,69 +16,29 @@ import {
TableWrap,
Tag,
Tiles,
Toggle,
} from '../components/ui';
import type { ScheduledTask, TaskRun, TasksResponse } from '../api/types';
import type { Tone } from '../lib/format';
import type { ScheduledTask, TasksResponse } from '../api/types';
/* Scheduled tasks: what the gateway does when nobody is watching.
*
* Polled faster than the console's own heartbeat while a task is running, because the one
* thing an operator does here is press Run now and then watch for the outcome — and a
* thirty-second poll makes a two-second job look like one that did nothing. */
* A directory, and — following the Users page it is now shaped like — very nearly only a
* directory. It was a stack of cards holding a paragraph, a select and two switches per
* task, which meant the one question this page is opened with, "is anything wrong", had to
* be answered by reading every entry in turn. One row per task answers it by scanning a
* column, and everything you can *do* to a task beyond starting it lives on the task's own
* page.
*
* Run now is the exception that stays in the row. It is the only action here that is not a
* change of configuration — it asks for something to happen once, and it is what an
* operator comes to this page to press.
*
* Polled faster than the console's own heartbeat while a task is running, because having
* pressed it the next thing they do is watch for the outcome, and a thirty-second poll
* makes a two-second job look like one that did nothing. */
const IDLE_POLL_MS = 20_000;
const BUSY_POLL_MS = 3_000;
/* The cadences an operator may choose from.
*
* A fixed list rather than a free-text duration, because the useful range here spans three
* orders of magnitude and the two ways to get it wrong are both silent: a number typed in
* the wrong unit, and a cadence so tight the job never finishes before it is due again. The
* floor matches the scheduler's own — it clamps anything under a minute — so the console
* cannot offer a value the server would quietly change underneath it.
*
* Zero is absent on purpose. The API reads it as "restore the declared cadence" rather than
* as "never", so a "run by hand only" entry here would appear to do nothing on any task that
* declares an interval. That is a server-side limitation and it belongs in the server, not
* in a control that lies about it. */
const CADENCE_CHOICES: { value: number; label: string }[] = [
{ value: 60, label: 'Every minute' },
{ value: 300, label: 'Every 5 minutes' },
{ value: 600, label: 'Every 10 minutes' },
{ value: 900, label: 'Every 15 minutes' },
{ value: 1_800, label: 'Every 30 minutes' },
{ value: 3_600, label: 'Hourly' },
{ value: 10_800, label: 'Every 3 hours' },
{ value: 21_600, label: 'Every 6 hours' },
{ value: 43_200, label: 'Every 12 hours' },
{ value: 86_400, label: 'Daily' },
{ value: 604_800, label: 'Weekly' },
];
/* The choices for one task: the presets, plus its own declared cadence and whatever it is
* currently set to if either falls outside the list.
*
* Adding them rather than snapping to the nearest preset is what stops the control being
* destructive to look at — a task declaring 45 minutes must not silently become hourly
* because somebody opened the page and the select had to show *something*. */
function cadenceChoices(task: ScheduledTask): { value: number; label: string }[] {
const choices = [...CADENCE_CHOICES];
for (const seconds of [task.defaultIntervalSeconds, task.intervalSeconds]) {
if (seconds > 0 && !choices.some((choice) => choice.value === seconds)) {
choices.push({ value: seconds, label: interval(seconds).replace(/^every /, 'Every ') });
}
}
return choices.sort((a, b) => a.value - b.value);
}
function statusTone(status: TaskRun['status']): Tone {
if (status === 'failed') return 'bad';
if (status === 'running') return 'info';
if (status === 'skipped') return 'warn';
return 'ok';
}
export function TasksPage() {
const { wrap } = useToast();
const { busy, run } = useAction();
@@ -98,45 +60,11 @@ export function TasksPage() {
await reload();
});
const setEnabled = (task: ScheduledTask, enabled: boolean) =>
run(`${task.id}:enabled`, async () => {
await wrap(
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { enabled }),
enabled ? `${task.name} switched on.` : `${task.name} switched off.`,
);
await reload();
});
// Named setCadence rather than setInterval so it cannot shadow the global of that
// name inside this component, which is a trap for anything added here later.
const setCadence = (task: ScheduledTask, intervalSeconds: number) =>
run(`${task.id}:interval`, async () => {
await wrap(
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds }),
`${task.name} now runs ${interval(intervalSeconds)}.`,
);
await reload();
});
// Sending zero is how the API is told to forget an override, so this is a separate call
// from the select rather than an option inside it — see CADENCE_CHOICES.
const resetCadence = (task: ScheduledTask) =>
run(`${task.id}:interval`, async () => {
await wrap(
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds: 0 }),
`${task.name} back to its default cadence.`,
);
await reload();
});
const failures = tasks.filter((task) => task.lastRun?.status === 'failed').length;
const retimed = tasks.filter(
(task) => task.defaultIntervalSeconds > 0 && task.intervalSeconds !== task.defaultIntervalSeconds,
).length;
const offSchedule = tasks.filter(retimed).length;
const disabled = tasks.filter((task) => !task.enabled).length;
const groups = data?.groups ?? [];
const ungrouped = tasks.filter((task) => !task.group);
const rows = [...tasks].sort(compareTasks);
return (
<>
@@ -177,9 +105,9 @@ export function TasksPage() {
// cadence currently in force.
{
label: 'Retimed',
value: num(retimed),
value: num(offSchedule),
icon: 'clock',
tone: retimed > 0 ? 'note' : undefined,
tone: offSchedule > 0 ? 'note' : undefined,
},
]}
/>
@@ -191,105 +119,103 @@ export function TasksPage() {
</Note>
) : null}
{[...groups, ...(ungrouped.length > 0 ? [''] : [])].map((group) => {
const inGroup = tasks.filter((task) => task.group === group);
if (inGroup.length === 0) return null;
return (
<Card
key={group || 'other'}
title={group || 'Other'}
icon={group === 'System' ? 'chip' : group === 'Analytics' ? 'chart' : 'wrench'}
tone={group === 'System' ? 'info' : group === 'Analytics' ? 'data' : 'note'}
>
<div className="list">
{inGroup.map((task) => (
<div className="list-item" key={task.id}>
<div className="list-body">
<b>
{task.name}{' '}
{task.running ? <Tag tone="info">running</Tag> : null}
{!task.enabled ? <Tag tone="warn">off</Tag> : null}
{task.defaultIntervalSeconds > 0 &&
task.intervalSeconds !== task.defaultIntervalSeconds ? (
<Tag tone="note">retimed</Tag>
) : null}
</b>
<p>{task.description}</p>
<p className="quiet">
{interval(task.intervalSeconds)}
{task.enabled && task.nextRun ? ` · next ${ago(task.nextRun).replace(' ago', '')}` : ''}
{task.lastRun ? (
<>
{' · last '}
<span title={when(task.lastRun.startedAt)}>{ago(task.lastRun.startedAt)}</span>
{` in ${duration(task.lastRun.durationMs)}`}
{task.lastRun.detail ? `${task.lastRun.detail}` : ''}
</>
) : (
' · never run'
)}
</p>
{task.lastRun?.error ? (
<p className="mono" style={undefined}>
<Tag tone="bad">{task.lastRun.error}</Tag>
</p>
) : null}
</div>
<div className="list-actions">
{task.lastRun ? (
<Tag tone={statusTone(task.lastRun.status)}>{task.lastRun.status}</Tag>
) : (
<Tag>never run</Tag>
)}
{/* Disabled while a run is in flight: changing the cadence
reschedules from now, and doing that underneath a running job
is how one run silently becomes two. */}
<select
aria-label={`How often ${task.name} runs`}
value={task.intervalSeconds}
disabled={busy === `${task.id}:interval` || task.running}
onChange={(event) => void setCadence(task, Number(event.target.value))}
>
{cadenceChoices(task).map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
{choice.value === task.defaultIntervalSeconds ? ' (default)' : ''}
</option>
))}
</select>
{task.defaultIntervalSeconds > 0 &&
task.intervalSeconds !== task.defaultIntervalSeconds ? (
<Button
size="sm"
icon="refresh"
busy={busy === `${task.id}:interval`}
onClick={() => void resetCadence(task)}
>
Default
</Button>
) : null}
<Toggle
label=""
checked={task.enabled}
disabled={busy === `${task.id}:enabled`}
onChange={(next) => void setEnabled(task, next)}
/>
<Button
size="sm"
icon="play"
busy={busy === task.id}
disabled={task.running}
onClick={() => void runNow(task)}
>
Run now
</Button>
</div>
</div>
))}
</div>
</Card>
);
})}
<Card
title="Tasks"
intro="Anything wrong sorts to the top. Open a task to change its schedule, switch it off, or read its own run history."
icon="clock"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Task</th>
<th>Service</th>
<th>Schedule</th>
<th>Last run</th>
<th className="num">Took</th>
<th>Next run</th>
<th>Status</th>
<th />
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyRow columns={8}>This gateway has no scheduled tasks registered.</EmptyRow>
) : (
rows.map((task) => {
const status = taskStatus(task);
const last = task.lastRun;
const href = `/admin/tasks/${encodeURIComponent(task.id)}`;
return (
<tr key={task.id}>
{/* The name is the row's primary element and everything under it
is quieter, so a column of forty reads as a list of names
rather than as forty paragraphs. */}
<td>
<Link className="table-row-link" to={href}>
{task.name}
</Link>
<span className="table-sub">{task.description}</span>
</td>
<td className="muted nowrap">{task.group || 'Other'}</td>
{/* The declared cadence sits under an overridden one rather than
beside it: what happens next is the answer, and what the code
asked for is the footnote explaining why the row is retimed. */}
<td className="nowrap">
{interval(task.intervalSeconds)}
{retimed(task) ? (
<span className="table-sub">
default {interval(task.defaultIntervalSeconds)}
</span>
) : null}
</td>
<td className="nowrap muted" title={last ? when(last.startedAt) : undefined}>
{last ? ago(last.startedAt) : 'never'}
{/* Only a failure earns a sub-line. A detail under every
successful row is a column of noise, and the detail is on
the task's own page either way. */}
{last?.error ? <span className="table-sub">{last.error}</span> : null}
</td>
<td className="num muted">{last ? duration(last.durationMs) : '—'}</td>
{/* A switched-off task still has a next run in the scheduler's
records and it is not going to happen, so the column says so
rather than printing a time that will pass with nothing at
the end of it. */}
<td className="nowrap muted">
{!task.enabled ? '—' : task.running ? 'now' : until(task.nextRun)}
</td>
<td className="nowrap">
<span className="row tight">
<span className="dot-state" data-tone={statusDot(status)} />
<Tag tone={status.tone}>
<span title={status.title}>{status.label}</span>
</Tag>
</span>
</td>
<td className="nowrap">
<span className="row tight">
<Button
size="sm"
icon="play"
busy={busy === task.id}
disabled={task.running}
title={`Run ${task.name} now`}
onClick={() => void runNow(task)}
/>
<Link className="table-row-link" to={href}>
Details
</Link>
</span>
</td>
</tr>
);
})
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="Recent runs"
@@ -318,10 +244,17 @@ export function TasksPage() {
<td className="nowrap muted" title={when(entry.startedAt)}>
{ago(entry.startedAt)}
</td>
<td>{tasks.find((task) => task.id === entry.taskId)?.name ?? entry.taskId}</td>
<td>
<Link
className="table-row-link"
to={`/admin/tasks/${encodeURIComponent(entry.taskId)}`}
>
{tasks.find((task) => task.id === entry.taskId)?.name ?? entry.taskId}
</Link>
</td>
<td className="muted">{entry.trigger}</td>
<td>
<Tag tone={statusTone(entry.status)}>{entry.status}</Tag>
<Tag tone={runTone(entry.status)}>{entry.status}</Tag>
</td>
<td className="num muted">{duration(entry.durationMs)}</td>
<td className="muted">{entry.error || entry.detail || '—'}</td>
+499
View File
@@ -0,0 +1,499 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { ago, duration, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
Confirm,
Empty,
EmptyRow,
Field,
Loading,
Note,
PageHead,
TableWrap,
Tag,
Toggle,
} from '../components/ui';
import type { Integration, IntegrationEventOption, IntegrationsResponse, RadarrRequestPolicy, SonarrRequestPolicy } from '../api/types';
/* Event webhooks: administrative events going out to somewhere else.
*
* It lives inside the Integrations area but answers the opposite question from the pages
* beside it: those are the services Memby *depends on*, this is the places Memby *posts
* to*. Nothing here is a dependency — remove every webhook and the gateway is unchanged.
*
* Two cards that used to sit on this page have gone to where they belong. The Sonarr and
* Radarr enable switches are on those services' own pages, beside their health and their
* run history, because a switch away from the evidence for pressing it is one pressed
* blind; and the request policies moved with them.
*
* The whole page is written around one property of the backend, and it is worth stating
* because the form would otherwise look careless: the webhook address is never returned.
* It is the credential — anybody holding it can post into the channel — so the gateway
* sends back only whether one is set and the channel id from the middle of it. That is why
* the address field on an existing integration is blank with a placeholder saying so, and
* why saving with it blank leaves the stored one alone. */
interface Draft {
id: string;
name: string;
url: string;
enabled: boolean;
events: string[];
}
const NEW_DRAFT: Draft = { id: '', name: 'Discord', url: '', enabled: true, events: [] };
export function WebhooksPage() {
const { wrap, show } = useToast();
const { busy, run } = useAction();
const { data, error, loading, reload } = useQuery<IntegrationsResponse>('/admin/api/integrations', {
pollMs: 60_000,
});
const [draft, setDraft] = useState<Draft | null>(null);
const [confirming, setConfirming] = useState<Integration | null>(null);
const catalogue = data?.catalogue ?? [];
const integrations = data?.integrations ?? [];
const edit = (integration: Integration) =>
setDraft({
id: integration.id,
name: integration.name,
url: '',
enabled: integration.enabled,
events: integration.events ?? [],
});
const save = () =>
run('save', async () => {
if (!draft) return;
const saved = await wrap(
() => api.post<IntegrationsResponse>('/admin/api/integrations', draft),
draft.id ? 'Integration saved.' : 'Integration added.',
);
if (saved) {
setDraft(null);
await reload();
}
});
const remove = (integration: Integration) =>
run('remove', async () => {
await wrap(
() => api.del(`/admin/api/integrations/${encodeURIComponent(integration.id)}`),
`${integration.name} removed.`,
);
setConfirming(null);
await reload();
});
const test = (integration: Integration) =>
run(`test:${integration.id}`, async () => {
const result = await wrap(() =>
api.post<{ ok: boolean; message: string }>(
`/admin/api/integrations/${encodeURIComponent(integration.id)}/test`,
),
);
// The test route answers 200 whether or not the webhook accepted it, because the
// *request* succeeded — so the verdict is in the body, and reporting it is this
// page's job rather than the transport's.
if (result) show(result.message, result.ok ? 'ok' : 'bad');
await reload();
});
return (
<>
<PageHead
title="Event webhooks"
intro="Send administrative events to somewhere you already look. Events pass through the gateway's own event layer, so nothing about authentication or scheduled tasks knows Discord exists — and a second kind of destination is a change here rather than everywhere."
actions={
<Button variant="primary" icon="plus" onClick={() => setDraft(NEW_DRAFT)}>
Add a webhook
</Button>
}
/>
<Banner message={error} />
{(data?.dropped ?? 0) > 0 ? (
<Note tone="warn">
{num(data?.dropped ?? 0)} events could not be queued for delivery. The queue is deliberately
lossy a slow endpoint must never hold up a television signing in but a number growing here
means a destination is not keeping up.
</Note>
) : null}
{loading ? (
<Loading />
) : integrations.length === 0 && !draft ? (
<Card title="Nothing configured" icon="plug" tone="note">
<Empty>
No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's
settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here.
</Empty>
</Card>
) : (
integrations.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
catalogue={catalogue}
busy={busy}
onEdit={() => edit(integration)}
onTest={() => void test(integration)}
onRemove={() => setConfirming(integration)}
/>
))
)}
{draft ? (
<DraftCard
draft={draft}
catalogue={catalogue}
busy={busy === 'save'}
onChange={setDraft}
onSave={() => void save()}
onCancel={() => setDraft(null)}
/>
) : null}
{confirming ? (
<Confirm
title={`Remove ${confirming.name}?`}
body="The webhook address and its delivery history go with it. Events already published stay in the activity feed."
confirmLabel="Remove"
destructive
busy={busy === 'remove'}
onConfirm={() => void remove(confirming)}
onCancel={() => setConfirming(null)}
/>
) : null}
</>
);
}
/* ArrIntegrationCard used to be here: two switches for Sonarr and Radarr on a page about
Discord. They are on each service's own page now, where the health, the last run and the
list of what goes off with them are which is the whole difference between a switch and
an informed one. The two request-policy cards below are exported for the same pages. */
export function SonarrRequestCard() {
const { wrap } = useToast();
const { busy, run } = useAction();
const { data, error, loading, reload } = useQuery<SonarrRequestPolicy>('/admin/api/sonarr-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('sonarr-request-policy', async () => {
await wrap(
() => api.post('/admin/api/sonarr-request-policy', { qualityProfileId: profileId, searchImmediately }),
'Sonarr TV request policy saved.',
);
await reload();
});
const selected = data?.profiles.find((profile) => profile.id === profileId);
return (
<Card
title="Sonarr TV requests"
intro="The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes 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 === 'sonarr-request-policy'} disabled={loading || profileId <= 0} onClick={() => void save()}>Save Sonarr policy</Button>}
>
<Banner message={error ?? data?.error ?? ''} />
{loading ? <Loading rows={2} /> : (
<>
<div className="fields">
<Field label="Request quality profile" hint="Memby stores this Sonarr 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 episodes immediately after request" hint="Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search." checked={searchImmediately} onChange={setSearchImmediately} />
{selected ? <Note tone="info">Requested series will use <b>{selected.name}</b> (profile ID {selected.id}), be monitored using Membys existing all-episodes strategy, and {searchImmediately ? 'start an immediate search.' : 'not start an immediate search.'}</Note> : null}
</>
)}
</Card>
);
}
export 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,
busy,
onEdit,
onTest,
onRemove,
}: {
integration: Integration;
catalogue: IntegrationEventOption[];
busy: string | null;
onEdit: () => void;
onTest: () => void;
onRemove: () => void;
}) {
const health = integration.health;
// "Is it working" is answered by the *last* attempt, not by a failure count: a webhook
// that failed once an hour ago and has worked since is healthy.
const healthy =
!health.lastFailure || (health.lastSuccess && health.lastSuccess > health.lastFailure);
const selected = integration.events ?? [];
return (
<Card
title={integration.name}
intro={integration.hint ? `Discord webhook ${integration.hint}` : 'Discord webhook'}
icon="plug"
tone={integration.enabled ? 'ok' : 'warn'}
actions={
<>
{integration.enabled ? <Tag tone="ok">on</Tag> : <Tag tone="warn">off</Tag>}
{health.deliveries > 0 ? (
<Tag tone={healthy ? 'ok' : 'bad'}>{healthy ? 'delivering' : 'failing'}</Tag>
) : (
<Tag>never used</Tag>
)}
<Button size="sm" icon="pulse" busy={busy === `test:${integration.id}`} onClick={onTest}>
Test
</Button>
<Button size="sm" onClick={onEdit}>
Edit
</Button>
<Button size="sm" variant="danger" icon="trash" onClick={onRemove} title="Remove" />
</>
}
>
<div className="list">
<div className="list-item">
<div className="list-body">
<b>Events sent</b>
<p>
{selected.length === 0
? 'None selected — this destination is configured but will never post anything.'
: selected
.map((type) => catalogue.find((entry) => entry.type === type)?.label ?? type)
.join(', ')}
</p>
</div>
</div>
<div className="list-item">
<div className="list-body">
<b>Last delivered</b>
<p>{health.lastSuccess ? when(health.lastSuccess) : 'never'}</p>
</div>
<div className="list-actions">
{health.deliveries > 0 ? (
<span className="quiet">
{num(health.deliveries)} attempts, {num(health.failures)} failed
</span>
) : null}
</div>
</div>
{health.lastFailure ? (
<div className="list-item">
<div className="list-body">
<b>Last failure</b>
<p>
{when(health.lastFailure)}
{health.lastError ? `${health.lastError}` : ''}
</p>
</div>
</div>
) : null}
</div>
{integration.deliveries.length > 0 ? (
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Attempted</th>
<th>Event</th>
<th>Result</th>
<th className="num">Took</th>
</tr>
</thead>
<tbody>
{integration.deliveries.map((delivery) => (
<tr key={delivery.id}>
<td className="nowrap muted" title={when(delivery.attemptedAt)}>
{ago(delivery.attemptedAt)}
</td>
<td className="muted">{delivery.eventType}</td>
<td>
{delivery.success ? (
<Tag tone="ok">{delivery.statusCode || 'ok'}</Tag>
) : (
<Tag tone="bad">{delivery.error || delivery.statusCode || 'failed'}</Tag>
)}
</td>
<td className="num muted">{duration(delivery.durationMs)}</td>
</tr>
))}
</tbody>
</table>
</TableWrap>
) : (
<TableWrap>
<table>
<tbody>
<EmptyRow columns={4}>Nothing has been delivered through this webhook yet.</EmptyRow>
</tbody>
</table>
</TableWrap>
)}
</Card>
);
}
function DraftCard({
draft,
catalogue,
busy,
onChange,
onSave,
onCancel,
}: {
draft: Draft;
catalogue: IntegrationEventOption[];
busy: boolean;
onChange: (next: Draft) => void;
onSave: () => void;
onCancel: () => void;
}) {
const groups = [...new Set(catalogue.map((entry) => entry.group))];
const toggleEvent = (type: string, on: boolean) =>
onChange({
...draft,
events: on ? [...draft.events, type] : draft.events.filter((entry) => entry !== type),
});
return (
<Card
title={draft.id ? `Edit ${draft.name}` : 'New Discord webhook'}
icon="plug"
tone="info"
footer={
<>
<Button variant="primary" busy={busy} onClick={onSave}>
{draft.id ? 'Save' : 'Add'}
</Button>
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<span className="spacer" />
{draft.events.length === 0 ? (
<span className="quiet">Nothing selected this destination would never post.</span>
) : (
<span className="quiet">{draft.events.length} events selected</span>
)}
</>
}
>
<div className="fields">
<Field label="Name" hint="What this destination is called in the console.">
<input
type="text"
value={draft.name}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
/>
</Field>
<Field
label="Webhook address"
hint={
draft.id
? 'Leave blank to keep the address already saved — it is a credential and is never sent back to this page.'
: 'Discord → channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.'
}
>
<input
type="url"
value={draft.url}
placeholder={draft.id ? 'unchanged' : 'https://discord.com/api/webhooks/…'}
onChange={(event) => onChange({ ...draft, url: event.target.value })}
/>
</Field>
</div>
<Toggle
label="Enabled"
hint="Off keeps the configuration and stops the posts."
checked={draft.enabled}
onChange={(next) => onChange({ ...draft, enabled: next })}
/>
{groups.map((group) => (
<div key={group}>
<div className="card-head" style={undefined}>
<div className="card-head-text">
<h2>{group}</h2>
</div>
</div>
{catalogue
.filter((entry) => entry.group === group)
.map((entry) => (
<Toggle
key={entry.type}
label={entry.label}
hint={entry.description}
checked={draft.events.includes(entry.type)}
onChange={(on) => toggleEvent(entry.type, on)}
/>
))}
</div>
))}
</Card>
);
}