From 85ac3df238fcee8f24a5357284f48b5ff7a03c80 Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Tue, 28 Jul 2026 10:31:21 -0400 Subject: [PATCH 1/3] Add Command Center widgets and API key auto-discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the dashboard: the six Command Center panels, the resource gauges, and the discovery layer that makes them work without anyone pasting an API key. Discovery is the piece that keeps the dashboard zero-config. Every service already writes its key to a file the stack bind-mounts — config.xml for the *arrs, config.ini for Tautulli, settings.json for Seerr — so the dashboard reads those through the read-only /discover mounts instead of asking. An env var still wins where one is set, for anyone running a service outside this stack. Discovery re-runs on a TTL rather than once at boot. On a clean install those files don't exist yet, so a panel has to be able to connect itself once the service behind it starts. Verified end to end: an integration went from waiting to live 33s after its config appeared, with no restart. Every widget route returns Result and answers 200 even when its upstream failed, carrying a reason and a hint instead of an error status. This is what keeps one dead upstream from blanking the page. Hints are specific to the failure — a rejected Transmission credential and an unreachable Transmission need different advice, and a generic hint sends people looking in the wrong place. Two places where the design mocked data the stack can't actually prove: the VPN card drops the invented latency and reports reachability plus the provider/server already in .env, and a throughput gauge shows an unfilled ring rather than dividing by a ceiling that doesn't exist. Verified against live services: gauges match the host (20 cores, 31Gi RAM, 72T of 101T), the Sonarr calendar, Seerr requests and the merged activity feed all return real data, and no API key appears in any response body. Refs #48 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 3 +- README.md | 19 +- dashboard/README.md | 18 + dashboard/server/src/config.ts | 46 ++ dashboard/server/src/discovery.test.ts | 71 +++ dashboard/server/src/discovery.ts | 250 +++++++++++ dashboard/server/src/http.ts | 69 +++ dashboard/server/src/index.ts | 39 ++ dashboard/server/src/sources/activity.ts | 91 ++++ dashboard/server/src/sources/arr.ts | 125 ++++++ dashboard/server/src/sources/prometheus.ts | 119 +++++ dashboard/server/src/sources/seerr.ts | 141 ++++++ dashboard/server/src/sources/sources.test.ts | 153 +++++++ dashboard/server/src/sources/tautulli.ts | 160 +++++++ dashboard/server/src/sources/transmission.ts | 218 +++++++++ dashboard/server/src/sources/upcoming.ts | 47 ++ dashboard/web/src/app/App.tsx | 88 +++- dashboard/web/src/app/Sidebar.tsx | 60 ++- dashboard/web/src/components/Gauges.tsx | 121 +++++ dashboard/web/src/components/Panel.tsx | 107 +++++ dashboard/web/src/types.ts | 101 +++++ dashboard/web/src/views/CommandCenter.tsx | 443 +++++++++++++++++++ dashboard/web/src/views/Setup.tsx | 100 +++++ 23 files changed, 2551 insertions(+), 38 deletions(-) create mode 100644 dashboard/server/src/discovery.test.ts create mode 100644 dashboard/server/src/discovery.ts create mode 100644 dashboard/server/src/http.ts create mode 100644 dashboard/server/src/sources/activity.ts create mode 100644 dashboard/server/src/sources/arr.ts create mode 100644 dashboard/server/src/sources/prometheus.ts create mode 100644 dashboard/server/src/sources/seerr.ts create mode 100644 dashboard/server/src/sources/sources.test.ts create mode 100644 dashboard/server/src/sources/tautulli.ts create mode 100644 dashboard/server/src/sources/transmission.ts create mode 100644 dashboard/server/src/sources/upcoming.ts create mode 100644 dashboard/web/src/components/Gauges.tsx create mode 100644 dashboard/web/src/components/Panel.tsx create mode 100644 dashboard/web/src/views/CommandCenter.tsx create mode 100644 dashboard/web/src/views/Setup.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 0c30504..236a470 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,8 @@ Things that aren't obvious: - **`server/src/services.ts` is the single source of truth** for the service list. Its `container` field must match `container_name` in `docker-compose.yml` exactly, or health lookups silently report the service as `absent`. Adding a service to compose means adding it here too. - **Container status comes from `docker-socket-proxy`, never a direct socket mount.** `:ro` on a socket does not make the Docker API read-only — it only affects the file node — so mounting it into the dashboard would be a second root-equivalent exposure alongside Portainer's. The proxy runs with `CONTAINERS=1` and everything else off. If asked to "simplify" by mounting the socket directly, push back. - **API keys are auto-discovered, not configured.** The dashboard reads each service's own config file (`config.xml` for the \*arrs, `config.ini` for Tautulli, `settings.json` for Seerr) through the read-only `/discover/*` mounts. Resolution order is env var → discovered file → unconfigured. Discovery re-runs at runtime because on a clean install those files don't exist until each service's first boot — so a panel must recover on its own without a dashboard restart. -- **Nothing may throw on missing configuration.** An unset key or a dead upstream degrades that one panel. One failed integration must never blank the page, and a failed refresh keeps the last good data on screen. +- **Nothing may throw on missing configuration.** An unset key or a dead upstream degrades that one panel. One failed integration must never blank the page, and a failed refresh keeps the last good data on screen. Every widget route returns `Result` — either the payload with `available: true`, or `{ available: false, reason, hint }`. Routes return 200 even when the upstream failed; the discriminant is how the UI decides what to render. Wrap source loads in `safely()` from `http.ts` rather than letting them reject. +- **A `hint` must name the actual fix.** `hintFor()` in `sources/transmission.ts` is the pattern: a rejected credential and an unreachable host need different advice, and a generic hint sends people looking in the wrong place. - **`web/src/styles/nocturne.css` is a vendored design system** from the issue #48 handoff. Take colors, spacing, radii and shadows from its `var(--*)` tokens rather than hard-coding values. Inter and Phosphor icons are self-hosted on purpose — a self-hosted media stack may have no outbound internet, so don't "optimize" them back to a CDN. - **Both new containers are named `autoplexx-*`** (`autoplexx-dashboard`, `autoplexx-socket-proxy`) rather than the bare `dashboard` / `docker-socket-proxy`, because those names are generic enough to collide with something a user already runs — and a `container_name` collision fails `docker compose up` outright. diff --git a/README.md b/README.md index 6cf911e..c2484e7 100644 --- a/README.md +++ b/README.md @@ -207,15 +207,28 @@ Portainer mounts the host's Docker socket (`/var/run/docker.sock`) so it can man ## Dashboard -The AutoPlexx Command Center — at **** by default, or whichever port `DASHBOARD_PORT` names — is a single pane over the whole stack: live container status for every service, and a launcher that opens each one's own UI. +The AutoPlexx Command Center — at **** by default, or whichever port `DASHBOARD_PORT` names — is a single pane over the whole stack. It has three views: + +- **Command Center** — what's streaming now (Plex/Tautulli), active downloads with progress and ETA, pending requests (Seerr), the next episodes due (Sonarr), and a merged activity feed of recent grabs and imports. +- **Launcher** — every service as a tile with live status, linking to its own UI. +- **Setup** — which integrations are connected, and the one concrete step for anything that isn't. + +Above them, a strip of CPU / memory / storage / network gauges from Prometheus and an at-a-glance `N / M up` health tile. ### Zero configuration It works out of the box, with no required configuration. `DASHBOARD_PORT` (default `8090`) sets the published port; everything else the dashboard reads is optional and has a working default — see [`dashboard/README.md`](dashboard/README.md#environment) for the full list, including the `*_API_KEY` overrides you only need if you run a service outside this stack. -API keys are **not** something you paste in. Each service writes its own key to a config file — Sonarr, Radarr, Prowlarr and Bazarr to `config.xml`, Tautulli to `config.ini`, Seerr to `settings.json` — and the dashboard reads those files through the read-only `/discover` mounts declared in `docker-compose.yml`. Discovery re-runs while the dashboard is running, so on a first boot each panel lights up by itself shortly after the service behind it comes up. No restart, no wizard. +API keys are **not** something you paste in. Each service writes its own key to a config file — Sonarr, Radarr, Prowlarr and Bazarr to `config.xml`, Tautulli to `config.ini`, Seerr to `settings.json` — and the dashboard reads those files through the read-only `/discover` mounts declared in `docker-compose.yml`. Discovery re-runs while the dashboard is running, so on a first boot each panel lights up by itself within a minute of the service behind it coming up. No restart, no wizard. + +The **Setup** view shows exactly where each integration stands, so you can watch them connect rather than guessing. Keys are read server-side and never sent to the browser. + +Two things worth knowing: + +- **Tautulli ships with its API disabled.** The dashboard will say so specifically rather than reporting a generic failure — enable it in Tautulli under Settings → Web Interface → API. +- **Transmission's RPC auth isn't discoverable**, since it lives in your `.env` rather than a config file. If you've set `TRANSMISSION_RPC_USERNAME` / `TRANSMISSION_RPC_PASSWORD`, the dashboard picks them up from the same variables Transmission does. The VPN card's provider and server come from `OPENVPN_PROVIDER` / `OPENVPN_CONFIG` for the same reason. -If you run one of these services outside this stack, set the matching `*_API_KEY` variable in `.env` and it takes precedence over discovery. +If you run one of these services outside this stack, set the matching `*_API_KEY` variable in `.env` and it takes precedence over discovery. `*_URL` variables (e.g. `SONARR_URL`) override where the dashboard looks for a service. ### Why a separate socket proxy diff --git a/dashboard/README.md b/dashboard/README.md index 928d03a..c67e0ab 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -64,7 +64,25 @@ Every variable has a working default — the app must start and be useful agains | `DOCKER_PROXY_URL` | `http://docker-socket-proxy:2375` | Socket proxy base URL | | `GRAFANA_PORT` | `3000` | Follows the stack's `GRAFANA_PORT` so the Launcher link is right | | `HEALTH_TTL_MS` | `5000` | Container-state cache TTL | +| `DISCOVERY_TTL_MS` | `60000` | How often config files are re-read for keys | +| `UPSTREAM_TIMEOUT_MS` | `6000` | Per-request upstream timeout | | `LOG_LEVEL` | `info` | Fastify log level | +| `_URL` | Compose service name | Override an upstream's address, e.g. `SONARR_URL` | +| `_API_KEY` | discovered | Override a discovered key, e.g. `TAUTULLI_API_KEY` | + +## Adding a widget + +1. Add a source module under `server/src/sources/`. It must export a `memoize`d + function returning `Result` — use `safely()` so a failure becomes + `{ available: false, reason, hint }` rather than a rejection. +2. Register a route in `server/src/index.ts`. +3. Add the payload type to `web/src/types.ts` and render it with ``, + which handles the loading, unavailable and empty cases for you. + +The `hint` is the part that matters: it should name the one concrete step that +fixes the problem. See `hintFor()` in `sources/transmission.ts` — an auth +failure and an unreachable host need different advice, and a generic hint sends +people looking in the wrong place. ## Conventions diff --git a/dashboard/server/src/config.ts b/dashboard/server/src/config.ts index 778558e..6cb6f8b 100644 --- a/dashboard/server/src/config.ts +++ b/dashboard/server/src/config.ts @@ -41,4 +41,50 @@ export const config = { /** How long container state is cached, in ms. Keeps polling off the proxy. */ healthTtlMs: num(process.env.HEALTH_TTL_MS, 5_000), + + /** + * Upstream base URLs. The defaults are the Compose service names, which is + * what they resolve to inside this stack; each is overridable for anyone + * running a service elsewhere. + */ + upstream: { + sonarr: process.env.SONARR_URL ?? 'http://sonarr:8989', + radarr: process.env.RADARR_URL ?? 'http://radarr:7878', + prowlarr: process.env.PROWLARR_URL ?? 'http://prowlarr:9696', + bazarr: process.env.BAZARR_URL ?? 'http://bazarr:6767', + tautulli: process.env.TAUTULLI_URL ?? 'http://tautulli:8181', + seerr: process.env.SEERR_URL ?? 'http://seerr:5055', + prometheus: process.env.PROMETHEUS_URL ?? 'http://prometheus:9090', + transmission: process.env.TRANSMISSION_URL ?? 'http://transmission:9091', + }, + + /** Optional Transmission RPC auth, mirroring the stack's existing .env vars. */ + transmissionAuth: { + username: process.env.TRANSMISSION_RPC_USERNAME ?? '', + password: process.env.TRANSMISSION_RPC_PASSWORD ?? '', + }, + + /** + * VPN details are read from the same variables Transmission itself uses, so + * there is nothing extra to configure for the VPN card. + */ + vpn: { + provider: process.env.OPENVPN_PROVIDER ?? '', + server: process.env.OPENVPN_CONFIG ?? '', + }, + + /** Cache TTLs per widget class, in ms. */ + ttl: { + /** Streams change second to second; the shortest useful cache. */ + streams: num(process.env.STREAMS_TTL_MS, 5_000), + downloads: num(process.env.DOWNLOADS_TTL_MS, 5_000), + metrics: num(process.env.METRICS_TTL_MS, 10_000), + /** Requests and the calendar move slowly; cache them harder. */ + requests: num(process.env.REQUESTS_TTL_MS, 30_000), + calendar: num(process.env.CALENDAR_TTL_MS, 60_000), + activity: num(process.env.ACTIVITY_TTL_MS, 30_000), + }, + + /** Upstream request timeout. Beyond this a widget degrades rather than hangs. */ + upstreamTimeoutMs: num(process.env.UPSTREAM_TIMEOUT_MS, 6_000), } as const; diff --git a/dashboard/server/src/discovery.test.ts b/dashboard/server/src/discovery.test.ts new file mode 100644 index 0000000..93e122d --- /dev/null +++ b/dashboard/server/src/discovery.test.ts @@ -0,0 +1,71 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { __test } from './discovery.js'; + +const { xmlTag, iniValue, SOURCES, ENV_VAR } = __test; + +// Shaped after a real Radarr config.xml. +const ARR_XML = ` + Info + 7878 + + abc123def456 + Radarr +`; + +test('xmlTag reads the API key', () => { + assert.equal(xmlTag(ARR_XML, 'ApiKey'), 'abc123def456'); +}); + +test('xmlTag returns null for an empty element rather than an empty string', () => { + assert.equal(xmlTag(ARR_XML, 'UrlBase'), null); +}); + +test('xmlTag returns null for a missing element', () => { + assert.equal(xmlTag(ARR_XML, 'NotThere'), null); +}); + +test('xmlTag does not match a tag that merely shares a prefix', () => { + // must not satisfy a lookup for . + const xml = 'wrongright'; + assert.equal(xmlTag(xml, 'ApiKey'), 'right'); +}); + +test('xmlTag reads a populated UrlBase', () => { + const xml = '/radarr'; + assert.equal(xmlTag(xml, 'UrlBase'), '/radarr'); +}); + +// Shaped after a real Tautulli config.ini. +const TAUTULLI_INI = `[General] +api_enabled = 1 +api_key = tautullikey123 +api_sql = 0 + +[PMS] +pms_url = http://localhost:32400 +api_key = wrong_section_key +`; + +test('iniValue reads a key from the requested section', () => { + assert.equal(iniValue(TAUTULLI_INI, 'General', 'api_key'), 'tautullikey123'); +}); + +test('iniValue does not leak a same-named key from a later section', () => { + assert.equal(iniValue(TAUTULLI_INI, 'PMS', 'api_key'), 'wrong_section_key'); +}); + +test('iniValue reads the api_enabled flag', () => { + assert.equal(iniValue(TAUTULLI_INI, 'General', 'api_enabled'), '1'); +}); + +test('iniValue returns null for a missing key', () => { + assert.equal(iniValue(TAUTULLI_INI, 'General', 'nope'), null); +}); + +test('every source has an env override variable', () => { + for (const source of SOURCES) { + assert.ok(ENV_VAR[source], `${source} has no env override`); + } +}); diff --git a/dashboard/server/src/discovery.ts b/dashboard/server/src/discovery.ts new file mode 100644 index 0000000..9b34017 --- /dev/null +++ b/dashboard/server/src/discovery.ts @@ -0,0 +1,250 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { memoize } from './cache.js'; + +/** + * API key auto-discovery. + * + * AutoPlexx is a public repo people clone and run, so the dashboard must not + * ask anyone to paste API keys. Every service it talks to already writes its + * key to a file inside a config directory the stack bind-mounts; those + * directories are mounted read-only at /discover/ and parsed here. + * + * Two properties this module has to hold: + * + * - Resolution order is env var -> discovered file -> unconfigured. The env + * var survives as an override for anyone running a service outside this + * stack. + * - Discovery re-runs while the process is alive. On a clean install these + * files do not exist yet — each service writes one on its first boot — so a + * short TTL means integrations light up on their own without a restart. + */ + +export type SourceId = 'sonarr' | 'radarr' | 'prowlarr' | 'bazarr' | 'tautulli' | 'seerr'; + +export type DiscoveryState = + /** A key is available; the integration can be used. */ + | 'live' + /** No key yet — the service probably hasn't written its config. Keep looking. */ + | 'waiting' + /** A key exists but the service needs a change before the API will answer. */ + | 'blocked'; + +export interface Credential { + source: SourceId; + apiKey: string | null; + state: DiscoveryState; + /** Where the key came from, for the Setup panel. */ + origin: 'env' | 'discovered' | 'none'; + /** One concrete step the user can take, when something needs doing. */ + hint: string | null; + /** + * URL base, if the service is configured to serve under a sub-path. Empty for + * a default install; the *arrs expose this as in config.xml. + */ + urlBase: string; +} + +const DISCOVER_ROOT = process.env.DISCOVER_ROOT ?? '/discover'; + +/** Env override per source, checked before any file is read. */ +const ENV_VAR: Record = { + sonarr: 'SONARR_API_KEY', + radarr: 'RADARR_API_KEY', + prowlarr: 'PROWLARR_API_KEY', + bazarr: 'BAZARR_API_KEY', + tautulli: 'TAUTULLI_API_KEY', + seerr: 'SEERR_API_KEY', +}; + +const WAITING_HINT: Record = { + sonarr: 'Waiting for Sonarr to write its config. Start Sonarr and open it once.', + radarr: 'Waiting for Radarr to write its config. Start Radarr and open it once.', + prowlarr: 'Waiting for Prowlarr to write its config. Start Prowlarr and open it once.', + bazarr: 'Waiting for Bazarr to write its config. Start Bazarr and open it once.', + tautulli: 'Waiting for Tautulli to write its config. Start Tautulli and open it once.', + seerr: 'Waiting for Seerr to write its config. Complete the Seerr setup wizard.', +}; + +async function readIfPresent(path: string): Promise { + try { + return await readFile(path, 'utf8'); + } catch { + // Missing file is the normal pre-first-boot case, and an unreadable one + // (wrong ownership, mount not declared) is equally non-fatal — the + // integration simply stays unconfigured. + return null; + } +} + +/** + * Minimal extraction of a single element's text from the *arr `config.xml`. + * + * A real XML parser would be a dependency for one tag in a file this stack + * writes itself; a scoped regex is proportionate. Deliberately anchored to the + * exact tag so it can't match a substring of another element. + */ +function xmlTag(xml: string, tag: string): string | null { + const match = new RegExp(`<${tag}>([^<]*)`, 'i').exec(xml); + return match?.[1]?.trim() || null; +} + +/** + * Reads `key = value` from a named INI section. + * + * Line-based rather than a section-slicing regex: Tautulli reuses key names + * across sections (`api_key` appears under both [General] and [PMS]), so the + * section boundary has to be tracked exactly. Nested `[[subsections]]` are + * treated as part of their parent, which is enough for the keys read here. + */ +function iniValue(ini: string, section: string, key: string): string | null { + let current: string | null = null; + + for (const rawLine of ini.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || line.startsWith(';')) continue; + + // A top-level header, i.e. [General] but not [[subsection]]. + if (line.startsWith('[') && !line.startsWith('[[') && line.endsWith(']')) { + current = line.slice(1, -1).trim(); + continue; + } + + if (current !== section) continue; + + const separator = line.indexOf('='); + if (separator === -1) continue; + if (line.slice(0, separator).trim() !== key) continue; + + return line.slice(separator + 1).trim() || null; + } + + return null; +} + +/** Sonarr / Radarr / Prowlarr / Bazarr all use the same `config.xml` shape. */ +async function discoverArr(source: SourceId): Promise> { + const xml = await readIfPresent(join(DISCOVER_ROOT, source, 'config.xml')); + if (!xml) { + return { apiKey: null, state: 'waiting', origin: 'none', hint: WAITING_HINT[source], urlBase: '' }; + } + + const apiKey = xmlTag(xml, 'ApiKey'); + const urlBase = xmlTag(xml, 'UrlBase') ?? ''; + + if (!apiKey) { + return { + apiKey: null, + state: 'waiting', + origin: 'none', + hint: `Found ${source}'s config.xml but no in it yet.`, + urlBase, + }; + } + return { apiKey, state: 'live', origin: 'discovered', hint: null, urlBase }; +} + +async function discoverTautulli(): Promise> { + const ini = await readIfPresent(join(DISCOVER_ROOT, 'tautulli', 'config.ini')); + if (!ini) { + return { apiKey: null, state: 'waiting', origin: 'none', hint: WAITING_HINT.tautulli, urlBase: '' }; + } + + const apiKey = iniValue(ini, 'General', 'api_key'); + // Tautulli ships with the API disabled; a key alone isn't enough to call it, + // and reporting a generic failure here would send people hunting in the wrong + // place. + const enabled = iniValue(ini, 'General', 'api_enabled') === '1'; + + if (!apiKey) { + return { + apiKey: null, + state: 'waiting', + origin: 'none', + hint: 'Tautulli has no API key yet. Settings -> Web Interface -> API.', + urlBase: '', + }; + } + if (!enabled) { + return { + apiKey, + state: 'blocked', + origin: 'discovered', + hint: 'Tautulli’s API is disabled. Enable it in Settings -> Web Interface -> API.', + urlBase: '', + }; + } + return { apiKey, state: 'live', origin: 'discovered', hint: null, urlBase: '' }; +} + +async function discoverSeerr(): Promise> { + const raw = await readIfPresent(join(DISCOVER_ROOT, 'seerr', 'settings.json')); + if (!raw) { + return { apiKey: null, state: 'waiting', origin: 'none', hint: WAITING_HINT.seerr, urlBase: '' }; + } + + try { + const parsed = JSON.parse(raw) as { main?: { apiKey?: unknown } }; + const apiKey = typeof parsed.main?.apiKey === 'string' ? parsed.main.apiKey : null; + if (!apiKey) { + return { + apiKey: null, + state: 'waiting', + origin: 'none', + hint: 'Seerr’s settings.json has no API key yet. Finish the setup wizard.', + urlBase: '', + }; + } + return { apiKey, state: 'live', origin: 'discovered', hint: null, urlBase: '' }; + } catch { + // Seerr rewrites this file in place; a read during a write can catch it + // mid-flight. Treat it as "not ready" and try again on the next cycle. + return { + apiKey: null, + state: 'waiting', + origin: 'none', + hint: 'Seerr’s settings.json could not be parsed; retrying.', + urlBase: '', + }; + } +} + +async function discoverOne(source: SourceId): Promise { + const override = process.env[ENV_VAR[source]]?.trim(); + if (override) { + return { source, apiKey: override, state: 'live', origin: 'env', hint: null, urlBase: '' }; + } + + const found = + source === 'tautulli' + ? await discoverTautulli() + : source === 'seerr' + ? await discoverSeerr() + : await discoverArr(source); + + return { source, ...found }; +} + +const SOURCES: SourceId[] = ['sonarr', 'radarr', 'prowlarr', 'bazarr', 'tautulli', 'seerr']; + +async function discoverAll(): Promise> { + const results = await Promise.all(SOURCES.map(discoverOne)); + return Object.fromEntries(results.map((c) => [c.source, c])) as Record; +} + +/** + * Cached for a minute: long enough that per-request polling doesn't re-read six + * files, short enough that a service coming up for the first time is picked up + * without anyone restarting the dashboard. + */ +export const getCredentials = memoize( + discoverAll, + Number(process.env.DISCOVERY_TTL_MS) || 60_000, +); + +export async function credentialFor(source: SourceId): Promise { + return (await getCredentials())[source]; +} + +export const __test = { xmlTag, iniValue, SOURCES, ENV_VAR }; diff --git a/dashboard/server/src/http.ts b/dashboard/server/src/http.ts new file mode 100644 index 0000000..8188a37 --- /dev/null +++ b/dashboard/server/src/http.ts @@ -0,0 +1,69 @@ +import { config } from './config.js'; + +/** + * Every widget is allowed to fail on its own. `Unavailable` is the shape that + * failure takes: a reason the UI can show, rather than an exception that would + * take the whole response with it. + */ +export interface Unavailable { + available: false; + reason: string; + /** A concrete next step, when there is one. */ + hint?: string; +} + +export type Result = (T & { available: true }) | Unavailable; + +export function unavailable(reason: string, hint?: string): Unavailable { + return hint ? { available: false, reason, hint } : { available: false, reason }; +} + +/** + * Turns a thrown error into a reason string a person can act on. Node's fetch + * wraps the useful part in `cause`, and the bare "fetch failed" it surfaces + * otherwise tells a user nothing. + */ +export function describeError(error: unknown): string { + if (error instanceof Error) { + if (error.name === 'TimeoutError' || error.name === 'AbortError') { + return 'upstream timed out'; + } + const code = (error.cause as { code?: string } | undefined)?.code; + if (code === 'ECONNREFUSED') return 'connection refused'; + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'host not found'; + return error.message; + } + return 'request failed'; +} + +/** GET a JSON endpoint with a timeout. Throws; callers convert to `Unavailable`. */ +export async function getJson(url: string, headers: Record = {}): Promise { + const response = await fetch(url, { + headers: { accept: 'application/json', ...headers }, + signal: AbortSignal.timeout(config.upstreamTimeoutMs), + }); + if (!response.ok) { + // 401/403 almost always means a stale API key, which is worth saying + // plainly rather than reporting as a generic HTTP error. + if (response.status === 401 || response.status === 403) { + throw new Error(`authentication rejected (${response.status})`); + } + throw new Error(`HTTP ${response.status}`); + } + return (await response.json()) as T; +} + +/** + * Wraps a source function so it always resolves. This is the single place the + * "one dead upstream must never blank the page" rule is enforced. + */ +export async function safely( + load: () => Promise, + hint?: string, +): Promise> { + try { + return { ...(await load()), available: true }; + } catch (error) { + return unavailable(describeError(error), hint); + } +} diff --git a/dashboard/server/src/index.ts b/dashboard/server/src/index.ts index f403942..ce7c16b 100644 --- a/dashboard/server/src/index.ts +++ b/dashboard/server/src/index.ts @@ -7,6 +7,13 @@ import fastifyStatic from '@fastify/static'; import { config } from './config.js'; import { getHealth } from './sources/docker.js'; +import { getMetrics } from './sources/prometheus.js'; +import { getStreams } from './sources/tautulli.js'; +import { getDownloads, getVpn } from './sources/transmission.js'; +import { getRequests } from './sources/seerr.js'; +import { getUpcoming } from './sources/upcoming.js'; +import { getActivity } from './sources/activity.js'; +import { getCredentials } from './discovery.js'; import { SERVICES, VISIBLE_GROUPS, withResolvedPorts } from './services.js'; const here = dirname(fileURLToPath(import.meta.url)); @@ -36,6 +43,38 @@ app.get('/api/health', async () => { return { ...report, services: withResolvedPorts(report.services, config.grafanaPort) }; }); +/** + * Widget data. Every one of these resolves to either a payload with + * `available: true` or an `{ available: false, reason, hint }` — they never + * reject, so one dead upstream can't take down the response. + */ +app.get('/api/metrics', async () => getMetrics()); +app.get('/api/streams', async () => getStreams()); +app.get('/api/downloads', async () => getDownloads()); +app.get('/api/requests', async () => getRequests()); +app.get('/api/upcoming', async () => getUpcoming()); +app.get('/api/activity', async () => getActivity()); +app.get('/api/vpn', async () => getVpn()); + +/** + * Integration status for the Setup panel — what's live, what's still waiting on + * a service's first boot, and the one concrete step for anything that's stuck. + * + * Deliberately reports only whether a key was found and where it came from. + * The keys themselves never leave the server. + */ +app.get('/api/integrations', async () => { + const credentials = await getCredentials(); + return { + integrations: Object.values(credentials).map((credential) => ({ + source: credential.source, + state: credential.state, + origin: credential.origin, + hint: credential.hint, + })), + }; +}); + // Serve the built SPA. Skipped in dev, where Vite serves the frontend itself. if (existsSync(webRoot)) { await app.register(fastifyStatic, { root: webRoot }); diff --git a/dashboard/server/src/sources/activity.ts b/dashboard/server/src/sources/activity.ts new file mode 100644 index 0000000..a1e7c7b --- /dev/null +++ b/dashboard/server/src/sources/activity.ts @@ -0,0 +1,91 @@ +import { config } from '../config.js'; +import { memoize } from '../cache.js'; +import { credentialFor } from '../discovery.js'; +import { safely, type Result } from '../http.js'; +import { history } from './arr.js'; + +/** + * The activity feed — a merged, time-ordered view of what the stack has been + * doing. Each contributing source is optional: whichever ones are configured + * show up, and the feed still renders if only one is. + */ + +export type ActivityKind = 'grab' | 'download' | 'upgrade' | 'other'; + +export interface ActivityItem { + kind: ActivityKind; + text: string; + /** ISO timestamp; the client formats it. */ + at: string; +} + +/** Maps Servarr event types onto the icons the design uses. */ +function kindOf(eventType: string | undefined): ActivityKind { + switch (eventType) { + case 'grabbed': + return 'grab'; + case 'downloadFolderImported': + case 'episodeFileImported': + case 'movieFileImported': + return 'download'; + case 'episodeFileRenamed': + case 'movieFileRenamed': + return 'upgrade'; + default: + return 'other'; + } +} + +function phrase(eventType: string | undefined, service: string, title: string): string { + switch (eventType) { + case 'grabbed': + return `${service} grabbed ${title}`; + case 'downloadFolderImported': + case 'episodeFileImported': + case 'movieFileImported': + return `Imported ${title}`; + case 'episodeFileDeleted': + case 'movieFileDeleted': + return `Deleted ${title}`; + default: + return `${service}: ${title}`; + } +} + +async function fromArr(arr: 'sonarr' | 'radarr'): Promise { + const credential = await credentialFor(arr); + if (credential.state !== 'live') return []; + + const service = arr === 'sonarr' ? 'Sonarr' : 'Radarr'; + try { + const records = await history(arr, 15); + return records + .filter((record) => record.date && record.sourceTitle) + .map((record) => ({ + kind: kindOf(record.eventType), + text: phrase(record.eventType, service, record.sourceTitle!), + at: record.date!, + })); + } catch { + // A single contributor failing must not empty the feed. + return []; + } +} + +async function load(): Promise<{ items: ActivityItem[] }> { + const contributions = await Promise.all([fromArr('sonarr'), fromArr('radarr')]); + + const items = contributions + .flat() + .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()) + .slice(0, 8); + + return { items }; +} + +export const getActivity = memoize>( + () => safely(load), + config.ttl.activity, +); + +export const __test = { kindOf, phrase }; diff --git a/dashboard/server/src/sources/arr.ts b/dashboard/server/src/sources/arr.ts new file mode 100644 index 0000000..2bdb58f --- /dev/null +++ b/dashboard/server/src/sources/arr.ts @@ -0,0 +1,125 @@ +import { config } from '../config.js'; +import { credentialFor, type SourceId } from '../discovery.js'; +import { getJson } from '../http.js'; + +/** + * Shared client for the Servarr v3 API. Sonarr and Radarr differ in their + * resources but not in their auth, versioning or error shape, so the transport + * lives here once. + */ + +export type ArrId = Extract; + +/** Builds a v3 API URL, honouring a UrlBase if the service is behind a sub-path. */ +export async function arrRequest( + arr: ArrId, + path: string, + params: Record = {}, +): Promise { + const credential = await credentialFor(arr); + if (!credential.apiKey) { + throw new Error(credential.hint ?? `${arr} API key not found yet`); + } + + const base = config.upstream[arr].replace(/\/$/, ''); + const urlBase = credential.urlBase.replace(/\/$/, ''); + const query = new URLSearchParams(params).toString(); + const url = `${base}${urlBase}/api/v3/${path}${query ? `?${query}` : ''}`; + + return getJson(url, { 'X-Api-Key': credential.apiKey }); +} + +export interface QueueRecord { + title?: string; + status?: string; + size?: number; + sizeleft?: number; + timeleft?: string; + downloadId?: string; + trackedDownloadState?: string; +} + +interface QueuePage { + records?: QueueRecord[]; +} + +/** + * Current queue. Asked for a generous page size because the dashboard matches + * these against Transmission's torrent list to label each download's source. + */ +export async function queue(arr: ArrId): Promise { + const page = await arrRequest(arr, 'queue', { + pageSize: '100', + includeUnknownMovieItems: 'false', + }); + return page.records ?? []; +} + +export interface HistoryRecord { + eventType?: string; + date?: string; + sourceTitle?: string; +} + +interface HistoryPage { + records?: HistoryRecord[]; +} + +/** Recent history, newest first — the raw material for the activity feed. */ +export async function history(arr: ArrId, pageSize = 20): Promise { + const page = await arrRequest(arr, 'history', { + page: '1', + pageSize: String(pageSize), + sortKey: 'date', + sortDirection: 'descending', + }); + return page.records ?? []; +} + +export interface CalendarEpisode { + seriesTitle: string; + code: string; + title: string; + network: string; + airDate: string; + /** Downloaded / airing today / not yet acquired. */ + status: 'downloaded' | 'airing' | 'missing'; +} + +interface SonarrCalendarItem { + title?: string; + seasonNumber?: number; + episodeNumber?: number; + airDateUtc?: string; + hasFile?: boolean; + series?: { title?: string; network?: string }; +} + +function classify(item: SonarrCalendarItem, now: Date): CalendarEpisode['status'] { + if (item.hasFile) return 'downloaded'; + const air = item.airDateUtc ? new Date(item.airDateUtc) : null; + // Not yet aired is "airing" (upcoming); aired without a file is "missing". + if (air && air.getTime() > now.getTime()) return 'airing'; + return 'missing'; +} + +/** Sonarr's release calendar over a date range. */ +export async function calendar(start: Date, end: Date): Promise { + const items = await arrRequest('sonarr', 'calendar', { + start: start.toISOString(), + end: end.toISOString(), + includeSeries: 'true', + }); + + const now = new Date(); + return items.map((item) => ({ + seriesTitle: item.series?.title ?? 'Unknown', + code: `S${String(item.seasonNumber ?? 0).padStart(2, '0')}E${String(item.episodeNumber ?? 0).padStart(2, '0')}`, + title: item.title ?? '', + network: item.series?.network ?? '', + airDate: item.airDateUtc ?? '', + status: classify(item, now), + })); +} + +export const __test = { classify }; diff --git a/dashboard/server/src/sources/prometheus.ts b/dashboard/server/src/sources/prometheus.ts new file mode 100644 index 0000000..36acd04 --- /dev/null +++ b/dashboard/server/src/sources/prometheus.ts @@ -0,0 +1,119 @@ +import { config } from '../config.js'; +import { memoize } from '../cache.js'; +import { getJson, safely, type Result } from '../http.js'; + +/** + * Resource gauges, from node-exporter via Prometheus. + * + * prometheus/prometheus.yml already scrapes node-exporter and cAdvisor, so + * these need no configuration at all — which is why the gauges work on a + * completely unconfigured stack. + */ + +interface PromResponse { + status: string; + data?: { result?: { value?: [number, string] }[] }; +} + +export interface Gauge { + id: 'cpu' | 'memory' | 'storage' | 'network'; + label: string; + value: string; + sub: string; + /** 0-1, drives the conic-gradient ring. Null when the metric has no ceiling. */ + fraction: number | null; +} + +/** Runs one instant query and returns the first sample's value. */ +async function query(expr: string): Promise { + const url = `${config.upstream.prometheus}/api/v1/query?query=${encodeURIComponent(expr)}`; + const body = await getJson(url); + if (body.status !== 'success') return null; + const raw = body.data?.result?.[0]?.value?.[1]; + if (raw === undefined) return null; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; +} + +function formatBytes(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(value >= 100 || unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +async function load(): Promise<{ gauges: Gauge[] }> { + // `mode="idle"` inverted gives busy time across all cores. + const cpuExpr = '100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[2m])) * 100)'; + const coresExpr = 'count(count by (cpu) (node_cpu_seconds_total))'; + const memTotalExpr = 'node_memory_MemTotal_bytes'; + const memAvailExpr = 'node_memory_MemAvailable_bytes'; + // Exclude pseudo-filesystems so "storage" reflects real disks. Summed across + // mountpoints because media commonly spans several. + const fsFilter = '{fstype!~"tmpfs|overlay|squashfs|ramfs|devtmpfs"}'; + const fsSizeExpr = `sum(node_filesystem_size_bytes${fsFilter})`; + const fsAvailExpr = `sum(node_filesystem_avail_bytes${fsFilter})`; + // Exclude loopback and virtual interfaces so the rate reflects real traffic. + const netFilter = '{device!~"lo|veth.*|docker.*|br-.*"}'; + const rxExpr = `sum(rate(node_network_receive_bytes_total${netFilter}[2m]))`; + const txExpr = `sum(rate(node_network_transmit_bytes_total${netFilter}[2m]))`; + + const [cpu, cores, memTotal, memAvail, fsSize, fsAvail, rx, tx] = await Promise.all([ + query(cpuExpr), + query(coresExpr), + query(memTotalExpr), + query(memAvailExpr), + query(fsSizeExpr), + query(fsAvailExpr), + query(rxExpr), + query(txExpr), + ]); + + const memUsed = memTotal !== null && memAvail !== null ? memTotal - memAvail : null; + const fsUsed = fsSize !== null && fsAvail !== null ? fsSize - fsAvail : null; + + const gauges: Gauge[] = [ + { + id: 'cpu', + label: 'CPU', + value: cpu === null ? '—' : `${Math.round(cpu)}%`, + sub: cores === null ? 'host' : `${cores} core${cores === 1 ? '' : 's'}`, + fraction: cpu === null ? null : Math.min(cpu / 100, 1), + }, + { + id: 'memory', + label: 'Memory', + value: memUsed === null ? '—' : formatBytes(memUsed), + sub: memTotal === null ? 'host' : `of ${formatBytes(memTotal)}`, + fraction: memUsed !== null && memTotal ? Math.min(memUsed / memTotal, 1) : null, + }, + { + id: 'storage', + label: 'Storage', + value: fsUsed === null ? '—' : formatBytes(fsUsed), + sub: fsSize === null ? 'disks' : `of ${formatBytes(fsSize)}`, + fraction: fsUsed !== null && fsSize ? Math.min(fsUsed / fsSize, 1) : null, + }, + { + id: 'network', + label: 'Network', + value: rx === null ? '—' : `${formatBytes(rx)}/s`, + sub: tx === null ? 'down' : `down · ${formatBytes(tx)}/s up`, + // Throughput has no ceiling to divide by, so the ring stays unfilled. + fraction: null, + }, + ]; + + return { gauges }; +} + +export const getMetrics = memoize>( + () => safely(load, 'Prometheus scrapes node-exporter; check both are running.'), + config.ttl.metrics, +); + +export const __test = { formatBytes }; diff --git a/dashboard/server/src/sources/seerr.ts b/dashboard/server/src/sources/seerr.ts new file mode 100644 index 0000000..526ac3d --- /dev/null +++ b/dashboard/server/src/sources/seerr.ts @@ -0,0 +1,141 @@ +import { config } from '../config.js'; +import { memoize } from '../cache.js'; +import { credentialFor } from '../discovery.js'; +import { getJson, safely, unavailable, type Result } from '../http.js'; + +/** Content requests, from Seerr. */ + +/** Seerr's numeric request status codes. */ +const STATUS: Record = { + 1: 'Pending', + 2: 'Approved', + 3: 'Declined', +}; + +/** Media availability, used to promote an approved request to "Downloading". */ +const MEDIA_AVAILABLE = 5; +const MEDIA_PARTIALLY_AVAILABLE = 4; +const MEDIA_PROCESSING = 3; + +interface SeerrRequest { + id?: number; + status?: number; + type?: string; + createdAt?: string; + requestedBy?: { displayName?: string; username?: string; plexUsername?: string }; + media?: { status?: number; tmdbId?: number }; +} + +interface SeerrPage { + pageInfo?: { results?: number }; + results?: SeerrRequest[]; +} + +export interface RequestItem { + title: string; + kind: 'Movie' | 'Series'; + user: string; + when: string; + status: 'Pending' | 'Approved' | 'Declined' | 'Downloading' | 'Available'; +} + +interface SeerrMediaDetails { + title?: string; + name?: string; +} + +export interface RequestsPayload { + requests: RequestItem[]; + pending: number; +} + +/** "2h ago" / "Yesterday" / a date, matching the design's relative phrasing. */ +function relative(iso: string | undefined, now: Date): string { + if (!iso) return ''; + const then = new Date(iso); + const minutes = Math.floor((now.getTime() - then.getTime()) / 60_000); + if (!Number.isFinite(minutes) || minutes < 0) return ''; + if (minutes < 60) return `${Math.max(minutes, 1)}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days === 1) return 'Yesterday'; + if (days < 7) return `${days}d ago`; + return then.toLocaleDateString(); +} + +function statusOf(request: SeerrRequest): RequestItem['status'] { + const mediaStatus = request.media?.status; + // An approved request whose media is already processing reads better as + // "Downloading" — that's what the user actually wants to know. + if (request.status === 2) { + if (mediaStatus === MEDIA_AVAILABLE) return 'Available'; + if (mediaStatus === MEDIA_PARTIALLY_AVAILABLE || mediaStatus === MEDIA_PROCESSING) { + return 'Downloading'; + } + } + return STATUS[request.status ?? 0] ?? 'Pending'; +} + +async function titleFor( + request: SeerrRequest, + apiKey: string, +): Promise { + const tmdbId = request.media?.tmdbId; + if (!tmdbId) return 'Unknown title'; + + const kind = request.type === 'tv' ? 'tv' : 'movie'; + try { + const details = await getJson( + `${config.upstream.seerr}/api/v1/${kind}/${tmdbId}`, + { 'X-Api-Key': apiKey }, + ); + return details.title || details.name || 'Unknown title'; + } catch { + // Title lookup goes out to TMDb via Seerr and can fail independently of the + // request list; a missing title shouldn't drop the row. + return 'Unknown title'; + } +} + +async function load(): Promise { + const credential = await credentialFor('seerr'); + if (!credential.apiKey) throw new Error(credential.hint ?? 'Seerr API key not found yet'); + + const page = await getJson( + `${config.upstream.seerr}/api/v1/request?take=8&skip=0&sort=added`, + { 'X-Api-Key': credential.apiKey }, + ); + + const now = new Date(); + const results = page.results ?? []; + + const requests = await Promise.all( + results.map(async (request): Promise => ({ + title: await titleFor(request, credential.apiKey!), + kind: request.type === 'tv' ? 'Series' : 'Movie', + user: + request.requestedBy?.displayName || + request.requestedBy?.plexUsername || + request.requestedBy?.username || + 'Unknown', + when: relative(request.createdAt, now), + status: statusOf(request), + })), + ); + + return { + requests, + pending: requests.filter((r) => r.status === 'Pending').length, + }; +} + +export const getRequests = memoize>(async () => { + const credential = await credentialFor('seerr'); + if (credential.state !== 'live') { + return unavailable('Waiting for Seerr', credential.hint ?? undefined); + } + return safely(load); +}, config.ttl.requests); + +export const __test = { relative, statusOf }; diff --git a/dashboard/server/src/sources/sources.test.ts b/dashboard/server/src/sources/sources.test.ts new file mode 100644 index 0000000..2d60806 --- /dev/null +++ b/dashboard/server/src/sources/sources.test.ts @@ -0,0 +1,153 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { __test as tautulli } from './tautulli.js'; +import { __test as transmission } from './transmission.js'; +import { __test as seerr } from './seerr.js'; +import { __test as arr } from './arr.js'; +import { __test as prometheus } from './prometheus.js'; +import { __test as activity } from './activity.js'; + +// ---- Tautulli -------------------------------------------------------------- + +test('duration formats under an hour without a leading hour', () => { + assert.equal(tautulli.duration(12 * 60 * 1000 + 44 * 1000), '12:44'); +}); + +test('duration formats over an hour with zero-padded minutes', () => { + assert.equal(tautulli.duration((1 * 3600 + 2 * 60 + 11) * 1000), '1:02:11'); +}); + +test('duration handles zero and nonsense without throwing', () => { + assert.equal(tautulli.duration(0), '0:00'); + assert.equal(tautulli.duration(Number.NaN), '0:00'); + assert.equal(tautulli.duration(-5), '0:00'); +}); + +test('transcode decision maps to the design’s three modes', () => { + assert.equal(tautulli.mode('transcode'), 'Transcode'); + assert.equal(tautulli.mode('copy'), 'Direct Stream'); + assert.equal(tautulli.mode('direct play'), 'Direct Play'); + assert.equal(tautulli.mode(undefined), 'Direct Play'); +}); + +test('monogram initialises multi-word titles and truncates single words', () => { + assert.equal(tautulli.monogram('The Bear'), 'TB'); + assert.equal(tautulli.monogram('Oppenheimer'), 'OPPE'); + assert.equal(tautulli.monogram('Dune: Part Two'), 'DPT'); + assert.equal(tautulli.monogram(''), '—'); +}); + +test('episode sessions render as SxxEyy, movies as a year', () => { + const episode = tautulli.toStream({ + media_type: 'episode', + grandparent_title: 'The Bear', + parent_media_index: '3', + media_index: '5', + }); + assert.equal(episode.title, 'The Bear'); + assert.equal(episode.meta, 'S03E05'); + + const movie = tautulli.toStream({ media_type: 'movie', title: 'Dune', year: '2024' }); + assert.equal(movie.meta, '2024'); +}); + +test('progress percent is clamped to 0-100', () => { + assert.equal(tautulli.toStream({ progress_percent: '150' }).percent, 100); + assert.equal(tautulli.toStream({ progress_percent: '-10' }).percent, 0); + assert.equal(tautulli.toStream({ progress_percent: 'abc' }).percent, 0); +}); + +// ---- Transmission ---------------------------------------------------------- + +test('speed switches units at 1 MB/s', () => { + assert.equal(transmission.formatSpeed(42_000_000), '42.0 MB/s'); + assert.equal(transmission.formatSpeed(500_000), '500 KB/s'); + assert.equal(transmission.formatSpeed(0), '0 MB/s'); +}); + +test('negative ETA means unknown, not a negative duration', () => { + // Transmission uses -1 for "unknown" and -2 for "not downloading". + assert.equal(transmission.formatEta(-1), '—'); + assert.equal(transmission.formatEta(-2), '—'); + assert.equal(transmission.formatEta(undefined), '—'); +}); + +test('ETA scales through seconds, minutes, hours and days', () => { + assert.equal(transmission.formatEta(45), '45s'); + assert.equal(transmission.formatEta(180), '3m'); + assert.equal(transmission.formatEta(3600 * 2 + 60 * 30), '2h 30m'); + assert.equal(transmission.formatEta(86_400 * 3), '3d'); +}); + +test('the failure hint matches the failure, not a generic one', () => { + assert.match( + transmission.hintFor('Transmission rejected the RPC credentials'), + /TRANSMISSION_RPC_USERNAME/, + ); + assert.match(transmission.hintFor('connection refused'), /LOCAL_NETWORK/); + assert.match(transmission.hintFor('upstream timed out'), /LOCAL_NETWORK/); + assert.match(transmission.hintFor('HTTP 500'), /logs/); +}); + +test('attribution falls back to OTHER rather than guessing', () => { + const sonarr = new Set(['Show.S01E01.1080p']); + const radarr = new Set(['Movie.2024.2160p']); + assert.equal(transmission.attribute('Show.S01E01.1080p', sonarr, radarr), 'SONARR'); + assert.equal(transmission.attribute('Movie.2024.2160p', sonarr, radarr), 'RADARR'); + assert.equal(transmission.attribute('Something.Else', sonarr, radarr), 'OTHER'); +}); + +// ---- Seerr ----------------------------------------------------------------- + +test('relative time reads naturally across ranges', () => { + const now = new Date('2026-07-28T12:00:00Z'); + const ago = (ms: number) => new Date(now.getTime() - ms).toISOString(); + assert.equal(seerr.relative(ago(30 * 60_000), now), '30m ago'); + assert.equal(seerr.relative(ago(5 * 3600_000), now), '5h ago'); + assert.equal(seerr.relative(ago(26 * 3600_000), now), 'Yesterday'); + assert.equal(seerr.relative(ago(3 * 86_400_000), now), '3d ago'); + assert.equal(seerr.relative(undefined, now), ''); +}); + +test('an approved request that is processing reads as Downloading', () => { + assert.equal(seerr.statusOf({ status: 2, media: { status: 3 } }), 'Downloading'); + assert.equal(seerr.statusOf({ status: 2, media: { status: 5 } }), 'Available'); + assert.equal(seerr.statusOf({ status: 2, media: { status: 1 } }), 'Approved'); + assert.equal(seerr.statusOf({ status: 1 }), 'Pending'); + assert.equal(seerr.statusOf({ status: 3 }), 'Declined'); +}); + +// ---- Sonarr calendar ------------------------------------------------------- + +test('an episode with a file is downloaded regardless of air date', () => { + const now = new Date('2026-07-28T12:00:00Z'); + assert.equal(arr.classify({ hasFile: true, airDateUtc: '2026-07-01T00:00:00Z' }, now), 'downloaded'); +}); + +test('a future episode is airing; a past one without a file is missing', () => { + const now = new Date('2026-07-28T12:00:00Z'); + assert.equal(arr.classify({ hasFile: false, airDateUtc: '2026-07-30T00:00:00Z' }, now), 'airing'); + assert.equal(arr.classify({ hasFile: false, airDateUtc: '2026-07-20T00:00:00Z' }, now), 'missing'); +}); + +// ---- Prometheus ------------------------------------------------------------ + +test('byte formatting steps through units', () => { + assert.equal(prometheus.formatBytes(512), '512 B'); + assert.equal(prometheus.formatBytes(1024 * 1024 * 1.5), '1.5 MB'); + assert.equal(prometheus.formatBytes(1024 ** 4 * 7.8), '7.8 TB'); +}); + +// ---- Activity feed --------------------------------------------------------- + +test('servarr event types map to feed icons', () => { + assert.equal(activity.kindOf('grabbed'), 'grab'); + assert.equal(activity.kindOf('downloadFolderImported'), 'download'); + assert.equal(activity.kindOf('somethingNew'), 'other'); +}); + +test('feed phrasing names the service that acted', () => { + assert.equal(activity.phrase('grabbed', 'Sonarr', 'Show.S01E01'), 'Sonarr grabbed Show.S01E01'); + assert.equal(activity.phrase('movieFileImported', 'Radarr', 'Movie.2024'), 'Imported Movie.2024'); +}); diff --git a/dashboard/server/src/sources/tautulli.ts b/dashboard/server/src/sources/tautulli.ts new file mode 100644 index 0000000..8d3813a --- /dev/null +++ b/dashboard/server/src/sources/tautulli.ts @@ -0,0 +1,160 @@ +import { config } from '../config.js'; +import { memoize } from '../cache.js'; +import { credentialFor } from '../discovery.js'; +import { getJson, safely, unavailable, type Result } from '../http.js'; + +/** Now Playing, from Tautulli's `get_activity` command. */ + +interface TautulliSession { + title?: string; + grandparent_title?: string; + parent_media_index?: string; + media_index?: string; + year?: string; + media_type?: string; + friendly_name?: string; + user?: string; + video_full_resolution?: string; + quality_profile?: string; + transcode_decision?: string; + player?: string; + platform?: string; + bandwidth?: string; + progress_percent?: string; + view_offset?: string; + duration?: string; +} + +interface TautulliActivity { + response?: { + result?: string; + message?: string | null; + data?: { sessions?: TautulliSession[]; stream_count?: string; total_bandwidth?: string }; + }; +} + +export type StreamMode = 'Direct Play' | 'Direct Stream' | 'Transcode'; + +export interface Stream { + title: string; + meta: string; + mono: string; + user: string; + quality: string; + mode: StreamMode; + device: string; + player: string; + bandwidth: string; + /** 0-100 */ + percent: number; + elapsed: string; + total: string; +} + +export interface StreamsPayload { + streams: Stream[]; + count: number; + /** Total bandwidth across sessions, pre-formatted. */ + bandwidth: string; + transcodes: number; +} + +/** Tautulli reports ms; the design shows h:mm:ss. */ +function duration(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return '0:00'; + const total = Math.floor(ms / 1000); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const mm = String(minutes).padStart(hours > 0 ? 2 : 1, '0'); + const ss = String(seconds).padStart(2, '0'); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; +} + +function mode(decision: string | undefined): StreamMode { + if (decision === 'transcode') return 'Transcode'; + if (decision === 'copy') return 'Direct Stream'; + return 'Direct Play'; +} + +/** A short monogram for the poster placeholder, e.g. "The Bear" -> "BEAR". */ +function monogram(title: string): string { + const words = title.replace(/[^\w\s]/g, '').split(/\s+/).filter(Boolean); + if (words.length === 0) return '—'; + if (words.length === 1) return words[0]!.slice(0, 4).toUpperCase(); + return words + .map((w) => w[0]!) + .join('') + .slice(0, 4) + .toUpperCase(); +} + +function toStream(session: TautulliSession): Stream { + const isEpisode = session.media_type === 'episode'; + const title = (isEpisode ? session.grandparent_title : session.title) ?? 'Unknown'; + + // Episodes read better as SxxEyy; movies as a year. + const season = session.parent_media_index?.padStart(2, '0'); + const episode = session.media_index?.padStart(2, '0'); + const meta = isEpisode && season && episode ? `S${season}E${episode}` : (session.year ?? ''); + + const offset = Number(session.view_offset ?? 0); + const total = Number(session.duration ?? 0); + const percent = Number(session.progress_percent ?? 0); + const bandwidthKbps = Number(session.bandwidth ?? 0); + + return { + title, + meta, + mono: monogram(title), + user: session.friendly_name || session.user || 'Unknown', + quality: session.video_full_resolution || session.quality_profile || '', + mode: mode(session.transcode_decision), + device: session.platform ?? '', + player: session.player ?? '', + bandwidth: bandwidthKbps > 0 ? `${(bandwidthKbps / 1000).toFixed(1)} Mbps` : '', + percent: Number.isFinite(percent) ? Math.min(Math.max(percent, 0), 100) : 0, + elapsed: duration(offset), + total: duration(total), + }; +} + +async function load(): Promise { + const credential = await credentialFor('tautulli'); + if (!credential.apiKey) { + throw new Error(credential.hint ?? 'Tautulli API key not found yet'); + } + + const url = `${config.upstream.tautulli}/api/v2?apikey=${encodeURIComponent(credential.apiKey)}&cmd=get_activity`; + const body = await getJson(url); + + // Tautulli answers 200 with result:"error" for a bad key, so the envelope has + // to be checked rather than trusting the status code. + if (body.response?.result !== 'success') { + throw new Error(body.response?.message || 'Tautulli rejected the request'); + } + + const sessions = body.response.data?.sessions ?? []; + const streams = sessions.map(toStream); + const totalKbps = Number(body.response.data?.total_bandwidth ?? 0); + + return { + streams, + count: streams.length, + bandwidth: totalKbps > 0 ? `${(totalKbps / 1000).toFixed(1)} Mbps` : '0 Mbps', + transcodes: streams.filter((s) => s.mode === 'Transcode').length, + }; +} + +export const getStreams = memoize>(async () => { + const credential = await credentialFor('tautulli'); + if (credential.state !== 'live') { + return unavailable( + credential.state === 'blocked' ? 'Tautulli API disabled' : 'Waiting for Tautulli', + credential.hint ?? undefined, + ); + } + return safely(load); +}, config.ttl.streams); + +export const __test = { duration, mode, monogram, toStream }; diff --git a/dashboard/server/src/sources/transmission.ts b/dashboard/server/src/sources/transmission.ts new file mode 100644 index 0000000..b448a76 --- /dev/null +++ b/dashboard/server/src/sources/transmission.ts @@ -0,0 +1,218 @@ +import { config } from '../config.js'; +import { memoize } from '../cache.js'; +import { safely, type Result } from '../http.js'; +import { queue, type QueueRecord } from './arr.js'; + +/** + * Active downloads, from Transmission's RPC endpoint. + * + * Transmission requires a CSRF handshake: the first call returns 409 with an + * `X-Transmission-Session-Id` header that must be echoed on the retry. The + * token stays valid until the daemon restarts, so it's cached and only + * re-fetched on the next 409. + */ + +const RPC_PATH = '/transmission/rpc'; + +let sessionId: string | null = null; + +interface TorrentFields { + name?: string; + percentDone?: number; + rateDownload?: number; + eta?: number; + status?: number; + isFinished?: boolean; +} + +interface RpcResponse { + result?: string; + arguments?: { torrents?: TorrentFields[] }; +} + +export interface Download { + label: string; + /** Which *arr grabbed it, when it can be matched. */ + source: 'SONARR' | 'RADARR' | 'OTHER'; + /** 0-100 */ + percent: number; + speed: string; + eta: string; +} + +export interface DownloadsPayload { + downloads: Download[]; + active: number; +} + +function authHeader(): Record { + const { username, password } = config.transmissionAuth; + if (!username && !password) return {}; + const encoded = Buffer.from(`${username}:${password}`).toString('base64'); + return { authorization: `Basic ${encoded}` }; +} + +async function rpc(body: unknown, retryOn409 = true): Promise { + const response = await fetch(`${config.upstream.transmission}${RPC_PATH}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(sessionId ? { 'X-Transmission-Session-Id': sessionId } : {}), + ...authHeader(), + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(config.upstreamTimeoutMs), + }); + + if (response.status === 409 && retryOn409) { + const token = response.headers.get('x-transmission-session-id'); + if (!token) throw new Error('Transmission returned 409 without a session id'); + sessionId = token; + // One retry only — a second 409 means something other than a stale token. + return rpc(body, false); + } + + if (response.status === 401) { + throw new Error('Transmission rejected the RPC credentials'); + } + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const parsed = (await response.json()) as RpcResponse; + if (parsed.result !== 'success') { + throw new Error(parsed.result || 'Transmission RPC error'); + } + return parsed; +} + +function formatSpeed(bytesPerSecond: number): string { + if (bytesPerSecond <= 0) return '0 MB/s'; + const mb = bytesPerSecond / 1_000_000; + return mb >= 1 ? `${mb.toFixed(1)} MB/s` : `${Math.round(bytesPerSecond / 1000)} KB/s`; +} + +function formatEta(seconds: number | undefined): string { + // Transmission uses negative values for "unknown" and "not downloading". + if (seconds === undefined || seconds < 0) return '—'; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ${minutes % 60}m`; + return `${Math.floor(hours / 24)}d`; +} + +/** + * Labels a torrent with the *arr that grabbed it. + * + * The queues carry the release title, which is what Transmission names the + * torrent, so an exact match works for the common case. Falls back to OTHER + * rather than guessing — a wrong attribution is worse than none. + */ +function attribute( + name: string, + sonarrTitles: Set, + radarrTitles: Set, +): Download['source'] { + if (sonarrTitles.has(name)) return 'SONARR'; + if (radarrTitles.has(name)) return 'RADARR'; + return 'OTHER'; +} + +function titleSet(records: QueueRecord[]): Set { + return new Set(records.map((r) => r.title).filter((t): t is string => Boolean(t))); +} + +async function load(): Promise { + // The *arr queues are a nice-to-have for labelling; if either is unavailable + // the downloads still render, just without a source tag. + const [response, sonarrQueue, radarrQueue] = await Promise.all([ + rpc({ + method: 'torrent-get', + arguments: { + fields: ['name', 'percentDone', 'rateDownload', 'eta', 'status', 'isFinished'], + }, + }), + queue('sonarr').catch(() => [] as QueueRecord[]), + queue('radarr').catch(() => [] as QueueRecord[]), + ]); + + const sonarrTitles = titleSet(sonarrQueue); + const radarrTitles = titleSet(radarrQueue); + + const torrents = response.arguments?.torrents ?? []; + // Status 4 is "downloading"; anything complete or seeding isn't interesting + // on a dashboard that answers "what's in flight". + const active = torrents.filter((t) => t.status === 4 && !t.isFinished); + + const downloads: Download[] = active + .map((torrent) => { + const name = torrent.name ?? 'Unknown'; + return { + label: name, + source: attribute(name, sonarrTitles, radarrTitles), + percent: Math.round((torrent.percentDone ?? 0) * 100), + speed: formatSpeed(torrent.rateDownload ?? 0), + eta: formatEta(torrent.eta), + }; + }) + // Closest to done first — that's the one the user is waiting on. + .sort((a, b) => b.percent - a.percent) + .slice(0, 6); + + return { downloads, active: active.length }; +} + +/** + * Picks the next step that actually matches the failure. A rejected credential + * and an unreachable host need completely different fixes, and a generic hint + * sends people looking in the wrong place. + */ +function hintFor(reason: string): string { + if (reason.includes('credentials')) { + return 'Set TRANSMISSION_RPC_USERNAME and TRANSMISSION_RPC_PASSWORD in .env to match Transmission.'; + } + if (reason.includes('refused') || reason.includes('not found') || reason.includes('timed out')) { + return 'Transmission runs behind the VPN; check LOCAL_NETWORK in .env covers your subnet.'; + } + return 'Check the Transmission container logs.'; +} + +export const getDownloads = memoize>(async () => { + const result = await safely(load); + return result.available ? result : { ...result, hint: hintFor(result.reason) }; +}, config.ttl.downloads); + +export interface VpnStatus { + provider: string; + server: string; + /** True when Transmission answered RPC — proof the tunnelled container works. */ + connected: boolean; +} + +/** + * VPN card. The design mocked provider/region/latency; provider and server come + * from the same variables Transmission itself uses, so there is nothing extra + * to configure. Latency isn't knowable from outside the container, so it isn't + * shown rather than being invented. + */ +export const getVpn = memoize>( + () => + safely(async () => { + // Reachability is the signal here, so an RPC failure is an answer rather + // than an error — the card still renders, just not as secured. + const connected = await rpc({ method: 'session-get' }).then( + () => true, + () => false, + ); + return { + provider: config.vpn.provider.toUpperCase(), + server: config.vpn.server.toUpperCase(), + connected, + }; + }), + config.ttl.downloads, +); + +export const __test = { formatSpeed, formatEta, attribute, hintFor }; diff --git a/dashboard/server/src/sources/upcoming.ts b/dashboard/server/src/sources/upcoming.ts new file mode 100644 index 0000000..7f7fc49 --- /dev/null +++ b/dashboard/server/src/sources/upcoming.ts @@ -0,0 +1,47 @@ +import { config } from '../config.js'; +import { memoize } from '../cache.js'; +import { credentialFor } from '../discovery.js'; +import { safely, unavailable, type Result } from '../http.js'; +import { calendar, type CalendarEpisode } from './arr.js'; + +/** + * The Dashboard's four-item "Upcoming" peek. The full calendar page consumes + * `calendar()` directly over a wider range. + */ + +export interface UpcomingItem extends CalendarEpisode { + /** Day of month, for the date chip. */ + day: number; + /** Uppercase short month, e.g. JUL. */ + month: string; +} + +async function load(): Promise<{ items: UpcomingItem[] }> { + const start = new Date(); + const end = new Date(start.getTime() + 21 * 24 * 60 * 60 * 1000); + + const episodes = await calendar(start, end); + + const items = episodes + .filter((episode) => episode.airDate) + .sort((a, b) => new Date(a.airDate).getTime() - new Date(b.airDate).getTime()) + .slice(0, 4) + .map((episode) => { + const date = new Date(episode.airDate); + return { + ...episode, + day: date.getDate(), + month: date.toLocaleString(undefined, { month: 'short' }).toUpperCase(), + }; + }); + + return { items }; +} + +export const getUpcoming = memoize>(async () => { + const credential = await credentialFor('sonarr'); + if (credential.state !== 'live') { + return unavailable('Waiting for Sonarr', credential.hint ?? undefined); + } + return safely(load); +}, config.ttl.calendar); diff --git a/dashboard/web/src/app/App.tsx b/dashboard/web/src/app/App.tsx index 35a7b57..a3cff0d 100644 --- a/dashboard/web/src/app/App.tsx +++ b/dashboard/web/src/app/App.tsx @@ -1,28 +1,36 @@ -import { useMemo } from 'react'; -import { WarningCircle } from '@phosphor-icons/react'; +import { useMemo, useState } from 'react'; +import { GridNine, Sliders, SquaresFour, WarningCircle } from '@phosphor-icons/react'; import { Header } from './Header'; import { Sidebar } from './Sidebar'; import { Launcher } from '../views/Launcher'; +import { CommandCenter } from '../views/CommandCenter'; +import { Setup } from '../views/Setup'; import { StackHealth } from '../components/StackHealth'; +import { Gauges } from '../components/Gauges'; import { usePolled } from '../hooks/usePolled'; import { useTheme } from '../hooks/useTheme'; -import type { HealthReport, ServiceGroup } from '../types'; +import type { Gauge, HealthReport, Result, ServiceGroup, VpnStatus } from '../types'; interface ServicesResponse { groups: readonly { id: ServiceGroup; label: string }[]; services: HealthReport['services']; } +type View = 'command' | 'launcher' | 'setup'; + const HEALTH_POLL_MS = 10_000; export function App() { const [theme, toggleTheme] = useTheme(); + const [view, setView] = useState('command'); // The catalog is static, so it's fetched once and never polled; only live // container state is refreshed. const catalog = usePolled('/api/services', 60 * 60 * 1000); const health = usePolled('/api/health', HEALTH_POLL_MS); + const metrics = usePolled>('/api/metrics', 15_000); + const vpn = usePolled>('/api/vpn', 30_000); const groups = catalog.data?.groups ?? []; const services = health.data?.services ?? catalog.data?.services ?? []; @@ -53,21 +61,60 @@ export function App() { fontFamily: 'var(--font-body)', }} > - +
-
+
-
- +
+ + +
+ {/* The resource strip stays on both data views — it's the "is the box OK" line. */} + {view !== 'setup' && ( +
+ + +
+ )} + {/* A failed refresh keeps the last good data on screen, so this banner reports the staleness rather than replacing the dashboard with an @@ -90,13 +137,16 @@ export function App() {
)} - {catalog.loading ? ( - - Loading services… - - ) : ( - - )} + {view === 'command' && } + {view === 'launcher' && + (catalog.loading ? ( + + Loading services… + + ) : ( + + ))} + {view === 'setup' && }
diff --git a/dashboard/web/src/app/Sidebar.tsx b/dashboard/web/src/app/Sidebar.tsx index 34ceb68..6034683 100644 --- a/dashboard/web/src/app/Sidebar.tsx +++ b/dashboard/web/src/app/Sidebar.tsx @@ -2,14 +2,15 @@ import { Shield, ShieldWarning } from '@phosphor-icons/react'; import { ServiceIcon } from '../components/ServiceIcon'; import { StatusDot } from '../components/StatusDot'; -import { serviceUrl, type ServiceGroup, type ServiceStatus } from '../types'; +import { serviceUrl, type Result, type ServiceGroup, type ServiceStatus, type VpnStatus } from '../types'; interface Props { services: ServiceStatus[]; groups: readonly { id: ServiceGroup; label: string }[]; + vpn: Result | null; } -export function Sidebar({ services, groups }: Props) { +export function Sidebar({ services, groups, vpn }: Props) { const transmission = services.find((service) => service.id === 'transmission'); return ( @@ -99,7 +100,7 @@ export function Sidebar({ services, groups }: Props) {
- {transmission && } + {transmission && } ); } @@ -140,36 +141,65 @@ function SidebarLink({ service }: { service: ServiceStatus }) { } /** - * Transmission's container state. + * Transmission's state, and the VPN it's configured against. * - * The design mocked this up as a VPN status card with provider, region and - * latency. None of that is knowable from outside the container, and — more - * importantly — a running container is *not* proof that the tunnel came up or - * that egress is protected. haugene/transmission-openvpn does gate traffic on - * the tunnel, but this card can only observe the container, so it reports - * exactly that and claims nothing about protection. Provider and server names - * arrive in phase 2 from OPENVPN_PROVIDER / OPENVPN_CONFIG. + * The design mocked this as a VPN status card with provider, region and + * latency. Two things it deliberately does not do: + * + * - It does not claim the tunnel is up. Neither a running container nor a + * responding RPC proves the tunnel established or that egress is protected. + * haugene/transmission-openvpn does gate traffic on the tunnel, but this + * card can only observe from outside, so it reports what it observed and + * claims nothing more. + * - It does not show latency, which isn't knowable from out here. The provider + * and server names are real — they come from the same OPENVPN_* variables + * Transmission itself reads — so they're shown as configuration, not status. */ -function VpnCard({ transmission }: { transmission: ServiceStatus }) { +function VpnCard({ + transmission, + vpn, +}: { + transmission: ServiceStatus; + vpn: Result | null; +}) { const running = transmission.state === 'up'; + const responding = vpn?.available ? vpn.connected : false; + const healthy = running && responding; + + const detail = !running + ? 'Container not running' + : responding + ? 'Container running · RPC responding' + : 'Container running · RPC unreachable'; + + const tags = vpn?.available ? [vpn.provider, vpn.server].filter(Boolean) : []; return (
- {running ? ( + {healthy ? ( ) : ( )} -
+
Transmission
- {running ? 'Container running · VPN image' : 'Container not running'} + {detail}
+ {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )}
); } diff --git a/dashboard/web/src/components/Gauges.tsx b/dashboard/web/src/components/Gauges.tsx new file mode 100644 index 0000000..f9a629b --- /dev/null +++ b/dashboard/web/src/components/Gauges.tsx @@ -0,0 +1,121 @@ +import { Cpu, HardDrives, Memory, WifiHigh, type Icon } from '@phosphor-icons/react'; + +import { PanelEmpty } from './Panel'; +import type { Gauge, Result } from '../types'; + +const ICON: Record = { + cpu: Cpu, + memory: Memory, + storage: HardDrives, + network: WifiHigh, +}; + +const HUE: Record = { + cpu: 'var(--ap-green)', + memory: 'var(--ap-cyan)', + storage: 'var(--ap-amber)', + network: 'var(--ap-violet)', +}; + +interface Props { + data: Result<{ gauges: Gauge[] }> | null; + loading: boolean; +} + +/** The four resource gauges. Placeholders keep the strip's layout stable. */ +export function Gauges({ data, loading }: Props) { + if (!data?.available) { + const ids: Gauge['id'][] = ['cpu', 'memory', 'storage', 'network']; + return ( + <> + {ids.map((id) => ( +
+ {id === 'cpu' && !loading && data && !data.available ? ( + + ) : ( + + {loading ? 'Loading…' : '—'} + + )} +
+ ))} + + ); + } + + return ( + <> + {data.gauges.map((gauge) => ( + + ))} + + ); +} + +function GaugeCard({ gauge }: { gauge: Gauge }) { + const color = HUE[gauge.id]; + const Icon = ICON[gauge.id]; + // The ring is a conic-gradient sweep, as in the design prototype. A metric + // with no ceiling (throughput) shows an unfilled track instead of a lie. + const degrees = gauge.fraction === null ? 0 : Math.round(gauge.fraction * 360); + + return ( +
+ +
+
+ {gauge.label} +
+
+ {gauge.value} +
+
+ {gauge.sub} +
+
+
+ ); +} diff --git a/dashboard/web/src/components/Panel.tsx b/dashboard/web/src/components/Panel.tsx new file mode 100644 index 0000000..70f219b --- /dev/null +++ b/dashboard/web/src/components/Panel.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from 'react'; +import { Info } from '@phosphor-icons/react'; + +import type { Result } from '../types'; + +interface PanelProps { + title: string; + /** Small grey label next to the title, naming the upstream service. */ + source?: string; + icon?: ReactNode; + /** Right-aligned content in the header row. */ + aside?: ReactNode; + span: number; + children: ReactNode; +} + +/** A Command Center card. `span` is in 12-column grid units. */ +export function Panel({ title, source, icon, aside, span, children }: PanelProps) { + return ( +
+
+ {icon} +

{title}

+ {source && ( + + {source} + + )} +
+ {aside} +
+ {children} +
+ ); +} + +/** + * What a panel shows when its upstream isn't usable yet. + * + * This is the visible half of the "one dead upstream never blanks the page" + * rule: it states the reason and, where there is one, the single concrete step + * that fixes it — so nobody has to go read docs to find out what's missing. + */ +export function PanelEmpty({ result }: { result: { reason: string; hint?: string } }) { + return ( +
+ +
+
{result.reason}
+ {result.hint && ( +
+ {result.hint} +
+ )} +
+
+ ); +} + +/** Placeholder while a panel's first request is in flight. */ +export function PanelLoading() { + return ( + + Loading… + + ); +} + +/** Renders data, an empty state, or a loader — the three states every panel has. */ +export function PanelBody({ + data, + loading, + empty, + children, +}: { + data: Result | null; + loading: boolean; + /** Shown when the upstream is fine but has nothing to report. */ + empty?: string; + children: (value: T) => ReactNode; +}) { + if (loading && !data) return ; + if (!data) return ; + if (!data.available) return ; + + const rendered = children(data); + if (empty && Array.isArray(rendered) && rendered.length === 0) { + return ( + + {empty} + + ); + } + return <>{rendered}; +} diff --git a/dashboard/web/src/types.ts b/dashboard/web/src/types.ts index d8047ca..19b5f0b 100644 --- a/dashboard/web/src/types.ts +++ b/dashboard/web/src/types.ts @@ -61,3 +61,104 @@ export const STATE_LABEL: Record = { export function serviceUrl(port: number): string { return `${window.location.protocol}//${window.location.hostname}:${port}`; } + +// ---- Widget payloads (mirrors the server's source modules) ------------------ + +/** + * Every widget endpoint returns either data or a reason it can't. The + * discriminant lets each panel render its own empty state without any of them + * being able to fail the page. + */ +export type Result = (T & { available: true }) | { available: false; reason: string; hint?: string }; + +export interface Gauge { + id: 'cpu' | 'memory' | 'storage' | 'network'; + label: string; + value: string; + sub: string; + fraction: number | null; +} + +export type StreamMode = 'Direct Play' | 'Direct Stream' | 'Transcode'; + +export interface Stream { + title: string; + meta: string; + mono: string; + user: string; + quality: string; + mode: StreamMode; + device: string; + player: string; + bandwidth: string; + percent: number; + elapsed: string; + total: string; +} + +export interface Download { + label: string; + source: 'SONARR' | 'RADARR' | 'OTHER'; + percent: number; + speed: string; + eta: string; +} + +export interface RequestItem { + title: string; + kind: 'Movie' | 'Series'; + user: string; + when: string; + status: 'Pending' | 'Approved' | 'Declined' | 'Downloading' | 'Available'; +} + +export interface UpcomingItem { + seriesTitle: string; + code: string; + title: string; + network: string; + airDate: string; + status: 'downloaded' | 'airing' | 'missing'; + day: number; + month: string; +} + +export interface ActivityItem { + kind: 'grab' | 'download' | 'upgrade' | 'other'; + text: string; + at: string; +} + +export interface VpnStatus { + provider: string; + server: string; + connected: boolean; +} + +export interface Integration { + source: string; + state: 'live' | 'waiting' | 'blocked'; + origin: 'env' | 'discovered' | 'none'; + hint: string | null; +} + +/** Stream mode hues, matching the prototype's `modeHue` logic. */ +export const MODE_HUE: Record = { + Transcode: 'var(--ap-amber)', + 'Direct Stream': 'var(--ap-cyan)', + 'Direct Play': 'var(--ap-green)', +}; + +export const REQUEST_HUE: Record = { + Pending: 'var(--ap-amber)', + Approved: 'var(--ap-cyan)', + Downloading: 'var(--ap-green)', + Available: 'var(--ap-cyan)', + Declined: 'var(--ap-red)', +}; + +export const UPCOMING_HUE: Record = { + downloaded: 'var(--ap-cyan)', + airing: 'var(--ap-amber)', + missing: 'var(--color-neutral-500)', +}; diff --git a/dashboard/web/src/views/CommandCenter.tsx b/dashboard/web/src/views/CommandCenter.tsx new file mode 100644 index 0000000..1f1a733 --- /dev/null +++ b/dashboard/web/src/views/CommandCenter.tsx @@ -0,0 +1,443 @@ +import { + ArrowDown, + ArrowsClockwise, + CheckCircle, + DownloadSimple, + Magnet, + PlayCircle, + PlusCircle, + ShieldCheck, + type Icon, +} from '@phosphor-icons/react'; + +import { Panel, PanelBody } from '../components/Panel'; +import { usePolled } from '../hooks/usePolled'; +import { + MODE_HUE, + REQUEST_HUE, + UPCOMING_HUE, + type ActivityItem, + type Download, + type RequestItem, + type Result, + type Stream, + type UpcomingItem, +} from '../types'; + +/** A tinted tag driven by the `--h` custom property, per the design's `.ap-tag`. */ +function Tag({ hue, children }: { hue: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Bar({ percent, hue, height = 5 }: { percent: number; hue: string; height?: number }) { + return ( +
+
+
+ ); +} + +export function CommandCenter() { + const streams = usePolled>( + '/api/streams', + 10_000, + ); + const downloads = usePolled>('/api/downloads', 10_000); + const upcoming = usePolled>('/api/upcoming', 5 * 60_000); + const requests = usePolled>('/api/requests', 60_000); + const activity = usePolled>('/api/activity', 60_000); + + return ( +
+ } + aside={ + streams.data?.available && streams.data.count > 0 ? ( + + + {streams.data.bandwidth} + + ) : null + } + > + + {(value) => + value.streams.length === 0 ? ( + + Nothing playing right now. + + ) : ( +
+ {value.streams.map((stream, index) => ( + + ))} +
+ ) + } +
+
+ + } + aside={ + + + VPN + + } + > + + {(value) => + value.downloads.length === 0 ? ( + + No active downloads. + + ) : ( +
+ {value.downloads.map((download) => ( + + ))} +
+ ) + } +
+
+ + + + {(value) => + value.items.length === 0 ? ( + + Nothing airing in the next three weeks. + + ) : ( +
+ {value.items.map((item, index) => ( + + ))} +
+ ) + } +
+
+ + 0 ? ( + {requests.data.pending} pending + ) : null + } + > + + {(value) => + value.requests.length === 0 ? ( + + No recent requests. + + ) : ( +
+ {value.requests.slice(0, 4).map((request, index) => ( + + ))} +
+ ) + } +
+
+ + + Recent + + } + > + + {(value) => + value.items.length === 0 ? ( + + Nothing to report yet. + + ) : ( +
+ {value.items.map((item, index) => ( + + ))} +
+ ) + } +
+
+
+ ); +} + +function StreamRow({ stream }: { stream: Stream }) { + const hue = MODE_HUE[stream.mode]; + return ( +
+ +
+
+ + {stream.title} + + + {stream.meta} + +
+
+ {stream.user} + {stream.quality && {stream.quality}} + {stream.mode} + {stream.device && ( + + {stream.device} + + )} +
+ +
+
+ {stream.elapsed} +
+ {stream.total} +
+
+ ); +} + +const SOURCE_HUE: Record = { + SONARR: 'var(--ap-cyan)', + RADARR: 'var(--ap-amber)', + OTHER: 'var(--color-neutral-500)', +}; + +function DownloadRow({ download }: { download: Download }) { + // Near-complete downloads go green regardless of source, matching the design. + const hue = download.percent > 90 ? 'var(--ap-green)' : SOURCE_HUE[download.source]; + return ( +
+
+ {download.source} + + {download.label} + +
+ +
+ + {download.percent}% · {download.speed} + + ETA {download.eta} +
+
+ ); +} + +function UpcomingRow({ item }: { item: UpcomingItem }) { + const hue = UPCOMING_HUE[item.status]; + return ( +
+
+
+ {item.day} +
+
+ {item.month} +
+
+
+
+
+ {item.seriesTitle} +
+
+ {item.code} + {item.network && ` · ${item.network}`} +
+
+
+ ); +} + +function RequestRow({ request }: { request: RequestItem }) { + return ( +
+ + ); +} + +const ACTIVITY_ICON: Record = { + grab: { Icon: Magnet, hue: 'var(--ap-cyan)' }, + download: { Icon: CheckCircle, hue: 'var(--ap-green)' }, + upgrade: { Icon: ArrowsClockwise, hue: 'var(--ap-violet)' }, + other: { Icon: PlusCircle, hue: 'var(--ap-amber)' }, +}; + +/** "8m" / "4h" / "3d", matching the design's compact feed timestamps. */ +function shortAgo(iso: string): string { + const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000); + if (!Number.isFinite(minutes) || minutes < 0) return ''; + if (minutes < 60) return `${Math.max(minutes, 1)}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +function ActivityRow({ item }: { item: ActivityItem }) { + const { Icon, hue } = ACTIVITY_ICON[item.kind]; + return ( +
+ +
+
+ {item.text} +
+
+ + {shortAgo(item.at)} + +
+ ); +} diff --git a/dashboard/web/src/views/Setup.tsx b/dashboard/web/src/views/Setup.tsx new file mode 100644 index 0000000..fd7f875 --- /dev/null +++ b/dashboard/web/src/views/Setup.tsx @@ -0,0 +1,100 @@ +import { CheckCircle, Clock, Warning } from '@phosphor-icons/react'; + +import { usePolled } from '../hooks/usePolled'; +import type { Integration } from '../types'; + +const LABEL: Record = { + sonarr: 'Sonarr', + radarr: 'Radarr', + prowlarr: 'Prowlarr', + bazarr: 'Bazarr', + tautulli: 'Tautulli', + seerr: 'Seerr', +}; + +const STATE = { + live: { Icon: CheckCircle, hue: 'var(--ap-green)', label: 'Connected' }, + waiting: { Icon: Clock, hue: 'var(--color-neutral-500)', label: 'Waiting' }, + blocked: { Icon: Warning, hue: 'var(--ap-amber)', label: 'Needs attention' }, +} as const; + +/** + * Shows which integrations are live and what's outstanding. + * + * The dashboard configures itself by reading each service's own config file, so + * this panel exists to make that legible: on a first boot the user can watch + * integrations connect themselves, and anything genuinely stuck names the one + * step that fixes it. Keys are never sent to the browser — only their state. + */ +export function Setup() { + // Polled on the same cadence as server-side discovery, so a service that has + // just written its config shows up here without a reload. + const { data, loading } = usePolled<{ integrations: Integration[] }>('/api/integrations', 30_000); + + const integrations = data?.integrations ?? []; + const live = integrations.filter((i) => i.state === 'live').length; + + return ( +
+
+

Integrations

+

+ The dashboard reads each service’s API key from the config file that service + writes, so there is nothing to paste. Anything still waiting will connect on its own + once that service has started for the first time. +

+ {!loading && ( +
+ {live} of {integrations.length} connected +
+ )} +
+ + {loading && !data ? ( + + Loading… + + ) : ( +
+ {integrations.map((integration) => { + const { Icon, hue, label } = STATE[integration.state]; + return ( +
+ +
+
+ + {LABEL[integration.source] ?? integration.source} + + + {label} + + {integration.origin === 'env' && ( + + override + + )} +
+ {integration.hint && ( +
+ {integration.hint} +
+ )} +
+
+ ); + })} +
+ )} +
+ ); +} From 4c09ddbc3709acf027b63e71e155eff7eeb7986c Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Tue, 28 Jul 2026 17:09:20 -0400 Subject: [PATCH 2/3] Address review feedback on the Command Center widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review, each a case of the UI claiming more than it knew. The Seerr pending count was derived from the 8 most-recent requests, so a household with more requests than that — or with older ones still waiting behind newer arrivals — saw a count that silently capped itself. It now comes from its own filter=pending query, reading pageInfo.results, and falls back to the visible rows if that call fails so a failed count can't cost the panel its list. VpnCard treated "the first /api/vpn response hasn't arrived" as "the RPC is unreachable", so it rendered a warning on every page load before anything had actually failed. Loading is now its own state, and the warning is reserved for a confirmed problem — an icon that clears itself a moment later teaches people to stop reading the card. PanelBody reported a transport failure as "No response yet", implying a request was still in flight when it had already failed. It now takes the polling error and names it, distinct from an upstream declining to answer (which arrives as a successful response carrying `available: false`). Co-Authored-By: Claude Opus 5 --- dashboard/server/src/sources/seerr.ts | 30 ++++++++++++- dashboard/server/src/sources/sources.test.ts | 44 ++++++++++++++++++++ dashboard/web/src/app/App.tsx | 2 +- dashboard/web/src/app/Sidebar.tsx | 30 +++++++++---- dashboard/web/src/components/Panel.tsx | 21 +++++++++- dashboard/web/src/views/CommandCenter.tsx | 10 ++--- 6 files changed, 119 insertions(+), 18 deletions(-) diff --git a/dashboard/server/src/sources/seerr.ts b/dashboard/server/src/sources/seerr.ts index 526ac3d..a98d168 100644 --- a/dashboard/server/src/sources/seerr.ts +++ b/dashboard/server/src/sources/seerr.ts @@ -98,6 +98,29 @@ async function titleFor( } } +/** + * The number of outstanding requests, from its own filtered query. + * + * Counting the `Pending` rows in the list below would cap the total at the page + * size, so a household with more than eight open requests — or with old ones + * still waiting behind newer arrivals — would see a count that quietly + * understates. `take=1` because only `pageInfo.results` is wanted; the row + * itself is discarded. + */ +async function pendingCount(apiKey: string, fallback: number): Promise { + try { + const page = await getJson( + `${config.upstream.seerr}/api/v1/request?filter=pending&take=1&skip=0`, + { 'X-Api-Key': apiKey }, + ); + return page.pageInfo?.results ?? fallback; + } catch { + // The list already loaded successfully; a failed count shouldn't cost the + // panel its rows, so fall back to what the visible page can prove. + return fallback; + } +} + async function load(): Promise { const credential = await credentialFor('seerr'); if (!credential.apiKey) throw new Error(credential.hint ?? 'Seerr API key not found yet'); @@ -126,7 +149,10 @@ async function load(): Promise { return { requests, - pending: requests.filter((r) => r.status === 'Pending').length, + pending: await pendingCount( + credential.apiKey, + requests.filter((r) => r.status === 'Pending').length, + ), }; } @@ -138,4 +164,4 @@ export const getRequests = memoize>(async () => { return safely(load); }, config.ttl.requests); -export const __test = { relative, statusOf }; +export const __test = { relative, statusOf, pendingCount }; diff --git a/dashboard/server/src/sources/sources.test.ts b/dashboard/server/src/sources/sources.test.ts index 2d60806..d9b04bb 100644 --- a/dashboard/server/src/sources/sources.test.ts +++ b/dashboard/server/src/sources/sources.test.ts @@ -118,6 +118,50 @@ test('an approved request that is processing reads as Downloading', () => { assert.equal(seerr.statusOf({ status: 3 }), 'Declined'); }); +test('the pending count comes from a filtered query, not the visible page', async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (url: string) => { + calls.push(String(url)); + return new Response(JSON.stringify({ pageInfo: { results: 23 }, results: [] }), { + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + + try { + // 23 outstanding, far beyond what the 8-row page could show. + assert.equal(await seerr.pendingCount('key', 3), 23); + assert.ok(calls[0]?.includes('filter=pending'), 'query must be filtered to pending'); + } finally { + globalThis.fetch = realFetch; + } +}); + +test('a failed or shapeless pending count falls back to the visible rows', async () => { + const realFetch = globalThis.fetch; + + globalThis.fetch = (async () => + new Response(JSON.stringify({ results: [] }), { + headers: { 'content-type': 'application/json' }, + })) as typeof fetch; + try { + // No pageInfo in the response — an older Seerr, or a changed shape. + assert.equal(await seerr.pendingCount('key', 3), 3); + } finally { + globalThis.fetch = realFetch; + } + + globalThis.fetch = (async () => { + throw new Error('connection refused'); + }) as typeof fetch; + try { + // The list already loaded; a failed count must not cost the panel its rows. + assert.equal(await seerr.pendingCount('key', 3), 3); + } finally { + globalThis.fetch = realFetch; + } +}); + // ---- Sonarr calendar ------------------------------------------------------- test('an episode with a file is downloaded regardless of air date', () => { diff --git a/dashboard/web/src/app/App.tsx b/dashboard/web/src/app/App.tsx index a3cff0d..3c678c1 100644 --- a/dashboard/web/src/app/App.tsx +++ b/dashboard/web/src/app/App.tsx @@ -61,7 +61,7 @@ export function App() { fontFamily: 'var(--font-body)', }} > - +
diff --git a/dashboard/web/src/app/Sidebar.tsx b/dashboard/web/src/app/Sidebar.tsx index 6034683..ec07f17 100644 --- a/dashboard/web/src/app/Sidebar.tsx +++ b/dashboard/web/src/app/Sidebar.tsx @@ -8,9 +8,11 @@ interface Props { services: ServiceStatus[]; groups: readonly { id: ServiceGroup; label: string }[]; vpn: Result | null; + /** True only until the first /api/vpn response — see `VpnCard`. */ + vpnLoading: boolean; } -export function Sidebar({ services, groups, vpn }: Props) { +export function Sidebar({ services, groups, vpn, vpnLoading }: Props) { const transmission = services.find((service) => service.id === 'transmission'); return ( @@ -100,7 +102,7 @@ export function Sidebar({ services, groups, vpn }: Props) {
- {transmission && } + {transmission && } ); } @@ -158,29 +160,39 @@ function SidebarLink({ service }: { service: ServiceStatus }) { function VpnCard({ transmission, vpn, + loading, }: { transmission: ServiceStatus; vpn: Result | null; + loading: boolean; }) { const running = transmission.state === 'up'; const responding = vpn?.available ? vpn.connected : false; - const healthy = running && responding; const detail = !running ? 'Container not running' - : responding - ? 'Container running · RPC responding' - : 'Container running · RPC unreachable'; + : loading + ? 'Container running · checking RPC' + : responding + ? 'Container running · RPC responding' + : 'Container running · RPC unreachable'; + + /* + * Warn only on a confirmed problem. Before the first /api/vpn response lands + * there is nothing to warn about, and a warning icon that clears itself a + * moment later teaches people to stop reading this card. + */ + const warn = !running || (!loading && !responding); const tags = vpn?.available ? [vpn.provider, vpn.server].filter(Boolean) : []; return (
- {healthy ? ( - - ) : ( + {warn ? ( + ) : ( + )}
diff --git a/dashboard/web/src/components/Panel.tsx b/dashboard/web/src/components/Panel.tsx index 70f219b..5873fe2 100644 --- a/dashboard/web/src/components/Panel.tsx +++ b/dashboard/web/src/components/Panel.tsx @@ -82,17 +82,36 @@ export function PanelLoading() { export function PanelBody({ data, loading, + error, empty, children, }: { data: Result | null; loading: boolean; + /** + * The polling error, if the request to this dashboard's own API failed. + * Distinct from `data.available === false`, which is the upstream declining + * to answer — that arrives as a successful response. + */ + error?: string | null; /** Shown when the upstream is fine but has nothing to report. */ empty?: string; children: (value: T) => ReactNode; }) { if (loading && !data) return ; - if (!data) return ; + // A transport failure leaves `data` null with `loading` false. Reporting that + // as "No response yet" would imply the request is still coming. + if (!data) { + return ( + + ); + } if (!data.available) return ; const rendered = children(data); diff --git a/dashboard/web/src/views/CommandCenter.tsx b/dashboard/web/src/views/CommandCenter.tsx index 1f1a733..df808e7 100644 --- a/dashboard/web/src/views/CommandCenter.tsx +++ b/dashboard/web/src/views/CommandCenter.tsx @@ -74,7 +74,7 @@ export function CommandCenter() { ) : null } > - + {(value) => value.streams.length === 0 ? ( @@ -102,7 +102,7 @@ export function CommandCenter() { } > - + {(value) => value.downloads.length === 0 ? ( @@ -120,7 +120,7 @@ export function CommandCenter() { - + {(value) => value.items.length === 0 ? ( @@ -147,7 +147,7 @@ export function CommandCenter() { ) : null } > - + {(value) => value.requests.length === 0 ? ( @@ -173,7 +173,7 @@ export function CommandCenter() { } > - + {(value) => value.items.length === 0 ? ( From cd0694bd65c6d89bc6b0795952a22237c4c027f4 Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Wed, 29 Jul 2026 10:42:20 -0400 Subject: [PATCH 3/3] Validate Seerr's pending count before trusting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getJson types the response body but doesn't validate it, so a successful but malformed reply — pageInfo.results as a string, a negative, a NaN — flowed straight through to the panel as the pending count. Check for a finite non-negative number and fall back to the visible rows otherwise, matching how the count already degrades on a failed request. Co-Authored-By: Claude Opus 5 --- dashboard/server/src/sources/seerr.ts | 6 +++++- dashboard/server/src/sources/sources.test.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/dashboard/server/src/sources/seerr.ts b/dashboard/server/src/sources/seerr.ts index a98d168..8dfff74 100644 --- a/dashboard/server/src/sources/seerr.ts +++ b/dashboard/server/src/sources/seerr.ts @@ -113,7 +113,11 @@ async function pendingCount(apiKey: string, fallback: number): Promise { `${config.upstream.seerr}/api/v1/request?filter=pending&take=1&skip=0`, { 'X-Api-Key': apiKey }, ); - return page.pageInfo?.results ?? fallback; + // `getJson` types the body but can't vouch for it, so a malformed count — + // a string, a negative, a NaN — falls back rather than reaching the panel. + const results = page.pageInfo?.results; + if (typeof results !== 'number' || !Number.isFinite(results) || results < 0) return fallback; + return results; } catch { // The list already loaded successfully; a failed count shouldn't cost the // panel its rows, so fall back to what the visible page can prove. diff --git a/dashboard/server/src/sources/sources.test.ts b/dashboard/server/src/sources/sources.test.ts index d9b04bb..ad27ce7 100644 --- a/dashboard/server/src/sources/sources.test.ts +++ b/dashboard/server/src/sources/sources.test.ts @@ -151,6 +151,20 @@ test('a failed or shapeless pending count falls back to the visible rows', async globalThis.fetch = realFetch; } + for (const results of ['23', -1, Number.NaN, null]) { + globalThis.fetch = (async () => + new Response(JSON.stringify({ pageInfo: { results } }), { + headers: { 'content-type': 'application/json' }, + })) as typeof fetch; + try { + // A 200 carrying a count that isn't a count at all — the body is typed, + // not validated, so the value has to be checked before the panel sees it. + assert.equal(await seerr.pendingCount('key', 3), 3, `results: ${String(results)}`); + } finally { + globalThis.fetch = realFetch; + } + } + globalThis.fetch = (async () => { throw new Error('connection refused'); }) as typeof fetch;