diff --git a/.env.example b/.env.example
index bbcab4c..3725a4f 100644
--- a/.env.example
+++ b/.env.example
@@ -27,8 +27,6 @@ PROMETHEUS_ENABLED=true
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL=http://alertmanager:9093
ALERTMANAGER_WEBHOOK_URL=
-GRAFANA_URL=http://grafana:3000
-PROMETHEUS_URL=http://prometheus:9090
# Required: master key for encrypting service secrets (API keys/tokens) at rest.
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..3b74dad
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,56 @@
+# Changelog
+
+All notable changes to Manage. Breaking changes are marked with **BREAKING**.
+
+## [Unreleased]
+
+### Added — Service registry
+
+- Runtime **service registry** persisted in the backend SQLite database. External
+ services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are now
+ configured in the app instead of via environment variables.
+- Services page (`/services`) to create, list, and delete service instances.
+- Service detail pages (`/services/:serviceType/:serviceId`) to edit name/enabled
+ state, rotate secrets, and view the widgets a service provides.
+- Service definitions live as Pydantic modules in `backend/.../integrations/`,
+ each declaring its config schema, secret fields, and widget kinds.
+- Multi-instance support: multiple Grafana/Jellyfin/etc. instances per type.
+- SSH task runner service records run history in a new `service_task_runs`
+ table, shown on the runner's service page.
+
+### Changed
+
+- Dashboard widgets are now **service-bound** (reference a service instance +
+ widget kind) or **built-in** (backups, static text). The "Add widget" flow is
+ pick-service → pick-widget-kind → configure.
+- Deleting a service cascade-deletes widgets that reference it.
+
+### Security
+
+- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
+ Fernet.
+
+### **BREAKING**
+
+- **`MANAGE_ENCRYPTION_KEY` is now required** to start the backend. Generate one
+ with:
+
+ ```bash
+ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+ ```
+
+- The `GRAFANA_URL` and `PROMETHEUS_URL` backend environment variables were
+ removed; Grafana/Prometheus URLs now live on service records configured in the
+ UI. Re-create them on the Services page after upgrading.
+- The legacy widget/addon-pages model (`/addons/:addonId`,
+ `/api/widgets/types`, `/api/widgets/sources`) was removed in favor of the
+ service registry.
+- Default dashboard widget seeding was removed; a fresh install starts with an
+ empty dashboard. Add widgets from the dashboard's edit dialog after
+ configuring services.
+
+### Notes / follow-ups
+
+- Machine-level Jellyfin/Jellyseerr app config still powers the Media/Users/Files
+ pages. Migrating those onto the service registry is a separate follow-up change
+ (see `openspec/changes/service-registry/design.md` §12.5).
diff --git a/README.md b/README.md
index b31a86a..ea11026 100644
--- a/README.md
+++ b/README.md
@@ -144,9 +144,7 @@ VITE_OIDC_SCOPE=openid profile email
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
-# Grafana / Prometheus URLs used by widget adapters and frontend deep-links
-GRAFANA_URL=http://grafana:3000
-PROMETHEUS_URL=http://prometheus:9090
+# Grafana / Prometheus public URLs for frontend deep-links (service adapters read URLs from service records)
VITE_GRAFANA_URL=https://grafana.manage.example.com
VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
@@ -186,4 +184,4 @@ cd frontend && npx tsc --noEmit && npm run build
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries.
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
-- Grafana and Prometheus widget adapters use `GRAFANA_URL` and `PROMETHEUS_URL` (backend) and `VITE_GRAFANA_URL` / `VITE_PROMETHEUS_URL` (frontend) for deep-links; no credentials are stored in widget config.
+- Grafana and Prometheus widget adapters resolve URLs from service records configured in the app; `VITE_GRAFANA_URL` / `VITE_PROMETHEUS_URL` are only used for frontend deep-links. No credentials are stored in widget config; service API keys are encrypted at rest with `MANAGE_ENCRYPTION_KEY`.
diff --git a/backend/src/media_library_viewer_api/config.py b/backend/src/media_library_viewer_api/config.py
index fac2884..7056b28 100644
--- a/backend/src/media_library_viewer_api/config.py
+++ b/backend/src/media_library_viewer_api/config.py
@@ -57,8 +57,6 @@ class Settings(BaseSettings):
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
alertmanager_url: str = "http://alertmanager:9093"
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
- grafana_url: str = "http://grafana:3000"
- prometheus_url: str = "http://prometheus:9090"
# Remote paths
remote_media_root: str = ""
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 5038064..b163424 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -17,8 +17,6 @@ services:
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
- GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
- PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?set MANAGE_ENCRYPTION_KEY in your .env}
ports:
- "8000:8000"
diff --git a/docker-compose.yml b/docker-compose.yml
index e2b6827..85362a1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -28,8 +28,6 @@ services:
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
- GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
- PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?generate one with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"}
volumes:
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md
index 2509bcd..4a3853e 100644
--- a/docs/REQUIREMENTS.md
+++ b/docs/REQUIREMENTS.md
@@ -256,54 +256,79 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
- Job templates should remain centralized in `jobs.py` for future extension.
- Remote job template values must be shell-quoted before execution.
-## Configurable Dashboard Widgets
+## Service Registry and Dashboard Widgets
### Overview
-The dashboard is composed of persisted widget instances stored in the backend SQLite
-settings database. Each widget has a type, title, configuration, enabled flag, and
-sort order. The frontend renders enabled widgets in sort order and fetches data
-independently through the backend source adapters.
+External services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are
+configured **in the app** and persisted in the backend SQLite database. Each
+service instance holds non-secret config plus encrypted secret fields. Dashboard
+widgets are either **service-bound** (reference a service instance + a widget
+kind declared by that service) or **built-in / service-less** (backups, static
+text).
-### Widget types
+Service definitions live as Pydantic modules in the backend
+(`integrations/`); they declare the service config schema, secret fields, and
+the widget kinds the service provides. There is no runtime plugin loading.
-- **Jellyfin activity** — live sessions and idle users from a configured Jellyfin machine.
-- **Backups** — backup job summary and active alerts.
-- **Grafana link** — deep-link to a Grafana dashboard or panel (no iframe embedding).
-- **Prometheus metric** — result of a PromQL instant query.
-- **SSH task output** — output of a saved task run on a machine.
+### Services
+
+- **Grafana** — base URL + optional API key; provides a dashboard-link widget.
+- **Prometheus** — base URL + optional bearer token; provides a PromQL metric widget.
+- **Jellyfin** — base URL + API key; provides a live-activity widget.
+- **Nextcloud** — base URL + app password (no widgets yet).
+- **SSH task runner** — host/port/username + saved SSH key reference + optional
+ passphrase; provides a task-output widget. Tasks stay in the global saved-task
+ registry; every run is recorded in `service_task_runs` as history.
+
+Multiple instances per service type are supported. Services are managed from the
+**Services** page (`/services`) and each instance has a detail page at
+`/services/:serviceType/:serviceId`.
+
+### Built-in widgets
+
+- **Backups** — internal backup job summary and active alerts.
- **Static text** — plain text or markdown note.
+These do not reference a service.
+
### Security
-- Widget `config` may not contain credential keys such as `password`, `token`,
- `secret`, `api_key`, `private_key`, or `passphrase`, or values that look like
- secrets (e.g., base64 blobs, `sk-` prefixes).
-- Widgets reuse machine-level Jellyfin/SSH credentials and environment settings for
- Grafana/Prometheus URLs; no secrets are stored in widget configuration.
-- SSH task widgets only run tasks from the saved-task registry; arbitrary commands
- are not accepted.
-
-### Addon pages
-
-Each non-core addon gets a dedicated page at `/addons/:addonId`:
-
-- `/addons/grafana`
-- `/addons/prometheus`
-- `/addons/ssh-tasks`
-
-Unknown addons render a "not installed" alert.
+- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
+ Fernet using a single env-provided `MANAGE_ENCRYPTION_KEY`, which is always
+ required to start the backend.
+- Widget `config` and service `config` may not contain credential keys or
+ secret-looking values; secrets go in the dedicated secret fields only.
+- Plaintext secrets are never returned by the API; only `secrets_set` flags are
+ surfaced.
+- SSH task widgets only run tasks from the saved-task registry; arbitrary
+ commands are not accepted.
### API
-- `GET /api/widgets/sources` — list source types.
-- `GET /api/widgets/types` — list widget type metadata.
+- `GET /api/services/types` — service definition metadata (config schema,
+ secret fields, widget kinds).
+- `GET /api/services/instances` — list service instances (no plaintext secrets).
+- `POST /api/services/instances` — create instance.
+- `PUT /api/services/instances/{id}` — update instance.
+- `DELETE /api/services/instances/{id}` — delete instance (cascade-deletes
+ widgets referencing it).
+- `GET /api/widgets/builtin` — built-in (service-less) widget kinds.
- `GET /api/widgets/instances` — list widget instances.
-- `POST /api/widgets/instances` — create instance.
-- `PUT /api/widgets/instances/{id}` — update instance.
-- `DELETE /api/widgets/instances/{id}` — delete instance.
+- `POST/PUT/DELETE /api/widgets/instances/{id}` — widget CRUD.
- `GET /api/widgets/instances/{id}/data` — fetch widget data.
+### Breaking change
+
+Grafana/Prometheus URLs and credentials moved from environment variables into
+service records. The legacy `GRAFANA_URL` / `PROMETHEUS_URL` backend settings and
+the widget/addon-pages model were removed. `MANAGE_ENCRYPTION_KEY` is now required.
+
+> **Follow-up (not in this change):** machine-level Jellyfin/Jellyseerr app
+> config still powers the Media/Users/Files pages. Migrating those onto the
+> service registry (and removing the machine app fields) is a separate change;
+> see `openspec/changes/service-registry/design.md` §12.5.
+
## Decision Log
- 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 1f285dc..f9b0a48 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -22,8 +22,8 @@ import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage";
-import { AddonPage } from "./pages/AddonPage";
import { ServicePage } from "./pages/ServicePage";
+import { ServicesPage } from "./pages/ServicesPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
@@ -57,6 +57,7 @@ import {
LogOut,
ChevronLeft,
ChevronRight,
+ Boxes,
} from "lucide-react";
const queryClient = new QueryClient({
@@ -90,6 +91,7 @@ const navItems = [
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap },
+ { path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
@@ -451,11 +453,8 @@ function AppInner() {
} />
} />
} />
- } />
- }
- />
+ } />
+ } />
@@ -487,11 +486,8 @@ function AppInner() {
} />
} />
} />
- } />
- }
- />
+ } />
+ } />
diff --git a/frontend/src/addons/GrafanaAddonPage.tsx b/frontend/src/addons/GrafanaAddonPage.tsx
deleted file mode 100644
index 894b41b..0000000
--- a/frontend/src/addons/GrafanaAddonPage.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { ExternalLink } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-
-export function GrafanaAddonPage() {
- const grafanaUrl =
- (import.meta.env.VITE_GRAFANA_URL as string | undefined) ||
- "http://localhost:3000";
-
- return (
-
-
Grafana
-
-
- Metrics & logs
-
-
-
- Open the full Grafana instance for dashboards, metrics, and log
- exploration.
-