Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` — 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.

Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 **<http://localhost:8090>** 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 **<http://localhost:8090>** 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

Expand Down
18 changes: 18 additions & 0 deletions dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| `<SERVICE>_URL` | Compose service name | Override an upstream's address, e.g. `SONARR_URL` |
| `<SERVICE>_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<T>` — 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 `<PanelBody>`,
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

Expand Down
46 changes: 46 additions & 0 deletions dashboard/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
71 changes: 71 additions & 0 deletions dashboard/server/src/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<Config>
<LogLevel>Info</LogLevel>
<Port>7878</Port>
<UrlBase></UrlBase>
<ApiKey>abc123def456</ApiKey>
<InstanceName>Radarr</InstanceName>
</Config>`;

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', () => {
// <ApiKeyBackup> must not satisfy a lookup for <ApiKey>.
const xml = '<Config><ApiKeyBackup>wrong</ApiKeyBackup><ApiKey>right</ApiKey></Config>';
assert.equal(xmlTag(xml, 'ApiKey'), 'right');
});

test('xmlTag reads a populated UrlBase', () => {
const xml = '<Config><UrlBase>/radarr</UrlBase></Config>';
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`);
}
});
Loading
Loading