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. -

- -
-
-
- ); -} diff --git a/frontend/src/addons/PrometheusAddonPage.tsx b/frontend/src/addons/PrometheusAddonPage.tsx deleted file mode 100644 index f1cd2a2..0000000 --- a/frontend/src/addons/PrometheusAddonPage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { ExternalLink } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; - -export function PrometheusAddonPage() { - const prometheusUrl = - (import.meta.env.VITE_PROMETHEUS_URL as string | undefined) || - "http://localhost:9090"; - - return ( -
-

Prometheus

- - - Metrics explorer - - -

- Open Prometheus to run ad-hoc PromQL queries and inspect targets. -

- -
-
-
- ); -} diff --git a/frontend/src/addons/SshTasksAddonPage.tsx b/frontend/src/addons/SshTasksAddonPage.tsx deleted file mode 100644 index 67f10f9..0000000 --- a/frontend/src/addons/SshTasksAddonPage.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Terminal } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useNavigate } from "react-router-dom"; - -export function SshTasksAddonPage() { - const navigate = useNavigate(); - - return ( -
-

SSH tasks

- - - Saved actions - - -

- Create, edit, and run saved shell or Python tasks against local or - remote machines. -

- -
-
-
- ); -} diff --git a/frontend/src/addons/index.ts b/frontend/src/addons/index.ts deleted file mode 100644 index b0578ef..0000000 --- a/frontend/src/addons/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { GrafanaAddonPage } from "./GrafanaAddonPage"; -export { PrometheusAddonPage } from "./PrometheusAddonPage"; -export { SshTasksAddonPage } from "./SshTasksAddonPage"; diff --git a/frontend/src/pages/AddonPage.tsx b/frontend/src/pages/AddonPage.tsx deleted file mode 100644 index a094b0a..0000000 --- a/frontend/src/pages/AddonPage.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useParams } from "react-router-dom"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { - GrafanaAddonPage, - PrometheusAddonPage, - SshTasksAddonPage, -} from "../addons"; - -const ADDON_PAGES: Record = { - grafana: GrafanaAddonPage, - prometheus: PrometheusAddonPage, - "ssh-tasks": SshTasksAddonPage, -}; - -export function AddonPage() { - const { addonId } = useParams<{ addonId: string }>(); - const Page = addonId ? ADDON_PAGES[addonId] : undefined; - - if (!Page) { - return ( - - Addon "{addonId}" is not installed. - - ); - } - - return ; -} diff --git a/frontend/src/pages/ServicesPage.tsx b/frontend/src/pages/ServicesPage.tsx new file mode 100644 index 0000000..2771257 --- /dev/null +++ b/frontend/src/pages/ServicesPage.tsx @@ -0,0 +1,342 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ExternalLink, Plus, Trash2 } from "lucide-react"; +import { + useDeleteServiceInstance, + useSaveServiceInstance, + useServiceInstances, +} from "../hooks/useServices"; +import { useServiceTypes } from "../hooks/useServices"; +import type { + SecretFieldInfo, + ServiceInstance, + ServiceInstanceInput, + ServiceTypeInfo, +} from "../types"; +import { SectionCard } from "../components/SectionCard"; +import { ConfirmDialog } from "../components/ConfirmDialog"; +import { DialogFooter } from "../components/DialogFooter"; +import { getServiceBinding } from "../integrations/registry"; + +interface CreateDraft { + serviceType: string; + name: string; + config: Record; + secrets: Record; + enabled: boolean; +} + +function emptyDraft(serviceType: string): CreateDraft { + return { serviceType, name: "", config: {}, secrets: {}, enabled: true }; +} + +function Field({ + label, + htmlFor, + helper, + children, +}: { + label: string; + htmlFor: string; + helper?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {helper ?

{helper}

: null} +
+ ); +} + +function ServiceConfigFields({ + type, + config, + onChange, +}: { + type: ServiceTypeInfo; + config: Record; + onChange: (config: Record) => void; +}) { + const properties = (type.config_schema as { properties?: Record }).properties ?? {}; + return ( +
+ {Object.entries(properties).map(([key, schema]) => { + const isNumber = schema.type === "integer" || schema.type === "number"; + return ( + + + onChange({ + ...config, + [key]: isNumber + ? e.target.value === "" + ? undefined + : Number(e.target.value) + : e.target.value, + }) + } + /> + + ); + })} +
+ ); +} + +function ServiceSecretFields({ + fields, + secrets, + onChange, +}: { + fields: SecretFieldInfo[]; + secrets: Record; + onChange: (secrets: Record) => void; +}) { + if (fields.length === 0) return null; + return ( +
+ {fields.map((field) => ( + + onChange({ ...secrets, [field.key]: e.target.value })} + /> + + ))} +
+ ); +} + +function CreateServiceDialog({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const { data: types = [] } = useServiceTypes(); + const saveService = useSaveServiceInstance(); + const [draft, setDraft] = useState(null); + + function reset() { + setDraft(null); + } + + async function save() { + if (!draft) return; + if (!draft.name.trim()) return; + const input: ServiceInstanceInput = { + service_type: draft.serviceType, + name: draft.name.trim(), + config: draft.config, + secrets: draft.secrets, + enabled: draft.enabled, + }; + await saveService.mutateAsync(input); + reset(); + onClose(); + } + + const selectedType = types.find((t) => t.service_type === draft?.serviceType); + + return ( + { + if (!next) { + reset(); + onClose(); + } + }} + > + + + New service + +
+ {!draft ? ( +
+ {types.map((t) => ( + + ))} +
+ ) : ( + <> +

{selectedType?.description}

+ + setDraft({ ...draft, name: e.target.value })} + /> + + {selectedType ? ( + setDraft({ ...draft, config })} + /> + ) : null} + {selectedType ? ( + setDraft({ ...draft, secrets })} + /> + ) : null} +
+ setDraft({ ...draft, enabled: checked })} + /> + +
+ + )} +
+ {draft ? ( + + ) : null} +
+
+ ); +} + +export function ServicesPage() { + const navigate = useNavigate(); + const { data: services = [] } = useServiceInstances(); + const { data: types = [] } = useServiceTypes(); + const deleteService = useDeleteServiceInstance(); + const [createOpen, setCreateOpen] = useState(false); + const [deleteId, setDeleteId] = useState(null); + + const grouped = useMemo(() => { + const map = new Map(); + for (const s of services) { + const list = map.get(s.service_type) ?? []; + list.push(s); + map.set(s.service_type, list); + } + return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0])); + }, [services]); + + const typeName = (t: string) => + types.find((x) => x.service_type === t)?.name ?? getServiceBinding(t)?.name ?? t; + + return ( +
+ setCreateOpen(true)}> + + Add service + + } + > + {services.length === 0 ? ( + + + No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud, or SSH task runner. + + + ) : ( +
+ {grouped.map(([serviceType, instances]) => ( +
+
{typeName(serviceType)}
+
+ {instances.map((s) => ( +
+
+
+ {s.name} + {s.service_type} + {!s.enabled ? disabled : null} + {Object.entries(s.secrets_set).some(([, v]) => v) ? ( + secrets set + ) : null} +
+
+
+ + +
+
+ ))} +
+
+ ))} +
+ )} +
+ + setCreateOpen(false)} /> + setDeleteId(null)} + onConfirm={() => { + if (deleteId) deleteService.mutate(deleteId); + setDeleteId(null); + }} + /> +
+ ); +} diff --git a/openspec/changes/service-registry/apply-progress.md b/openspec/changes/service-registry/apply-progress.md index 69244c4..b06ae0b 100644 --- a/openspec/changes/service-registry/apply-progress.md +++ b/openspec/changes/service-registry/apply-progress.md @@ -1,87 +1,66 @@ # Apply Progress: Runtime Service Registry **Change:** `service-registry` -**Apply run:** PR 1 + PR 2 + PR 3 (Slices 1–3) +**Apply run:** PRs #7–#10 (Slices 1–4a) **Date:** 2026-06-19 -## Slice 1 — Backend service foundation (MERGED, PR #7) +## Slices 1–3 (MERGED) -Fernet secrets, closed `integrations/` registry (Pydantic config + widget-config -for grafana/prometheus/jellyfin/nextcloud/ssh_tasks), `services` + -`service_task_runs` tables with cascade delete, `/api/services*` CRUD, -`MANAGE_ENCRYPTION_KEY` required at startup. +- Slice 1 (#7): backend service foundation — encryption, integrations registry, + services + service_task_runs tables, `/api/services*` CRUD. +- Slice 2 (#8): backend widget rebind — service_id + widget_kind, ServiceRecord + adapters, built-ins, SSH run logging, retired old widget registry. +- Slice 3 (#9): frontend services runtime — types/API/hooks, frontend registry, + ServicePage, route swap, reconciled widget components + config dialog. -## Slice 2 — Backend widget rebind (MERGED, PR #8) - -Widgets carry `service_id` + `widget_kind`; adapters take -`fetch(service: ServiceRecord | None, widget_kind, config)`; backups + static -stay as service-less built-ins; SSH adapter logs to `service_task_runs`; old -`widgets/registry.py` retired; default seeding removed. - -## Slice 3 — Frontend services runtime (this PR) +## Slice 4a — Cleanup + services admin UI + docs (this PR) ### Completed tasks -- [x] 3.1 Service + new widget TypeScript types (`ServiceInstance`, - `ServiceInstanceInput`, `ServiceTypeInfo`, `ServiceWidgetKindInfo`, - `SecretFieldInfo`, `BuiltinWidgetKindInfo`; widget gains `service_id` + - `widget_kind`). -- [x] 3.2 Services API + hooks (`api/services.ts`, `hooks/useServices.ts`). - Reconciled `api/widgets.ts` + `hooks/useWidgets.ts` to the new shape - (removed sources/types; added builtin kinds). -- [x] 3.3 Closed frontend service registry (`integrations/registry.ts`) - mirroring the backend; `resolveWidget(widget, services)` maps a widget to - its component + refresh interval. -- [x] 3.4 Service page at `/services/:serviceType/:serviceId` with config view, - empty-on-edit secret inputs + "set" badges, enable toggle, delete, and the - service's widget-kind list. -- [x] 3.5 Route swap: added `/services/:serviceType/:serviceId`; addon route - retained for now (removed in Slice 4 cleanup). -- [x] 3.6 Reconciled widget components to take `refreshIntervalMs` + - `description` props; rewrote `WidgetConfigDialog` around the - service → widget-kind picker (pulled 4.1 forward to keep the build whole). -- [x] 3.7 Registry + Dashboard tests updated; new - `integrations/registry.test.ts`. +- [x] Removed addon pages (`/addons/:addonId`, `AddonPage.tsx`, `addons/*`) — + superseded by service pages. +- [x] Removed `grafana_url` / `prometheus_url` from `config.py`, both compose + files, `.env.example`, and README. (Frontend `VITE_GRAFANA_URL` / + `VITE_PROMETHEUS_URL` deep-link vars retained.) +- [x] Added a **Services page** (`/services`) with create/list/delete and a nav + entry, so service pages are reachable and services are configurable in the + tool itself. +- [x] Registered `/services` route in both route trees + sidebar nav. +- [x] Updated `docs/REQUIREMENTS.md` (service registry section) and added + `CHANGELOG.md` with the breaking-upgrade note. ### Decision resolved mid-slice -Secret edit UX = **empty-on-edit + "set" badge** (blank = keep existing; typing -= replace). Applied on the ServicePage secrets card. +"Full machine migration" was scoped into **4a (cleanup) + 4b (Jellyfin/Jellyseerr +migration)** because removing machine-level Jellyfin/Jellyseerr fields is deeply +coupled to the Media/Users/Files pages (load-bearing) and there is no +`jellyseerr` service definition yet. 4a ships the safe cleanup + the services +admin UI; 4b does the machine-app-field migration as its own reviewable change. -### Files changed (Slice 3) +### Files changed (Slice 4a) -- New: `api/services.ts`, `hooks/useServices.ts`, `integrations/registry.ts`, - `integrations/registry.test.ts`, `pages/ServicePage.tsx`. -- Modified: `types/index.ts`, `api/widgets.ts`, `hooks/useWidgets.ts`, - `components/WidgetInstance.tsx`, `components/WidgetConfigDialog.tsx`, - `pages/Dashboard.tsx`, `pages/__tests__/Dashboard.test.tsx`, `App.tsx`, - all six `widgets/*.tsx` components, `widgets/index.ts`. -- Deleted: `widgets/registry.ts`, `widgets/registry.test.ts`. +- Backend: `config.py` (removed grafana_url/prometheus_url). +- Compose/env/docs: `docker-compose.yml`, `docker-compose.dev.yml`, + `.env.example`, `README.md`, `docs/REQUIREMENTS.md`, `CHANGELOG.md` (new). +- Frontend: new `pages/ServicesPage.tsx`; `App.tsx` (routes + nav); removed + `pages/AddonPage.tsx`, `addons/*`. -### Verification (Slice 3) +### Verification (Slice 4a) ```bash -cd frontend +cd backend +.venv/bin/ruff check . # clean +PYTHONPATH=src .venv/bin/python -m pytest # 222 passed +cd ../frontend npm run lint # 0 errors npm run build # success npm run test # 70 passed -cd ../backend -.venv/bin/ruff check . # clean -PYTHONPATH=src .venv/bin/python -m pytest # 222 passed ``` -### Deviations / notes - -- `WidgetConfigDialog` was rewritten in this slice (pulled forward from task - 4.1) because the old dialog imported the deleted widget registry and would - not compile. The SSH task-output widget keeps a dedicated task picker; other - widget configs use a generic schema-driven field editor. -- Addon pages (`/addons/:addonId`) are kept compiling but superseded by service - pages; Slice 4 removes them and the now-unused machine Jellyfin/Jellyseerr - fields + `grafana_url`/`prometheus_url` env vars, and writes the changelog. - ## Remaining work -- Slice 4: remove addon pages + machine app fields, remove - `grafana_url`/`prometheus_url` from config + compose, docs + changelog - (breaking upgrade note). +- Slice 4b: add `jellyseerr` service definition; rewire `dependencies.py` + Jellyfin/Jellyseerr resolution to the service registry; migrate the + Media/Users/Files/Dashboard selector from machine_id to service instance; + remove machine-level Jellyfin/Jellyseerr fields from `settings_store.py`, + `routers/settings.py`, and the Settings UI.