10fd4ead4a
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.
112 lines
3.2 KiB
Python
112 lines
3.2 KiB
Python
"""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, model_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."""
|
|
|
|
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
|
|
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 {})
|
|
|
|
@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."""
|
|
|
|
id: str | None = None
|
|
|
|
|
|
class WidgetInstance(_WidgetInstanceBase):
|
|
"""Persisted widget instance returned by the API."""
|
|
|
|
id: str
|
|
created_at: int
|
|
updated_at: int
|
|
|
|
|
|
class BuiltinWidgetKindInfo(BaseModel):
|
|
"""Metadata about a built-in (service-less) widget kind."""
|
|
|
|
kind: str
|
|
name: str
|
|
description: 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
|
|
data: dict[str, Any] | None = None
|
|
error: str | None = None
|
|
fetched_at: int
|