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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-KrrVPZvy.js"></script>
<script type="module" crossorigin src="/admin/assets/index-CJIn-EsM.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-C5rUVO6U.css">
<link rel="stylesheet" crossorigin href="/admin/assets/index-Bx4XLLdG.css">
</head>
<body>
<div id="root"></div>
+17 -2
View File
@@ -14,7 +14,6 @@ import { ClientsPage } from './pages/Clients';
import { LoginsPage } from './pages/Logins';
import { DevicePage } from './pages/Device';
import { LibraryPage } from './pages/Library';
import { RatingsPage } from './pages/Ratings';
import { RequestsPage } from './pages/Requests';
import { RecommendationsPage } from './pages/Recommendations';
import { InspectorPage } from './pages/Inspector';
@@ -24,8 +23,12 @@ import { PlaybackPage } from './pages/Playback';
import { SubtitlesPage } from './pages/Subtitles';
import { UpdatesPage } from './pages/Updates';
import { TasksPage } from './pages/Tasks';
import { TaskPage } from './pages/Task';
import { IntegrationsPage } from './pages/Integrations';
import { IntegrationPage } from './pages/Integration';
import { WebhooksPage } from './pages/Webhooks';
import { MaintenancePage } from './pages/Maintenance';
import { RuntimePage } from './pages/Runtime';
import { SettingsPage } from './pages/Settings';
import { ImportsPage } from './pages/Imports';
import { LogsPage } from './pages/Logs';
@@ -72,7 +75,6 @@ export function App() {
<Route path="devices/:deviceId" element={<DevicePage />} />
<Route path="library" element={<LibraryPage />} />
<Route path="ratings" element={<RatingsPage />} />
<Route path="requests" element={<RequestsPage />} />
<Route path="recommendations" element={<RecommendationsPage />} />
@@ -86,8 +88,14 @@ export function App() {
<Route path="updates" element={<UpdatesPage />} />
<Route path="tasks" element={<TasksPage />} />
<Route path="tasks/:taskId" element={<TaskPage />} />
<Route path="integrations" element={<IntegrationsPage />} />
{/* Ahead of the id route, or "webhooks" would be read as an integration
id and open a page about a service that does not exist. */}
<Route path="integrations/webhooks" element={<WebhooksPage />} />
<Route path="integrations/:integrationId" element={<IntegrationPage />} />
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="runtime" element={<RuntimePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="imports" element={<ImportsPage />} />
<Route path="logs" element={<LogsPage />} />
@@ -103,6 +111,13 @@ export function App() {
{/* The old console redirected /admin/ to /admin/overview. Anything that
still links there lands on the overview rather than on a 404. */}
<Route path="overview" element={<Navigate to="/admin" replace />} />
{/* MDBList's settings moved into the Integrations area. The address was
bookmarkable and is in older activity-feed links, so it redirects
rather than 404s. */}
<Route
path="ratings"
element={<Navigate to="/admin/integrations/mdblist" replace />}
/>
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
+213 -2
View File
@@ -385,9 +385,21 @@ export interface DeviceDetailResponse {
/* ---------- scheduled tasks ---------- */
/** What a run counted. Every task carries the shape; most count nothing, and a run whose
* `processed` is zero counted nothing at all the console prints no figures rather than
* four zeroes. */
export interface RunCounts {
processed: number;
changed: number;
skipped: number;
failed: number;
}
export interface TaskRun {
id: number;
taskId: string;
/** The external service this run belongs to, absent for the gateway's own work. */
integrationId?: string;
trigger: string;
status: 'running' | 'success' | 'failed' | 'skipped';
startedAt: string;
@@ -395,6 +407,7 @@ export interface TaskRun {
durationMs: number;
detail?: string;
error?: string;
counts: RunCounts;
}
export interface ScheduledTask {
@@ -402,6 +415,8 @@ export interface ScheduledTask {
name: string;
description: string;
group: string;
/** The external service this job belongs to, absent for the gateway's own work. */
integration?: string;
intervalSeconds: number;
/** The cadence declared in code. Differs from intervalSeconds only when overridden. */
defaultIntervalSeconds: number;
@@ -419,6 +434,68 @@ export interface TasksResponse {
/* ---------- integrations ---------- */
/* The external services Memby depends on, which is a different question from the webhook
destinations below: those are places administrative events are posted to, these are
things without which parts of Memby stop working. */
export type IntegrationStatus =
| 'healthy'
| 'error'
| 'running'
| 'disabled'
| 'unconfigured'
| 'idle';
export interface IntegrationProbe {
reachable: boolean;
checkedAt: string;
error?: string;
latencyMs: number;
}
export interface IntegrationFact {
label: string;
value: string;
tone?: string;
}
export interface IntegrationService {
id: string;
name: string;
summary: string;
address?: string;
configured: boolean;
enabled: boolean;
status: IntegrationStatus;
statusLabel: string;
detail?: string;
running: boolean;
/** What stops working when this service is switched off. */
powers: string[];
lastRun?: TaskRun;
lastSuccessAt?: string;
lastFailureAt?: string;
lastError?: string;
nextRun?: string;
runs: number;
failures: number;
health?: IntegrationProbe;
/** Whether this service can be probed at all — see the MDBList note on the server. */
probed: boolean;
tasks: ScheduledTask[];
facts?: IntegrationFact[];
}
export interface IntegrationServicesResponse {
services: IntegrationService[];
runs: TaskRun[];
}
export interface IntegrationServiceResponse {
service: IntegrationService;
runs: TaskRun[];
}
export interface IntegrationHealth {
integrationId: string;
lastSuccess?: string;
@@ -514,9 +591,53 @@ export interface LogResponse {
/* ---------- runtime ---------- */
export interface RuntimeStatus {
/* The process figures, and the interpretation of them.
*
* The gateway sends the verdict rather than the console deriving one, for the reason every
* label the gateway prints is its own: the threshold and the sentence explaining it belong
* together, and a console built before a threshold existed must not be the thing deciding
* what "watch" means. See server/internal/runtimestats. */
export type RuntimeLevel = 'ok' | 'watch' | 'bad';
export type TrendDirection = 'unknown' | 'steady' | 'rising' | 'falling';
export interface RuntimeTrend {
direction: TrendDirection;
/** perHour is the observed rate of change in the value's own units goroutines an hour,
* bytes an hour which is what makes "rising" something to act on rather than notice. */
perHour: number;
first: number;
latest: number;
min: number;
max: number;
spanSeconds: number;
points: number;
}
export interface RuntimeSample {
at: string;
goroutines: number;
gomaxprocs: number;
heapInuse: number;
sys: number;
numGc: number;
}
export interface RuntimeNote {
level: RuntimeLevel;
/** area names the part of Memby to look at. It is the note's most useful field: a number
* that has moved is only actionable once it points somewhere. */
area: string;
message: string;
}
export interface RuntimeHealth {
level: RuntimeLevel;
summary: string;
notes?: RuntimeNote[];
areas?: string[];
}
export interface RuntimeMemory {
heapAlloc: number;
heapInuse: number;
heapIdle: number;
@@ -525,8 +646,98 @@ export interface RuntimeStatus {
sys: number;
nextGc: number;
numGc: number;
pauseTotalMs: number;
pauseRecentMs: number;
memoryLimit: number;
configuredLimit?: string;
/** heapShare is heap in use as a fraction of the limit, or 0 when there is no limit. */
heapShare: number;
gcPerHour: number;
}
export interface RuntimeWorker {
name: string;
component: string;
state: 'running' | 'finished';
started: string;
stopped?: string;
starts: number;
}
export interface RuntimeProcess {
pid: number;
cpuSeconds?: number;
cpuPercent?: number;
/** cpuKnown and filesKnown separate "nothing open" from "could not look": these come
* from /proc, which the container has and a developer machine does not. */
cpuKnown: boolean;
openFiles?: number;
openSockets?: number;
fileLimit?: number;
filesKnown: boolean;
}
export interface RuntimeStatus {
at: string;
uptimeSeconds: number;
goroutines: number;
gomaxprocs: number;
threads: number;
goVersion: string;
memory: RuntimeMemory;
workers?: RuntimeWorker[];
process: RuntimeProcess;
samples?: RuntimeSample[];
goroutineTrend: RuntimeTrend;
heapTrend: RuntimeTrend;
reservedTrend: RuntimeTrend;
sampleEverySeconds: number;
health: RuntimeHealth;
}
/* The on-demand half. Walking every goroutine's stack stops the world, so this is asked
for and never polled which is also why it says when it was collected and what it cost. */
export interface RuntimeStateCount {
state: string;
count: number;
}
export interface RuntimeCategory {
category: string;
label: string;
description: string;
count: number;
states?: RuntimeStateCount[];
}
export interface RuntimeComponentCount {
component: string;
count: number;
longestWaitMinutes: number;
}
export interface RuntimeStackGroup {
count: number;
component: string;
category: string;
state: string;
function: string;
file: string;
createdBy?: string;
longestWaitMinutes: number;
}
export interface GoroutineReport {
at: string;
total: number;
collectedInMs: number;
dumpBytes: number;
categories?: RuntimeCategory[];
components?: RuntimeComponentCount[];
groups?: RuntimeStackGroup[];
groupsTotal: number;
workers?: RuntimeWorker[];
}
/* ---------- gateway settings ---------- */
+82
View File
@@ -0,0 +1,82 @@
import { Icon } from './Icon';
import { Tag } from './ui';
import type { Tone } from '../lib/format';
import type { RuntimeHealth, RuntimeLevel, RuntimeTrend } from '../api/types';
/* The two places the process figures are read the overview's Process card and the
Runtime page say the same things about them, so the wording lives here rather than
twice. None of it decides anything: the verdict, its sentences and the areas to
investigate all arrive from the gateway, and this renders what it was handed. */
export const levelTone: Record<RuntimeLevel, Tone> = {
ok: 'ok',
watch: 'warn',
bad: 'bad',
};
const levelWord: Record<RuntimeLevel, string> = {
ok: 'Healthy',
watch: 'Worth watching',
bad: 'Needs attention',
};
/** RuntimeVerdict is the headline. It replaces a bare goroutine count with the answer that
* count was standing in for, and when there is something to look at it names the area
* rather than leaving an operator to work out which number matters. */
export function RuntimeVerdict({
health,
compact,
}: {
health: RuntimeHealth;
compact?: boolean;
}) {
const notes = health.notes ?? [];
return (
<div className="verdict" data-tone={levelTone[health.level] ?? 'ok'}>
<div className="verdict-head">
<Icon name={health.level === 'ok' ? 'check' : 'alert'} />
<b>{levelWord[health.level] ?? 'Unknown'}</b>
<span>{health.summary}</span>
</div>
{/* On the overview the summary is the whole of it the card is a signpost, and the
notes belong on the page it points at. */}
{compact || notes.length === 0 ? null : (
<ul className="verdict-notes">
{notes.map((note, index) => (
<li key={`${note.area}-${index}`}>
<Tag tone={levelTone[note.level] ?? 'note'}>{note.area}</Tag>
<span>{note.message}</span>
</li>
))}
</ul>
)}
</div>
);
}
/** trendWord turns a trend into the half-sentence that sits under a figure. "Steady" is
* worth printing: it is the answer to the question the figure raises, and a tile that
* says nothing when nothing is wrong makes the operator check anyway. */
export function trendWord(trend: RuntimeTrend, format: (value: number) => string): string {
switch (trend.direction) {
case 'rising':
return `rising ${format(Math.abs(trend.perHour))} an hour`;
case 'falling':
return `falling ${format(Math.abs(trend.perHour))} an hour`;
case 'steady':
return 'steady';
default:
return 'gathering history';
}
}
/** uptime words how long the process has been up. Days matter here and seconds do not: the
* question this answers is "has it restarted", not "how long exactly". */
export function uptime(seconds: number): string {
if (!seconds || seconds < 60) return `${Math.max(0, Math.round(seconds))}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours}h ${minutes % 60}m`;
return `${Math.floor(hours / 24)} days`;
}
+16
View File
@@ -3,6 +3,7 @@ import { matchPath, useLocation } from 'react-router-dom';
import { Glyph, Icon, type IconName } from './Icon';
import type { Tone } from '../lib/format';
import { allNavItems } from '../nav';
import { useDocumentTitle } from '../lib/hooks';
/* The component vocabulary. Every page is built from what is below and adds nothing of its
own: a screen that needs a look it cannot get from here is a missing component, not a
@@ -27,6 +28,9 @@ export function PageHead({
const pageIcon = icon ?? allNavItems.find((item) =>
matchPath({ path: item.path, end: true }, location.pathname),
)?.icon;
// The tab is named after the heading, so the two can never disagree — see
// useDocumentTitle for why this hangs off PageHead rather than off the router.
useDocumentTitle(title);
return (
<header className="page-head">
{crumbs ? <nav className="crumbs">{crumbs}</nav> : null}
@@ -480,6 +484,18 @@ export function KeyValue({ rows }: { rows: { label: string; value: ReactNode }[]
);
}
/** Subhead titles a section inside a card. Two pages had already hand-rolled an h4 with
* their own rules; a screen that needs a look of its own is a missing component here, not
* a licence for a style attribute. */
export function Subhead({ children, aside }: { children: ReactNode; aside?: ReactNode }) {
return (
<h4 className="subhead">
<span>{children}</span>
{aside ? <small>{aside}</small> : null}
</h4>
);
}
/** PlainTiles is a tile strip with no card around it, for a group of numbers inside one. */
export function PlainTiles({ tiles }: { tiles: TileSpec[] }) {
return (
+21
View File
@@ -95,6 +95,27 @@ export function ago(value: string | undefined | null): string {
return new Date(value).toLocaleDateString();
}
/** until words a moment that has not happened yet, where `ago` words one that has.
*
* Two formatters rather than one because the tense is not a suffix: the schedule page used
* to print the next run with `ago(next).replace(' ago', '')`, which reads "5 min" for a run
* five minutes away and "just now" for one that is overdue and overdue is exactly the
* state an operator opens the page to find. A moment already past is named as such rather
* than rounded to zero, and a task with no next run at all is a dash. */
export function until(value: string | undefined | null): string {
if (!value) return '—';
const remaining = new Date(value).getTime() - Date.now();
if (!Number.isFinite(remaining)) return '—';
if (remaining <= 0) return 'due now';
const seconds = Math.round(remaining / 1000);
if (seconds < 60) return `in ${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `in ${minutes} min`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `in ${hours}h`;
return `in ${Math.round(hours / 24)}d`;
}
/* "Seen in the last quarter of an hour" is what the console means by active: a television
checks in every few seconds while somebody is using it. */
const ACTIVE_MS = 15 * 60 * 1000;
+25
View File
@@ -154,3 +154,28 @@ export function useSorted<T>(
});
}, [rows, key, direction]);
}
/* The product name every tab is suffixed with. It is deliberately not "Memby admin", which
* is what the pre-mount title in index.html says: that one is a placeholder for the moment
* before the console knows which page it is on, and these are real page names that already
* say the console is what they belong to. */
const TITLE_SUFFIX = 'Memby';
/** useDocumentTitle names the browser tab after the page.
*
* It is called from PageHead rather than from each page, because PageHead is already the
* one component that states a page's name and every route renders exactly one of them
* so the tab and the heading cannot come apart, and a page added tomorrow is titled
* without anybody remembering to do it. That also means the dynamic pages are named after
* the thing they are about ("Kitchen TV | Memby") rather than after their route.
*
* Nothing is restored on unmount: the next page's PageHead sets the title in the same
* commit, and clearing it first would blink the product name into the tab between every
* navigation. A blank name falls back to the suffix alone rather than printing a bare
* separator. */
export function useDocumentTitle(title: string): void {
useEffect(() => {
const name = title.trim();
document.title = name ? `${name} | ${TITLE_SUFFIX}` : TITLE_SUFFIX;
}, [title]);
}
+108
View File
@@ -0,0 +1,108 @@
import type { IntegrationService, IntegrationStatus, RunCounts, TaskRun } from '../api/types';
import type { Tone } from './format';
/* The vocabulary the two integration pages share.
*
* It lives here rather than in either page for the reason lib/tasks.ts exists: the
* overview and one service's own page make exactly the same judgements what tone a
* status wears, what a run's figures read as, whether a run is worth expanding and two
* copies of those is how a table comes to say a service is fine while the page it links to
* says it failed. Everything here is pure.
*
* What is deliberately *not* here is the status word itself. That is the gateway's
* (`statusLabel` on the wire), the stance every label Memby prints takes: the threshold and
* the sentence explaining it belong together, and an older console must not be the thing
* deciding what "healthy" means about a server it is only reporting on. */
/** The tone one status wears. Six statuses, four tones: idle and unconfigured are neither
* good nor bad, and giving them a colour of their own would make every row on the page
* look like it carried a verdict. */
export function integrationTone(status: IntegrationStatus): Tone | undefined {
switch (status) {
case 'error':
return 'bad';
case 'healthy':
return 'ok';
case 'running':
return 'info';
case 'disabled':
return 'warn';
default:
return undefined;
}
}
/** The dot beside the word, which is what makes the column scannable without reading it.
* A dot the tag does not also carry a word for would be a colour nobody can interpret. */
export function integrationDot(status: IntegrationStatus): Tone | undefined {
return integrationTone(status);
}
/** Anything wrong sorts to the top, the order the tasks table takes. The server already
* sends the list in this order; the console re-applies it because it also sorts lists the
* server never ordered a single service's task list, for one. */
const STATUS_RANK: Record<IntegrationStatus, number> = {
error: 0,
running: 1,
idle: 2,
healthy: 3,
disabled: 4,
unconfigured: 5,
};
export function compareIntegrations(a: IntegrationService, b: IntegrationService): number {
const byStatus = STATUS_RANK[a.status] - STATUS_RANK[b.status];
if (byStatus !== 0) return byStatus;
return a.name.localeCompare(b.name);
}
/** Whether a run counted anything worth printing. A run of four zeroes counted nothing
* most housekeeping does and printing them would make every row look like a failure. */
export const counted = (counts: RunCounts | undefined): boolean =>
Boolean(counts && (counts.processed || counts.changed || counts.skipped || counts.failed));
/** One run's figures as a phrase: "412 checked · 7 changed · 2 skipped".
*
* Only non-zero figures appear. A run that skipped nothing is not making a claim about
* skipping, and a row of "0 skipped, 0 failed" reads as a report about failure rather
* than as one about work done. */
export function countSummary(counts: RunCounts | undefined): string {
if (!counted(counts) || !counts) return '';
const parts: string[] = [];
if (counts.processed) parts.push(`${counts.processed.toLocaleString()} checked`);
if (counts.changed) parts.push(`${counts.changed.toLocaleString()} changed`);
if (counts.skipped) parts.push(`${counts.skipped.toLocaleString()} skipped`);
if (counts.failed) parts.push(`${counts.failed.toLocaleString()} failed`);
return parts.join(' · ');
}
/** What one run's outcome column says.
*
* The error wins where there is one, then whatever the task said about itself, then its
* figures. A run with none of the three did nothing worth reporting, which is the common
* case for a job that checks whether anything is due and "no changes" is the honest
* reading of it, rather than an em dash that reads as missing data. */
export function runOutcome(run: TaskRun): string {
if (run.status === 'running') return 'Running…';
if (run.error) return run.error;
if (run.detail) return run.detail;
const summary = countSummary(run.counts);
if (summary) return summary;
return 'No changes';
}
/* runTone is the scheduled-task pages' own rule, re-exported rather than copied: an
integration run is a task run, and two definitions of what colour "skipped" wears is
exactly how the same row comes to be amber on one page and green on another. */
export { runTone } from './tasks';
/** The sentence under a disabled switch.
*
* It names what goes with the service rather than repeating the switch, because "Sonarr
* is off" says nothing an operator who just pressed it does not know what they may not
* have thought about is the television calendar going with it. That is the whole of
* "make the dependency clear rather than silently failing" on this side of the wire. */
export function dependencyWarning(service: IntegrationService): string {
if (!service.powers.length) return '';
return `Switching ${service.name} off also stops: ${service.powers.join('; ')}.`;
}
+157
View File
@@ -0,0 +1,157 @@
import type { ScheduledTask, TaskRun } from '../api/types';
import { interval } from './format';
import type { Tone } from './format';
/* The vocabulary the two scheduled-task pages share.
*
* It lives here rather than in either page because the list and one task's own page make
* exactly the same judgements what state a task is in, what cadences it may be given,
* whether it has been retimed and two copies of those is how a table comes to say a task
* is fine while the page it links to says it failed. Everything in this file is pure, so
* it is the half that can be reasoned about without a server. */
/* 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*. */
export 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);
}
/** A task is retimed when an operator has moved it off the cadence its code declares. It is
* the most likely explanation for "why has this not run", and it is invisible on a page
* that only prints the cadence currently in force. */
export const retimed = (task: ScheduledTask): boolean =>
task.defaultIntervalSeconds > 0 && task.intervalSeconds !== task.defaultIntervalSeconds;
/** The tone of one run's outcome. Skipped is amber rather than green: the run was declined,
* which is a fine thing to have happened and a poor thing to read as success. */
export function runTone(status: TaskRun['status']): Tone {
if (status === 'failed') return 'bad';
if (status === 'running') return 'info';
if (status === 'skipped') return 'warn';
return 'ok';
}
export type TaskState = 'running' | 'disabled' | 'failed' | 'skipped' | 'successful' | 'never';
export interface TaskStatus {
state: TaskState;
label: string;
tone: Tone | undefined;
/** The sentence behind the word, shown on hover a tag is two syllables and an operator
* reading a column of them deserves to be told what each one is claiming. */
title: string;
}
/* One word per task, resolved in priority order, because a status column with two answers
* in it is one nobody can scan.
*
* Switched off outranks everything: a task nobody is running has no meaningful last result
* and drawing yesterday's success beside it would read as one that is still working. A run
* in flight outranks its own history for the same reason in the other direction what it
* did an hour ago is not what an operator watching it now is asking about. Below those, the
* task is described by its last run, and a task that has never run says exactly that rather
* than borrowing either verdict. */
export function taskStatus(task: ScheduledTask): TaskStatus {
if (task.running) {
return { state: 'running', label: 'Running', tone: 'info', title: 'Running right now' };
}
if (!task.enabled) {
return {
state: 'disabled',
label: 'Disabled',
tone: 'warn',
title: 'Switched off — it will not run on its schedule',
};
}
const last = task.lastRun;
if (!last) {
return {
state: 'never',
label: 'Never run',
tone: undefined,
title: 'This task has not run since the gateway started recording',
};
}
if (last.status === 'failed') {
return { state: 'failed', label: 'Failed', tone: 'bad', title: last.error || 'The last run failed' };
}
if (last.status === 'skipped') {
return {
state: 'skipped',
label: 'Skipped',
tone: 'warn',
title: last.detail || 'The last run declined to do anything',
};
}
return { state: 'successful', label: 'Successful', tone: 'ok', title: 'The last run finished cleanly' };
}
/* The dot beside the word, which is what makes the column scannable at a glance without
* reading it. Only four tones exist for it, so the two states that are neither good nor bad
* never run, and skipped share the quiet one rather than inventing a fifth. */
export function statusDot(status: TaskStatus): Tone | undefined {
if (status.state === 'running' || status.state === 'successful') return 'ok';
if (status.state === 'failed') return 'bad';
if (status.state === 'disabled' || status.state === 'skipped') return 'warn';
return undefined;
}
/** The list's order, and it is not alphabetical: the rows worth an operator's attention go
* to the top, because a page read from the top down should not require a sort to find the
* one thing that is wrong. Within a band the group holds the rows together and the name
* settles it, so the table is stable between polls. */
const STATE_RANK: Record<TaskState, number> = {
failed: 0,
running: 1,
skipped: 2,
disabled: 3,
never: 4,
successful: 5,
};
export function compareTasks(a: ScheduledTask, b: ScheduledTask): number {
const byState = STATE_RANK[taskStatus(a).state] - STATE_RANK[taskStatus(b).state];
if (byState !== 0) return byState;
// A task belonging to no service sorts after every named one, rather than to the top
// where an empty string would otherwise put it.
if (Boolean(a.group) !== Boolean(b.group)) return a.group ? -1 : 1;
const byGroup = a.group.localeCompare(b.group);
if (byGroup !== 0) return byGroup;
return a.name.localeCompare(b.name);
}
+58 -10
View File
@@ -193,14 +193,6 @@ export const nav: NavGroup[] = [
intro: 'The prepared pools personalised rows are drawn from.',
icon: 'sparkle',
},
{
id: 'ratings',
path: '/admin/ratings',
label: 'Movie ratings',
title: 'Movie ratings',
intro: 'Optional MDBList scores on films and shows.',
icon: 'star',
},
{
id: 'inspector',
path: '/admin/inspector',
@@ -263,6 +255,17 @@ export const nav: NavGroup[] = [
intro: 'What the gateway does in the background, when it last ran and whether it worked.',
icon: 'clock',
},
{
/* One task, addressed by its id a page about a single job belongs to the job
rather than to the rail, the stance the user and device pages take. */
id: 'task',
path: '/admin/tasks/:taskId',
label: 'Task',
title: 'Task',
intro: 'One scheduled task: its cadence, its switch and its own run history.',
icon: 'clock',
hidden: true,
},
{
id: 'imports',
path: '/admin/imports',
@@ -271,6 +274,17 @@ export const nav: NavGroup[] = [
intro: 'Catalogue synchronisation history.',
icon: 'database',
},
{
/* The process, rather than the household. It sits beside Logs and Maintenance
because the question it answers is the container healthy is the one an
operator arrives with when something is slow rather than wrong. */
id: 'runtime',
path: '/admin/runtime',
label: 'Runtime',
title: 'Runtime',
intro: 'Goroutines, memory and the background workers inside the gateway process.',
icon: 'chip',
},
{
id: 'maintenance',
path: '/admin/maintenance',
@@ -292,14 +306,48 @@ export const nav: NavGroup[] = [
icon: 'sliders',
hidden: true,
},
],
},
{
/* External services, and the one place they are configured.
*
* Its own section rather than an entry under Operations, because the question it
* answers is everything Memby depends on working is not the same as "what is the
* container doing", and because four services' settings previously lived on four
* unrelated pages: MDBList under Movie ratings, the *arr switches and request
* policies on a page about Discord, and Tracearr nowhere at all. */
id: 'integrations',
label: 'Integrations',
defaultCollapsed: true,
items: [
{
id: 'integrations',
path: '/admin/integrations',
label: 'Integrations',
label: 'Overview',
title: 'Integrations',
intro: 'Send administrative events to Discord and, in time, elsewhere.',
intro: 'Every external service Memby depends on: configured, working, and what it last did.',
icon: 'plug',
},
{
/* One service, addressed by its id a page about a single integration belongs to
the integration rather than to the rail, the stance the user, device and task
pages take. */
id: 'integration',
path: '/admin/integrations/:integrationId',
label: 'Integration',
title: 'Integration',
intro: 'One external service: its switch, its settings, its jobs and its run history.',
icon: 'plug',
hidden: true,
},
{
id: 'webhooks',
path: '/admin/integrations/webhooks',
label: 'Event webhooks',
title: 'Event webhooks',
intro: 'Send administrative events to Discord and, in time, elsewhere.',
icon: 'send',
},
],
},
{
+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>
);
}
+78
View File
@@ -3582,3 +3582,81 @@ details summary {
font: 13px/1.6 var(--sans);
overflow-wrap: anywhere;
}
/* ---------- runtime ----------
The verdict block and the section headings the runtime pages are built from. The verdict
is deliberately not a `.banner`: a banner is something that has gone wrong and can be
dismissed, and this is a standing answer that is usually good news. */
.subhead {
display: flex;
align-items: baseline;
gap: 8px;
margin: 6px 0 0;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--muted);
}
.subhead small {
font-size: 11px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
color: var(--quiet);
}
.verdict {
margin: 0 0 16px;
padding: 12px 14px;
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: var(--radius-sm);
background: var(--surface-lift);
}
.verdict[data-tone="warn"] {
border-left-color: var(--warn);
}
.verdict[data-tone="bad"] {
border-left-color: var(--danger);
}
.verdict-head {
display: flex;
align-items: center;
gap: 9px;
flex-wrap: wrap;
font-size: 13px;
color: var(--muted);
}
.verdict-head svg {
width: 17px;
height: 17px;
color: var(--accent);
}
.verdict[data-tone="warn"] .verdict-head svg {
color: var(--warn);
}
.verdict[data-tone="bad"] .verdict-head svg {
color: var(--danger);
}
.verdict-head b {
color: var(--text);
font-size: 13px;
}
.verdict-notes {
margin: 11px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.verdict-notes li {
display: flex;
align-items: baseline;
gap: 9px;
font-size: 12.5px;
color: var(--muted);
}