Compare commits

...

6 Commits

Author SHA1 Message Date
Developer 739ad38e29 style(services): apply formatter to frontend services runtime 2026-06-22 19:13:59 +00:00
Developer 1da67f38c7 feat(services): frontend services runtime and widget rebind
PR 3 of 4 for the runtime service registry change.

- Add service + new-shape widget TypeScript types; widgets carry service_id
  + widget_kind (service-bound) or null (built-in).
- Add services API client + TanStack Query hooks; reconcile the widget API
  client/hooks to the new endpoints (remove sources/types; add builtin kinds).
- Add closed frontend service registry (integrations/registry.ts) mirroring the
  backend, with resolveWidget(widget, services) mapping a widget to its
  component + refresh interval.
- Add ServicePage at /services/:serviceType/:serviceId with config view,
  empty-on-edit secret inputs + 'set' badges, enable toggle, delete, and the
  service's widget-kind list.
- Register /services/:serviceType/:serviceId in App.tsx.
- Reconcile the six widget components to refreshIntervalMs + description props;
  rewrite WidgetConfigDialog around a service -> widget-kind picker.
- Update Dashboard test; add integrations/registry.test.ts.

Verification: frontend lint 0 errors, build success, 70 tests passed; backend
ruff clean, 222 tests passed.
2026-06-22 18:59:41 +00:00
Developer 41dddbccc0 Merge pull request 'feat(widgets): rebind widgets to the service registry' (#8) from feat/service-registry-widget-rebind into main 2026-06-22 18:22:18 +00:00
Developer f6a86310cc style(widgets): apply formatter to widget rebind files 2026-06-22 18:22:18 +00:00
Developer 10fd4ead4a feat(widgets): rebind widgets to the service registry
PR 2 of 4 for the runtime service registry change.

- dashboard_widgets gains service_id + widget_kind columns (legacy
  addon_id/widget_type kept but unused).
- Source adapters take (service: ServiceRecord | None, widget_kind, config).
  SERVICE_ADAPTERS keyed by service_type; BUILTIN_ADAPTERS for backups/static.
- Backups and static stay as service-less built-ins (service_id nullable),
  exposed via GET /api/widgets/builtin.
- SSH task adapter resolves the task + instance, runs over SSH, and appends a
  service_task_runs history row on success/failure/timeout/error.
- Retire widgets/registry.py; widget metadata now comes from the integrations
  registry + widgets/builtin. Remove /api/widgets/types and /api/widgets/sources.
- Stop default widget seeding (fresh install = empty dashboard).
- Rewrite widget tests around the service-bound + built-in model (26 tests).

Backend-only breaking change; frontend is reconciled in Slice 3. Build/lint
stay green; pytest 222 passed.
2026-06-22 16:42:56 +00:00
Developer 2452e2e1e4 Merge pull request 'feat(services): backend service registry foundation' (#7) from feat/service-registry-backend-foundation into main 2026-06-22 14:00:07 +00:00
31 changed files with 1777 additions and 1238 deletions
@@ -59,6 +59,7 @@ class WidgetKind:
config_schema: dict[str, Any] config_schema: dict[str, Any]
default_config: dict[str, Any] = field(default_factory=dict) default_config: dict[str, Any] = field(default_factory=dict)
refresh_interval_ms: int = 0 refresh_interval_ms: int = 0
config_model: type[WidgetConfigBase] | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -108,6 +109,7 @@ def widget_kind(
config_schema=schema, config_schema=schema,
default_config=dict(default_config or {}), default_config=dict(default_config or {}),
refresh_interval_ms=refresh_interval_ms, refresh_interval_ms=refresh_interval_ms,
config_model=model_cls,
) )
@@ -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 typing import Any
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator, model_validator
FORBIDDEN_CONFIG_KEYS = { FORBIDDEN_CONFIG_KEYS = {
"password", "password",
@@ -47,8 +57,8 @@ def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
class _WidgetInstanceBase(BaseModel): class _WidgetInstanceBase(BaseModel):
"""Shared fields between input and output widget models.""" """Shared fields between input and output widget models."""
addon_id: str service_id: str | None = None
widget_type: str widget_kind: str = Field(..., min_length=1)
title: str = Field(..., min_length=1) title: str = Field(..., min_length=1)
config: dict[str, Any] = Field(default_factory=dict) config: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True enabled: bool = True
@@ -59,6 +69,13 @@ class _WidgetInstanceBase(BaseModel):
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]: def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
return _validate_config_keys(value or {}) 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): class WidgetInstanceInput(_WidgetInstanceBase):
"""Payload for creating or updating a widget instance.""" """Payload for creating or updating a widget instance."""
@@ -74,22 +91,21 @@ class WidgetInstance(_WidgetInstanceBase):
updated_at: int updated_at: int
class WidgetTypeInfo(BaseModel): class BuiltinWidgetKindInfo(BaseModel):
"""Metadata about a built-in widget type.""" """Metadata about a built-in (service-less) widget kind."""
addon_id: str kind: str
widget_type: str
name: str name: str
description: str description: str
source_type: str
config_schema: dict[str, Any] config_schema: dict[str, Any]
default_config: dict[str, Any]
refresh_interval_ms: int
class WidgetDataResponse(BaseModel): class WidgetDataResponse(BaseModel):
"""Response from the per-widget data endpoint.""" """Response from the per-widget data endpoint."""
widget_id: str widget_id: str
widget_type: str
data: dict[str, Any] | None = None data: dict[str, Any] | None = None
error: str | None = None error: str | None = None
fetched_at: int fetched_at: int
@@ -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 logging
import time import time
@@ -7,68 +14,91 @@ 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.integrations.base import validate_config
from media_library_viewer_api.integrations.registry import get_service_definition
from media_library_viewer_api.models.widgets import ( from media_library_viewer_api.models.widgets import (
BuiltinWidgetKindInfo,
WidgetDataResponse, WidgetDataResponse,
WidgetInstance, WidgetInstance,
WidgetInstanceInput, 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.builtin import (
get_widget_info, BUILTIN_WIDGET_KINDS,
list_source_types, is_builtin_kind,
list_widget_types, validate_builtin_config,
validate_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"]) router = APIRouter(prefix="/api/widgets", tags=["widgets"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _registry_for_type(widget_type: str) -> dict[str, Any]: def _validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) -> None:
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY """Validate widget_kind + config against the service definition or built-ins."""
if body.service_id:
info = WIDGET_REGISTRY.get(widget_type) service = store.get_service(body.service_id)
if not info: if not service:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown widget type: {widget_type}", detail=f"Service {body.service_id} not found",
) )
return info definition = get_service_definition(service["service_type"])
if definition is None:
raise HTTPException(
def _validate_widget_input(body: WidgetInstanceInput) -> None: status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
"""Validate widget_type/addon_id match and config schema.""" detail=f"Unknown service type: {service['service_type']}",
info = _registry_for_type(body.widget_type) )
expected_addon = info["addon_id"] widget_kind = definition.widget_kind(body.widget_kind)
if body.addon_id != expected_addon: if widget_kind is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(f"Service type '{service['service_type']}' does not provide 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( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=( detail=(
f"Widget type '{body.widget_type}' belongs to addon " f"Unknown built-in widget kind '{body.widget_kind}' (set service_id for service-bound widgets)"
f"'{expected_addon}', not '{body.addon_id}'"
), ),
) )
try: try:
validate_config(body.widget_type, body.config) validate_builtin_config(body.widget_kind, body.config)
except ValueError as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc), detail=f"Invalid widget config: {exc}",
) from exc ) from exc
@router.get("/sources") @router.get("/builtin")
def list_sources() -> list[str]: def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
"""Return all registered widget source types.""" """Return metadata for service-less built-in widget kinds."""
return list_source_types() return [
BuiltinWidgetKindInfo(
kind=wk.kind,
@router.get("/types") name=wk.name,
def list_types() -> list[dict[str, Any]]: description=wk.description,
"""Return metadata for all registered widget types.""" config_schema=wk.config_schema,
return [info.model_dump() for info in list_widget_types()] default_config=wk.default_config,
refresh_interval_ms=wk.refresh_interval_ms,
)
for wk in BUILTIN_WIDGET_KINDS.values()
]
@router.get("/instances") @router.get("/instances")
@@ -85,7 +115,7 @@ def create_instance(
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a new widget instance.""" """Create a new widget instance."""
_validate_widget_input(body) _validate_widget_input(body, store)
widget = store.upsert_widget(body.model_dump()) widget = store.upsert_widget(body.model_dump())
return WidgetInstance(**widget).model_dump() return WidgetInstance(**widget).model_dump()
@@ -105,7 +135,7 @@ def update_instance(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in path does not match ID in body", 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) widget = store.upsert_widget(body.model_dump(), widget_id)
return WidgetInstance(**widget).model_dump() return WidgetInstance(**widget).model_dump()
@@ -133,31 +163,44 @@ async def fetch_data(
if not widget: if not widget:
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")
widget_type = widget["widget_type"] service_id = widget.get("service_id")
info = get_widget_info(widget_type) widget_kind = widget.get("widget_kind") or ""
if info is None:
service: Any = None
if service_id:
service_row = store.get_service(service_id)
if not service_row:
return WidgetDataResponse( return WidgetDataResponse(
widget_id=widget_id, widget_id=widget_id,
widget_type=widget_type, error=f"Service {service_id} not found",
data=None,
error=f"Unknown widget type: {widget_type}",
fetched_at=int(time.time()), fetched_at=int(time.time()),
).model_dump() ).model_dump()
if not service_row.get("enabled", True):
adapter = get_source_adapter(info.source_type)
if adapter is None:
# Defensive: registry should prevent this, but return a safe error.
return WidgetDataResponse( return WidgetDataResponse(
widget_id=widget_id, widget_id=widget_id,
widget_type=widget_type, error="Service is disabled",
data=None, fetched_at=int(time.time()),
error=f"No adapter registered for source type: {info.source_type}", ).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()), fetched_at=int(time.time()),
).model_dump() ).model_dump()
try: try:
data = await adapter.fetch(widget["config"]) data = await adapter.fetch(service, widget_kind, widget.get("config") or {})
except Exception as exc: except Exception as exc: # pragma: no cover - defensive
logger.exception("Unhandled adapter exception widget_id=%s", widget_id) logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -166,7 +209,6 @@ async def fetch_data(
return WidgetDataResponse( return WidgetDataResponse(
widget_id=widget_id, widget_id=widget_id,
widget_type=widget_type,
data=data if "error" not in data else None, data=data if "error" not in data else None,
error=data.get("error"), error=data.get("error"),
fetched_at=int(time.time()), fetched_at=int(time.time()),
@@ -180,6 +180,11 @@ class SettingsStore:
""" """
) )
conn.execute("CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)") 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(""" conn.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs ( CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -453,39 +458,13 @@ class SettingsStore:
) )
def _seed_dashboard_widgets(self) -> None: def _seed_dashboard_widgets(self) -> None:
"""Seed default dashboard widgets only when the table is empty.""" """Default widget seeding was removed.
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
self.init_schema() Widgets are now service-bound (or built-in). A fresh install starts with
with self.connect() as conn: no widgets; the user configures services and adds widgets from the UI.
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone() Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
if row and int(row[0]) > 0: """
return return None
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)
def ensure_defaults(self) -> None: def ensure_defaults(self) -> None:
self.init_schema() self.init_schema()
@@ -493,7 +472,6 @@ class SettingsStore:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone() row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0: if not row or int(row[0]) == 0:
self._seed_local_machine() self._seed_local_machine()
self._seed_dashboard_widgets()
def list_machines(self) -> list[dict[str, Any]]: def list_machines(self) -> list[dict[str, Any]]:
self.init_schema() self.init_schema()
@@ -1404,10 +1382,13 @@ class SettingsStore:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]: def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
keys = row.keys()
return { return {
"id": row["id"], "id": row["id"],
"addon_id": row["addon_id"], "addon_id": row["addon_id"],
"widget_type": row["widget_type"], "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"], "title": row["title"],
"config": json.loads(row["config_json"] or "{}"), "config": json.loads(row["config_json"] or "{}"),
"enabled": bool(row["enabled"]), "enabled": bool(row["enabled"]),
@@ -1423,8 +1404,8 @@ class SettingsStore:
) -> dict[str, Any]: ) -> dict[str, Any]:
current = self.get_widget(widget_id) if widget_id else None 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] 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() service_id = str(payload.get("service_id") or (current or {}).get("service_id") or "").strip() or None
widget_type = str(payload.get("widget_type") or (current or {}).get("widget_type", "")).strip() 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() title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
config = payload.get("config", (current or {}).get("config", {})) config = payload.get("config", (current or {}).get("config", {}))
if not isinstance(config, dict): if not isinstance(config, dict):
@@ -1433,10 +1414,14 @@ class SettingsStore:
_validate_config_keys(config) _validate_config_keys(config)
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True))) 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) 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 { return {
"id": widget_id, "id": widget_id,
"addon_id": addon_id, "addon_id": "",
"widget_type": widget_type, "widget_type": widget_type,
"service_id": service_id,
"widget_kind": widget_kind,
"title": title, "title": title,
"config": config, "config": config,
"enabled": enabled, "enabled": enabled,
@@ -1470,13 +1455,15 @@ class SettingsStore:
conn.execute( conn.execute(
""" """
INSERT INTO dashboard_widgets ( INSERT INTO dashboard_widgets (
id, addon_id, widget_type, title, config_json, enabled, id, addon_id, widget_type, service_id, widget_kind, title,
sort_order, created_at, updated_at config_json, enabled, sort_order, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
addon_id = excluded.addon_id, addon_id = excluded.addon_id,
widget_type = excluded.widget_type, widget_type = excluded.widget_type,
service_id = excluded.service_id,
widget_kind = excluded.widget_kind,
title = excluded.title, title = excluded.title,
config_json = excluded.config_json, config_json = excluded.config_json,
enabled = excluded.enabled, enabled = excluded.enabled,
@@ -1487,6 +1474,8 @@ class SettingsStore:
widget["id"], widget["id"],
widget["addon_id"], widget["addon_id"],
widget["widget_type"], widget["widget_type"],
widget["service_id"],
widget["widget_kind"],
widget["title"], widget["title"],
json.dumps(widget["config"]), json.dumps(widget["config"]),
1 if widget["enabled"] else 0, 1 if widget["enabled"] else 0,
@@ -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)
@@ -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}")
@@ -1,9 +1,11 @@
"""Widget source adapters. """Widget source adapters.
Each adapter implements a uniform async interface and translates widget Adapters translate a widget instance into dashboard data. Service-bound widgets
configuration into data for the dashboard. Adapters reuse existing clients, are resolved against a :class:`ServiceRecord` (config + decrypted secrets); the
machine registries, and environment settings; they never accept arbitrary built-in widgets (backups, static) take ``service=None``.
commands or store credentials.
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 from __future__ import annotations
@@ -11,94 +13,103 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import shlex import shlex
import time
from dataclasses import dataclass, field
from typing import Any, Protocol from typing import Any, Protocol
import requests 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.config import get_settings
from media_library_viewer_api.dependencies import get_jellyfin_client
from media_library_viewer_api.domain.dashboard import ( from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows, _map_sessions_to_activity_rows,
build_backup_dashboard_summary, 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 SettingsStore, get_settings_store
from media_library_viewer_api.services.settings_store import get_settings_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _request_with_machine_id(machine_id: str | None = None) -> Request: @dataclass
"""Build a minimal Starlette Request carrying a machine_id query param.""" class ServiceRecord:
query = f"machine_id={machine_id}".encode() if machine_id else b"" """Runtime view of a service instance with decrypted secrets."""
return Request({"type": "http", "query_string": query})
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): class WidgetSource(Protocol):
"""Protocol for widget source adapters.""" """Protocol for widget source adapters."""
source_type: str async def fetch(
self,
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ... service: ServiceRecord | None,
widget_kind: str,
config: dict[str, Any],
) -> dict[str, Any]: ...
class JellyfinWidgetSource: # ---------------------------------------------------------------------------
"""Fetch Jellyfin sessions and map them to activity rows.""" # Built-in (service-less) adapters
# ---------------------------------------------------------------------------
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: class BackupsWidgetSource:
"""Compute the backup dashboard summary.""" """Compute the backup dashboard summary from internal tables."""
source_type = "backups" async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
timeout = 10
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
try: try:
store = get_settings_store() store = get_settings_store()
summary = build_backup_dashboard_summary(store) summary = build_backup_dashboard_summary(store)
return summary.model_dump() return summary.model_dump()
except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"}
except Exception as exc: except Exception as exc:
logger.exception("backups adapter failed") logger.exception("backups adapter failed")
return {"error": f"Backup summary failed: {exc}"} 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: class GrafanaWidgetSource:
"""Build a Grafana deep-link (no embedding).""" """Build a Grafana deep-link (no embedding)."""
source_type = "grafana" async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
timeout = 5
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
try: 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") dashboard_uid = config.get("dashboard_uid")
if not dashboard_uid: if not dashboard_uid:
return {"error": "dashboard_uid is required"} 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") panel_id = config.get("panel_id")
if panel_id is not None: if panel_id is not None:
url = f"{url}?viewPanel={panel_id}" url = f"{url}?viewPanel={panel_id}"
@@ -109,26 +120,26 @@ class GrafanaWidgetSource:
class PrometheusWidgetSource: class PrometheusWidgetSource:
"""Run a PromQL instant query against Prometheus.""" """Run a PromQL instant query against a Prometheus service."""
source_type = "prometheus" async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
timeout = 10
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
try: 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") promql = config.get("promql")
if not promql: if not promql:
return {"error": "promql is required"} 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( response = await asyncio.wait_for(
asyncio.to_thread( asyncio.to_thread(
requests.get, requests.get,
url, url,
params={"query": promql}, params={"query": promql},
timeout=self.timeout, timeout=timeout,
), ),
timeout=self.timeout, timeout=timeout,
) )
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
@@ -143,16 +154,44 @@ class PrometheusWidgetSource:
return {"error": f"Prometheus query failed: {exc}"} return {"error": f"Prometheus query failed: {exc}"}
class SshTaskWidgetSource: class JellyfinWidgetSource:
"""Run a saved task from the registry and return its output.""" """Fetch Jellyfin sessions and map them to activity rows."""
source_type = "ssh_task" async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
timeout = 30 timeout = 10
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
try: 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() store = get_settings_store()
task_id = config.get("task_id") task_id = config.get("task_id") or ""
if not task_id: if not task_id:
return {"error": "task_id is required"} return {"error": "task_id is required"}
task = store.get_task(task_id) task = store.get_task(task_id)
@@ -161,11 +200,8 @@ class SshTaskWidgetSource:
if not task.get("enabled", True): if not task.get("enabled", True):
return {"error": "Task is disabled"} return {"error": "Task is disabled"}
machine = _resolve_machine_for_task(store, task, None) client = _build_ssh_client(store, service)
if not machine: timeout = int(service.config.get("timeout_seconds") or 30)
return {"error": "No machine available for this task"}
client = _client_for_machine(store, machine)
task_type = str(task.get("task_type") or "shell").lower() task_type = str(task.get("task_type") or "shell").lower()
command = str(task.get("content") or "") command = str(task.get("content") or "")
if task_type == "python": if task_type == "python":
@@ -173,41 +209,112 @@ class SshTaskWidgetSource:
elif task_type != "shell": elif task_type != "shell":
return {"error": f"Unknown task type: {task_type}"} return {"error": f"Unknown task type: {task_type}"}
start = time.perf_counter()
result = await asyncio.wait_for( result = await asyncio.wait_for(
asyncio.to_thread(client.run, command, timeout=self.timeout), asyncio.to_thread(client.run, command, timeout),
timeout=self.timeout, timeout=timeout,
) )
return { 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, "exit_status": result.exit_status,
"stdout": result.stdout or "", "duration_ms": duration_ms,
"stderr": result.stderr or "", "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: except asyncio.TimeoutError:
_record_timeout(service, config, timeout)
return {"error": "Widget data fetch timed out"} return {"error": "Widget data fetch timed out"}
except Exception as exc: except Exception as exc:
logger.exception("ssh_task adapter failed") 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}"} return {"error": f"SSH task failed: {exc}"}
class StaticWidgetSource: def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) -> None:
"""Return static text/markdown unchanged.""" try:
store = get_settings_store()
source_type = "static" store.record_service_task_run(
{
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: "task_id": str(config.get("task_id") or ""),
return {"text": config.get("text", "")} "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] = { def _build_ssh_client(store: SettingsStore, service: ServiceRecord) -> RemoteSSHClient:
"jellyfin": JellyfinWidgetSource(), """Build an SSH client from an ssh_tasks service instance + referenced key."""
"backups": BackupsWidgetSource(), 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(), "grafana": GrafanaWidgetSource(),
"prometheus": PrometheusWidgetSource(), "prometheus": PrometheusWidgetSource(),
"ssh_task": SshTaskWidgetSource(), "jellyfin": JellyfinWidgetSource(),
"ssh_tasks": SshTaskWidgetSource(),
}
BUILTIN_ADAPTERS: dict[str, WidgetSource] = {
"backups": BackupsWidgetSource(),
"static": StaticWidgetSource(), "static": StaticWidgetSource(),
} }
def get_source_adapter(source_type: str) -> WidgetSource | None: def get_service_adapter(service_type: str) -> WidgetSource | None:
"""Return the adapter for a source type, or None if unknown.""" return SERVICE_ADAPTERS.get(service_type)
return SOURCE_REGISTRY.get(source_type)
def get_builtin_adapter(kind: str) -> WidgetSource | None:
return BUILTIN_ADAPTERS.get(kind)
+255 -343
View File
@@ -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 types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import patch
import pytest import pytest
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient 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 ( from media_library_viewer_api.widgets.sources import (
SOURCE_REGISTRY, BackupsWidgetSource,
GrafanaWidgetSource, GrafanaWidgetSource,
SshTaskWidgetSource, ServiceRecord,
StaticWidgetSource, 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 @pytest.fixture
def client(tmp_path): def client(tmp_path):
@@ -30,446 +40,348 @@ def client(tmp_path):
app.dependency_overrides.clear() app.dependency_overrides.clear()
def test_widget_sources(client): def _make_grafana_service(client, name="Production Grafana", **config_overrides):
response = client.get("/api/widgets/sources") 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 response.status_code == 200
assert set(response.json()) == { kinds = {item["kind"] for item in response.json()}
"jellyfin", assert kinds == {"backups", "static"}
"backups",
"grafana",
"prometheus",
"ssh_task",
"static",
}
def test_widget_types(client): def test_create_and_read_static_widget(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):
response = client.post( response = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"addon_id": "core", "widget_kind": "static",
"widget_type": "static",
"title": "Note", "title": "Note",
"config": {"text": "hello"}, "config": {"text": "hello"},
"enabled": True,
"sort_order": 5,
}, },
) )
assert response.status_code == 201 assert response.status_code == 201
widget = response.json() created = response.json()
assert widget["title"] == "Note" assert created["widget_kind"] == "static"
assert widget["config"] == {"text": "hello"} assert created["service_id"] is None
assert widget["enabled"] is True assert created["config"]["text"] == "hello"
assert widget["sort_order"] == 5
widget_id = widget["id"]
response = client.get("/api/widgets/instances") listed = client.get("/api/widgets/instances").json()
assert response.status_code == 200 assert len(listed) == 1
assert any(w["id"] == widget_id for w in response.json()) 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( response = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"addon_id": "core", "service_id": service["id"],
"widget_type": "static", "widget_kind": "link",
"title": "Note", "title": "Dashboard",
"config": {"text": "hello"}, "config": {"dashboard_uid": "overview"},
}, },
) )
widget_id = response.json()["id"] assert response.status_code == 201
created = response.json()
response = client.put( assert created["service_id"] == service["id"]
f"/api/widgets/instances/{widget_id}", assert created["widget_kind"] == "link"
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
def test_delete_widget(client): def test_service_bound_widget_unknown_kind_rejected(client):
service = _make_grafana_service(client)
response = client.post( response = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"addon_id": "core", "service_id": service["id"],
"widget_type": "static", "widget_kind": "metric",
"title": "To delete", "title": "x",
"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",
"config": {}, "config": {},
}, },
) )
assert response.status_code == 422 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( response = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"addon_id": "grafana", "service_id": "missing",
"widget_type": "static", "widget_kind": "link",
"title": "Bad", "title": "x",
"config": {"text": "x"}, "config": {"dashboard_uid": "u"},
}, },
) )
assert response.status_code == 422 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( response = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"addon_id": "core", "service_id": service["id"],
"widget_type": "static", "widget_kind": "link",
"title": "Bad", "title": "x",
"config": {"api_key": "secret123"}, "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 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( response = client.put(
"/api/widgets/instances/does-not-exist", "/api/widgets/instances/missing",
json={ json={"widget_kind": "static", "title": "x", "config": {}},
"addon_id": "core",
"widget_type": "static",
"title": "Bad",
"config": {"text": "x"},
},
) )
assert response.status_code == 404 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): def test_update_id_mismatch_returns_400(client):
response = client.post( created = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={"widget_kind": "static", "title": "x", "config": {}},
"addon_id": "core", ).json()
"widget_type": "static",
"title": "Note",
"config": {"text": "hello"},
},
)
widget_id = response.json()["id"]
response = client.put( response = client.put(
f"/api/widgets/instances/{widget_id}", f"/api/widgets/instances/{created['id']}",
json={ json={"id": "other", "widget_kind": "static", "title": "x", "config": {}},
"id": "different-id",
"addon_id": "core",
"widget_type": "static",
"title": "Updated",
"config": {"text": "world"},
},
) )
assert response.status_code == 400 assert response.status_code == 400
def test_empty_title_rejected(client): # ---------------------------------------------------------------------------
response = client.post( # Data endpoint
"/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
def test_fetch_static_widget_data(client): def test_fetch_static_widget_data(client):
response = client.post( created = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={"widget_kind": "static", "title": "Note", "config": {"text": "hello"}},
"addon_id": "core", ).json()
"widget_type": "static", response = client.get(f"/api/widgets/instances/{created['id']}/data")
"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 assert response.status_code == 200
data = response.json() body = response.json()
assert data["widget_id"] == widget_id assert body["data"]["text"] == "hello"
assert data["widget_type"] == "static" assert body["error"] is None
assert data["data"] == {"text": "hello world"}
assert data["error"] is None
assert isinstance(data["fetched_at"], int)
def test_fetch_grafana_widget_data(client): def test_fetch_backups_widget_data(client):
response = client.post( created = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={"widget_kind": "backups", "title": "Backups", "config": {}},
"addon_id": "grafana", ).json()
"widget_type": "grafana-link", response = client.get(f"/api/widgets/instances/{created['id']}/data")
"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 assert response.status_code == 200
data = response.json() assert "total_jobs" in response.json()["data"]
assert data["widget_type"] == "grafana-link"
assert data["data"]["url"] == "http://grafana:3000/d/overview?viewPanel=3"
def test_fetch_prometheus_widget_data(client): def test_fetch_grafana_link_widget_data(client):
response = client.post( service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"addon_id": "prometheus", "service_id": service["id"],
"widget_type": "prometheus-metric", "widget_kind": "link",
"title": "CPU", "title": "Dashboard",
"config": {"promql": '100 - avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100'}, "config": {"dashboard_uid": "overview", "panel_id": 2},
}, },
) ).json()
widget_id = response.json()["id"] response = client.get(f"/api/widgets/instances/{created['id']}/data")
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 assert response.status_code == 200
data = response.json() assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
assert data["widget_type"] == "prometheus-metric"
assert data["data"]["result"]["resultType"] == "scalar"
def test_fetch_jellyfin_widget_data_error(client): def test_fetch_widget_service_not_found(client):
response = client.post( service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"addon_id": "core", "service_id": service["id"],
"widget_type": "jellyfin", "widget_kind": "link",
"title": "Activity", "title": "x",
"config": {"machine_id": ""}, "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/{created['id']}/data")
response = client.get(f"/api/widgets/instances/{widget_id}/data")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() assert "disabled" in response.json()["error"]
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): def test_fetch_widget_not_found(client):
response = client.get("/api/widgets/instances/does-not-exist/data") assert client.get("/api/widgets/instances/missing/data").status_code == 404
assert response.status_code == 404
def test_fetch_widget_data_unhandled_exception_returns_500(client): # ---------------------------------------------------------------------------
response = client.post( # Adapter unit tests
"/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): @pytest.mark.asyncio
raise RuntimeError("boom") 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 @pytest.mark.asyncio
async def test_static_adapter(): async def test_static_adapter():
adapter = StaticWidgetSource() adapter = StaticWidgetSource()
result = await adapter.fetch({"text": "hello"}) result = await adapter.fetch(None, "static", {"text": "hi"})
assert result == {"text": "hello"} assert result == {"text": "hi"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_grafana_adapter(): async def test_backups_adapter(client):
adapter = GrafanaWidgetSource() store = app.dependency_overrides[get_settings_store]()
result = await adapter.fetch({"dashboard_uid": "overview", "panel_id": 2}) with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store):
assert result["url"] == "http://grafana:3000/d/overview?viewPanel=2" adapter = BackupsWidgetSource()
result = await adapter.fetch(None, "backups", {})
result = await adapter.fetch({"dashboard_uid": "overview"}) assert "total_jobs" in result
assert result["url"] == "http://grafana:3000/d/overview"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ssh_task_adapter_timeout(tmp_path): async def test_ssh_task_adapter_missing_service():
store = SettingsStore(tmp_path / "settings.sqlite") from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
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() adapter = SshTaskWidgetSource()
with patch( result = await adapter.fetch(None, "task_output", {"task_id": "t1"})
"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 "error" in result
assert "timed out" in result["error"].lower()
def test_source_registry_closed(): @pytest.mark.asyncio
assert set(SOURCE_REGISTRY.keys()) == { async def test_ssh_task_adapter_records_history_on_run(client):
"jellyfin", store = app.dependency_overrides[get_settings_store]()
"backups", # Save a task and an ssh_tasks service instance.
"grafana", task = store.upsert_task(
"prometheus", {
"ssh_task", "name": "echo",
"static", "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"
+9
View File
@@ -23,6 +23,7 @@ import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage"; import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage"; import { ObservabilityPage } from "./components/ObservabilityPage";
import { AddonPage } from "./pages/AddonPage"; import { AddonPage } from "./pages/AddonPage";
import { ServicePage } from "./pages/ServicePage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth"; import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client"; import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version"; import { FRONTEND_VERSION_LABEL } from "./version";
@@ -451,6 +452,10 @@ function AppInner() {
<Route path="/observability" element={<ObservabilityPage />} /> <Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} /> <Route path="/addons/:addonId" element={<AddonPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -483,6 +488,10 @@ function AppInner() {
<Route path="/observability" element={<ObservabilityPage />} /> <Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} /> <Route path="/addons/:addonId" element={<AddonPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+59
View File
@@ -0,0 +1,59 @@
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../types";
const API_BASE = "/api";
export async function fetchServiceTypes(): Promise<ServiceTypeInfo[]> {
const res = await fetch(`${API_BASE}/services/types`);
if (!res.ok) throw new Error("Failed to fetch service types");
return res.json();
}
export async function fetchServiceInstances(
serviceType?: string,
): Promise<ServiceInstance[]> {
const query = serviceType
? `?service_type=${encodeURIComponent(serviceType)}`
: "";
const res = await fetch(`${API_BASE}/services/instances${query}`);
if (!res.ok) throw new Error("Failed to fetch service instances");
return res.json();
}
export async function createServiceInstance(
input: ServiceInstanceInput,
): Promise<ServiceInstance> {
const res = await fetch(`${API_BASE}/services/instances`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to create service instance");
return res.json();
}
export async function updateServiceInstance(
input: ServiceInstanceInput,
): Promise<ServiceInstance> {
if (!input.id) throw new Error("Service ID is required for update");
const res = await fetch(`${API_BASE}/services/instances/${input.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to update service instance");
return res.json();
}
export async function deleteServiceInstance(
serviceId: string,
): Promise<{ status: string }> {
const res = await fetch(`${API_BASE}/services/instances/${serviceId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete service instance");
return res.json();
}
+6 -10
View File
@@ -1,21 +1,17 @@
import type { import type {
BuiltinWidgetKindInfo,
WidgetDataResponse, WidgetDataResponse,
WidgetInstance, WidgetInstance,
WidgetInstanceInput, WidgetInstanceInput,
WidgetTypeInfo,
} from "../types"; } from "../types";
const API_BASE = "/api"; const API_BASE = "/api";
export async function fetchWidgetSources(): Promise<string[]> { export async function fetchBuiltinWidgetKinds(): Promise<
const res = await fetch(`${API_BASE}/widgets/sources`); BuiltinWidgetKindInfo[]
if (!res.ok) throw new Error("Failed to fetch widget sources"); > {
return res.json(); const res = await fetch(`${API_BASE}/widgets/builtin`);
} if (!res.ok) throw new Error("Failed to fetch built-in widget kinds");
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(); return res.json();
} }
+164 -150
View File
@@ -23,36 +23,29 @@ import {
useDeleteWidgetInstance, useDeleteWidgetInstance,
useSaveWidgetInstance, useSaveWidgetInstance,
useWidgetInstances, useWidgetInstances,
useWidgetTypes,
} from "../hooks/useWidgets"; } from "../hooks/useWidgets";
import { useMonitoringSettings, useTasks } from "../hooks/useSettings"; import { useServiceInstances } from "../hooks/useServices";
import type { import { useTasks } from "../hooks/useSettings";
MonitoringMachine, import type { WidgetInstance, WidgetInstanceInput } from "../types";
SavedTask,
WidgetInstance,
WidgetInstanceInput,
} from "../types";
import { import {
getWidgetDefinition, BUILTIN_WIDGETS,
listWidgetTypes, SERVICE_REGISTRY,
type WidgetDefinition, type ServiceWidgetBinding,
} from "../widgets/registry"; } from "../integrations/registry";
interface Props { interface Props {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
} }
function emptyDraft(widgetType: string): WidgetInstanceInput { interface Draft {
const def = getWidgetDefinition(widgetType); id?: string;
return { serviceId: string | null;
addon_id: def?.addonId ?? "", widgetKind: string;
widget_type: widgetType, title: string;
title: def?.name ?? "", config: Record<string, unknown>;
config: { ...(def?.defaultConfig ?? {}) }, enabled: boolean;
enabled: true, sortOrder: number;
sort_order: 0,
};
} }
function Field({ function Field({
@@ -77,71 +70,36 @@ function Field({
); );
} }
function WidgetConfigFields({ function bindingLabel(serviceId: string | null, widgetKind: string): string {
definition, if (serviceId === null)
return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind;
return widgetKind;
}
function WidgetConfigEditor({
binding,
isTaskOutput,
config, config,
onChange, onChange,
machines,
tasks, tasks,
}: { }: {
definition: WidgetDefinition; binding: ServiceWidgetBinding | undefined;
isTaskOutput: boolean;
config: Record<string, unknown>; config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void; onChange: (config: Record<string, unknown>) => void;
machines: MonitoringMachine[]; tasks: { id: string; name: string; enabled: boolean }[];
tasks: SavedTask[];
}) { }) {
// SSH task output gets a dedicated task picker; everything else gets a
// generic text field per top-level schema property.
if (isTaskOutput) {
return ( return (
<div className="flex flex-col gap-3"> <Field label="Saved task" htmlFor="widget-task-id">
{definition.configFields.map((field) => {
const value = config[field.key] ?? "";
if (
definition.widgetType === "jellyfin" &&
field.key === "machine_id"
) {
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Select <Select
value={String(value)} value={String(config.task_id ?? "")}
onValueChange={(v) => onChange({ ...config, [field.key]: v })} onValueChange={(v) => onChange({ ...config, task_id: v })}
> >
<SelectTrigger id={field.key}> <SelectTrigger id="widget-task-id">
<SelectValue /> <SelectValue placeholder="Select a task" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Default</SelectItem>
{machines
.filter((m) => m.enabled && m.services.includes("jellyfin"))
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
if (definition.widgetType === "ssh-task" && field.key === "task_id") {
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Select
value={String(value)}
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
>
<SelectTrigger id={field.key}>
<SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tasks {tasks
@@ -157,46 +115,43 @@ function WidgetConfigFields({
); );
} }
if (field.type === "number") { const properties = binding
return ( ? Object.entries(
<Field (
key={field.key} binding.configSchema as
label={field.label} | { properties?: Record<string, unknown> }
htmlFor={field.key} | undefined
helper={field.helper} )?.properties ?? {},
> )
<Input : [];
id={field.key}
type="number" if (properties.length === 0) return null;
value={String(value)}
onChange={(e) =>
onChange({
...config,
[field.key]:
e.target.value === ""
? undefined
: Number(e.target.value),
})
}
/>
</Field>
);
}
return (
<div className="flex flex-col gap-3">
{properties.map(([key, schema]) => {
const isNumber =
(schema as { type?: string }).type === "integer" ||
(schema as { type?: string }).type === "number";
return ( return (
<Field <Field
key={field.key} key={key}
label={field.label} label={key}
htmlFor={field.key} htmlFor={`widget-cfg-${key}`}
helper={field.helper} helper={(schema as { description?: string }).description}
> >
<Input <Input
id={field.key} id={`widget-cfg-${key}`}
value={String(value)} type={isNumber ? "number" : "text"}
value={String(config[key] ?? "")}
onChange={(e) => onChange={(e) =>
onChange({ onChange({
...config, ...config,
[field.key]: e.target.value, [key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
}) })
} }
/> />
@@ -209,16 +164,12 @@ function WidgetConfigFields({
export function WidgetConfigDialog({ open, onClose }: Props) { export function WidgetConfigDialog({ open, onClose }: Props) {
const { data: instances = [] } = useWidgetInstances(); const { data: instances = [] } = useWidgetInstances();
const { data: types = [] } = useWidgetTypes(); const { data: services = [] } = useServiceInstances();
const { data: machines = [] } = useMonitoringSettings();
const { data: tasks = [] } = useTasks(); const { data: tasks = [] } = useTasks();
const saveWidget = useSaveWidgetInstance(); const saveWidget = useSaveWidgetInstance();
const deleteWidget = useDeleteWidgetInstance(); const deleteWidget = useDeleteWidgetInstance();
const [draft, setDraft] = useState<WidgetInstanceInput | null>(null); const [draft, setDraft] = useState<Draft | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const registryDefinitions = useMemo(() => listWidgetTypes(), []);
const sortedInstances = useMemo( const sortedInstances = useMemo(
() => () =>
@@ -228,39 +179,72 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
[instances], [instances],
); );
function startAdd(widgetType: string) { function startAddBuiltIn(kind: string) {
setDraft(emptyDraft(widgetType)); const binding = BUILTIN_WIDGETS[kind];
setEditingId(null); setDraft({
serviceId: null,
widgetKind: kind,
title: binding?.name ?? kind,
config: { ...(binding?.defaultConfig ?? {}) },
enabled: true,
sortOrder: 0,
});
}
function startAddService(serviceId: string, kind: string) {
const binding = SERVICE_REGISTRY[
services.find((s) => s.id === serviceId)?.service_type ?? ""
]?.widgets.find((w) => w.kind === kind);
setDraft({
serviceId,
widgetKind: kind,
title: binding?.name ?? kind,
config: { ...(binding?.defaultConfig ?? {}) },
enabled: true,
sortOrder: 0,
});
} }
function startEdit(instance: WidgetInstance) { function startEdit(instance: WidgetInstance) {
setDraft({ setDraft({
id: instance.id, id: instance.id,
addon_id: instance.addon_id, serviceId: instance.service_id,
widget_type: instance.widget_type, widgetKind: instance.widget_kind,
title: instance.title, title: instance.title,
config: instance.config, config: instance.config,
enabled: instance.enabled, enabled: instance.enabled,
sort_order: instance.sort_order, sortOrder: instance.sort_order,
}); });
setEditingId(instance.id);
} }
function reset() { function reset() {
setDraft(null); setDraft(null);
setEditingId(null);
} }
async function saveDraft() { async function saveDraft() {
if (!draft) return; if (!draft) return;
await saveWidget.mutateAsync(draft); const input: WidgetInstanceInput = {
id: draft.id ?? null,
service_id: draft.serviceId,
widget_kind: draft.widgetKind,
title: draft.title,
config: draft.config,
enabled: draft.enabled,
sort_order: draft.sortOrder,
};
await saveWidget.mutateAsync(input);
reset(); reset();
} }
async function toggleEnabled(instance: WidgetInstance) { async function toggleEnabled(instance: WidgetInstance) {
await saveWidget.mutateAsync({ await saveWidget.mutateAsync({
...instance, id: instance.id,
service_id: instance.service_id,
widget_kind: instance.widget_kind,
title: instance.title,
config: instance.config,
enabled: !instance.enabled, enabled: !instance.enabled,
sort_order: instance.sort_order,
}); });
} }
@@ -286,7 +270,17 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
} }
} }
const definition = draft ? getWidgetDefinition(draft.widget_type) : undefined; const draftBinding = draft
? draft.serviceId
? SERVICE_REGISTRY[
services.find((s) => s.id === draft.serviceId)?.service_type ?? ""
]?.widgets.find((w) => w.kind === draft.widgetKind)
: BUILTIN_WIDGETS[draft.widgetKind]
: undefined;
const isTaskOutput =
draft?.serviceId !== null &&
services.find((s) => s.id === draft?.serviceId)?.service_type ===
"ssh_tasks";
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
@@ -294,18 +288,15 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{draft {draft
? editingId ? draft.id
? "Edit widget" ? "Edit widget"
: "Add widget" : "Add widget"
: "Dashboard widgets"} : "Dashboard widgets"}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{draft && definition ? ( {draft ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
{definition.description}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Title" htmlFor="widget-title"> <Field label="Title" htmlFor="widget-title">
<Input <Input
@@ -320,11 +311,11 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<Input <Input
id="widget-sort-order" id="widget-sort-order"
type="number" type="number"
value={String(draft.sort_order)} value={String(draft.sortOrder)}
onChange={(e) => onChange={(e) =>
setDraft({ setDraft({
...draft, ...draft,
sort_order: sortOrder:
e.target.value === "" ? 0 : Number(e.target.value), e.target.value === "" ? 0 : Number(e.target.value),
}) })
} }
@@ -341,11 +332,11 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
/> />
<Label htmlFor="widget-enabled">Enabled</Label> <Label htmlFor="widget-enabled">Enabled</Label>
</div> </div>
<WidgetConfigFields <WidgetConfigEditor
definition={definition} binding={draftBinding}
isTaskOutput={!!isTaskOutput}
config={draft.config} config={draft.config}
onChange={(config) => setDraft({ ...draft, config })} onChange={(config) => setDraft({ ...draft, config })}
machines={machines}
tasks={tasks} tasks={tasks}
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -368,7 +359,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{sortedInstances.map((instance, index) => { {sortedInstances.map((instance, index) => {
const typeDef = getWidgetDefinition(instance.widget_type); const serviceName = instance.service_id
? services.find((s) => s.id === instance.service_id)?.name
: "Built-in";
return ( return (
<div <div
key={instance.id} key={instance.id}
@@ -378,8 +371,16 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">{instance.title}</span> <span className="font-medium">{instance.title}</span>
<Badge variant="outline"> <Badge variant="outline">
{typeDef?.name ?? instance.widget_type} {bindingLabel(
instance.service_id,
instance.widget_kind,
)}
</Badge> </Badge>
{serviceName ? (
<span className="text-xs text-muted-foreground">
{serviceName}
</span>
) : null}
{!instance.enabled ? ( {!instance.enabled ? (
<Badge variant="secondary">disabled</Badge> <Badge variant="secondary">disabled</Badge>
) : null} ) : null}
@@ -435,27 +436,40 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-sm font-medium">Add widget</p> <p className="text-sm font-medium">Add widget</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{registryDefinitions.map((def) => ( {Object.values(BUILTIN_WIDGETS).map((b) => (
<Button <Button
key={def.widgetType} key={b.kind}
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => startAdd(def.widgetType)} onClick={() => startAddBuiltIn(b.kind)}
> >
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
{def.name} {b.name}
</Button> </Button>
))} ))}
{services
.filter((s) => s.enabled)
.flatMap((s) =>
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
(w) => (
<Button
key={`${s.id}:${w.kind}`}
variant="outline"
size="sm"
onClick={() => startAddService(s.id, w.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{w.name} · {s.name}
</Button>
),
),
)}
</div> </div>
<p className="text-xs text-muted-foreground">
Configure services on their service pages to unlock more
widgets.
</p>
</div> </div>
{types.length === 0 ? (
<Alert>
<AlertDescription>
Widget registry is empty. Backend may not be running.
</AlertDescription>
</Alert>
) : null}
</div> </div>
)} )}
</DialogContent> </DialogContent>
+19 -9
View File
@@ -1,5 +1,6 @@
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { getWidgetDefinition } from "../widgets/registry"; import { useServiceInstances } from "../hooks/useServices";
import { resolveWidget } from "../integrations/registry";
import type { WidgetInstance } from "../types"; import type { WidgetInstance } from "../types";
import { SectionCard } from "./SectionCard"; import { SectionCard } from "./SectionCard";
@@ -7,20 +8,29 @@ interface Props {
widget: WidgetInstance; widget: WidgetInstance;
} }
export function WidgetInstance({ widget }: Props) { export function WidgetInstanceCard({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type); const { data: services = [] } = useServiceInstances();
if (!def) { const resolved = resolveWidget(widget, services);
if (!resolved) {
const label = widget.service_id
? `Unknown widget: ${widget.widget_kind} (service-bound)`
: `Unknown widget: ${widget.widget_kind} (built-in)`;
return ( return (
<SectionCard title={widget.title}> <SectionCard title={widget.title}>
<Alert> <Alert>
<AlertDescription> <AlertDescription>{label}</AlertDescription>
Unknown widget type: {widget.widget_type}
</AlertDescription>
</Alert> </Alert>
</SectionCard> </SectionCard>
); );
} }
const Component = def.component; const Component = resolved.component;
return <Component widget={widget} />; return (
<Component
widget={widget}
refreshIntervalMs={resolved.refreshIntervalMs}
description={resolved.description}
/>
);
} }
+47
View File
@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createServiceInstance,
deleteServiceInstance,
fetchServiceInstances,
fetchServiceTypes,
updateServiceInstance,
} from "../api/services";
import type { ServiceInstanceInput } from "../types";
export function useServiceTypes() {
return useQuery({
queryKey: ["services", "types"],
queryFn: fetchServiceTypes,
staleTime: 5 * 60 * 1000,
});
}
export function useServiceInstances(serviceType?: string) {
return useQuery({
queryKey: ["services", "instances", serviceType ?? "all"],
queryFn: () => fetchServiceInstances(serviceType),
refetchInterval: 60_000,
});
}
export function useSaveServiceInstance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ServiceInstanceInput) =>
input.id ? updateServiceInstance(input) : createServiceInstance(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services", "instances"] });
},
});
}
export function useDeleteServiceInstance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (serviceId: string) => deleteServiceInstance(serviceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services", "instances"] });
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
},
});
}
+5 -12
View File
@@ -2,10 +2,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
createWidgetInstance, createWidgetInstance,
deleteWidgetInstance, deleteWidgetInstance,
fetchBuiltinWidgetKinds,
fetchWidgetData, fetchWidgetData,
fetchWidgetInstances, fetchWidgetInstances,
fetchWidgetSources,
fetchWidgetTypes,
updateWidgetInstance, updateWidgetInstance,
} from "../api/widgets"; } from "../api/widgets";
import type { WidgetInstanceInput } from "../types"; import type { WidgetInstanceInput } from "../types";
@@ -49,16 +48,10 @@ export function useDeleteWidgetInstance() {
}); });
} }
export function useWidgetSources() { export function useBuiltinWidgetKinds() {
return useQuery({ return useQuery({
queryKey: ["widgets", "sources"], queryKey: ["widgets", "builtin"],
queryFn: fetchWidgetSources, queryFn: fetchBuiltinWidgetKinds,
}); staleTime: 5 * 60 * 1000,
}
export function useWidgetTypes() {
return useQuery({
queryKey: ["widgets", "types"],
queryFn: fetchWidgetTypes,
}); });
} }
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_WIDGETS,
SERVICE_REGISTRY,
getBuiltinBinding,
getServiceBinding,
resolveWidget,
} from "./registry";
import type { ServiceInstance, WidgetInstance } from "../types";
describe("service registry", () => {
it("registers the five backend service types", () => {
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
"grafana",
"jellyfin",
"nextcloud",
"prometheus",
"ssh_tasks",
]);
});
it("binds widget kinds per service", () => {
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
"link",
]);
expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([
"task_output",
]);
expect(SERVICE_REGISTRY.nextcloud.widgets).toEqual([]);
});
it("registers the two built-in widget kinds", () => {
expect(Object.keys(BUILTIN_WIDGETS).sort()).toEqual(["backups", "static"]);
});
it("resolves a service-bound widget via the services list", () => {
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "link",
title: "Dashboard",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
const services: ServiceInstance[] = [
{
id: "s1",
service_type: "grafana",
name: "Grafana",
config: { base_url: "https://grafana.example.com" },
secrets_set: { api_key: true },
enabled: true,
created_at: 0,
updated_at: 0,
},
];
const resolved = resolveWidget(widget, services);
expect(resolved).toBeDefined();
expect(resolved?.refreshIntervalMs).toBe(0);
});
it("resolves a built-in widget without a service", () => {
const widget: WidgetInstance = {
id: "w2",
service_id: null,
widget_kind: "static",
title: "Note",
config: { text: "hi" },
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
const resolved = resolveWidget(widget, []);
expect(resolved).toBeDefined();
});
it("returns undefined for an unknown widget kind", () => {
const widget: WidgetInstance = {
id: "w3",
service_id: null,
widget_kind: "bogus",
title: "x",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
expect(resolveWidget(widget, [])).toBeUndefined();
});
it("lookups return undefined for unknown types", () => {
expect(getServiceBinding("nope")).toBeUndefined();
expect(getBuiltinBinding("nope")).toBeUndefined();
});
});
+213
View File
@@ -0,0 +1,213 @@
import type { ComponentType } from "react";
import { BackupsWidget } from "../widgets/BackupsWidget";
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
import { SshTaskWidget } from "../widgets/SshTaskWidget";
import { StaticWidget } from "../widgets/StaticWidget";
import type {
ServiceInstance,
ServiceTypeInfo,
WidgetInstance,
} from "../types";
/**
* Closed frontend registry mirroring the backend service definitions.
*
* Each service type maps its widget kinds to a presentational component and a
* refresh interval. Built-in (service-less) kinds are listed separately.
*/
export interface WidgetComponentProps {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export interface ServiceWidgetBinding {
kind: string;
name: string;
description: string;
refreshIntervalMs: number;
defaultConfig: Record<string, unknown>;
configSchema: Record<string, unknown>;
component: ComponentType<WidgetComponentProps>;
}
export interface ServiceBinding {
serviceType: string;
name: string;
description: string;
widgets: ServiceWidgetBinding[];
}
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
grafana: {
serviceType: "grafana",
name: "Grafana",
description: "Dashboards, metrics, and logs.",
widgets: [
{
kind: "link",
name: "Dashboard link",
description: "Deep-link to a Grafana dashboard or panel.",
refreshIntervalMs: 0,
defaultConfig: { dashboard_uid: "" },
configSchema: {
type: "object",
properties: {
dashboard_uid: { type: "string" },
panel_id: { type: "integer" },
},
required: ["dashboard_uid"],
},
component: GrafanaLinkWidget,
},
],
},
prometheus: {
serviceType: "prometheus",
name: "Prometheus",
description: "Metrics storage and PromQL queries.",
widgets: [
{
kind: "metric",
name: "Metric",
description: "Instant query result rendered as a metric.",
refreshIntervalMs: 30_000,
defaultConfig: { promql: "" },
configSchema: {
type: "object",
properties: { promql: { type: "string" } },
required: ["promql"],
},
component: PrometheusMetricWidget,
},
],
},
jellyfin: {
serviceType: "jellyfin",
name: "Jellyfin",
description: "Media server with live session activity.",
widgets: [
{
kind: "activity",
name: "Activity",
description: "Live sessions and idle users.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: JellyfinWidget,
},
],
},
nextcloud: {
serviceType: "nextcloud",
name: "Nextcloud",
description: "Self-hosted files and collaboration.",
widgets: [],
},
ssh_tasks: {
serviceType: "ssh_tasks",
name: "SSH task runner",
description: "Run reusable saved tasks over SSH and keep run history.",
widgets: [
{
kind: "task_output",
name: "Task output",
description: "Output of a saved task run.",
refreshIntervalMs: 0,
defaultConfig: { task_id: "" },
configSchema: {
type: "object",
properties: { task_id: { type: "string" } },
required: ["task_id"],
},
component: SshTaskWidget,
},
],
},
};
export const BUILTIN_WIDGETS: Record<string, ServiceWidgetBinding> = {
backups: {
kind: "backups",
name: "Backups",
description: "Backup job summary and active alerts.",
refreshIntervalMs: 60_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: BackupsWidget,
},
static: {
kind: "static",
name: "Static text",
description: "Plain text or markdown note.",
refreshIntervalMs: 0,
defaultConfig: { text: "" },
configSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
component: StaticWidget,
},
};
export function getServiceBinding(
serviceType: string,
): ServiceBinding | undefined {
return SERVICE_REGISTRY[serviceType];
}
export function getBuiltinBinding(
kind: string,
): ServiceWidgetBinding | undefined {
return BUILTIN_WIDGETS[kind];
}
export interface ResolvedWidget {
component: ComponentType<WidgetComponentProps>;
description: string;
refreshIntervalMs: number;
}
/**
* Resolve a widget instance to its component + metadata.
*
* Service-bound widgets are resolved via the parent service's type (looked up
* from the services list); built-in widgets are resolved directly.
*/
export function resolveWidget(
widget: WidgetInstance,
services: ServiceInstance[],
): ResolvedWidget | undefined {
if (widget.service_id) {
const service = services.find((s) => s.id === widget.service_id);
if (!service) return undefined;
const binding = getServiceBinding(service.service_type);
const widgetBinding = binding?.widgets.find(
(w) => w.kind === widget.widget_kind,
);
if (!widgetBinding) return undefined;
return {
component: widgetBinding.component,
description: widgetBinding.description,
refreshIntervalMs: widgetBinding.refreshIntervalMs,
};
}
const builtin = getBuiltinBinding(widget.widget_kind);
if (!builtin) return undefined;
return {
component: builtin.component,
description: builtin.description,
refreshIntervalMs: builtin.refreshIntervalMs,
};
}
/** Merge backend type metadata (config_schema, secret_fields) onto bindings. */
export function enrichServiceTypes(
types: ServiceTypeInfo[],
): ServiceTypeInfo[] {
return types;
}
+2 -2
View File
@@ -30,7 +30,7 @@ import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter"; import { DialogFooter } from "../components/DialogFooter";
import { WidgetInstance } from "../components/WidgetInstance"; import { WidgetInstanceCard } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog"; import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
function emptyShortcut(): DashboardShortcutInput { function emptyShortcut(): DashboardShortcutInput {
@@ -418,7 +418,7 @@ export function Dashboard() {
</SectionCard> </SectionCard>
{visibleWidgets.map((widget) => ( {visibleWidgets.map((widget) => (
<WidgetInstance key={widget.id} widget={widget} /> <WidgetInstanceCard key={widget.id} widget={widget} />
))} ))}
<ShortcutDialog <ShortcutDialog
+255
View File
@@ -0,0 +1,255 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
} from "../hooks/useServices";
import type { ServiceInstance, ServiceInstanceInput } from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { getServiceBinding } from "../integrations/registry";
function Field({
label,
htmlFor,
helper,
children,
}: {
label: string;
htmlFor: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{helper ? (
<p className="text-xs text-muted-foreground">{helper}</p>
) : null}
</div>
);
}
export function ServicePage() {
const { serviceType = "", serviceId = "" } = useParams<{
serviceType: string;
serviceId: string;
}>();
const { data: services = [] } = useServiceInstances(serviceType || undefined);
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
const instance = useMemo(
() => services.find((s) => s.id === serviceId),
[services, serviceId],
);
const binding = getServiceBinding(serviceType);
const [name, setName] = useState("");
const [enabled, setEnabled] = useState(true);
const [deleteOpen, setDeleteOpen] = useState(false);
const [hydrated, setHydrated] = useState(false);
// Hydrate local form state once the instance loads.
if (instance && !hydrated) {
setName(instance.name);
setEnabled(instance.enabled);
setHydrated(true);
}
if (!binding) {
return (
<Alert>
<AlertDescription>Unknown service type: {serviceType}</AlertDescription>
</Alert>
);
}
if (!instance) {
return (
<Alert>
<AlertDescription>Service not found.</AlertDescription>
</Alert>
);
}
function buildInput(): ServiceInstanceInput {
return {
id: instance!.id,
service_type: instance!.service_type,
name,
config: instance!.config,
secrets: {}, // secrets are managed via the dedicated inputs below
enabled,
};
}
async function save() {
await saveService.mutateAsync(buildInput());
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">{instance.name}</h2>
<p className="text-sm text-muted-foreground">{binding.description}</p>
</div>
<Badge variant="outline">{binding.name}</Badge>
</div>
<SectionCard title="General">
<div className="flex flex-col gap-3">
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
<div className="flex justify-between">
<Button onClick={save} disabled={saveService.isPending}>
Save
</Button>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
Delete
</Button>
</div>
</div>
</SectionCard>
<ServiceSecretsCard instance={instance} />
{binding.widgets.length > 0 ? (
<SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
<div className="flex flex-col gap-2">
{binding.widgets.map((w) => (
<div
key={w.kind}
className="flex items-center justify-between rounded border p-2"
>
<div>
<div className="font-medium">{w.name}</div>
<div className="text-xs text-muted-foreground">
{w.description}
</div>
</div>
<Badge variant="outline">{w.kind}</Badge>
</div>
))}
<p className="text-xs text-muted-foreground">
Add these to the dashboard from the dashboard's edit dialog.
</p>
</div>
</SectionCard>
) : null}
<ConfirmDialog
open={deleteOpen}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
}}
/>
</div>
);
}
function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
const saveService = useSaveServiceInstance();
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
return (
<SectionCard
title="Connection"
description="Non-secret config is read-only here for now; edit secret values below."
>
<div className="flex flex-col gap-3">
{Object.entries(instance.config).length === 0 ? (
<p className="text-sm text-muted-foreground">No connection config.</p>
) : (
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
{Object.entries(instance.config).map(([key, value]) => (
<div key={key} className="flex flex-col">
<dt className="text-xs text-muted-foreground">{key}</dt>
<dd className="truncate font-mono text-xs">{String(value)}</dd>
</div>
))}
</dl>
)}
{Object.keys(instance.secrets_set).length === 0 ? (
<p className="text-sm text-muted-foreground">No secret fields.</p>
) : (
<div className="flex flex-col gap-3">
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
<div key={key} className="flex flex-col gap-1.5">
<Field
label={key}
htmlFor={`secret-${key}`}
helper="Leave blank to keep the current value."
>
<Input
id={`secret-${key}`}
type="password"
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
setDraftSecrets({
...draftSecrets,
[key]: e.target.value,
})
}
/>
</Field>
{isSet ? <Badge variant="secondary">set</Badge> : null}
</div>
))}
<Button
onClick={() => {
const onlyChanged = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
saveService.mutate({
id: instance.id,
service_type: instance.service_type,
name: instance.name,
config: instance.config,
secrets: onlyChanged,
enabled: instance.enabled,
});
setDraftSecrets({});
}}
>
Update secrets
</Button>
</div>
)}
</div>
</SectionCard>
);
}
@@ -5,12 +5,12 @@ import { Dashboard } from "../Dashboard";
import type { DashboardShortcut } from "../../types"; import type { DashboardShortcut } from "../../types";
// Stub the composed widgets so the test exercises Dashboard's own behavior // Stub the composed widgets so the test exercises Dashboard's own behavior
// (shortcut CRUD) without rendering the session panel or the backup query. // (shortcut CRUD) without rendering widgets or their data queries.
vi.mock("../../components/NowPlaying", () => ({ vi.mock("../../components/WidgetInstance", () => ({
NowPlaying: () => <div data-testid="now-playing-stub" />, WidgetInstanceCard: () => <div data-testid="widget-stub" />,
})); }));
vi.mock("../../components/BackupDashboardWidget", () => ({ vi.mock("../../components/WidgetConfigDialog", () => ({
default: () => <div data-testid="backup-widget-stub" />, WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
})); }));
const navigate = vi.fn(); const navigate = vi.fn();
@@ -21,6 +21,9 @@ vi.mock("react-router-dom", () => ({
vi.mock("../../hooks/useSettings", () => ({ vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }), useMonitoringSettings: () => ({ data: [] }),
})); }));
vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: [] }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({}); const saveShortcutMutate = vi.fn().mockResolvedValue({});
const deleteShortcutMutate = vi.fn(); const deleteShortcutMutate = vi.fn();
+58 -14
View File
@@ -446,8 +446,8 @@ export interface PrometheusTarget {
export interface WidgetInstance { export interface WidgetInstance {
id: string; id: string;
addon_id: string; service_id: string | null;
widget_type: string; widget_kind: string;
title: string; title: string;
config: Record<string, unknown>; config: Record<string, unknown>;
enabled: boolean; enabled: boolean;
@@ -458,27 +458,71 @@ export interface WidgetInstance {
export interface WidgetInstanceInput { export interface WidgetInstanceInput {
id?: string | null; id?: string | null;
addon_id: string; service_id: string | null;
widget_type: string; widget_kind: string;
title: string; title: string;
config: Record<string, unknown>; config: Record<string, unknown>;
enabled: boolean; enabled: boolean;
sort_order: number; 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 { export interface WidgetDataResponse {
widget_id: string; widget_id: string;
widget_type: string;
data: Record<string, unknown> | null; data: Record<string, unknown> | null;
error: string | null; error: string | null;
fetched_at: number; fetched_at: number;
} }
export interface SecretFieldInfo {
key: string;
label: string;
required: boolean;
helper?: string | null;
}
export interface ServiceWidgetKindInfo {
kind: string;
name: string;
description: string;
config_schema: Record<string, unknown>;
default_config: Record<string, unknown>;
refresh_interval_ms: number;
}
export interface ServiceTypeInfo {
service_type: string;
name: string;
description: string;
config_schema: Record<string, unknown>;
secret_fields: SecretFieldInfo[];
widget_kinds: ServiceWidgetKindInfo[];
}
export interface ServiceInstance {
id: string;
service_type: string;
name: string;
config: Record<string, unknown>;
secrets_set: Record<string, boolean>;
enabled: boolean;
created_at: number;
updated_at: number;
}
export interface ServiceInstanceInput {
id?: string | null;
service_type: string;
name: string;
config: Record<string, unknown>;
secrets: Record<string, string>;
enabled: boolean;
}
export interface BuiltinWidgetKindInfo {
kind: string;
name: string;
description: string;
config_schema: Record<string, unknown>;
default_config: Record<string, unknown>;
refresh_interval_ms: number;
}
+9 -8
View File
@@ -5,22 +5,23 @@ import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets"; import { useWidgetData } from "../hooks/useWidgets";
import type { BackupDashboardSummary } from "../types/backups"; import type { BackupDashboardSummary } from "../types/backups";
import type { WidgetInstance } from "../types"; import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props { interface Props {
widget: WidgetInstance; widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
} }
export function BackupsWidget({ widget }: Props) { export function BackupsWidget({
const def = getWidgetDefinition(widget.widget_type); widget,
const { data, isLoading } = useWidgetData( refreshIntervalMs,
widget.id, description,
def?.refreshInterval ?? 0, }: Props) {
); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const summary = data?.data as BackupDashboardSummary | undefined; const summary = data?.data as BackupDashboardSummary | undefined;
return ( return (
<SectionCard title={widget.title} description={def?.description}> <SectionCard title={widget.title} description={description}>
{isLoading && !data ? ( {isLoading && !data ? (
<div className="flex flex-row flex-wrap gap-6"> <div className="flex flex-row flex-wrap gap-6">
<Skeleton className="h-10 w-20" /> <Skeleton className="h-10 w-20" />
+9 -8
View File
@@ -5,22 +5,23 @@ import { ExternalLink } from "lucide-react";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets"; import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types"; import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props { interface Props {
widget: WidgetInstance; widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
} }
export function GrafanaLinkWidget({ widget }: Props) { export function GrafanaLinkWidget({
const def = getWidgetDefinition(widget.widget_type); widget,
const { data, isLoading } = useWidgetData( refreshIntervalMs,
widget.id, description,
def?.refreshInterval ?? 0, }: Props) {
); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const url = data?.data?.url as string | undefined; const url = data?.data?.url as string | undefined;
return ( return (
<SectionCard title={widget.title} description={def?.description}> <SectionCard title={widget.title} description={description}>
{isLoading && !data ? ( {isLoading && !data ? (
<Skeleton className="h-10 w-48" /> <Skeleton className="h-10 w-48" />
) : data?.error ? ( ) : data?.error ? (
+9 -8
View File
@@ -4,22 +4,23 @@ import { SessionActivityPanel } from "../components/SessionActivityPanel";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets"; import { useWidgetData } from "../hooks/useWidgets";
import type { NowPlayingSession, WidgetInstance } from "../types"; import type { NowPlayingSession, WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props { interface Props {
widget: WidgetInstance; widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
} }
export function JellyfinWidget({ widget }: Props) { export function JellyfinWidget({
const def = getWidgetDefinition(widget.widget_type); widget,
const { data, isLoading } = useWidgetData( refreshIntervalMs,
widget.id, description,
def?.refreshInterval ?? 0, }: Props) {
); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined; const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
return ( return (
<SectionCard title={widget.title} description={def?.description}> <SectionCard title={widget.title} description={description}>
{isLoading && !data ? ( {isLoading && !data ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-3/4" />
@@ -3,10 +3,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets"; import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types"; import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props { interface Props {
widget: WidgetInstance; widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
} }
type PromQLResult = { type PromQLResult = {
@@ -35,16 +36,16 @@ function formatPrometheusValue(result: PromQLResult | undefined): string {
return JSON.stringify(result, null, 2); return JSON.stringify(result, null, 2);
} }
export function PrometheusMetricWidget({ widget }: Props) { export function PrometheusMetricWidget({
const def = getWidgetDefinition(widget.widget_type); widget,
const { data, isLoading } = useWidgetData( refreshIntervalMs,
widget.id, description,
def?.refreshInterval ?? 0, }: Props) {
); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const result = data?.data?.result as PromQLResult | undefined; const result = data?.data?.result as PromQLResult | undefined;
return ( return (
<SectionCard title={widget.title} description={def?.description}> <SectionCard title={widget.title} description={description}>
{isLoading && !data ? ( {isLoading && !data ? (
<Skeleton className="h-10 w-32" /> <Skeleton className="h-10 w-32" />
) : data?.error ? ( ) : data?.error ? (
+9 -8
View File
@@ -3,10 +3,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets"; import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types"; import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props { interface Props {
widget: WidgetInstance; widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
} }
type SshTaskResult = { type SshTaskResult = {
@@ -15,16 +16,16 @@ type SshTaskResult = {
stderr: string; stderr: string;
}; };
export function SshTaskWidget({ widget }: Props) { export function SshTaskWidget({
const def = getWidgetDefinition(widget.widget_type); widget,
const { data, isLoading } = useWidgetData( refreshIntervalMs,
widget.id, description,
def?.refreshInterval ?? 0, }: Props) {
); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const result = data?.data as SshTaskResult | undefined; const result = data?.data as SshTaskResult | undefined;
return ( return (
<SectionCard title={widget.title} description={def?.description}> <SectionCard title={widget.title} description={description}>
{isLoading && !data ? ( {isLoading && !data ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" /> <Skeleton className="h-4 w-full" />
+9 -5
View File
@@ -1,19 +1,23 @@
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets"; import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types"; import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props { interface Props {
widget: WidgetInstance; widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
} }
export function StaticWidget({ widget }: Props) { export function StaticWidget({
const def = getWidgetDefinition(widget.widget_type); widget,
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0); refreshIntervalMs,
description,
}: Props) {
const { data } = useWidgetData(widget.id, refreshIntervalMs);
const text = data?.data?.text as string | undefined; const text = data?.data?.text as string | undefined;
return ( return (
<SectionCard title={widget.title} description={def?.description}> <SectionCard title={widget.title} description={description}>
{text ? ( {text ? (
<p className="whitespace-pre-wrap text-sm">{text}</p> <p className="whitespace-pre-wrap text-sm">{text}</p>
) : ( ) : (
-6
View File
@@ -4,9 +4,3 @@ export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget"; export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { SshTaskWidget } from "./SshTaskWidget"; export { SshTaskWidget } from "./SshTaskWidget";
export { StaticWidget } from "./StaticWidget"; export { StaticWidget } from "./StaticWidget";
export {
getWidgetDefinition,
listWidgetTypes,
WIDGET_REGISTRY,
} from "./registry";
export type { WidgetConfigField, WidgetDefinition } from "./registry";
-42
View File
@@ -1,42 +0,0 @@
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
@@ -1,122 +0,0 @@
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);
}
@@ -1,79 +1,87 @@
# Apply Progress: Runtime Service Registry # Apply Progress: Runtime Service Registry
**Change:** `service-registry` **Change:** `service-registry`
**Apply run:** PR 1 / Slice 1 — Backend service foundation **Apply run:** PR 1 + PR 2 + PR 3 (Slices 13)
**Date:** 2026-06-19 **Date:** 2026-06-19
## Completed tasks (Slice 1) ## Slice 1 — Backend service foundation (MERGED, PR #7)
- [x] 1.1 Add encryption helper (`services/secrets.py`) Fernet secrets, closed `integrations/` registry (Pydantic config + widget-config
- [x] 1.2 Add integrations base classes (`integrations/base.py`) for grafana/prometheus/jellyfin/nextcloud/ssh_tasks), `services` +
- [x] 1.3 Add five service definitions + registry `service_task_runs` tables with cascade delete, `/api/services*` CRUD,
- [x] 1.4 Add `services` + `service_task_runs` tables + store CRUD with cascade delete `MANAGE_ENCRYPTION_KEY` required at startup.
- [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)
## Files changed (Slice 1) ## Slice 2 — Backend widget rebind (MERGED, PR #8)
### New files Widgets carry `service_id` + `widget_kind`; adapters take
`fetch(service: ServiceRecord | None, widget_kind, config)`; backups + static
stay as service-less built-ins; SSH adapter logs to `service_task_runs`; old
`widgets/registry.py` retired; default seeding removed.
- `backend/src/media_library_viewer_api/integrations/__init__.py` — package marker. ## Slice 3 — Frontend services runtime (this PR)
- `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.
### Modified files ### Completed tasks
- `backend/src/media_library_viewer_api/services/settings_store.py``services` and - [x] 3.1 Service + new widget TypeScript types (`ServiceInstance`,
`service_task_runs` tables; service CRUD; cascade delete (defensive against the `ServiceInstanceInput`, `ServiceTypeInfo`, `ServiceWidgetKindInfo`,
not-yet-present `dashboard_widgets.service_id` column); task-run history helpers. `SecretFieldInfo`, `BuiltinWidgetKindInfo`; widget gains `service_id` +
- `backend/src/media_library_viewer_api/main.py` — register `services_router`; `widget_kind`).
validate encryption key on startup. - [x] 3.2 Services API + hooks (`api/services.ts`, `hooks/useServices.ts`).
- `backend/pyproject.toml` — declare `cryptography>=42.0` direct dependency. Reconciled `api/widgets.ts` + `hooks/useWidgets.ts` to the new shape
- `docker-compose.yml`, `docker-compose.dev.yml`, `.env.example`, `README.md` — require (removed sources/types; added builtin kinds).
and document `MANAGE_ENCRYPTION_KEY`. - [x] 3.3 Closed frontend service registry (`integrations/registry.ts`)
mirroring the backend; `resolveWidget(widget, services)` maps a widget to
its component + refresh interval.
- [x] 3.4 Service page at `/services/:serviceType/:serviceId` with config view,
empty-on-edit secret inputs + "set" badges, enable toggle, delete, and the
service's widget-kind list.
- [x] 3.5 Route swap: added `/services/:serviceType/:serviceId`; addon route
retained for now (removed in Slice 4 cleanup).
- [x] 3.6 Reconciled widget components to take `refreshIntervalMs` +
`description` props; rewrote `WidgetConfigDialog` around the
service → widget-kind picker (pulled 4.1 forward to keep the build whole).
- [x] 3.7 Registry + Dashboard tests updated; new
`integrations/registry.test.ts`.
## Verification (Slice 1) ### Decision resolved mid-slice
Secret edit UX = **empty-on-edit + "set" badge** (blank = keep existing; typing
= replace). Applied on the ServicePage secrets card.
### Files changed (Slice 3)
- New: `api/services.ts`, `hooks/useServices.ts`, `integrations/registry.ts`,
`integrations/registry.test.ts`, `pages/ServicePage.tsx`.
- Modified: `types/index.ts`, `api/widgets.ts`, `hooks/useWidgets.ts`,
`components/WidgetInstance.tsx`, `components/WidgetConfigDialog.tsx`,
`pages/Dashboard.tsx`, `pages/__tests__/Dashboard.test.tsx`, `App.tsx`,
all six `widgets/*.tsx` components, `widgets/index.ts`.
- Deleted: `widgets/registry.ts`, `widgets/registry.test.ts`.
### Verification (Slice 3)
```bash ```bash
cd backend cd frontend
.venv/bin/ruff check . # All checks passed
PYTHONPATH=src .venv/bin/python -m pytest # 225 passed
cd ../frontend
npm run lint # 0 errors npm run lint # 0 errors
npm run build # success npm run build # success
npm run test # 70 passed
cd ../backend
.venv/bin/ruff check . # clean
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
``` ```
Smoke: encryption round-trip OK; missing `MANAGE_ENCRYPTION_KEY` raises on startup. ### Deviations / notes
## Deviations from design - `WidgetConfigDialog` was rewritten in this slice (pulled forward from task
4.1) because the old dialog imported the deleted widget registry and would
- Service-config and widget-config schemas are derived from **Pydantic models** not compile. The SSH task-output widget keeps a dedicated task picker; other
(`model_json_schema()`), matching the user's "proper pydantic config definitions" widget configs use a generic schema-driven field editor.
request. The design's hand-written JSON schemas were replaced by model-derived ones. - Addon pages (`/addons/:addonId`) are kept compiling but superseded by service
- Service-table CRUD lives on `SettingsStore` (not a separate `service_store.py`) to pages; Slice 4 removes them and the now-unused machine Jellyfin/Jellyseerr
match how widgets/saved_tasks/ssh_keys are already handled there. This keeps a single fields + `grafana_url`/`prometheus_url` env vars, and writes the changelog.
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.
## Remaining work ## Remaining work
- Slice 2: Backend widget rebind to services (add `service_id`/`widget_kind`, refactor - Slice 4: remove addon pages + machine app fields, remove
adapters to take a `ServiceRecord`, retire old widget registry, SSH run logging). `grafana_url`/`prometheus_url` from config + compose, docs + changelog
- Slice 3: Frontend services runtime (types, API, hooks, frontend registry, service (breaking upgrade note).
pages, route swap).
- Slice 4: Dashboard picker, settings rework, remove `grafana_url`/`prometheus_url`
env vars, stop default seeding, docs + changelog.