From 1cd8e926de1e97cf5601776367bce1c362a7c786 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 21 Jun 2026 10:09:45 +0000 Subject: [PATCH] feat(widgets): add backend source adapters and per-widget data endpoint PR 2 of 4 for configurable dashboard widgets. - Add grafana_url and prometheus_url settings (config.py + compose/env). - Create WidgetSource protocol and adapters for jellyfin, backups, grafana, prometheus, ssh_task, and static sources. - Add GET /api/widgets/instances/{id}/data endpoint. - Extract shared dashboard helpers into domain/dashboard.py so widgets and the dashboard router reuse the same logic. - Add adapter and data-endpoint tests. - Update apply-progress.md. Verification: ruff clean; backend pytest 200 passed; frontend lint/build green. --- .env.example | 2 + .../src/media_library_viewer_api/config.py | 2 + .../domain/dashboard.py | 91 ++++++++ .../routers/dashboard.py | 85 +------ .../routers/widgets.py | 64 +++++- .../widgets/sources.py | 213 ++++++++++++++++++ backend/tests/test_widgets.py | 188 +++++++++++++++- docker-compose.dev.yml | 2 + docker-compose.yml | 2 + .../apply-progress.md | 43 +++- 10 files changed, 607 insertions(+), 85 deletions(-) create mode 100644 backend/src/media_library_viewer_api/domain/dashboard.py create mode 100644 backend/src/media_library_viewer_api/widgets/sources.py diff --git a/.env.example b/.env.example index f29aa87..a122a26 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,8 @@ PROMETHEUS_ENABLED=true PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd ALERTMANAGER_URL=http://alertmanager:9093 ALERTMANAGER_WEBHOOK_URL= +GRAFANA_URL=http://grafana:3000 +PROMETHEUS_URL=http://prometheus:9090 BACKEND_CACHE_DIR=./backend-cache # Auth diff --git a/backend/src/media_library_viewer_api/config.py b/backend/src/media_library_viewer_api/config.py index 7056b28..fac2884 100644 --- a/backend/src/media_library_viewer_api/config.py +++ b/backend/src/media_library_viewer_api/config.py @@ -57,6 +57,8 @@ class Settings(BaseSettings): prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd" alertmanager_url: str = "http://alertmanager:9093" alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications + grafana_url: str = "http://grafana:3000" + prometheus_url: str = "http://prometheus:9090" # Remote paths remote_media_root: str = "" diff --git a/backend/src/media_library_viewer_api/domain/dashboard.py b/backend/src/media_library_viewer_api/domain/dashboard.py new file mode 100644 index 0000000..389581b --- /dev/null +++ b/backend/src/media_library_viewer_api/domain/dashboard.py @@ -0,0 +1,91 @@ +"""Dashboard domain helpers shared between routers and widget adapters.""" + +from __future__ import annotations + +import time +from typing import Any + +from media_library_viewer_api.models.backups import BackupDashboardSummary +from media_library_viewer_api.services.settings_store import SettingsStore + + +def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Normalize Jellyfin sessions into dashboard activity rows.""" + results: list[dict[str, Any]] = [] + for session in sessions: + item = session.get("NowPlayingItem") or {} + play_state = session.get("PlayState") or {} + transcoding = session.get("TranscodingInfo") or {} + + has_item = bool(item) + series = item.get("SeriesName") or "" + title = ( + (f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")) + if has_item + else "(idle)" + ) + + if not has_item: + state_label = "idle" + else: + state_label = "paused" if play_state.get("IsPaused") else "playing" + + is_transcoding = bool(transcoding) + transcode_type: list[str] = [] + if is_transcoding: + if transcoding.get("IsVideoDirect") is False: + transcode_type.append("video") + if transcoding.get("IsAudioDirect") is False: + transcode_type.append("audio") + if not transcode_type: + transcode_type.append("active") + + results.append( + { + "user": session.get("UserName") or "Unknown", + "title": title, + "type": item.get("Type", "") if has_item else "", + "state": state_label, + "transcoding": "yes" if is_transcoding else "no", + "transcoding_type": ", ".join(transcode_type), + "device": session.get("DeviceName") or session.get("Client") or "", + "session_id": session.get("Id") or "", + } + ) + return results + + +def build_backup_dashboard_summary(store: SettingsStore) -> BackupDashboardSummary: + """Compute the backup summary shown on the dashboard.""" + jobs = store.list_backup_jobs() + total_jobs = len(jobs) + + cutoff = int(time.time()) - (24 * 60 * 60) + recent_runs = [] + for job in jobs: + runs = store.list_backup_runs(job_id=job["id"], limit=1) + if runs and runs[0]["started_at"] >= cutoff: + recent_runs.append(runs[0]) + + successful = sum(1 for r in recent_runs if r["status"] == "success") + success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0 + + alerts = store.list_backup_alerts(acknowledged=False) + active_alerts = len(alerts) + + failed_runs = [] + for job in jobs: + runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1) + if runs: + failed_runs.append(runs[0]) + + last_failed_at = None + if failed_runs: + last_failed_at = max(r["started_at"] for r in failed_runs) + + return BackupDashboardSummary( + total_jobs=total_jobs, + success_rate_24h=round(success_rate, 1), + active_alerts=active_alerts, + last_failed_at=last_failed_at, + ) diff --git a/backend/src/media_library_viewer_api/routers/dashboard.py b/backend/src/media_library_viewer_api/routers/dashboard.py index 6a7cf8b..a9a54d1 100644 --- a/backend/src/media_library_viewer_api/routers/dashboard.py +++ b/backend/src/media_library_viewer_api/routers/dashboard.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import time from typing import Any from fastapi import APIRouter, Depends @@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import ( get_settings_store, get_user_id, ) +from media_library_viewer_api.domain.dashboard import ( + _map_sessions_to_activity_rows, + build_backup_dashboard_summary, +) from media_library_viewer_api.models.backups import BackupDashboardSummary from media_library_viewer_api.services.settings_store import SettingsStore @@ -85,50 +88,6 @@ def delete_shortcut( return {"status": "deleted"} -def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Normalize Jellyfin sessions into dashboard activity rows.""" - results: list[dict[str, Any]] = [] - for session in sessions: - item = session.get("NowPlayingItem") or {} - play_state = session.get("PlayState") or {} - transcoding = session.get("TranscodingInfo") or {} - - has_item = bool(item) - series = item.get("SeriesName") or "" - title = ( - (f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")) if has_item else "(idle)" - ) - - if not has_item: - state_label = "idle" - else: - state_label = "paused" if play_state.get("IsPaused") else "playing" - - is_transcoding = bool(transcoding) - transcode_type: list[str] = [] - if is_transcoding: - if transcoding.get("IsVideoDirect") is False: - transcode_type.append("video") - if transcoding.get("IsAudioDirect") is False: - transcode_type.append("audio") - if not transcode_type: - transcode_type.append("active") - - results.append( - { - "user": session.get("UserName") or "Unknown", - "title": title, - "type": item.get("Type", "") if has_item else "", - "state": state_label, - "transcoding": "yes" if is_transcoding else "no", - "transcoding_type": ", ".join(transcode_type), - "device": session.get("DeviceName") or session.get("Client") or "", - "session_id": session.get("Id") or "", - } - ) - return results - - @router.get("/activity") def get_activity( client: JellyfinClient = Depends(get_jellyfin_client), @@ -154,38 +113,4 @@ def get_now_playing( def get_backup_dashboard( store: SettingsStore = Depends(get_settings_store), ) -> BackupDashboardSummary: - jobs = store.list_backup_jobs() - total_jobs = len(jobs) - - # Calculate 24h success rate - cutoff = int(time.time()) - (24 * 60 * 60) - recent_runs = [] - for job in jobs: - runs = store.list_backup_runs(job_id=job["id"], limit=1) - if runs and runs[0]["started_at"] >= cutoff: - recent_runs.append(runs[0]) - - successful = sum(1 for r in recent_runs if r["status"] == "success") - success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0 - - # Active alerts - alerts = store.list_backup_alerts(acknowledged=False) - active_alerts = len(alerts) - - # Last failed - failed_runs = [] - for job in jobs: - runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1) - if runs: - failed_runs.append(runs[0]) - - last_failed_at = None - if failed_runs: - last_failed_at = max(r["started_at"] for r in failed_runs) - - return BackupDashboardSummary( - total_jobs=total_jobs, - success_rate_24h=round(success_rate, 1), - active_alerts=active_alerts, - last_failed_at=last_failed_at, - ) + return build_backup_dashboard_summary(store) diff --git a/backend/src/media_library_viewer_api/routers/widgets.py b/backend/src/media_library_viewer_api/routers/widgets.py index 649cfdf..37c7a26 100644 --- a/backend/src/media_library_viewer_api/routers/widgets.py +++ b/backend/src/media_library_viewer_api/routers/widgets.py @@ -1,20 +1,30 @@ """REST API for dashboard widget instances and registry metadata.""" +import logging +import time 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.models.widgets import WidgetInstance, WidgetInstanceInput, WidgetTypeInfo +from media_library_viewer_api.models.widgets import ( + 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.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 @@ -56,7 +66,7 @@ def list_sources() -> list[str]: @router.get("/types") -def list_types() -> list[WidgetTypeInfo]: +def list_types() -> list[dict[str, Any]]: """Return metadata for all registered widget types.""" return [info.model_dump() for info in list_widget_types()] @@ -111,3 +121,53 @@ def delete_instance( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found") store.delete_widget(widget_id) return {"status": "deleted"} + + +@router.get("/instances/{widget_id}/data") +async def fetch_data( + widget_id: str, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + """Fetch widget data through the registered source adapter.""" + widget = store.get_widget(widget_id) + 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() + + 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() + + try: + data = await adapter.fetch(widget["config"]) + except Exception as exc: + logger.exception("Unhandled adapter exception widget_id=%s", widget_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Widget data fetch failed", + ) from exc + + 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()), + ).model_dump() diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py new file mode 100644 index 0000000..c3dde2a --- /dev/null +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -0,0 +1,213 @@ +"""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. +""" + +from __future__ import annotations + +import asyncio +import logging +import shlex +from typing import Any, Protocol + +import requests +from starlette.requests import Request + +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 + +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}) + + +class WidgetSource(Protocol): + """Protocol for widget source adapters.""" + + source_type: str + + async def fetch(self, 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}"} + + +class BackupsWidgetSource: + """Compute the backup dashboard summary.""" + + source_type = "backups" + timeout = 10 + + async def fetch(self, 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 GrafanaWidgetSource: + """Build a Grafana deep-link (no embedding).""" + + source_type = "grafana" + timeout = 5 + + async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: + try: + settings = get_settings() + 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}" + panel_id = config.get("panel_id") + if panel_id is not None: + url = f"{url}?viewPanel={panel_id}" + return {"url": url} + except Exception as exc: + logger.exception("grafana adapter failed") + return {"error": f"Grafana link failed: {exc}"} + + +class PrometheusWidgetSource: + """Run a PromQL instant query against Prometheus.""" + + source_type = "prometheus" + timeout = 10 + + async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: + try: + settings = get_settings() + promql = config.get("promql") + if not promql: + return {"error": "promql is required"} + url = f"{settings.prometheus_url.rstrip('/')}/api/v1/query" + response = await asyncio.wait_for( + asyncio.to_thread( + requests.get, + url, + params={"query": promql}, + timeout=self.timeout, + ), + timeout=self.timeout, + ) + response.raise_for_status() + payload = response.json() + return {"result": payload.get("data", {})} + except asyncio.TimeoutError: + return {"error": "Widget data fetch timed out"} + except requests.RequestException as exc: + logger.exception("prometheus adapter failed") + return {"error": f"Prometheus query failed: {exc}"} + except Exception as exc: + logger.exception("prometheus adapter failed") + return {"error": f"Prometheus query failed: {exc}"} + + +class SshTaskWidgetSource: + """Run a saved task from the registry and return its output.""" + + source_type = "ssh_task" + timeout = 30 + + async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: + try: + store = get_settings_store() + task_id = config.get("task_id") + if not task_id: + return {"error": "task_id is required"} + task = store.get_task(task_id) + if not task: + return {"error": f"Task {task_id} not found"} + 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) + task_type = str(task.get("task_type") or "shell").lower() + command = str(task.get("content") or "") + if task_type == "python": + command = f"python3 -c {shlex.quote(command)}" + elif task_type != "shell": + return {"error": f"Unknown task type: {task_type}"} + + result = await asyncio.wait_for( + asyncio.to_thread(client.run, command, timeout=self.timeout), + timeout=self.timeout, + ) + return { + "exit_status": result.exit_status, + "stdout": result.stdout or "", + "stderr": result.stderr or "", + } + except asyncio.TimeoutError: + return {"error": "Widget data fetch timed out"} + except Exception as exc: + logger.exception("ssh_task adapter failed") + 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", "")} + + +SOURCE_REGISTRY: dict[str, WidgetSource] = { + "jellyfin": JellyfinWidgetSource(), + "backups": BackupsWidgetSource(), + "grafana": GrafanaWidgetSource(), + "prometheus": PrometheusWidgetSource(), + "ssh_task": SshTaskWidgetSource(), + "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) diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index 75b8907..ba00a07 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -1,7 +1,8 @@ -"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding.""" +"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding, adapters.""" +import asyncio from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient @@ -9,6 +10,12 @@ 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, + GrafanaWidgetSource, + SshTaskWidgetSource, + StaticWidgetSource, +) @pytest.fixture @@ -289,3 +296,180 @@ def test_enabled_round_trip(client): ) assert response.status_code == 200 assert response.json()["enabled"] is True + + +def test_fetch_static_widget_data(client): + response = 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") + 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) + + +def test_fetch_grafana_widget_data(client): + response = 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") + 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" + + +def test_fetch_prometheus_widget_data(client): + response = 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'}, + }, + ) + 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") + + assert response.status_code == 200 + data = response.json() + assert data["widget_type"] == "prometheus-metric" + assert data["data"]["result"]["resultType"] == "scalar" + + +def test_fetch_jellyfin_widget_data_error(client): + response = client.post( + "/api/widgets/instances", + json={ + "addon_id": "core", + "widget_type": "jellyfin", + "title": "Activity", + "config": {"machine_id": ""}, + }, + ) + widget_id = response.json()["id"] + + response = client.get(f"/api/widgets/instances/{widget_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() + + +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_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"] + + class _ExplodingAdapter: + source_type = "static" + + async def fetch(self, config): + raise RuntimeError("boom") + + 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_static_adapter(): + adapter = StaticWidgetSource() + result = await adapter.fetch({"text": "hello"}) + assert result == {"text": "hello"} + + +@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" + + +@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"], + } + ) + + 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"]}) + + 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", + } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7e89916..b41a536 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -17,6 +17,8 @@ services: PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093} ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-} + GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000} + PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090} ports: - "8000:8000" volumes: diff --git a/docker-compose.yml b/docker-compose.yml index f7726aa..5febad9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,8 @@ services: PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd} ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093} ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-} + GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000} + PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090} volumes: - ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache restart: unless-stopped diff --git a/openspec/changes/configurable-dashboard-widgets/apply-progress.md b/openspec/changes/configurable-dashboard-widgets/apply-progress.md index bd881bf..55b3208 100644 --- a/openspec/changes/configurable-dashboard-widgets/apply-progress.md +++ b/openspec/changes/configurable-dashboard-widgets/apply-progress.md @@ -51,9 +51,50 @@ Focused widget test output: `12 passed`. - None significant for Slice 1. The implementation follows the design's backend CRUD layout. - Used `HTTP_422_UNPROCESSABLE_CONTENT` instead of the deprecated `HTTP_422_UNPROCESSABLE_ENTITY`. +## Completed tasks (Slice 2) + +All Slice 2 tasks are marked `- [x]` in `tasks.md`: + +- [x] 2.1 Add observability URL settings (`grafana_url`, `prometheus_url`) +- [x] 2.2 Create source adapters (`jellyfin`, `backups`, `grafana`, `prometheus`, `ssh_task`, `static`) +- [x] 2.3 Add per-widget data endpoint (`GET /api/widgets/instances/{id}/data`) +- [x] 2.4 Extract shared backup/Jellyfin dashboard helpers into `domain/dashboard.py` +- [x] 2.5 Add adapter + data endpoint tests + +## Files changed (Slice 2) + +### New files + +- `backend/src/media_library_viewer_api/widgets/sources.py` — `WidgetSource` protocol and six source adapters. +- `backend/src/media_library_viewer_api/domain/dashboard.py` — Shared dashboard helpers (`_map_sessions_to_activity_rows`, `build_backup_dashboard_summary`). + +### Modified files + +- `backend/src/media_library_viewer_api/config.py` — Added `grafana_url` and `prometheus_url` settings. +- `backend/src/media_library_viewer_api/routers/widgets.py` — Added `GET /api/widgets/instances/{id}/data`. +- `backend/src/media_library_viewer_api/routers/dashboard.py` — Delegated to shared `domain/dashboard.py` helpers. +- `backend/tests/test_widgets.py` — Added adapter and data endpoint tests. +- `docker-compose.yml`, `docker-compose.dev.yml`, `.env.example` — Wired `GRAFANA_URL` and `PROMETHEUS_URL` for the new adapters. + +## Verification (Slice 2) + +```bash +cd backend +.venv/bin/python -m ruff check . # All checks passed +PYTHONPATH=src .venv/bin/python -m pytest # 200 passed, 2 warnings +cd ../frontend +npm run lint # 2 pre-existing warnings, 0 errors +npm run build # Built successfully +``` + +Focused widget test output: `27 passed`. + +## Deviations from design (Slice 2) + +- Adapters currently call `get_settings_store()` internally for `backups`/`ssh_task` sources. The router-level endpoint uses FastAPI DI, but adapter unit tests patch `get_settings_store` to inject a test store. A future refactor can pass `store` and `settings` explicitly into `adapter.fetch()` for cleaner testability. + ## Remaining work -- Slice 2: Backend source adapters + `GET /api/widgets/instances/{id}/data` - Slice 3: Frontend types/API/hooks/registry/components - Slice 4: Dashboard loop + configuration UI + addon pages