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.
This commit is contained in:
Developer
2026-06-22 16:42:56 +00:00
parent 2452e2e1e4
commit 10fd4ead4a
9 changed files with 755 additions and 801 deletions
@@ -180,6 +180,11 @@ class SettingsStore:
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)")
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
if "service_id" not in widget_cols:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
if "widget_kind" not in widget_cols:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
conn.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY,
@@ -453,39 +458,13 @@ class SettingsStore:
)
def _seed_dashboard_widgets(self) -> None:
"""Seed default dashboard widgets only when the table is empty."""
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
"""Default widget seeding was removed.
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone()
if row and int(row[0]) > 0:
return
defaults = [
{
"id": "jellyfin-activity-default",
"addon_id": "core",
"widget_type": "jellyfin",
"title": "Jellyfin activity",
"config": {"machine_id": ""},
"enabled": True,
"sort_order": 0,
},
{
"id": "backups-summary-default",
"addon_id": "backups",
"widget_type": "backups",
"title": "Backups",
"config": {},
"enabled": True,
"sort_order": 1,
},
]
for widget in defaults:
info = WIDGET_REGISTRY.get(widget["widget_type"])
if not info or info["addon_id"] != widget["addon_id"]:
continue
self.upsert_widget(widget)
Widgets are now service-bound (or built-in). A fresh install starts with
no widgets; the user configures services and adds widgets from the UI.
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
"""
return None
def ensure_defaults(self) -> None:
self.init_schema()
@@ -493,7 +472,6 @@ class SettingsStore:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0:
self._seed_local_machine()
self._seed_dashboard_widgets()
def list_machines(self) -> list[dict[str, Any]]:
self.init_schema()
@@ -1404,10 +1382,13 @@ class SettingsStore:
# ------------------------------------------------------------------
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
keys = row.keys()
return {
"id": row["id"],
"addon_id": row["addon_id"],
"widget_type": row["widget_type"],
"service_id": row["service_id"] if "service_id" in keys else None,
"widget_kind": row["widget_kind"] if "widget_kind" in keys else None,
"title": row["title"],
"config": json.loads(row["config_json"] or "{}"),
"enabled": bool(row["enabled"]),
@@ -1423,8 +1404,11 @@ class SettingsStore:
) -> dict[str, Any]:
current = self.get_widget(widget_id) if widget_id else None
widget_id = str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
addon_id = str(payload.get("addon_id") or (current or {}).get("addon_id", "")).strip()
widget_type = str(payload.get("widget_type") or (current or {}).get("widget_type", "")).strip()
service_id = (
str(payload.get("service_id") or (current or {}).get("service_id") or "").strip()
or None
)
widget_kind = str(payload.get("widget_kind") or (current or {}).get("widget_kind", "")).strip()
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
config = payload.get("config", (current or {}).get("config", {}))
if not isinstance(config, dict):
@@ -1433,10 +1417,14 @@ class SettingsStore:
_validate_config_keys(config)
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
# Legacy label kept for diagnostics; new code uses service_id + widget_kind.
widget_type = f"{service_id}:{widget_kind}" if widget_kind else ""
return {
"id": widget_id,
"addon_id": addon_id,
"addon_id": "",
"widget_type": widget_type,
"service_id": service_id,
"widget_kind": widget_kind,
"title": title,
"config": config,
"enabled": enabled,
@@ -1470,13 +1458,15 @@ class SettingsStore:
conn.execute(
"""
INSERT INTO dashboard_widgets (
id, addon_id, widget_type, title, config_json, enabled,
sort_order, created_at, updated_at
id, addon_id, widget_type, service_id, widget_kind, title,
config_json, enabled, sort_order, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
addon_id = excluded.addon_id,
widget_type = excluded.widget_type,
service_id = excluded.service_id,
widget_kind = excluded.widget_kind,
title = excluded.title,
config_json = excluded.config_json,
enabled = excluded.enabled,
@@ -1487,6 +1477,8 @@ class SettingsStore:
widget["id"],
widget["addon_id"],
widget["widget_type"],
widget["service_id"],
widget["widget_kind"],
widget["title"],
json.dumps(widget["config"]),
1 if widget["enabled"] else 0,