feat(widgets): add backend CRUD, registry, and default seeding

Introduce a closed, compile-time widget registry and backend CRUD for
dashboard widget instances.

- Add dashboard_widgets SQLite table in SettingsStore with CRUD helpers and
  default seeding (Jellyfin + Backups) on first install.
- Add Pydantic models with credential-key and secret-value rejection.
- Add widgets router: /api/widgets/sources, /types, /instances CRUD.
- Call ensure_defaults() in app lifespan so fresh installs seed defaults.
- Add backend tests covering registry, CRUD, validation, and seeding.
- Include SDD artifacts: exploration, proposal, spec, design, tasks.
This commit is contained in:
Developer
2026-06-19 20:07:47 +00:00
parent 24427b4869
commit 200d319fb0
13 changed files with 2894 additions and 6 deletions
@@ -23,6 +23,7 @@ from media_library_viewer_api.observability import (
)
from media_library_viewer_api.routers import backups as backups_router
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
from media_library_viewer_api.routers import widgets as widgets_router
from media_library_viewer_api.routers.settings import router as settings_router
from .services.backup_poller import get_backup_poller
@@ -45,6 +46,10 @@ async def lifespan(app: FastAPI):
write_prometheus_targets(get_settings_store())
except Exception:
logger.exception("Failed to write Prometheus file-SD targets during startup")
try:
get_settings_store().ensure_defaults()
except Exception:
logger.exception("Failed to seed default settings during startup")
mail_queue = get_mail_queue()
backup_poller = get_backup_poller()
mail_queue.start()
@@ -137,6 +142,7 @@ app.include_router(users.router)
app.include_router(tasks.router)
app.include_router(settings_router)
app.include_router(backups_router.router)
app.include_router(widgets_router.router)
@app.get("/api/health")
@@ -0,0 +1,95 @@
"""Pydantic models for the dashboard widget system."""
from typing import Any
from pydantic import BaseModel, Field, field_validator
FORBIDDEN_CONFIG_KEYS = {
"password",
"token",
"secret",
"api_key",
"apikey",
"private_key",
"passphrase",
"credential",
}
def _looks_secret(value: Any) -> bool:
"""Heuristic to detect values that look like secrets/tokens."""
if not isinstance(value, str) or not value.strip():
return False
lowered = value.lower()
if value.startswith("sk-") or value.startswith("eyJ"):
return True
if len(value) > 64 and lowered.isalnum():
return True
return False
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
"""Recursively reject credential keys and secret-looking values."""
for key, value in config.items():
if key.lower() in FORBIDDEN_CONFIG_KEYS:
raise ValueError(f"Credential key '{key}' is not allowed in widget config")
if _looks_secret(value):
raise ValueError(f"Value for '{key}' looks like a secret")
if isinstance(value, dict):
_validate_config_keys(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
_validate_config_keys(item)
return config
class _WidgetInstanceBase(BaseModel):
"""Shared fields between input and output widget models."""
addon_id: str
widget_type: str
title: str = Field(..., min_length=1)
config: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
sort_order: int = Field(default=0, ge=0)
@field_validator("config")
@classmethod
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
return _validate_config_keys(value or {})
class WidgetInstanceInput(_WidgetInstanceBase):
"""Payload for creating or updating a widget instance."""
id: str | None = None
class WidgetInstance(_WidgetInstanceBase):
"""Persisted widget instance returned by the API."""
id: str
created_at: int
updated_at: int
class WidgetTypeInfo(BaseModel):
"""Metadata about a built-in widget type."""
addon_id: str
widget_type: str
name: str
description: str
source_type: str
config_schema: dict[str, Any]
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
@@ -0,0 +1,113 @@
"""REST API for dashboard widget instances and registry metadata."""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.models.widgets import WidgetInstance, WidgetInstanceInput, WidgetTypeInfo
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.registry import (
list_source_types,
list_widget_types,
validate_config,
)
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
def _registry_for_type(widget_type: str) -> dict[str, Any]:
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
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}",
)
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[WidgetTypeInfo]:
"""Return metadata for all registered widget types."""
return [info.model_dump() for info in list_widget_types()]
@router.get("/instances")
def list_instances(
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
"""Return all persisted widget instances."""
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
@router.post("/instances", status_code=status.HTTP_201_CREATED)
def create_instance(
body: WidgetInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Create a new widget instance."""
_validate_widget_input(body)
widget = store.upsert_widget(body.model_dump())
return WidgetInstance(**widget).model_dump()
@router.put("/instances/{widget_id}")
def update_instance(
widget_id: str,
body: WidgetInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Update an existing widget instance."""
existing = store.get_widget(widget_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
if body.id is not None and body.id != widget_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in path does not match ID in body",
)
_validate_widget_input(body)
widget = store.upsert_widget(body.model_dump(), widget_id)
return WidgetInstance(**widget).model_dump()
@router.delete("/instances/{widget_id}")
def delete_instance(
widget_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, str]:
"""Delete a widget instance."""
existing = store.get_widget(widget_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
store.delete_widget(widget_id)
return {"status": "deleted"}
@@ -18,6 +18,7 @@ from typing import Any
import paramiko
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.models.widgets import _validate_config_keys
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
LOCAL_MACHINE_ID = "local"
@@ -163,6 +164,24 @@ class SettingsStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_dashboard_shortcuts_type ON dashboard_shortcuts(shortcut_type)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS dashboard_widgets (
id TEXT PRIMARY KEY,
addon_id TEXT NOT NULL,
widget_type TEXT NOT NULL,
title TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY,
@@ -353,12 +372,8 @@ class SettingsStore:
"notes": notes,
}
def ensure_defaults(self) -> None:
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if row and int(row[0]) > 0:
return
def _seed_local_machine(self) -> None:
"""Seed the default local machine if none exists."""
machine = _default_local_machine()
now = int(time.time())
config = {
@@ -401,6 +416,49 @@ 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
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)
def ensure_defaults(self) -> None:
self.init_schema()
with self.connect() as conn:
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()
with self.connect() as conn:
@@ -1306,6 +1364,119 @@ class SettingsStore:
)
# ------------------------------------------------------------------
# Dashboard widgets
# ------------------------------------------------------------------
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"addon_id": row["addon_id"],
"widget_type": row["widget_type"],
"title": row["title"],
"config": json.loads(row["config_json"] or "{}"),
"enabled": bool(row["enabled"]),
"sort_order": int(row["sort_order"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _normalize_widget_payload(
self,
payload: dict[str, Any],
widget_id: str | None = None,
) -> 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()
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):
config = {}
# Defense-in-depth: reject credential keys at the store layer too.
_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)
return {
"id": widget_id,
"addon_id": addon_id,
"widget_type": widget_type,
"title": title,
"config": config,
"enabled": enabled,
"sort_order": sort_order,
}
def list_widgets(self) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
rows = conn.execute(
"SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC"
).fetchall()
return [self._row_to_widget(row) for row in rows]
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
if not widget_id:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute(
"SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)
).fetchone()
return self._row_to_widget(row) if row else None
def upsert_widget(self, payload: dict[str, Any], widget_id: str | None = None) -> dict[str, Any]:
self.init_schema()
widget = self._normalize_widget_payload(payload, widget_id)
now = int(time.time())
with self.connect() as conn:
existing = conn.execute(
"SELECT created_at FROM dashboard_widgets WHERE id = ?",
(widget["id"],),
).fetchone()
created_at = int(existing[0]) if existing else now
conn.execute(
"""
INSERT INTO dashboard_widgets (
id, addon_id, widget_type, title, config_json, enabled,
sort_order, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
addon_id = excluded.addon_id,
widget_type = excluded.widget_type,
title = excluded.title,
config_json = excluded.config_json,
enabled = excluded.enabled,
sort_order = excluded.sort_order,
updated_at = excluded.updated_at
""",
(
widget["id"],
widget["addon_id"],
widget["widget_type"],
widget["title"],
json.dumps(widget["config"]),
1 if widget["enabled"] else 0,
widget["sort_order"],
created_at,
now,
),
)
return self.get_widget(widget["id"]) or widget
def delete_widget(self, widget_id: str) -> None:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
_store: SettingsStore | None = None
@@ -0,0 +1 @@
"""Widget subsystem package."""
@@ -0,0 +1,187 @@
"""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}")