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]
default_config: dict[str, Any] = field(default_factory=dict)
refresh_interval_ms: int = 0
config_model: type[WidgetConfigBase] | None = None
@dataclass(frozen=True)
@@ -108,6 +109,7 @@ def widget_kind(
config_schema=schema,
default_config=dict(default_config or {}),
refresh_interval_ms=refresh_interval_ms,
config_model=model_cls,
)
@@ -1,8 +1,18 @@
"""Pydantic models for the dashboard widget system."""
"""Pydantic models for the dashboard widget system.
Widgets are either:
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
by that service's definition (Grafana link, Prometheus metric, Jellyfin
activity, SSH task output); or
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
service-less kinds (backups, static).
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
FORBIDDEN_CONFIG_KEYS = {
"password",
@@ -47,8 +57,8 @@ def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
class _WidgetInstanceBase(BaseModel):
"""Shared fields between input and output widget models."""
addon_id: str
widget_type: str
service_id: str | None = None
widget_kind: str = Field(..., min_length=1)
title: str = Field(..., min_length=1)
config: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
@@ -59,6 +69,13 @@ class _WidgetInstanceBase(BaseModel):
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
return _validate_config_keys(value or {})
@model_validator(mode="after")
def _validate_kind(self) -> "_WidgetInstanceBase":
# The kind must be non-empty (Field enforces it); service_id may be None
# for built-ins. Deeper validation happens in the router against the
# service definition / built-in registry.
return self
class WidgetInstanceInput(_WidgetInstanceBase):
"""Payload for creating or updating a widget instance."""
@@ -74,22 +91,21 @@ class WidgetInstance(_WidgetInstanceBase):
updated_at: int
class WidgetTypeInfo(BaseModel):
"""Metadata about a built-in widget type."""
class BuiltinWidgetKindInfo(BaseModel):
"""Metadata about a built-in (service-less) widget kind."""
addon_id: str
widget_type: str
kind: str
name: str
description: str
source_type: str
config_schema: dict[str, Any]
default_config: dict[str, Any]
refresh_interval_ms: int
class WidgetDataResponse(BaseModel):
"""Response from the per-widget data endpoint."""
widget_id: str
widget_type: str
data: dict[str, Any] | None = None
error: str | None = None
fetched_at: int
@@ -1,4 +1,11 @@
"""REST API for dashboard widget instances and registry metadata."""
"""REST API for dashboard widget instances.
Widgets are either service-bound (``service_id`` + ``widget_kind`` from the
service definition) or built-in (``service_id`` is null; ``widget_kind`` is one
of the service-less kinds exposed by ``GET /api/widgets/builtin``).
"""
from __future__ import annotations
import logging
import time
@@ -7,68 +14,91 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.integrations.base import validate_config
from media_library_viewer_api.integrations.registry import get_service_definition
from media_library_viewer_api.models.widgets import (
BuiltinWidgetKindInfo,
WidgetDataResponse,
WidgetInstance,
WidgetInstanceInput,
)
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.registry import (
get_widget_info,
list_source_types,
list_widget_types,
validate_config,
from media_library_viewer_api.widgets.builtin import (
BUILTIN_WIDGET_KINDS,
is_builtin_kind,
validate_builtin_config,
)
from media_library_viewer_api.widgets.sources import (
build_service_record,
get_builtin_adapter,
get_service_adapter,
)
from media_library_viewer_api.widgets.sources import get_source_adapter
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
logger = logging.getLogger(__name__)
def _registry_for_type(widget_type: str) -> dict[str, Any]:
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
def _validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) -> None:
"""Validate widget_kind + config against the service definition or built-ins."""
if body.service_id:
service = store.get_service(body.service_id)
if not service:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Service {body.service_id} not found",
)
definition = get_service_definition(service["service_type"])
if definition is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown service type: {service['service_type']}",
)
widget_kind = definition.widget_kind(body.widget_kind)
if widget_kind is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(f"Service type '{service['service_type']}' does not provide widget kind '{body.widget_kind}'"),
)
if widget_kind.config_model is not None:
try:
validate_config(widget_kind.config_model, body.config)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid widget config: {exc}",
) from exc
else:
if not is_builtin_kind(body.widget_kind):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"Unknown built-in widget kind '{body.widget_kind}' (set service_id for service-bound widgets)"
),
)
try:
validate_builtin_config(body.widget_kind, body.config)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid widget config: {exc}",
) from exc
info = WIDGET_REGISTRY.get(widget_type)
if not info:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown widget type: {widget_type}",
@router.get("/builtin")
def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
"""Return metadata for service-less built-in widget kinds."""
return [
BuiltinWidgetKindInfo(
kind=wk.kind,
name=wk.name,
description=wk.description,
config_schema=wk.config_schema,
default_config=wk.default_config,
refresh_interval_ms=wk.refresh_interval_ms,
)
return info
def _validate_widget_input(body: WidgetInstanceInput) -> None:
"""Validate widget_type/addon_id match and config schema."""
info = _registry_for_type(body.widget_type)
expected_addon = info["addon_id"]
if body.addon_id != expected_addon:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"Widget type '{body.widget_type}' belongs to addon "
f"'{expected_addon}', not '{body.addon_id}'"
),
)
try:
validate_config(body.widget_type, body.config)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.get("/sources")
def list_sources() -> list[str]:
"""Return all registered widget source types."""
return list_source_types()
@router.get("/types")
def list_types() -> list[dict[str, Any]]:
"""Return metadata for all registered widget types."""
return [info.model_dump() for info in list_widget_types()]
for wk in BUILTIN_WIDGET_KINDS.values()
]
@router.get("/instances")
@@ -85,7 +115,7 @@ def create_instance(
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Create a new widget instance."""
_validate_widget_input(body)
_validate_widget_input(body, store)
widget = store.upsert_widget(body.model_dump())
return WidgetInstance(**widget).model_dump()
@@ -105,7 +135,7 @@ def update_instance(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in path does not match ID in body",
)
_validate_widget_input(body)
_validate_widget_input(body, store)
widget = store.upsert_widget(body.model_dump(), widget_id)
return WidgetInstance(**widget).model_dump()
@@ -133,31 +163,44 @@ async def fetch_data(
if not widget:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
widget_type = widget["widget_type"]
info = get_widget_info(widget_type)
if info is None:
return WidgetDataResponse(
widget_id=widget_id,
widget_type=widget_type,
data=None,
error=f"Unknown widget type: {widget_type}",
fetched_at=int(time.time()),
).model_dump()
service_id = widget.get("service_id")
widget_kind = widget.get("widget_kind") or ""
adapter = get_source_adapter(info.source_type)
if adapter is None:
# Defensive: registry should prevent this, but return a safe error.
return WidgetDataResponse(
widget_id=widget_id,
widget_type=widget_type,
data=None,
error=f"No adapter registered for source type: {info.source_type}",
fetched_at=int(time.time()),
).model_dump()
service: Any = None
if service_id:
service_row = store.get_service(service_id)
if not service_row:
return WidgetDataResponse(
widget_id=widget_id,
error=f"Service {service_id} not found",
fetched_at=int(time.time()),
).model_dump()
if not service_row.get("enabled", True):
return WidgetDataResponse(
widget_id=widget_id,
error="Service is disabled",
fetched_at=int(time.time()),
).model_dump()
adapter = get_service_adapter(service_row["service_type"])
if adapter is None:
return WidgetDataResponse(
widget_id=widget_id,
error=f"No adapter for service type {service_row['service_type']}",
fetched_at=int(time.time()),
).model_dump()
service = build_service_record(store, service_row)
else:
adapter = get_builtin_adapter(widget_kind)
if adapter is None:
return WidgetDataResponse(
widget_id=widget_id,
error=f"Unknown built-in widget kind: {widget_kind}",
fetched_at=int(time.time()),
).model_dump()
try:
data = await adapter.fetch(widget["config"])
except Exception as exc:
data = await adapter.fetch(service, widget_kind, widget.get("config") or {})
except Exception as exc: # pragma: no cover - defensive
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -166,7 +209,6 @@ async def fetch_data(
return WidgetDataResponse(
widget_id=widget_id,
widget_type=widget_type,
data=data if "error" not in data else None,
error=data.get("error"),
fetched_at=int(time.time()),
@@ -180,6 +180,11 @@ class SettingsStore:
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)")
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
if "service_id" not in widget_cols:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
if "widget_kind" not in widget_cols:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
conn.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY,
@@ -453,39 +458,13 @@ class SettingsStore:
)
def _seed_dashboard_widgets(self) -> None:
"""Seed default dashboard widgets only when the table is empty."""
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
"""Default widget seeding was removed.
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone()
if row and int(row[0]) > 0:
return
defaults = [
{
"id": "jellyfin-activity-default",
"addon_id": "core",
"widget_type": "jellyfin",
"title": "Jellyfin activity",
"config": {"machine_id": ""},
"enabled": True,
"sort_order": 0,
},
{
"id": "backups-summary-default",
"addon_id": "backups",
"widget_type": "backups",
"title": "Backups",
"config": {},
"enabled": True,
"sort_order": 1,
},
]
for widget in defaults:
info = WIDGET_REGISTRY.get(widget["widget_type"])
if not info or info["addon_id"] != widget["addon_id"]:
continue
self.upsert_widget(widget)
Widgets are now service-bound (or built-in). A fresh install starts with
no widgets; the user configures services and adds widgets from the UI.
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
"""
return None
def ensure_defaults(self) -> None:
self.init_schema()
@@ -493,7 +472,6 @@ class SettingsStore:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0:
self._seed_local_machine()
self._seed_dashboard_widgets()
def list_machines(self) -> list[dict[str, Any]]:
self.init_schema()
@@ -1404,10 +1382,13 @@ class SettingsStore:
# ------------------------------------------------------------------
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
keys = row.keys()
return {
"id": row["id"],
"addon_id": row["addon_id"],
"widget_type": row["widget_type"],
"service_id": row["service_id"] if "service_id" in keys else None,
"widget_kind": row["widget_kind"] if "widget_kind" in keys else None,
"title": row["title"],
"config": json.loads(row["config_json"] or "{}"),
"enabled": bool(row["enabled"]),
@@ -1423,8 +1404,8 @@ class SettingsStore:
) -> dict[str, Any]:
current = self.get_widget(widget_id) if widget_id else None
widget_id = str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
addon_id = str(payload.get("addon_id") or (current or {}).get("addon_id", "")).strip()
widget_type = str(payload.get("widget_type") or (current or {}).get("widget_type", "")).strip()
service_id = str(payload.get("service_id") or (current or {}).get("service_id") or "").strip() or None
widget_kind = str(payload.get("widget_kind") or (current or {}).get("widget_kind", "")).strip()
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
config = payload.get("config", (current or {}).get("config", {}))
if not isinstance(config, dict):
@@ -1433,10 +1414,14 @@ class SettingsStore:
_validate_config_keys(config)
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
# Legacy label kept for diagnostics; new code uses service_id + widget_kind.
widget_type = f"{service_id}:{widget_kind}" if widget_kind else ""
return {
"id": widget_id,
"addon_id": addon_id,
"addon_id": "",
"widget_type": widget_type,
"service_id": service_id,
"widget_kind": widget_kind,
"title": title,
"config": config,
"enabled": enabled,
@@ -1470,13 +1455,15 @@ class SettingsStore:
conn.execute(
"""
INSERT INTO dashboard_widgets (
id, addon_id, widget_type, title, config_json, enabled,
sort_order, created_at, updated_at
id, addon_id, widget_type, service_id, widget_kind, title,
config_json, enabled, sort_order, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
addon_id = excluded.addon_id,
widget_type = excluded.widget_type,
service_id = excluded.service_id,
widget_kind = excluded.widget_kind,
title = excluded.title,
config_json = excluded.config_json,
enabled = excluded.enabled,
@@ -1487,6 +1474,8 @@ class SettingsStore:
widget["id"],
widget["addon_id"],
widget["widget_type"],
widget["service_id"],
widget["widget_kind"],
widget["title"],
json.dumps(widget["config"]),
1 if widget["enabled"] else 0,
@@ -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.
Each adapter implements a uniform async interface and translates widget
configuration into data for the dashboard. Adapters reuse existing clients,
machine registries, and environment settings; they never accept arbitrary
commands or store credentials.
Adapters translate a widget instance into dashboard data. Service-bound widgets
are resolved against a :class:`ServiceRecord` (config + decrypted secrets); the
built-in widgets (backups, static) take ``service=None``.
Adapters never accept arbitrary commands and never store credentials — secrets
are decrypted in memory only for the duration of a fetch.
"""
from __future__ import annotations
@@ -11,94 +13,103 @@ from __future__ import annotations
import asyncio
import logging
import shlex
import time
from dataclasses import dataclass, field
from typing import Any, Protocol
import requests
from starlette.requests import Request
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_jellyfin_client
from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows,
build_backup_dashboard_summary,
)
from media_library_viewer_api.routers.tasks import _client_for_machine, _resolve_machine_for_task
from media_library_viewer_api.services.settings_store import get_settings_store
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
logger = logging.getLogger(__name__)
def _request_with_machine_id(machine_id: str | None = None) -> Request:
"""Build a minimal Starlette Request carrying a machine_id query param."""
query = f"machine_id={machine_id}".encode() if machine_id else b""
return Request({"type": "http", "query_string": query})
@dataclass
class ServiceRecord:
"""Runtime view of a service instance with decrypted secrets."""
id: str
service_type: str
name: str
config: dict[str, Any] = field(default_factory=dict)
secrets: dict[str, str] = field(default_factory=dict)
enabled: bool = True
def build_service_record(store: SettingsStore, service_row: dict[str, Any]) -> ServiceRecord:
"""Build a :class:`ServiceRecord`, decrypting secrets in memory."""
from media_library_viewer_api.services.secrets import decrypt_secrets
return ServiceRecord(
id=service_row["id"],
service_type=service_row["service_type"],
name=service_row["name"],
config=service_row.get("config") or {},
secrets=decrypt_secrets(service_row.get("secrets") or {}),
enabled=bool(service_row.get("enabled", True)),
)
class WidgetSource(Protocol):
"""Protocol for widget source adapters."""
source_type: str
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ...
async def fetch(
self,
service: ServiceRecord | None,
widget_kind: str,
config: dict[str, Any],
) -> dict[str, Any]: ...
class JellyfinWidgetSource:
"""Fetch Jellyfin sessions and map them to activity rows."""
source_type = "jellyfin"
timeout = 10
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
try:
request = _request_with_machine_id(config.get("machine_id") or None)
client = await asyncio.wait_for(
asyncio.to_thread(get_jellyfin_client, request),
timeout=self.timeout,
)
sessions = await asyncio.wait_for(
asyncio.to_thread(client.sessions),
timeout=self.timeout,
)
rows = _map_sessions_to_activity_rows(sessions)
return {"sessions": rows}
except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"}
except Exception as exc:
logger.exception("jellyfin adapter failed")
return {"error": f"Jellyfin data fetch failed: {exc}"}
# ---------------------------------------------------------------------------
# Built-in (service-less) adapters
# ---------------------------------------------------------------------------
class BackupsWidgetSource:
"""Compute the backup dashboard summary."""
"""Compute the backup dashboard summary from internal tables."""
source_type = "backups"
timeout = 10
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
store = get_settings_store()
summary = build_backup_dashboard_summary(store)
return summary.model_dump()
except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"}
except Exception as exc:
logger.exception("backups adapter failed")
return {"error": f"Backup summary failed: {exc}"}
class StaticWidgetSource:
"""Return static text/markdown unchanged."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
return {"text": config.get("text", "")}
# ---------------------------------------------------------------------------
# Service-bound adapters
# ---------------------------------------------------------------------------
class GrafanaWidgetSource:
"""Build a Grafana deep-link (no embedding)."""
source_type = "grafana"
timeout = 5
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
settings = get_settings()
if service is None:
return {"error": "Grafana widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/")
dashboard_uid = config.get("dashboard_uid")
if not dashboard_uid:
return {"error": "dashboard_uid is required"}
url = f"{settings.grafana_url.rstrip('/')}/d/{dashboard_uid}"
url = f"{base_url}/d/{dashboard_uid}"
panel_id = config.get("panel_id")
if panel_id is not None:
url = f"{url}?viewPanel={panel_id}"
@@ -109,26 +120,26 @@ class GrafanaWidgetSource:
class PrometheusWidgetSource:
"""Run a PromQL instant query against Prometheus."""
"""Run a PromQL instant query against a Prometheus service."""
source_type = "prometheus"
timeout = 10
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
settings = get_settings()
if service is None:
return {"error": "Prometheus widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/")
timeout = int(service.config.get("timeout_seconds") or 10)
promql = config.get("promql")
if not promql:
return {"error": "promql is required"}
url = f"{settings.prometheus_url.rstrip('/')}/api/v1/query"
url = f"{base_url}/api/v1/query"
response = await asyncio.wait_for(
asyncio.to_thread(
requests.get,
url,
params={"query": promql},
timeout=self.timeout,
timeout=timeout,
),
timeout=self.timeout,
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
@@ -143,16 +154,44 @@ class PrometheusWidgetSource:
return {"error": f"Prometheus query failed: {exc}"}
class SshTaskWidgetSource:
"""Run a saved task from the registry and return its output."""
class JellyfinWidgetSource:
"""Fetch Jellyfin sessions and map them to activity rows."""
source_type = "ssh_task"
timeout = 30
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
timeout = 10
try:
if service is None:
return {"error": "Jellyfin widget is missing its service"}
base_url = str(service.config.get("base_url") or "")
api_key = str(service.secrets.get("api_key") or "")
timeout = int(service.config.get("timeout_seconds") or 10)
client = await asyncio.wait_for(
asyncio.to_thread(JellyfinClient, base_url, api_key, timeout),
timeout=timeout,
)
sessions = await asyncio.wait_for(
asyncio.to_thread(client.sessions),
timeout=timeout,
)
rows = _map_sessions_to_activity_rows(sessions)
return {"sessions": rows}
except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"}
except Exception as exc:
logger.exception("jellyfin adapter failed")
return {"error": f"Jellyfin data fetch failed: {exc}"}
class SshTaskWidgetSource:
"""Run a saved task on an SSH task runner instance and log the run."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
timeout = 30
try:
if service is None:
return {"error": "SSH task widget is missing its service"}
store = get_settings_store()
task_id = config.get("task_id")
task_id = config.get("task_id") or ""
if not task_id:
return {"error": "task_id is required"}
task = store.get_task(task_id)
@@ -161,11 +200,8 @@ class SshTaskWidgetSource:
if not task.get("enabled", True):
return {"error": "Task is disabled"}
machine = _resolve_machine_for_task(store, task, None)
if not machine:
return {"error": "No machine available for this task"}
client = _client_for_machine(store, machine)
client = _build_ssh_client(store, service)
timeout = int(service.config.get("timeout_seconds") or 30)
task_type = str(task.get("task_type") or "shell").lower()
command = str(task.get("content") or "")
if task_type == "python":
@@ -173,41 +209,112 @@ class SshTaskWidgetSource:
elif task_type != "shell":
return {"error": f"Unknown task type: {task_type}"}
start = time.perf_counter()
result = await asyncio.wait_for(
asyncio.to_thread(client.run, command, timeout=self.timeout),
timeout=self.timeout,
asyncio.to_thread(client.run, command, timeout),
timeout=timeout,
)
return {
"exit_status": result.exit_status,
"stdout": result.stdout or "",
"stderr": result.stderr or "",
}
duration_ms = int((time.perf_counter() - start) * 1000)
stdout = result.stdout or ""
stderr = result.stderr or ""
store.record_service_task_run(
{
"task_id": task_id,
"service_id": service.id,
"status": "success" if result.exit_status == 0 else "failure",
"exit_status": result.exit_status,
"duration_ms": duration_ms,
"stdout_tail": stdout,
"stderr_tail": stderr,
"error": "" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
}
)
return {"exit_status": result.exit_status, "stdout": stdout, "stderr": stderr}
except asyncio.TimeoutError:
_record_timeout(service, config, timeout)
return {"error": "Widget data fetch timed out"}
except Exception as exc:
logger.exception("ssh_task adapter failed")
store = get_settings_store()
store.record_service_task_run(
{
"task_id": str(config.get("task_id") or ""),
"service_id": service.id if service else "",
"status": "error",
"duration_ms": 0,
"error": str(exc)[:1000],
}
)
return {"error": f"SSH task failed: {exc}"}
class StaticWidgetSource:
"""Return static text/markdown unchanged."""
source_type = "static"
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
return {"text": config.get("text", "")}
def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) -> None:
try:
store = get_settings_store()
store.record_service_task_run(
{
"task_id": str(config.get("task_id") or ""),
"service_id": service.id if service else "",
"status": "timeout",
"duration_ms": timeout * 1000,
"error": f"Task timed out after {timeout}s",
}
)
except Exception: # pragma: no cover - logging best-effort
logger.exception("failed to record ssh task timeout")
SOURCE_REGISTRY: dict[str, WidgetSource] = {
"jellyfin": JellyfinWidgetSource(),
"backups": BackupsWidgetSource(),
def _build_ssh_client(store: SettingsStore, service: ServiceRecord) -> RemoteSSHClient:
"""Build an SSH client from an ssh_tasks service instance + referenced key."""
config = service.config
host = str(config.get("host") or "").strip()
username = str(config.get("username") or "").strip()
if not host or not username:
raise ValueError("SSH task service is missing host or username")
settings = get_settings()
private_key = ""
key_passphrase = ""
ssh_key_id = str(config.get("ssh_key_id") or "").strip()
if ssh_key_id:
ssh_key = store.get_ssh_key(ssh_key_id)
if ssh_key:
private_key = str(ssh_key.get("private_key") or "")
key_passphrase = str(ssh_key.get("passphrase") or "")
# Service-level passphrase secret takes precedence.
key_passphrase = str(service.secrets.get("passphrase") or "") or key_passphrase
return RemoteSSHClient(
host=host,
username=username,
port=int(config.get("port") or 22),
private_key=private_key or None,
private_key_passphrase=key_passphrase or None,
known_hosts_path=str(settings.ssh_known_hosts_file),
timeout=int(config.get("timeout_seconds") or 30),
)
# ---------------------------------------------------------------------------
# Registries
# ---------------------------------------------------------------------------
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
"grafana": GrafanaWidgetSource(),
"prometheus": PrometheusWidgetSource(),
"ssh_task": SshTaskWidgetSource(),
"jellyfin": JellyfinWidgetSource(),
"ssh_tasks": SshTaskWidgetSource(),
}
BUILTIN_ADAPTERS: dict[str, WidgetSource] = {
"backups": BackupsWidgetSource(),
"static": StaticWidgetSource(),
}
def get_source_adapter(source_type: str) -> WidgetSource | None:
"""Return the adapter for a source type, or None if unknown."""
return SOURCE_REGISTRY.get(source_type)
def get_service_adapter(service_type: str) -> WidgetSource | None:
return SERVICE_ADAPTERS.get(service_type)
def get_builtin_adapter(kind: str) -> WidgetSource | None:
return BUILTIN_ADAPTERS.get(kind)
+256 -344
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 unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.main import app
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import (
SOURCE_REGISTRY,
BackupsWidgetSource,
GrafanaWidgetSource,
SshTaskWidgetSource,
ServiceRecord,
StaticWidgetSource,
)
TEST_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch):
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
yield
@pytest.fixture
def client(tmp_path):
@@ -30,446 +40,348 @@ def client(tmp_path):
app.dependency_overrides.clear()
def test_widget_sources(client):
response = client.get("/api/widgets/sources")
def _make_grafana_service(client, name="Production Grafana", **config_overrides):
config = {"base_url": "https://grafana.example.com"}
config.update(config_overrides)
return client.post(
"/api/services/instances",
json={"service_type": "grafana", "name": name, "config": config, "enabled": True},
).json()
# ---------------------------------------------------------------------------
# Built-in kinds + built-in widget CRUD
# ---------------------------------------------------------------------------
def test_list_builtin_kinds(client):
response = client.get("/api/widgets/builtin")
assert response.status_code == 200
assert set(response.json()) == {
"jellyfin",
"backups",
"grafana",
"prometheus",
"ssh_task",
"static",
}
kinds = {item["kind"] for item in response.json()}
assert kinds == {"backups", "static"}
def test_widget_types(client):
response = client.get("/api/widgets/types")
assert response.status_code == 200
types = {item["widget_type"] for item in response.json()}
assert types == {
"jellyfin",
"backups",
"grafana-link",
"prometheus-metric",
"ssh-task",
"static",
}
def test_create_and_read_widget(client):
def test_create_and_read_static_widget(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"widget_kind": "static",
"title": "Note",
"config": {"text": "hello"},
"enabled": True,
"sort_order": 5,
},
)
assert response.status_code == 201
widget = response.json()
assert widget["title"] == "Note"
assert widget["config"] == {"text": "hello"}
assert widget["enabled"] is True
assert widget["sort_order"] == 5
widget_id = widget["id"]
created = response.json()
assert created["widget_kind"] == "static"
assert created["service_id"] is None
assert created["config"]["text"] == "hello"
response = client.get("/api/widgets/instances")
assert response.status_code == 200
assert any(w["id"] == widget_id for w in response.json())
listed = client.get("/api/widgets/instances").json()
assert len(listed) == 1
assert listed[0]["id"] == created["id"]
def test_update_widget(client):
def test_create_backups_widget(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "backups", "title": "Backups", "config": {}},
)
assert response.status_code == 201
def test_unknown_builtin_kind_rejected(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "bogus", "title": "x", "config": {}},
)
assert response.status_code == 422
def test_credential_key_in_config_rejected(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "x", "config": {"api_key": "leak"}},
)
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Service-bound widget CRUD
# ---------------------------------------------------------------------------
def test_create_service_bound_widget(client):
service = _make_grafana_service(client)
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Note",
"config": {"text": "hello"},
"service_id": service["id"],
"widget_kind": "link",
"title": "Dashboard",
"config": {"dashboard_uid": "overview"},
},
)
widget_id = response.json()["id"]
response = client.put(
f"/api/widgets/instances/{widget_id}",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Updated",
"config": {"text": "world"},
"enabled": False,
"sort_order": 10,
},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Updated"
assert data["config"] == {"text": "world"}
assert data["enabled"] is False
assert data["sort_order"] == 10
assert response.status_code == 201
created = response.json()
assert created["service_id"] == service["id"]
assert created["widget_kind"] == "link"
def test_delete_widget(client):
def test_service_bound_widget_unknown_kind_rejected(client):
service = _make_grafana_service(client)
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "To delete",
"config": {"text": "bye"},
},
)
widget_id = response.json()["id"]
response = client.delete(f"/api/widgets/instances/{widget_id}")
assert response.status_code == 200
response = client.get("/api/widgets/instances")
assert not any(w["id"] == widget_id for w in response.json())
def test_unknown_widget_type_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "unknown",
"title": "Bad",
"service_id": service["id"],
"widget_kind": "metric",
"title": "x",
"config": {},
},
)
assert response.status_code == 422
def test_addon_id_mismatch_rejected(client):
def test_service_bound_widget_service_not_found_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "grafana",
"widget_type": "static",
"title": "Bad",
"config": {"text": "x"},
"service_id": "missing",
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
)
assert response.status_code == 422
def test_credential_key_rejected(client):
def test_service_bound_widget_invalid_config_rejected(client):
service = _make_grafana_service(client)
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Bad",
"config": {"api_key": "secret123"},
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": ""}, # empty still validates; use bad type
},
)
# Empty string passes Pydantic; force a real failure with a bad type.
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": 123},
},
)
assert response.status_code == 422
def test_update_nonexistent_widget(client):
def test_update_and_delete_widget(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "a"}},
).json()
updated = client.put(
f"/api/widgets/instances/{created['id']}",
json={"widget_kind": "static", "title": "Note2", "config": {"text": "b"}},
).json()
assert updated["title"] == "Note2"
assert client.delete(f"/api/widgets/instances/{created['id']}").status_code == 200
assert client.get("/api/widgets/instances").json() == []
def test_update_nonexistent_returns_404(client):
response = client.put(
"/api/widgets/instances/does-not-exist",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Bad",
"config": {"text": "x"},
},
"/api/widgets/instances/missing",
json={"widget_kind": "static", "title": "x", "config": {}},
)
assert response.status_code == 404
def test_delete_nonexistent_widget(client):
response = client.delete("/api/widgets/instances/does-not-exist")
assert response.status_code == 404
def test_default_widgets_seeded(client):
response = client.get("/api/widgets/instances")
assert response.status_code == 200
widgets = response.json()
types = [w["widget_type"] for w in widgets]
assert "jellyfin" in types
assert "backups" in types
def test_no_reseed_when_widgets_exist(tmp_path):
db_path = tmp_path / "settings.sqlite"
store = SettingsStore(db_path)
store.ensure_defaults()
widgets = store.list_widgets()
assert len(widgets) == 2
store.delete_widget(widgets[0]["id"])
store.ensure_defaults()
remaining = store.list_widgets()
assert len(remaining) == 1
def test_update_id_mismatch_returns_400(client):
response = client.post(
created = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Note",
"config": {"text": "hello"},
},
)
widget_id = response.json()["id"]
json={"widget_kind": "static", "title": "x", "config": {}},
).json()
response = client.put(
f"/api/widgets/instances/{widget_id}",
json={
"id": "different-id",
"addon_id": "core",
"widget_type": "static",
"title": "Updated",
"config": {"text": "world"},
},
f"/api/widgets/instances/{created['id']}",
json={"id": "other", "widget_kind": "static", "title": "x", "config": {}},
)
assert response.status_code == 400
def test_empty_title_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "",
"config": {"text": "hello"},
},
)
assert response.status_code == 422
def test_config_type_error_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "grafana",
"widget_type": "grafana-link",
"title": "Grafana",
"config": {"panel_id": "not-an-integer"},
},
)
assert response.status_code == 422
def test_list_instances_respects_sort_order(client):
response = client.get("/api/widgets/instances")
assert response.status_code == 200
widgets = response.json()
orders = [w["sort_order"] for w in widgets]
assert orders == sorted(orders)
def test_enabled_round_trip(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Toggle",
"config": {"text": "x"},
"enabled": False,
},
)
widget_id = response.json()["id"]
response = client.put(
f"/api/widgets/instances/{widget_id}",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Toggle",
"config": {"text": "x"},
"enabled": True,
},
)
assert response.status_code == 200
assert response.json()["enabled"] is True
# ---------------------------------------------------------------------------
# Data endpoint
# ---------------------------------------------------------------------------
def test_fetch_static_widget_data(client):
response = client.post(
created = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Note",
"config": {"text": "hello world"},
},
)
widget_id = response.json()["id"]
response = client.get(f"/api/widgets/instances/{widget_id}/data")
json={"widget_kind": "static", "title": "Note", "config": {"text": "hello"}},
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
data = response.json()
assert data["widget_id"] == widget_id
assert data["widget_type"] == "static"
assert data["data"] == {"text": "hello world"}
assert data["error"] is None
assert isinstance(data["fetched_at"], int)
body = response.json()
assert body["data"]["text"] == "hello"
assert body["error"] is None
def test_fetch_grafana_widget_data(client):
response = client.post(
def test_fetch_backups_widget_data(client):
created = client.post(
"/api/widgets/instances",
json={
"addon_id": "grafana",
"widget_type": "grafana-link",
"title": "Grafana",
"config": {"dashboard_uid": "overview", "panel_id": 3},
},
)
widget_id = response.json()["id"]
response = client.get(f"/api/widgets/instances/{widget_id}/data")
json={"widget_kind": "backups", "title": "Backups", "config": {}},
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
data = response.json()
assert data["widget_type"] == "grafana-link"
assert data["data"]["url"] == "http://grafana:3000/d/overview?viewPanel=3"
assert "total_jobs" in response.json()["data"]
def test_fetch_prometheus_widget_data(client):
response = client.post(
def test_fetch_grafana_link_widget_data(client):
service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances",
json={
"addon_id": "prometheus",
"widget_type": "prometheus-metric",
"title": "CPU",
"config": {"promql": '100 - avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100'},
"service_id": service["id"],
"widget_kind": "link",
"title": "Dashboard",
"config": {"dashboard_uid": "overview", "panel_id": 2},
},
)
widget_id = response.json()["id"]
fake_payload = {"data": {"resultType": "scalar", "result": [1718900000, "42.5"]}}
with patch("media_library_viewer_api.widgets.sources.requests.get") as mock_get:
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = fake_payload
mock_get.return_value = mock_response
response = client.get(f"/api/widgets/instances/{widget_id}/data")
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
data = response.json()
assert data["widget_type"] == "prometheus-metric"
assert data["data"]["result"]["resultType"] == "scalar"
assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
def test_fetch_jellyfin_widget_data_error(client):
response = client.post(
def test_fetch_widget_service_not_found(client):
service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "jellyfin",
"title": "Activity",
"config": {"machine_id": ""},
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
).json()
# Deleting the service cascade-deletes its widgets, so the widget is gone.
client.delete(f"/api/services/instances/{service['id']}")
assert client.get("/api/widgets/instances").json() == []
assert client.get(f"/api/widgets/instances/{created['id']}/data").status_code == 404
def test_fetch_widget_service_disabled(client):
service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
).json()
client.put(
f"/api/services/instances/{service['id']}",
json={
"service_type": "grafana",
"name": service["name"],
"config": {"base_url": "https://grafana.example.com"},
"enabled": False,
},
)
widget_id = response.json()["id"]
response = client.get(f"/api/widgets/instances/{widget_id}/data")
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
data = response.json()
assert data["widget_type"] == "jellyfin"
assert data["data"] is None
assert data["error"] is not None
assert "Jellyfin" in data["error"] or "machine" in data["error"].lower()
assert "disabled" in response.json()["error"]
def test_fetch_widget_data_not_found(client):
response = client.get("/api/widgets/instances/does-not-exist/data")
assert response.status_code == 404
def test_fetch_widget_not_found(client):
assert client.get("/api/widgets/instances/missing/data").status_code == 404
def test_fetch_widget_data_unhandled_exception_returns_500(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Note",
"config": {"text": "x"},
},
)
widget_id = response.json()["id"]
# ---------------------------------------------------------------------------
# Adapter unit tests
# ---------------------------------------------------------------------------
class _ExplodingAdapter:
source_type = "static"
async def fetch(self, config):
raise RuntimeError("boom")
@pytest.mark.asyncio
async def test_grafana_adapter_builds_url():
adapter = GrafanaWidgetSource()
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov"})
assert result["url"] == "http://g:3000/d/ov"
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov", "panel_id": 4})
assert result["url"] == "http://g:3000/d/ov?viewPanel=4"
with patch("media_library_viewer_api.routers.widgets.get_source_adapter", return_value=_ExplodingAdapter()):
response = client.get(f"/api/widgets/instances/{widget_id}/data")
assert response.status_code == 500
@pytest.mark.asyncio
async def test_grafana_adapter_missing_service():
adapter = GrafanaWidgetSource()
result = await adapter.fetch(None, "link", {"dashboard_uid": "ov"})
assert "error" in result
@pytest.mark.asyncio
async def test_static_adapter():
adapter = StaticWidgetSource()
result = await adapter.fetch({"text": "hello"})
assert result == {"text": "hello"}
result = await adapter.fetch(None, "static", {"text": "hi"})
assert result == {"text": "hi"}
@pytest.mark.asyncio
async def test_grafana_adapter():
adapter = GrafanaWidgetSource()
result = await adapter.fetch({"dashboard_uid": "overview", "panel_id": 2})
assert result["url"] == "http://grafana:3000/d/overview?viewPanel=2"
result = await adapter.fetch({"dashboard_uid": "overview"})
assert result["url"] == "http://grafana:3000/d/overview"
async def test_backups_adapter(client):
store = app.dependency_overrides[get_settings_store]()
with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store):
adapter = BackupsWidgetSource()
result = await adapter.fetch(None, "backups", {})
assert "total_jobs" in result
@pytest.mark.asyncio
async def test_ssh_task_adapter_timeout(tmp_path):
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
# Create a local machine and a simple shell task.
machine = store.list_machines()[0]
task = store.upsert_task(
{
"name": "slow-task",
"task_type": "shell",
"content": "echo hello",
"enabled": True,
"default_machine_id": machine["id"],
}
)
async def test_ssh_task_adapter_missing_service():
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
adapter = SshTaskWidgetSource()
with patch(
"media_library_viewer_api.widgets.sources.get_settings_store",
return_value=store,
), patch(
"media_library_viewer_api.widgets.sources.asyncio.wait_for",
side_effect=asyncio.TimeoutError,
):
result = await adapter.fetch({"task_id": task["id"]})
result = await adapter.fetch(None, "task_output", {"task_id": "t1"})
assert "error" in result
assert "timed out" in result["error"].lower()
def test_source_registry_closed():
assert set(SOURCE_REGISTRY.keys()) == {
"jellyfin",
"backups",
"grafana",
"prometheus",
"ssh_task",
"static",
}
@pytest.mark.asyncio
async def test_ssh_task_adapter_records_history_on_run(client):
store = app.dependency_overrides[get_settings_store]()
# Save a task and an ssh_tasks service instance.
task = store.upsert_task(
{
"name": "echo",
"task_type": "shell",
"content": "echo hi",
"enabled": True,
"default_machine_id": "",
}
)
service = store.upsert_service(
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
)
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
fake_client = SimpleNamespace(run=lambda *a, **k: fake_result)
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
adapter = SshTaskWidgetSource()
service_record = ServiceRecord(
id=service["id"], service_type="ssh_tasks", name="box", config={"host": "h", "username": "u"}
)
with (
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
patch("media_library_viewer_api.widgets.sources._build_ssh_client", return_value=fake_client),
):
result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]})
assert result["exit_status"] == 0
runs = store.list_service_task_runs(service_id=service["id"])
assert len(runs) == 1
assert runs[0]["status"] == "success"
+9
View File
@@ -23,6 +23,7 @@ import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage";
import { AddonPage } from "./pages/AddonPage";
import { ServicePage } from "./pages/ServicePage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
@@ -451,6 +452,10 @@ function AppInner() {
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Route>
</Routes>
</BrowserRouter>
@@ -483,6 +488,10 @@ function AppInner() {
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Route>
</Routes>
</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 {
BuiltinWidgetKindInfo,
WidgetDataResponse,
WidgetInstance,
WidgetInstanceInput,
WidgetTypeInfo,
} from "../types";
const API_BASE = "/api";
export async function fetchWidgetSources(): Promise<string[]> {
const res = await fetch(`${API_BASE}/widgets/sources`);
if (!res.ok) throw new Error("Failed to fetch widget sources");
return res.json();
}
export async function fetchWidgetTypes(): Promise<WidgetTypeInfo[]> {
const res = await fetch(`${API_BASE}/widgets/types`);
if (!res.ok) throw new Error("Failed to fetch widget types");
export async function fetchBuiltinWidgetKinds(): Promise<
BuiltinWidgetKindInfo[]
> {
const res = await fetch(`${API_BASE}/widgets/builtin`);
if (!res.ok) throw new Error("Failed to fetch built-in widget kinds");
return res.json();
}
+181 -167
View File
@@ -23,36 +23,29 @@ import {
useDeleteWidgetInstance,
useSaveWidgetInstance,
useWidgetInstances,
useWidgetTypes,
} from "../hooks/useWidgets";
import { useMonitoringSettings, useTasks } from "../hooks/useSettings";
import type {
MonitoringMachine,
SavedTask,
WidgetInstance,
WidgetInstanceInput,
} from "../types";
import { useServiceInstances } from "../hooks/useServices";
import { useTasks } from "../hooks/useSettings";
import type { WidgetInstance, WidgetInstanceInput } from "../types";
import {
getWidgetDefinition,
listWidgetTypes,
type WidgetDefinition,
} from "../widgets/registry";
BUILTIN_WIDGETS,
SERVICE_REGISTRY,
type ServiceWidgetBinding,
} from "../integrations/registry";
interface Props {
open: boolean;
onClose: () => void;
}
function emptyDraft(widgetType: string): WidgetInstanceInput {
const def = getWidgetDefinition(widgetType);
return {
addon_id: def?.addonId ?? "",
widget_type: widgetType,
title: def?.name ?? "",
config: { ...(def?.defaultConfig ?? {}) },
enabled: true,
sort_order: 0,
};
interface Draft {
id?: string;
serviceId: string | null;
widgetKind: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sortOrder: number;
}
function Field({
@@ -77,126 +70,88 @@ function Field({
);
}
function WidgetConfigFields({
definition,
function bindingLabel(serviceId: string | null, widgetKind: string): string {
if (serviceId === null)
return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind;
return widgetKind;
}
function WidgetConfigEditor({
binding,
isTaskOutput,
config,
onChange,
machines,
tasks,
}: {
definition: WidgetDefinition;
binding: ServiceWidgetBinding | undefined;
isTaskOutput: boolean;
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
machines: MonitoringMachine[];
tasks: SavedTask[];
tasks: { id: string; name: string; enabled: boolean }[];
}) {
// SSH task output gets a dedicated task picker; everything else gets a
// generic text field per top-level schema property.
if (isTaskOutput) {
return (
<Field label="Saved task" htmlFor="widget-task-id">
<Select
value={String(config.task_id ?? "")}
onValueChange={(v) => onChange({ ...config, task_id: v })}
>
<SelectTrigger id="widget-task-id">
<SelectValue placeholder="Select a task" />
</SelectTrigger>
<SelectContent>
{tasks
.filter((t) => t.enabled)
.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
const properties = binding
? Object.entries(
(
binding.configSchema as
| { properties?: Record<string, unknown> }
| undefined
)?.properties ?? {},
)
: [];
if (properties.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{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
value={String(value)}
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
>
<SelectTrigger id={field.key}>
<SelectValue />
</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>
<SelectContent>
{tasks
.filter((t) => t.enabled)
.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
if (field.type === "number") {
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Input
id={field.key}
type="number"
value={String(value)}
onChange={(e) =>
onChange({
...config,
[field.key]:
e.target.value === ""
? undefined
: Number(e.target.value),
})
}
/>
</Field>
);
}
{properties.map(([key, schema]) => {
const isNumber =
(schema as { type?: string }).type === "integer" ||
(schema as { type?: string }).type === "number";
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
key={key}
label={key}
htmlFor={`widget-cfg-${key}`}
helper={(schema as { description?: string }).description}
>
<Input
id={field.key}
value={String(value)}
id={`widget-cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(config[key] ?? "")}
onChange={(e) =>
onChange({
...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) {
const { data: instances = [] } = useWidgetInstances();
const { data: types = [] } = useWidgetTypes();
const { data: machines = [] } = useMonitoringSettings();
const { data: services = [] } = useServiceInstances();
const { data: tasks = [] } = useTasks();
const saveWidget = useSaveWidgetInstance();
const deleteWidget = useDeleteWidgetInstance();
const [draft, setDraft] = useState<WidgetInstanceInput | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const registryDefinitions = useMemo(() => listWidgetTypes(), []);
const [draft, setDraft] = useState<Draft | null>(null);
const sortedInstances = useMemo(
() =>
@@ -228,39 +179,72 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
[instances],
);
function startAdd(widgetType: string) {
setDraft(emptyDraft(widgetType));
setEditingId(null);
function startAddBuiltIn(kind: string) {
const binding = BUILTIN_WIDGETS[kind];
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) {
setDraft({
id: instance.id,
addon_id: instance.addon_id,
widget_type: instance.widget_type,
serviceId: instance.service_id,
widgetKind: instance.widget_kind,
title: instance.title,
config: instance.config,
enabled: instance.enabled,
sort_order: instance.sort_order,
sortOrder: instance.sort_order,
});
setEditingId(instance.id);
}
function reset() {
setDraft(null);
setEditingId(null);
}
async function saveDraft() {
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();
}
async function toggleEnabled(instance: WidgetInstance) {
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,
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 (
<Dialog open={open} onOpenChange={handleClose}>
@@ -294,18 +288,15 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<DialogHeader>
<DialogTitle>
{draft
? editingId
? draft.id
? "Edit widget"
: "Add widget"
: "Dashboard widgets"}
</DialogTitle>
</DialogHeader>
{draft && definition ? (
{draft ? (
<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">
<Field label="Title" htmlFor="widget-title">
<Input
@@ -320,11 +311,11 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<Input
id="widget-sort-order"
type="number"
value={String(draft.sort_order)}
value={String(draft.sortOrder)}
onChange={(e) =>
setDraft({
...draft,
sort_order:
sortOrder:
e.target.value === "" ? 0 : Number(e.target.value),
})
}
@@ -341,11 +332,11 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
/>
<Label htmlFor="widget-enabled">Enabled</Label>
</div>
<WidgetConfigFields
definition={definition}
<WidgetConfigEditor
binding={draftBinding}
isTaskOutput={!!isTaskOutput}
config={draft.config}
onChange={(config) => setDraft({ ...draft, config })}
machines={machines}
tasks={tasks}
/>
<div className="flex justify-end gap-2">
@@ -368,7 +359,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
) : (
<div className="flex flex-col gap-2">
{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 (
<div
key={instance.id}
@@ -378,8 +371,16 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<div className="flex items-center gap-2">
<span className="font-medium">{instance.title}</span>
<Badge variant="outline">
{typeDef?.name ?? instance.widget_type}
{bindingLabel(
instance.service_id,
instance.widget_kind,
)}
</Badge>
{serviceName ? (
<span className="text-xs text-muted-foreground">
{serviceName}
</span>
) : null}
{!instance.enabled ? (
<Badge variant="secondary">disabled</Badge>
) : null}
@@ -435,27 +436,40 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Add widget</p>
<div className="flex flex-wrap gap-2">
{registryDefinitions.map((def) => (
{Object.values(BUILTIN_WIDGETS).map((b) => (
<Button
key={def.widgetType}
key={b.kind}
variant="outline"
size="sm"
onClick={() => startAdd(def.widgetType)}
onClick={() => startAddBuiltIn(b.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{def.name}
{b.name}
</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>
<p className="text-xs text-muted-foreground">
Configure services on their service pages to unlock more
widgets.
</p>
</div>
{types.length === 0 ? (
<Alert>
<AlertDescription>
Widget registry is empty. Backend may not be running.
</AlertDescription>
</Alert>
) : null}
</div>
)}
</DialogContent>
+19 -9
View File
@@ -1,5 +1,6 @@
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 { SectionCard } from "./SectionCard";
@@ -7,20 +8,29 @@ interface Props {
widget: WidgetInstance;
}
export function WidgetInstance({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
if (!def) {
export function WidgetInstanceCard({ widget }: Props) {
const { data: services = [] } = useServiceInstances();
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 (
<SectionCard title={widget.title}>
<Alert>
<AlertDescription>
Unknown widget type: {widget.widget_type}
</AlertDescription>
<AlertDescription>{label}</AlertDescription>
</Alert>
</SectionCard>
);
}
const Component = def.component;
return <Component widget={widget} />;
const Component = resolved.component;
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 {
createWidgetInstance,
deleteWidgetInstance,
fetchBuiltinWidgetKinds,
fetchWidgetData,
fetchWidgetInstances,
fetchWidgetSources,
fetchWidgetTypes,
updateWidgetInstance,
} from "../api/widgets";
import type { WidgetInstanceInput } from "../types";
@@ -49,16 +48,10 @@ export function useDeleteWidgetInstance() {
});
}
export function useWidgetSources() {
export function useBuiltinWidgetKinds() {
return useQuery({
queryKey: ["widgets", "sources"],
queryFn: fetchWidgetSources,
});
}
export function useWidgetTypes() {
return useQuery({
queryKey: ["widgets", "types"],
queryFn: fetchWidgetTypes,
queryKey: ["widgets", "builtin"],
queryFn: fetchBuiltinWidgetKinds,
staleTime: 5 * 60 * 1000,
});
}
+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 { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import { WidgetInstance } from "../components/WidgetInstance";
import { WidgetInstanceCard } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
function emptyShortcut(): DashboardShortcutInput {
@@ -418,7 +418,7 @@ export function Dashboard() {
</SectionCard>
{visibleWidgets.map((widget) => (
<WidgetInstance key={widget.id} widget={widget} />
<WidgetInstanceCard key={widget.id} widget={widget} />
))}
<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";
// Stub the composed widgets so the test exercises Dashboard's own behavior
// (shortcut CRUD) without rendering the session panel or the backup query.
vi.mock("../../components/NowPlaying", () => ({
NowPlaying: () => <div data-testid="now-playing-stub" />,
// (shortcut CRUD) without rendering widgets or their data queries.
vi.mock("../../components/WidgetInstance", () => ({
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
}));
vi.mock("../../components/BackupDashboardWidget", () => ({
default: () => <div data-testid="backup-widget-stub" />,
vi.mock("../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
}));
const navigate = vi.fn();
@@ -21,6 +21,9 @@ vi.mock("react-router-dom", () => ({
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }),
}));
vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: [] }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({});
const deleteShortcutMutate = vi.fn();
+58 -14
View File
@@ -446,8 +446,8 @@ export interface PrometheusTarget {
export interface WidgetInstance {
id: string;
addon_id: string;
widget_type: string;
service_id: string | null;
widget_kind: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
@@ -458,27 +458,71 @@ export interface WidgetInstance {
export interface WidgetInstanceInput {
id?: string | null;
addon_id: string;
widget_type: string;
service_id: string | null;
widget_kind: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sort_order: number;
}
export interface WidgetTypeInfo {
addon_id: string;
widget_type: string;
name: string;
description: string;
source_type: string;
config_schema: Record<string, unknown>;
}
export interface WidgetDataResponse {
widget_id: string;
widget_type: string;
data: Record<string, unknown> | null;
error: string | null;
fetched_at: number;
}
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 type { BackupDashboardSummary } from "../types/backups";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function BackupsWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
export function BackupsWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const summary = data?.data as BackupDashboardSummary | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<div className="flex flex-row flex-wrap gap-6">
<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 { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function GrafanaLinkWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
export function GrafanaLinkWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const url = data?.data?.url as string | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-10 w-48" />
) : data?.error ? (
+9 -8
View File
@@ -4,22 +4,23 @@ import { SessionActivityPanel } from "../components/SessionActivityPanel";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { NowPlayingSession, WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function JellyfinWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
export function JellyfinWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-3/4" />
@@ -3,10 +3,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
type PromQLResult = {
@@ -35,16 +36,16 @@ function formatPrometheusValue(result: PromQLResult | undefined): string {
return JSON.stringify(result, null, 2);
}
export function PrometheusMetricWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
export function PrometheusMetricWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const result = data?.data?.result as PromQLResult | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-10 w-32" />
) : data?.error ? (
+9 -8
View File
@@ -3,10 +3,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
type SshTaskResult = {
@@ -15,16 +16,16 @@ type SshTaskResult = {
stderr: string;
};
export function SshTaskWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
export function SshTaskWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const result = data?.data as SshTaskResult | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
+9 -5
View File
@@ -1,19 +1,23 @@
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function StaticWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
export function StaticWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data } = useWidgetData(widget.id, refreshIntervalMs);
const text = data?.data?.text as string | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
<SectionCard title={widget.title} description={description}>
{text ? (
<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 { SshTaskWidget } from "./SshTaskWidget";
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
**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
## Completed tasks (Slice 1)
## Slice 1 — Backend service foundation (MERGED, PR #7)
- [x] 1.1 Add encryption helper (`services/secrets.py`)
- [x] 1.2 Add integrations base classes (`integrations/base.py`)
- [x] 1.3 Add five service definitions + registry
- [x] 1.4 Add `services` + `service_task_runs` tables + store CRUD with cascade delete
- [x] 1.5 Add service Pydantic models + `/api/services*` router
- [x] 1.6 Validate `MANAGE_ENCRYPTION_KEY` on startup
- [x] 1.7 Add backend tests (`tests/test_services.py`)
- [x] 1.8 Verify (ruff + pytest green)
Fernet secrets, closed `integrations/` registry (Pydantic config + widget-config
for grafana/prometheus/jellyfin/nextcloud/ssh_tasks), `services` +
`service_task_runs` tables with cascade delete, `/api/services*` CRUD,
`MANAGE_ENCRYPTION_KEY` required at startup.
## 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.
- `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.
## Slice 3 — Frontend services runtime (this PR)
### Modified files
### Completed tasks
- `backend/src/media_library_viewer_api/services/settings_store.py``services` and
`service_task_runs` tables; service CRUD; cascade delete (defensive against the
not-yet-present `dashboard_widgets.service_id` column); task-run history helpers.
- `backend/src/media_library_viewer_api/main.py` — register `services_router`;
validate encryption key on startup.
- `backend/pyproject.toml` — declare `cryptography>=42.0` direct dependency.
- `docker-compose.yml`, `docker-compose.dev.yml`, `.env.example`, `README.md` — require
and document `MANAGE_ENCRYPTION_KEY`.
- [x] 3.1 Service + new widget TypeScript types (`ServiceInstance`,
`ServiceInstanceInput`, `ServiceTypeInfo`, `ServiceWidgetKindInfo`,
`SecretFieldInfo`, `BuiltinWidgetKindInfo`; widget gains `service_id` +
`widget_kind`).
- [x] 3.2 Services API + hooks (`api/services.ts`, `hooks/useServices.ts`).
Reconciled `api/widgets.ts` + `hooks/useWidgets.ts` to the new shape
(removed sources/types; added builtin kinds).
- [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
cd backend
.venv/bin/ruff check . # All checks passed
PYTHONPATH=src .venv/bin/python -m pytest # 225 passed
cd ../frontend
cd frontend
npm run lint # 0 errors
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
- Service-config and widget-config schemas are derived from **Pydantic models**
(`model_json_schema()`), matching the user's "proper pydantic config definitions"
request. The design's hand-written JSON schemas were replaced by model-derived ones.
- Service-table CRUD lives on `SettingsStore` (not a separate `service_store.py`) to
match how widgets/saved_tasks/ssh_keys are already handled there. This keeps a single
store owner for all tables.
- The cascade delete defensively checks for `dashboard_widgets.service_id` (added in
Slice 2) so Slice 1 stays green without the column.
- `WidgetConfigDialog` was rewritten in this slice (pulled forward from task
4.1) because the old dialog imported the deleted widget registry and would
not compile. The SSH task-output widget keeps a dedicated task picker; other
widget configs use a generic schema-driven field editor.
- Addon pages (`/addons/:addonId`) are kept compiling but superseded by service
pages; Slice 4 removes them and the now-unused machine Jellyfin/Jellyseerr
fields + `grafana_url`/`prometheus_url` env vars, and writes the changelog.
## Remaining work
- Slice 2: Backend widget rebind to services (add `service_id`/`widget_kind`, refactor
adapters to take a `ServiceRecord`, retire old widget registry, SSH run logging).
- Slice 3: Frontend services runtime (types, API, hooks, frontend registry, service
pages, route swap).
- Slice 4: Dashboard picker, settings rework, remove `grafana_url`/`prometheus_url`
env vars, stop default seeding, docs + changelog.
- Slice 4: remove addon pages + machine app fields, remove
`grafana_url`/`prometheus_url` from config + compose, docs + changelog
(breaking upgrade note).