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
@@ -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}")