Compare commits

..

13 Commits

Author SHA1 Message Date
Developer 8c69911252 docs(unify-tasks): SDD proposal, design, and tasks
Design-only artifacts for unifying saved tasks on ssh_tasks services.
No implementation yet.

- proposal: two-path problem (Actions→machine vs widget→service), goals,
  non-goals, grilling decisions (SSH-only, service_task_runs only, keep override)
- design: shared run_saved_task helper, column rename, saved_task_runs dropped,
  API + frontend changes, 2-slice plan
- tasks: backend (shared runner + router) + frontend (Actions page)
2026-06-23 13:05:52 +00:00
Developer 7b3e2ebace Merge pull request 'chore: remove dead machine-level Jellyfin/Jellyseerr fields' (#13) from chore/remove-dead-machine-jellyfin-fields into main 2026-06-23 12:55:32 +00:00
Developer cfb9977532 chore: remove dead machine-level Jellyfin/Jellyseerr fields
Follow-up #1 to the service-registry change. Jellyfin/Jellyseerr now resolve
from the service registry, so the machine-level app fields are dead config.

- dependencies.py: drop dead _jellyseerr_client_for; simplify _resolve_machine
  to SSH-only.
- settings_store.py + routers/settings.py: remove jellyfin_*/jellyseerr_* from
  machine default config, get_machine_config, normalization, row mappers, and
  MachineInput.
- frontend types + Settings.tsx: drop the fields and the Jellyfin/Jellyseerr
  form sections + service options.
- Update frontend test fixtures.

Existing DB rows may still carry these keys in config_json; they are inert and
drop on the next machine save. Verification: backend ruff clean, pytest 222;
frontend lint 0 errors, build success, 70 tests.
2026-06-23 12:54:27 +00:00
Developer 802a9202e9 Merge pull request 'feat(services): select Jellyfin via jellyfin_service_id on the frontend' (#12) from feat/service-registry-jellyfin-services-frontend into main 2026-06-23 12:28:53 +00:00
Developer 7ab9b1ac59 style(tests): apply formatter to Applications and Media tests 2026-06-23 12:28:53 +00:00
Developer cbb703341e feat(services): select Jellyfin via jellyfin_service_id on the frontend
Slice 4b frontend half. Jellyfin-touching pages now select a Jellyfin service
instance instead of a machine.

- api/client.ts: Jellyfin-backed calls (counts/libraries/activity/users, media
  status/build/stop/force-stop, queryMedia) send jellyfin_service_id.
- hooks/useDashboard, useUsers, useMedia: selector param renamed to
  jellyfinServiceId.
- pages/Media + Applications: list jellyfin service instances and persist
  jellyfin_service_id in the URL.
- Dashboard (widgets) and Users (default instance) need no selector change.
- Update Applications + Media tests for the new hook/param.

Files/SSH transport keeps machine_id. Verification: frontend lint 0 errors,
build success, 70 tests; backend ruff clean, 222 tests.
2026-06-23 12:10:27 +00:00
Developer a13f560df2 Merge pull request 'feat(services): resolve Jellyfin/Jellyseerr from the service registry (backend)' (#11) from feat/service-registry-jellyfin-services-backend into main 2026-06-23 11:48:25 +00:00
Developer 5eb49be697 style(dependencies): apply formatter to dependencies rewrite 2026-06-23 11:48:25 +00:00
Developer 8ff735d644 feat(services): resolve Jellyfin/Jellyseerr from the service registry (backend)
Slice 4b backend half. Jellyfin and Jellyseerr clients are now resolved from
service instances instead of machine-level app config.

- Add jellyseerr service definition (6 service types total); add user_id to
  the Jellyfin service config.
- dependencies.py: jellyfin_service_id query param + _service_record
  (decrypt-on-read); get_jellyfin_client / get_jellyseerr_client / get_user_id
  resolve against the service registry (first enabled instance as fallback).
- SSH/Files transport (get_ssh_client) unchanged; still uses machine_id.
- Update service-registry tests for 6 types.

Selection model: split params — ?jellyfin_service_id= for Jellyfin/Jellyseerr,
?machine_id= for SSH/Files. Frontend threading follows in the next PR.

Verification: backend ruff clean, pytest 222 passed; frontend green (unchanged).
2026-06-23 11:43:33 +00:00
Developer d998e6ab0c Merge pull request 'feat(services): cleanup, services admin UI, docs' (#10) from feat/service-registry-cleanup-services-ui into main 2026-06-23 11:07:20 +00:00
Developer 9a6cbfae68 style(services): apply formatter to App and ServicesPage 2026-06-23 11:07:19 +00:00
Developer c9c72be0b6 feat(services): cleanup, services admin UI, docs
PR 4a of the runtime service registry change.

- Remove addon pages (/addons/:addonId, AddonPage, addons/*) superseded by
  service pages.
- Remove grafana_url/prometheus_url from backend config, compose, .env.example,
  and README (URLs now live on service records; VITE_ frontend deep-link vars
  retained).
- Add Services page (/services) with create/list/delete + sidebar nav, so
  services are configurable in the tool itself and service pages are reachable.
- Update docs/REQUIREMENTS.md service-registry section; add CHANGELOG.md with
  the breaking-upgrade note (MANAGE_ENCRYPTION_KEY required; grafana/prometheus
  env vars removed; default widget seeding removed).

Verification: backend ruff clean, pytest 222 passed; frontend lint 0 errors,
build success, 70 tests passed.
2026-06-23 10:57:30 +00:00
Developer 5ec35b4849 Merge pull request 'feat(services): frontend services runtime and widget rebind' (#9) from feat/service-registry-frontend-runtime into main 2026-06-22 19:13:59 +00:00
37 changed files with 1175 additions and 588 deletions
-2
View File
@@ -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
+66
View File
@@ -0,0 +1,66 @@
# 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).
## Follow-up #1 — remove dead machine Jellyfin/Jellyseerr fields
With Jellyfin/Jellyseerr now resolved from the service registry, the machine-level
Jellyfin/Jellyseerr fields are dead config. Removed from `dependencies.py` (dead
`_jellyseerr_client_for`; `_resolve_machine` simplified to SSH-only),
`services/settings_store.py`, `routers/settings.py` (`MachineInput`), frontend
types, the `Settings.tsx` form, and frontend test fixtures. Existing DB rows may
still carry these keys in `config_json`; they are inert and get dropped on the
next machine save. No data migration required.
+2 -4
View File
@@ -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`.
@@ -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 = ""
@@ -1,9 +1,12 @@
"""Dependency injection for FastAPI.
Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request
context. The selected machine can be chosen with a ``machine_id`` query
parameter; otherwise the backend falls back to the first enabled machine that
matches the requested service.
Provides access to service-specific Jellyfin/Jellyseerr clients and
machine-specific SSH clients via FastAPI's request context.
- Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query
parameter (resolved against the service registry); the backend falls back to
the first enabled ``jellyfin``/``jellyseerr`` service instance.
- SSH/Files transport is selected with ``machine_id`` as before.
"""
from __future__ import annotations
@@ -34,6 +37,41 @@ def _request_machine_id(request: Request | None) -> str | None:
return machine_id or None
def _request_jellyfin_service_id(request: Request | None) -> str | None:
if request is None:
return None
service_id = request.query_params.get("jellyfin_service_id")
return service_id or None
def _service_record(store: SettingsStore, service_type: str, service_id: str | None) -> dict[str, Any] | None:
"""Return a service row for a type, preferring the requested id.
The row carries an in-memory decrypted ``secrets`` dict. Returns None if no
enabled instance of the type exists.
"""
from media_library_viewer_api.services.secrets import decrypt_secrets
row = None
if service_id:
candidate = store.get_service(service_id)
if candidate and candidate.get("service_type") == service_type and candidate.get("enabled", True):
row = candidate
if row is None:
instances = [s for s in store.list_services(service_type) if s.get("enabled", True)]
row = instances[0] if instances else None
if row is None:
return None
decrypted = {}
blob = row.get("secrets") or {}
if blob:
try:
decrypted = decrypt_secrets(blob)
except Exception:
logger.exception("Failed to decrypt service secrets service_id=%s", row.get("id"))
return {**row, "secrets": decrypted}
@lru_cache(maxsize=32)
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
machine_id, url, api_key = cache_key
@@ -43,21 +81,6 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
return JellyfinClient(url, api_key)
@lru_cache(maxsize=32)
def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | None:
machine_id, url = cache_key
if not url:
return None
settings = get_settings_store().get_machine_config(machine_id) if machine_id else None
api_key = (settings or {}).get("jellyseerr_api_key") if settings else ""
if not api_key:
return None
logger.info(
"Creating Jellyseerr client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>"
)
return JellyseerrClient(url, api_key)
@lru_cache(maxsize=32)
def _ssh_client_for(
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
@@ -116,6 +139,10 @@ def _ssh_client_for(
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
"""Resolve an SSH/Files machine for the given transport service.
Jellyfin/Jellyseerr are resolved against the service registry, not here.
"""
store = get_settings_store()
machine_id = _request_machine_id(request)
if machine_id:
@@ -123,11 +150,7 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
if machine and (service in machine.get("services", []) or service == "ssh"):
return machine
return machine
if service == "jellyfin":
machines = store.list_machines_for_service("jellyfin")
elif service == "jellyseerr":
machines = [m for m in store.list_machines_for_service("jellyfin") if m.get("jellyseerr_url")]
elif service == "ssh":
if service == "ssh":
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
else:
machines = store.list_machines_for_service(service)
@@ -135,37 +158,34 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
"""Return a Jellyfin client for the selected machine."""
"""Return a Jellyfin client for the selected Jellyfin service instance."""
store = get_settings_store()
machine_id = _request_machine_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None
if machine is None:
resolved = _resolve_machine("jellyfin", request)
if resolved:
machine = store.get_machine_config(resolved["id"])
if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"):
cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "")
return _jellyfin_client_for(cache_key)
raise RuntimeError(
"No Jellyfin machine is configured. Add a machine with jellyfin_url and jellyfin_api_key in Settings."
)
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyfin", service_id)
if service is None:
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
base_url = str(service.get("config", {}).get("base_url") or "")
api_key = str(service.get("secrets", {}).get("api_key") or "")
if not base_url or not api_key:
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
cache_key = (service["id"], base_url, api_key)
return _jellyfin_client_for(cache_key)
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise None."""
store = get_settings_store()
machine_id = _request_machine_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None
if machine is None:
resolved = _resolve_machine("jellyseerr", request)
if resolved:
machine = store.get_machine_config(resolved["id"])
if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"):
return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "")
logger.info("Jellyseerr client not configured (no machine with jellyseerr_url and jellyseerr_api_key)")
return None
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyseerr", service_id)
if service is None:
logger.info("Jellyseerr client not configured (no jellyseerr service)")
return None
base_url = str(service.get("config", {}).get("base_url") or "")
api_key = str(service.get("secrets", {}).get("api_key") or "")
if not base_url or not api_key:
logger.info("Jellyseerr service is missing base_url or api_key")
return None
return JellyseerrClient(base_url, api_key)
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
@@ -253,16 +273,12 @@ def get_settings_store() -> SettingsStore:
def get_user_id(request: Request = None) -> str:
"""Return the configured Jellyfin user ID or discover the first available one."""
store = get_settings_store()
machine_id = _request_machine_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None
if machine is None:
resolved = _resolve_machine("jellyfin", request)
if resolved:
machine = store.get_machine_config(resolved["id"])
if machine and machine.get("jellyfin_user_id"):
return str(machine["jellyfin_user_id"])
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyfin", service_id)
if service and service.get("config", {}).get("user_id"):
return str(service["config"]["user_id"])
client = get_jellyfin_client(request)
users = client.users()
if not users:
raise RuntimeError("No Jellyfin users found and no machine/user id configured")
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
return users[0]["Id"]
@@ -0,0 +1,32 @@
"""Jellyseerr service definition.
Jellyseerr is a companion to Jellyfin (request management). It is modeled as its
own service type so multiple Jellyseerr instances are supported independently of
Jellyfin. It provides no dashboard widgets today.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
)
class JellyseerrConfig(ServiceConfigBase):
"""Non-secret Jellyseerr connection config."""
base_url: str
DEFINITION = ServiceDefinition(
service_type="jellyseerr",
name="Jellyseerr",
description="Request management companion to Jellyfin.",
config_model=JellyseerrConfig,
secret_fields=[
SecretField(key="api_key", label="API key", required=True),
],
widget_kinds=[],
)
@@ -9,6 +9,7 @@ from __future__ import annotations
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
@@ -17,6 +18,7 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
GRAFANA.service_type: GRAFANA,
PROMETHEUS.service_type: PROMETHEUS,
JELLYFIN.service_type: JELLYFIN,
JELLYSEERR.service_type: JELLYSEERR,
NEXTCLOUD.service_type: NEXTCLOUD,
SSH_TASKS.service_type: SSH_TASKS,
}
@@ -43,11 +43,6 @@ class MonitoringMachineInput(BaseModel):
password: str = ""
media_root: str = ""
path_prefix: str = ""
jellyfin_url: str = ""
jellyfin_user_id: str = ""
jellyfin_api_key: str = ""
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
notes: str = ""
@@ -44,11 +44,6 @@ def _default_local_machine() -> dict[str, Any]:
"password": "",
"media_root": settings.media_root,
"path_prefix": settings.path_prefix,
"jellyfin_url": "",
"jellyfin_user_id": "",
"jellyfin_api_key": "",
"jellyseerr_url": "",
"jellyseerr_api_key": "",
"node_exporter_enabled": False,
"node_exporter_port": 9100,
"node_exporter_scrape_host": "",
@@ -306,11 +301,6 @@ class SettingsStore:
"password_set": bool(data.get("password")),
"media_root": data.get("media_root", ""),
"path_prefix": data.get("path_prefix", ""),
"jellyfin_url": data.get("jellyfin_url", ""),
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
"jellyfin_api_key_set": bool(data.get("jellyfin_api_key")),
"jellyseerr_url": data.get("jellyseerr_url", ""),
"jellyseerr_api_key_set": bool(data.get("jellyseerr_api_key")),
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
@@ -360,17 +350,6 @@ class SettingsStore:
password = str(password or "")
media_root = _current_str("media_root")
path_prefix = _current_str("path_prefix")
jellyfin_url = _current_str("jellyfin_url")
jellyfin_user_id = _current_str("jellyfin_user_id")
jellyfin_api_key = payload.get("jellyfin_api_key")
if jellyfin_api_key in (None, ""):
jellyfin_api_key = (current or {}).get("jellyfin_api_key", "")
jellyfin_api_key = str(jellyfin_api_key or "")
jellyseerr_url = _current_str("jellyseerr_url")
jellyseerr_api_key = payload.get("jellyseerr_api_key")
if jellyseerr_api_key in (None, ""):
jellyseerr_api_key = (current or {}).get("jellyseerr_api_key", "")
jellyseerr_api_key = str(jellyseerr_api_key or "")
node_exporter_enabled = bool(
payload.get("node_exporter_enabled")
if payload.get("node_exporter_enabled") is not None
@@ -402,11 +381,6 @@ class SettingsStore:
"password": password,
"media_root": media_root,
"path_prefix": path_prefix,
"jellyfin_url": jellyfin_url,
"jellyfin_user_id": jellyfin_user_id,
"jellyfin_api_key": jellyfin_api_key,
"jellyseerr_url": jellyseerr_url,
"jellyseerr_api_key": jellyseerr_api_key,
"node_exporter_enabled": node_exporter_enabled,
"node_exporter_port": node_exporter_port,
"node_exporter_scrape_host": node_exporter_scrape_host,
@@ -430,11 +404,6 @@ class SettingsStore:
"password": "",
"media_root": machine["media_root"],
"path_prefix": machine["path_prefix"],
"jellyfin_url": machine["jellyfin_url"],
"jellyfin_user_id": machine["jellyfin_user_id"],
"jellyfin_api_key": machine["jellyfin_api_key"],
"jellyseerr_url": machine["jellyseerr_url"],
"jellyseerr_api_key": machine["jellyseerr_api_key"],
"node_exporter_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
@@ -520,11 +489,6 @@ class SettingsStore:
"password": data.get("password", ""),
"media_root": data.get("media_root", ""),
"path_prefix": data.get("path_prefix", ""),
"jellyfin_url": data.get("jellyfin_url", ""),
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
"jellyfin_api_key": data.get("jellyfin_api_key", ""),
"jellyseerr_url": data.get("jellyseerr_url", ""),
"jellyseerr_api_key": data.get("jellyseerr_api_key", ""),
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
@@ -564,11 +528,6 @@ class SettingsStore:
"password": machine["password"],
"media_root": machine["media_root"],
"path_prefix": machine["path_prefix"],
"jellyfin_url": machine["jellyfin_url"],
"jellyfin_user_id": machine["jellyfin_user_id"],
"jellyfin_api_key": machine["jellyfin_api_key"],
"jellyseerr_url": machine["jellyseerr_url"],
"jellyseerr_api_key": machine["jellyseerr_api_key"],
"node_exporter_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
+9 -1
View File
@@ -61,6 +61,7 @@ def test_registry_contains_five_service_types():
"grafana",
"prometheus",
"jellyfin",
"jellyseerr",
"nextcloud",
"ssh_tasks",
}
@@ -133,7 +134,14 @@ def test_list_service_types(client):
response = client.get("/api/services/types")
assert response.status_code == 200
types = {item["service_type"] for item in response.json()}
assert types == {"grafana", "prometheus", "jellyfin", "nextcloud", "ssh_tasks"}
assert types == {
"grafana",
"jellyfin",
"jellyseerr",
"nextcloud",
"prometheus",
"ssh_tasks",
}
def test_service_type_includes_secret_and_widget_metadata(client):
-2
View File
@@ -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"
-2
View File
@@ -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
+58 -33
View File
@@ -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.
+5 -3
View File
@@ -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,7 +453,7 @@ function AppInner() {
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
@@ -487,7 +489,7 @@ function AppInner() {
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
-37
View File
@@ -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 (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Grafana</h2>
<Card>
<CardHeader>
<CardTitle>Metrics & logs</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Open the full Grafana instance for dashboards, metrics, and log
exploration.
</p>
<Button asChild>
<a
href={grafanaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
Open Grafana
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -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 (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Prometheus</h2>
<Card>
<CardHeader>
<CardTitle>Metrics explorer</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Open Prometheus to run ad-hoc PromQL queries and inspect targets.
</p>
<Button asChild>
<a
href={prometheusUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
Open Prometheus
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
);
}
-29
View File
@@ -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 (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">SSH tasks</h2>
<Card>
<CardHeader>
<CardTitle>Saved actions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Create, edit, and run saved shell or Python tasks against local or
remote machines.
</p>
<Button onClick={() => navigate("/actions")}>
<Terminal className="mr-2 h-4 w-4" />
Open Actions
</Button>
</CardContent>
</Card>
</div>
);
}
-3
View File
@@ -1,3 +0,0 @@
export { GrafanaAddonPage } from "./GrafanaAddonPage";
export { PrometheusAddonPage } from "./PrometheusAddonPage";
export { SshTasksAddonPage } from "./SshTasksAddonPage";
+24 -22
View File
@@ -134,26 +134,26 @@ async function del<T>(path: string): Promise<T> {
return response.json();
}
// Dashboard
export const fetchCounts = (machineId?: string) =>
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
export const fetchCounts = (jellyfinServiceId?: string) =>
get<MediaCounts>(
"/api/dashboard/counts",
machineId ? { machine_id: machineId } : undefined,
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
export const fetchLibraries = (machineId?: string) =>
export const fetchLibraries = (jellyfinServiceId?: string) =>
get<LibraryCount[]>(
"/api/dashboard/libraries",
machineId ? { machine_id: machineId } : undefined,
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
export const fetchActivity = (machineId?: string) =>
export const fetchActivity = (jellyfinServiceId?: string) =>
get<NowPlayingSession[]>(
"/api/dashboard/activity",
machineId ? { machine_id: machineId } : undefined,
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
export const fetchUsers = (machineId?: string) =>
export const fetchUsers = (jellyfinServiceId?: string) =>
get<UserDirectoryResponse>(
"/api/users",
machineId ? { machine_id: machineId } : undefined,
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
// Backward-compatible alias used by older hooks/components.
@@ -293,27 +293,27 @@ export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
);
// Media
export const fetchMediaStatus = (machineId?: string) =>
export const fetchMediaStatus = (jellyfinServiceId?: string) =>
get<MediaIndexStatus>(
"/api/media/status",
machineId ? { machine_id: machineId } : undefined,
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
export const buildMediaIndex = (machineId?: string) =>
export const buildMediaIndex = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>(
machineId
? `/api/media/build?machine_id=${encodeURIComponent(machineId)}`
jellyfinServiceId
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/build",
);
export const stopMediaIndexBuild = (machineId?: string) =>
export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>(
machineId
? `/api/media/stop?machine_id=${encodeURIComponent(machineId)}`
jellyfinServiceId
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/stop",
);
export const forceStopMediaIndexBuild = (machineId?: string) =>
export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>(
machineId
? `/api/media/force-stop?machine_id=${encodeURIComponent(machineId)}`
jellyfinServiceId
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/force-stop",
);
export const queryMedia = (params: {
@@ -325,7 +325,7 @@ export const queryMedia = (params: {
sort_order?: string;
limit?: number;
offset?: number;
machineId?: string;
jellyfinServiceId?: string;
}) =>
get<MediaQueryResponse>("/api/media/query", {
libraries: params.libraries || "",
@@ -336,7 +336,9 @@ export const queryMedia = (params: {
sort_order: params.sort_order || "Ascending",
limit: String(params.limit || 100),
offset: String(params.offset || 0),
...(params.machineId ? { machine_id: params.machineId } : {}),
...(params.jellyfinServiceId
? { jellyfin_service_id: params.jellyfinServiceId }
: {}),
});
// Files
+9 -9
View File
@@ -9,26 +9,26 @@ import {
} from "../api/client";
import type { DashboardShortcutInput } from "../types";
export function useCounts(machineId?: string) {
export function useCounts(jellyfinServiceId?: string) {
return useQuery({
queryKey: ["dashboard", "counts", machineId ?? "default"],
queryFn: () => fetchCounts(machineId),
queryKey: ["dashboard", "counts", jellyfinServiceId ?? "default"],
queryFn: () => fetchCounts(jellyfinServiceId),
staleTime: 5 * 60 * 1000,
});
}
export function useLibraries(machineId?: string) {
export function useLibraries(jellyfinServiceId?: string) {
return useQuery({
queryKey: ["dashboard", "libraries", machineId ?? "default"],
queryFn: () => fetchLibraries(machineId),
queryKey: ["dashboard", "libraries", jellyfinServiceId ?? "default"],
queryFn: () => fetchLibraries(jellyfinServiceId),
staleTime: 5 * 60 * 1000,
});
}
export function useActivity(machineId?: string) {
export function useActivity(jellyfinServiceId?: string) {
return useQuery({
queryKey: ["dashboard", "activity", machineId ?? "default"],
queryFn: () => fetchActivity(machineId),
queryKey: ["dashboard", "activity", jellyfinServiceId ?? "default"],
queryFn: () => fetchActivity(jellyfinServiceId),
refetchInterval: 15_000,
});
}
+10 -10
View File
@@ -7,10 +7,10 @@ import {
forceStopMediaIndexBuild,
} from "../api/client";
export function useMediaStatus(machineId?: string) {
export function useMediaStatus(jellyfinServiceId?: string) {
return useQuery({
queryKey: ["media", "status", machineId ?? "default"],
queryFn: () => fetchMediaStatus(machineId),
queryKey: ["media", "status", jellyfinServiceId ?? "default"],
queryFn: () => fetchMediaStatus(jellyfinServiceId),
staleTime: 5_000,
refetchInterval: (query) =>
query.state.data?.build_running ? 1000 : false,
@@ -27,7 +27,7 @@ export function useMediaQuery(params: {
sort_order?: string;
limit?: number;
offset?: number;
machineId?: string;
jellyfinServiceId?: string;
enabled?: boolean;
}) {
const { enabled = true, ...queryParams } = params;
@@ -44,30 +44,30 @@ function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ["media"] });
}
export function useBuildIndex(machineId?: string) {
export function useBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => buildMediaIndex(machineId),
mutationFn: () => buildMediaIndex(jellyfinServiceId),
onSuccess: () => {
invalidateMedia(queryClient);
},
});
}
export function useStopBuildIndex(machineId?: string) {
export function useStopBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => stopMediaIndexBuild(machineId),
mutationFn: () => stopMediaIndexBuild(jellyfinServiceId),
onSuccess: () => {
invalidateMedia(queryClient);
},
});
}
export function useForceStopBuildIndex(machineId?: string) {
export function useForceStopBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => forceStopMediaIndexBuild(machineId),
mutationFn: () => forceStopMediaIndexBuild(jellyfinServiceId),
onSuccess: () => {
invalidateMedia(queryClient);
},
+3 -3
View File
@@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/client";
import type { UserDirectoryResponse } from "../types";
export function useUsers(machineId?: string) {
export function useUsers(jellyfinServiceId?: string) {
return useQuery<UserDirectoryResponse>({
queryKey: ["users", machineId ?? "default"],
queryFn: () => fetchUsers(machineId),
queryKey: ["users", jellyfinServiceId ?? "default"],
queryFn: () => fetchUsers(jellyfinServiceId),
staleTime: 30_000,
});
}
-28
View File
@@ -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<string, React.ComponentType> = {
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 (
<Alert>
<AlertDescription>Addon "{addonId}" is not installed.</AlertDescription>
</Alert>
);
}
return <Page />;
}
+10 -15
View File
@@ -1,28 +1,23 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { TabsTrigger } from "@/components/ui/tabs";
import { Media } from "./Media";
import { useCounts, useLibraries } from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings";
import { useServiceInstances } from "../hooks/useServices";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
function JellyfinLibraryStats() {
const [searchParams] = useSearchParams();
const { data: machines = [] } = useMonitoringSettings();
const jellyfinMachines = useMemo(
() =>
machines.filter(
(machine) => machine.enabled && machine.services.includes("jellyfin"),
),
[machines],
);
const selectedMachineId =
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
const { data: counts } = useCounts(selectedMachineId || undefined);
const { data: libraries } = useLibraries(selectedMachineId || undefined);
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const selectedServiceId =
searchParams.get("jellyfin_service_id") ||
jellyfinServices.find((s) => s.enabled)?.id ||
"";
const { data: counts } = useCounts(selectedServiceId || undefined);
const { data: libraries } = useLibraries(selectedServiceId || undefined);
return (
<SectionCard
@@ -30,7 +25,7 @@ function JellyfinLibraryStats() {
description="Compact Jellyfin summary for the selected machine."
action={
<Badge variant="outline">
{selectedMachineId ? "Selected machine" : "Default machine"}
{selectedServiceId ? "Selected service" : "Default service"}
</Badge>
}
>
+24 -29
View File
@@ -32,7 +32,7 @@ import {
} from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState";
import type { MediaItem } from "../types";
import { useMonitoringSettings } from "../hooks/useSettings";
import { useServiceInstances } from "../hooks/useServices";
import { useCounts, useLibraries } from "../hooks/useDashboard";
function formatDuration(seconds: number | null | undefined): string {
@@ -178,23 +178,18 @@ export function Media() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isSmall = usePrefersSmallScreen();
const { data: machines } = useMonitoringSettings();
const jellyfinMachines = useMemo(
() =>
(machines ?? []).filter(
(machine) => machine.enabled && machine.services.includes("jellyfin"),
),
[machines],
);
const selectedMachineId =
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
const { data: counts } = useCounts(selectedMachineId || undefined);
const { data: libraries } = useLibraries(selectedMachineId || undefined);
const { data: status } = useMediaStatus(selectedMachineId || undefined);
const buildIndex = useBuildIndex(selectedMachineId || undefined);
const stopBuildIndex = useStopBuildIndex(selectedMachineId || undefined);
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const selectedServiceId =
searchParams.get("jellyfin_service_id") ||
jellyfinServices.find((s) => s.enabled)?.id ||
"";
const { data: counts } = useCounts(selectedServiceId || undefined);
const { data: libraries } = useLibraries(selectedServiceId || undefined);
const { data: status } = useMediaStatus(selectedServiceId || undefined);
const buildIndex = useBuildIndex(selectedServiceId || undefined);
const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
const forceStopBuildIndex = useForceStopBuildIndex(
selectedMachineId || undefined,
selectedServiceId || undefined,
);
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
@@ -215,17 +210,17 @@ export function Media() {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
useEffect(() => {
if (!searchParams.get("machine_id") && selectedMachineId) {
if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("machine_id", selectedMachineId);
next.set("jellyfin_service_id", selectedServiceId);
return next;
},
{ replace: true },
);
}
}, [searchParams, selectedMachineId, setSearchParams]);
}, [searchParams, selectedServiceId, setSearchParams]);
const { data: queryResult, isLoading } = useMediaDataQuery({
types,
@@ -235,7 +230,7 @@ export function Media() {
sort_order: sortOrder,
limit: pageSize,
offset,
machineId: selectedMachineId || undefined,
jellyfinServiceId: selectedServiceId || undefined,
enabled: status?.exists ?? false,
});
@@ -323,27 +318,27 @@ export function Media() {
<div className="flex flex-row flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">Jellyfin</h2>
<div className="flex flex-col gap-1.5">
<Label htmlFor="media-machine">Machine</Label>
<Label htmlFor="media-service">Service</Label>
<Select
value={selectedMachineId}
value={selectedServiceId}
onValueChange={(value) =>
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("machine_id", value);
next.set("jellyfin_service_id", value);
return next;
},
{ replace: true },
)
}
>
<SelectTrigger id="media-machine" className="w-full md:w-[220px]">
<SelectValue placeholder="Select a machine" />
<SelectTrigger id="media-service" className="w-full md:w-[220px]">
<SelectValue placeholder="Select a service" />
</SelectTrigger>
<SelectContent>
{jellyfinMachines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
{jellyfinServices.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.name}
</SelectItem>
))}
</SelectContent>
+372
View File
@@ -0,0 +1,372 @@
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<string, unknown>;
secrets: Record<string, string>;
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 (
<div className="flex flex-col gap-1.5">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{helper ? (
<p className="text-xs text-muted-foreground">{helper}</p>
) : null}
</div>
);
}
function ServiceConfigFields({
type,
config,
onChange,
}: {
type: ServiceTypeInfo;
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
}) {
const properties =
(
type.config_schema as {
properties?: Record<string, { type?: string; description?: string }>;
}
).properties ?? {};
return (
<div className="flex flex-col gap-3">
{Object.entries(properties).map(([key, schema]) => {
const isNumber = schema.type === "integer" || schema.type === "number";
return (
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
<Input
id={`cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(config[key] ?? "")}
onChange={(e) =>
onChange({
...config,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</Field>
);
})}
</div>
);
}
function ServiceSecretFields({
fields,
secrets,
onChange,
}: {
fields: SecretFieldInfo[];
secrets: Record<string, string>;
onChange: (secrets: Record<string, string>) => void;
}) {
if (fields.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{fields.map((field) => (
<Field
key={field.key}
label={field.label}
htmlFor={`secret-${field.key}`}
helper={field.helper ?? (field.required ? "Required" : undefined)}
>
<Input
id={`secret-${field.key}`}
type="password"
value={secrets[field.key] ?? ""}
onChange={(e) =>
onChange({ ...secrets, [field.key]: e.target.value })
}
/>
</Field>
))}
</div>
);
}
function CreateServiceDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { data: types = [] } = useServiceTypes();
const saveService = useSaveServiceInstance();
const [draft, setDraft] = useState<CreateDraft | null>(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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) {
reset();
onClose();
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New service</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
{!draft ? (
<div className="flex flex-col gap-2">
{types.map((t) => (
<Button
key={t.service_type}
variant="outline"
onClick={() => setDraft(emptyDraft(t.service_type))}
>
<Plus className="mr-1 h-3 w-3" />
{t.name}
</Button>
))}
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
{selectedType?.description}
</p>
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</Field>
{selectedType ? (
<ServiceConfigFields
type={selectedType}
config={draft.config}
onChange={(config) => setDraft({ ...draft, config })}
/>
) : null}
{selectedType ? (
<ServiceSecretFields
fields={selectedType.secret_fields}
secrets={draft.secrets}
onChange={(secrets) => setDraft({ ...draft, secrets })}
/>
) : null}
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
</>
)}
</div>
{draft ? (
<DialogFooter
onCancel={reset}
onConfirm={save}
confirmLabel="Create service"
confirmDisabled={!draft.name.trim() || saveService.isPending}
/>
) : null}
</DialogContent>
</Dialog>
);
}
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<string | null>(null);
const grouped = useMemo(() => {
const map = new Map<string, ServiceInstance[]>();
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 (
<div className="flex flex-col gap-4">
<SectionCard
title="Services"
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
action={
<Button variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1 h-3 w-3" />
Add service
</Button>
}
>
{services.length === 0 ? (
<Alert>
<AlertDescription>
No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud,
or SSH task runner.
</AlertDescription>
</Alert>
) : (
<div className="flex flex-col gap-4">
{grouped.map(([serviceType, instances]) => (
<div key={serviceType} className="flex flex-col gap-2">
<div className="text-sm font-medium">
{typeName(serviceType)}
</div>
<div className="flex flex-col gap-2">
{instances.map((s) => (
<div
key={s.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{s.name}</span>
<Badge variant="outline">{s.service_type}</Badge>
{!s.enabled ? (
<Badge variant="secondary">disabled</Badge>
) : null}
{Object.entries(s.secrets_set).some(([, v]) => v) ? (
<Badge variant="outline">secrets set</Badge>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() =>
navigate(`/services/${s.service_type}/${s.id}`)
}
>
Open <ExternalLink className="ml-1 h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(s.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</SectionCard>
<CreateServiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
/>
<ConfirmDialog
open={Boolean(deleteId)}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (deleteId) deleteService.mutate(deleteId);
setDeleteId(null);
}}
/>
</div>
);
}
+2 -122
View File
@@ -52,8 +52,6 @@ import { Textarea } from "@/components/ui/textarea";
const SERVICE_OPTIONS = [
{ value: "monitoring", label: "Monitoring" },
{ value: "files", label: "Files" },
{ value: "jellyfin", label: "Jellyfin" },
{ value: "jellyseerr", label: "Jellyseerr" },
{ value: "nextcloud", label: "Nextcloud" },
];
@@ -114,7 +112,7 @@ function emptyMachine(
name: mode === "local" ? "This machine" : "",
mode,
enabled: true,
services: mode === "local" ? ["monitoring", "files", "jellyfin"] : [],
services: mode === "local" ? ["monitoring", "files"] : [],
host: "",
port: 22,
username: "",
@@ -126,11 +124,6 @@ function emptyMachine(
password: "",
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key: "",
jellyseerr_url: "",
jellyseerr_api_key: "",
notes: "",
};
}
@@ -169,8 +162,6 @@ function MachineEditor({
const isLocal = draft.mode === "local";
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
const enabledServices = draft.services.length;
const hasJellyfin = draft.services.includes("jellyfin");
const hasJellyseerr = draft.services.includes("jellyseerr");
const placeholderIfSet = (isSet: boolean | undefined) =>
isSet ? "Set, not shown" : undefined;
return (
@@ -398,99 +389,6 @@ function MachineEditor({
/>
</FormField>
</div>
{hasJellyfin && (
<>
<div className="col-span-12">
<SectionLabel
title="Jellyfin"
description="Library host and user selection for media browsing."
/>
</div>
<div className="col-span-12 md:col-span-6">
<FormField label="Jellyfin URL">
<Input
value={draft.jellyfin_url}
onChange={(e) =>
setDraft((current) => ({
...current,
jellyfin_url: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-6">
<FormField label="Jellyfin user ID">
<Input
value={draft.jellyfin_user_id}
onChange={(e) =>
setDraft((current) => ({
...current,
jellyfin_user_id: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-6">
<FormField label="Jellyfin API key">
<Input
type="password"
placeholder={placeholderIfSet(
editingMachine?.jellyfin_api_key_set,
)}
value={draft.jellyfin_api_key}
onChange={(e) =>
setDraft((current) => ({
...current,
jellyfin_api_key: e.target.value,
}))
}
/>
</FormField>
</div>
</>
)}
{hasJellyseerr && (
<>
<div className="col-span-12">
<SectionLabel
title="Jellyseerr"
description="Optional request-manager enrichment for users and requests."
/>
</div>
<div className="col-span-12 md:col-span-6">
<FormField label="Jellyseerr URL">
<Input
value={draft.jellyseerr_url}
onChange={(e) =>
setDraft((current) => ({
...current,
jellyseerr_url: e.target.value,
}))
}
/>
</FormField>
</div>
<div className="col-span-12 md:col-span-6">
<FormField label="Jellyseerr API key">
<Input
type="password"
placeholder={placeholderIfSet(
editingMachine?.jellyseerr_api_key_set,
)}
value={draft.jellyseerr_api_key}
onChange={(e) =>
setDraft((current) => ({
...current,
jellyseerr_api_key: e.target.value,
}))
}
/>
</FormField>
</div>
</>
)}
{isLocal && (
<div className="col-span-12 md:col-span-6">
<FormField label="Local hint">
@@ -538,7 +436,7 @@ function MachineEditor({
</AlertDescription>
</Alert>
) : null}
{!isLocal && !hasJellyfin && (
{!isLocal && enabledServices === 0 && (
<Alert>
<AlertDescription>
SSH machines usually need monitoring or files enabled.
@@ -584,13 +482,6 @@ function MachineEditor({
)}
</div>
)}
{hasJellyseerr && !draft.jellyseerr_url && (
<Alert>
<AlertDescription>
Jellyseerr is enabled, but no URL is configured yet.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
@@ -1146,11 +1037,6 @@ export function Settings() {
ssh_private_key_passphrase: "",
password: "",
media_root: machine.media_root,
jellyfin_url: machine.jellyfin_url,
jellyfin_user_id: machine.jellyfin_user_id,
jellyfin_api_key: "",
jellyseerr_url: machine.jellyseerr_url,
jellyseerr_api_key: "",
notes: machine.notes,
},
machine,
@@ -1237,12 +1123,6 @@ export function Settings() {
ssh_private_key_passphrase: "",
password: "",
media_root: selectedMachine.media_root,
jellyfin_url: selectedMachine.jellyfin_url,
jellyfin_user_id:
selectedMachine.jellyfin_user_id,
jellyfin_api_key: "",
jellyseerr_url: selectedMachine.jellyseerr_url,
jellyseerr_api_key: "",
notes: selectedMachine.notes,
},
selectedMachine,
@@ -48,11 +48,6 @@ function machine(
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
} as MonitoringMachine;
@@ -26,6 +26,14 @@ vi.mock("../../hooks/useSettings", () => ({
}),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: [
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({
data: { movies: 10, series: 5, episodes: 100 },
@@ -30,11 +30,6 @@ function machineFixture(
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
};
+12 -6
View File
@@ -34,11 +34,6 @@ function machineFixture(
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
};
@@ -103,7 +98,10 @@ let queryResult: MediaQueryResponse;
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
useSearchParams: () => [new URLSearchParams("machine_id=local"), vi.fn()],
useSearchParams: () => [
new URLSearchParams("jellyfin_service_id=jfs1"),
vi.fn(),
],
}));
vi.mock("../../hooks/useMedia", () => ({
@@ -118,6 +116,14 @@ vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [machineFixture()] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: [
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({ data: undefined }),
useLibraries: () => ({ data: undefined }),
@@ -53,11 +53,6 @@ function localMachine(
password_set: false,
media_root: "/mnt/media",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "Primary node",
...overrides,
} as MonitoringMachine;
-10
View File
@@ -175,11 +175,6 @@ export interface MonitoringMachine {
password_set: boolean;
media_root: string;
path_prefix: string;
jellyfin_url: string;
jellyfin_user_id: string;
jellyfin_api_key_set: boolean;
jellyseerr_url: string;
jellyseerr_api_key_set: boolean;
notes: string;
}
@@ -200,11 +195,6 @@ export interface MonitoringMachineInput {
password: string;
media_root: string;
path_prefix: string;
jellyfin_url: string;
jellyfin_user_id: string;
jellyfin_api_key: string;
jellyseerr_url: string;
jellyseerr_api_key: string;
notes: string;
}
@@ -1,87 +1,119 @@
# Apply Progress: Runtime Service Registry
**Change:** `service-registry`
**Apply run:** PR 1 + PR 2 + PR 3 (Slices 13)
**Apply run:** PRs #7#10 (Slices 14a)
**Date:** 2026-06-19
## Slice 1 — Backend service foundation (MERGED, PR #7)
## Slices 13 (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 2Backend 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 4aCleanup + 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
## Slice 4b — Jellyfin/Jellyseerr → services migration (in progress)
- `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.
### Completed (backend, this PR)
## Remaining work
- [x] Added `jellyseerr` service definition (`integrations/jellyseerr.py`) and
registered it (6 service types total).
- [x] Added `user_id` to the Jellyfin service config.
- [x] `dependencies.py`: new `_request_jellyfin_service_id` + `_service_record`
(decrypt-on-read). Rewrote `get_jellyfin_client`, `get_jellyseerr_client`,
and `get_user_id` to resolve against the service registry via the
`jellyfin_service_id` query param (first enabled instance as fallback).
- [x] SSH/Files transport (`get_ssh_client`) unchanged — still uses
`machine_id`.
- [x] Updated service-registry tests for 6 types.
- Slice 4: remove addon pages + machine app fields, remove
`grafana_url`/`prometheus_url` from config + compose, docs + changelog
(breaking upgrade note).
### Selection model (decided)
Split query params: `?jellyfin_service_id=` selects the Jellyfin/Jellyseerr
instance; `?machine_id=` selects SSH/Files transport. Pages that need both pass
both.
### Remaining (frontend, next PR)
- Thread `jellyfinServiceId` through Media / Applications / Dashboard / Users:
list `jellyfin` service instances instead of `useMonitoringSettings()`
Jellyfin machines; pass `jellyfin_service_id` to Jellyfin API calls.
- Files page keeps `machine_id`.
- Settings UI: remove machine-level Jellyfin/Jellyseerr fields.
- Remove machine app fields from `settings_store.py` + `routers/settings.py`
once the UI no longer writes them.
### Frontend half (this PR)
- [x] `api/client.ts`: Jellyfin-backed calls (`fetchCounts`, `fetchLibraries`,
`fetchActivity`, `fetchUsers`, Media status/build/stop/force-stop, and
`queryMedia`) now send `jellyfin_service_id` instead of `machine_id`.
- [x] `hooks/useDashboard.ts`, `hooks/useUsers.ts`, `hooks/useMedia.ts`: renamed
the selector param to `jellyfinServiceId`.
- [x] `pages/Media.tsx` + `pages/Applications.tsx`: select a `jellyfin` service
instance via `useServiceInstances("jellyfin")` and persist
`jellyfin_service_id` in the URL.
- [x] Dashboard (widget-based) and Users (default-instance) need no selector
change.
- [x] Updated Applications + Media tests for the new hook/param.
### Deferred (explicit follow-up)
- Remove machine-level Jellyfin/Jellyseerr fields from `settings_store.py`,
`routers/settings.py`, and the Settings UI. Low urgency now that the runtime
reads from services; the machine fields are simply unused for Jellyfin.
### Verification (backend half)
```bash
cd backend
.venv/bin/ruff check . # clean
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
cd ../frontend
npm run lint && npm run build && npm run test # green (unchanged)
```
@@ -0,0 +1,177 @@
# Design: Unify Saved Tasks on SSH Services
**Change:** `unify-tasks-on-services`
**Phase:** design
**Date:** 2026-06-19
## 1. Architecture overview
```
┌─────────────────────────────────────┐
│ saved_tasks (global, reusable) │
│ default_service_id → ssh_tasks │
└─────────────────────────────────────┘
│ │
Actions page │ │ SSH task widget
▼ ▼
┌─────────────────────────────────────┐
│ run_saved_task(store, task, svc) │ ← shared helper
│ build client → run → log │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ service_task_runs (one history) │
└─────────────────────────────────────┘
```
Both the Actions runner and the SSH task widget call one shared helper, so there
is a single execution path and a single history table.
## 2. Shared execution helper
New: `backend/src/media_library_viewer_api/services/task_runner.py`
```python
from dataclasses import dataclass
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord, _build_ssh_client
@dataclass
class TaskRunResult:
exit_status: int
stdout: str
stderr: str
duration_ms: int
status: str # "success" | "failure" | "timeout" | "error"
error: str
def run_saved_task(
store: SettingsStore,
task: dict,
service: ServiceRecord,
*,
request_id: str = "",
) -> TaskRunResult:
"""Run a saved task on an ssh_tasks service instance and log it.
Builds the SSH client from the service record, renders the command (shell or
python3 -c), runs it with the service's timeout, appends a service_task_runs
row, and returns the result.
"""
...
```
- The widget adapter (`SshTaskWidgetSource.fetch`) is refactored to call
`run_saved_task`, removing its inline copy.
- `routers/tasks.py` `run_task` calls `run_saved_task` instead of
`_client_for_machine` + `record_task_run`.
- `_build_ssh_client` (currently private in `widgets/sources.py`) is promoted to
the helper module or a shared location so both callers use it.
## 3. Data model changes
### 3.1 `saved_tasks`
```sql
-- default_machine_id replaced by default_service_id
ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id;
```
In SQLite (3.25+) `RENAME COLUMN` is supported. The column still stores an id,
now pointing at `services.id` (an `ssh_tasks` instance) instead of a machine.
### 3.2 `saved_task_runs` dropped
```sql
DROP TABLE IF EXISTS saved_task_runs;
```
All history lives in `service_task_runs` (added in the service-registry change).
The `record_task_run` / `list_task_runs` methods on `SettingsStore` are removed.
## 4. Backend API
### `routers/tasks.py`
| Method | Path | Change |
|--------|------|--------|
| GET | `/api/tasks` | Unchanged (task now carries `default_service_id`). |
| POST | `/api/tasks` | `TaskInput.default_service_id` replaces `default_machine_id`. |
| PUT | `/api/tasks/{id}` | Same field rename. |
| DELETE | `/api/tasks/{id}` | Unchanged. |
| GET | `/api/tasks/{id}/runs` | Reads `service_task_runs` (filtered by `task_id`). |
| POST | `/api/tasks/run?service_id=...` | `service_id` replaces `machine_id`; resolves an `ssh_tasks` service (override) or the task's `default_service_id`; calls `run_saved_task`. |
`_resolve_machine_for_task` and `_client_for_machine` are removed (replaced by
service resolution + the shared helper).
### Resolution + validation
- `run_task`: load the task; if `service_id` query param is given, use it
(override), else use `task.default_service_id`; load the `ssh_tasks` service
record; build a `ServiceRecord` (decrypt secrets); call `run_saved_task`.
- 400 if the task is disabled; 400 if no service resolves; 404 if the task or
service is missing.
## 5. Frontend
### 5.1 Types
`SavedTask` / `SavedTaskInput` / `SavedTaskRun` (`frontend/src/types/index.ts`):
- `default_machine_id``default_service_id`.
- `SavedTaskRun` fields align with `service_task_runs` (`service_id`,
`exit_status`, `stdout_tail`, …).
### 5.2 API client + hooks
- `runTask(taskId, serviceId?)` sends `service_id`.
- `fetchSavedTaskRuns(taskId)` reads `/api/tasks/{id}/runs` (now
`service_task_runs`-backed).
### 5.3 Actions page
- Task editor: "Default service" `<Select>` lists `ssh_tasks` service instances
(via `useServiceInstances("ssh_tasks")`), not machines.
- Run dialog: "Run on" `<Select>` lists `ssh_tasks` instances (override).
- Run history: reads the task's `service_task_runs`.
- `useMonitoringSettings` removed from the Actions page (no longer needed).
## 6. Migration and breaking changes
- **DB:** `saved_tasks.default_machine_id` renamed to `default_service_id`
(existing values become stale references to machine ids; inert — the user
re-points). `saved_task_runs` dropped.
- **Local execution removed.** Deployments relying on local tasks must use an
`ssh_tasks` service (e.g. pointing at localhost with a key).
- **Changelog + README** note the breaking change.
## 7. File-level plan
### Create (backend)
- `services/task_runner.py``run_saved_task` shared helper.
### Modify (backend)
- `services/settings_store.py` — rename column; drop `saved_task_runs` +
`record_task_run` / `list_task_runs` (task-run flavor).
- `routers/tasks.py` — service resolution; call `run_saved_task`; `service_id`
param; read `service_task_runs`.
- `widgets/sources.py``SshTaskWidgetSource.fetch` delegates to
`run_saved_task`.
### Modify (frontend)
- `types/index.ts` — field rename + `SavedTaskRun` alignment.
- `api/client.ts``runTask` sends `service_id`.
- `pages/Actions.tsx` — service selectors + history source.
## 8. Slice boundaries
1. **Backend**`run_saved_task` helper; saved_tasks column rename; tasks router
rewired; widget delegates; `saved_task_runs` dropped; tests.
2. **Frontend** — types + API + Actions page rewire; tests.
Estimated ~600800 changed lines across two PRs.
@@ -0,0 +1,78 @@
# Proposal: Unify Saved Tasks on SSH Services
**Change:** `unify-tasks-on-services`
**Phase:** proposal
**Date:** 2026-06-19
**Status:** awaiting review (design only — no implementation yet)
## Context and problem
Saved tasks (the Actions page) currently have **two execution paths**:
1. **Actions page** → resolves a *machine* (`default_machine_id`) → runs via
`_client_for_machine` → logs to `saved_task_runs`.
2. **SSH task widget** → resolves an `ssh_tasks` *service instance* → runs via
`_build_ssh_client` → logs to `service_task_runs`.
Same saved-task records, two runners, two history tables, two target models. This
is the leftover inconsistency from the service-registry change (design §12): the
widget was migrated to services but the Actions page was not.
## Proposal
Migrate the Actions page onto the same `ssh_tasks` service model the widget
already uses, so there is **one execution path** and **one history table**.
- Saved tasks gain `default_service_id` (replaces `default_machine_id`), pointing
at an `ssh_tasks` service instance.
- The Actions runner resolves an `ssh_tasks` service (the task's default, or an
explicit run-time override), builds the SSH client from the service record, runs
the task, and logs to `service_task_runs`.
- `saved_task_runs` is dropped; both the Actions page and the widget read
`service_task_runs`.
- Local (API-host) task execution is dropped — all tasks run over SSH against
`ssh_tasks` services.
## Goals
- One execution path for saved tasks (Actions page + widget share it).
- One run-history table (`service_task_runs`).
- Tasks target `ssh_tasks` service instances, consistent with the rest of the
service registry.
- Run-time override preserved: a task can be run against any `ssh_tasks` instance.
## Non-goals
- **No change to the jobs router** (`/api/jobs/run`, the `disk_usage` template,
etc.). That stays machine-based for the File Browser's on-demand SSH checks.
- **No machine/service unification** (follow-up #3). Machines still own File
Browser + node_exporter transport.
- **No local execution mode.** Dropped per decision; tasks are SSH-only.
- **No automatic data migration** of `default_machine_id``default_service_id`.
Break backwards compatibility (consistent with the service-registry change):
existing tasks lose their default target and the user re-points them.
## Decisions (from grilling)
| Topic | Decision |
|-------|----------|
| Local execution | **SSH-only.** Drop local mode; `ssh_tasks` services handle all task execution. |
| Run history | **`service_task_runs` only.** Drop `saved_task_runs`. |
| Run-time override | **Keep.** A task can run against any `ssh_tasks` instance at run time. |
## Risks
- **Breaking upgrade.** Existing tasks lose `default_machine_id`; users re-point
to an `ssh_tasks` service. Document in changelog.
- **Local-mode loss.** Any deployment relying on local task execution must set up
an SSH loopback (or an ssh_tasks service pointing at localhost with a key) to
keep running local tasks.
- **Shared execution code.** The Actions runner and the widget must share one
`run_saved_task` helper to avoid divergence; extracting it is the core refactor.
## Out of scope
- Machine/service unification (follow-up #3).
- Migrating the jobs router (`/api/jobs`) off machines.
- A UI for browsing `service_task_runs` across all services (the service page
already shows per-instance history; the Actions page shows per-task history).
@@ -0,0 +1,105 @@
# Tasks: Unify Saved Tasks on SSH Services
**Change:** `unify-tasks-on-services`
**Phase:** tasks
**Date:** 2026-06-19
## Review workload forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~600800 |
| Chained PRs recommended | Yes (2 PRs) |
| Chain strategy | stacked-to-main |
## Slice 1: Backend — shared runner + service-based tasks
**Goal:** One execution path; tasks target ssh_tasks services; one history table.
- [ ] **1.1 Add shared `run_saved_task` helper**
- Files: `backend/src/media_library_viewer_api/services/task_runner.py` (new)
- Lines: ~90
- Details: `run_saved_task(store, task, service, *, request_id)` builds the SSH
client from the service record (promote `_build_ssh_client`), renders the
command, runs with the service timeout, appends a `service_task_runs` row,
returns a `TaskRunResult`.
- [ ] **1.2 Rename saved_tasks column**
- Files: `services/settings_store.py` (modify)
- Lines: ~20
- Details: `default_machine_id``default_service_id` (ALTER TABLE RENAME
COLUMN on startup; update `_row_to_task`, `_normalize_task_payload`,
`upsert_task`).
- [ ] **1.3 Drop saved_task_runs**
- Files: `services/settings_store.py` (modify)
- Lines: ~-60
- Details: `DROP TABLE IF EXISTS saved_task_runs`; remove `record_task_run`
and `list_task_runs` (task flavor).
- [ ] **1.4 Rewire tasks router**
- Files: `routers/tasks.py` (modify)
- Lines: ~70
- Details: `TaskInput.default_service_id`; `run_task` takes `service_id`
(override), resolves an ssh_tasks service, calls `run_saved_task`;
`/api/tasks/{id}/runs` reads `service_task_runs`. Remove
`_resolve_machine_for_task` and `_client_for_machine`.
- [ ] **1.5 Widget delegates to shared helper**
- Files: `widgets/sources.py` (modify)
- Lines: ~-40
- Details: `SshTaskWidgetSource.fetch` calls `run_saved_task` instead of its
inline run+log block.
- [ ] **1.6 Add `list_service_task_runs` by task (if not present)**
- Files: `services/settings_store.py` (modify)
- Lines: ~10
- Details: Confirm `list_service_task_runs(task_id=...)` covers the tasks
router needs.
- [ ] **1.7 Update backend tests**
- Files: `backend/tests/test_jobs.py`, `test_api.py` (modify)
- Lines: ~60
- Details: Update task-run tests to the service model; cover override +
default + disabled-service paths.
- [ ] **1.8 Verify**
- Run: `cd backend && .venv/bin/ruff check . && PYTHONPATH=src .venv/bin/python -m pytest`
**Slice 1 total:** ~250 changed lines.
## Slice 2: Frontend — Actions page on services
**Goal:** Actions page targets ssh_tasks services; reads service_task_runs.
- [ ] **2.1 Update types**
- Files: `frontend/src/types/index.ts` (modify)
- Lines: ~15
- Details: `SavedTask` / `SavedTaskInput` `default_service_id`;
`SavedTaskRun` aligned to `service_task_runs`.
- [ ] **2.2 Update API client**
- Files: `frontend/src/api/client.ts` (modify)
- Lines: ~10
- Details: `runTask(taskId, serviceId?)` sends `service_id`.
- [ ] **2.3 Rewire Actions page**
- Files: `frontend/src/pages/Actions.tsx` (modify)
- Lines: ~120
- Details: Task editor "Default service" select lists ssh_tasks services via
`useServiceInstances("ssh_tasks")`; run dialog "Run on" selects an instance;
run history reads `service_task_runs`. Remove `useMonitoringSettings`.
- [ ] **2.4 Update Actions tests**
- Files: `frontend/src/pages/__tests__/Actions.test.tsx` (modify)
- Lines: ~30
- Details: Mock `useServiceInstances`; update fixtures.
- [ ] **2.5 Docs + changelog**
- Files: `docs/REQUIREMENTS.md`, `CHANGELOG.md` (modify)
- Lines: ~30
- Details: Saved-actions section: tasks target ssh_tasks services; local mode
dropped; breaking-upgrade note.
- [ ] **2.6 Verify**
- Run: `cd frontend && npm run lint && npm run build && npm run test`
**Slice 2 total:** ~200 changed lines.
## Integration and acceptance
- [ ] **3.1 Backend full test run**`PYTHONPATH=src pytest`, all green.
- [ ] **3.2 Frontend full build/lint/test**.
- [ ] **3.3 Manual dev-stack check**:
- Create an ssh_tasks service; create a task with that default; run from
Actions; see the run in both the Actions history and the service page.
- Override the target at run time.
- SSH task widget uses the same history.