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..8dfff74 --- /dev/null +++ b/dashboard/server/src/sources/seerr.ts @@ -0,0 +1,171 @@ +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'; + } +} + +/** + * 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 }, + ); + // `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. + 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'); + + 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: await pendingCount( + credential.apiKey, + 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, pendingCount }; diff --git a/dashboard/server/src/sources/sources.test.ts b/dashboard/server/src/sources/sources.test.ts new file mode 100644 index 0000000..ad27ce7 --- /dev/null +++ b/dashboard/server/src/sources/sources.test.ts @@ -0,0 +1,211 @@ +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'); +}); + +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; + } + + 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; + 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', () => { + 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..3c678c1 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..ec07f17 100644 --- a/dashboard/web/src/app/Sidebar.tsx +++ b/dashboard/web/src/app/Sidebar.tsx @@ -2,14 +2,17 @@ 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; + /** True only until the first /api/vpn response — see `VpnCard`. */ + vpnLoading: boolean; } -export function Sidebar({ services, groups }: Props) { +export function Sidebar({ services, groups, vpn, vpnLoading }: Props) { const transmission = services.find((service) => service.id === 'transmission'); return ( @@ -99,7 +102,7 @@ export function Sidebar({ services, groups }: Props) {
- {transmission && } + {transmission && } ); } @@ -140,36 +143,75 @@ 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, + loading, +}: { + transmission: ServiceStatus; + vpn: Result | null; + loading: boolean; +}) { const running = transmission.state === 'up'; + const responding = vpn?.available ? vpn.connected : false; + + const detail = !running + ? 'Container not running' + : 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 (
- {running ? ( - - ) : ( + {warn ? ( + ) : ( + )} -
+
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..5873fe2 --- /dev/null +++ b/dashboard/web/src/components/Panel.tsx @@ -0,0 +1,126 @@ +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, + 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 ; + // 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); + 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..df808e7 --- /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} +
+ )} +
+
+ ); + })} +
+ )} +
+ ); +}