Compare commits

..

12 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
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 PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL=http://alertmanager:9093 ALERTMANAGER_URL=http://alertmanager:9093
ALERTMANAGER_WEBHOOK_URL= 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. # 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())" # Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key 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_REDIRECT_URI=https://manage.example.com/oidc/callback
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
# Grafana / Prometheus URLs used by widget adapters and frontend deep-links # Grafana / Prometheus public URLs for frontend deep-links (service adapters read URLs from service records)
GRAFANA_URL=http://grafana:3000
PROMETHEUS_URL=http://prometheus:9090
VITE_GRAFANA_URL=https://grafana.manage.example.com VITE_GRAFANA_URL=https://grafana.manage.example.com
VITE_PROMETHEUS_URL=https://prometheus.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`. - 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. - 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. - 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" prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
alertmanager_url: str = "http://alertmanager:9093" alertmanager_url: str = "http://alertmanager:9093"
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications 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 paths
remote_media_root: str = "" remote_media_root: str = ""
@@ -1,9 +1,12 @@
"""Dependency injection for FastAPI. """Dependency injection for FastAPI.
Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request Provides access to service-specific Jellyfin/Jellyseerr clients and
context. The selected machine can be chosen with a ``machine_id`` query machine-specific SSH clients via FastAPI's request context.
parameter; otherwise the backend falls back to the first enabled machine that
matches the requested service. - 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 from __future__ import annotations
@@ -34,6 +37,41 @@ def _request_machine_id(request: Request | None) -> str | None:
return machine_id or 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) @lru_cache(maxsize=32)
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient: def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
machine_id, url, api_key = cache_key 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) 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) @lru_cache(maxsize=32)
def _ssh_client_for( def _ssh_client_for(
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None], 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: 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() store = get_settings_store()
machine_id = _request_machine_id(request) machine_id = _request_machine_id(request)
if machine_id: 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"): if machine and (service in machine.get("services", []) or service == "ssh"):
return machine return machine
return machine return machine
if service == "jellyfin": if service == "ssh":
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":
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring") machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
else: else:
machines = store.list_machines_for_service(service) 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: 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() store = get_settings_store()
machine_id = _request_machine_id(request) service_id = _request_jellyfin_service_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None service = _service_record(store, "jellyfin", service_id)
if machine is None: if service is None:
resolved = _resolve_machine("jellyfin", request) raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
if resolved: base_url = str(service.get("config", {}).get("base_url") or "")
machine = store.get_machine_config(resolved["id"]) api_key = str(service.get("secrets", {}).get("api_key") or "")
if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"): if not base_url or not api_key:
cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "") raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
return _jellyfin_client_for(cache_key) cache_key = (service["id"], base_url, api_key)
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."
)
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None: def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise None.""" """Return a cached Jellyseerr client when configured, otherwise None."""
store = get_settings_store() store = get_settings_store()
machine_id = _request_machine_id(request) service_id = _request_jellyfin_service_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None service = _service_record(store, "jellyseerr", service_id)
if machine is None: if service is None:
resolved = _resolve_machine("jellyseerr", request) logger.info("Jellyseerr client not configured (no jellyseerr service)")
if resolved: return None
machine = store.get_machine_config(resolved["id"]) base_url = str(service.get("config", {}).get("base_url") or "")
if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"): api_key = str(service.get("secrets", {}).get("api_key") or "")
return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "") if not base_url or not api_key:
logger.info("Jellyseerr service is missing base_url or api_key")
logger.info("Jellyseerr client not configured (no machine with jellyseerr_url and jellyseerr_api_key)") return None
return None return JellyseerrClient(base_url, api_key)
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient: 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: def get_user_id(request: Request = None) -> str:
"""Return the configured Jellyfin user ID or discover the first available one.""" """Return the configured Jellyfin user ID or discover the first available one."""
store = get_settings_store() store = get_settings_store()
machine_id = _request_machine_id(request) service_id = _request_jellyfin_service_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None service = _service_record(store, "jellyfin", service_id)
if machine is None: if service and service.get("config", {}).get("user_id"):
resolved = _resolve_machine("jellyfin", request) return str(service["config"]["user_id"])
if resolved:
machine = store.get_machine_config(resolved["id"])
if machine and machine.get("jellyfin_user_id"):
return str(machine["jellyfin_user_id"])
client = get_jellyfin_client(request) client = get_jellyfin_client(request)
users = client.users() users = client.users()
if not 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"] 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.base import ServiceDefinition, WidgetKind
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA 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.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.nextcloud import DEFINITION as NEXTCLOUD
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS 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, GRAFANA.service_type: GRAFANA,
PROMETHEUS.service_type: PROMETHEUS, PROMETHEUS.service_type: PROMETHEUS,
JELLYFIN.service_type: JELLYFIN, JELLYFIN.service_type: JELLYFIN,
JELLYSEERR.service_type: JELLYSEERR,
NEXTCLOUD.service_type: NEXTCLOUD, NEXTCLOUD.service_type: NEXTCLOUD,
SSH_TASKS.service_type: SSH_TASKS, SSH_TASKS.service_type: SSH_TASKS,
} }
@@ -43,11 +43,6 @@ class MonitoringMachineInput(BaseModel):
password: str = "" password: str = ""
media_root: str = "" media_root: str = ""
path_prefix: str = "" path_prefix: str = ""
jellyfin_url: str = ""
jellyfin_user_id: str = ""
jellyfin_api_key: str = ""
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
notes: str = "" notes: str = ""
@@ -44,11 +44,6 @@ def _default_local_machine() -> dict[str, Any]:
"password": "", "password": "",
"media_root": settings.media_root, "media_root": settings.media_root,
"path_prefix": settings.path_prefix, "path_prefix": settings.path_prefix,
"jellyfin_url": "",
"jellyfin_user_id": "",
"jellyfin_api_key": "",
"jellyseerr_url": "",
"jellyseerr_api_key": "",
"node_exporter_enabled": False, "node_exporter_enabled": False,
"node_exporter_port": 9100, "node_exporter_port": 9100,
"node_exporter_scrape_host": "", "node_exporter_scrape_host": "",
@@ -306,11 +301,6 @@ class SettingsStore:
"password_set": bool(data.get("password")), "password_set": bool(data.get("password")),
"media_root": data.get("media_root", ""), "media_root": data.get("media_root", ""),
"path_prefix": data.get("path_prefix", ""), "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_enabled": bool(data.get("node_exporter_enabled", False)),
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100), "node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""), "node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
@@ -360,17 +350,6 @@ class SettingsStore:
password = str(password or "") password = str(password or "")
media_root = _current_str("media_root") media_root = _current_str("media_root")
path_prefix = _current_str("path_prefix") 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( node_exporter_enabled = bool(
payload.get("node_exporter_enabled") payload.get("node_exporter_enabled")
if payload.get("node_exporter_enabled") is not None if payload.get("node_exporter_enabled") is not None
@@ -402,11 +381,6 @@ class SettingsStore:
"password": password, "password": password,
"media_root": media_root, "media_root": media_root,
"path_prefix": path_prefix, "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_enabled": node_exporter_enabled,
"node_exporter_port": node_exporter_port, "node_exporter_port": node_exporter_port,
"node_exporter_scrape_host": node_exporter_scrape_host, "node_exporter_scrape_host": node_exporter_scrape_host,
@@ -430,11 +404,6 @@ class SettingsStore:
"password": "", "password": "",
"media_root": machine["media_root"], "media_root": machine["media_root"],
"path_prefix": machine["path_prefix"], "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_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"], "node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"], "node_exporter_scrape_host": machine["node_exporter_scrape_host"],
@@ -520,11 +489,6 @@ class SettingsStore:
"password": data.get("password", ""), "password": data.get("password", ""),
"media_root": data.get("media_root", ""), "media_root": data.get("media_root", ""),
"path_prefix": data.get("path_prefix", ""), "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_enabled": bool(data.get("node_exporter_enabled", False)),
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100), "node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""), "node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
@@ -564,11 +528,6 @@ class SettingsStore:
"password": machine["password"], "password": machine["password"],
"media_root": machine["media_root"], "media_root": machine["media_root"],
"path_prefix": machine["path_prefix"], "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_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"], "node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"], "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", "grafana",
"prometheus", "prometheus",
"jellyfin", "jellyfin",
"jellyseerr",
"nextcloud", "nextcloud",
"ssh_tasks", "ssh_tasks",
} }
@@ -133,7 +134,14 @@ def test_list_service_types(client):
response = client.get("/api/services/types") response = client.get("/api/services/types")
assert response.status_code == 200 assert response.status_code == 200
types = {item["service_type"] for item in response.json()} 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): 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 PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093} ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-} 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} MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?set MANAGE_ENCRYPTION_KEY in your .env}
ports: ports:
- "8000:8000" - "8000:8000"
-2
View File
@@ -28,8 +28,6 @@ services:
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd} PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093} ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-} 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())"} MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?generate one with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"}
volumes: volumes:
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache - ${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. - Job templates should remain centralized in `jobs.py` for future extension.
- Remote job template values must be shell-quoted before execution. - Remote job template values must be shell-quoted before execution.
## Configurable Dashboard Widgets ## Service Registry and Dashboard Widgets
### Overview ### Overview
The dashboard is composed of persisted widget instances stored in the backend SQLite External services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are
settings database. Each widget has a type, title, configuration, enabled flag, and configured **in the app** and persisted in the backend SQLite database. Each
sort order. The frontend renders enabled widgets in sort order and fetches data service instance holds non-secret config plus encrypted secret fields. Dashboard
independently through the backend source adapters. 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. ### Services
- **Backups** — backup job summary and active alerts.
- **Grafana link** — deep-link to a Grafana dashboard or panel (no iframe embedding). - **Grafana** — base URL + optional API key; provides a dashboard-link widget.
- **Prometheus metric** — result of a PromQL instant query. - **Prometheus** — base URL + optional bearer token; provides a PromQL metric widget.
- **SSH task output** — output of a saved task run on a machine. - **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. - **Static text** — plain text or markdown note.
These do not reference a service.
### Security ### Security
- Widget `config` may not contain credential keys such as `password`, `token`, - Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
`secret`, `api_key`, `private_key`, or `passphrase`, or values that look like Fernet using a single env-provided `MANAGE_ENCRYPTION_KEY`, which is always
secrets (e.g., base64 blobs, `sk-` prefixes). required to start the backend.
- Widgets reuse machine-level Jellyfin/SSH credentials and environment settings for - Widget `config` and service `config` may not contain credential keys or
Grafana/Prometheus URLs; no secrets are stored in widget configuration. secret-looking values; secrets go in the dedicated secret fields only.
- SSH task widgets only run tasks from the saved-task registry; arbitrary commands - Plaintext secrets are never returned by the API; only `secrets_set` flags are
are not accepted. surfaced.
- SSH task widgets only run tasks from the saved-task registry; arbitrary
### Addon pages commands are not accepted.
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.
### API ### API
- `GET /api/widgets/sources` — list source types. - `GET /api/services/types` — service definition metadata (config schema,
- `GET /api/widgets/types` — list widget type metadata. 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. - `GET /api/widgets/instances` — list widget instances.
- `POST /api/widgets/instances` — create instance. - `POST/PUT/DELETE /api/widgets/instances/{id}` — widget CRUD.
- `PUT /api/widgets/instances/{id}` — update instance.
- `DELETE /api/widgets/instances/{id}` — delete instance.
- `GET /api/widgets/instances/{id}/data` — fetch widget data. - `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 ## 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. - 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 { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage"; import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage"; import { ObservabilityPage } from "./components/ObservabilityPage";
import { AddonPage } from "./pages/AddonPage";
import { ServicePage } from "./pages/ServicePage"; import { ServicePage } from "./pages/ServicePage";
import { ServicesPage } from "./pages/ServicesPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth"; import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client"; import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version"; import { FRONTEND_VERSION_LABEL } from "./version";
@@ -57,6 +57,7 @@ import {
LogOut, LogOut,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Boxes,
} from "lucide-react"; } from "lucide-react";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -90,6 +91,7 @@ const navItems = [
{ path: "/backups", label: "Backups", icon: DatabaseBackup }, { path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users }, { path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap }, { path: "/actions", label: "Actions", icon: Zap },
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon }, { path: "/settings", label: "Settings", icon: SettingsIcon },
]; ];
@@ -451,7 +453,7 @@ function AppInner() {
<Route path="/backups" element={<BackupsPage />} /> <Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} /> <Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} /> <Route path="/services" element={<ServicesPage />} />
<Route <Route
path="/services/:serviceType/:serviceId" path="/services/:serviceType/:serviceId"
element={<ServicePage />} element={<ServicePage />}
@@ -487,7 +489,7 @@ function AppInner() {
<Route path="/backups" element={<BackupsPage />} /> <Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} /> <Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} /> <Route path="/services" element={<ServicesPage />} />
<Route <Route
path="/services/:serviceType/:serviceId" path="/services/:serviceType/:serviceId"
element={<ServicePage />} 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(); return response.json();
} }
// Dashboard // Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
export const fetchCounts = (machineId?: string) => export const fetchCounts = (jellyfinServiceId?: string) =>
get<MediaCounts>( get<MediaCounts>(
"/api/dashboard/counts", "/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[]>( get<LibraryCount[]>(
"/api/dashboard/libraries", "/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[]>( get<NowPlayingSession[]>(
"/api/dashboard/activity", "/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>( get<UserDirectoryResponse>(
"/api/users", "/api/users",
machineId ? { machine_id: machineId } : undefined, jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
); );
// Backward-compatible alias used by older hooks/components. // Backward-compatible alias used by older hooks/components.
@@ -293,27 +293,27 @@ export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
); );
// Media // Media
export const fetchMediaStatus = (machineId?: string) => export const fetchMediaStatus = (jellyfinServiceId?: string) =>
get<MediaIndexStatus>( get<MediaIndexStatus>(
"/api/media/status", "/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>( post<MediaIndexActionResponse>(
machineId jellyfinServiceId
? `/api/media/build?machine_id=${encodeURIComponent(machineId)}` ? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/build", : "/api/media/build",
); );
export const stopMediaIndexBuild = (machineId?: string) => export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>( post<MediaIndexActionResponse>(
machineId jellyfinServiceId
? `/api/media/stop?machine_id=${encodeURIComponent(machineId)}` ? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/stop", : "/api/media/stop",
); );
export const forceStopMediaIndexBuild = (machineId?: string) => export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>( post<MediaIndexActionResponse>(
machineId jellyfinServiceId
? `/api/media/force-stop?machine_id=${encodeURIComponent(machineId)}` ? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/force-stop", : "/api/media/force-stop",
); );
export const queryMedia = (params: { export const queryMedia = (params: {
@@ -325,7 +325,7 @@ export const queryMedia = (params: {
sort_order?: string; sort_order?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
machineId?: string; jellyfinServiceId?: string;
}) => }) =>
get<MediaQueryResponse>("/api/media/query", { get<MediaQueryResponse>("/api/media/query", {
libraries: params.libraries || "", libraries: params.libraries || "",
@@ -336,7 +336,9 @@ export const queryMedia = (params: {
sort_order: params.sort_order || "Ascending", sort_order: params.sort_order || "Ascending",
limit: String(params.limit || 100), limit: String(params.limit || 100),
offset: String(params.offset || 0), offset: String(params.offset || 0),
...(params.machineId ? { machine_id: params.machineId } : {}), ...(params.jellyfinServiceId
? { jellyfin_service_id: params.jellyfinServiceId }
: {}),
}); });
// Files // Files
+9 -9
View File
@@ -9,26 +9,26 @@ import {
} from "../api/client"; } from "../api/client";
import type { DashboardShortcutInput } from "../types"; import type { DashboardShortcutInput } from "../types";
export function useCounts(machineId?: string) { export function useCounts(jellyfinServiceId?: string) {
return useQuery({ return useQuery({
queryKey: ["dashboard", "counts", machineId ?? "default"], queryKey: ["dashboard", "counts", jellyfinServiceId ?? "default"],
queryFn: () => fetchCounts(machineId), queryFn: () => fetchCounts(jellyfinServiceId),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
export function useLibraries(machineId?: string) { export function useLibraries(jellyfinServiceId?: string) {
return useQuery({ return useQuery({
queryKey: ["dashboard", "libraries", machineId ?? "default"], queryKey: ["dashboard", "libraries", jellyfinServiceId ?? "default"],
queryFn: () => fetchLibraries(machineId), queryFn: () => fetchLibraries(jellyfinServiceId),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
export function useActivity(machineId?: string) { export function useActivity(jellyfinServiceId?: string) {
return useQuery({ return useQuery({
queryKey: ["dashboard", "activity", machineId ?? "default"], queryKey: ["dashboard", "activity", jellyfinServiceId ?? "default"],
queryFn: () => fetchActivity(machineId), queryFn: () => fetchActivity(jellyfinServiceId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
} }
+10 -10
View File
@@ -7,10 +7,10 @@ import {
forceStopMediaIndexBuild, forceStopMediaIndexBuild,
} from "../api/client"; } from "../api/client";
export function useMediaStatus(machineId?: string) { export function useMediaStatus(jellyfinServiceId?: string) {
return useQuery({ return useQuery({
queryKey: ["media", "status", machineId ?? "default"], queryKey: ["media", "status", jellyfinServiceId ?? "default"],
queryFn: () => fetchMediaStatus(machineId), queryFn: () => fetchMediaStatus(jellyfinServiceId),
staleTime: 5_000, staleTime: 5_000,
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.build_running ? 1000 : false, query.state.data?.build_running ? 1000 : false,
@@ -27,7 +27,7 @@ export function useMediaQuery(params: {
sort_order?: string; sort_order?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
machineId?: string; jellyfinServiceId?: string;
enabled?: boolean; enabled?: boolean;
}) { }) {
const { enabled = true, ...queryParams } = params; const { enabled = true, ...queryParams } = params;
@@ -44,30 +44,30 @@ function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ["media"] }); queryClient.invalidateQueries({ queryKey: ["media"] });
} }
export function useBuildIndex(machineId?: string) { export function useBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => buildMediaIndex(machineId), mutationFn: () => buildMediaIndex(jellyfinServiceId),
onSuccess: () => { onSuccess: () => {
invalidateMedia(queryClient); invalidateMedia(queryClient);
}, },
}); });
} }
export function useStopBuildIndex(machineId?: string) { export function useStopBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => stopMediaIndexBuild(machineId), mutationFn: () => stopMediaIndexBuild(jellyfinServiceId),
onSuccess: () => { onSuccess: () => {
invalidateMedia(queryClient); invalidateMedia(queryClient);
}, },
}); });
} }
export function useForceStopBuildIndex(machineId?: string) { export function useForceStopBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => forceStopMediaIndexBuild(machineId), mutationFn: () => forceStopMediaIndexBuild(jellyfinServiceId),
onSuccess: () => { onSuccess: () => {
invalidateMedia(queryClient); invalidateMedia(queryClient);
}, },
+3 -3
View File
@@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/client"; import { fetchUsers } from "../api/client";
import type { UserDirectoryResponse } from "../types"; import type { UserDirectoryResponse } from "../types";
export function useUsers(machineId?: string) { export function useUsers(jellyfinServiceId?: string) {
return useQuery<UserDirectoryResponse>({ return useQuery<UserDirectoryResponse>({
queryKey: ["users", machineId ?? "default"], queryKey: ["users", jellyfinServiceId ?? "default"],
queryFn: () => fetchUsers(machineId), queryFn: () => fetchUsers(jellyfinServiceId),
staleTime: 30_000, 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 { useSearchParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { TabsTrigger } from "@/components/ui/tabs"; import { TabsTrigger } from "@/components/ui/tabs";
import { Media } from "./Media"; import { Media } from "./Media";
import { useCounts, useLibraries } from "../hooks/useDashboard"; import { useCounts, useLibraries } from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings"; import { useServiceInstances } from "../hooks/useServices";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard"; import { TabbedCard } from "../components/TabbedCard";
function JellyfinLibraryStats() { function JellyfinLibraryStats() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { data: machines = [] } = useMonitoringSettings(); const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const jellyfinMachines = useMemo( const selectedServiceId =
() => searchParams.get("jellyfin_service_id") ||
machines.filter( jellyfinServices.find((s) => s.enabled)?.id ||
(machine) => machine.enabled && machine.services.includes("jellyfin"), "";
), const { data: counts } = useCounts(selectedServiceId || undefined);
[machines], const { data: libraries } = useLibraries(selectedServiceId || undefined);
);
const selectedMachineId =
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
const { data: counts } = useCounts(selectedMachineId || undefined);
const { data: libraries } = useLibraries(selectedMachineId || undefined);
return ( return (
<SectionCard <SectionCard
@@ -30,7 +25,7 @@ function JellyfinLibraryStats() {
description="Compact Jellyfin summary for the selected machine." description="Compact Jellyfin summary for the selected machine."
action={ action={
<Badge variant="outline"> <Badge variant="outline">
{selectedMachineId ? "Selected machine" : "Default machine"} {selectedServiceId ? "Selected service" : "Default service"}
</Badge> </Badge>
} }
> >
+24 -29
View File
@@ -32,7 +32,7 @@ import {
} from "../hooks/useMedia"; } from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState"; import { usePersistentState } from "../hooks/usePersistentState";
import type { MediaItem } from "../types"; import type { MediaItem } from "../types";
import { useMonitoringSettings } from "../hooks/useSettings"; import { useServiceInstances } from "../hooks/useServices";
import { useCounts, useLibraries } from "../hooks/useDashboard"; import { useCounts, useLibraries } from "../hooks/useDashboard";
function formatDuration(seconds: number | null | undefined): string { function formatDuration(seconds: number | null | undefined): string {
@@ -178,23 +178,18 @@ export function Media() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const isSmall = usePrefersSmallScreen(); const isSmall = usePrefersSmallScreen();
const { data: machines } = useMonitoringSettings(); const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const jellyfinMachines = useMemo( const selectedServiceId =
() => searchParams.get("jellyfin_service_id") ||
(machines ?? []).filter( jellyfinServices.find((s) => s.enabled)?.id ||
(machine) => machine.enabled && machine.services.includes("jellyfin"), "";
), const { data: counts } = useCounts(selectedServiceId || undefined);
[machines], const { data: libraries } = useLibraries(selectedServiceId || undefined);
); const { data: status } = useMediaStatus(selectedServiceId || undefined);
const selectedMachineId = const buildIndex = useBuildIndex(selectedServiceId || undefined);
searchParams.get("machine_id") || jellyfinMachines[0]?.id || ""; const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
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 forceStopBuildIndex = useForceStopBuildIndex( const forceStopBuildIndex = useForceStopBuildIndex(
selectedMachineId || undefined, selectedServiceId || undefined,
); );
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>( const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
@@ -215,17 +210,17 @@ export function Media() {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
useEffect(() => { useEffect(() => {
if (!searchParams.get("machine_id") && selectedMachineId) { if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
setSearchParams( setSearchParams(
(current) => { (current) => {
const next = new URLSearchParams(current); const next = new URLSearchParams(current);
next.set("machine_id", selectedMachineId); next.set("jellyfin_service_id", selectedServiceId);
return next; return next;
}, },
{ replace: true }, { replace: true },
); );
} }
}, [searchParams, selectedMachineId, setSearchParams]); }, [searchParams, selectedServiceId, setSearchParams]);
const { data: queryResult, isLoading } = useMediaDataQuery({ const { data: queryResult, isLoading } = useMediaDataQuery({
types, types,
@@ -235,7 +230,7 @@ export function Media() {
sort_order: sortOrder, sort_order: sortOrder,
limit: pageSize, limit: pageSize,
offset, offset,
machineId: selectedMachineId || undefined, jellyfinServiceId: selectedServiceId || undefined,
enabled: status?.exists ?? false, enabled: status?.exists ?? false,
}); });
@@ -323,27 +318,27 @@ export function Media() {
<div className="flex flex-row flex-wrap items-center gap-2"> <div className="flex flex-row flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">Jellyfin</h2> <h2 className="text-lg font-semibold">Jellyfin</h2>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="media-machine">Machine</Label> <Label htmlFor="media-service">Service</Label>
<Select <Select
value={selectedMachineId} value={selectedServiceId}
onValueChange={(value) => onValueChange={(value) =>
setSearchParams( setSearchParams(
(current) => { (current) => {
const next = new URLSearchParams(current); const next = new URLSearchParams(current);
next.set("machine_id", value); next.set("jellyfin_service_id", value);
return next; return next;
}, },
{ replace: true }, { replace: true },
) )
} }
> >
<SelectTrigger id="media-machine" className="w-full md:w-[220px]"> <SelectTrigger id="media-service" className="w-full md:w-[220px]">
<SelectValue placeholder="Select a machine" /> <SelectValue placeholder="Select a service" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{jellyfinMachines.map((machine) => ( {jellyfinServices.map((service) => (
<SelectItem key={machine.id} value={machine.id}> <SelectItem key={service.id} value={service.id}>
{machine.name} {service.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </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 = [ const SERVICE_OPTIONS = [
{ value: "monitoring", label: "Monitoring" }, { value: "monitoring", label: "Monitoring" },
{ value: "files", label: "Files" }, { value: "files", label: "Files" },
{ value: "jellyfin", label: "Jellyfin" },
{ value: "jellyseerr", label: "Jellyseerr" },
{ value: "nextcloud", label: "Nextcloud" }, { value: "nextcloud", label: "Nextcloud" },
]; ];
@@ -114,7 +112,7 @@ function emptyMachine(
name: mode === "local" ? "This machine" : "", name: mode === "local" ? "This machine" : "",
mode, mode,
enabled: true, enabled: true,
services: mode === "local" ? ["monitoring", "files", "jellyfin"] : [], services: mode === "local" ? ["monitoring", "files"] : [],
host: "", host: "",
port: 22, port: 22,
username: "", username: "",
@@ -126,11 +124,6 @@ function emptyMachine(
password: "", password: "",
media_root: "", media_root: "",
path_prefix: "", path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key: "",
jellyseerr_url: "",
jellyseerr_api_key: "",
notes: "", notes: "",
}; };
} }
@@ -169,8 +162,6 @@ function MachineEditor({
const isLocal = draft.mode === "local"; const isLocal = draft.mode === "local";
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id); const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
const enabledServices = draft.services.length; const enabledServices = draft.services.length;
const hasJellyfin = draft.services.includes("jellyfin");
const hasJellyseerr = draft.services.includes("jellyseerr");
const placeholderIfSet = (isSet: boolean | undefined) => const placeholderIfSet = (isSet: boolean | undefined) =>
isSet ? "Set, not shown" : undefined; isSet ? "Set, not shown" : undefined;
return ( return (
@@ -398,99 +389,6 @@ function MachineEditor({
/> />
</FormField> </FormField>
</div> </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 && ( {isLocal && (
<div className="col-span-12 md:col-span-6"> <div className="col-span-12 md:col-span-6">
<FormField label="Local hint"> <FormField label="Local hint">
@@ -538,7 +436,7 @@ function MachineEditor({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
{!isLocal && !hasJellyfin && ( {!isLocal && enabledServices === 0 && (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
SSH machines usually need monitoring or files enabled. SSH machines usually need monitoring or files enabled.
@@ -584,13 +482,6 @@ function MachineEditor({
)} )}
</div> </div>
)} )}
{hasJellyseerr && !draft.jellyseerr_url && (
<Alert>
<AlertDescription>
Jellyseerr is enabled, but no URL is configured yet.
</AlertDescription>
</Alert>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -1146,11 +1037,6 @@ export function Settings() {
ssh_private_key_passphrase: "", ssh_private_key_passphrase: "",
password: "", password: "",
media_root: machine.media_root, 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, notes: machine.notes,
}, },
machine, machine,
@@ -1237,12 +1123,6 @@ export function Settings() {
ssh_private_key_passphrase: "", ssh_private_key_passphrase: "",
password: "", password: "",
media_root: selectedMachine.media_root, 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, notes: selectedMachine.notes,
}, },
selectedMachine, selectedMachine,
@@ -48,11 +48,6 @@ function machine(
password_set: false, password_set: false,
media_root: "", media_root: "",
path_prefix: "", path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "", notes: "",
...overrides, ...overrides,
} as MonitoringMachine; } 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", () => ({ vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({ useCounts: () => ({
data: { movies: 10, series: 5, episodes: 100 }, data: { movies: 10, series: 5, episodes: 100 },
@@ -30,11 +30,6 @@ function machineFixture(
password_set: false, password_set: false,
media_root: "", media_root: "",
path_prefix: "", path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "", notes: "",
...overrides, ...overrides,
}; };
+12 -6
View File
@@ -34,11 +34,6 @@ function machineFixture(
password_set: false, password_set: false,
media_root: "", media_root: "",
path_prefix: "", path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "", notes: "",
...overrides, ...overrides,
}; };
@@ -103,7 +98,10 @@ let queryResult: MediaQueryResponse;
vi.mock("react-router-dom", () => ({ vi.mock("react-router-dom", () => ({
useNavigate: () => navigate, useNavigate: () => navigate,
useSearchParams: () => [new URLSearchParams("machine_id=local"), vi.fn()], useSearchParams: () => [
new URLSearchParams("jellyfin_service_id=jfs1"),
vi.fn(),
],
})); }));
vi.mock("../../hooks/useMedia", () => ({ vi.mock("../../hooks/useMedia", () => ({
@@ -118,6 +116,14 @@ vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [machineFixture()] }), useMonitoringSettings: () => ({ data: [machineFixture()] }),
})); }));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: [
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({ vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({ data: undefined }), useCounts: () => ({ data: undefined }),
useLibraries: () => ({ data: undefined }), useLibraries: () => ({ data: undefined }),
@@ -53,11 +53,6 @@ function localMachine(
password_set: false, password_set: false,
media_root: "/mnt/media", media_root: "/mnt/media",
path_prefix: "", path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "Primary node", notes: "Primary node",
...overrides, ...overrides,
} as MonitoringMachine; } as MonitoringMachine;
-10
View File
@@ -175,11 +175,6 @@ export interface MonitoringMachine {
password_set: boolean; password_set: boolean;
media_root: string; media_root: string;
path_prefix: 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; notes: string;
} }
@@ -200,11 +195,6 @@ export interface MonitoringMachineInput {
password: string; password: string;
media_root: string; media_root: string;
path_prefix: string; path_prefix: string;
jellyfin_url: string;
jellyfin_user_id: string;
jellyfin_api_key: string;
jellyseerr_url: string;
jellyseerr_api_key: string;
notes: string; notes: string;
} }
@@ -1,87 +1,119 @@
# Apply Progress: Runtime Service Registry # Apply Progress: Runtime Service Registry
**Change:** `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 **Date:** 2026-06-19
## Slice 1 — Backend service foundation (MERGED, PR #7) ## Slices 13 (MERGED)
Fernet secrets, closed `integrations/` registry (Pydantic config + widget-config - Slice 1 (#7): backend service foundation — encryption, integrations registry,
for grafana/prometheus/jellyfin/nextcloud/ssh_tasks), `services` + services + service_task_runs tables, `/api/services*` CRUD.
`service_task_runs` tables with cascade delete, `/api/services*` CRUD, - Slice 2 (#8): backend widget rebind — service_id + widget_kind, ServiceRecord
`MANAGE_ENCRYPTION_KEY` required at startup. 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) ## Slice 4aCleanup + services admin UI + docs (this PR)
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)
### Completed tasks ### Completed tasks
- [x] 3.1 Service + new widget TypeScript types (`ServiceInstance`, - [x] Removed addon pages (`/addons/:addonId`, `AddonPage.tsx`, `addons/*`) —
`ServiceInstanceInput`, `ServiceTypeInfo`, `ServiceWidgetKindInfo`, superseded by service pages.
`SecretFieldInfo`, `BuiltinWidgetKindInfo`; widget gains `service_id` + - [x] Removed `grafana_url` / `prometheus_url` from `config.py`, both compose
`widget_kind`). files, `.env.example`, and README. (Frontend `VITE_GRAFANA_URL` /
- [x] 3.2 Services API + hooks (`api/services.ts`, `hooks/useServices.ts`). `VITE_PROMETHEUS_URL` deep-link vars retained.)
Reconciled `api/widgets.ts` + `hooks/useWidgets.ts` to the new shape - [x] Added a **Services page** (`/services`) with create/list/delete and a nav
(removed sources/types; added builtin kinds). entry, so service pages are reachable and services are configurable in the
- [x] 3.3 Closed frontend service registry (`integrations/registry.ts`) tool itself.
mirroring the backend; `resolveWidget(widget, services)` maps a widget to - [x] Registered `/services` route in both route trees + sidebar nav.
its component + refresh interval. - [x] Updated `docs/REQUIREMENTS.md` (service registry section) and added
- [x] 3.4 Service page at `/services/:serviceType/:serviceId` with config view, `CHANGELOG.md` with the breaking-upgrade note.
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`.
### Decision resolved mid-slice ### Decision resolved mid-slice
Secret edit UX = **empty-on-edit + "set" badge** (blank = keep existing; typing "Full machine migration" was scoped into **4a (cleanup) + 4b (Jellyfin/Jellyseerr
= replace). Applied on the ServicePage secrets card. 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`, - Backend: `config.py` (removed grafana_url/prometheus_url).
`integrations/registry.test.ts`, `pages/ServicePage.tsx`. - Compose/env/docs: `docker-compose.yml`, `docker-compose.dev.yml`,
- Modified: `types/index.ts`, `api/widgets.ts`, `hooks/useWidgets.ts`, `.env.example`, `README.md`, `docs/REQUIREMENTS.md`, `CHANGELOG.md` (new).
`components/WidgetInstance.tsx`, `components/WidgetConfigDialog.tsx`, - Frontend: new `pages/ServicesPage.tsx`; `App.tsx` (routes + nav); removed
`pages/Dashboard.tsx`, `pages/__tests__/Dashboard.test.tsx`, `App.tsx`, `pages/AddonPage.tsx`, `addons/*`.
all six `widgets/*.tsx` components, `widgets/index.ts`.
- Deleted: `widgets/registry.ts`, `widgets/registry.test.ts`.
### Verification (Slice 3) ### Verification (Slice 4a)
```bash ```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 lint # 0 errors
npm run build # success npm run build # success
npm run test # 70 passed 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 ### Completed (backend, this PR)
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 - [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 ### Selection model (decided)
`grafana_url`/`prometheus_url` from config + compose, docs + changelog
(breaking upgrade note). 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.