Compare commits

..

6 Commits

Author SHA1 Message Date
Developer 2557185fb7 style(widgets): apply formatter to widget runtime files 2026-06-21 16:55:12 +00:00
Developer e1356b20f1 feat(widgets): add frontend widget runtime (types, API, hooks, registry, components)
PR 3 of 4 for configurable dashboard widgets.

- Add TypeScript widget interfaces (WidgetInstance, WidgetInstanceInput,
  WidgetTypeInfo, WidgetDataResponse).
- Create widget API client for CRUD, registry metadata, and per-widget data.
- Create TanStack Query hooks for instances, data, sources, types, and mutations.
- Create closed frontend widget registry with metadata, source type, refresh
  intervals, and config fields.
- Add six shadcn/ui-based widget components: Jellyfin, Backups, Grafana link,
  Prometheus metric, SSH task output, and static text.
- Add Vitest unit tests for registry metadata.

Verification:
- backend ruff clean; pytest 200 passed
- frontend npm run lint: 0 errors
- frontend npm run build: success
- frontend npm run test -- src/widgets/registry.test.ts: 3 passed
2026-06-21 16:24:44 +00:00
Developer e6d333ef7b Merge pull request 'feat(widgets): add backend source adapters and per-widget data endpoint' (#3) from feat/dashboard-widgets-backend-adapters into main 2026-06-21 16:01:44 +00:00
Developer 1cd8e926de 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.
2026-06-21 10:09:45 +00:00
alex 1a52dfb087 Merge pull request 'feat(widgets): add backend CRUD, registry, and default seeding' (#2) from feat/dashboard-widgets-backend-crud into main
Reviewed-on: #2
2026-06-19 22:15:58 +02:00
alex 9dfe62eb6f Merge pull request 'chore(config): remove dead GRAFANA_URL and wire VITE_GRAFANA_URL' (#1) from chore/wire-grafana-url-envs into main
Reviewed-on: #1
2026-06-19 22:15:46 +02:00
22 changed files with 1301 additions and 86 deletions
+2
View File
@@ -23,6 +23,8 @@ PROMETHEUS_ENABLED=true
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL=http://alertmanager:9093 ALERTMANAGER_URL=http://alertmanager:9093
ALERTMANAGER_WEBHOOK_URL= ALERTMANAGER_WEBHOOK_URL=
GRAFANA_URL=http://grafana:3000
PROMETHEUS_URL=http://prometheus:9090
BACKEND_CACHE_DIR=./backend-cache BACKEND_CACHE_DIR=./backend-cache
# Auth # Auth
@@ -57,6 +57,8 @@ class Settings(BaseSettings):
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd" prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
alertmanager_url: str = "http://alertmanager:9093" alertmanager_url: str = "http://alertmanager:9093"
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
grafana_url: str = "http://grafana:3000"
prometheus_url: str = "http://prometheus:9090"
# Remote paths # Remote paths
remote_media_root: str = "" remote_media_root: str = ""
@@ -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,
)
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import (
get_settings_store, get_settings_store,
get_user_id, 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.models.backups import BackupDashboardSummary
from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.services.settings_store import SettingsStore
@@ -85,50 +88,6 @@ def delete_shortcut(
return {"status": "deleted"} 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") @router.get("/activity")
def get_activity( def get_activity(
client: JellyfinClient = Depends(get_jellyfin_client), client: JellyfinClient = Depends(get_jellyfin_client),
@@ -154,38 +113,4 @@ def get_now_playing(
def get_backup_dashboard( def get_backup_dashboard(
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> BackupDashboardSummary: ) -> BackupDashboardSummary:
jobs = store.list_backup_jobs() return build_backup_dashboard_summary(store)
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,
)
@@ -1,20 +1,30 @@
"""REST API for dashboard widget instances and registry metadata.""" """REST API for dashboard widget instances and registry metadata."""
import logging
import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from media_library_viewer_api.dependencies import get_settings_store 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.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.registry import ( from media_library_viewer_api.widgets.registry import (
get_widget_info,
list_source_types, list_source_types,
list_widget_types, list_widget_types,
validate_config, validate_config,
) )
from media_library_viewer_api.widgets.sources import get_source_adapter
router = APIRouter(prefix="/api/widgets", tags=["widgets"]) router = APIRouter(prefix="/api/widgets", tags=["widgets"])
logger = logging.getLogger(__name__)
def _registry_for_type(widget_type: str) -> dict[str, Any]: def _registry_for_type(widget_type: str) -> dict[str, Any]:
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
@@ -56,7 +66,7 @@ def list_sources() -> list[str]:
@router.get("/types") @router.get("/types")
def list_types() -> list[WidgetTypeInfo]: def list_types() -> list[dict[str, Any]]:
"""Return metadata for all registered widget types.""" """Return metadata for all registered widget types."""
return [info.model_dump() for info in list_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") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
store.delete_widget(widget_id) store.delete_widget(widget_id)
return {"status": "deleted"} 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()
@@ -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)
+186 -2
View File
@@ -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 types import SimpleNamespace
from unittest.mock import patch from unittest.mock import MagicMock, patch
import pytest import pytest
from fastapi.testclient import TestClient 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.dependencies import get_settings_store
from media_library_viewer_api.main import app from media_library_viewer_api.main import app
from media_library_viewer_api.services.settings_store import SettingsStore 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 @pytest.fixture
@@ -289,3 +296,180 @@ def test_enabled_round_trip(client):
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["enabled"] is True 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",
}
+2
View File
@@ -17,6 +17,8 @@ services:
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093} ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-} ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
+2
View File
@@ -28,6 +28,8 @@ services:
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd} PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093} ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-} ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
volumes: volumes:
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache - ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
restart: unless-stopped restart: unless-stopped
+69
View File
@@ -0,0 +1,69 @@
import type {
WidgetDataResponse,
WidgetInstance,
WidgetInstanceInput,
WidgetTypeInfo,
} from "../types";
const API_BASE = "/api";
export async function fetchWidgetSources(): Promise<string[]> {
const res = await fetch(`${API_BASE}/widgets/sources`);
if (!res.ok) throw new Error("Failed to fetch widget sources");
return res.json();
}
export async function fetchWidgetTypes(): Promise<WidgetTypeInfo[]> {
const res = await fetch(`${API_BASE}/widgets/types`);
if (!res.ok) throw new Error("Failed to fetch widget types");
return res.json();
}
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
const res = await fetch(`${API_BASE}/widgets/instances`);
if (!res.ok) throw new Error("Failed to fetch widget instances");
return res.json();
}
export async function createWidgetInstance(
input: WidgetInstanceInput,
): Promise<WidgetInstance> {
const res = await fetch(`${API_BASE}/widgets/instances`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to create widget instance");
return res.json();
}
export async function updateWidgetInstance(
input: WidgetInstanceInput,
): Promise<WidgetInstance> {
if (!input.id) throw new Error("Widget ID is required for update");
const res = await fetch(`${API_BASE}/widgets/instances/${input.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to update widget instance");
return res.json();
}
export async function deleteWidgetInstance(
widgetId: string,
): Promise<{ status: string }> {
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete widget instance");
return res.json();
}
export async function fetchWidgetData(
widgetId: string,
): Promise<WidgetDataResponse> {
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}/data`);
if (!res.ok) throw new Error("Failed to fetch widget data");
return res.json();
}
+64
View File
@@ -0,0 +1,64 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createWidgetInstance,
deleteWidgetInstance,
fetchWidgetData,
fetchWidgetInstances,
fetchWidgetSources,
fetchWidgetTypes,
updateWidgetInstance,
} from "../api/widgets";
import type { WidgetInstanceInput } from "../types";
export function useWidgetInstances() {
return useQuery({
queryKey: ["widgets", "instances"],
queryFn: fetchWidgetInstances,
refetchInterval: 60_000,
});
}
export function useWidgetData(widgetId: string, refreshInterval: number) {
return useQuery({
queryKey: ["widgets", "data", widgetId],
queryFn: () => fetchWidgetData(widgetId),
refetchInterval: refreshInterval || false,
enabled: !!widgetId,
retry: 1,
});
}
export function useSaveWidgetInstance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: WidgetInstanceInput) =>
input.id ? updateWidgetInstance(input) : createWidgetInstance(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
},
});
}
export function useDeleteWidgetInstance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (widgetId: string) => deleteWidgetInstance(widgetId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
},
});
}
export function useWidgetSources() {
return useQuery({
queryKey: ["widgets", "sources"],
queryFn: fetchWidgetSources,
});
}
export function useWidgetTypes() {
return useQuery({
queryKey: ["widgets", "types"],
queryFn: fetchWidgetTypes,
});
}
+39
View File
@@ -443,3 +443,42 @@ export interface PrometheusTarget {
labels: Record<string, string>; labels: Record<string, string>;
targets: string[]; targets: string[];
} }
export interface WidgetInstance {
id: string;
addon_id: string;
widget_type: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sort_order: number;
created_at: number;
updated_at: number;
}
export interface WidgetInstanceInput {
id?: string | null;
addon_id: string;
widget_type: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sort_order: number;
}
export interface WidgetTypeInfo {
addon_id: string;
widget_type: string;
name: string;
description: string;
source_type: string;
config_schema: Record<string, unknown>;
}
export interface WidgetDataResponse {
widget_id: string;
widget_type: string;
data: Record<string, unknown> | null;
error: string | null;
fetched_at: number;
}
+66
View File
@@ -0,0 +1,66 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { BackupDashboardSummary } from "../types/backups";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function BackupsWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const summary = data?.data as BackupDashboardSummary | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<div className="flex flex-row flex-wrap gap-6">
<Skeleton className="h-10 w-20" />
<Skeleton className="h-10 w-20" />
<Skeleton className="h-10 w-20" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : summary ? (
<div className="flex flex-row flex-wrap gap-6">
<div>
<div className="text-2xl font-semibold">{summary.total_jobs}</div>
<div className="text-xs text-muted-foreground">Jobs</div>
</div>
<div>
<div className="text-2xl font-semibold">
{summary.success_rate_24h}%
</div>
<div className="text-xs text-muted-foreground">24h Success</div>
</div>
<div>
<div className="text-2xl font-semibold">
{summary.active_alerts > 0 ? (
<Badge variant="destructive">{summary.active_alerts}</Badge>
) : (
0
)}
</div>
<div className="text-xs text-muted-foreground">Alerts</div>
</div>
{summary.last_failed_at ? (
<div className="self-center text-xs text-destructive">
Last failed:{" "}
{new Date(summary.last_failed_at * 1000).toLocaleString()}
</div>
) : null}
</div>
) : null}
</SectionCard>
);
}
@@ -0,0 +1,44 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ExternalLink } from "lucide-react";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function GrafanaLinkWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const url = data?.data?.url as string | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<Skeleton className="h-10 w-48" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : url ? (
<Button asChild>
<a href={url} target="_blank" rel="noopener noreferrer">
Open Grafana
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
) : (
<Alert>
<AlertDescription>No Grafana URL configured.</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SessionActivityPanel } from "../components/SessionActivityPanel";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { NowPlayingSession, WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function JellyfinWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : Array.isArray(sessions) ? (
<SessionActivityPanel
sessions={sessions}
emptyMessage="No recent user activity sessions right now."
/>
) : null}
</SectionCard>
);
}
@@ -0,0 +1,61 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
type PromQLResult = {
resultType?: string;
result?: unknown;
};
type PromQLVectorSample = {
metric?: Record<string, string>;
value?: [number, string];
};
function formatPrometheusValue(result: PromQLResult | undefined): string {
if (!result) return "No data";
if (result.resultType === "scalar" && Array.isArray(result.result)) {
return String(result.result[1] ?? "No data");
}
if (
result.resultType === "vector" &&
Array.isArray(result.result) &&
result.result.length > 0
) {
const first = result.result[0] as PromQLVectorSample;
if (first.value) return String(first.value[1]);
}
return JSON.stringify(result, null, 2);
}
export function PrometheusMetricWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const result = data?.data?.result as PromQLResult | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<Skeleton className="h-10 w-32" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : (
<pre className="whitespace-pre-wrap text-sm">
{formatPrometheusValue(result)}
</pre>
)}
</SectionCard>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
type SshTaskResult = {
exit_status: number;
stdout: string;
stderr: string;
};
export function SshTaskWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const result = data?.data as SshTaskResult | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : result ? (
<div className="flex flex-col gap-3">
<div className="text-xs text-muted-foreground">
Exit status:{" "}
<span
className={
result.exit_status === 0 ? "text-green-600" : "text-destructive"
}
>
{result.exit_status}
</span>
</div>
{result.stdout ? (
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
{result.stdout}
</pre>
) : null}
{result.stderr ? (
<pre className="max-h-64 overflow-auto rounded bg-destructive/10 p-2 text-xs text-destructive">
{result.stderr}
</pre>
) : null}
</div>
) : null}
</SectionCard>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function StaticWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
const text = data?.data?.text as string | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{text ? (
<p className="whitespace-pre-wrap text-sm">{text}</p>
) : (
<p className="text-sm text-muted-foreground">No content configured.</p>
)}
</SectionCard>
);
}
+12
View File
@@ -0,0 +1,12 @@
export { BackupsWidget } from "./BackupsWidget";
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { SshTaskWidget } from "./SshTaskWidget";
export { StaticWidget } from "./StaticWidget";
export {
getWidgetDefinition,
listWidgetTypes,
WIDGET_REGISTRY,
} from "./registry";
export type { WidgetConfigField, WidgetDefinition } from "./registry";
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
getWidgetDefinition,
listWidgetTypes,
WIDGET_REGISTRY,
} from "./registry";
describe("widget registry", () => {
it("contains exactly six Phase 1 types", () => {
const types = listWidgetTypes();
expect(types).toHaveLength(6);
expect(types.map((t) => t.widgetType).sort()).toEqual([
"backups",
"grafana-link",
"jellyfin",
"prometheus-metric",
"ssh-task",
"static",
]);
});
it("has refresh intervals matching the spec", () => {
expect(getWidgetDefinition("jellyfin")?.refreshInterval).toBe(30_000);
expect(getWidgetDefinition("backups")?.refreshInterval).toBe(60_000);
expect(getWidgetDefinition("grafana-link")?.refreshInterval).toBe(0);
expect(getWidgetDefinition("prometheus-metric")?.refreshInterval).toBe(
30_000,
);
expect(getWidgetDefinition("ssh-task")?.refreshInterval).toBe(0);
expect(getWidgetDefinition("static")?.refreshInterval).toBe(0);
});
it("defines required metadata for every widget", () => {
for (const def of Object.values(WIDGET_REGISTRY)) {
expect(def.widgetType).toBeTruthy();
expect(def.addonId).toBeTruthy();
expect(def.name).toBeTruthy();
expect(def.sourceType).toBeTruthy();
expect(def.component).toBeDefined();
}
});
});
+122
View File
@@ -0,0 +1,122 @@
import type { ComponentType } from "react";
import type { WidgetInstance } from "../types";
import { BackupsWidget } from "./BackupsWidget";
import { GrafanaLinkWidget } from "./GrafanaLinkWidget";
import { JellyfinWidget } from "./JellyfinWidget";
import { PrometheusMetricWidget } from "./PrometheusMetricWidget";
import { SshTaskWidget } from "./SshTaskWidget";
import { StaticWidget } from "./StaticWidget";
export interface WidgetConfigField {
key: string;
label: string;
type: "string" | "select" | "boolean" | "number";
options?: { label: string; value: string }[];
helper?: string;
}
export interface WidgetDefinition {
widgetType: string;
addonId: string;
name: string;
description: string;
sourceType: string;
refreshInterval: number;
defaultConfig: Record<string, unknown>;
configFields: WidgetConfigField[];
component: ComponentType<{ widget: WidgetInstance }>;
}
export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
jellyfin: {
widgetType: "jellyfin",
addonId: "core",
name: "Jellyfin activity",
description: "Live sessions and idle users from a Jellyfin server.",
sourceType: "jellyfin",
refreshInterval: 30_000,
defaultConfig: { machine_id: "" },
configFields: [
{
key: "machine_id",
label: "Machine ID",
type: "string",
helper: "Jellyfin machine id (empty = default)",
},
],
component: JellyfinWidget,
},
backups: {
widgetType: "backups",
addonId: "backups",
name: "Backups",
description: "Backup job summary and active alerts.",
sourceType: "backups",
refreshInterval: 60_000,
defaultConfig: {},
configFields: [],
component: BackupsWidget,
},
"grafana-link": {
widgetType: "grafana-link",
addonId: "grafana",
name: "Grafana link",
description: "Deep-link to a Grafana dashboard or panel.",
sourceType: "grafana",
refreshInterval: 0,
defaultConfig: { dashboard_uid: "" },
configFields: [
{ key: "dashboard_uid", label: "Dashboard UID", type: "string" },
{
key: "panel_id",
label: "Panel ID",
type: "number",
helper: "Optional",
},
],
component: GrafanaLinkWidget,
},
"prometheus-metric": {
widgetType: "prometheus-metric",
addonId: "prometheus",
name: "Prometheus metric",
description: "Instant query result rendered as a metric.",
sourceType: "prometheus",
refreshInterval: 30_000,
defaultConfig: { promql: "" },
configFields: [{ key: "promql", label: "PromQL query", type: "string" }],
component: PrometheusMetricWidget,
},
"ssh-task": {
widgetType: "ssh-task",
addonId: "ssh-tasks",
name: "SSH task output",
description: "Output of a saved task run on a machine.",
sourceType: "ssh_task",
refreshInterval: 0,
defaultConfig: { task_id: "" },
configFields: [{ key: "task_id", label: "Saved task ID", type: "string" }],
component: SshTaskWidget,
},
static: {
widgetType: "static",
addonId: "core",
name: "Static text",
description: "Plain text or markdown note.",
sourceType: "static",
refreshInterval: 0,
defaultConfig: { text: "" },
configFields: [{ key: "text", label: "Text", type: "string" }],
component: StaticWidget,
},
};
export function getWidgetDefinition(
widgetType: string,
): WidgetDefinition | undefined {
return WIDGET_REGISTRY[widgetType];
}
export function listWidgetTypes(): WidgetDefinition[] {
return Object.values(WIDGET_REGISTRY);
}
@@ -51,10 +51,98 @@ Focused widget test output: `12 passed`.
- None significant for Slice 1. The implementation follows the design's backend CRUD layout. - 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`. - 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.
## Completed tasks (Slice 3)
All Slice 3 tasks are marked `- [x]` in `tasks.md`:
- [x] 3.1 Add TypeScript widget interfaces (`WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse`)
- [x] 3.2 Create widget API client (`frontend/src/api/widgets.ts`)
- [x] 3.3 Create widget TanStack Query hooks (`frontend/src/hooks/useWidgets.ts`)
- [x] 3.4 Create frontend widget registry (`frontend/src/widgets/registry.ts`)
- [x] 3.5 Implement six widget presentational components (`frontend/src/widgets/*.tsx`)
- [x] 3.6 Add frontend registry unit test (`frontend/src/widgets/registry.test.ts`)
## Files changed (Slice 3)
### New files
- `frontend/src/api/widgets.ts` — API functions for widget CRUD, registry metadata, and per-widget data.
- `frontend/src/hooks/useWidgets.ts` — TanStack Query hooks for instances, data, sources, types, and mutations.
- `frontend/src/widgets/registry.ts` — Closed frontend registry with metadata, refresh intervals, and config fields.
- `frontend/src/widgets/JellyfinWidget.tsx` — Renders Jellyfin session activity.
- `frontend/src/widgets/BackupsWidget.tsx` — Renders backup dashboard summary.
- `frontend/src/widgets/GrafanaLinkWidget.tsx` — Renders a deep-link to Grafana (no iframe).
- `frontend/src/widgets/PrometheusMetricWidget.tsx` — Renders PromQL instant query result.
- `frontend/src/widgets/SshTaskWidget.tsx` — Renders saved SSH task output.
- `frontend/src/widgets/StaticWidget.tsx` — Renders static text.
- `frontend/src/widgets/index.ts` — Barrel exports.
- `frontend/src/widgets/registry.test.ts` — Vitest unit tests for registry metadata.
### Modified files
- `frontend/src/types/index.ts` — Added widget TypeScript interfaces.
## Verification (Slice 3)
```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
npm run test -- src/widgets/registry.test.ts # 3 passed
```
## Deviations from design (Slice 3)
- Registry unit test is colocated at `frontend/src/widgets/registry.test.ts` and runs with Vitest, matching the project's existing `npm run test` setup, instead of `frontend/tests/widgets.test.mjs`.
- `JellyfinWidget` uses `SessionActivityPanel` directly because `NowPlaying` does not expose an `emptyMessage` prop.
## Remaining work ## 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 - Slice 4: Dashboard loop + configuration UI + addon pages
## PR boundary ## PR boundary