diff --git a/backend/src/media_library_viewer_api/integrations/base.py b/backend/src/media_library_viewer_api/integrations/base.py index 6dee3ce..09da520 100644 --- a/backend/src/media_library_viewer_api/integrations/base.py +++ b/backend/src/media_library_viewer_api/integrations/base.py @@ -59,6 +59,7 @@ class WidgetKind: config_schema: dict[str, Any] default_config: dict[str, Any] = field(default_factory=dict) refresh_interval_ms: int = 0 + config_model: type[WidgetConfigBase] | None = None @dataclass(frozen=True) @@ -108,6 +109,7 @@ def widget_kind( config_schema=schema, default_config=dict(default_config or {}), refresh_interval_ms=refresh_interval_ms, + config_model=model_cls, ) diff --git a/backend/src/media_library_viewer_api/models/widgets.py b/backend/src/media_library_viewer_api/models/widgets.py index e36dad2..4a29ebc 100644 --- a/backend/src/media_library_viewer_api/models/widgets.py +++ b/backend/src/media_library_viewer_api/models/widgets.py @@ -1,8 +1,18 @@ -"""Pydantic models for the dashboard widget system.""" +"""Pydantic models for the dashboard widget system. + +Widgets are either: +* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared + by that service's definition (Grafana link, Prometheus metric, Jellyfin + activity, SSH task output); or +* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the + service-less kinds (backups, static). +""" + +from __future__ import annotations from typing import Any -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator FORBIDDEN_CONFIG_KEYS = { "password", @@ -47,8 +57,8 @@ def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]: class _WidgetInstanceBase(BaseModel): """Shared fields between input and output widget models.""" - addon_id: str - widget_type: str + service_id: str | None = None + widget_kind: str = Field(..., min_length=1) title: str = Field(..., min_length=1) config: dict[str, Any] = Field(default_factory=dict) enabled: bool = True @@ -59,6 +69,13 @@ class _WidgetInstanceBase(BaseModel): def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]: return _validate_config_keys(value or {}) + @model_validator(mode="after") + def _validate_kind(self) -> "_WidgetInstanceBase": + # The kind must be non-empty (Field enforces it); service_id may be None + # for built-ins. Deeper validation happens in the router against the + # service definition / built-in registry. + return self + class WidgetInstanceInput(_WidgetInstanceBase): """Payload for creating or updating a widget instance.""" @@ -74,22 +91,21 @@ class WidgetInstance(_WidgetInstanceBase): updated_at: int -class WidgetTypeInfo(BaseModel): - """Metadata about a built-in widget type.""" +class BuiltinWidgetKindInfo(BaseModel): + """Metadata about a built-in (service-less) widget kind.""" - addon_id: str - widget_type: str + kind: str name: str description: str - source_type: str config_schema: dict[str, Any] + default_config: dict[str, Any] + refresh_interval_ms: int class WidgetDataResponse(BaseModel): """Response from the per-widget data endpoint.""" widget_id: str - widget_type: str data: dict[str, Any] | None = None error: str | None = None fetched_at: int diff --git a/backend/src/media_library_viewer_api/routers/widgets.py b/backend/src/media_library_viewer_api/routers/widgets.py index 37c7a26..7ddeeb6 100644 --- a/backend/src/media_library_viewer_api/routers/widgets.py +++ b/backend/src/media_library_viewer_api/routers/widgets.py @@ -1,4 +1,11 @@ -"""REST API for dashboard widget instances and registry metadata.""" +"""REST API for dashboard widget instances. + +Widgets are either service-bound (``service_id`` + ``widget_kind`` from the +service definition) or built-in (``service_id`` is null; ``widget_kind`` is one +of the service-less kinds exposed by ``GET /api/widgets/builtin``). +""" + +from __future__ import annotations import logging import time @@ -7,68 +14,95 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.integrations.base import validate_config +from media_library_viewer_api.integrations.registry import get_service_definition from media_library_viewer_api.models.widgets import ( + BuiltinWidgetKindInfo, WidgetDataResponse, WidgetInstance, WidgetInstanceInput, ) from media_library_viewer_api.services.settings_store import SettingsStore -from media_library_viewer_api.widgets.registry import ( - get_widget_info, - list_source_types, - list_widget_types, - validate_config, +from media_library_viewer_api.widgets.builtin import ( + BUILTIN_WIDGET_KINDS, + is_builtin_kind, + validate_builtin_config, +) +from media_library_viewer_api.widgets.sources import ( + build_service_record, + get_builtin_adapter, + get_service_adapter, ) -from media_library_viewer_api.widgets.sources import get_source_adapter router = APIRouter(prefix="/api/widgets", tags=["widgets"]) logger = logging.getLogger(__name__) -def _registry_for_type(widget_type: str) -> dict[str, Any]: - from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY +def _validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) -> None: + """Validate widget_kind + config against the service definition or built-ins.""" + if body.service_id: + service = store.get_service(body.service_id) + if not service: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Service {body.service_id} not found", + ) + definition = get_service_definition(service["service_type"]) + if definition is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Unknown service type: {service['service_type']}", + ) + widget_kind = definition.widget_kind(body.widget_kind) + if widget_kind is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=( + f"Service type '{service['service_type']}' does not provide " + f"widget kind '{body.widget_kind}'" + ), + ) + if widget_kind.config_model is not None: + try: + validate_config(widget_kind.config_model, body.config) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Invalid widget config: {exc}", + ) from exc + else: + if not is_builtin_kind(body.widget_kind): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=( + f"Unknown built-in widget kind '{body.widget_kind}' " + "(set service_id for service-bound widgets)" + ), + ) + try: + validate_builtin_config(body.widget_kind, body.config) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Invalid widget config: {exc}", + ) from exc - info = WIDGET_REGISTRY.get(widget_type) - if not info: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=f"Unknown widget type: {widget_type}", + +@router.get("/builtin") +def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]: + """Return metadata for service-less built-in widget kinds.""" + return [ + BuiltinWidgetKindInfo( + kind=wk.kind, + name=wk.name, + description=wk.description, + config_schema=wk.config_schema, + default_config=wk.default_config, + refresh_interval_ms=wk.refresh_interval_ms, ) - return info - - -def _validate_widget_input(body: WidgetInstanceInput) -> None: - """Validate widget_type/addon_id match and config schema.""" - info = _registry_for_type(body.widget_type) - expected_addon = info["addon_id"] - if body.addon_id != expected_addon: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=( - f"Widget type '{body.widget_type}' belongs to addon " - f"'{expected_addon}', not '{body.addon_id}'" - ), - ) - try: - validate_config(body.widget_type, body.config) - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=str(exc), - ) from exc - - -@router.get("/sources") -def list_sources() -> list[str]: - """Return all registered widget source types.""" - return list_source_types() - - -@router.get("/types") -def list_types() -> list[dict[str, Any]]: - """Return metadata for all registered widget types.""" - return [info.model_dump() for info in list_widget_types()] + for wk in BUILTIN_WIDGET_KINDS.values() + ] @router.get("/instances") @@ -85,7 +119,7 @@ def create_instance( store: SettingsStore = Depends(get_settings_store), ) -> dict[str, Any]: """Create a new widget instance.""" - _validate_widget_input(body) + _validate_widget_input(body, store) widget = store.upsert_widget(body.model_dump()) return WidgetInstance(**widget).model_dump() @@ -105,7 +139,7 @@ def update_instance( status_code=status.HTTP_400_BAD_REQUEST, detail="ID in path does not match ID in body", ) - _validate_widget_input(body) + _validate_widget_input(body, store) widget = store.upsert_widget(body.model_dump(), widget_id) return WidgetInstance(**widget).model_dump() @@ -133,31 +167,44 @@ async def fetch_data( if not widget: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found") - widget_type = widget["widget_type"] - info = get_widget_info(widget_type) - if info is None: - return WidgetDataResponse( - widget_id=widget_id, - widget_type=widget_type, - data=None, - error=f"Unknown widget type: {widget_type}", - fetched_at=int(time.time()), - ).model_dump() + service_id = widget.get("service_id") + widget_kind = widget.get("widget_kind") or "" - adapter = get_source_adapter(info.source_type) - if adapter is None: - # Defensive: registry should prevent this, but return a safe error. - return WidgetDataResponse( - widget_id=widget_id, - widget_type=widget_type, - data=None, - error=f"No adapter registered for source type: {info.source_type}", - fetched_at=int(time.time()), - ).model_dump() + service: Any = None + if service_id: + service_row = store.get_service(service_id) + if not service_row: + return WidgetDataResponse( + widget_id=widget_id, + error=f"Service {service_id} not found", + fetched_at=int(time.time()), + ).model_dump() + if not service_row.get("enabled", True): + return WidgetDataResponse( + widget_id=widget_id, + error="Service is disabled", + fetched_at=int(time.time()), + ).model_dump() + adapter = get_service_adapter(service_row["service_type"]) + if adapter is None: + return WidgetDataResponse( + widget_id=widget_id, + error=f"No adapter for service type {service_row['service_type']}", + fetched_at=int(time.time()), + ).model_dump() + service = build_service_record(store, service_row) + else: + adapter = get_builtin_adapter(widget_kind) + if adapter is None: + return WidgetDataResponse( + widget_id=widget_id, + error=f"Unknown built-in widget kind: {widget_kind}", + fetched_at=int(time.time()), + ).model_dump() try: - data = await adapter.fetch(widget["config"]) - except Exception as exc: + data = await adapter.fetch(service, widget_kind, widget.get("config") or {}) + except Exception as exc: # pragma: no cover - defensive logger.exception("Unhandled adapter exception widget_id=%s", widget_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -166,7 +213,6 @@ async def fetch_data( return WidgetDataResponse( widget_id=widget_id, - widget_type=widget_type, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py index d825ed6..4a081cc 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -180,6 +180,11 @@ class SettingsStore: """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)") + widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()} + if "service_id" not in widget_cols: + conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT") + if "widget_kind" not in widget_cols: + conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT") conn.execute(""" CREATE TABLE IF NOT EXISTS backup_jobs ( id TEXT PRIMARY KEY, @@ -453,39 +458,13 @@ class SettingsStore: ) def _seed_dashboard_widgets(self) -> None: - """Seed default dashboard widgets only when the table is empty.""" - from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY + """Default widget seeding was removed. - self.init_schema() - with self.connect() as conn: - row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone() - if row and int(row[0]) > 0: - return - defaults = [ - { - "id": "jellyfin-activity-default", - "addon_id": "core", - "widget_type": "jellyfin", - "title": "Jellyfin activity", - "config": {"machine_id": ""}, - "enabled": True, - "sort_order": 0, - }, - { - "id": "backups-summary-default", - "addon_id": "backups", - "widget_type": "backups", - "title": "Backups", - "config": {}, - "enabled": True, - "sort_order": 1, - }, - ] - for widget in defaults: - info = WIDGET_REGISTRY.get(widget["widget_type"]) - if not info or info["addon_id"] != widget["addon_id"]: - continue - self.upsert_widget(widget) + Widgets are now service-bound (or built-in). A fresh install starts with + no widgets; the user configures services and adds widgets from the UI. + Kept as a no-op so :meth:`ensure_defaults` callers are unchanged. + """ + return None def ensure_defaults(self) -> None: self.init_schema() @@ -493,7 +472,6 @@ class SettingsStore: row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone() if not row or int(row[0]) == 0: self._seed_local_machine() - self._seed_dashboard_widgets() def list_machines(self) -> list[dict[str, Any]]: self.init_schema() @@ -1404,10 +1382,13 @@ class SettingsStore: # ------------------------------------------------------------------ def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]: + keys = row.keys() return { "id": row["id"], "addon_id": row["addon_id"], "widget_type": row["widget_type"], + "service_id": row["service_id"] if "service_id" in keys else None, + "widget_kind": row["widget_kind"] if "widget_kind" in keys else None, "title": row["title"], "config": json.loads(row["config_json"] or "{}"), "enabled": bool(row["enabled"]), @@ -1423,8 +1404,11 @@ class SettingsStore: ) -> dict[str, Any]: current = self.get_widget(widget_id) if widget_id else None widget_id = str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12] - addon_id = str(payload.get("addon_id") or (current or {}).get("addon_id", "")).strip() - widget_type = str(payload.get("widget_type") or (current or {}).get("widget_type", "")).strip() + service_id = ( + str(payload.get("service_id") or (current or {}).get("service_id") or "").strip() + or None + ) + widget_kind = str(payload.get("widget_kind") or (current or {}).get("widget_kind", "")).strip() title = str(payload.get("title") or (current or {}).get("title", "") or "").strip() config = payload.get("config", (current or {}).get("config", {})) if not isinstance(config, dict): @@ -1433,10 +1417,14 @@ class SettingsStore: _validate_config_keys(config) enabled = bool(payload.get("enabled", (current or {}).get("enabled", True))) sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0) + # Legacy label kept for diagnostics; new code uses service_id + widget_kind. + widget_type = f"{service_id}:{widget_kind}" if widget_kind else "" return { "id": widget_id, - "addon_id": addon_id, + "addon_id": "", "widget_type": widget_type, + "service_id": service_id, + "widget_kind": widget_kind, "title": title, "config": config, "enabled": enabled, @@ -1470,13 +1458,15 @@ class SettingsStore: conn.execute( """ INSERT INTO dashboard_widgets ( - id, addon_id, widget_type, title, config_json, enabled, - sort_order, created_at, updated_at + id, addon_id, widget_type, service_id, widget_kind, title, + config_json, enabled, sort_order, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET addon_id = excluded.addon_id, widget_type = excluded.widget_type, + service_id = excluded.service_id, + widget_kind = excluded.widget_kind, title = excluded.title, config_json = excluded.config_json, enabled = excluded.enabled, @@ -1487,6 +1477,8 @@ class SettingsStore: widget["id"], widget["addon_id"], widget["widget_type"], + widget["service_id"], + widget["widget_kind"], widget["title"], json.dumps(widget["config"]), 1 if widget["enabled"] else 0, diff --git a/backend/src/media_library_viewer_api/widgets/builtin.py b/backend/src/media_library_viewer_api/widgets/builtin.py new file mode 100644 index 0000000..b44e6d8 --- /dev/null +++ b/backend/src/media_library_viewer_api/widgets/builtin.py @@ -0,0 +1,68 @@ +"""Built-in, service-less widget kinds. + +These widgets do not talk to an external service and therefore have no +``service_id``. They are kept out of the service registry (which models +configurable external services) and live here as a small closed set. + +Currently: ``backups`` (reads the internal backup tables) and ``static`` +(plain text/markdown). +""" + +from __future__ import annotations + +from typing import Any + +from media_library_viewer_api.integrations.base import WidgetKind + +BUILTIN_WIDGET_KINDS: dict[str, WidgetKind] = { + "backups": WidgetKind( + kind="backups", + name="Backups", + description="Backup job summary and active alerts.", + config_schema={"type": "object", "properties": {}, "required": []}, + default_config={}, + refresh_interval_ms=60_000, + ), + "static": WidgetKind( + kind="static", + name="Static text", + description="Plain text or markdown note.", + config_schema={ + "type": "object", + "properties": {"text": {"type": "string", "description": "Text or markdown content"}}, + "required": ["text"], + }, + default_config={"text": ""}, + refresh_interval_ms=0, + ), +} + + +def get_builtin_widget_kind(kind: str) -> WidgetKind | None: + return BUILTIN_WIDGET_KINDS.get(kind) + + +def is_builtin_kind(kind: str) -> bool: + return kind in BUILTIN_WIDGET_KINDS + + +def builtin_widget_kind_models() -> dict[str, type]: + """Pydantic widget-config models for built-in kinds (validated manually). + + Backups has no user fields; static validates ``text``. + """ + from pydantic import BaseModel, Field + + class StaticConfig(BaseModel): + text: str = Field(default="") + + return {"static": StaticConfig} + + +def validate_builtin_config(kind: str, config: dict[str, Any]) -> dict[str, Any]: + """Validate (lightly) a built-in widget config and return the cleaned dict.""" + models = builtin_widget_kind_models() + model_cls = models.get(kind) + if model_cls is None: + return dict(config or {}) + return model_cls.model_validate(config or {}).model_dump(exclude_none=True) diff --git a/backend/src/media_library_viewer_api/widgets/registry.py b/backend/src/media_library_viewer_api/widgets/registry.py deleted file mode 100644 index c228f77..0000000 --- a/backend/src/media_library_viewer_api/widgets/registry.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Closed, compile-time widget registry. - -New widget types and source adapters require a code change in Phase 1. -There is no runtime plugin loading. -""" - -from typing import Any - -from media_library_viewer_api.models.widgets import WidgetTypeInfo - -WIDGET_REGISTRY: dict[str, dict[str, Any]] = { - "jellyfin": { - "addon_id": "core", - "name": "Jellyfin activity", - "description": "Live sessions and idle users from a Jellyfin server.", - "source_type": "jellyfin", - "config_schema": { - "type": "object", - "properties": { - "machine_id": { - "type": "string", - "description": "Jellyfin machine id (empty = default)", - }, - }, - "required": ["machine_id"], - }, - }, - "backups": { - "addon_id": "backups", - "name": "Backups", - "description": "Backup job summary and active alerts.", - "source_type": "backups", - "config_schema": { - "type": "object", - "properties": {}, - "required": [], - }, - }, - "grafana-link": { - "addon_id": "grafana", - "name": "Grafana link", - "description": "Deep-link to a Grafana dashboard or panel.", - "source_type": "grafana", - "config_schema": { - "type": "object", - "properties": { - "dashboard_uid": { - "type": "string", - "description": "Grafana dashboard UID", - }, - "panel_id": { - "type": "integer", - "description": "Optional panel id", - }, - }, - "required": ["dashboard_uid"], - }, - }, - "prometheus-metric": { - "addon_id": "prometheus", - "name": "Prometheus metric", - "description": "Instant query result rendered as a metric.", - "source_type": "prometheus", - "config_schema": { - "type": "object", - "properties": { - "promql": { - "type": "string", - "description": "PromQL instant query", - }, - }, - "required": ["promql"], - }, - }, - "ssh-task": { - "addon_id": "ssh-tasks", - "name": "SSH task output", - "description": "Output of a saved task run on a machine.", - "source_type": "ssh_task", - "config_schema": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Saved task id", - }, - }, - "required": ["task_id"], - }, - }, - "static": { - "addon_id": "core", - "name": "Static text", - "description": "Plain text or markdown note.", - "source_type": "static", - "config_schema": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Text or markdown content", - }, - }, - "required": ["text"], - }, - }, -} - - -def list_source_types() -> list[str]: - """Return all registered source type names.""" - return sorted({info["source_type"] for info in WIDGET_REGISTRY.values()}) - - -def list_widget_types() -> list[WidgetTypeInfo]: - """Return metadata for all registered widget types.""" - return [ - WidgetTypeInfo( - addon_id=info["addon_id"], - widget_type=widget_type, - name=info["name"], - description=info["description"], - source_type=info["source_type"], - config_schema=info["config_schema"], - ) - for widget_type, info in WIDGET_REGISTRY.items() - ] - - -def get_widget_info(widget_type: str) -> WidgetTypeInfo | None: - """Return metadata for a single widget type, or None if unknown.""" - info = WIDGET_REGISTRY.get(widget_type) - if not info: - return None - return WidgetTypeInfo( - addon_id=info["addon_id"], - widget_type=widget_type, - name=info["name"], - description=info["description"], - source_type=info["source_type"], - config_schema=info["config_schema"], - ) - - -def _validate_type(value: Any, expected: str) -> bool: - if expected == "string": - return isinstance(value, str) - if expected == "integer": - return isinstance(value, int) and not isinstance(value, bool) - if expected == "boolean": - return isinstance(value, bool) - if expected == "number": - return isinstance(value, (int, float)) and not isinstance(value, bool) - if expected == "object": - return isinstance(value, dict) - if expected == "array": - return isinstance(value, list) - return True - - -def validate_config(widget_type: str, config: dict[str, Any]) -> None: - """Validate a widget config against its registered JSON schema. - - Raises ValueError with a descriptive message if validation fails. - Phase 1 supports only required-field and primitive-type checks. - """ - info = WIDGET_REGISTRY.get(widget_type) - if not info: - raise ValueError(f"Unknown widget type: {widget_type}") - - schema = info["config_schema"] - required = schema.get("required", []) - properties = schema.get("properties", {}) - - for key in required: - if key not in config: - raise ValueError(f"Missing required config field: {key}") - - for key, value in config.items(): - prop = properties.get(key) - if not prop: - # Unknown keys are allowed in Phase 1 unless they look like secrets - # (handled by the model validator). Skip type checks for unknowns. - continue - expected_type = prop.get("type") - if expected_type and not _validate_type(value, expected_type): - raise ValueError(f"Config field '{key}' must be of type {expected_type}") diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index c3dde2a..b12f436 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -1,9 +1,11 @@ """Widget source adapters. -Each adapter implements a uniform async interface and translates widget -configuration into data for the dashboard. Adapters reuse existing clients, -machine registries, and environment settings; they never accept arbitrary -commands or store credentials. +Adapters translate a widget instance into dashboard data. Service-bound widgets +are resolved against a :class:`ServiceRecord` (config + decrypted secrets); the +built-in widgets (backups, static) take ``service=None``. + +Adapters never accept arbitrary commands and never store credentials — secrets +are decrypted in memory only for the duration of a fetch. """ from __future__ import annotations @@ -11,94 +13,103 @@ from __future__ import annotations import asyncio import logging import shlex +import time +from dataclasses import dataclass, field from typing import Any, Protocol import requests -from starlette.requests import Request +from media_library_viewer_api.clients.jellyfin import JellyfinClient +from media_library_viewer_api.clients.ssh import RemoteSSHClient from media_library_viewer_api.config import get_settings -from media_library_viewer_api.dependencies import get_jellyfin_client from media_library_viewer_api.domain.dashboard import ( _map_sessions_to_activity_rows, build_backup_dashboard_summary, ) -from media_library_viewer_api.routers.tasks import _client_for_machine, _resolve_machine_for_task -from media_library_viewer_api.services.settings_store import get_settings_store +from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store logger = logging.getLogger(__name__) -def _request_with_machine_id(machine_id: str | None = None) -> Request: - """Build a minimal Starlette Request carrying a machine_id query param.""" - query = f"machine_id={machine_id}".encode() if machine_id else b"" - return Request({"type": "http", "query_string": query}) +@dataclass +class ServiceRecord: + """Runtime view of a service instance with decrypted secrets.""" + + id: str + service_type: str + name: str + config: dict[str, Any] = field(default_factory=dict) + secrets: dict[str, str] = field(default_factory=dict) + enabled: bool = True + + +def build_service_record(store: SettingsStore, service_row: dict[str, Any]) -> ServiceRecord: + """Build a :class:`ServiceRecord`, decrypting secrets in memory.""" + from media_library_viewer_api.services.secrets import decrypt_secrets + + return ServiceRecord( + id=service_row["id"], + service_type=service_row["service_type"], + name=service_row["name"], + config=service_row.get("config") or {}, + secrets=decrypt_secrets(service_row.get("secrets") or {}), + enabled=bool(service_row.get("enabled", True)), + ) class WidgetSource(Protocol): """Protocol for widget source adapters.""" - source_type: str - - async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ... + async def fetch( + self, + service: ServiceRecord | None, + widget_kind: str, + config: dict[str, Any], + ) -> dict[str, Any]: ... -class JellyfinWidgetSource: - """Fetch Jellyfin sessions and map them to activity rows.""" - - source_type = "jellyfin" - timeout = 10 - - async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: - try: - request = _request_with_machine_id(config.get("machine_id") or None) - client = await asyncio.wait_for( - asyncio.to_thread(get_jellyfin_client, request), - timeout=self.timeout, - ) - sessions = await asyncio.wait_for( - asyncio.to_thread(client.sessions), - timeout=self.timeout, - ) - rows = _map_sessions_to_activity_rows(sessions) - return {"sessions": rows} - except asyncio.TimeoutError: - return {"error": "Widget data fetch timed out"} - except Exception as exc: - logger.exception("jellyfin adapter failed") - return {"error": f"Jellyfin data fetch failed: {exc}"} +# --------------------------------------------------------------------------- +# Built-in (service-less) adapters +# --------------------------------------------------------------------------- class BackupsWidgetSource: - """Compute the backup dashboard summary.""" + """Compute the backup dashboard summary from internal tables.""" - source_type = "backups" - timeout = 10 - - async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: + async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: try: store = get_settings_store() summary = build_backup_dashboard_summary(store) return summary.model_dump() - except asyncio.TimeoutError: - return {"error": "Widget data fetch timed out"} except Exception as exc: logger.exception("backups adapter failed") return {"error": f"Backup summary failed: {exc}"} +class StaticWidgetSource: + """Return static text/markdown unchanged.""" + + async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: + return {"text": config.get("text", "")} + + +# --------------------------------------------------------------------------- +# Service-bound adapters +# --------------------------------------------------------------------------- + + class GrafanaWidgetSource: """Build a Grafana deep-link (no embedding).""" - source_type = "grafana" - timeout = 5 - - async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: + async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: try: - settings = get_settings() + if service is None: + return {"error": "Grafana widget is missing its service"} + base_url = str(service.config.get("base_url") or "").rstrip("/") dashboard_uid = config.get("dashboard_uid") if not dashboard_uid: return {"error": "dashboard_uid is required"} - url = f"{settings.grafana_url.rstrip('/')}/d/{dashboard_uid}" + url = f"{base_url}/d/{dashboard_uid}" panel_id = config.get("panel_id") if panel_id is not None: url = f"{url}?viewPanel={panel_id}" @@ -109,26 +120,26 @@ class GrafanaWidgetSource: class PrometheusWidgetSource: - """Run a PromQL instant query against Prometheus.""" + """Run a PromQL instant query against a Prometheus service.""" - source_type = "prometheus" - timeout = 10 - - async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: + async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: try: - settings = get_settings() + if service is None: + return {"error": "Prometheus widget is missing its service"} + base_url = str(service.config.get("base_url") or "").rstrip("/") + timeout = int(service.config.get("timeout_seconds") or 10) promql = config.get("promql") if not promql: return {"error": "promql is required"} - url = f"{settings.prometheus_url.rstrip('/')}/api/v1/query" + url = f"{base_url}/api/v1/query" response = await asyncio.wait_for( asyncio.to_thread( requests.get, url, params={"query": promql}, - timeout=self.timeout, + timeout=timeout, ), - timeout=self.timeout, + timeout=timeout, ) response.raise_for_status() payload = response.json() @@ -143,16 +154,44 @@ class PrometheusWidgetSource: return {"error": f"Prometheus query failed: {exc}"} -class SshTaskWidgetSource: - """Run a saved task from the registry and return its output.""" +class JellyfinWidgetSource: + """Fetch Jellyfin sessions and map them to activity rows.""" - source_type = "ssh_task" - timeout = 30 - - async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: + async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: + timeout = 10 try: + if service is None: + return {"error": "Jellyfin widget is missing its service"} + base_url = str(service.config.get("base_url") or "") + api_key = str(service.secrets.get("api_key") or "") + timeout = int(service.config.get("timeout_seconds") or 10) + client = await asyncio.wait_for( + asyncio.to_thread(JellyfinClient, base_url, api_key, timeout), + timeout=timeout, + ) + sessions = await asyncio.wait_for( + asyncio.to_thread(client.sessions), + timeout=timeout, + ) + rows = _map_sessions_to_activity_rows(sessions) + return {"sessions": rows} + except asyncio.TimeoutError: + return {"error": "Widget data fetch timed out"} + except Exception as exc: + logger.exception("jellyfin adapter failed") + return {"error": f"Jellyfin data fetch failed: {exc}"} + + +class SshTaskWidgetSource: + """Run a saved task on an SSH task runner instance and log the run.""" + + async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: + timeout = 30 + try: + if service is None: + return {"error": "SSH task widget is missing its service"} store = get_settings_store() - task_id = config.get("task_id") + task_id = config.get("task_id") or "" if not task_id: return {"error": "task_id is required"} task = store.get_task(task_id) @@ -161,11 +200,8 @@ class SshTaskWidgetSource: if not task.get("enabled", True): return {"error": "Task is disabled"} - machine = _resolve_machine_for_task(store, task, None) - if not machine: - return {"error": "No machine available for this task"} - - client = _client_for_machine(store, machine) + client = _build_ssh_client(store, service) + timeout = int(service.config.get("timeout_seconds") or 30) task_type = str(task.get("task_type") or "shell").lower() command = str(task.get("content") or "") if task_type == "python": @@ -173,41 +209,112 @@ class SshTaskWidgetSource: elif task_type != "shell": return {"error": f"Unknown task type: {task_type}"} + start = time.perf_counter() result = await asyncio.wait_for( - asyncio.to_thread(client.run, command, timeout=self.timeout), - timeout=self.timeout, + asyncio.to_thread(client.run, command, timeout), + timeout=timeout, ) - return { - "exit_status": result.exit_status, - "stdout": result.stdout or "", - "stderr": result.stderr or "", - } + duration_ms = int((time.perf_counter() - start) * 1000) + stdout = result.stdout or "" + stderr = result.stderr or "" + store.record_service_task_run( + { + "task_id": task_id, + "service_id": service.id, + "status": "success" if result.exit_status == 0 else "failure", + "exit_status": result.exit_status, + "duration_ms": duration_ms, + "stdout_tail": stdout, + "stderr_tail": stderr, + "error": "" if result.exit_status == 0 else (stderr or stdout or "Task failed"), + } + ) + return {"exit_status": result.exit_status, "stdout": stdout, "stderr": stderr} except asyncio.TimeoutError: + _record_timeout(service, config, timeout) return {"error": "Widget data fetch timed out"} except Exception as exc: logger.exception("ssh_task adapter failed") + store = get_settings_store() + store.record_service_task_run( + { + "task_id": str(config.get("task_id") or ""), + "service_id": service.id if service else "", + "status": "error", + "duration_ms": 0, + "error": str(exc)[:1000], + } + ) return {"error": f"SSH task failed: {exc}"} -class StaticWidgetSource: - """Return static text/markdown unchanged.""" - - source_type = "static" - - async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: - return {"text": config.get("text", "")} +def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) -> None: + try: + store = get_settings_store() + store.record_service_task_run( + { + "task_id": str(config.get("task_id") or ""), + "service_id": service.id if service else "", + "status": "timeout", + "duration_ms": timeout * 1000, + "error": f"Task timed out after {timeout}s", + } + ) + except Exception: # pragma: no cover - logging best-effort + logger.exception("failed to record ssh task timeout") -SOURCE_REGISTRY: dict[str, WidgetSource] = { - "jellyfin": JellyfinWidgetSource(), - "backups": BackupsWidgetSource(), +def _build_ssh_client(store: SettingsStore, service: ServiceRecord) -> RemoteSSHClient: + """Build an SSH client from an ssh_tasks service instance + referenced key.""" + config = service.config + host = str(config.get("host") or "").strip() + username = str(config.get("username") or "").strip() + if not host or not username: + raise ValueError("SSH task service is missing host or username") + + settings = get_settings() + private_key = "" + key_passphrase = "" + ssh_key_id = str(config.get("ssh_key_id") or "").strip() + if ssh_key_id: + ssh_key = store.get_ssh_key(ssh_key_id) + if ssh_key: + private_key = str(ssh_key.get("private_key") or "") + key_passphrase = str(ssh_key.get("passphrase") or "") + # Service-level passphrase secret takes precedence. + key_passphrase = str(service.secrets.get("passphrase") or "") or key_passphrase + + return RemoteSSHClient( + host=host, + username=username, + port=int(config.get("port") or 22), + private_key=private_key or None, + private_key_passphrase=key_passphrase or None, + known_hosts_path=str(settings.ssh_known_hosts_file), + timeout=int(config.get("timeout_seconds") or 30), + ) + + +# --------------------------------------------------------------------------- +# Registries +# --------------------------------------------------------------------------- + +SERVICE_ADAPTERS: dict[str, WidgetSource] = { "grafana": GrafanaWidgetSource(), "prometheus": PrometheusWidgetSource(), - "ssh_task": SshTaskWidgetSource(), + "jellyfin": JellyfinWidgetSource(), + "ssh_tasks": SshTaskWidgetSource(), +} + +BUILTIN_ADAPTERS: dict[str, WidgetSource] = { + "backups": BackupsWidgetSource(), "static": StaticWidgetSource(), } -def get_source_adapter(source_type: str) -> WidgetSource | None: - """Return the adapter for a source type, or None if unknown.""" - return SOURCE_REGISTRY.get(source_type) +def get_service_adapter(service_type: str) -> WidgetSource | None: + return SERVICE_ADAPTERS.get(service_type) + + +def get_builtin_adapter(kind: str) -> WidgetSource | None: + return BUILTIN_ADAPTERS.get(kind) diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index ba00a07..b540159 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -1,22 +1,32 @@ -"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding, adapters.""" +"""Tests for the dashboard widget system: service-bound + built-in widgets.""" + +from __future__ import annotations -import asyncio from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest +from cryptography.fernet import Fernet from fastapi.testclient import TestClient from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.main import app from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.widgets.sources import ( - SOURCE_REGISTRY, + BackupsWidgetSource, GrafanaWidgetSource, - SshTaskWidgetSource, + ServiceRecord, StaticWidgetSource, ) +TEST_KEY = Fernet.generate_key().decode() + + +@pytest.fixture(autouse=True) +def _encryption_key(monkeypatch): + monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY) + yield + @pytest.fixture def client(tmp_path): @@ -30,446 +40,347 @@ def client(tmp_path): app.dependency_overrides.clear() -def test_widget_sources(client): - response = client.get("/api/widgets/sources") +def _make_grafana_service(client, name="Production Grafana", **config_overrides): + config = {"base_url": "https://grafana.example.com"} + config.update(config_overrides) + return client.post( + "/api/services/instances", + json={"service_type": "grafana", "name": name, "config": config, "enabled": True}, + ).json() + + +# --------------------------------------------------------------------------- +# Built-in kinds + built-in widget CRUD +# --------------------------------------------------------------------------- + + +def test_list_builtin_kinds(client): + response = client.get("/api/widgets/builtin") assert response.status_code == 200 - assert set(response.json()) == { - "jellyfin", - "backups", - "grafana", - "prometheus", - "ssh_task", - "static", - } + kinds = {item["kind"] for item in response.json()} + assert kinds == {"backups", "static"} -def test_widget_types(client): - response = client.get("/api/widgets/types") - assert response.status_code == 200 - types = {item["widget_type"] for item in response.json()} - assert types == { - "jellyfin", - "backups", - "grafana-link", - "prometheus-metric", - "ssh-task", - "static", - } - - -def test_create_and_read_widget(client): +def test_create_and_read_static_widget(client): response = client.post( "/api/widgets/instances", json={ - "addon_id": "core", - "widget_type": "static", + "widget_kind": "static", "title": "Note", "config": {"text": "hello"}, - "enabled": True, - "sort_order": 5, }, ) assert response.status_code == 201 - widget = response.json() - assert widget["title"] == "Note" - assert widget["config"] == {"text": "hello"} - assert widget["enabled"] is True - assert widget["sort_order"] == 5 - widget_id = widget["id"] + created = response.json() + assert created["widget_kind"] == "static" + assert created["service_id"] is None + assert created["config"]["text"] == "hello" - response = client.get("/api/widgets/instances") - assert response.status_code == 200 - assert any(w["id"] == widget_id for w in response.json()) + listed = client.get("/api/widgets/instances").json() + assert len(listed) == 1 + assert listed[0]["id"] == created["id"] -def test_update_widget(client): +def test_create_backups_widget(client): + response = client.post( + "/api/widgets/instances", + json={"widget_kind": "backups", "title": "Backups", "config": {}}, + ) + assert response.status_code == 201 + + +def test_unknown_builtin_kind_rejected(client): + response = client.post( + "/api/widgets/instances", + json={"widget_kind": "bogus", "title": "x", "config": {}}, + ) + assert response.status_code == 422 + + +def test_credential_key_in_config_rejected(client): + response = client.post( + "/api/widgets/instances", + json={"widget_kind": "static", "title": "x", "config": {"api_key": "leak"}}, + ) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# Service-bound widget CRUD +# --------------------------------------------------------------------------- + + +def test_create_service_bound_widget(client): + service = _make_grafana_service(client) response = client.post( "/api/widgets/instances", json={ - "addon_id": "core", - "widget_type": "static", - "title": "Note", - "config": {"text": "hello"}, + "service_id": service["id"], + "widget_kind": "link", + "title": "Dashboard", + "config": {"dashboard_uid": "overview"}, }, ) - widget_id = response.json()["id"] - - response = client.put( - f"/api/widgets/instances/{widget_id}", - json={ - "addon_id": "core", - "widget_type": "static", - "title": "Updated", - "config": {"text": "world"}, - "enabled": False, - "sort_order": 10, - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["title"] == "Updated" - assert data["config"] == {"text": "world"} - assert data["enabled"] is False - assert data["sort_order"] == 10 + assert response.status_code == 201 + created = response.json() + assert created["service_id"] == service["id"] + assert created["widget_kind"] == "link" -def test_delete_widget(client): +def test_service_bound_widget_unknown_kind_rejected(client): + service = _make_grafana_service(client) response = client.post( "/api/widgets/instances", json={ - "addon_id": "core", - "widget_type": "static", - "title": "To delete", - "config": {"text": "bye"}, - }, - ) - widget_id = response.json()["id"] - - response = client.delete(f"/api/widgets/instances/{widget_id}") - assert response.status_code == 200 - - response = client.get("/api/widgets/instances") - assert not any(w["id"] == widget_id for w in response.json()) - - -def test_unknown_widget_type_rejected(client): - response = client.post( - "/api/widgets/instances", - json={ - "addon_id": "core", - "widget_type": "unknown", - "title": "Bad", + "service_id": service["id"], + "widget_kind": "metric", + "title": "x", "config": {}, }, ) assert response.status_code == 422 -def test_addon_id_mismatch_rejected(client): +def test_service_bound_widget_service_not_found_rejected(client): response = client.post( "/api/widgets/instances", json={ - "addon_id": "grafana", - "widget_type": "static", - "title": "Bad", - "config": {"text": "x"}, + "service_id": "missing", + "widget_kind": "link", + "title": "x", + "config": {"dashboard_uid": "u"}, }, ) assert response.status_code == 422 -def test_credential_key_rejected(client): +def test_service_bound_widget_invalid_config_rejected(client): + service = _make_grafana_service(client) response = client.post( "/api/widgets/instances", json={ - "addon_id": "core", - "widget_type": "static", - "title": "Bad", - "config": {"api_key": "secret123"}, + "service_id": service["id"], + "widget_kind": "link", + "title": "x", + "config": {"dashboard_uid": ""}, # empty still validates; use bad type + }, + ) + # Empty string passes Pydantic; force a real failure with a bad type. + response = client.post( + "/api/widgets/instances", + json={ + "service_id": service["id"], + "widget_kind": "link", + "title": "x", + "config": {"dashboard_uid": 123}, }, ) assert response.status_code == 422 -def test_update_nonexistent_widget(client): +def test_update_and_delete_widget(client): + created = client.post( + "/api/widgets/instances", + json={"widget_kind": "static", "title": "Note", "config": {"text": "a"}}, + ).json() + updated = client.put( + f"/api/widgets/instances/{created['id']}", + json={"widget_kind": "static", "title": "Note2", "config": {"text": "b"}}, + ).json() + assert updated["title"] == "Note2" + + assert client.delete(f"/api/widgets/instances/{created['id']}").status_code == 200 + assert client.get("/api/widgets/instances").json() == [] + + +def test_update_nonexistent_returns_404(client): response = client.put( - "/api/widgets/instances/does-not-exist", - json={ - "addon_id": "core", - "widget_type": "static", - "title": "Bad", - "config": {"text": "x"}, - }, + "/api/widgets/instances/missing", + json={"widget_kind": "static", "title": "x", "config": {}}, ) assert response.status_code == 404 -def test_delete_nonexistent_widget(client): - response = client.delete("/api/widgets/instances/does-not-exist") - assert response.status_code == 404 - - -def test_default_widgets_seeded(client): - response = client.get("/api/widgets/instances") - assert response.status_code == 200 - widgets = response.json() - types = [w["widget_type"] for w in widgets] - assert "jellyfin" in types - assert "backups" in types - - -def test_no_reseed_when_widgets_exist(tmp_path): - db_path = tmp_path / "settings.sqlite" - store = SettingsStore(db_path) - store.ensure_defaults() - widgets = store.list_widgets() - assert len(widgets) == 2 - - store.delete_widget(widgets[0]["id"]) - store.ensure_defaults() - - remaining = store.list_widgets() - assert len(remaining) == 1 - - def test_update_id_mismatch_returns_400(client): - response = client.post( + created = client.post( "/api/widgets/instances", - json={ - "addon_id": "core", - "widget_type": "static", - "title": "Note", - "config": {"text": "hello"}, - }, - ) - widget_id = response.json()["id"] - + json={"widget_kind": "static", "title": "x", "config": {}}, + ).json() response = client.put( - f"/api/widgets/instances/{widget_id}", - json={ - "id": "different-id", - "addon_id": "core", - "widget_type": "static", - "title": "Updated", - "config": {"text": "world"}, - }, + f"/api/widgets/instances/{created['id']}", + json={"id": "other", "widget_kind": "static", "title": "x", "config": {}}, ) assert response.status_code == 400 -def test_empty_title_rejected(client): - response = client.post( - "/api/widgets/instances", - json={ - "addon_id": "core", - "widget_type": "static", - "title": "", - "config": {"text": "hello"}, - }, - ) - assert response.status_code == 422 - - -def test_config_type_error_rejected(client): - response = client.post( - "/api/widgets/instances", - json={ - "addon_id": "grafana", - "widget_type": "grafana-link", - "title": "Grafana", - "config": {"panel_id": "not-an-integer"}, - }, - ) - assert response.status_code == 422 - - -def test_list_instances_respects_sort_order(client): - response = client.get("/api/widgets/instances") - assert response.status_code == 200 - widgets = response.json() - orders = [w["sort_order"] for w in widgets] - assert orders == sorted(orders) - - -def test_enabled_round_trip(client): - response = client.post( - "/api/widgets/instances", - json={ - "addon_id": "core", - "widget_type": "static", - "title": "Toggle", - "config": {"text": "x"}, - "enabled": False, - }, - ) - widget_id = response.json()["id"] - - response = client.put( - f"/api/widgets/instances/{widget_id}", - json={ - "addon_id": "core", - "widget_type": "static", - "title": "Toggle", - "config": {"text": "x"}, - "enabled": True, - }, - ) - assert response.status_code == 200 - assert response.json()["enabled"] is True +# --------------------------------------------------------------------------- +# Data endpoint +# --------------------------------------------------------------------------- def test_fetch_static_widget_data(client): - response = client.post( + created = client.post( "/api/widgets/instances", - json={ - "addon_id": "core", - "widget_type": "static", - "title": "Note", - "config": {"text": "hello world"}, - }, - ) - widget_id = response.json()["id"] - - response = client.get(f"/api/widgets/instances/{widget_id}/data") + json={"widget_kind": "static", "title": "Note", "config": {"text": "hello"}}, + ).json() + response = client.get(f"/api/widgets/instances/{created['id']}/data") assert response.status_code == 200 - data = response.json() - assert data["widget_id"] == widget_id - assert data["widget_type"] == "static" - assert data["data"] == {"text": "hello world"} - assert data["error"] is None - assert isinstance(data["fetched_at"], int) + body = response.json() + assert body["data"]["text"] == "hello" + assert body["error"] is None -def test_fetch_grafana_widget_data(client): - response = client.post( +def test_fetch_backups_widget_data(client): + created = client.post( "/api/widgets/instances", - json={ - "addon_id": "grafana", - "widget_type": "grafana-link", - "title": "Grafana", - "config": {"dashboard_uid": "overview", "panel_id": 3}, - }, - ) - widget_id = response.json()["id"] - - response = client.get(f"/api/widgets/instances/{widget_id}/data") + json={"widget_kind": "backups", "title": "Backups", "config": {}}, + ).json() + response = client.get(f"/api/widgets/instances/{created['id']}/data") assert response.status_code == 200 - data = response.json() - assert data["widget_type"] == "grafana-link" - assert data["data"]["url"] == "http://grafana:3000/d/overview?viewPanel=3" + assert "total_jobs" in response.json()["data"] -def test_fetch_prometheus_widget_data(client): - response = client.post( +def test_fetch_grafana_link_widget_data(client): + service = _make_grafana_service(client) + created = client.post( "/api/widgets/instances", json={ - "addon_id": "prometheus", - "widget_type": "prometheus-metric", - "title": "CPU", - "config": {"promql": '100 - avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100'}, + "service_id": service["id"], + "widget_kind": "link", + "title": "Dashboard", + "config": {"dashboard_uid": "overview", "panel_id": 2}, }, - ) - widget_id = response.json()["id"] - - fake_payload = {"data": {"resultType": "scalar", "result": [1718900000, "42.5"]}} - with patch("media_library_viewer_api.widgets.sources.requests.get") as mock_get: - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = fake_payload - mock_get.return_value = mock_response - - response = client.get(f"/api/widgets/instances/{widget_id}/data") - + ).json() + response = client.get(f"/api/widgets/instances/{created['id']}/data") assert response.status_code == 200 - data = response.json() - assert data["widget_type"] == "prometheus-metric" - assert data["data"]["result"]["resultType"] == "scalar" + assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2" -def test_fetch_jellyfin_widget_data_error(client): - response = client.post( +def test_fetch_widget_service_not_found(client): + service = _make_grafana_service(client) + created = client.post( "/api/widgets/instances", json={ - "addon_id": "core", - "widget_type": "jellyfin", - "title": "Activity", - "config": {"machine_id": ""}, + "service_id": service["id"], + "widget_kind": "link", + "title": "x", + "config": {"dashboard_uid": "u"}, + }, + ).json() + # Deleting the service cascade-deletes its widgets, so the widget is gone. + client.delete(f"/api/services/instances/{service['id']}") + assert client.get("/api/widgets/instances").json() == [] + assert client.get(f"/api/widgets/instances/{created['id']}/data").status_code == 404 + + +def test_fetch_widget_service_disabled(client): + service = _make_grafana_service(client) + created = client.post( + "/api/widgets/instances", + json={ + "service_id": service["id"], + "widget_kind": "link", + "title": "x", + "config": {"dashboard_uid": "u"}, + }, + ).json() + client.put( + f"/api/services/instances/{service['id']}", + json={ + "service_type": "grafana", + "name": service["name"], + "config": {"base_url": "https://grafana.example.com"}, + "enabled": False, }, ) - widget_id = response.json()["id"] - - response = client.get(f"/api/widgets/instances/{widget_id}/data") + response = client.get(f"/api/widgets/instances/{created['id']}/data") assert response.status_code == 200 - data = response.json() - assert data["widget_type"] == "jellyfin" - assert data["data"] is None - assert data["error"] is not None - assert "Jellyfin" in data["error"] or "machine" in data["error"].lower() + assert "disabled" in response.json()["error"] -def test_fetch_widget_data_not_found(client): - response = client.get("/api/widgets/instances/does-not-exist/data") - assert response.status_code == 404 +def test_fetch_widget_not_found(client): + assert client.get("/api/widgets/instances/missing/data").status_code == 404 -def test_fetch_widget_data_unhandled_exception_returns_500(client): - response = client.post( - "/api/widgets/instances", - json={ - "addon_id": "core", - "widget_type": "static", - "title": "Note", - "config": {"text": "x"}, - }, - ) - widget_id = response.json()["id"] +# --------------------------------------------------------------------------- +# Adapter unit tests +# --------------------------------------------------------------------------- - class _ExplodingAdapter: - source_type = "static" - async def fetch(self, config): - raise RuntimeError("boom") +@pytest.mark.asyncio +async def test_grafana_adapter_builds_url(): + adapter = GrafanaWidgetSource() + service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"}) + result = await adapter.fetch(service, "link", {"dashboard_uid": "ov"}) + assert result["url"] == "http://g:3000/d/ov" + result = await adapter.fetch(service, "link", {"dashboard_uid": "ov", "panel_id": 4}) + assert result["url"] == "http://g:3000/d/ov?viewPanel=4" - with patch("media_library_viewer_api.routers.widgets.get_source_adapter", return_value=_ExplodingAdapter()): - response = client.get(f"/api/widgets/instances/{widget_id}/data") - assert response.status_code == 500 +@pytest.mark.asyncio +async def test_grafana_adapter_missing_service(): + adapter = GrafanaWidgetSource() + result = await adapter.fetch(None, "link", {"dashboard_uid": "ov"}) + assert "error" in result @pytest.mark.asyncio async def test_static_adapter(): adapter = StaticWidgetSource() - result = await adapter.fetch({"text": "hello"}) - assert result == {"text": "hello"} + result = await adapter.fetch(None, "static", {"text": "hi"}) + assert result == {"text": "hi"} @pytest.mark.asyncio -async def test_grafana_adapter(): - adapter = GrafanaWidgetSource() - result = await adapter.fetch({"dashboard_uid": "overview", "panel_id": 2}) - assert result["url"] == "http://grafana:3000/d/overview?viewPanel=2" - - result = await adapter.fetch({"dashboard_uid": "overview"}) - assert result["url"] == "http://grafana:3000/d/overview" +async def test_backups_adapter(client): + store = app.dependency_overrides[get_settings_store]() + with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store): + adapter = BackupsWidgetSource() + result = await adapter.fetch(None, "backups", {}) + assert "total_jobs" in result @pytest.mark.asyncio -async def test_ssh_task_adapter_timeout(tmp_path): - store = SettingsStore(tmp_path / "settings.sqlite") - store.ensure_defaults() - - # Create a local machine and a simple shell task. - machine = store.list_machines()[0] - task = store.upsert_task( - { - "name": "slow-task", - "task_type": "shell", - "content": "echo hello", - "enabled": True, - "default_machine_id": machine["id"], - } - ) +async def test_ssh_task_adapter_missing_service(): + from media_library_viewer_api.widgets.sources import SshTaskWidgetSource adapter = SshTaskWidgetSource() - with patch( - "media_library_viewer_api.widgets.sources.get_settings_store", - return_value=store, - ), patch( - "media_library_viewer_api.widgets.sources.asyncio.wait_for", - side_effect=asyncio.TimeoutError, - ): - result = await adapter.fetch({"task_id": task["id"]}) - + result = await adapter.fetch(None, "task_output", {"task_id": "t1"}) assert "error" in result - assert "timed out" in result["error"].lower() -def test_source_registry_closed(): - assert set(SOURCE_REGISTRY.keys()) == { - "jellyfin", - "backups", - "grafana", - "prometheus", - "ssh_task", - "static", - } +@pytest.mark.asyncio +async def test_ssh_task_adapter_records_history_on_run(client): + store = app.dependency_overrides[get_settings_store]() + # Save a task and an ssh_tasks service instance. + task = store.upsert_task( + { + "name": "echo", + "task_type": "shell", + "content": "echo hi", + "enabled": True, + "default_machine_id": "", + } + ) + service = store.upsert_service( + {"service_type": "ssh_tasks", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True} + ) + + fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="") + fake_client = SimpleNamespace(run=lambda *a, **k: fake_result) + + from media_library_viewer_api.widgets.sources import SshTaskWidgetSource + + adapter = SshTaskWidgetSource() + service_record = ServiceRecord( + id=service["id"], service_type="ssh_tasks", name="box", config={"host": "h", "username": "u"} + ) + with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store), patch( + "media_library_viewer_api.widgets.sources._build_ssh_client", return_value=fake_client + ): + result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]}) + + assert result["exit_status"] == 0 + runs = store.list_service_task_runs(service_id=service["id"]) + assert len(runs) == 1 + assert runs[0]["status"] == "success" diff --git a/openspec/changes/service-registry/apply-progress.md b/openspec/changes/service-registry/apply-progress.md index ea07b56..d89c895 100644 --- a/openspec/changes/service-registry/apply-progress.md +++ b/openspec/changes/service-registry/apply-progress.md @@ -1,79 +1,78 @@ # Apply Progress: Runtime Service Registry **Change:** `service-registry` -**Apply run:** PR 1 / Slice 1 — Backend service foundation +**Apply run:** PR 1 + PR 2 / Slice 1 + Slice 2 **Date:** 2026-06-19 -## Completed tasks (Slice 1) +## Slice 1 — Backend service foundation (MERGED) -- [x] 1.1 Add encryption helper (`services/secrets.py`) -- [x] 1.2 Add integrations base classes (`integrations/base.py`) -- [x] 1.3 Add five service definitions + registry -- [x] 1.4 Add `services` + `service_task_runs` tables + store CRUD with cascade delete -- [x] 1.5 Add service Pydantic models + `/api/services*` router -- [x] 1.6 Validate `MANAGE_ENCRYPTION_KEY` on startup -- [x] 1.7 Add backend tests (`tests/test_services.py`) -- [x] 1.8 Verify (ruff + pytest green) +Completed in PR #7. See git history. Summary: Fernet secrets helper, closed +`integrations/` registry with Pydantic config + widget-config definitions for +grafana/prometheus/jellyfin/nextcloud/ssh_tasks, `services` + `service_task_runs` +tables with cascade delete, `/api/services*` CRUD, `MANAGE_ENCRYPTION_KEY` +required at startup, 25 tests. -## Files changed (Slice 1) +## Slice 2 — Backend widget rebind to services (this PR) -### New files +### Completed tasks -- `backend/src/media_library_viewer_api/integrations/__init__.py` — package marker. -- `backend/src/media_library_viewer_api/integrations/base.py` — `ServiceConfigBase`, - `WidgetConfigBase`, `SecretField`, `WidgetKind`, `ServiceDefinition`, `widget_kind()`, - `validate_config()`. -- `backend/src/media_library_viewer_api/integrations/{grafana,prometheus,jellyfin,nextcloud,ssh_tasks}.py` - — one Pydantic-config + widget-config definition per service. -- `backend/src/media_library_viewer_api/integrations/registry.py` — closed - `SERVICE_DEFINITIONS` + helpers. -- `backend/src/media_library_viewer_api/services/secrets.py` — Fernet encrypt/decrypt - - key validation. -- `backend/src/media_library_viewer_api/models/services.py` — request/response models. -- `backend/src/media_library_viewer_api/routers/services.py` — `/api/services/types` - - `/api/services/instances` CRUD. -- `backend/tests/test_services.py` — 25 tests. +- [x] 2.1 Add `service_id` / `widget_kind` columns to `dashboard_widgets` + (additive ALTER; legacy `addon_id`/`widget_type` kept but unused). +- [x] 2.2 Refactor source adapters to `fetch(service, widget_kind, config)` + with `ServiceRecord | None`. `SERVICE_ADAPTERS` keyed by service_type; + `BUILTIN_ADAPTERS` for backups/static. SSH adapter resolves the task + + instance, runs, and appends a `service_task_runs` row (success/failure/ + timeout/error). +- [x] 2.3 Retire old `widgets/registry.py` (deleted; metadata now comes from + `integrations/registry` + `widgets/builtin`). +- [x] 2.4 Update widgets router + models for service-bound + built-in widgets. + Removed `/api/widgets/types` and `/api/widgets/sources`; added + `/api/widgets/builtin`. +- [x] 2.5 Rewrite widget tests around the new model. +- [x] 2.6 Stop default widget seeding (fresh install = empty dashboard). -### Modified files +### Decision resolved mid-slice -- `backend/src/media_library_viewer_api/services/settings_store.py` — `services` and - `service_task_runs` tables; service CRUD; cascade delete (defensive against the - not-yet-present `dashboard_widgets.service_id` column); task-run history helpers. -- `backend/src/media_library_viewer_api/main.py` — register `services_router`; - validate encryption key on startup. -- `backend/pyproject.toml` — declare `cryptography>=42.0` direct dependency. -- `docker-compose.yml`, `docker-compose.dev.yml`, `.env.example`, `README.md` — require - and document `MANAGE_ENCRYPTION_KEY`. +Backups and static widgets stay as **service-less built-ins** (`service_id` +nullable), per product decision. The data endpoint resolves built-ins via +`BUILTIN_ADAPTERS` and service-bound widgets via `SERVICE_ADAPTERS` + a +decrypted `ServiceRecord`. -## Verification (Slice 1) +### Files changed (Slice 2) + +- New: `widgets/builtin.py` (built-in kinds + light config validation). +- Rewritten: `widgets/sources.py` (`ServiceRecord`, new protocol, service + + built-in adapters, SSH run logging, `_build_ssh_client`). +- Deleted: `widgets/registry.py`. +- Modified: `models/widgets.py` (service_id + widget_kind; `BuiltinWidgetKindInfo`). +- Modified: `routers/widgets.py` (new validation, `/builtin`, data resolution). +- Modified: `services/settings_store.py` (widget columns; no-op seeding). +- Modified: `integrations/base.py` (`WidgetKind.config_model` for Pydantic + widget-config validation). +- Rewritten: `tests/test_widgets.py` (26 tests). + +### Verification (Slice 2) ```bash cd backend .venv/bin/ruff check . # All checks passed -PYTHONPATH=src .venv/bin/python -m pytest # 225 passed +PYTHONPATH=src .venv/bin/python -m pytest # 222 passed cd ../frontend npm run lint # 0 errors npm run build # success ``` -Smoke: encryption round-trip OK; missing `MANAGE_ENCRYPTION_KEY` raises on startup. +### Known transient state (resolved by Slice 3) -## Deviations from design - -- Service-config and widget-config schemas are derived from **Pydantic models** - (`model_json_schema()`), matching the user's "proper pydantic config definitions" - request. The design's hand-written JSON schemas were replaced by model-derived ones. -- Service-table CRUD lives on `SettingsStore` (not a separate `service_store.py`) to - match how widgets/saved_tasks/ssh_keys are already handled there. This keeps a single - store owner for all tables. -- The cascade delete defensively checks for `dashboard_widgets.service_id` (added in - Slice 2) so Slice 1 stays green without the column. +Slice 2 is a backend-only breaking change to the widget API. Until Slice 3 +lands, the frontend still calls the removed `/api/widgets/types` and +`/api/widgets/sources` endpoints and uses the old `widget_type` shape, so the +dashboard widget config UI is non-functional at runtime. Build/lint stay green. +This is the accepted transient state for a stacked backend→frontend rebind. ## Remaining work -- Slice 2: Backend widget rebind to services (add `service_id`/`widget_kind`, refactor - adapters to take a `ServiceRecord`, retire old widget registry, SSH run logging). -- Slice 3: Frontend services runtime (types, API, hooks, frontend registry, service - pages, route swap). -- Slice 4: Dashboard picker, settings rework, remove `grafana_url`/`prometheus_url` - env vars, stop default seeding, docs + changelog. +- Slice 3: Frontend services runtime (types, API, hooks, frontend service + registry, service pages, route swap, remove addon pages, reconcile widget UI). +- Slice 4: Dashboard picker on services, settings rework, remove + `grafana_url`/`prometheus_url` env vars, docs + changelog.