Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd534a816b | |||
| 8cdeadd6dd | |||
| d1819c0186 | |||
| 9459de5c07 | |||
| 9782280a03 | |||
| 0ad6a04053 | |||
| 75636c00d4 | |||
| f4b16b5844 | |||
| 09eb76bf0f | |||
| ed7a7a5ce0 | |||
| e4e879d1c8 | |||
| 2557185fb7 | |||
| e1356b20f1 | |||
| e6d333ef7b | |||
| 1cd8e926de | |||
| 1a52dfb087 | |||
| 9dfe62eb6f | |||
| 200d319fb0 |
@@ -1,3 +1,7 @@
|
||||
# Manage environment template
|
||||
# Copy this file to .env, fill in the required values, and export them in your shell
|
||||
# before running docker compose. Compose files use interpolation, not env_file.
|
||||
|
||||
# App
|
||||
APP_VERSION=0.1.0
|
||||
APP_BUILD_INFO=dev
|
||||
@@ -23,6 +27,11 @@ PROMETHEUS_ENABLED=true
|
||||
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
||||
ALERTMANAGER_URL=http://alertmanager:9093
|
||||
ALERTMANAGER_WEBHOOK_URL=
|
||||
GRAFANA_URL=http://grafana:3000
|
||||
PROMETHEUS_URL=http://prometheus:9090
|
||||
# Required: master key for encrypting service secrets (API keys/tokens) at rest.
|
||||
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
|
||||
BACKEND_CACHE_DIR=./backend-cache
|
||||
|
||||
# Auth
|
||||
@@ -42,6 +51,7 @@ VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||
VITE_GRAFANA_URL=https://grafana.example.com
|
||||
VITE_PROMETHEUS_URL=https://prometheus.example.com
|
||||
|
||||
# SMTP
|
||||
SMTP_HOST=smtp.example.com
|
||||
|
||||
@@ -20,14 +20,15 @@ The project consists of two subprojects:
|
||||
|
||||
## Features
|
||||
|
||||
- Dashboard with now-playing sessions, server monitoring overview, and per-library media counts
|
||||
- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts plus a sortable dashboard table covering all configured machines
|
||||
- Per-machine monitoring settings with local and remote targets managed in the UI, plus backend-collected recent action history per machine
|
||||
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, SSH task output, static text) and shortcuts
|
||||
- Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
|
||||
- Per-machine settings for Jellyfin, Jellyseerr, SSH, and monitoring targets
|
||||
- SQLite-indexed media table with full-library sort/filter
|
||||
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
||||
- Remote file browser with ffprobe preview and job execution
|
||||
- Jellyfin API integration for library metadata and user identity data
|
||||
- SSH-based file inspection and remote job templates
|
||||
- SSH-based file inspection and safe remote job templates
|
||||
- Addon pages for Grafana, Prometheus, and SSH tasks at `/addons/:addonId`
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -39,7 +40,9 @@ Production-style deployment with the frontend serving the SPA and proxying `/api
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open the app at http://localhost:8080.
|
||||
Open the app at <http://localhost:8080>.
|
||||
|
||||
The production Compose file requires OIDC and Traefik variables; see [Configuration](#configuration) below. Copy `.env.example` to `.env`, fill in the required values, and export them in your shell before running `docker compose up`.
|
||||
|
||||
Local development with hot reload:
|
||||
|
||||
@@ -47,9 +50,9 @@ Local development with hot reload:
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
Frontend runs on http://localhost:5173 and the backend on http://localhost:8000.
|
||||
The backend media index is persisted in a Docker volume (`backend_cache`) so rebuilds and container restarts do not force a full re-index.
|
||||
Monitoring machine definitions and recent machine activity are stored in the backend so the UI can show one section per configured machine and preserve history across restarts.
|
||||
Frontend runs on <http://localhost:5173> and the backend on <http://localhost:8000>. Dev compose disables OIDC by default (`AUTH_ENABLED=false`), so you can open it directly without an identity provider.
|
||||
|
||||
The backend media index and settings database (including monitoring machines, SSH keys, saved tasks, and dashboard widgets) are persisted in Docker volumes so rebuilds and container restarts do not reset state.
|
||||
|
||||
### Manual backend/frontend development
|
||||
|
||||
@@ -76,13 +79,17 @@ The Compose files use environment-variable interpolation. Export the required va
|
||||
Production-style example with shell exports:
|
||||
|
||||
```bash
|
||||
export BACKEND_APP_HOST=manage.example.com
|
||||
export BACKEND_APP_HOST=api.manage.example.com
|
||||
export FRONTEND_APP_HOST=manage.example.com
|
||||
export GRAFANA_APP_HOST=grafana.manage.example.com
|
||||
export CERT_RESOLVER=letsencrypt
|
||||
export VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/
|
||||
export VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||
export VITE_OIDC_CLIENT_ID=manage
|
||||
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/
|
||||
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||
export VITE_GRAFANA_URL=https://grafana.manage.example.com
|
||||
export VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
|
||||
export MANAGE_ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
||||
|
||||
docker compose up --build
|
||||
```
|
||||
@@ -90,7 +97,7 @@ docker compose up --build
|
||||
Inline one-liner example:
|
||||
|
||||
```bash
|
||||
BACKEND_APP_HOST=manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build
|
||||
BACKEND_APP_HOST=api.manage.example.com FRONTEND_APP_HOST=manage.example.com GRAFANA_APP_HOST=grafana.manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ VITE_GRAFANA_URL=https://grafana.manage.example.com VITE_PROMETHEUS_URL=https://prometheus.manage.example.com docker compose up --build
|
||||
```
|
||||
|
||||
For local development, no SSH key is required unless you want to connect to remote SSH machines later:
|
||||
@@ -124,27 +131,37 @@ SMTP_TIMEOUT=30
|
||||
|
||||
# Authentik / OIDC
|
||||
AUTH_ENABLED=true
|
||||
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/
|
||||
OIDC_AUDIENCE=media-library-viewer
|
||||
OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
|
||||
OIDC_AUDIENCE=manage
|
||||
OIDC_JWKS_URL=
|
||||
OIDC_CLOCK_SKEW_SECONDS=30
|
||||
|
||||
# Frontend OIDC settings
|
||||
VITE_OIDC_ENABLED=true
|
||||
VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/
|
||||
VITE_OIDC_CLIENT_ID=media-library-viewer
|
||||
VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||
VITE_OIDC_CLIENT_ID=manage
|
||||
VITE_OIDC_SCOPE=openid profile email
|
||||
VITE_OIDC_REDIRECT_URI=http://localhost:8080/
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/
|
||||
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||
|
||||
# Grafana / Prometheus URLs used by widget adapters and frontend deep-links
|
||||
GRAFANA_URL=http://grafana:3000
|
||||
PROMETHEUS_URL=http://prometheus:9090
|
||||
VITE_GRAFANA_URL=https://grafana.manage.example.com
|
||||
VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
|
||||
|
||||
# Required: master key encrypting service secrets (API keys/tokens) at rest.
|
||||
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
|
||||
```
|
||||
|
||||
## Remote server requirements
|
||||
|
||||
The remote server needs:
|
||||
|
||||
- Linux `/proc` and `/sys/block` for monitoring
|
||||
- `/bin/sh` (POSIX shell)
|
||||
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk`
|
||||
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk` for file inspection and job templates
|
||||
- SSH access with a key configured in the app's Settings tab
|
||||
|
||||
The SSH client rejects unknown host keys. Connect manually once first:
|
||||
|
||||
@@ -167,5 +184,6 @@ cd frontend && npx tsc --noEmit && npm run build
|
||||
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
|
||||
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
|
||||
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
|
||||
- Monitoring collector uses JSONL in `/tmp`, pruned to 7 days / 70k lines.
|
||||
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries.
|
||||
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
|
||||
- Grafana and Prometheus widget adapters use `GRAFANA_URL` and `PROMETHEUS_URL` (backend) and `VITE_GRAFANA_URL` / `VITE_PROMETHEUS_URL` (frontend) for deep-links; no credentials are stored in widget config.
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"python-multipart>=0.0.9",
|
||||
"prometheus-client>=0.21",
|
||||
"python-json-logger>=2.0",
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -57,6 +57,8 @@ class Settings(BaseSettings):
|
||||
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
|
||||
alertmanager_url: str = "http://alertmanager:9093"
|
||||
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
|
||||
grafana_url: str = "http://grafana:3000"
|
||||
prometheus_url: str = "http://prometheus:9090"
|
||||
|
||||
# Remote paths
|
||||
remote_media_root: str = ""
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Dashboard domain helpers shared between routers and widget adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||
results: list[dict[str, Any]] = []
|
||||
for session in sessions:
|
||||
item = session.get("NowPlayingItem") or {}
|
||||
play_state = session.get("PlayState") or {}
|
||||
transcoding = session.get("TranscodingInfo") or {}
|
||||
|
||||
has_item = bool(item)
|
||||
series = item.get("SeriesName") or ""
|
||||
title = (
|
||||
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown"))
|
||||
if has_item
|
||||
else "(idle)"
|
||||
)
|
||||
|
||||
if not has_item:
|
||||
state_label = "idle"
|
||||
else:
|
||||
state_label = "paused" if play_state.get("IsPaused") else "playing"
|
||||
|
||||
is_transcoding = bool(transcoding)
|
||||
transcode_type: list[str] = []
|
||||
if is_transcoding:
|
||||
if transcoding.get("IsVideoDirect") is False:
|
||||
transcode_type.append("video")
|
||||
if transcoding.get("IsAudioDirect") is False:
|
||||
transcode_type.append("audio")
|
||||
if not transcode_type:
|
||||
transcode_type.append("active")
|
||||
|
||||
results.append(
|
||||
{
|
||||
"user": session.get("UserName") or "Unknown",
|
||||
"title": title,
|
||||
"type": item.get("Type", "") if has_item else "",
|
||||
"state": state_label,
|
||||
"transcoding": "yes" if is_transcoding else "no",
|
||||
"transcoding_type": ", ".join(transcode_type),
|
||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||
"session_id": session.get("Id") or "",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def build_backup_dashboard_summary(store: SettingsStore) -> BackupDashboardSummary:
|
||||
"""Compute the backup summary shown on the dashboard."""
|
||||
jobs = store.list_backup_jobs()
|
||||
total_jobs = len(jobs)
|
||||
|
||||
cutoff = int(time.time()) - (24 * 60 * 60)
|
||||
recent_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], limit=1)
|
||||
if runs and runs[0]["started_at"] >= cutoff:
|
||||
recent_runs.append(runs[0])
|
||||
|
||||
successful = sum(1 for r in recent_runs if r["status"] == "success")
|
||||
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
|
||||
|
||||
alerts = store.list_backup_alerts(acknowledged=False)
|
||||
active_alerts = len(alerts)
|
||||
|
||||
failed_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
|
||||
if runs:
|
||||
failed_runs.append(runs[0])
|
||||
|
||||
last_failed_at = None
|
||||
if failed_runs:
|
||||
last_failed_at = max(r["started_at"] for r in failed_runs)
|
||||
|
||||
return BackupDashboardSummary(
|
||||
total_jobs=total_jobs,
|
||||
success_rate_24h=round(success_rate, 1),
|
||||
active_alerts=active_alerts,
|
||||
last_failed_at=last_failed_at,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Closed registry of service integrations."""
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Base classes for service integrations.
|
||||
|
||||
A *service definition* is a closed, compile-time description of an external service
|
||||
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
|
||||
|
||||
* its non-secret ``config_schema`` (derived from a Pydantic model),
|
||||
* the secret fields it accepts (API keys / tokens),
|
||||
* the widget kinds it can contribute to the dashboard (each with its own
|
||||
Pydantic-derived config schema).
|
||||
|
||||
Definitions live in :mod:`media_library_viewer_api.integrations` modules and are
|
||||
assembled into the closed :data:`~media_library_viewer_api.integrations.registry.SERVICE_DEFINITIONS`
|
||||
map. There is no runtime plugin loading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ServiceConfigBase(BaseModel):
|
||||
"""Base for per-service non-secret config models.
|
||||
|
||||
Subclass this in each integration module and declare the connection fields.
|
||||
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
|
||||
"""
|
||||
|
||||
|
||||
class WidgetConfigBase(BaseModel):
|
||||
"""Base for per-widget config models.
|
||||
|
||||
Subclass this for each widget kind a service provides. Widget configs never
|
||||
hold secrets; credentials live on the parent service record.
|
||||
"""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecretField:
|
||||
"""A secret field stored encrypted on the service record."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
required: bool = False
|
||||
helper: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WidgetKind:
|
||||
"""A widget kind contributed by a service definition."""
|
||||
|
||||
kind: str
|
||||
name: str
|
||||
description: str
|
||||
config_schema: dict[str, Any]
|
||||
default_config: dict[str, Any] = field(default_factory=dict)
|
||||
refresh_interval_ms: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceDefinition:
|
||||
"""Closed description of an external service type."""
|
||||
|
||||
service_type: str
|
||||
name: str
|
||||
description: str
|
||||
config_model: type[ServiceConfigBase]
|
||||
secret_fields: list[SecretField]
|
||||
widget_kinds: list[WidgetKind]
|
||||
|
||||
@property
|
||||
def config_schema(self) -> dict[str, Any]:
|
||||
"""JSON schema for the service's non-secret config."""
|
||||
return self.config_model.model_json_schema()
|
||||
|
||||
@property
|
||||
def secret_keys(self) -> set[str]:
|
||||
return {sf.key for sf in self.secret_fields}
|
||||
|
||||
def widget_kind(self, kind: str) -> WidgetKind | None:
|
||||
for wk in self.widget_kinds:
|
||||
if wk.kind == kind:
|
||||
return wk
|
||||
return None
|
||||
|
||||
|
||||
def widget_kind(
|
||||
kind: str,
|
||||
name: str,
|
||||
description: str,
|
||||
model_cls: type[WidgetConfigBase],
|
||||
*,
|
||||
default_config: dict[str, Any] | None = None,
|
||||
refresh_interval_ms: int = 0,
|
||||
) -> WidgetKind:
|
||||
"""Build a :class:`WidgetKind` from a Pydantic widget-config model."""
|
||||
schema = model_cls.model_json_schema()
|
||||
# Strip Pydantic's title noise so the exposed schema stays clean.
|
||||
schema.pop("title", None)
|
||||
return WidgetKind(
|
||||
kind=kind,
|
||||
name=name,
|
||||
description=description,
|
||||
config_schema=schema,
|
||||
default_config=dict(default_config or {}),
|
||||
refresh_interval_ms=refresh_interval_ms,
|
||||
)
|
||||
|
||||
|
||||
def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
|
||||
instance = model_cls.model_validate(config or {})
|
||||
return instance.model_dump(exclude_none=True)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Grafana service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class GrafanaConfig(ServiceConfigBase):
|
||||
"""Non-secret Grafana connection config."""
|
||||
|
||||
base_url: str
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
||||
"""Deep-link to a Grafana dashboard or panel."""
|
||||
|
||||
dashboard_uid: str
|
||||
panel_id: int | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="grafana",
|
||||
name="Grafana",
|
||||
description="Dashboards, metrics, and logs.",
|
||||
config_model=GrafanaConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="link",
|
||||
name="Dashboard link",
|
||||
description="Deep-link to a Grafana dashboard or panel.",
|
||||
model_cls=GrafanaLinkWidgetConfig,
|
||||
default_config={"dashboard_uid": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Jellyfin service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class JellyfinConfig(ServiceConfigBase):
|
||||
"""Non-secret Jellyfin connection config."""
|
||||
|
||||
base_url: str
|
||||
user_id: str = ""
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||
"""Live Jellyfin session activity."""
|
||||
|
||||
# No user-overridable fields; the service record carries user_id.
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="jellyfin",
|
||||
name="Jellyfin",
|
||||
description="Media server with live session activity.",
|
||||
config_model=JellyfinConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", required=True),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="activity",
|
||||
name="Activity",
|
||||
description="Live sessions and idle users.",
|
||||
model_cls=JellyfinActivityWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Nextcloud service definition.
|
||||
|
||||
Nextcloud is included as a proof-of-concept third-party service. It has no
|
||||
dashboard widgets yet; its service page holds connection config only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
)
|
||||
|
||||
|
||||
class NextcloudConfig(ServiceConfigBase):
|
||||
"""Non-secret Nextcloud connection config."""
|
||||
|
||||
base_url: str
|
||||
username: str = ""
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="nextcloud",
|
||||
name="Nextcloud",
|
||||
description="Self-hosted files and collaboration.",
|
||||
config_model=NextcloudConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="app_password", label="App password", required=True),
|
||||
],
|
||||
widget_kinds=[],
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Prometheus service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class PrometheusConfig(ServiceConfigBase):
|
||||
"""Non-secret Prometheus connection config."""
|
||||
|
||||
base_url: str
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
class PrometheusMetricWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL instant query rendered as a metric."""
|
||||
|
||||
promql: str
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="prometheus",
|
||||
name="Prometheus",
|
||||
description="Metrics storage and PromQL queries.",
|
||||
config_model=PrometheusConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="metric",
|
||||
name="Metric",
|
||||
description="Instant query result rendered as a metric.",
|
||||
model_cls=PrometheusMetricWidgetConfig,
|
||||
default_config={"promql": ""},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Closed registry of service definitions.
|
||||
|
||||
Adding a brand-new service still requires a backend deploy and a module here.
|
||||
There is no runtime plugin loading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||
|
||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
GRAFANA.service_type: GRAFANA,
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||
SSH_TASKS.service_type: SSH_TASKS,
|
||||
}
|
||||
|
||||
|
||||
def list_service_types() -> list[str]:
|
||||
"""Return all registered service type names (sorted for stable output)."""
|
||||
return sorted(SERVICE_DEFINITIONS)
|
||||
|
||||
|
||||
def get_service_definition(service_type: str) -> ServiceDefinition | None:
|
||||
"""Return the definition for a service type, or ``None`` if unknown."""
|
||||
return SERVICE_DEFINITIONS.get(service_type)
|
||||
|
||||
|
||||
def get_widget_kind(service_type: str, widget_kind: str) -> WidgetKind | None:
|
||||
"""Return a widget kind declared by a service definition, or ``None``."""
|
||||
definition = get_service_definition(service_type)
|
||||
if definition is None:
|
||||
return None
|
||||
return definition.widget_kind(widget_kind)
|
||||
|
||||
|
||||
def require_service_definition(service_type: str) -> ServiceDefinition:
|
||||
"""Return the definition or raise ``ValueError`` for an unknown type."""
|
||||
definition = get_service_definition(service_type)
|
||||
if definition is None:
|
||||
raise ValueError(f"Unknown service type: {service_type}")
|
||||
return definition
|
||||
@@ -0,0 +1,60 @@
|
||||
"""SSH task runner service definition.
|
||||
|
||||
An ``ssh_tasks`` instance is an SSH endpoint that can run reusable saved tasks.
|
||||
Tasks themselves stay in the global saved-task registry; the instance only owns
|
||||
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
|
||||
and shown as history on the instance's service page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class SshTasksConfig(ServiceConfigBase):
|
||||
"""Non-secret SSH task runner config.
|
||||
|
||||
The SSH key itself lives in the saved SSH-key registry and is referenced by
|
||||
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
|
||||
"""
|
||||
|
||||
host: str
|
||||
port: int = 22
|
||||
username: str = ""
|
||||
ssh_key_id: str = ""
|
||||
timeout_seconds: int = 30
|
||||
|
||||
|
||||
class SshTaskOutputWidgetConfig(WidgetConfigBase):
|
||||
"""Output of a saved task run on this instance."""
|
||||
|
||||
task_id: str
|
||||
# service_id is implicit (the widget's service); allow overriding per-widget.
|
||||
service_id: str | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="ssh_tasks",
|
||||
name="SSH task runner",
|
||||
description="Run reusable saved tasks over SSH and keep run history.",
|
||||
config_model=SshTasksConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="task_output",
|
||||
name="Task output",
|
||||
description="Output of a saved task run.",
|
||||
model_cls=SshTaskOutputWidgetConfig,
|
||||
default_config={"task_id": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -23,6 +23,8 @@ 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 services as services_router
|
||||
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
|
||||
@@ -37,6 +39,9 @@ async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level, settings.log_format)
|
||||
validate_auth_settings(settings)
|
||||
from media_library_viewer_api.services.secrets import validate_encryption_key
|
||||
|
||||
validate_encryption_key()
|
||||
logger.info("Backend startup complete: %s", describe_settings(settings))
|
||||
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
|
||||
try:
|
||||
@@ -45,6 +50,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 +146,8 @@ 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.include_router(services_router.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Pydantic models for the service registry API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Reject credential keys in non-secret service config.
|
||||
|
||||
Secrets are sent in the separate ``secrets`` mapping; the plain ``config``
|
||||
object must never hold them.
|
||||
"""
|
||||
forbidden = {
|
||||
"password",
|
||||
"token",
|
||||
"secret",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"private_key",
|
||||
"passphrase",
|
||||
"credential",
|
||||
}
|
||||
|
||||
def _check(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
if key.lower() in forbidden:
|
||||
raise ValueError(f"Credential key '{key}' is not allowed in service config")
|
||||
_check(child)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_check(item)
|
||||
|
||||
_check(config)
|
||||
return config
|
||||
|
||||
|
||||
class ServiceInstanceInput(BaseModel):
|
||||
"""Payload for creating or updating a service instance."""
|
||||
|
||||
id: str | None = None
|
||||
service_type: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
secrets: dict[str, str] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
|
||||
@field_validator("config")
|
||||
@classmethod
|
||||
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||
return _validate_config_keys(value or {})
|
||||
|
||||
|
||||
class ServiceInstance(BaseModel):
|
||||
"""Persisted service instance returned by the API (no plaintext secrets)."""
|
||||
|
||||
id: str
|
||||
service_type: str
|
||||
name: str
|
||||
config: dict[str, Any]
|
||||
secrets_set: dict[str, bool]
|
||||
enabled: bool
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class SecretFieldInfo(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
required: bool = False
|
||||
helper: str | None = None
|
||||
|
||||
|
||||
class WidgetKindInfo(BaseModel):
|
||||
kind: str
|
||||
name: str
|
||||
description: str
|
||||
config_schema: dict[str, Any]
|
||||
default_config: dict[str, Any]
|
||||
refresh_interval_ms: int
|
||||
|
||||
|
||||
class ServiceTypeInfo(BaseModel):
|
||||
"""Metadata about a registered service type."""
|
||||
|
||||
service_type: str
|
||||
name: str
|
||||
description: str
|
||||
config_schema: dict[str, Any]
|
||||
secret_fields: list[SecretFieldInfo]
|
||||
widget_kinds: list[WidgetKindInfo]
|
||||
@@ -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
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import (
|
||||
get_settings_store,
|
||||
get_user_id,
|
||||
)
|
||||
from media_library_viewer_api.domain.dashboard import (
|
||||
_map_sessions_to_activity_rows,
|
||||
build_backup_dashboard_summary,
|
||||
)
|
||||
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
@@ -85,50 +88,6 @@ def delete_shortcut(
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||
results: list[dict[str, Any]] = []
|
||||
for session in sessions:
|
||||
item = session.get("NowPlayingItem") or {}
|
||||
play_state = session.get("PlayState") or {}
|
||||
transcoding = session.get("TranscodingInfo") or {}
|
||||
|
||||
has_item = bool(item)
|
||||
series = item.get("SeriesName") or ""
|
||||
title = (
|
||||
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")) if has_item else "(idle)"
|
||||
)
|
||||
|
||||
if not has_item:
|
||||
state_label = "idle"
|
||||
else:
|
||||
state_label = "paused" if play_state.get("IsPaused") else "playing"
|
||||
|
||||
is_transcoding = bool(transcoding)
|
||||
transcode_type: list[str] = []
|
||||
if is_transcoding:
|
||||
if transcoding.get("IsVideoDirect") is False:
|
||||
transcode_type.append("video")
|
||||
if transcoding.get("IsAudioDirect") is False:
|
||||
transcode_type.append("audio")
|
||||
if not transcode_type:
|
||||
transcode_type.append("active")
|
||||
|
||||
results.append(
|
||||
{
|
||||
"user": session.get("UserName") or "Unknown",
|
||||
"title": title,
|
||||
"type": item.get("Type", "") if has_item else "",
|
||||
"state": state_label,
|
||||
"transcoding": "yes" if is_transcoding else "no",
|
||||
"transcoding_type": ", ".join(transcode_type),
|
||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||
"session_id": session.get("Id") or "",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/activity")
|
||||
def get_activity(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
@@ -154,38 +113,4 @@ def get_now_playing(
|
||||
def get_backup_dashboard(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> BackupDashboardSummary:
|
||||
jobs = store.list_backup_jobs()
|
||||
total_jobs = len(jobs)
|
||||
|
||||
# Calculate 24h success rate
|
||||
cutoff = int(time.time()) - (24 * 60 * 60)
|
||||
recent_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], limit=1)
|
||||
if runs and runs[0]["started_at"] >= cutoff:
|
||||
recent_runs.append(runs[0])
|
||||
|
||||
successful = sum(1 for r in recent_runs if r["status"] == "success")
|
||||
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
|
||||
|
||||
# Active alerts
|
||||
alerts = store.list_backup_alerts(acknowledged=False)
|
||||
active_alerts = len(alerts)
|
||||
|
||||
# Last failed
|
||||
failed_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
|
||||
if runs:
|
||||
failed_runs.append(runs[0])
|
||||
|
||||
last_failed_at = None
|
||||
if failed_runs:
|
||||
last_failed_at = max(r["started_at"] for r in failed_runs)
|
||||
|
||||
return BackupDashboardSummary(
|
||||
total_jobs=total_jobs,
|
||||
success_rate_24h=round(success_rate, 1),
|
||||
active_alerts=active_alerts,
|
||||
last_failed_at=last_failed_at,
|
||||
)
|
||||
return build_backup_dashboard_summary(store)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""REST API for the service registry.
|
||||
|
||||
Service instances hold non-secret config and encrypted secrets. Plaintext
|
||||
secrets are never returned; only the boolean ``secrets_set`` map is exposed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
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 (
|
||||
SERVICE_DEFINITIONS,
|
||||
get_service_definition,
|
||||
require_service_definition,
|
||||
)
|
||||
from media_library_viewer_api.models.services import (
|
||||
SecretFieldInfo,
|
||||
ServiceInstance,
|
||||
ServiceInstanceInput,
|
||||
ServiceTypeInfo,
|
||||
WidgetKindInfo,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
router = APIRouter(prefix="/api/services", tags=["services"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_type_info(service_type: str) -> ServiceTypeInfo:
|
||||
definition = require_service_definition(service_type)
|
||||
return ServiceTypeInfo(
|
||||
service_type=definition.service_type,
|
||||
name=definition.name,
|
||||
description=definition.description,
|
||||
config_schema=definition.config_schema,
|
||||
secret_fields=[
|
||||
SecretFieldInfo(
|
||||
key=sf.key,
|
||||
label=sf.label,
|
||||
required=sf.required,
|
||||
helper=sf.helper,
|
||||
)
|
||||
for sf in definition.secret_fields
|
||||
],
|
||||
widget_kinds=[
|
||||
WidgetKindInfo(
|
||||
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,
|
||||
)
|
||||
for wk in definition.widget_kinds
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _to_instance(row: dict[str, Any]) -> ServiceInstance:
|
||||
"""Build an API response model, surfacing only secret 'set' flags."""
|
||||
definition = get_service_definition(row["service_type"])
|
||||
known_secrets = definition.secret_keys if definition else set()
|
||||
secrets_blob = row.get("secrets") or {}
|
||||
secrets_set = {key: (key in secrets_blob and bool(secrets_blob[key])) for key in known_secrets}
|
||||
return ServiceInstance(
|
||||
id=row["id"],
|
||||
service_type=row["service_type"],
|
||||
name=row["name"],
|
||||
config=row.get("config") or {},
|
||||
secrets_set=secrets_set,
|
||||
enabled=row["enabled"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
def _validate_input(body: ServiceInstanceInput) -> None:
|
||||
"""Validate service_type, config, and secret keys against the definition."""
|
||||
definition = get_service_definition(body.service_type)
|
||||
if definition is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unknown service type: {body.service_type}",
|
||||
)
|
||||
try:
|
||||
validate_config(definition.config_model, body.config)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Invalid service config: {exc}",
|
||||
) from exc
|
||||
unknown_secrets = set(body.secrets) - definition.secret_keys
|
||||
if unknown_secrets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unknown secret fields for {body.service_type}: {sorted(unknown_secrets)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
def list_types() -> list[ServiceTypeInfo]:
|
||||
"""Return metadata for every registered service type."""
|
||||
return [_to_type_info(service_type) for service_type in sorted(SERVICE_DEFINITIONS)]
|
||||
|
||||
|
||||
@router.get("/instances")
|
||||
def list_instances(
|
||||
service_type: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[ServiceInstance]:
|
||||
"""Return all persisted service instances (no plaintext secrets)."""
|
||||
rows = store.list_services(service_type)
|
||||
return [_to_instance(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||
def create_instance(
|
||||
body: ServiceInstanceInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> ServiceInstance:
|
||||
"""Create a new service instance."""
|
||||
_validate_input(body)
|
||||
row = store.upsert_service(
|
||||
{
|
||||
"id": body.id,
|
||||
"service_type": body.service_type,
|
||||
"name": body.name,
|
||||
"config": body.config,
|
||||
"enabled": body.enabled,
|
||||
},
|
||||
secret_values=body.secrets,
|
||||
)
|
||||
return _to_instance(row)
|
||||
|
||||
|
||||
@router.put("/instances/{service_id}")
|
||||
def update_instance(
|
||||
service_id: str,
|
||||
body: ServiceInstanceInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> ServiceInstance:
|
||||
"""Update an existing service instance."""
|
||||
existing = store.get_service(service_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
||||
if body.id is not None and body.id != service_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="ID in path does not match ID in body",
|
||||
)
|
||||
_validate_input(body)
|
||||
row = store.upsert_service(
|
||||
{
|
||||
"id": service_id,
|
||||
"service_type": body.service_type,
|
||||
"name": body.name,
|
||||
"config": body.config,
|
||||
"enabled": body.enabled,
|
||||
},
|
||||
secret_values=body.secrets,
|
||||
service_id=service_id,
|
||||
)
|
||||
return _to_instance(row)
|
||||
|
||||
|
||||
@router.delete("/instances/{service_id}")
|
||||
def delete_instance(
|
||||
service_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Delete a service instance (cascade-deletes widgets referencing it)."""
|
||||
existing = store.get_service(service_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
||||
store.delete_service(service_id)
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,173 @@
|
||||
"""REST API for dashboard widget instances and registry metadata."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
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 (
|
||||
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.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
|
||||
|
||||
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[dict[str, Any]]:
|
||||
"""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"}
|
||||
|
||||
|
||||
@router.get("/instances/{widget_id}/data")
|
||||
async def fetch_data(
|
||||
widget_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch widget data through the registered source adapter."""
|
||||
widget = store.get_widget(widget_id)
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
try:
|
||||
data = await adapter.fetch(widget["config"])
|
||||
except Exception as exc:
|
||||
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Widget data fetch failed",
|
||||
) from exc
|
||||
|
||||
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()),
|
||||
).model_dump()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Encryption-at-rest for service secrets.
|
||||
|
||||
Service API keys / tokens are stored encrypted in the ``services.secrets_json``
|
||||
column. Encryption uses Fernet (symmetric authenticated encryption) with a single
|
||||
master key provided via the ``MANAGE_ENCRYPTION_KEY`` environment variable.
|
||||
|
||||
* The key **must** be a urlsafe base64-encoded 32-byte value (Fernet format).
|
||||
* The key is **always required** — there is no development fallback, so secrets
|
||||
are never accidentally stored in plaintext.
|
||||
* Secrets are encrypted field-by-field; the ``"which secrets are set"`` metadata
|
||||
can be derived from the ciphertext blob without decrypting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
ENCRYPTION_KEY_ENV = "MANAGE_ENCRYPTION_KEY"
|
||||
|
||||
|
||||
class EncryptionKeyError(RuntimeError):
|
||||
"""Raised when the encryption key is missing or invalid."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_encryption_key() -> bytes:
|
||||
"""Return the raw Fernet key, or raise if missing/invalid.
|
||||
|
||||
The result is cached for the process lifetime. Tests should call
|
||||
:func:`reset_encryption_key_cache` after changing the environment.
|
||||
"""
|
||||
raw = os.environ.get(ENCRYPTION_KEY_ENV)
|
||||
if not raw:
|
||||
raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} is required to store service secrets")
|
||||
key = raw.strip().encode()
|
||||
try:
|
||||
Fernet(key)
|
||||
except (ValueError, TypeError) as exc: # pragma: no cover - validated by tests
|
||||
raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} must be a valid Fernet key") from exc
|
||||
return key
|
||||
|
||||
|
||||
def reset_encryption_key_cache() -> None:
|
||||
"""Drop the cached encryption key (used by tests that swap keys)."""
|
||||
get_encryption_key.cache_clear()
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
return Fernet(get_encryption_key())
|
||||
|
||||
|
||||
def encrypt_value(plaintext: str) -> str:
|
||||
"""Encrypt a single secret value and return the ciphertext string."""
|
||||
return _fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_value(ciphertext: str) -> str:
|
||||
"""Decrypt a single ciphertext value."""
|
||||
try:
|
||||
return _fernet().decrypt(ciphertext.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise EncryptionKeyError("Service secret could not be decrypted") from exc
|
||||
|
||||
|
||||
def encrypt_secrets(values: dict[str, str]) -> dict[str, str]:
|
||||
"""Encrypt every provided secret value."""
|
||||
fernet = _fernet()
|
||||
return {key: fernet.encrypt(value.encode()).decode() for key, value in values.items()}
|
||||
|
||||
|
||||
def decrypt_secrets(blob: dict[str, str]) -> dict[str, str]:
|
||||
"""Decrypt every secret value in a blob."""
|
||||
fernet = _fernet()
|
||||
result: dict[str, str] = {}
|
||||
for key, ciphertext in blob.items():
|
||||
try:
|
||||
result[key] = fernet.decrypt(ciphertext.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise EncryptionKeyError(f"Service secret '{key}' could not be decrypted") from exc
|
||||
return result
|
||||
|
||||
|
||||
def generate_development_key() -> str:
|
||||
"""Return a freshly generated Fernet key (helper for operators/docs)."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
def validate_encryption_key() -> None:
|
||||
"""Eagerly validate that the encryption key is present and well-formed."""
|
||||
get_encryption_key() # raises EncryptionKeyError on failure
|
||||
@@ -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,22 @@ 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,
|
||||
@@ -207,6 +224,44 @@ class SettingsStore:
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_job_id ON backup_alerts(job_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_acknowledged ON backup_alerts(acknowledged)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
id TEXT PRIMARY KEY,
|
||||
service_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
secrets_json TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_services_type ON services(service_type)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS service_task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
service_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
exit_status INTEGER,
|
||||
duration_ms INTEGER,
|
||||
stdout_tail TEXT NOT NULL DEFAULT '',
|
||||
stderr_tail TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_service "
|
||||
"ON service_task_runs(service_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||
@@ -353,12 +408,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 +452,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:
|
||||
@@ -1305,6 +1399,307 @@ class SettingsStore:
|
||||
(key, value, now),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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,))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Service registry
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _row_to_service(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
secrets_blob = json.loads(row["secrets_json"] or "{}")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"service_type": row["service_type"],
|
||||
"name": row["name"],
|
||||
"config": json.loads(row["config_json"] or "{}"),
|
||||
"secrets": secrets_blob,
|
||||
"enabled": bool(row["enabled"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def list_services(self, service_type: str | None = None) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
if service_type:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM services WHERE service_type = ? ORDER BY name ASC",
|
||||
(service_type,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM services ORDER BY name ASC").fetchall()
|
||||
return [self._row_to_service(row) for row in rows]
|
||||
|
||||
def get_service(self, service_id: str) -> dict[str, Any] | None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM services WHERE id = ?", (service_id,)).fetchone()
|
||||
return self._row_to_service(row) if row else None
|
||||
|
||||
def _normalize_service_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
service_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current = self.get_service(service_id) if service_id else None
|
||||
service_id = str(payload.get("id") or service_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||
service_type = str(payload.get("service_type") or (current or {}).get("service_type", "")).strip()
|
||||
name = str(payload.get("name") or (current or {}).get("name", "") or "").strip()
|
||||
config = payload.get("config", (current or {}).get("config", {}))
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
return {
|
||||
"id": service_id,
|
||||
"service_type": service_type,
|
||||
"name": name,
|
||||
"config": config,
|
||||
"enabled": enabled,
|
||||
}
|
||||
|
||||
def upsert_service(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
secret_values: dict[str, str] | None = None,
|
||||
service_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Insert or update a service instance.
|
||||
|
||||
``secret_values`` carries plaintext secrets to encrypt and store. A key
|
||||
absent from ``secret_values`` preserves the existing ciphertext; a key
|
||||
mapped to an empty string clears it.
|
||||
"""
|
||||
self.init_schema()
|
||||
service = self._normalize_service_payload(payload, service_id)
|
||||
now = int(time.time())
|
||||
|
||||
existing = self.get_service(service["id"])
|
||||
secrets_blob: dict[str, str]
|
||||
if existing is not None:
|
||||
secrets_blob = dict(existing["secrets"])
|
||||
else:
|
||||
secrets_blob = {}
|
||||
if secret_values:
|
||||
from media_library_viewer_api.services.secrets import encrypt_value
|
||||
|
||||
for key, value in secret_values.items():
|
||||
if value == "":
|
||||
secrets_blob.pop(key, None)
|
||||
else:
|
||||
secrets_blob[key] = encrypt_value(value)
|
||||
|
||||
with self.connect() as conn:
|
||||
created_at = int(existing["created_at"]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO services (
|
||||
id, service_type, name, config_json, secrets_json,
|
||||
enabled, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
service_type = excluded.service_type,
|
||||
name = excluded.name,
|
||||
config_json = excluded.config_json,
|
||||
secrets_json = excluded.secrets_json,
|
||||
enabled = excluded.enabled,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
service["id"],
|
||||
service["service_type"],
|
||||
service["name"],
|
||||
json.dumps(service["config"]),
|
||||
json.dumps(secrets_blob),
|
||||
1 if service["enabled"] else 0,
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_service(service["id"]) or service
|
||||
|
||||
def delete_service(self, service_id: str) -> None:
|
||||
"""Delete a service and cascade-delete widgets referencing it."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
# The service_id column on dashboard_widgets is added in a later
|
||||
# slice; only cascade when it is present.
|
||||
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
|
||||
if "service_id" in widget_cols:
|
||||
conn.execute(
|
||||
"DELETE FROM dashboard_widgets WHERE service_id = ?",
|
||||
(service_id,),
|
||||
)
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
|
||||
|
||||
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Append a service task run history row."""
|
||||
self.init_schema()
|
||||
run_id = str(payload.get("id") or uuid.uuid4().hex[:12])
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO service_task_runs (
|
||||
id, task_id, service_id, status, exit_status, duration_ms,
|
||||
stdout_tail, stderr_tail, error, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
run_id,
|
||||
str(payload.get("task_id") or ""),
|
||||
str(payload.get("service_id") or ""),
|
||||
str(payload.get("status") or "error"),
|
||||
payload.get("exit_status"),
|
||||
payload.get("duration_ms"),
|
||||
str(payload.get("stdout_tail") or "")[:8000],
|
||||
str(payload.get("stderr_tail") or "")[:8000],
|
||||
str(payload.get("error") or "")[:1000],
|
||||
int(payload.get("created_at") or now),
|
||||
),
|
||||
)
|
||||
return {"id": run_id}
|
||||
|
||||
def list_service_task_runs(
|
||||
self,
|
||||
service_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if service_id:
|
||||
clauses.append("service_id = ?")
|
||||
params.append(service_id)
|
||||
if task_id:
|
||||
clauses.append("task_id = ?")
|
||||
params.append(task_id)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
params.append(int(limit))
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM service_task_runs {where} ORDER BY created_at DESC LIMIT ?",
|
||||
params,
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"task_id": row["task_id"],
|
||||
"service_id": row["service_id"],
|
||||
"status": row["status"],
|
||||
"exit_status": row["exit_status"],
|
||||
"duration_ms": row["duration_ms"],
|
||||
"stdout_tail": row["stdout_tail"],
|
||||
"stderr_tail": row["stderr_tail"],
|
||||
"error": row["error"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
_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}")
|
||||
@@ -0,0 +1,213 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
from typing import Any, Protocol
|
||||
|
||||
import requests
|
||||
from starlette.requests import Request
|
||||
|
||||
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
|
||||
|
||||
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})
|
||||
|
||||
|
||||
class WidgetSource(Protocol):
|
||||
"""Protocol for widget source adapters."""
|
||||
|
||||
source_type: str
|
||||
|
||||
async def fetch(self, 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}"}
|
||||
|
||||
|
||||
class BackupsWidgetSource:
|
||||
"""Compute the backup dashboard summary."""
|
||||
|
||||
source_type = "backups"
|
||||
timeout = 10
|
||||
|
||||
async def fetch(self, 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 GrafanaWidgetSource:
|
||||
"""Build a Grafana deep-link (no embedding)."""
|
||||
|
||||
source_type = "grafana"
|
||||
timeout = 5
|
||||
|
||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
settings = get_settings()
|
||||
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}"
|
||||
panel_id = config.get("panel_id")
|
||||
if panel_id is not None:
|
||||
url = f"{url}?viewPanel={panel_id}"
|
||||
return {"url": url}
|
||||
except Exception as exc:
|
||||
logger.exception("grafana adapter failed")
|
||||
return {"error": f"Grafana link failed: {exc}"}
|
||||
|
||||
|
||||
class PrometheusWidgetSource:
|
||||
"""Run a PromQL instant query against Prometheus."""
|
||||
|
||||
source_type = "prometheus"
|
||||
timeout = 10
|
||||
|
||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
settings = get_settings()
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
url = f"{settings.prometheus_url.rstrip('/')}/api/v1/query"
|
||||
response = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
requests.get,
|
||||
url,
|
||||
params={"query": promql},
|
||||
timeout=self.timeout,
|
||||
),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {"result": payload.get("data", {})}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Widget data fetch timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
except Exception as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
|
||||
|
||||
class SshTaskWidgetSource:
|
||||
"""Run a saved task from the registry and return its output."""
|
||||
|
||||
source_type = "ssh_task"
|
||||
timeout = 30
|
||||
|
||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_settings_store()
|
||||
task_id = config.get("task_id")
|
||||
if not task_id:
|
||||
return {"error": "task_id is required"}
|
||||
task = store.get_task(task_id)
|
||||
if not task:
|
||||
return {"error": f"Task {task_id} not found"}
|
||||
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)
|
||||
task_type = str(task.get("task_type") or "shell").lower()
|
||||
command = str(task.get("content") or "")
|
||||
if task_type == "python":
|
||||
command = f"python3 -c {shlex.quote(command)}"
|
||||
elif task_type != "shell":
|
||||
return {"error": f"Unknown task type: {task_type}"}
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.to_thread(client.run, command, timeout=self.timeout),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return {
|
||||
"exit_status": result.exit_status,
|
||||
"stdout": result.stdout or "",
|
||||
"stderr": result.stderr or "",
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Widget data fetch timed out"}
|
||||
except Exception as exc:
|
||||
logger.exception("ssh_task adapter failed")
|
||||
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", "")}
|
||||
|
||||
|
||||
SOURCE_REGISTRY: dict[str, WidgetSource] = {
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
"backups": BackupsWidgetSource(),
|
||||
"grafana": GrafanaWidgetSource(),
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"ssh_task": SshTaskWidgetSource(),
|
||||
"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)
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Tests for the service registry: definitions, encryption, CRUD, cascade delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
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.integrations.registry import (
|
||||
SERVICE_DEFINITIONS,
|
||||
get_service_definition,
|
||||
get_widget_kind,
|
||||
)
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.services.secrets import (
|
||||
EncryptionKeyError,
|
||||
decrypt_secrets,
|
||||
decrypt_value,
|
||||
encrypt_secrets,
|
||||
encrypt_value,
|
||||
get_encryption_key,
|
||||
reset_encryption_key_cache,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
TEST_KEY = Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _encryption_key(monkeypatch):
|
||||
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
|
||||
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
|
||||
reset_encryption_key_cache()
|
||||
yield
|
||||
reset_encryption_key_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
"""FastAPI test client with a fresh settings store and auth disabled."""
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
app.dependency_overrides[get_settings_store] = lambda: store
|
||||
auth_settings = SimpleNamespace(auth_enabled=False)
|
||||
with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_contains_five_service_types():
|
||||
assert set(SERVICE_DEFINITIONS) == {
|
||||
"grafana",
|
||||
"prometheus",
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"ssh_tasks",
|
||||
}
|
||||
|
||||
|
||||
def test_definitions_declare_widget_kinds():
|
||||
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
|
||||
assert get_service_definition("nextcloud").widget_kinds == []
|
||||
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
|
||||
|
||||
|
||||
def test_widget_kind_lookup():
|
||||
assert get_widget_kind("grafana", "link") is not None
|
||||
assert get_widget_kind("grafana", "missing") is None
|
||||
assert get_widget_kind("unknown", "link") is None
|
||||
|
||||
|
||||
def test_service_config_schema_is_json_schema():
|
||||
schema = get_service_definition("grafana").config_schema
|
||||
assert schema["type"] == "object"
|
||||
assert "base_url" in schema["properties"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Encryption
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_encrypt_decrypt_round_trip():
|
||||
cipher = encrypt_value("hunter2")
|
||||
assert cipher != "hunter2"
|
||||
assert decrypt_value(cipher) == "hunter2"
|
||||
|
||||
|
||||
def test_encrypt_decrypt_secrets_dict():
|
||||
blob = encrypt_secrets({"api_key": "abc", "token": "xyz"})
|
||||
assert decrypt_secrets(blob) == {"api_key": "abc", "token": "xyz"}
|
||||
|
||||
|
||||
def test_missing_encryption_key_raises(monkeypatch):
|
||||
monkeypatch.delenv("MANAGE_ENCRYPTION_KEY", raising=False)
|
||||
reset_encryption_key_cache()
|
||||
with pytest.raises(EncryptionKeyError):
|
||||
get_encryption_key()
|
||||
reset_encryption_key_cache()
|
||||
|
||||
|
||||
def test_decrypt_with_wrong_key_raises(monkeypatch):
|
||||
blob = encrypt_secrets({"api_key": "abc"})
|
||||
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
reset_encryption_key_cache()
|
||||
with pytest.raises(EncryptionKeyError):
|
||||
decrypt_secrets(blob)
|
||||
reset_encryption_key_cache()
|
||||
|
||||
|
||||
def test_invalid_ciphertext_raises():
|
||||
with pytest.raises(EncryptionKeyError):
|
||||
decrypt_value("not-a-real-token")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service type metadata endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_service_types(client):
|
||||
response = client.get("/api/services/types")
|
||||
assert response.status_code == 200
|
||||
types = {item["service_type"] for item in response.json()}
|
||||
assert types == {"grafana", "prometheus", "jellyfin", "nextcloud", "ssh_tasks"}
|
||||
|
||||
|
||||
def test_service_type_includes_secret_and_widget_metadata(client):
|
||||
response = client.get("/api/services/types")
|
||||
grafana = next(item for item in response.json() if item["service_type"] == "grafana")
|
||||
assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
|
||||
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _grafana_payload(**overrides):
|
||||
payload = {
|
||||
"service_type": "grafana",
|
||||
"name": "Production Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"secrets": {"api_key": "secret-token"},
|
||||
"enabled": True,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_create_and_list_service(client):
|
||||
response = client.post("/api/services/instances", json=_grafana_payload())
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_type"] == "grafana"
|
||||
assert created["config"]["base_url"] == "https://grafana.example.com"
|
||||
# Plaintext secrets are never returned.
|
||||
assert "secrets" not in created
|
||||
assert created["secrets_set"] == {"api_key": True}
|
||||
|
||||
response = client.get("/api/services/instances")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
|
||||
|
||||
def test_list_instances_filters_by_type(client):
|
||||
client.post("/api/services/instances", json=_grafana_payload())
|
||||
client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "prometheus",
|
||||
"name": "Prom",
|
||||
"config": {"base_url": "http://prometheus:9090"},
|
||||
},
|
||||
)
|
||||
response = client.get("/api/services/instances?service_type=grafana")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["service_type"] == "grafana"
|
||||
|
||||
|
||||
def test_update_service_preserves_unsent_secrets(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
# Update without sending secrets; the existing key should remain set.
|
||||
updated = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "Renamed Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com", "timeout_seconds": 10},
|
||||
},
|
||||
).json()
|
||||
assert updated["name"] == "Renamed Grafana"
|
||||
assert updated["secrets_set"] == {"api_key": True}
|
||||
|
||||
|
||||
def test_update_service_can_clear_secret(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
updated = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "Production Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"secrets": {"api_key": ""},
|
||||
},
|
||||
).json()
|
||||
assert updated["secrets_set"] == {"api_key": False}
|
||||
|
||||
|
||||
def test_unknown_service_type_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "bogus", "name": "x", "config": {}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_invalid_config_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
|
||||
)
|
||||
# Pydantic accepts empty string; force a real validation error via bad type.
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_unknown_secret_field_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "x",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"secrets": {"password": "leak"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_credential_key_in_config_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "x",
|
||||
"config": {"base_url": "https://grafana.example.com", "api_key": "leak"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_update_nonexistent_returns_404(client):
|
||||
response = client.put(
|
||||
"/api/services/instances/missing",
|
||||
json=_grafana_payload(id="missing"),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_update_id_mismatch_returns_400(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
response = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json=_grafana_payload(id="other-id"),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_delete_service(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
response = client.delete(f"/api/services/instances/{created['id']}")
|
||||
assert response.status_code == 200
|
||||
assert client.get("/api/services/instances").json() == []
|
||||
|
||||
|
||||
def test_delete_nonexistent_returns_404(client):
|
||||
assert client.delete("/api/services/instances/missing").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cascade delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
"""Once widgets carry service_id (Slice 2), deleting a service removes them.
|
||||
|
||||
This test seeds a widget row directly with the column present to prove the
|
||||
cascade path; the column is added defensively here so the test is meaningful
|
||||
even before Slice 2 lands.
|
||||
"""
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
service = store.upsert_service(
|
||||
{"service_type": "grafana", "name": "Grafana", "config": {"base_url": "u"}, "enabled": True}
|
||||
)
|
||||
|
||||
# Ensure the service_id column exists and seed a referencing widget.
|
||||
with store.connect() as conn:
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
|
||||
if "service_id" not in cols:
|
||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO dashboard_widgets (id, addon_id, widget_type, title, config_json,
|
||||
enabled, sort_order, created_at, updated_at, service_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
("w1", "grafana", "grafana.link", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||
)
|
||||
|
||||
store.delete_service(service["id"])
|
||||
assert store.get_service(service["id"]) is None
|
||||
with store.connect() as conn:
|
||||
remaining = conn.execute(
|
||||
"SELECT COUNT(*) FROM dashboard_widgets WHERE service_id = ?",
|
||||
(service["id"],),
|
||||
).fetchone()
|
||||
assert int(remaining[0]) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service task run history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_record_and_list_service_task_runs(client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
service = store.upsert_service(
|
||||
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h"}, "enabled": True}
|
||||
)
|
||||
store.record_service_task_run(
|
||||
{
|
||||
"task_id": "t1",
|
||||
"service_id": service["id"],
|
||||
"status": "success",
|
||||
"exit_status": 0,
|
||||
"stdout_tail": "ok",
|
||||
}
|
||||
)
|
||||
runs = store.list_service_task_runs(service_id=service["id"])
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["status"] == "success"
|
||||
assert runs[0]["stdout_tail"] == "ok"
|
||||
@@ -0,0 +1,475 @@
|
||||
"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding, adapters."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
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,
|
||||
GrafanaWidgetSource,
|
||||
SshTaskWidgetSource,
|
||||
StaticWidgetSource,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
"""FastAPI test client with a fresh settings store and auth disabled."""
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
app.dependency_overrides[get_settings_store] = lambda: store
|
||||
auth_settings = SimpleNamespace(auth_enabled=False)
|
||||
with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_widget_sources(client):
|
||||
response = client.get("/api/widgets/sources")
|
||||
assert response.status_code == 200
|
||||
assert set(response.json()) == {
|
||||
"jellyfin",
|
||||
"backups",
|
||||
"grafana",
|
||||
"prometheus",
|
||||
"ssh_task",
|
||||
"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):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "core",
|
||||
"widget_type": "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"]
|
||||
|
||||
response = client.get("/api/widgets/instances")
|
||||
assert response.status_code == 200
|
||||
assert any(w["id"] == widget_id for w in response.json())
|
||||
|
||||
|
||||
def test_update_widget(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "core",
|
||||
"widget_type": "static",
|
||||
"title": "Note",
|
||||
"config": {"text": "hello"},
|
||||
},
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def test_delete_widget(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",
|
||||
"config": {},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_addon_id_mismatch_rejected(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "grafana",
|
||||
"widget_type": "static",
|
||||
"title": "Bad",
|
||||
"config": {"text": "x"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_credential_key_rejected(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "core",
|
||||
"widget_type": "static",
|
||||
"title": "Bad",
|
||||
"config": {"api_key": "secret123"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_update_nonexistent_widget(client):
|
||||
response = client.put(
|
||||
"/api/widgets/instances/does-not-exist",
|
||||
json={
|
||||
"addon_id": "core",
|
||||
"widget_type": "static",
|
||||
"title": "Bad",
|
||||
"config": {"text": "x"},
|
||||
},
|
||||
)
|
||||
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(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "core",
|
||||
"widget_type": "static",
|
||||
"title": "Note",
|
||||
"config": {"text": "hello"},
|
||||
},
|
||||
)
|
||||
widget_id = response.json()["id"]
|
||||
|
||||
response = client.put(
|
||||
f"/api/widgets/instances/{widget_id}",
|
||||
json={
|
||||
"id": "different-id",
|
||||
"addon_id": "core",
|
||||
"widget_type": "static",
|
||||
"title": "Updated",
|
||||
"config": {"text": "world"},
|
||||
},
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def test_fetch_static_widget_data(client):
|
||||
response = 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")
|
||||
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)
|
||||
|
||||
|
||||
def test_fetch_grafana_widget_data(client):
|
||||
response = 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")
|
||||
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"
|
||||
|
||||
|
||||
def test_fetch_prometheus_widget_data(client):
|
||||
response = 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'},
|
||||
},
|
||||
)
|
||||
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")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["widget_type"] == "prometheus-metric"
|
||||
assert data["data"]["result"]["resultType"] == "scalar"
|
||||
|
||||
|
||||
def test_fetch_jellyfin_widget_data_error(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "core",
|
||||
"widget_type": "jellyfin",
|
||||
"title": "Activity",
|
||||
"config": {"machine_id": ""},
|
||||
},
|
||||
)
|
||||
widget_id = response.json()["id"]
|
||||
|
||||
response = client.get(f"/api/widgets/instances/{widget_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()
|
||||
|
||||
|
||||
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_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"]
|
||||
|
||||
class _ExplodingAdapter:
|
||||
source_type = "static"
|
||||
|
||||
async def fetch(self, config):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
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_static_adapter():
|
||||
adapter = StaticWidgetSource()
|
||||
result = await adapter.fetch({"text": "hello"})
|
||||
assert result == {"text": "hello"}
|
||||
|
||||
|
||||
@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"
|
||||
|
||||
|
||||
@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"],
|
||||
}
|
||||
)
|
||||
|
||||
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"]})
|
||||
|
||||
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",
|
||||
}
|
||||
@@ -17,6 +17,9 @@ services:
|
||||
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
|
||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
||||
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
||||
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
|
||||
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
|
||||
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?set MANAGE_ENCRYPTION_KEY in your .env}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
@@ -38,6 +41,7 @@ services:
|
||||
VITE_OIDC_ENABLED: "false"
|
||||
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
|
||||
VITE_GRAFANA_URL: "http://localhost:3000"
|
||||
VITE_PROMETHEUS_URL: "http://localhost:9090"
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
|
||||
@@ -28,6 +28,9 @@ services:
|
||||
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
|
||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
||||
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
||||
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
|
||||
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
|
||||
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?generate one with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"}
|
||||
volumes:
|
||||
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
|
||||
restart: unless-stopped
|
||||
@@ -70,6 +73,7 @@ services:
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI:?set VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
|
||||
VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000}
|
||||
VITE_GRAFANA_URL: ${VITE_GRAFANA_URL:-https://grafana.example.com}
|
||||
VITE_PROMETHEUS_URL: ${VITE_PROMETHEUS_URL:-http://localhost:9090}
|
||||
VITE_APP_VERSION: ${APP_VERSION:-0.1.0}
|
||||
VITE_APP_BUILD_INFO: ${APP_BUILD_INFO:-dev}
|
||||
depends_on:
|
||||
|
||||
@@ -256,6 +256,54 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
|
||||
- Job templates should remain centralized in `jobs.py` for future extension.
|
||||
- Remote job template values must be shell-quoted before execution.
|
||||
|
||||
## Configurable Dashboard Widgets
|
||||
|
||||
### Overview
|
||||
|
||||
The dashboard is composed of persisted widget instances stored in the backend SQLite
|
||||
settings database. Each widget has a type, title, configuration, enabled flag, and
|
||||
sort order. The frontend renders enabled widgets in sort order and fetches data
|
||||
independently through the backend source adapters.
|
||||
|
||||
### Widget types
|
||||
|
||||
- **Jellyfin activity** — live sessions and idle users from a configured Jellyfin machine.
|
||||
- **Backups** — backup job summary and active alerts.
|
||||
- **Grafana link** — deep-link to a Grafana dashboard or panel (no iframe embedding).
|
||||
- **Prometheus metric** — result of a PromQL instant query.
|
||||
- **SSH task output** — output of a saved task run on a machine.
|
||||
- **Static text** — plain text or markdown note.
|
||||
|
||||
### Security
|
||||
|
||||
- Widget `config` may not contain credential keys such as `password`, `token`,
|
||||
`secret`, `api_key`, `private_key`, or `passphrase`, or values that look like
|
||||
secrets (e.g., base64 blobs, `sk-` prefixes).
|
||||
- Widgets reuse machine-level Jellyfin/SSH credentials and environment settings for
|
||||
Grafana/Prometheus URLs; no secrets are stored in widget configuration.
|
||||
- SSH task widgets only run tasks from the saved-task registry; arbitrary commands
|
||||
are not accepted.
|
||||
|
||||
### Addon pages
|
||||
|
||||
Each non-core addon gets a dedicated page at `/addons/:addonId`:
|
||||
|
||||
- `/addons/grafana`
|
||||
- `/addons/prometheus`
|
||||
- `/addons/ssh-tasks`
|
||||
|
||||
Unknown addons render a "not installed" alert.
|
||||
|
||||
### API
|
||||
|
||||
- `GET /api/widgets/sources` — list source types.
|
||||
- `GET /api/widgets/types` — list widget type metadata.
|
||||
- `GET /api/widgets/instances` — list widget instances.
|
||||
- `POST /api/widgets/instances` — create instance.
|
||||
- `PUT /api/widgets/instances/{id}` — update instance.
|
||||
- `DELETE /api/widgets/instances/{id}` — delete instance.
|
||||
- `GET /api/widgets/instances/{id}/data` — fetch widget data.
|
||||
|
||||
## Decision Log
|
||||
|
||||
- 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template.
|
||||
|
||||
@@ -16,6 +16,7 @@ ARG VITE_OIDC_REDIRECT_URI=
|
||||
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
|
||||
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||
ARG VITE_GRAFANA_URL=https://grafana.example.com
|
||||
ARG VITE_PROMETHEUS_URL=http://localhost:9090
|
||||
ARG VITE_APP_VERSION=0.1.0
|
||||
ARG VITE_APP_BUILD_INFO=dev
|
||||
|
||||
@@ -28,6 +29,7 @@ ENV VITE_API_URL=${VITE_API_URL} \
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} \
|
||||
VITE_DEV_API_PROXY_TARGET=${VITE_DEV_API_PROXY_TARGET} \
|
||||
VITE_GRAFANA_URL=${VITE_GRAFANA_URL} \
|
||||
VITE_PROMETHEUS_URL=${VITE_PROMETHEUS_URL} \
|
||||
VITE_APP_VERSION=${VITE_APP_VERSION} \
|
||||
VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO}
|
||||
|
||||
@@ -53,6 +55,7 @@ ENV VITE_API_URL=/api \
|
||||
VITE_OIDC_ENABLED=false \
|
||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000 \
|
||||
VITE_GRAFANA_URL=http://localhost:3000 \
|
||||
VITE_PROMETHEUS_URL=http://localhost:9090 \
|
||||
VITE_APP_VERSION=0.1.0 \
|
||||
VITE_APP_BUILD_INFO=dev
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { FileBrowser } from "./pages/FileBrowser";
|
||||
import { Actions } from "./pages/Actions";
|
||||
import BackupsPage from "./components/BackupsPage";
|
||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||
import { AddonPage } from "./pages/AddonPage";
|
||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||
import { fetchAppVersion } from "./api/client";
|
||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||
@@ -449,6 +450,7 @@ function AppInner() {
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
@@ -480,6 +482,7 @@ function AppInner() {
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function GrafanaAddonPage() {
|
||||
const grafanaUrl =
|
||||
(import.meta.env.VITE_GRAFANA_URL as string | undefined) ||
|
||||
"http://localhost:3000";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">Grafana</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics & logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open the full Grafana instance for dashboards, metrics, and log
|
||||
exploration.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a
|
||||
href={grafanaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
Open Grafana
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function PrometheusAddonPage() {
|
||||
const prometheusUrl =
|
||||
(import.meta.env.VITE_PROMETHEUS_URL as string | undefined) ||
|
||||
"http://localhost:9090";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">Prometheus</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics explorer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open Prometheus to run ad-hoc PromQL queries and inspect targets.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a
|
||||
href={prometheusUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
Open Prometheus
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Terminal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function SshTasksAddonPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">SSH tasks</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Saved actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create, edit, and run saved shell or Python tasks against local or
|
||||
remote machines.
|
||||
</p>
|
||||
<Button onClick={() => navigate("/actions")}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
Open Actions
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { GrafanaAddonPage } from "./GrafanaAddonPage";
|
||||
export { PrometheusAddonPage } from "./PrometheusAddonPage";
|
||||
export { SshTasksAddonPage } from "./SshTasksAddonPage";
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
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");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
|
||||
const res = await fetch(`${API_BASE}/widgets/instances`);
|
||||
if (!res.ok) throw new Error("Failed to fetch widget instances");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createWidgetInstance(
|
||||
input: WidgetInstanceInput,
|
||||
): Promise<WidgetInstance> {
|
||||
const res = await fetch(`${API_BASE}/widgets/instances`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create widget instance");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateWidgetInstance(
|
||||
input: WidgetInstanceInput,
|
||||
): Promise<WidgetInstance> {
|
||||
if (!input.id) throw new Error("Widget ID is required for update");
|
||||
const res = await fetch(`${API_BASE}/widgets/instances/${input.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update widget instance");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteWidgetInstance(
|
||||
widgetId: string,
|
||||
): Promise<{ status: string }> {
|
||||
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete widget instance");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchWidgetData(
|
||||
widgetId: string,
|
||||
): Promise<WidgetDataResponse> {
|
||||
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}/data`);
|
||||
if (!res.ok) throw new Error("Failed to fetch widget data");
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
useDeleteWidgetInstance,
|
||||
useSaveWidgetInstance,
|
||||
useWidgetInstances,
|
||||
useWidgetTypes,
|
||||
} from "../hooks/useWidgets";
|
||||
import { useMonitoringSettings, useTasks } from "../hooks/useSettings";
|
||||
import type {
|
||||
MonitoringMachine,
|
||||
SavedTask,
|
||||
WidgetInstance,
|
||||
WidgetInstanceInput,
|
||||
} from "../types";
|
||||
import {
|
||||
getWidgetDefinition,
|
||||
listWidgetTypes,
|
||||
type WidgetDefinition,
|
||||
} from "../widgets/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,
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetConfigFields({
|
||||
definition,
|
||||
config,
|
||||
onChange,
|
||||
machines,
|
||||
tasks,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
config: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
machines: MonitoringMachine[];
|
||||
tasks: SavedTask[];
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
htmlFor={field.key}
|
||||
helper={field.helper}
|
||||
>
|
||||
<Input
|
||||
id={field.key}
|
||||
value={String(value)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
[field.key]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
const { data: instances = [] } = useWidgetInstances();
|
||||
const { data: types = [] } = useWidgetTypes();
|
||||
const { data: machines = [] } = useMonitoringSettings();
|
||||
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 sortedInstances = useMemo(
|
||||
() =>
|
||||
[...instances].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||
),
|
||||
[instances],
|
||||
);
|
||||
|
||||
function startAdd(widgetType: string) {
|
||||
setDraft(emptyDraft(widgetType));
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
function startEdit(instance: WidgetInstance) {
|
||||
setDraft({
|
||||
id: instance.id,
|
||||
addon_id: instance.addon_id,
|
||||
widget_type: instance.widget_type,
|
||||
title: instance.title,
|
||||
config: instance.config,
|
||||
enabled: instance.enabled,
|
||||
sort_order: instance.sort_order,
|
||||
});
|
||||
setEditingId(instance.id);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setDraft(null);
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!draft) return;
|
||||
await saveWidget.mutateAsync(draft);
|
||||
reset();
|
||||
}
|
||||
|
||||
async function toggleEnabled(instance: WidgetInstance) {
|
||||
await saveWidget.mutateAsync({
|
||||
...instance,
|
||||
enabled: !instance.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
async function moveInstance(index: number, direction: -1 | 1) {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
|
||||
const a = sortedInstances[index];
|
||||
const b = sortedInstances[targetIndex];
|
||||
await Promise.all([
|
||||
saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }),
|
||||
saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function removeInstance(instance: WidgetInstance) {
|
||||
await deleteWidget.mutateAsync(instance.id);
|
||||
}
|
||||
|
||||
function handleClose(next: boolean) {
|
||||
if (!next) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
const definition = draft ? getWidgetDefinition(draft.widget_type) : undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{draft
|
||||
? editingId
|
||||
? "Edit widget"
|
||||
: "Add widget"
|
||||
: "Dashboard widgets"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{draft && definition ? (
|
||||
<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
|
||||
id="widget-title"
|
||||
value={draft.title}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, title: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Sort order" htmlFor="widget-sort-order">
|
||||
<Input
|
||||
id="widget-sort-order"
|
||||
type="number"
|
||||
value={String(draft.sort_order)}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
sort_order:
|
||||
e.target.value === "" ? 0 : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="widget-enabled"
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft({ ...draft, enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="widget-enabled">Enabled</Label>
|
||||
</div>
|
||||
<WidgetConfigFields
|
||||
definition={definition}
|
||||
config={draft.config}
|
||||
onChange={(config) => setDraft({ ...draft, config })}
|
||||
machines={machines}
|
||||
tasks={tasks}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={reset}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
|
||||
Save widget
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{sortedInstances.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No widgets yet. Add one below.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortedInstances.map((instance, index) => {
|
||||
const typeDef = getWidgetDefinition(instance.widget_type);
|
||||
return (
|
||||
<div
|
||||
key={instance.id}
|
||||
className="flex items-center gap-2 rounded border p-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{instance.title}</span>
|
||||
<Badge variant="outline">
|
||||
{typeDef?.name ?? instance.widget_type}
|
||||
</Badge>
|
||||
{!instance.enabled ? (
|
||||
<Badge variant="secondary">disabled</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveInstance(index, -1)}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={index === sortedInstances.length - 1}
|
||||
onClick={() => moveInstance(index, 1)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={instance.enabled}
|
||||
onCheckedChange={() => toggleEnabled(instance)}
|
||||
aria-label={`Toggle ${instance.title}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => startEdit(instance)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => removeInstance(instance)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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) => (
|
||||
<Button
|
||||
key={def.widgetType}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => startAdd(def.widgetType)}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
{def.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{types.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Widget registry is empty. Backend may not be running.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { getWidgetDefinition } from "../widgets/registry";
|
||||
import type { WidgetInstance } from "../types";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
}
|
||||
|
||||
export function WidgetInstance({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
if (!def) {
|
||||
return (
|
||||
<SectionCard title={widget.title}>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Unknown widget type: {widget.widget_type}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = def.component;
|
||||
return <Component widget={widget} />;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createWidgetInstance,
|
||||
deleteWidgetInstance,
|
||||
fetchWidgetData,
|
||||
fetchWidgetInstances,
|
||||
fetchWidgetSources,
|
||||
fetchWidgetTypes,
|
||||
updateWidgetInstance,
|
||||
} from "../api/widgets";
|
||||
import type { WidgetInstanceInput } from "../types";
|
||||
|
||||
export function useWidgetInstances() {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "instances"],
|
||||
queryFn: fetchWidgetInstances,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWidgetData(widgetId: string, refreshInterval: number) {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "data", widgetId],
|
||||
queryFn: () => fetchWidgetData(widgetId),
|
||||
refetchInterval: refreshInterval || false,
|
||||
enabled: !!widgetId,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveWidgetInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: WidgetInstanceInput) =>
|
||||
input.id ? updateWidgetInstance(input) : createWidgetInstance(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWidgetInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (widgetId: string) => deleteWidgetInstance(widgetId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useWidgetSources() {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "sources"],
|
||||
queryFn: fetchWidgetSources,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWidgetTypes() {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "types"],
|
||||
queryFn: fetchWidgetTypes,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
GrafanaAddonPage,
|
||||
PrometheusAddonPage,
|
||||
SshTasksAddonPage,
|
||||
} from "../addons";
|
||||
|
||||
const ADDON_PAGES: Record<string, React.ComponentType> = {
|
||||
grafana: GrafanaAddonPage,
|
||||
prometheus: PrometheusAddonPage,
|
||||
"ssh-tasks": SshTasksAddonPage,
|
||||
};
|
||||
|
||||
export function AddonPage() {
|
||||
const { addonId } = useParams<{ addonId: string }>();
|
||||
const Page = addonId ? ADDON_PAGES[addonId] : undefined;
|
||||
|
||||
if (!Page) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>Addon "{addonId}" is not installed.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return <Page />;
|
||||
}
|
||||
@@ -21,18 +21,17 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useActivity,
|
||||
useDashboardShortcuts,
|
||||
useDeleteDashboardShortcut,
|
||||
useSaveDashboardShortcut,
|
||||
} from "../hooks/useDashboard";
|
||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
||||
import { NowPlaying } from "../components/NowPlaying";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
||||
import { WidgetInstance } from "../components/WidgetInstance";
|
||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||
|
||||
function emptyShortcut(): DashboardShortcutInput {
|
||||
return {
|
||||
@@ -327,19 +326,6 @@ function ShortcutCard({
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { data: machines = [] } = useMonitoringSettings();
|
||||
const jellyfinMachines = useMemo(
|
||||
() =>
|
||||
machines.filter(
|
||||
(machine) => machine.enabled && machine.services.includes("jellyfin"),
|
||||
),
|
||||
[machines],
|
||||
);
|
||||
const [activeJellyfinMachineId, setActiveJellyfinMachineId] =
|
||||
useState<string>("");
|
||||
const selectedJellyfinId =
|
||||
activeJellyfinMachineId || jellyfinMachines[0]?.id || "";
|
||||
const { data: activity } = useActivity(selectedJellyfinId || undefined);
|
||||
const { data: shortcuts = [] } = useDashboardShortcuts();
|
||||
const saveShortcut = useSaveDashboardShortcut();
|
||||
const deleteShortcut = useDeleteDashboardShortcut();
|
||||
@@ -348,6 +334,16 @@ export function Dashboard() {
|
||||
emptyShortcut(),
|
||||
);
|
||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||
const { data: widgetInstances = [] } = useWidgetInstances();
|
||||
|
||||
const visibleWidgets = useMemo(
|
||||
() =>
|
||||
widgetInstances
|
||||
.filter((w) => w.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order),
|
||||
[widgetInstances],
|
||||
);
|
||||
|
||||
const openCreateShortcut = () => {
|
||||
setShortcutDraft(emptyShortcut());
|
||||
@@ -382,9 +378,14 @@ export function Dashboard() {
|
||||
title="Shortcuts"
|
||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||
action={
|
||||
<Button variant="outline" onClick={openCreateShortcut}>
|
||||
Add shortcut
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
|
||||
Edit dashboard
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openCreateShortcut}>
|
||||
Add shortcut
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{shortcuts.length ? (
|
||||
@@ -416,42 +417,9 @@ export function Dashboard() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Jellyfin activity"
|
||||
description="Live sessions and idle users from Jellyfin."
|
||||
action={
|
||||
jellyfinMachines.length > 1 ? (
|
||||
<Select
|
||||
value={selectedJellyfinId}
|
||||
onValueChange={(value) => setActiveJellyfinMachineId(value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{jellyfinMachines.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : jellyfinMachines.length === 1 ? (
|
||||
<Badge variant="outline">{jellyfinMachines[0].name}</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{activity ? (
|
||||
<NowPlaying
|
||||
sessions={activity}
|
||||
onSelectSession={(session) =>
|
||||
navigate(`/users?user=${encodeURIComponent(session.user)}`)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
|
||||
<BackupDashboardWidget />
|
||||
{visibleWidgets.map((widget) => (
|
||||
<WidgetInstance key={widget.id} widget={widget} />
|
||||
))}
|
||||
|
||||
<ShortcutDialog
|
||||
open={shortcutDialogOpen}
|
||||
@@ -473,6 +441,10 @@ export function Dashboard() {
|
||||
setDeleteShortcutId(null);
|
||||
}}
|
||||
/>
|
||||
<WidgetConfigDialog
|
||||
open={widgetDialogOpen}
|
||||
onClose={() => setWidgetDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,3 +443,42 @@ export interface PrometheusTarget {
|
||||
labels: Record<string, string>;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
addon_id: string;
|
||||
widget_type: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface WidgetInstanceInput {
|
||||
id?: string | null;
|
||||
addon_id: string;
|
||||
widget_type: 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
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;
|
||||
}
|
||||
|
||||
export function BackupsWidget({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data, isLoading } = useWidgetData(
|
||||
widget.id,
|
||||
def?.refreshInterval ?? 0,
|
||||
);
|
||||
const summary = data?.data as BackupDashboardSummary | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex flex-row flex-wrap gap-6">
|
||||
<Skeleton className="h-10 w-20" />
|
||||
<Skeleton className="h-10 w-20" />
|
||||
<Skeleton className="h-10 w-20" />
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : summary ? (
|
||||
<div className="flex flex-row flex-wrap gap-6">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold">{summary.total_jobs}</div>
|
||||
<div className="text-xs text-muted-foreground">Jobs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold">
|
||||
{summary.success_rate_24h}%
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">24h Success</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold">
|
||||
{summary.active_alerts > 0 ? (
|
||||
<Badge variant="destructive">{summary.active_alerts}</Badge>
|
||||
) : (
|
||||
0
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Alerts</div>
|
||||
</div>
|
||||
{summary.last_failed_at ? (
|
||||
<div className="self-center text-xs text-destructive">
|
||||
Last failed:{" "}
|
||||
{new Date(summary.last_failed_at * 1000).toLocaleString()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
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;
|
||||
}
|
||||
|
||||
export function GrafanaLinkWidget({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data, isLoading } = useWidgetData(
|
||||
widget.id,
|
||||
def?.refreshInterval ?? 0,
|
||||
);
|
||||
const url = data?.data?.url as string | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-10 w-48" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : url ? (
|
||||
<Button asChild>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
Open Grafana
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No Grafana URL configured.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
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;
|
||||
}
|
||||
|
||||
export function JellyfinWidget({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data, isLoading } = useWidgetData(
|
||||
widget.id,
|
||||
def?.refreshInterval ?? 0,
|
||||
);
|
||||
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : Array.isArray(sessions) ? (
|
||||
<SessionActivityPanel
|
||||
sessions={sessions}
|
||||
emptyMessage="No recent user activity sessions right now."
|
||||
/>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
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;
|
||||
}
|
||||
|
||||
type PromQLResult = {
|
||||
resultType?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
type PromQLVectorSample = {
|
||||
metric?: Record<string, string>;
|
||||
value?: [number, string];
|
||||
};
|
||||
|
||||
function formatPrometheusValue(result: PromQLResult | undefined): string {
|
||||
if (!result) return "No data";
|
||||
if (result.resultType === "scalar" && Array.isArray(result.result)) {
|
||||
return String(result.result[1] ?? "No data");
|
||||
}
|
||||
if (
|
||||
result.resultType === "vector" &&
|
||||
Array.isArray(result.result) &&
|
||||
result.result.length > 0
|
||||
) {
|
||||
const first = result.result[0] as PromQLVectorSample;
|
||||
if (first.value) return String(first.value[1]);
|
||||
}
|
||||
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,
|
||||
);
|
||||
const result = data?.data?.result as PromQLResult | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-10 w-32" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm">
|
||||
{formatPrometheusValue(result)}
|
||||
</pre>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
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;
|
||||
}
|
||||
|
||||
type SshTaskResult = {
|
||||
exit_status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export function SshTaskWidget({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data, isLoading } = useWidgetData(
|
||||
widget.id,
|
||||
def?.refreshInterval ?? 0,
|
||||
);
|
||||
const result = data?.data as SshTaskResult | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : result ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Exit status:{" "}
|
||||
<span
|
||||
className={
|
||||
result.exit_status === 0 ? "text-green-600" : "text-destructive"
|
||||
}
|
||||
>
|
||||
{result.exit_status}
|
||||
</span>
|
||||
</div>
|
||||
{result.stdout ? (
|
||||
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
|
||||
{result.stdout}
|
||||
</pre>
|
||||
) : null}
|
||||
{result.stderr ? (
|
||||
<pre className="max-h-64 overflow-auto rounded bg-destructive/10 p-2 text-xs text-destructive">
|
||||
{result.stderr}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
import { getWidgetDefinition } from "./registry";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
}
|
||||
|
||||
export function StaticWidget({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
|
||||
const text = data?.data?.text as string | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
{text ? (
|
||||
<p className="whitespace-pre-wrap text-sm">{text}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No content configured.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { BackupsWidget } from "./BackupsWidget";
|
||||
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||
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";
|
||||
@@ -0,0 +1,42 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# Apply Progress: Configurable Dashboard Widgets
|
||||
|
||||
**Change:** `configurable-dashboard-widgets`
|
||||
**Apply run:** PR 1 / Slice 1 — Backend CRUD and default seeding
|
||||
**Date:** 2026-06-19
|
||||
|
||||
## Completed tasks (Slice 1)
|
||||
|
||||
All Slice 1 tasks are marked `- [x]` in `tasks.md`:
|
||||
|
||||
- [x] 1.1 Create widget Pydantic models
|
||||
- [x] 1.2 Create backend widget registry
|
||||
- [x] 1.3 Implement widgets router (CRUD + metadata)
|
||||
- [x] 1.4 Extend `SettingsStore` for `dashboard_widgets`
|
||||
- [x] 1.5 Register widgets router in `main.py`
|
||||
- [x] 1.6 Add backend tests for registry, CRUD, and seeding
|
||||
- [x] 1.7 Verify backend slice
|
||||
|
||||
## Files changed
|
||||
|
||||
### New files
|
||||
|
||||
- `backend/src/media_library_viewer_api/models/widgets.py` — Pydantic models: `WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse`, plus credential-key/secret-value validators.
|
||||
- `backend/src/media_library_viewer_api/widgets/__init__.py` — Package marker.
|
||||
- `backend/src/media_library_viewer_api/widgets/registry.py` — Closed `WIDGET_REGISTRY` for six Phase 1 widget types, source-type listing, type metadata, and lightweight config-schema validation.
|
||||
- `backend/src/media_library_viewer_api/routers/widgets.py` — REST endpoints for `/api/widgets/sources`, `/types`, `/instances`, and instance CRUD.
|
||||
- `backend/tests/test_widgets.py` — 12 tests covering registry, CRUD, validation, and seeding.
|
||||
|
||||
### Modified files
|
||||
|
||||
- `backend/src/media_library_viewer_api/services/settings_store.py` — Added `dashboard_widgets` table, index, CRUD helpers, default seeding, and refactored `ensure_defaults()` to seed widgets independently of machine seeding.
|
||||
- `backend/src/media_library_viewer_api/main.py` — Registered `widgets_router`.
|
||||
|
||||
## Verification
|
||||
|
||||
Commands run:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
.venv/bin/python -m ruff check . # All checks passed
|
||||
PYTHONPATH=src .venv/bin/python -m pytest # 185 passed, 2 warnings
|
||||
cd ../frontend
|
||||
npm run lint # 2 pre-existing warnings, 0 errors
|
||||
npm run build # Built successfully
|
||||
```
|
||||
|
||||
Focused widget test output: `12 passed`.
|
||||
|
||||
## Deviations from design
|
||||
|
||||
- None significant for Slice 1. The implementation follows the design's backend CRUD layout.
|
||||
- Used `HTTP_422_UNPROCESSABLE_CONTENT` instead of the deprecated `HTTP_422_UNPROCESSABLE_ENTITY`.
|
||||
|
||||
## Completed tasks (Slice 2)
|
||||
|
||||
All Slice 2 tasks are marked `- [x]` in `tasks.md`:
|
||||
|
||||
- [x] 2.1 Add observability URL settings (`grafana_url`, `prometheus_url`)
|
||||
- [x] 2.2 Create source adapters (`jellyfin`, `backups`, `grafana`, `prometheus`, `ssh_task`, `static`)
|
||||
- [x] 2.3 Add per-widget data endpoint (`GET /api/widgets/instances/{id}/data`)
|
||||
- [x] 2.4 Extract shared backup/Jellyfin dashboard helpers into `domain/dashboard.py`
|
||||
- [x] 2.5 Add adapter + data endpoint tests
|
||||
|
||||
## Files changed (Slice 2)
|
||||
|
||||
### New files
|
||||
|
||||
- `backend/src/media_library_viewer_api/widgets/sources.py` — `WidgetSource` protocol and six source adapters.
|
||||
- `backend/src/media_library_viewer_api/domain/dashboard.py` — Shared dashboard helpers (`_map_sessions_to_activity_rows`, `build_backup_dashboard_summary`).
|
||||
|
||||
### Modified files
|
||||
|
||||
- `backend/src/media_library_viewer_api/config.py` — Added `grafana_url` and `prometheus_url` settings.
|
||||
- `backend/src/media_library_viewer_api/routers/widgets.py` — Added `GET /api/widgets/instances/{id}/data`.
|
||||
- `backend/src/media_library_viewer_api/routers/dashboard.py` — Delegated to shared `domain/dashboard.py` helpers.
|
||||
- `backend/tests/test_widgets.py` — Added adapter and data endpoint tests.
|
||||
- `docker-compose.yml`, `docker-compose.dev.yml`, `.env.example` — Wired `GRAFANA_URL` and `PROMETHEUS_URL` for the new adapters.
|
||||
|
||||
## Verification (Slice 2)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
.venv/bin/python -m ruff check . # All checks passed
|
||||
PYTHONPATH=src .venv/bin/python -m pytest # 200 passed, 2 warnings
|
||||
cd ../frontend
|
||||
npm run lint # 2 pre-existing warnings, 0 errors
|
||||
npm run build # Built successfully
|
||||
```
|
||||
|
||||
Focused widget test output: `27 passed`.
|
||||
|
||||
## Deviations from design (Slice 2)
|
||||
|
||||
- Adapters currently call `get_settings_store()` internally for `backups`/`ssh_task` sources. The router-level endpoint uses FastAPI DI, but adapter unit tests patch `get_settings_store` to inject a test store. A future refactor can pass `store` and `settings` explicitly into `adapter.fetch()` for cleaner testability.
|
||||
|
||||
## Completed tasks (Slice 3)
|
||||
|
||||
All Slice 3 tasks are marked `- [x]` in `tasks.md`:
|
||||
|
||||
- [x] 3.1 Add TypeScript widget interfaces (`WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse`)
|
||||
- [x] 3.2 Create widget API client (`frontend/src/api/widgets.ts`)
|
||||
- [x] 3.3 Create widget TanStack Query hooks (`frontend/src/hooks/useWidgets.ts`)
|
||||
- [x] 3.4 Create frontend widget registry (`frontend/src/widgets/registry.ts`)
|
||||
- [x] 3.5 Implement six widget presentational components (`frontend/src/widgets/*.tsx`)
|
||||
- [x] 3.6 Add frontend registry unit test (`frontend/src/widgets/registry.test.ts`)
|
||||
|
||||
## Files changed (Slice 3)
|
||||
|
||||
### New files
|
||||
|
||||
- `frontend/src/api/widgets.ts` — API functions for widget CRUD, registry metadata, and per-widget data.
|
||||
- `frontend/src/hooks/useWidgets.ts` — TanStack Query hooks for instances, data, sources, types, and mutations.
|
||||
- `frontend/src/widgets/registry.ts` — Closed frontend registry with metadata, refresh intervals, and config fields.
|
||||
- `frontend/src/widgets/JellyfinWidget.tsx` — Renders Jellyfin session activity.
|
||||
- `frontend/src/widgets/BackupsWidget.tsx` — Renders backup dashboard summary.
|
||||
- `frontend/src/widgets/GrafanaLinkWidget.tsx` — Renders a deep-link to Grafana (no iframe).
|
||||
- `frontend/src/widgets/PrometheusMetricWidget.tsx` — Renders PromQL instant query result.
|
||||
- `frontend/src/widgets/SshTaskWidget.tsx` — Renders saved SSH task output.
|
||||
- `frontend/src/widgets/StaticWidget.tsx` — Renders static text.
|
||||
- `frontend/src/widgets/index.ts` — Barrel exports.
|
||||
- `frontend/src/widgets/registry.test.ts` — Vitest unit tests for registry metadata.
|
||||
|
||||
### Modified files
|
||||
|
||||
- `frontend/src/types/index.ts` — Added widget TypeScript interfaces.
|
||||
|
||||
## Verification (Slice 3)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
.venv/bin/python -m ruff check . # All checks passed
|
||||
PYTHONPATH=src .venv/bin/python -m pytest # 200 passed, 2 warnings
|
||||
cd ../frontend
|
||||
npm run lint # 2 pre-existing warnings, 0 errors
|
||||
npm run build # Built successfully
|
||||
npm run test -- src/widgets/registry.test.ts # 3 passed
|
||||
```
|
||||
|
||||
## Deviations from design (Slice 3)
|
||||
|
||||
- Registry unit test is colocated at `frontend/src/widgets/registry.test.ts` and runs with Vitest, matching the project's existing `npm run test` setup, instead of `frontend/tests/widgets.test.mjs`.
|
||||
- `JellyfinWidget` uses `SessionActivityPanel` directly because `NowPlaying` does not expose an `emptyMessage` prop.
|
||||
|
||||
## Completed tasks (Slice 4)
|
||||
|
||||
All Slice 4 tasks are marked `- [x]` in `tasks.md`:
|
||||
|
||||
- [x] 4.1 Refactor `Dashboard.tsx` to render enabled widget instances in sort order
|
||||
- [x] 4.2 Create `WidgetInstance` renderer component
|
||||
- [x] 4.3 Create `WidgetConfigDialog` for add/edit/reorder/delete widgets
|
||||
- [x] 4.4 Create addon pages (`AddonPage`, `GrafanaAddonPage`, `PrometheusAddonPage`, `SshTasksAddonPage`)
|
||||
- [x] 4.5 Register `/addons/:addonId` route in `App.tsx`
|
||||
- [x] 4.6 Update `docs/REQUIREMENTS.md` with widget system documentation
|
||||
|
||||
## Files changed (Slice 4)
|
||||
|
||||
### New files
|
||||
|
||||
- `frontend/src/components/WidgetInstance.tsx` — Renders a widget instance by looking up its definition and dispatching to the registered component.
|
||||
- `frontend/src/components/WidgetConfigDialog.tsx` — Dashboard widget configuration UI: list, add, edit, delete, reorder, enable/disable.
|
||||
- `frontend/src/pages/AddonPage.tsx` — Route mapper for `/addons/:addonId`.
|
||||
- `frontend/src/addons/GrafanaAddonPage.tsx` — Grafana addon landing page (deep-link only).
|
||||
- `frontend/src/addons/PrometheusAddonPage.tsx` — Prometheus addon landing page.
|
||||
- `frontend/src/addons/SshTasksAddonPage.tsx` — SSH tasks addon landing page.
|
||||
- `frontend/src/addons/index.ts` — Barrel exports.
|
||||
|
||||
### Modified files
|
||||
|
||||
- `frontend/src/pages/Dashboard.tsx` — Replaced hard-coded Jellyfin/Backups sections with widget instance loop; kept Shortcuts section; added "Edit dashboard" button.
|
||||
- `frontend/src/App.tsx` — Registered `/addons/:addonId` route in both OIDC and non-OIDC route trees.
|
||||
- `docs/REQUIREMENTS.md` — Added Configurable Dashboard Widgets section.
|
||||
|
||||
## Verification (Slice 4)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
.venv/bin/python -m ruff check . # All checks passed
|
||||
PYTHONPATH=src .venv/bin/python -m pytest # 200 passed, 2 warnings
|
||||
cd ../frontend
|
||||
npm run lint # 2 pre-existing warnings, 0 errors
|
||||
npm run build # Built successfully
|
||||
npm run test -- src/widgets/registry.test.ts # 3 passed
|
||||
```
|
||||
|
||||
## Deviations from design (Slice 4)
|
||||
|
||||
- The "Edit dashboard" button lives in the Shortcuts section action area for now. A future UI pass can move it to a dedicated dashboard header.
|
||||
- Machine/task selectors in the config dialog filter to enabled Jellyfin machines / enabled tasks, which is slightly stricter than the design's generic string field.
|
||||
|
||||
## Remaining work
|
||||
|
||||
- Phase 1 widget system is complete. Future work could include widget grid layout, drag-and-drop reorder, richer Prometheus visualizations, or migrating shortcuts into the widget system.
|
||||
|
||||
## PR boundary
|
||||
|
||||
This slice is **PR 1 of 4** in the approved stacked-to-main chain. It is backend-only and leaves the frontend build/lint green.
|
||||
|
||||
**Actual changed-line count:** ~780 added lines across production code and tests (new files: ~597 lines; modified files: ~181 insertions). This is above the nominal ~400-line review budget, but Slice 1 is the smallest coherent backend unit: removing the CRUD router, store helpers, or tests would leave the slice non-functional or unverifiable. If the reviewer prefers a smaller blast radius, the store helpers (~90 lines) could be split into a preceding PR, though that PR would not be independently user-visible.
|
||||
@@ -0,0 +1,749 @@
|
||||
# SDD Design: Configurable Dashboard Widgets
|
||||
|
||||
**Change:** `configurable-dashboard-widgets`
|
||||
**Phase:** design
|
||||
**Date:** 2026-06-19
|
||||
|
||||
## 1. Architecture overview
|
||||
|
||||
The widget system introduces a thin, closed registry layer between the existing FastAPI backend and the React dashboard. It reuses the existing `SettingsStore` SQLite database, dependency-injection helpers (`get_jellyfin_client`, `get_ssh_client`, saved-task registry), and shadcn/ui component patterns.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ Dashboard.tsx ──► WidgetInstance renderer ──► widget registry │
|
||||
│ │ │ │ │
|
||||
│ │ useWidgetData() addon pages │
|
||||
│ │ │ │ │
|
||||
│ └──────────────► /api/widgets/instances/{id}/data ◄────────┘
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────┐
|
||||
│ FastAPI /api/widgets router │
|
||||
│ - CRUD instances │
|
||||
│ - registry metadata │
|
||||
│ - data fetch via source adapters │
|
||||
└────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┼─────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
SettingsStore source adapters existing routers
|
||||
(SQLite) (stateless) /api/dashboard
|
||||
dashboard_widgets jellyfin /api/tasks
|
||||
backups /api/settings
|
||||
grafana
|
||||
prometheus
|
||||
ssh_task
|
||||
static
|
||||
```
|
||||
|
||||
**Key constraints carried from the spec:**
|
||||
|
||||
- Closed, compile-time registries in both backend and frontend. No runtime plugin loading.
|
||||
- No secrets in `config_json`; credentials come from the machine/SSH-key store or environment settings.
|
||||
- Stacked `SectionCard` layout; no grid/drag/resize.
|
||||
- Each widget fetches its own data independently with per-type polling intervals and timeouts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend design
|
||||
|
||||
### 2.1 `dashboard_widgets` table schema
|
||||
|
||||
Extend `SettingsStore.init_schema()` in `backend/src/media_library_viewer_api/services/settings_store.py`:
|
||||
|
||||
```sql
|
||||
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
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order);
|
||||
```
|
||||
|
||||
Store helper additions:
|
||||
|
||||
- `_row_to_widget(row)` — parse `config_json` into a `config` dict.
|
||||
- `_normalize_widget_payload(payload, widget_id=None)` — validate/assign defaults, generate `id` if missing.
|
||||
- `list_widgets()` — return all rows ordered by `sort_order ASC, created_at ASC`.
|
||||
- `get_widget(widget_id)` — single row.
|
||||
- `upsert_widget(payload, widget_id=None)` — insert or replace; preserve `created_at`.
|
||||
- `delete_widget(widget_id)` — delete by id.
|
||||
- `seed_default_widgets()` — called from `ensure_defaults()`; inserts the two defaults only when the table is empty.
|
||||
|
||||
`ensure_defaults()` already runs on startup (called via `get_settings_store()`). Seeding logic:
|
||||
|
||||
```python
|
||||
def ensure_defaults(self) -> None:
|
||||
self.init_schema()
|
||||
# existing local-machine seeding ...
|
||||
self._seed_dashboard_widgets()
|
||||
|
||||
def _seed_dashboard_widgets(self) -> None:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone()
|
||||
if row and int(row[0]) > 0:
|
||||
return
|
||||
now = int(time.time())
|
||||
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 w in defaults:
|
||||
self.upsert_widget(w)
|
||||
```
|
||||
|
||||
IDs are hard-coded so repeated startups are idempotent. Empty `config` for `jellyfin` resolves to the first enabled Jellyfin machine via existing DI.
|
||||
|
||||
### 2.2 Widget source adapter protocol
|
||||
|
||||
Adapters live in `backend/src/media_library_viewer_api/widgets/sources.py` (single file is sufficient for Phase 1).
|
||||
|
||||
```python
|
||||
from typing import Any, Protocol
|
||||
|
||||
class WidgetSource(Protocol):
|
||||
source_type: str
|
||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ...
|
||||
```
|
||||
|
||||
Concrete adapters:
|
||||
|
||||
| source_type | class | implementation notes |
|
||||
|-------------|-------|----------------------|
|
||||
| `jellyfin` | `JellyfinWidgetSource` | Build a Starlette `Request` with `machine_id` query param, call `get_jellyfin_client(req)` and `get_user_id(req)`, then `client.sessions()`; reuse `_map_sessions_to_activity_rows` from `routers/dashboard.py` or move the helper to a shared `domain/dashboard.py`. |
|
||||
| `backups` | `BackupsWidgetSource` | Call `SettingsStore.list_backup_jobs`, `list_backup_runs`, `list_backup_alerts` and compute the same summary as `GET /api/dashboard/backups`; reuse `BackupDashboardSummary`. |
|
||||
| `grafana` | `GrafanaWidgetSource` | Read `grafana_url` from `get_settings()` (new setting, default `http://grafana:3000`) and `config.dashboard_uid`/`panel_id`; return `{url: "{grafana_url}/d/{dashboard_uid}?..."}`. No embedding. |
|
||||
| `prometheus` | `PrometheusWidgetSource` | Read `prometheus_url` from settings (env or default `http://prometheus:9090`), run instant query `config.promql`, return scalar/vector result. Apply 10 s timeout. |
|
||||
| `ssh_task` | `SshTaskWidgetSource` | Look up saved task by `config.task_id` in `SettingsStore`, resolve machine via existing `_resolve_machine_for_task` logic or a shared helper, run via `LocalCommandClient`/`RemoteSSHClient`, return trimmed stdout/stderr/exit_status. |
|
||||
| `static` | `StaticWidgetSource` | Return `{"text": config.get("text", "")}`; no network call. |
|
||||
|
||||
Adapter registry:
|
||||
|
||||
```python
|
||||
SOURCE_REGISTRY: dict[str, WidgetSource] = {
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
"backups": BackupsWidgetSource(),
|
||||
"grafana": GrafanaWidgetSource(),
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"ssh_task": SshTaskWidgetSource(),
|
||||
"static": StaticWidgetSource(),
|
||||
}
|
||||
```
|
||||
|
||||
Adapters must catch all exceptions and return `{"error": "human-readable message"}`. The only 500 case is an unhandled exception in the adapter, which the endpoint catches and logs.
|
||||
|
||||
Timeouts (adapter-level, not HTTP client-level where possible):
|
||||
|
||||
- `jellyfin`: 10 s
|
||||
- `backups`: 10 s
|
||||
- `prometheus`: 10 s
|
||||
- `ssh_task`: 30 s
|
||||
- `grafana`: 5 s
|
||||
- `static`: no fetch
|
||||
|
||||
### 2.3 Router layout
|
||||
|
||||
New file: `backend/src/media_library_viewer_api/routers/widgets.py`
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.models.widgets import (
|
||||
WidgetInstance,
|
||||
WidgetInstanceInput,
|
||||
WidgetTypeInfo,
|
||||
WidgetDataResponse,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
||||
from media_library_viewer_api.widgets.sources import SOURCE_REGISTRY
|
||||
|
||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
| Method | Path | Handler |
|
||||
|--------|------|---------|
|
||||
| GET | `/sources` | `list_sources()` — returns `["jellyfin", "backups", "grafana", "prometheus", "ssh_task", "static"]` |
|
||||
| GET | `/types` | `list_types()` — returns `list[WidgetTypeInfo]` built from `WIDGET_REGISTRY` |
|
||||
| GET | `/instances` | `list_instances(store)` — `store.list_widgets()` mapped to `WidgetInstance` |
|
||||
| POST | `/instances` | `create_instance(body, store)` — status 201 |
|
||||
| PUT | `/instances/{widget_id}` | `update_instance(widget_id, body, store)` — 404 if missing, 400 if `body.id != widget_id` |
|
||||
| DELETE | `/instances/{widget_id}` | `delete_instance(widget_id, store)` — 404 if missing |
|
||||
| GET | `/instances/{widget_id}/data` | `fetch_data(widget_id, store)` — look up widget, resolve source adapter, return `WidgetDataResponse` |
|
||||
|
||||
Validation flow in create/update:
|
||||
|
||||
1. Validate `WidgetInstanceInput` Pydantic model.
|
||||
2. Reject forbidden credential keys anywhere in `config`.
|
||||
3. Verify `widget_type` is in `WIDGET_REGISTRY`.
|
||||
4. Verify `addon_id` matches the registry entry for that type.
|
||||
5. Validate `config` against the widget type's JSON schema.
|
||||
6. Persist via `store.upsert_widget()`.
|
||||
|
||||
### 2.4 Pydantic models
|
||||
|
||||
New file: `backend/src/media_library_viewer_api/models/widgets.py`
|
||||
|
||||
```python
|
||||
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:
|
||||
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]:
|
||||
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)
|
||||
return config
|
||||
|
||||
class WidgetInstanceInput(BaseModel):
|
||||
id: str | None = None
|
||||
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, v):
|
||||
return _validate_config_keys(v or {})
|
||||
|
||||
class WidgetInstance(WidgetInstanceInput):
|
||||
id: str
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
class WidgetTypeInfo(BaseModel):
|
||||
addon_id: str
|
||||
widget_type: str
|
||||
name: str
|
||||
description: str
|
||||
source_type: str
|
||||
config_schema: dict[str, Any]
|
||||
|
||||
class WidgetDataResponse(BaseModel):
|
||||
widget_id: str
|
||||
widget_type: str
|
||||
data: dict[str, Any] | None
|
||||
error: str | None
|
||||
fetched_at: int
|
||||
```
|
||||
|
||||
Widget registry file: `backend/src/media_library_viewer_api/widgets/registry.py`
|
||||
|
||||
```python
|
||||
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": [],
|
||||
},
|
||||
},
|
||||
"backups": { "addon_id": "backups", ... },
|
||||
"grafana-link": { "addon_id": "grafana", ... },
|
||||
"prometheus-metric": { "addon_id": "prometheus", ... },
|
||||
"ssh-task": { "addon_id": "ssh-tasks", ... },
|
||||
"static": { "addon_id": "core", ... },
|
||||
}
|
||||
```
|
||||
|
||||
The registry explicitly maps `widget_type -> addon_id` so the backend can enforce invariant #2.
|
||||
|
||||
### 2.5 Main.py registration
|
||||
|
||||
Add to `backend/src/media_library_viewer_api/main.py`:
|
||||
|
||||
```python
|
||||
from media_library_viewer_api.routers import widgets as widgets_router
|
||||
...
|
||||
app.include_router(widgets_router.router)
|
||||
```
|
||||
|
||||
Because all `/api/widgets` endpoints are under the existing JWT/API-key middleware (`enforce_jwt_auth`), no additional auth decorator is needed.
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend design
|
||||
|
||||
### 3.1 Widget registry
|
||||
|
||||
New file: `frontend/src/widgets/registry.ts`
|
||||
|
||||
```typescript
|
||||
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
||||
|
||||
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; // ms, 0 = no polling
|
||||
defaultConfig: Record<string, unknown>;
|
||||
configFields: WidgetConfigField[];
|
||||
component: React.ComponentType<{ widget: WidgetInstance }>;
|
||||
}
|
||||
|
||||
export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
|
||||
jellyfin: { ... },
|
||||
backups: { ... },
|
||||
"grafana-link": { ... },
|
||||
"prometheus-metric": { ... },
|
||||
"ssh-task": { ... },
|
||||
static: { ... },
|
||||
};
|
||||
|
||||
export function getWidgetDefinition(widgetType: string): WidgetDefinition | undefined {
|
||||
return WIDGET_REGISTRY[widgetType];
|
||||
}
|
||||
```
|
||||
|
||||
Refresh intervals (ms):
|
||||
|
||||
- `jellyfin`: 30_000
|
||||
- `backups`: 60_000
|
||||
- `grafana-link`: 0
|
||||
- `prometheus-metric`: 30_000
|
||||
- `ssh-task`: 0
|
||||
- `static`: 0
|
||||
|
||||
Widget components live in `frontend/src/widgets/*.tsx`:
|
||||
|
||||
- `JellyfinWidget.tsx` — wraps `NowPlaying` / activity data.
|
||||
- `BackupsWidget.tsx` — reuses `BackupDashboardWidget` internals or extracts a shared presentational component.
|
||||
- `GrafanaLinkWidget.tsx` — renders a deep-link card.
|
||||
- `PrometheusMetricWidget.tsx` — metric value/sparkline card.
|
||||
- `SshTaskWidget.tsx` — preformatted output panel.
|
||||
- `StaticWidget.tsx` — markdown/text block.
|
||||
|
||||
### 3.2 Dashboard rendering loop
|
||||
|
||||
Modify `frontend/src/pages/Dashboard.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||
import { WidgetInstance } from "../components/WidgetInstance";
|
||||
|
||||
export function Dashboard() {
|
||||
const { data: instances = [] } = useWidgetInstances();
|
||||
const visible = useMemo(
|
||||
() => instances.filter((w) => w.enabled).sort((a, b) => a.sort_order - b.sort_order),
|
||||
[instances],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Shortcuts remain a first-class section to avoid data migration */}
|
||||
<ShortcutsSection />
|
||||
|
||||
{visible.map((widget) => (
|
||||
<WidgetInstance key={widget.id} widget={widget} />
|
||||
))}
|
||||
|
||||
<WidgetConfigDialog />
|
||||
<ConfirmDialog ... />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`WidgetInstance` renderer (`frontend/src/components/WidgetInstance.tsx`):
|
||||
|
||||
```tsx
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import { getWidgetDefinition } from "../widgets/registry";
|
||||
|
||||
export function WidgetInstance({ widget }: { widget: WidgetInstance }) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data, isLoading } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
|
||||
|
||||
if (!def) {
|
||||
return (
|
||||
<SectionCard title={widget.title}>
|
||||
<Alert><AlertDescription>Unknown widget type: {widget.widget_type}</AlertDescription></Alert>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = def.component;
|
||||
return (
|
||||
<SectionCard title={widget.title}>
|
||||
{isLoading && !data ? <SkeletonWidget /> : <Component widget={widget} />}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Each widget component receives the `widget` instance and reads `data?.data` / `data?.error` from its own `useWidgetData` query (or the parent can pass it; both work, but passing avoids a second hook call). Prefer passing `data` and `isLoading` from `WidgetInstance` to the component to keep components pure.
|
||||
|
||||
### 3.3 TanStack Query hooks
|
||||
|
||||
New file: `frontend/src/hooks/useWidgets.ts`:
|
||||
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchWidgetSources,
|
||||
fetchWidgetTypes,
|
||||
fetchWidgetInstances,
|
||||
createWidgetInstance,
|
||||
updateWidgetInstance,
|
||||
deleteWidgetInstance,
|
||||
fetchWidgetData,
|
||||
} from "../api/widgets";
|
||||
import type { WidgetInstanceInput } from "../types";
|
||||
|
||||
export function useWidgetInstances() {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "instances"],
|
||||
queryFn: fetchWidgetInstances,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWidgetData(widgetId: string, refreshInterval: number) {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "data", widgetId],
|
||||
queryFn: () => fetchWidgetData(widgetId),
|
||||
refetchInterval: refreshInterval || false,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveWidgetInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: WidgetInstanceInput) =>
|
||||
input.id ? updateWidgetInstance(input) : createWidgetInstance(input),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWidgetInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (widgetId: string) => deleteWidgetInstance(widgetId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWidgetSources() {
|
||||
return useQuery({ queryKey: ["widgets", "sources"], queryFn: fetchWidgetSources });
|
||||
}
|
||||
|
||||
export function useWidgetTypes() {
|
||||
return useQuery({ queryKey: ["widgets", "types"], queryFn: fetchWidgetTypes });
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Configuration UI
|
||||
|
||||
Add a new `WidgetConfigDialog` component (can live in `frontend/src/components/WidgetConfigDialog.tsx` or inline in `Dashboard.tsx`).
|
||||
|
||||
Behavior:
|
||||
|
||||
- "Edit dashboard" button in the Dashboard header opens the dialog.
|
||||
- Dialog lists all instances (enabled and disabled) with sort-order inputs, enabled toggle, edit/delete actions, and up/down reorder buttons.
|
||||
- "Add widget" sub-flow: select widget type from registry, then render source-specific config fields.
|
||||
- Form fields reuse `Dialog`, `Input`, `Label`, `Switch`, `Select`, `Button`, `Alert`.
|
||||
|
||||
Source-specific config rendering:
|
||||
|
||||
```tsx
|
||||
function WidgetConfigFields({
|
||||
definition,
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
config: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{definition.configFields.map((field) => (
|
||||
<Field key={field.key} label={field.label} htmlFor={field.key}>
|
||||
{field.type === "select" ? (
|
||||
<Select
|
||||
value={String(config[field.key] ?? "")}
|
||||
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
|
||||
>
|
||||
{/* ... */}
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={field.key}
|
||||
value={String(config[field.key] ?? "")}
|
||||
onChange={(e) => onChange({ ...config, [field.key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For fields that need dynamic options (e.g., machine selection for `jellyfin`, saved task selection for `ssh-task`), the dialog can use `useMonitoringSettings()` and `useTasks()` to populate select options and map them to `machine_id`/`task_id` config values.
|
||||
|
||||
### 3.5 Addon pages
|
||||
|
||||
New file: `frontend/src/pages/AddonPage.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { GrafanaAddonPage } from "../addons/GrafanaAddonPage";
|
||||
import { PrometheusAddonPage } from "../addons/PrometheusAddonPage";
|
||||
import { SshTasksAddonPage } from "../addons/SshTasksAddonPage";
|
||||
|
||||
const ADDON_PAGES: Record<string, React.ComponentType> = {
|
||||
grafana: GrafanaAddonPage,
|
||||
prometheus: PrometheusAddonPage,
|
||||
"ssh-tasks": SshTasksAddonPage,
|
||||
};
|
||||
|
||||
export function AddonPage() {
|
||||
const { addonId } = useParams<{ addonId: string }>();
|
||||
const Page = addonId ? ADDON_PAGES[addonId] : undefined;
|
||||
if (!Page) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>Addon "{addonId}" is not installed.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return <Page />;
|
||||
}
|
||||
```
|
||||
|
||||
Register in `frontend/src/App.tsx` inside both route trees:
|
||||
|
||||
```tsx
|
||||
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||
```
|
||||
|
||||
Grafana widgets render a link to `/addons/grafana` or directly to the external Grafana URL; either is acceptable. The spec requires the addon page route exists and Grafana widgets deep-link rather than embed.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data flow
|
||||
|
||||
1. **Config CRUD**
|
||||
- User opens config dialog → `useWidgetInstances()` and `useWidgetTypes()` load.
|
||||
- Add/edit form → `useSaveWidgetInstance().mutate(input)` → `POST/PUT /api/widgets/instances` → backend validates, persists, returns `WidgetInstance` → query cache invalidated → dashboard re-renders.
|
||||
|
||||
2. **Per-widget data fetch**
|
||||
- `Dashboard.tsx` maps enabled instances to `<WidgetInstance />`.
|
||||
- Each `WidgetInstance` calls `useWidgetData(widget.id, refreshInterval)`.
|
||||
- Hook calls `GET /api/widgets/instances/{id}/data`.
|
||||
- Endpoint loads the instance, picks the adapter by `source_type`, calls `adapter.fetch(config)`, wraps in `WidgetDataResponse`.
|
||||
- Adapter resolves credentials from machine store / env / SSH-key store and returns data or error payload.
|
||||
|
||||
3. **Error boundaries and loading states**
|
||||
- Adapter exceptions are caught by the endpoint and returned as `error` with HTTP 200; unhandled exceptions return 500.
|
||||
- `WidgetInstance` shows a skeleton on initial load.
|
||||
- If `data.error` is set, render an inline `Alert` inside the widget's `SectionCard`.
|
||||
- A failing widget does not block sibling widgets because each has its own query.
|
||||
|
||||
---
|
||||
|
||||
## 5. Security design
|
||||
|
||||
- **No secrets in `config_json`**: forbidden key list enforced by Pydantic validator and store write path. Values starting with `sk-`/`eyJ` or long alphanumeric strings are rejected.
|
||||
- **Credential resolution**: adapters use `get_settings_store().get_machine_config()`, `get_ssh_key()`, and `get_settings()` for Grafana/Prometheus URLs. No widget config stores URLs with embedded credentials.
|
||||
- **Saved-task registry reuse**: `ssh_task` adapter only runs tasks from the existing saved-task registry; no arbitrary command execution.
|
||||
- **Auth**: all `/api/widgets` endpoints inherit existing JWT/API-key middleware.
|
||||
- **No iframes**: addon pages and Grafana widgets render links only.
|
||||
- **Validation at two layers**: Pydantic model rejects malformed/credential-laden configs; store-level normalization also rejects forbidden keys as defense-in-depth.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing approach
|
||||
|
||||
### Backend
|
||||
|
||||
New test file: `backend/tests/test_widgets.py`
|
||||
|
||||
- `TestWidgetRegistry`: `GET /api/widgets/sources` and `/api/widgets/types` return expected closed lists.
|
||||
- `TestWidgetCrud`:
|
||||
- create static widget → 201, config round-trips.
|
||||
- update nonexistent → 404.
|
||||
- delete → 404 after delete.
|
||||
- unknown widget type → 422.
|
||||
- credential key in config → 422.
|
||||
- `TestWidgetData`:
|
||||
- static widget data returns text unchanged.
|
||||
- misconfigured jellyfin widget returns `error` in payload with HTTP 200.
|
||||
- `TestWidgetSeeding`:
|
||||
- fresh store seeds Jellyfin + Backups widgets.
|
||||
- existing widget rows prevent re-seeding.
|
||||
|
||||
Use existing `test_client` fixture pattern from `test_api.py` with mocked Jellyfin/SSH clients where needed.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `npm run build` (via `tsc -b`) validates new TypeScript types and component imports.
|
||||
- Add `frontend/tests/widgets.test.mjs` using the existing `node:test` + `node:assert/strict` setup to test:
|
||||
- `getWidgetDefinition` returns correct refresh intervals.
|
||||
- registry contains exactly the six Phase 1 widget types.
|
||||
- If/when the project adopts Vitest, add hook tests with MSW; for Phase 1, rely on build + manual component tests.
|
||||
|
||||
### Integration / manual
|
||||
|
||||
- Fresh Docker dev stack shows Jellyfin activity + Backups widgets by default.
|
||||
- Add each widget type via config UI and verify render + polling behavior.
|
||||
- Verify disabled widget is hidden and reorder changes dashboard order.
|
||||
|
||||
---
|
||||
|
||||
## 7. File-level plan
|
||||
|
||||
### Create
|
||||
|
||||
| File | Rationale |
|
||||
|------|-----------|
|
||||
| `backend/src/media_library_viewer_api/models/widgets.py` | Pydantic models: `WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse` plus credential validators. |
|
||||
| `backend/src/media_library_viewer_api/widgets/__init__.py` | Package marker for widget subsystem. |
|
||||
| `backend/src/media_library_viewer_api/widgets/registry.py` | Closed widget-type registry mapping widget_type → addon_id, source_type, JSON schema. |
|
||||
| `backend/src/media_library_viewer_api/widgets/sources.py` | Stateless source adapters for all six source types. |
|
||||
| `backend/src/media_library_viewer_api/routers/widgets.py` | REST endpoints for CRUD, registry metadata, and data fetch. |
|
||||
| `frontend/src/types/index.ts` additions | TypeScript interfaces matching backend models. |
|
||||
| `frontend/src/api/widgets.ts` | API functions for widget endpoints. |
|
||||
| `frontend/src/hooks/useWidgets.ts` | TanStack Query hooks for instances, data, mutations. |
|
||||
| `frontend/src/widgets/registry.ts` | Frontend closed widget registry. |
|
||||
| `frontend/src/widgets/*.tsx` | Six widget presentational components. |
|
||||
| `frontend/src/components/WidgetInstance.tsx` | Renderer that loads data and dispatches to widget component. |
|
||||
| `frontend/src/components/WidgetConfigDialog.tsx` | Add/edit/reorder/remove configuration UI. |
|
||||
| `frontend/src/pages/AddonPage.tsx` | Route target for `/addons/:addonId`. |
|
||||
| `frontend/src/addons/GrafanaAddonPage.tsx` | Grafana addon page (links only, no iframe). |
|
||||
| `frontend/src/addons/PrometheusAddonPage.tsx` | Prometheus addon page. |
|
||||
| `frontend/src/addons/SshTasksAddonPage.tsx` | SSH tasks addon page. |
|
||||
| `backend/tests/test_widgets.py` | Backend API and store tests. |
|
||||
| `frontend/tests/widgets.test.mjs` | Frontend registry unit tests. |
|
||||
|
||||
### Modify
|
||||
|
||||
| File | Rationale |
|
||||
|------|-----------|
|
||||
| `backend/src/media_library_viewer_api/services/settings_store.py` | Add `dashboard_widgets` schema, CRUD helpers, default seeding in `ensure_defaults()`. |
|
||||
| `backend/src/media_library_viewer_api/config.py` | Add `grafana_url: str` setting (default `http://grafana:3000`) so adapters can build deep-links. Optional if Grafana URL is already derivable from env; for Phase 1 add it explicitly. |
|
||||
| `backend/src/media_library_viewer_api/main.py` | Register `widgets_router`. |
|
||||
| `frontend/src/pages/Dashboard.tsx` | Replace hard-coded Jellyfin/Backups sections with widget instance loop; keep Shortcuts section intact; add "Edit dashboard" action. |
|
||||
| `frontend/src/App.tsx` | Add `/addons/:addonId` route in both OIDC and non-OIDC route trees. |
|
||||
| `docs/REQUIREMENTS.md` | Document new widget system behavior and security rule. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Slice boundaries
|
||||
|
||||
A full Phase 1 implementation is expected to touch ~1,000–1,200 lines across backend and frontend, exceeding the ~400-line review budget. Recommended reviewable slices:
|
||||
|
||||
### Slice 1: Backend CRUD and default seeding
|
||||
|
||||
- Create `models/widgets.py`.
|
||||
- Create `widgets/registry.py`.
|
||||
- Create `routers/widgets.py` for CRUD + metadata endpoints.
|
||||
- Extend `settings_store.py` with table schema, helpers, and `_seed_dashboard_widgets()`.
|
||||
- Register router in `main.py`.
|
||||
- Add `backend/tests/test_widgets.py` for CRUD/registry tests.
|
||||
- **Estimated:** ~350–400 changed lines.
|
||||
|
||||
### Slice 2: Backend source adapters and data endpoint
|
||||
|
||||
- Create `widgets/sources.py` with all six adapters.
|
||||
- Add `GET /api/widgets/instances/{id}/data` endpoint.
|
||||
- Add `grafana_url` to `config.py`.
|
||||
- Extract/share `dashboard.py` activity mapping if needed.
|
||||
- Extend tests with data-fetch scenarios.
|
||||
- **Estimated:** ~300–350 changed lines.
|
||||
|
||||
### Slice 3: Frontend types, API, hooks, and widget registry
|
||||
|
||||
- Add TypeScript interfaces to `types/index.ts`.
|
||||
- Create `api/widgets.ts` and `hooks/useWidgets.ts`.
|
||||
- Create `widgets/registry.ts` and the six widget components.
|
||||
- Add `frontend/tests/widgets.test.mjs`.
|
||||
- **Estimated:** ~350–400 changed lines.
|
||||
|
||||
### Slice 4: Dashboard rendering loop, config UI, and addon pages
|
||||
|
||||
- Modify `Dashboard.tsx` to render widget instances.
|
||||
- Create `WidgetInstance.tsx` and `WidgetConfigDialog.tsx`.
|
||||
- Create `AddonPage.tsx` and addon pages.
|
||||
- Register addon route in `App.tsx`.
|
||||
- Update `docs/REQUIREMENTS.md`.
|
||||
- **Estimated:** ~350–400 changed lines.
|
||||
|
||||
**Recommended order:** Slice 1 → Slice 2 → Slice 3 → Slice 4. Each slice is independently testable and leaves the app in a working state. Slices 1 and 2 can be merged into one PR if the backend-only change stays under the budget; otherwise keep them separate.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions / decisions
|
||||
|
||||
1. **Grafana URL source**: Add `grafana_url` to `Settings` in `config.py` (default `http://grafana:3000`). This is the minimal change; alternatively derive from `ALERTMANAGER_URL` or an env var, but explicit is clearer.
|
||||
2. **Shortcuts migration**: Keep Shortcuts as a hard-coded section above widgets for Phase 1. This avoids a data migration and satisfies "no data is lost". A future phase can migrate shortcuts into the widget system.
|
||||
3. **Prometheus URL**: Reuse existing `prometheus_file_sd_dir` / convention or add `prometheus_url` setting. For instant queries the adapter needs a query URL; add `prometheus_url: str = "http://prometheus:9090"` to `Settings`.
|
||||
@@ -0,0 +1,210 @@
|
||||
# SDD Explore: Configurable Dashboard Widgets
|
||||
|
||||
**Change:** `configurable-dashboard-widgets`
|
||||
**Phase:** explore
|
||||
**Date:** 2026-06-19
|
||||
|
||||
## 1. Existing Frontend Architecture
|
||||
|
||||
### Routing & navigation
|
||||
|
||||
- `frontend/src/App.tsx` defines a static `navItems` array and registers routes inside `<Routes>`.
|
||||
- Current top-level pages: `/` Dashboard, `/observability`, `/media`, `/files`, `/backups`, `/users`, `/actions`, `/settings`.
|
||||
- Sidebar and mobile drawer both consume `navItems`; adding a new addon page requires editing this file today.
|
||||
|
||||
### Page structure
|
||||
|
||||
- Pages live in `frontend/src/pages/`.
|
||||
- Some pages are re-exported through thin entrypoints (`FileBrowser.tsx`, `Users.tsx`) while implementations live in `*.impl.tsx` files.
|
||||
- `BackupsPage` and `ObservabilityPage` live under `frontend/src/components/` but are routed as pages.
|
||||
|
||||
### Dashboard composition today
|
||||
|
||||
- `frontend/src/pages/Dashboard.tsx` renders three hard-coded sections:
|
||||
1. **Shortcuts** — `SectionCard` + `ShortcutCard` grid.
|
||||
2. **Jellyfin activity** — `SectionCard` + `NowPlaying`.
|
||||
3. **Backups** — `BackupDashboardWidget`.
|
||||
- Machine selection (e.g., active Jellyfin machine) is local component state.
|
||||
|
||||
## 2. Existing Backend Architecture
|
||||
|
||||
### Router registration
|
||||
|
||||
- `backend/src/media_library_viewer_api/main.py` statically imports routers and calls `app.include_router(...)`.
|
||||
- Existing routers: `dashboard`, `monitoring`, `media`, `files`, `jobs`, `users`, `tasks`, `settings`, `backups`.
|
||||
|
||||
### Settings persistence
|
||||
|
||||
- `backend/src/media_library_viewer_api/services/settings_store.py` is the single SQLite-backed store.
|
||||
- Pattern: `init_schema()` creates tables, JSON columns store flexible config, CRUD helpers return plain dicts.
|
||||
- Already stores: monitoring machines, SSH keys, saved tasks, dashboard shortcuts, backup jobs/runs/alerts.
|
||||
|
||||
### Client resolution
|
||||
|
||||
- `backend/src/media_library_viewer_api/dependencies.py` resolves machines by `machine_id` query param and service tag.
|
||||
- Jellyfin/SSH/local clients are built from machine config + SSH key store.
|
||||
|
||||
## 3. Widget / Addon Extension Points
|
||||
|
||||
### Frontend
|
||||
|
||||
| Extension point | Current state | How to reuse/extend |
|
||||
|---|---|---|
|
||||
| Sidebar nav | Static `navItems` | Derive from an addon registry; add dynamic `Route` entries |
|
||||
| Dashboard surface | Hard-coded sections | Render widget instances from persisted config |
|
||||
| Widget chrome | `SectionCard`, `MetricCard` | Reuse as container tiles |
|
||||
| Page chrome | `ObservabilityPage` pattern | Model addon pages on shadcn Card + lucide icons + TanStack Query |
|
||||
| Data fetching | `useDashboard`, `useBackups`, `useObservability` | Add `useWidgets` hooks per source |
|
||||
|
||||
### Backend
|
||||
|
||||
| Extension point | Current state | How to reuse/extend |
|
||||
|---|---|---|
|
||||
| Router registration | Static imports | Add a `widgets` dispatcher router or explicitly register addon routers |
|
||||
| Persistence | `SettingsStore` JSON columns | Add `dashboard_widgets` / `addon_configs` tables |
|
||||
| Client/credential access | `dependencies.py` machine resolution | Widget adapters reuse existing clients |
|
||||
| Source adapters | None | New abstraction: `WidgetSource` per source type |
|
||||
|
||||
## 4. What a Widget Needs to Consume Data
|
||||
|
||||
### Source adapters (backend)
|
||||
|
||||
A widget source adapter should implement a small interface, e.g.:
|
||||
|
||||
```python
|
||||
class WidgetSource(Protocol):
|
||||
source_type: str
|
||||
|
||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
...
|
||||
```
|
||||
|
||||
Candidate source types:
|
||||
|
||||
- `jellyfin` — reuse `JellyfinClient` for counts/sessions.
|
||||
- `backups` — reuse backup summary logic already in `dashboard.py`.
|
||||
- `grafana` — link/iframe metadata or query a Grafana datasource (env URL/auth already configured).
|
||||
- `prometheus` — instant query via env Prometheus URL.
|
||||
- `alertmanager` — summary already exists in `monitoring.py`.
|
||||
- `ssh_task` / `script` — run a saved task or whitelisted script through the existing machine/task registry.
|
||||
- `static` — simple text/markdown/no-data widget.
|
||||
|
||||
### Config schema
|
||||
|
||||
Each widget instance needs:
|
||||
|
||||
- `id`, `addon_id`, `widget_type`, `title`, `icon`, `enabled`
|
||||
- `source_type` + `source_config` (JSON)
|
||||
- `refresh_interval_seconds`
|
||||
- `layout` (position, size) or `sort_order`
|
||||
- `display_options` (e.g., show header, variant)
|
||||
|
||||
### Refresh / polling
|
||||
|
||||
- Frontend: TanStack Query `refetchInterval` per widget type.
|
||||
- Backend: short-lived proxy/adapters; avoid heavy polling for slow sources (SSH scripts).
|
||||
|
||||
### Credential handling
|
||||
|
||||
- **Never store secrets in widget config.**
|
||||
- Jellyfin/SSH: use machine registry + SSH key store.
|
||||
- Grafana/Prometheus/Alertmanager: use backend env settings (`get_settings()`).
|
||||
|
||||
## 5. Key Architectural Decisions
|
||||
|
||||
### Widget registry: compile-time vs runtime
|
||||
|
||||
- **Compile-time** (simpler): a static map of `widget_type -> component` in the frontend and source adapters in the backend.
|
||||
- **Runtime** (more “addon”): backend serves an addon manifest, frontend lazily loads component modules.
|
||||
- **Recommendation**: start compile-time for Phase 1; keep the data model flexible for runtime manifests later.
|
||||
|
||||
### Addon manifest format
|
||||
|
||||
A minimal manifest could be:
|
||||
|
||||
```yaml
|
||||
id: grafana-addon
|
||||
name: Grafana
|
||||
icon: Activity
|
||||
page:
|
||||
route: /addons/grafana
|
||||
component: ./addons/grafana/GrafanaPage
|
||||
widgets:
|
||||
- type: grafana-link
|
||||
name: Grafana Link
|
||||
component: ./addons/grafana/GrafanaLinkWidget
|
||||
source_type: grafana
|
||||
config_schema:
|
||||
- name: dashboardUid
|
||||
type: string
|
||||
```
|
||||
|
||||
### Dashboard persistence model
|
||||
|
||||
- Store widget instances globally (like current shortcuts) in a new `dashboard_widgets` table:
|
||||
- `id TEXT PRIMARY KEY`
|
||||
- `addon_id TEXT`
|
||||
- `widget_type TEXT`
|
||||
- `title TEXT`
|
||||
- `config_json TEXT`
|
||||
- `enabled INTEGER`
|
||||
- `sort_order INTEGER`
|
||||
- `created_at`, `updated_at`
|
||||
- Consider a `user_id` column later if multi-user config is needed.
|
||||
|
||||
### Layout
|
||||
|
||||
- **Option A**: keep the existing stacked `SectionCard` list (simple, mobile-safe, no new dependencies).
|
||||
- **Option B**: adopt a grid library (e.g., `react-grid-layout`) for drag/resize.
|
||||
- **Recommendation**: Option A for Phase 1 to respect the thin-dashboard aesthetic and review budget.
|
||||
|
||||
### Routing
|
||||
|
||||
- Addon pages under `/addons/{addon_id}` avoids collisions and keeps the namespace clean.
|
||||
- Alternatively top-level routes if the UX demands it.
|
||||
|
||||
### Backend API surface
|
||||
|
||||
Proposed endpoints:
|
||||
|
||||
- `GET /api/widgets/sources` — list available source types.
|
||||
- `GET /api/widgets/types` — list widget types per addon.
|
||||
- `GET /api/widgets/instances` — persisted dashboard widget instances.
|
||||
- `POST /api/widgets/instances` — create instance.
|
||||
- `PUT /api/widgets/instances/{id}` — update instance.
|
||||
- `DELETE /api/widgets/instances/{id}` — delete instance.
|
||||
- `GET /api/widgets/instances/{id}/data` — fetch widget data via source adapter.
|
||||
|
||||
### Admin vs user configuration
|
||||
|
||||
- Today there is no RBAC; Settings is implicitly admin.
|
||||
- Widget configuration can live in Settings or a new “Dashboard settings” mode.
|
||||
- Keep it simple: global config, editable by any authenticated user.
|
||||
|
||||
### Default widgets
|
||||
|
||||
- Seed new installs with the existing defaults: Jellyfin activity, Backup summary.
|
||||
- This preserves today’s out-of-box dashboard while making it configurable.
|
||||
|
||||
### Error / loading states
|
||||
|
||||
- Reuse `Skeleton`, `Alert`, `EmptyState` patterns from `ObservabilityPage`.
|
||||
- Each widget fails independently; the dashboard continues to render.
|
||||
|
||||
## 6. Patterns to Reuse
|
||||
|
||||
- **UI containers**: `SectionCard`, `MetricCard`, `Card`, `Badge`.
|
||||
- **Data fetching**: TanStack Query hooks with `refetchInterval`.
|
||||
- **Local state**: `usePersistentState`.
|
||||
- **Backend persistence**: `SettingsStore` JSON-column CRUD.
|
||||
- **Dependency injection**: FastAPI `Depends` + machine/client resolution.
|
||||
- **Type contracts**: Pydantic models in `backend/src/media_library_viewer_api/models/`.
|
||||
- **Lazy loading**: `React.lazy` for optional addon frontends.
|
||||
|
||||
## 7. Open Questions for Proposal
|
||||
|
||||
1. Should Phase 1 support runtime addon discovery, or a closed built-in widget set?
|
||||
2. Do we need a grid layout with drag/resize, or is the existing stacked SectionCard list sufficient?
|
||||
3. Should widget configuration be global or per-user?
|
||||
4. Which sources are in Phase 1? (Recommended: Jellyfin, Backups, Grafana link, Prometheus instant query, SSH saved task.)
|
||||
5. Do we want addon pages to be iframes (e.g., Grafana) or custom React pages?
|
||||
@@ -0,0 +1,155 @@
|
||||
# SDD Proposal: Configurable Dashboard Widgets
|
||||
|
||||
**Change:** `configurable-dashboard-widgets`
|
||||
**Phase:** proposal
|
||||
**Date:** 2026-06-19
|
||||
|
||||
## 1. Problem / Why Now
|
||||
|
||||
The Manage dashboard (`frontend/src/pages/Dashboard.tsx`) currently renders three hard-coded sections: Shortcuts, Jellyfin activity, and Backups. Each new source of at-a-glance information requires editing the dashboard component and adding ad hoc backend endpoints. The user wants to surface information from many sources—Grafana, Jellyfin, SSH scripts, Prometheus, and more—without rebuilding the dashboard every time. We need a small, extensible widget system that makes the dashboard configurable while keeping the implementation within the existing FastAPI/React stack and the current thin-dashboard aesthetic.
|
||||
|
||||
## 2. Target Users and Situations
|
||||
|
||||
- **Primary users:** Homelab operators and small-team admins who open Manage to check overall system health.
|
||||
- **Workflow moments:**
|
||||
- First login of the day: scan backup status, Jellyfin activity, and key Prometheus metrics.
|
||||
- Troubleshooting: jump from a widget into a dedicated addon page (e.g., Grafana dashboard, saved SSH task output).
|
||||
- Onboarding a new machine: add a widget that exposes a saved SSH task or Prometheus query without a code change.
|
||||
- **Urgency:** Medium. The existing dashboard already works; the pain is maintainability and visibility into an expanding set of sources.
|
||||
|
||||
## 3. Product Outcome
|
||||
|
||||
After Phase 1, an authenticated user can:
|
||||
|
||||
- See the existing dashboard sections rendered as configurable widgets.
|
||||
- Add, edit, remove, enable/disable, and reorder widgets from a single global dashboard configuration.
|
||||
- Choose from a built-in set of widget types backed by Jellyfin, backup summaries, Grafana deep-links, Prometheus instant queries, and SSH saved-task output.
|
||||
- Open dedicated addon pages under `/addons/{addon_id}` for widgets that need more space (e.g., Grafana details).
|
||||
- Continue using the familiar stacked `SectionCard` layout on desktop and mobile.
|
||||
|
||||
## 4. Scope Boundaries (Phase 1) and Non-Goals
|
||||
|
||||
### In scope for Phase 1
|
||||
|
||||
- A closed, compile-time widget registry in both frontend and backend.
|
||||
- Five source types:
|
||||
1. `jellyfin` — activity/counts (reuses existing `useActivity` / counts data).
|
||||
2. `backups` — backup summary (reuses `BackupDashboardWidget` logic).
|
||||
3. `grafana` — deep-link to a Grafana dashboard or panel.
|
||||
4. `prometheus` — instant query result rendered as a metric or spark value.
|
||||
5. `ssh_task` — output of a saved task (reuses saved task registry and `run_task`).
|
||||
- Optional `static` text/markdown widget to dog-food the configuration UI.
|
||||
- Global dashboard widget config persisted in SQLite and editable by any authenticated user.
|
||||
- Addon pages rendered as custom React pages under `/addons/{addon_id}`; Grafana widgets deep-link to Grafana instead of embedding.
|
||||
- Stacked `SectionCard` layout; no grid, drag, or resize.
|
||||
|
||||
### Non-goals (explicitly out of scope)
|
||||
|
||||
- Runtime addon discovery or dynamic component loading.
|
||||
- Per-user widget configuration.
|
||||
- Grid/drag/resize layout engine.
|
||||
- Iframe embedding of Grafana or any other external UI.
|
||||
- Public/unauthenticated widget access.
|
||||
- Generic "run any script" widget; only saved tasks from the existing registry are allowed.
|
||||
- Real-time WebSocket updates; polling via TanStack Query refetch intervals is sufficient.
|
||||
|
||||
## 5. High-Level Approach
|
||||
|
||||
### 5.1 Backend
|
||||
|
||||
1. **Data model**
|
||||
- Add a `dashboard_widgets` table in `SettingsStore`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE 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,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_dashboard_widgets_sort ON dashboard_widgets(sort_order);
|
||||
```
|
||||
|
||||
- `config_json` stores source-specific settings (e.g., `machine_id`, `dashboard_uid`, `promql`, `task_id`). No secrets are stored here.
|
||||
|
||||
2. **Widget source adapters**
|
||||
- Introduce a small protocol/interface, e.g. `WidgetSource`:
|
||||
|
||||
```python
|
||||
class WidgetSource(Protocol):
|
||||
source_type: str
|
||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ...
|
||||
```
|
||||
|
||||
- Implement one adapter per source type. Adapters reuse existing dependency-injection helpers (`get_jellyfin_client`, `get_ssh_client`, saved task registry, Grafana/Prometheus URLs from `get_settings()`).
|
||||
|
||||
3. **API surface**
|
||||
- `GET /api/widgets/sources` — list available source types.
|
||||
- `GET /api/widgets/types` — list built-in widget types per addon.
|
||||
- `GET /api/widgets/instances` — persisted widget instances.
|
||||
- `POST /api/widgets/instances` — create instance.
|
||||
- `PUT /api/widgets/instances/{id}` — update instance.
|
||||
- `DELETE /api/widgets/instances/{id}` — delete instance.
|
||||
- `GET /api/widgets/instances/{id}/data` — fetch data via the source adapter.
|
||||
|
||||
4. **Default data**
|
||||
- On first install, seed `dashboard_widgets` with the existing defaults: Jellyfin activity and Backup summary. Existing dashboards keep their current behavior after upgrade.
|
||||
|
||||
### 5.2 Frontend
|
||||
|
||||
1. **Widget registry**
|
||||
- A static TypeScript map: `widget_type -> { component, defaultConfig, configSchema }`.
|
||||
- Components render inside the existing `SectionCard` container and use `MetricCard`, `Skeleton`, `Alert`, and `Badge` patterns already present in `ObservabilityPage`.
|
||||
|
||||
2. **Dashboard rendering**
|
||||
- `Dashboard.tsx` replaces its three hard-coded sections with a loop over widget instances returned by `useWidgetsInstances()`.
|
||||
- Each widget fetches its own data through `useWidgetData(widgetId, refreshInterval)` with TanStack Query `refetchInterval`.
|
||||
|
||||
3. **Configuration UI**
|
||||
- Add an "Edit dashboard" action that opens a dialog/panel listing widget instances.
|
||||
- Reuse the form patterns from `ShortcutDialog` and `Settings.tsx` for add/edit widget forms.
|
||||
- Source-specific fields are rendered by small config sub-forms registered next to each widget type.
|
||||
|
||||
4. **Addon pages**
|
||||
- Register a wildcard-ish route `/addons/:addonId` in `App.tsx` that renders an `AddonPage` component.
|
||||
- `AddonPage` looks up the addon in a static map and renders its dedicated page component (e.g., `GrafanaAddonPage`).
|
||||
- Sidebar/nav items for addons are added to the existing `navItems` array in Phase 1; dynamic nav is deferred to a future phase.
|
||||
|
||||
### 5.3 Type contracts
|
||||
|
||||
- Add Pydantic models in `backend/src/media_library_viewer_api/models/` for `WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse`.
|
||||
- Add matching TypeScript interfaces in `frontend/src/types/index.ts`.
|
||||
|
||||
## 6. Success Criteria / Acceptance Criteria
|
||||
|
||||
1. A fresh install shows the Jellyfin activity and Backup summary widgets by default.
|
||||
2. An authenticated user can add, edit, enable/disable, delete, and reorder widgets; changes persist across reloads.
|
||||
3. All five Phase 1 source types can be selected and rendered without errors when configured correctly.
|
||||
4. A misconfigured widget fails gracefully: the rest of the dashboard renders, and the widget shows an error state.
|
||||
5. Addon page route `/addons/{addon_id}` renders a custom React page for the selected addon.
|
||||
6. Existing backend tests and frontend typecheck (`npm run build`) continue to pass.
|
||||
7. No secrets are stored in `config_json`.
|
||||
|
||||
## 7. Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Scope creep toward a full grid/layout engine | Document and enforce Phase 1 non-goals; keep stacked `SectionCard` layout. |
|
||||
| Widget source adapters duplicating backend logic | Reuse existing routers/clients via dependency injection rather than reimplementing endpoints. |
|
||||
| Slow SSH-task widgets blocking dashboard renders | Fetch each widget independently; short timeouts; display loading/error states per widget. |
|
||||
| Secrets leaking into widget config | Validate config schema server-side; reject credential fields; rely on machine/SSH key store and env settings. |
|
||||
| Upgrade path breaks existing dashboards | Seed default widget rows on first install only; leave existing shortcuts/sections untouched. |
|
||||
| Review budget overrun (~400 changed lines) | Keep the registry closed and compile-time; avoid generic schema editors; defer dynamic routing. |
|
||||
|
||||
## 8. Future Phases
|
||||
|
||||
1. **Per-user dashboards** — add `user_id` column and UI toggle between global and personal layouts.
|
||||
2. **Runtime addon discovery** — backend serves an addon manifest; frontend lazily loads addon page modules.
|
||||
3. **Grid layout** — optional `react-grid-layout` integration with drag/resize behind a feature flag.
|
||||
4. **Additional sources** — Alertmanager summary, Loki log snippets, custom HTTP endpoints, Jellyseerr requests.
|
||||
5. **Widget templates/export** — import/export widget layouts and shareable presets.
|
||||
@@ -0,0 +1,576 @@
|
||||
# Dashboard Widgets Specification
|
||||
|
||||
> Domain: `dashboard-widgets` · Change: `configurable-dashboard-widgets`
|
||||
> Full spec (no prior canonical spec exists for this domain).
|
||||
|
||||
## Purpose
|
||||
|
||||
Define WHAT must be true after Phase 1 of the configurable dashboard widgets change: the Manage dashboard becomes a persisted, configurable stack of widget instances backed by a closed, compile-time registry. Authenticated users can add, edit, enable/disable, reorder, and remove widgets; widget data is fetched independently; misconfigured widgets fail gracefully; and addon pages render under `/addons/{addon_id}`.
|
||||
|
||||
## Scope Summary
|
||||
|
||||
### In scope
|
||||
|
||||
- Closed compile-time widget/source registries in the backend and frontend.
|
||||
- SQLite persistence of widget instances (`dashboard_widgets` table).
|
||||
- Source adapters for `jellyfin`, `backups`, `grafana`, `prometheus`, and `ssh_task`, plus a `static` text/markdown widget.
|
||||
- REST API for widget instance CRUD and per-instance data fetch.
|
||||
- Dashboard rendering loop in `Dashboard.tsx` using widget instances.
|
||||
- Configuration UI for add/edit/reorder/remove widgets.
|
||||
- Addon page route `/addons/:addonId` with a static addon page registry.
|
||||
- Default widget seeding on first install.
|
||||
|
||||
### Out of scope (reminders)
|
||||
|
||||
- Runtime addon discovery or dynamic component loading.
|
||||
- Per-user widget configuration.
|
||||
- Grid, drag, or resize layout engine.
|
||||
- Iframe embedding of Grafana or any external UI.
|
||||
- Public/unauthenticated widget access.
|
||||
- Generic "run any script" widget; only saved tasks from the existing registry are allowed.
|
||||
- Real-time WebSocket updates.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Widget instance persistence
|
||||
|
||||
The backend MUST persist widget instances in a `dashboard_widgets` table with the following columns and invariants:
|
||||
|
||||
- `id` TEXT PRIMARY KEY
|
||||
- `addon_id` TEXT NOT NULL
|
||||
- `widget_type` TEXT NOT NULL
|
||||
- `title` TEXT NOT NULL
|
||||
- `config_json` TEXT NOT NULL (source-specific JSON config)
|
||||
- `enabled` INTEGER NOT NULL DEFAULT 1
|
||||
- `sort_order` INTEGER NOT NULL DEFAULT 0
|
||||
- `created_at` INTEGER NOT NULL
|
||||
- `updated_at` INTEGER NOT NULL
|
||||
|
||||
The table MUST have an index on `sort_order` named `idx_dashboard_widgets_sort`.
|
||||
|
||||
The `SettingsStore` MUST provide CRUD helpers that return plain Python dicts matching the API response shape. `config_json` MUST be stored as JSON text and validated on write.
|
||||
|
||||
#### Scenario: Create and read a widget instance
|
||||
|
||||
- GIVEN an empty `dashboard_widgets` table
|
||||
- WHEN the store creates a widget instance with `addon_id="core"`, `widget_type="static"`, `title="Notes"`, `config_json={"text":"hello"}`, `enabled=true`, `sort_order=1`
|
||||
- THEN `list_widgets()` returns a list containing one item with the same field values
|
||||
- AND `created_at` and `updated_at` are Unix epoch seconds
|
||||
|
||||
#### Scenario: Update enabled and sort_order
|
||||
|
||||
- GIVEN an existing widget instance
|
||||
- WHEN the store updates `enabled` to `false` and `sort_order` to `5`
|
||||
- THEN subsequent reads reflect the new values
|
||||
- AND `updated_at` is greater than or equal to the write time
|
||||
|
||||
#### Scenario: Delete a widget instance
|
||||
|
||||
- GIVEN an existing widget instance
|
||||
- WHEN the store deletes it by `id`
|
||||
- THEN `list_widgets()` no longer returns that instance
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Default widget seeding on first install
|
||||
|
||||
On first install (when `dashboard_widgets` is empty during startup or `ensure_defaults`), the system MUST seed exactly two default widget instances:
|
||||
|
||||
1. `addon_id="core"`, `widget_type="jellyfin"`, `title="Jellyfin activity"`, enabled, sort_order before backups.
|
||||
2. `addon_id="backups"`, `widget_type="backups"`, `title="Backups"`, enabled, sort_order after Jellyfin.
|
||||
|
||||
Existing installations with one or more widget rows MUST NOT be modified by the seeding logic.
|
||||
|
||||
#### Scenario: Fresh install shows default widgets
|
||||
|
||||
- GIVEN a fresh settings database with no `dashboard_widgets` rows
|
||||
- WHEN the backend starts or `ensure_defaults()` runs
|
||||
- THEN `GET /api/widgets/instances` returns exactly the Jellyfin activity and Backups widgets in that order
|
||||
- AND both are enabled
|
||||
|
||||
#### Scenario: Existing install is not re-seeded
|
||||
|
||||
- GIVEN a settings database with at least one `dashboard_widgets` row
|
||||
- WHEN the backend starts
|
||||
- THEN the existing widget rows remain unchanged
|
||||
- AND no new default rows are inserted
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Closed widget and source registries
|
||||
|
||||
The widget system MUST use a closed, compile-time registry. The backend MUST reject any `widget_type` not in the registry and any `source_type` without a registered adapter.
|
||||
|
||||
Phase 1 built-in widget types:
|
||||
|
||||
| `widget_type` | `addon_id` | Source adapter | Purpose |
|
||||
|---|---|---|---|
|
||||
| `jellyfin` | `core` | `jellyfin` | Activity/counts from a Jellyfin machine |
|
||||
| `backups` | `backups` | `backups` | Backup summary stats |
|
||||
| `grafana-link` | `grafana` | `grafana` | Deep-link to a Grafana dashboard or panel |
|
||||
| `prometheus-metric` | `prometheus` | `prometheus` | Instant query rendered as a metric |
|
||||
| `ssh-task` | `ssh-tasks` | `ssh_task` | Output of a saved task |
|
||||
| `static` | `core` | `static` | Plain text/markdown widget |
|
||||
|
||||
#### Scenario: Unknown widget type is rejected
|
||||
|
||||
- GIVEN a `POST /api/widgets/instances` request with `widget_type="unknown"`
|
||||
- WHEN the request is processed
|
||||
- THEN the response status is `422 Unprocessable Entity`
|
||||
- AND the response body contains a validation error naming the unsupported widget type
|
||||
|
||||
#### Scenario: Source registry is fixed
|
||||
|
||||
- GIVEN `GET /api/widgets/sources`
|
||||
- WHEN the endpoint responds
|
||||
- THEN the list contains exactly `jellyfin`, `backups`, `grafana`, `prometheus`, `ssh_task`, and `static`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Widget config validation
|
||||
|
||||
Each widget type MUST have a JSON config schema. The backend MUST validate `config_json` against the schema on create and update and reject credential fields.
|
||||
|
||||
The following keys are forbidden anywhere in `config_json` (case-insensitive):
|
||||
|
||||
- `password`, `token`, `secret`, `api_key`, `apikey`, `private_key`, `passphrase`, `credential`
|
||||
|
||||
Any value that is a non-empty string and looks like a secret (e.g., starts with `sk-`, `eyJ`, or is longer than 64 random-looking characters) SHOULD be rejected as a defense-in-depth measure.
|
||||
|
||||
#### Scenario: Valid static widget config passes
|
||||
|
||||
- GIVEN a `POST /api/widgets/instances` request with `widget_type="static"` and `config_json={"text":"Hello"}`
|
||||
- WHEN the request is processed
|
||||
- THEN the response status is `200 OK` or `201 Created`
|
||||
- AND the stored `config_json` equals the submitted value
|
||||
|
||||
#### Scenario: Credential field in config is rejected
|
||||
|
||||
- GIVEN a `POST /api/widgets/instances` request with `config_json={"api_key":"abc123"}`
|
||||
- WHEN the request is processed
|
||||
- THEN the response status is `422 Unprocessable Entity`
|
||||
- AND the error message indicates that credential fields are not allowed
|
||||
|
||||
#### Scenario: Jellyfin config requires machine_id
|
||||
|
||||
- GIVEN a `POST` for `widget_type="jellyfin"` with `config_json={}`
|
||||
- WHEN the request is processed
|
||||
- THEN the response status is `422 Unprocessable Entity`
|
||||
- AND the error indicates that `machine_id` is required
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Widget source adapters
|
||||
|
||||
Each source adapter MUST implement a uniform async interface:
|
||||
|
||||
```python
|
||||
class WidgetSource(Protocol):
|
||||
source_type: str
|
||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ...
|
||||
```
|
||||
|
||||
Adapters MUST reuse existing dependency-injection helpers and MUST NOT reimplement client logic:
|
||||
|
||||
- `jellyfin`: `get_jellyfin_client` + existing `client.sessions()` / counts.
|
||||
- `backups`: `BackupDashboardSummary` building logic from `dashboard.py`.
|
||||
- `grafana`: `get_settings()` Grafana URL; only returns deep-link metadata, never embeds.
|
||||
- `prometheus`: `get_settings()` Prometheus URL; performs an instant query via HTTP.
|
||||
- `ssh_task`: existing saved task registry + `run_task` helper.
|
||||
- `static`: returns the text/markdown from `config_json` unchanged.
|
||||
|
||||
Adapters MUST catch their own exceptions and return an error payload; they MUST NOT raise unhandled exceptions into the endpoint.
|
||||
|
||||
#### Scenario: Jellyfin adapter returns sessions
|
||||
|
||||
- GIVEN a Jellyfin widget configured with a valid `machine_id`
|
||||
- WHEN `GET /api/widgets/instances/{id}/data` is called
|
||||
- THEN the response contains a `data` field with activity rows
|
||||
- AND `error` is null
|
||||
|
||||
#### Scenario: SSH task adapter times out gracefully
|
||||
|
||||
- GIVEN an `ssh-task` widget configured with a slow task
|
||||
- WHEN the adapter exceeds its timeout
|
||||
- THEN it returns `{ "error": "Widget data fetch timed out" }`
|
||||
- AND the HTTP endpoint still responds with `200 OK` carrying the error payload
|
||||
|
||||
---
|
||||
|
||||
### Requirement: API contract
|
||||
|
||||
The backend MUST expose the following endpoints under `/api/widgets`, protected by the existing JWT/API-key auth:
|
||||
|
||||
| Method | Path | Purpose | Success | Error |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/api/widgets/sources` | List source types | `200 OK` + list of strings | 401/403 |
|
||||
| GET | `/api/widgets/types` | List widget types per addon | `200 OK` + `WidgetTypeInfo[]` | 401/403 |
|
||||
| GET | `/api/widgets/instances` | List persisted instances | `200 OK` + `WidgetInstance[]` | 401/403 |
|
||||
| POST | `/api/widgets/instances` | Create instance | `201 Created` + `WidgetInstance` | 400/401/403/422 |
|
||||
| PUT | `/api/widgets/instances/{id}` | Update instance | `200 OK` + `WidgetInstance` | 400/401/403/404/422 |
|
||||
| DELETE | `/api/widgets/instances/{id}` | Delete instance | `200 OK` + `{status:"deleted"}` | 401/403/404 |
|
||||
| GET | `/api/widgets/instances/{id}/data` | Fetch widget data | `200 OK` + `WidgetDataResponse` | 401/403/404/500 |
|
||||
|
||||
`WidgetInstance` response fields (exact names):
|
||||
|
||||
- `id`: string
|
||||
- `addon_id`: string
|
||||
- `widget_type`: string
|
||||
- `title`: string
|
||||
- `config`: object (parsed JSON)
|
||||
- `enabled`: boolean
|
||||
- `sort_order`: number
|
||||
- `created_at`: number
|
||||
- `updated_at`: number
|
||||
|
||||
`WidgetInstanceInput` request fields:
|
||||
|
||||
- `id`: string | null (optional on create)
|
||||
- `addon_id`: string
|
||||
- `widget_type`: string
|
||||
- `title`: string
|
||||
- `config`: object
|
||||
- `enabled`: boolean
|
||||
- `sort_order`: number
|
||||
|
||||
`WidgetTypeInfo` fields:
|
||||
|
||||
- `addon_id`: string
|
||||
- `widget_type`: string
|
||||
- `name`: string
|
||||
- `description`: string
|
||||
- `source_type`: string
|
||||
- `config_schema`: JSON Schema object
|
||||
|
||||
`WidgetDataResponse` fields:
|
||||
|
||||
- `widget_id`: string
|
||||
- `widget_type`: string
|
||||
- `data`: object | null
|
||||
- `error`: string | null
|
||||
- `fetched_at`: number (Unix epoch seconds)
|
||||
|
||||
#### Scenario: Create widget instance via API
|
||||
|
||||
- GIVEN an authenticated `POST /api/widgets/instances` with a valid `WidgetInstanceInput`
|
||||
- WHEN the request is processed
|
||||
- THEN the response status is `201 Created`
|
||||
- AND the response body contains the created `WidgetInstance` with a generated `id`
|
||||
|
||||
#### Scenario: Update nonexistent widget returns 404
|
||||
|
||||
- GIVEN an authenticated `PUT /api/widgets/instances/does-not-exist`
|
||||
- WHEN the request is processed
|
||||
- THEN the response status is `404 Not Found`
|
||||
|
||||
#### Scenario: Data endpoint returns error for misconfigured widget
|
||||
|
||||
- GIVEN a widget whose adapter returns an error payload
|
||||
- WHEN `GET /api/widgets/instances/{id}/data` is called
|
||||
- THEN the response status is `200 OK`
|
||||
- AND `error` is a non-empty string
|
||||
- AND `data` is null
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Type contracts
|
||||
|
||||
The Pydantic models in the backend and the TypeScript interfaces in the frontend MUST use the exact field names listed above.
|
||||
|
||||
Backend Pydantic models MUST live in `backend/src/media_library_viewer_api/models/widgets.py` and MUST include:
|
||||
|
||||
- `WidgetInstance`
|
||||
- `WidgetInstanceInput`
|
||||
- `WidgetTypeInfo`
|
||||
- `WidgetDataResponse`
|
||||
|
||||
Frontend TypeScript interfaces MUST be added to `frontend/src/types/index.ts`:
|
||||
|
||||
- `WidgetInstance`
|
||||
- `WidgetInstanceInput`
|
||||
- `WidgetTypeInfo`
|
||||
- `WidgetDataResponse`
|
||||
- `WidgetSource` (string union of source types)
|
||||
|
||||
#### Scenario: Backend model serializes config as object
|
||||
|
||||
- GIVEN a `WidgetInstance` model initialized from a database row with `config_json='{"text":"x"}'`
|
||||
- WHEN it is serialized with `model_dump()`
|
||||
- THEN `config` is the parsed object `{"text":"x"}`
|
||||
|
||||
#### Scenario: Frontend type matches API response
|
||||
|
||||
- GIVEN the `WidgetInstance` TypeScript interface
|
||||
- WHEN a widget instance payload from `GET /api/widgets/instances` is typed with it
|
||||
- THEN `npm run build` succeeds without type errors
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Dashboard rendering loop
|
||||
|
||||
`frontend/src/pages/Dashboard.tsx` MUST render widget instances returned by `useWidgetInstances()` instead of the three hard-coded sections.
|
||||
|
||||
The dashboard MUST:
|
||||
|
||||
- Query widget instances on mount.
|
||||
- Render only instances with `enabled === true`.
|
||||
- Sort enabled instances by `sort_order` ascending.
|
||||
- Render each widget inside the existing `SectionCard` container.
|
||||
- Pass the widget instance to a registered widget component.
|
||||
- Preserve the existing stacked layout (`flex flex-col gap-4`).
|
||||
- Keep the existing Shortcuts functionality as a widget type or continue to support it as a first-class widget instance (`widget_type="shortcuts"` or equivalent) so that no data is lost.
|
||||
|
||||
#### Scenario: Fresh install rendering
|
||||
|
||||
- GIVEN a fresh install with default widgets
|
||||
- WHEN the Dashboard page loads
|
||||
- THEN it renders the Jellyfin activity widget followed by the Backups widget
|
||||
- AND both fetch their own data independently
|
||||
|
||||
#### Scenario: Disabled widget is hidden
|
||||
|
||||
- GIVEN a widget instance with `enabled=false`
|
||||
- WHEN the Dashboard renders
|
||||
- THEN that widget is not rendered
|
||||
- AND the remaining widgets maintain their sort order
|
||||
|
||||
#### Scenario: Misconfigured widget fails gracefully
|
||||
|
||||
- GIVEN a dashboard with one valid widget and one widget whose data endpoint returns an error
|
||||
- WHEN the Dashboard renders
|
||||
- THEN the valid widget displays normally
|
||||
- AND the failing widget renders an inline `Alert` with the error message
|
||||
- AND the rest of the dashboard is not blocked
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Independent widget data fetching
|
||||
|
||||
Each widget MUST fetch its own data independently via `useWidgetData(widgetId, refreshInterval)`. The hook MUST use TanStack Query with a per-widget `refetchInterval`.
|
||||
|
||||
Default refresh intervals:
|
||||
|
||||
- `jellyfin`: 30 seconds
|
||||
- `backups`: 60 seconds
|
||||
- `grafana`: 0 (no polling; static link)
|
||||
- `prometheus`: 30 seconds
|
||||
- `ssh_task`: 0 (fetch on mount only; heavy)
|
||||
- `static`: 0
|
||||
|
||||
A widget component MUST show a loading state while data is being fetched for the first time and MUST show an error state if `error` is non-null.
|
||||
|
||||
#### Scenario: Jellyfin widget auto-refreshes
|
||||
|
||||
- GIVEN a rendered Jellyfin widget
|
||||
- WHEN 30 seconds elapse
|
||||
- THEN `useWidgetData` refetches the data automatically
|
||||
|
||||
#### Scenario: Grafana widget does not poll
|
||||
|
||||
- GIVEN a rendered Grafana-link widget
|
||||
- WHEN it mounts
|
||||
- THEN it fetches data once to build the deep-link
|
||||
- AND it does not refetch automatically
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Configuration UI
|
||||
|
||||
The Dashboard MUST provide an "Edit dashboard" action that opens a configuration panel or dialog. The panel MUST allow the user to:
|
||||
|
||||
- See all widget instances (enabled and disabled).
|
||||
- Add a new widget by choosing a widget type from the closed registry.
|
||||
- Edit a widget's `title`, `enabled` flag, `sort_order`, and source-specific `config`.
|
||||
- Remove a widget with a confirmation step.
|
||||
- Reorder widgets by changing `sort_order` (simple numeric input or up/down buttons).
|
||||
|
||||
Source-specific config fields MUST be rendered by small sub-forms registered next to each widget type in the frontend registry.
|
||||
|
||||
The UI MUST reuse existing shadcn/ui form patterns (`Dialog`, `Input`, `Label`, `Switch`, `Select`, `Button`, `Alert`).
|
||||
|
||||
#### Scenario: User adds a Grafana-link widget
|
||||
|
||||
- GIVEN the dashboard configuration panel is open
|
||||
- WHEN the user selects widget type `grafana-link`, enters `title="Grafana Overview"`, `config.dashboard_uid="overview"`, and saves
|
||||
- THEN a new widget instance is persisted
|
||||
- AND it appears on the dashboard with a deep-link to Grafana
|
||||
|
||||
#### Scenario: User disables a widget
|
||||
|
||||
- GIVEN the dashboard configuration panel is open and a widget is enabled
|
||||
- WHEN the user toggles its `enabled` switch off and saves
|
||||
- THEN the widget disappears from the dashboard
|
||||
- AND it remains in the instances list with `enabled=false`
|
||||
|
||||
#### Scenario: Reorder widgets
|
||||
|
||||
- GIVEN two widgets with sort_order 0 and 1
|
||||
- WHEN the user swaps their sort_order values and saves
|
||||
- THEN the dashboard re-renders them in the new order
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Addon pages
|
||||
|
||||
The frontend MUST register a route `/addons/:addonId` in `App.tsx`. The `AddonPage` component MUST look up `addonId` in a static addon registry and render the matching page component.
|
||||
|
||||
Phase 1 addon registry MUST include at least:
|
||||
|
||||
- `grafana` — `GrafanaAddonPage`
|
||||
- `prometheus` — `PrometheusAddonPage`
|
||||
- `ssh-tasks` — `SshTasksAddonPage`
|
||||
|
||||
Navigating to an unknown `addonId` MUST render a 404-style message inside the page shell.
|
||||
|
||||
Grafana widgets MUST deep-link to Grafana (using env-configured URL) instead of embedding.
|
||||
|
||||
#### Scenario: Addon page navigation
|
||||
|
||||
- GIVEN the user clicks "Open Grafana addon" from a Grafana widget
|
||||
- WHEN the browser navigates to `/addons/grafana`
|
||||
- THEN the `GrafanaAddonPage` component renders
|
||||
- AND the page shows Grafana deep-links and no iframe
|
||||
|
||||
#### Scenario: Unknown addon page
|
||||
|
||||
- GIVEN a navigation to `/addons/unknown`
|
||||
- WHEN the route resolves
|
||||
- THEN the page renders an `Alert` stating the addon is not found
|
||||
- AND the sidebar and shell remain intact
|
||||
|
||||
---
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
### Requirement: Security — no secrets in widget config
|
||||
|
||||
The system MUST ensure that widget `config_json` never stores secrets. Credential detection MUST be applied both at the Pydantic model level and at the store write level. Backend adapters MUST resolve credentials from the existing machine/SSH-key store or environment settings.
|
||||
|
||||
#### Scenario: Secret-looking value rejected
|
||||
|
||||
- GIVEN a widget config containing `"token": "super-secret-api-token-value"`
|
||||
- WHEN the create/update endpoint processes it
|
||||
- THEN the request is rejected with `422 Unprocessable Entity`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Performance — independent fetches and timeouts
|
||||
|
||||
Each widget data fetch MUST be independent. A slow or failing adapter MUST NOT block other widgets or the dashboard render. Adapters MUST apply a short timeout:
|
||||
|
||||
- `jellyfin`: 10 seconds
|
||||
- `backups`: 10 seconds
|
||||
- `prometheus`: 10 seconds
|
||||
- `ssh_task`: 30 seconds
|
||||
- `grafana`: 5 seconds
|
||||
- `static`: no fetch
|
||||
|
||||
The dashboard MUST render the widget chrome immediately and show loading skeletons while data loads.
|
||||
|
||||
#### Scenario: Slow widget does not block dashboard
|
||||
|
||||
- GIVEN a dashboard with three widgets, one of which takes 25 seconds
|
||||
- WHEN the dashboard loads
|
||||
- THEN the other two widgets render their data immediately
|
||||
- AND the slow widget shows a loading skeleton until it completes or times out
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Maintainability — closed registry
|
||||
|
||||
The widget and source registries MUST be closed and compile-time. Adding a new widget type or source adapter MUST require a code change in both backend and frontend registries. There MUST be no plugin loading, dynamic imports, or runtime manifests in Phase 1.
|
||||
|
||||
#### Scenario: Registry is discoverable in source
|
||||
|
||||
- GIVEN the source code
|
||||
- WHEN searching for the list of supported widget types
|
||||
- THEN it is found as an explicit map/list in the backend and frontend source files
|
||||
|
||||
---
|
||||
|
||||
## Invariants and Validation Rules
|
||||
|
||||
1. `widget_type` MUST be in the closed registry.
|
||||
2. `addon_id` MUST match the addon registered for the widget type.
|
||||
3. `config_json` MUST be valid JSON and MUST validate against the widget type's JSON schema.
|
||||
4. `config_json` MUST NOT contain keys matching the forbidden credential list.
|
||||
5. `sort_order` MUST be a non-negative integer.
|
||||
6. `enabled` MUST be a boolean.
|
||||
7. The data endpoint for a disabled widget MUST still function if called directly, but the dashboard MUST NOT render it.
|
||||
8. A widget instance's `id` MUST be immutable after creation.
|
||||
9. Source adapters MUST be stateless and MUST NOT persist widget-specific secrets.
|
||||
10. Addon page components MUST NOT embed external iframes.
|
||||
|
||||
## Error Handling Requirements
|
||||
|
||||
| Flow / Endpoint | Expected Error Condition | Response |
|
||||
|---|---|---|
|
||||
| `GET /api/widgets/instances` | Unauthenticated | `401 Unauthorized` |
|
||||
| `POST /api/widgets/instances` | Invalid JSON | `400 Bad Request` |
|
||||
| `POST /api/widgets/instances` | Unknown `widget_type` | `422 Unprocessable Entity` |
|
||||
| `POST /api/widgets/instances` | Config fails schema validation | `422 Unprocessable Entity` |
|
||||
| `POST /api/widgets/instances` | Config contains credential key | `422 Unprocessable Entity` |
|
||||
| `PUT /api/widgets/instances/{id}` | Widget not found | `404 Not Found` |
|
||||
| `PUT /api/widgets/instances/{id}` | ID in path mismatches body | `400 Bad Request` |
|
||||
| `DELETE /api/widgets/instances/{id}` | Widget not found | `404 Not Found` |
|
||||
| `GET /api/widgets/instances/{id}/data` | Widget not found | `404 Not Found` |
|
||||
| `GET /api/widgets/instances/{id}/data` | Adapter raises unhandled exception | `500 Internal Server Error` with a safe message |
|
||||
| `GET /api/widgets/instances/{id}/data` | Adapter returns error payload | `200 OK` with `error` set |
|
||||
| Dashboard render | Widget data hook errors | Inline error state; dashboard continues |
|
||||
| Configuration UI | Network error on save | Inline `Alert`; form remains open |
|
||||
|
||||
## Scenario Catalog
|
||||
|
||||
### Scenario: Fresh install shows default widgets
|
||||
|
||||
- GIVEN a fresh settings database
|
||||
- WHEN the backend starts and the Dashboard page loads
|
||||
- THEN `GET /api/widgets/instances` returns two enabled widgets: Jellyfin activity and Backups
|
||||
- AND the Dashboard renders them in order
|
||||
|
||||
### Scenario: User adds a Grafana-link widget
|
||||
|
||||
- GIVEN the Dashboard configuration panel is open
|
||||
- WHEN the user chooses `grafana-link`, sets `title="Grafana Overview"`, `config.dashboard_uid="overview"`, and saves
|
||||
- THEN `POST /api/widgets/instances` succeeds
|
||||
- AND the new widget appears on the dashboard
|
||||
- AND clicking the widget opens the Grafana dashboard in a new tab
|
||||
|
||||
### Scenario: User disables a widget
|
||||
|
||||
- GIVEN a widget is enabled and visible on the dashboard
|
||||
- WHEN the user opens the configuration panel, toggles the widget off, and saves
|
||||
- THEN `PUT /api/widgets/instances/{id}` returns `enabled=false`
|
||||
- AND the widget is no longer rendered on the dashboard
|
||||
|
||||
### Scenario: Misconfigured widget fails gracefully
|
||||
|
||||
- GIVEN a `prometheus-metric` widget with an invalid `promql` query
|
||||
- WHEN the dashboard renders
|
||||
- THEN the widget shows an error Alert with a message from the adapter
|
||||
- AND all other widgets render normally
|
||||
- AND the dashboard remains scrollable and interactive
|
||||
|
||||
### Scenario: Addon page navigation
|
||||
|
||||
- GIVEN a Grafana widget with a configured dashboard
|
||||
- WHEN the user clicks the addon deep-link
|
||||
- THEN the browser navigates to `/addons/grafana`
|
||||
- AND the `GrafanaAddonPage` renders with relevant deep-links
|
||||
- AND no iframe is present
|
||||
|
||||
## File Targets (Informative)
|
||||
|
||||
- Backend models: `backend/src/media_library_viewer_api/models/widgets.py`
|
||||
- Backend router: `backend/src/media_library_viewer_api/routers/widgets.py`
|
||||
- Backend source adapters: `backend/src/media_library_viewer_api/widgets/*.py`
|
||||
- Backend store: extend `backend/src/media_library_viewer_api/services/settings_store.py`
|
||||
- Backend main: register router in `backend/src/media_library_viewer_api/main.py`
|
||||
- Frontend types: `frontend/src/types/index.ts`
|
||||
- Frontend API client: `frontend/src/api/widgets.ts`
|
||||
- Frontend hooks: `frontend/src/hooks/useWidgets.ts`
|
||||
- Frontend widget registry: `frontend/src/widgets/registry.ts`
|
||||
- Frontend widget components: `frontend/src/widgets/*.tsx`
|
||||
- Frontend dashboard: `frontend/src/pages/Dashboard.tsx`
|
||||
- Frontend addon page: `frontend/src/pages/AddonPage.tsx`
|
||||
- Frontend app routes: `frontend/src/App.tsx`
|
||||
@@ -0,0 +1,270 @@
|
||||
# SDD Tasks: Configurable Dashboard Widgets
|
||||
|
||||
**Change:** `configurable-dashboard-widgets`
|
||||
**Phase:** tasks
|
||||
**Date:** 2026-06-19
|
||||
|
||||
## Review Workload Forecast
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Estimated changed lines | ~1,550–1,650 (sum of four implementation slices) |
|
||||
| 400-line budget risk | High |
|
||||
| Chained PRs recommended | Yes |
|
||||
| Suggested split | PR 1: Backend CRUD + default seeding → PR 2: Backend source adapters + data endpoint → PR 3: Frontend types/API/hooks/registry/components → PR 4: Dashboard loop + config UI + addon pages |
|
||||
| Delivery strategy | ask-on-risk |
|
||||
| Chain strategy | stacked-to-main |
|
||||
|
||||
```text
|
||||
Decision needed before apply: Yes
|
||||
Chained PRs recommended: Yes
|
||||
Chain strategy: stacked-to-main
|
||||
400-line budget risk: High
|
||||
```
|
||||
|
||||
> **Note:** The preflight preference is `single-PR-default`, but the Phase 1 implementation clearly exceeds the ~400 changed-line review budget. The recommended split above keeps every slice independently testable and green. Confirm the chained-PR strategy before moving to `sdd-apply`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 Goal
|
||||
|
||||
Replace the hard-coded dashboard sections in `frontend/src/pages/Dashboard.tsx` with a persisted, closed-registry widget system. Backend stores widget instances in SQLite, exposes CRUD + per-widget data endpoints, and provides source adapters for Jellyfin, backups, Grafana links, Prometheus instant queries, saved SSH tasks, and static text. Frontend renders enabled widgets in sort order, fetches data independently, and provides a configuration UI plus `/addons/:addonId` pages.
|
||||
|
||||
---
|
||||
|
||||
## Slice 1: Backend CRUD and default seeding
|
||||
|
||||
**Goal:** Persist widget instances and expose registry metadata + CRUD endpoints. Leave all source adapters and data fetch for Slice 2.
|
||||
|
||||
- [x] **1.1 Create widget Pydantic models**
|
||||
- Files: `backend/src/media_library_viewer_api/models/widgets.py` (new)
|
||||
- Lines: ~70
|
||||
- Dependencies: none
|
||||
- Details: Add `WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse`. Include credential-key validator (`password`, `token`, `secret`, `api_key`, etc.) and secret-looking-value heuristic.
|
||||
|
||||
- [x] **1.2 Create backend widget registry**
|
||||
- Files: `backend/src/media_library_viewer_api/widgets/__init__.py` (new), `backend/src/media_library_viewer_api/widgets/registry.py` (new)
|
||||
- Lines: ~50
|
||||
- Dependencies: 1.1
|
||||
- Details: Define `WIDGET_REGISTRY` mapping `widget_type` → `addon_id`, `name`, `description`, `source_type`, JSON Schema `config_schema` for all six Phase 1 types.
|
||||
|
||||
- [x] **1.3 Implement widgets router (CRUD + metadata)**
|
||||
- Files: `backend/src/media_library_viewer_api/routers/widgets.py` (new)
|
||||
- Lines: ~110
|
||||
- Dependencies: 1.1, 1.2
|
||||
- Details: Implement `GET /api/widgets/sources`, `GET /api/widgets/types`, `GET /api/widgets/instances`, `POST /api/widgets/instances` (201), `PUT /api/widgets/instances/{id}`, `DELETE /api/widgets/instances/{id}`. Validate `widget_type` and `addon_id` against registry; validate config schema; reject credential keys.
|
||||
|
||||
- [x] **1.4 Extend `SettingsStore` for `dashboard_widgets`**
|
||||
- Files: `backend/src/media_library_viewer_api/services/settings_store.py`
|
||||
- Lines: ~90
|
||||
- Dependencies: none
|
||||
- Details: Add table + index `idx_dashboard_widgets_sort`, `_row_to_widget`, `_normalize_widget_payload`, `list_widgets`, `get_widget`, `upsert_widget`, `delete_widget`, and `_seed_dashboard_widgets` (Jellyfin + Backups defaults only when table is empty).
|
||||
|
||||
- [x] **1.5 Register widgets router in `main.py`**
|
||||
- Files: `backend/src/media_library_viewer_api/main.py`
|
||||
- Lines: ~5
|
||||
- Dependencies: 1.3
|
||||
- Details: `app.include_router(widgets_router.router)`; endpoints inherit existing JWT/API-key middleware.
|
||||
|
||||
- [x] **1.6 Add backend tests for registry, CRUD, and seeding**
|
||||
- Files: `backend/tests/test_widgets.py` (new)
|
||||
- Lines: ~75
|
||||
- Dependencies: 1.3, 1.4
|
||||
- Details: Test sources/types lists, create/read/update/delete, unknown widget type → 422, credential key → 422, fresh-store seeding, existing store not re-seeded.
|
||||
|
||||
- [x] **1.7 Verify backend slice**
|
||||
- Run: `cd backend && ruff check . && PYTHONPATH=src pytest tests/test_widgets.py`
|
||||
|
||||
**Slice 1 total:** ~400 changed lines.
|
||||
|
||||
---
|
||||
|
||||
## Slice 2: Backend source adapters and data endpoint
|
||||
|
||||
**Goal:** Fetch widget data through stateless adapters reusing existing DI and clients.
|
||||
|
||||
- [ ] **2.1 Add observability URL settings**
|
||||
- Files: `backend/src/media_library_viewer_api/config.py`
|
||||
- Lines: ~15
|
||||
- Dependencies: none
|
||||
- Details: Add `grafana_url: str = "http://grafana:3000"` and `prometheus_url: str = "http://prometheus:9090"`.
|
||||
|
||||
- [ ] **2.2 Create source adapters**
|
||||
- Files: `backend/src/media_library_viewer_api/widgets/sources.py` (new)
|
||||
- Lines: ~200
|
||||
- Dependencies: 1.2, 2.1
|
||||
- Details: Implement `WidgetSource` protocol + adapters for `jellyfin`, `backups`, `grafana`, `prometheus`, `ssh_task`, `static`. Catch exceptions and return `{"error": "..."}`. Apply per-type timeouts (10 s / 10 s / 5 s / 10 s / 30 s / none).
|
||||
|
||||
- [ ] **2.3 Add per-widget data endpoint**
|
||||
- Files: `backend/src/media_library_viewer_api/routers/widgets.py`
|
||||
- Lines: ~35
|
||||
- Dependencies: 1.3, 2.2
|
||||
- Details: Implement `GET /api/widgets/instances/{id}/data`, returning `WidgetDataResponse` with `widget_id`, `widget_type`, `data`, `error`, `fetched_at`. Unhandled adapter exceptions → 500.
|
||||
|
||||
- [ ] **2.4 Share Jellyfin activity mapping helper**
|
||||
- Files: `backend/src/media_library_viewer_api/routers/dashboard.py`, `backend/src/media_library_viewer_api/domain/dashboard.py` (new)
|
||||
- Lines: ~25
|
||||
- Dependencies: 2.2
|
||||
- Details: Move `_map_sessions_to_activity_rows` to `domain/dashboard.py`; import it from both `routers/dashboard.py` and the Jellyfin adapter.
|
||||
|
||||
- [ ] **2.5 Add backend tests for adapters and data endpoint**
|
||||
- Files: `backend/tests/test_widgets.py`
|
||||
- Lines: ~85
|
||||
- Dependencies: 2.2, 2.3
|
||||
- Details: Test static widget data round-trip, misconfigured jellyfin returns `error` with HTTP 200, SSH task adapter timeout returns error payload, unhandled exception path returns 500.
|
||||
|
||||
- [ ] **2.6 Verify backend slice**
|
||||
- Run: `cd backend && ruff check . && PYTHONPATH=src pytest tests/test_widgets.py`
|
||||
|
||||
**Slice 2 total:** ~360 changed lines.
|
||||
|
||||
---
|
||||
|
||||
## Slice 3: Frontend types, API, hooks, registry, and widget components
|
||||
|
||||
**Goal:** Build the frontend widget runtime: types, API client, hooks, closed registry, and presentational components. No dashboard integration yet.
|
||||
|
||||
- [ ] **3.1 Add TypeScript widget interfaces**
|
||||
- Files: `frontend/src/types/index.ts`
|
||||
- Lines: ~45
|
||||
- Dependencies: none
|
||||
- Details: Add `WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse`, and `WidgetSource` union type with exact field names from the spec.
|
||||
|
||||
- [ ] **3.2 Create widget API client**
|
||||
- Files: `frontend/src/api/widgets.ts` (new)
|
||||
- Lines: ~60
|
||||
- Dependencies: 3.1
|
||||
- Details: Functions for `fetchWidgetSources`, `fetchWidgetTypes`, `fetchWidgetInstances`, `createWidgetInstance`, `updateWidgetInstance`, `deleteWidgetInstance`, `fetchWidgetData`.
|
||||
|
||||
- [ ] **3.3 Create widget TanStack Query hooks**
|
||||
- Files: `frontend/src/hooks/useWidgets.ts` (new)
|
||||
- Lines: ~70
|
||||
- Dependencies: 3.2
|
||||
- Details: `useWidgetInstances`, `useWidgetData(widgetId, refreshInterval)`, `useSaveWidgetInstance`, `useDeleteWidgetInstance`, `useWidgetSources`, `useWidgetTypes`. Use correct per-type `refetchInterval`.
|
||||
|
||||
- [ ] **3.4 Create frontend widget registry**
|
||||
- Files: `frontend/src/widgets/registry.ts` (new)
|
||||
- Lines: ~70
|
||||
- Dependencies: 3.1
|
||||
- Details: Define `WidgetConfigField`, `WidgetDefinition`, `WIDGET_REGISTRY` for all six types, `getWidgetDefinition`, plus `refreshInterval` defaults.
|
||||
|
||||
- [ ] **3.5 Implement widget presentational components**
|
||||
- Files: `frontend/src/widgets/JellyfinWidget.tsx`, `BackupsWidget.tsx`, `GrafanaLinkWidget.tsx`, `PrometheusMetricWidget.tsx`, `SshTaskWidget.tsx`, `StaticWidget.tsx`
|
||||
- Lines: ~150
|
||||
- Dependencies: 3.1, 3.3, 3.4
|
||||
- Details: Each component receives `widget: WidgetInstance` and renders inside the existing card patterns. Grafana widget renders an external deep-link only (no iframe).
|
||||
|
||||
- [ ] **3.6 Add frontend registry unit tests**
|
||||
- Files: `frontend/tests/widgets.test.mjs` (new)
|
||||
- Lines: ~40
|
||||
- Dependencies: 3.4
|
||||
- Details: Assert registry contains exactly six widget types and refresh intervals match spec.
|
||||
|
||||
- [ ] **3.7 Verify frontend slice**
|
||||
- Run: `cd frontend && npm run lint && npm run build`
|
||||
|
||||
**Slice 3 total:** ~435 changed lines.
|
||||
|
||||
---
|
||||
|
||||
## Slice 4: Dashboard loop, configuration UI, and addon pages
|
||||
|
||||
**Goal:** Wire widgets into the dashboard, add configuration UI, and add addon page routes.
|
||||
|
||||
- [ ] **4.1 Refactor `Dashboard.tsx` to render widget instances**
|
||||
- Files: `frontend/src/pages/Dashboard.tsx`
|
||||
- Lines: ~60
|
||||
- Dependencies: Slice 3
|
||||
- Details: Keep the existing Shortcuts section as a hard-coded first-class section (no migration). Add an "Edit dashboard" button. Render enabled widgets sorted by `sort_order` via `<WidgetInstance />`.
|
||||
|
||||
- [ ] **4.2 Create widget instance renderer**
|
||||
- Files: `frontend/src/components/WidgetInstance.tsx` (new)
|
||||
- Lines: ~40
|
||||
- Dependencies: 3.3, 3.4, 3.5
|
||||
- Details: Lookup definition, call `useWidgetData`, show skeleton on first load, render inline `Alert` for `error`, dispatch to registered component.
|
||||
|
||||
- [ ] **4.3 Create widget configuration dialog**
|
||||
- Files: `frontend/src/components/WidgetConfigDialog.tsx` (new)
|
||||
- Lines: ~160
|
||||
- Dependencies: 3.3, 3.4
|
||||
- Details: List all instances with enabled toggle, sort-order input, up/down reorder, edit/delete. Add widget flow selects type then renders source-specific config fields. Use existing shadcn `Dialog`, `Input`, `Label`, `Switch`, `Select`, `Button`, `Alert`.
|
||||
|
||||
- [ ] **4.4 Create addon pages**
|
||||
- Files: `frontend/src/pages/AddonPage.tsx` (new), `frontend/src/addons/GrafanaAddonPage.tsx` (new), `frontend/src/addons/PrometheusAddonPage.tsx` (new), `frontend/src/addons/SshTasksAddonPage.tsx` (new)
|
||||
- Lines: ~130
|
||||
- Dependencies: none
|
||||
- Details: `AddonPage` maps `addonId` to static page components; unknown addon shows an `Alert`. Pages render links/metadata only (no iframes).
|
||||
|
||||
- [ ] **4.5 Register addon route in `App.tsx`**
|
||||
- Files: `frontend/src/App.tsx`
|
||||
- Lines: ~5
|
||||
- Dependencies: 4.4
|
||||
- Details: Add `<Route path="/addons/:addonId" element={<AddonPage />} />` in both the OIDC and non-OIDC route trees.
|
||||
|
||||
- [ ] **4.6 Update `docs/REQUIREMENTS.md`**
|
||||
- Files: `docs/REQUIREMENTS.md`
|
||||
- Lines: ~25
|
||||
- Dependencies: none
|
||||
- Details: Document configurable dashboard widgets, supported source types, security rule (no secrets in config), and addon pages.
|
||||
|
||||
- [ ] **4.7 Verify frontend slice and full build**
|
||||
- Run: `cd frontend && npm run lint && npm run build`
|
||||
|
||||
**Slice 4 total:** ~420 changed lines.
|
||||
|
||||
---
|
||||
|
||||
## Integration and acceptance verification
|
||||
|
||||
- [ ] **5.1 Backend full test run**
|
||||
- Run: `cd backend && PYTHONPATH=src pytest`
|
||||
- Verify existing tests still pass and `test_widgets.py` covers registry, CRUD, seeding, and data fetch.
|
||||
|
||||
- [ ] **5.2 Frontend full build + lint**
|
||||
- Run: `cd frontend && npm run lint && npm run build`
|
||||
- Verify no TypeScript errors and no new lint failures.
|
||||
|
||||
- [ ] **5.3 Manual dev-stack verification**
|
||||
- Run: `docker compose -f docker-compose.dev.yml up --build`
|
||||
- Verify:
|
||||
- Fresh install shows Jellyfin activity + Backups widgets.
|
||||
- Disabled widget is hidden.
|
||||
- Reorder changes dashboard order.
|
||||
- Misconfigured widget shows inline error without blocking dashboard.
|
||||
- `/addons/grafana`, `/addons/prometheus`, `/addons/ssh-tasks` render; unknown addon shows not-found alert.
|
||||
- No widget config can contain `api_key`, `token`, `secret`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Total Phase 1 estimate
|
||||
|
||||
| Slice | Changed lines |
|
||||
|-------|---------------|
|
||||
| Slice 1: Backend CRUD + seeding | ~400 |
|
||||
| Slice 2: Backend adapters + data endpoint | ~360 |
|
||||
| Slice 3: Frontend runtime (types/API/hooks/registry/components) | ~435 |
|
||||
| Slice 4: Dashboard loop + config UI + addon pages | ~420 |
|
||||
| Integration tests/docs | ~25 |
|
||||
| **Total** | **~1,640** |
|
||||
|
||||
This exceeds the ~400-line review budget. Use the four chained PRs above; each slice is independently buildable/testable and leaves the app functional.
|
||||
|
||||
---
|
||||
|
||||
## Tests and docs summary
|
||||
|
||||
- **Backend tests:** New `backend/tests/test_widgets.py` covering registry, CRUD, validation, default seeding, and adapter data fetch. Run with `pytest`.
|
||||
- **Frontend tests:** New `frontend/tests/widgets.test.mjs` covering registry contents and refresh intervals. Run implicitly via `npm run build`/`lint`; add Vitest/MSW tests only if the project adopts Vitest before this change.
|
||||
- **Typecheck/build:** `npm run build` (runs `tsc -b`) must pass for every slice.
|
||||
- **Docs:** Update `docs/REQUIREMENTS.md` to describe the widget system, security rule, and addon pages.
|
||||
|
||||
---
|
||||
|
||||
## Guard lines
|
||||
|
||||
```text
|
||||
Decision needed before apply: Yes
|
||||
Chained PRs recommended: Yes
|
||||
Chain strategy: stacked-to-main
|
||||
400-line budget risk: High
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
# Apply Progress: Runtime Service Registry
|
||||
|
||||
**Change:** `service-registry`
|
||||
**Apply run:** PR 1 / Slice 1 — Backend service foundation
|
||||
**Date:** 2026-06-19
|
||||
|
||||
## Completed tasks (Slice 1)
|
||||
|
||||
- [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)
|
||||
|
||||
## Files changed (Slice 1)
|
||||
|
||||
### New files
|
||||
|
||||
- `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.
|
||||
|
||||
### Modified files
|
||||
|
||||
- `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`.
|
||||
|
||||
## Verification (Slice 1)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
.venv/bin/ruff check . # All checks passed
|
||||
PYTHONPATH=src .venv/bin/python -m pytest # 225 passed
|
||||
cd ../frontend
|
||||
npm run lint # 0 errors
|
||||
npm run build # success
|
||||
```
|
||||
|
||||
Smoke: encryption round-trip OK; missing `MANAGE_ENCRYPTION_KEY` raises on startup.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,420 @@
|
||||
# Design: Runtime Service Registry
|
||||
|
||||
**Change:** `service-registry`
|
||||
**Phase:** design
|
||||
**Date:** 2026-06-19
|
||||
|
||||
## 1. Architecture overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ /services/:type/:id ─► ServicePage ─► frontend SERVICE_REGISTRY
|
||||
│ Dashboard ─► WidgetInstance ─► widget component │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ FastAPI /api/services + /api/widgets │
|
||||
│ CRUD service instances · registry metadata · widget data │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────┼───────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
ServiceStore (SQLite) integrations/ definitions source adapters
|
||||
services table (Pydantic, closed registry) (resolve service
|
||||
dashboard_widgets table grafana/prometheus/jellyfin/ → decrypt → call)
|
||||
nextcloud/ssh_tasks
|
||||
```
|
||||
|
||||
Two closed, compile-time registries cooperate:
|
||||
|
||||
- **`integrations.registry.SERVICE_DEFINITIONS`** maps `service_type → ServiceDefinition`.
|
||||
Each definition declares config schema, secret fields, and widget kinds.
|
||||
- The widget types available to the dashboard are **derived** from
|
||||
`SERVICE_DEFINITIONS` at startup, not hand-maintained.
|
||||
|
||||
## 2. Backend data model
|
||||
|
||||
### 2.1 New `services` table
|
||||
|
||||
Extend `SettingsStore.init_schema()`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
id TEXT PRIMARY KEY,
|
||||
service_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
secrets_json TEXT NOT NULL DEFAULT '{}', -- encrypted blob (Fernet)
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_services_type ON services(service_type);
|
||||
```
|
||||
|
||||
- `config_json` — non-secret config validated against the service definition's
|
||||
`config_schema`.
|
||||
- `secrets_json` — a JSON object of `{field_name: ciphertext}` produced by the
|
||||
encryption helper. Never returned to the client in plaintext; only the boolean
|
||||
"is set" flags are surfaced.
|
||||
|
||||
### 2.2 `dashboard_widgets` schema change
|
||||
|
||||
The existing table gains two columns and loses the global meaning of `widget_type`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT;
|
||||
ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT;
|
||||
```
|
||||
|
||||
- `widget_kind` is the kind declared by the service definition (e.g. `"link"`,
|
||||
`"metric"`, `"activity"`).
|
||||
- `service_id` references `services.id`.
|
||||
- `widget_type` is retained temporarily as `"{service_type}.{widget_kind}"` for
|
||||
backwards-compatible reads during the transition, then dropped in the final slice.
|
||||
- The old `addon_id` column is dropped; addon identity is now `service_type`.
|
||||
|
||||
## 3. Service definitions (Pydantic, in repo)
|
||||
|
||||
New package: `backend/src/media_library_viewer_api/integrations/`
|
||||
(chosen to avoid collision with the existing `services/` infra package).
|
||||
|
||||
### 3.1 Base classes — `integrations/base.py`
|
||||
|
||||
```python
|
||||
from typing import Any, ClassVar
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class SecretField(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
required: bool = False
|
||||
helper: str | None = None
|
||||
|
||||
class WidgetKind(BaseModel):
|
||||
kind: str # e.g. "link", "metric", "activity"
|
||||
name: str
|
||||
description: str
|
||||
config_schema: dict[str, Any] # JSON schema for widget config
|
||||
default_config: dict[str, Any] = {}
|
||||
refresh_interval_ms: int = 0
|
||||
|
||||
class ServiceConfigBase(BaseModel):
|
||||
"""Subclass per service to define non-secret config fields."""
|
||||
|
||||
class ServiceDefinition(BaseModel):
|
||||
service_type: ClassVar[str]
|
||||
name: ClassVar[str]
|
||||
description: ClassVar[str]
|
||||
config_schema: ClassVar[dict[str, Any]]
|
||||
secret_fields: ClassVar[list[SecretField]]
|
||||
widget_kinds: ClassVar[list[WidgetKind]]
|
||||
|
||||
# Adapters are referenced by dotted path or registered separately;
|
||||
# see §4. The definition itself stays a pure data/schema object.
|
||||
```
|
||||
|
||||
### 3.2 Example — `integrations/grafana.py`
|
||||
|
||||
```python
|
||||
class GrafanaConfig(ServiceConfigBase):
|
||||
base_url: str = Field(..., description="Grafana base URL, e.g. https://grafana.example.com")
|
||||
|
||||
GRAFANA_DEFINITION = ServiceDefinition(
|
||||
service_type="grafana",
|
||||
name="Grafana",
|
||||
description="Dashboards, metrics, and logs.",
|
||||
config_schema=GrafanaConfig.model_json_schema(),
|
||||
secret_fields=[SecretField(key="api_key", label="API key", helper="Service account token")],
|
||||
widget_kinds=[
|
||||
WidgetKind(
|
||||
kind="link",
|
||||
name="Dashboard link",
|
||||
description="Deep-link to a Grafana dashboard or panel.",
|
||||
config_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dashboard_uid": {"type": "string"},
|
||||
"panel_id": {"type": "integer"},
|
||||
},
|
||||
"required": ["dashboard_uid"],
|
||||
},
|
||||
default_config={"dashboard_uid": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Other definition modules follow the same shape: `prometheus.py`, `jellyfin.py`,
|
||||
`nextcloud.py`, `ssh_tasks.py`.
|
||||
|
||||
### 3.3 Registry — `integrations/registry.py`
|
||||
|
||||
```python
|
||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
"grafana": GRAFANA_DEFINITION,
|
||||
"prometheus": PROMETHEUS_DEFINITION,
|
||||
"jellyfin": JELLYFIN_DEFINITION,
|
||||
"nextcloud": NEXTCLOUD_DEFINITION,
|
||||
"ssh_tasks": SSH_TASKS_DEFINITION,
|
||||
}
|
||||
|
||||
def list_service_types() -> list[str]: ...
|
||||
def get_service_definition(service_type: str) -> ServiceDefinition | None: ...
|
||||
def get_widget_kind(service_type: str, widget_kind: str) -> WidgetKind | None: ...
|
||||
```
|
||||
|
||||
The closed `widgets/registry.py` from Phase 1 is **retired**; its metadata is now derived
|
||||
from `SERVICE_DEFINITIONS`.
|
||||
|
||||
## 4. Source adapters
|
||||
|
||||
`widgets/sources.py` is refactored so each adapter resolves a **service instance** rather
|
||||
than reading `get_settings()`:
|
||||
|
||||
```python
|
||||
class WidgetSource(Protocol):
|
||||
async def fetch(
|
||||
self,
|
||||
service: ServiceRecord, # config + decrypted secrets
|
||||
widget_kind: str,
|
||||
config: dict[str, Any],
|
||||
) -> dict[str, Any]: ...
|
||||
```
|
||||
|
||||
- `ServiceRecord` is a runtime object built by `ServiceStore` carrying the decrypted
|
||||
secret dict in memory for the duration of the fetch.
|
||||
- `SOURCE_ADAPTERS` is keyed by `service_type`.
|
||||
- The data endpoint loads the widget's `service_id`, builds the `ServiceRecord`, then
|
||||
calls `adapter.fetch(service, widget_kind, widget_config)`.
|
||||
|
||||
## 5. Encryption — `services/secrets.py`
|
||||
|
||||
```python
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
def get_encryption_key() -> bytes:
|
||||
raw = os.environ.get("MANAGE_ENCRYPTION_KEY")
|
||||
if not raw:
|
||||
raise RuntimeError("MANAGE_ENCRYPTION_KEY is required")
|
||||
return raw.encode()
|
||||
|
||||
def encrypt_secrets(values: dict[str, str]) -> dict[str, str]: ...
|
||||
def decrypt_secrets(blob: dict[str, str]) -> dict[str, str]: ...
|
||||
```
|
||||
|
||||
- `cryptography.fernet.Fernet` (already a transitive dependency to verify).
|
||||
- Startup validation: `validate_auth_settings` is extended to require
|
||||
`MANAGE_ENCRYPTION_KEY` and to reject an obviously invalid key.
|
||||
- Secrets are encrypted field-by-field so the "which secrets are set" metadata is cheap
|
||||
to compute without decrypting.
|
||||
|
||||
## 6. REST API
|
||||
|
||||
### Services
|
||||
|
||||
| Method | Path | Handler |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/services/types` | List service definitions (metadata + config schema + widget kinds). |
|
||||
| GET | `/api/services` | List service instances (no plaintext secrets; only "set" flags). |
|
||||
| POST | `/api/services` | Create instance (validates type, config, secret schema). |
|
||||
| PUT | `/api/services/{id}` | Update instance. |
|
||||
| DELETE | `/api/services/{id}` | Delete instance; **cascade-deletes** widgets referencing it in the same transaction. |
|
||||
|
||||
### Widgets (unchanged paths, new semantics)
|
||||
|
||||
| Method | Path | Handler |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/widgets/instances` | List widgets; each carries `service_id`, `widget_kind`. |
|
||||
| POST/PUT/DELETE | `/api/widgets/instances/{id}` | CRUD; validation uses service definition's widget schema. |
|
||||
| GET | `/api/widgets/instances/{id}/data` | Resolve service → adapter → fetch. |
|
||||
|
||||
`GET /api/widgets/types` and `/api/widgets/sources` are removed; widget metadata is
|
||||
served via `/api/services/types` (widget kinds under each service).
|
||||
|
||||
## 7. Frontend
|
||||
|
||||
### 7.1 New `frontend/src/integrations/registry.ts`
|
||||
|
||||
Closed frontend registry mirroring the backend: `serviceType → ServiceDefinition`
|
||||
(config fields, secret fields with `secret: true`, widget kinds, default refresh
|
||||
intervals, and a `component` for the service page).
|
||||
|
||||
### 7.2 Service pages
|
||||
|
||||
- Route: `/services/:serviceType/:serviceId` (replaces `/addons/:addonId`).
|
||||
- `ServicePage` looks up the definition and renders the service-specific component,
|
||||
a config editor, and the list of widget kinds that can be added to the dashboard.
|
||||
- `App.tsx` removes the `/addons/:addonId` route; old addon URLs redirect to the
|
||||
default service of that type (or a not-found alert).
|
||||
|
||||
### 7.3 Dashboard config dialog
|
||||
|
||||
- "Add widget" flow becomes: **pick service → pick widget kind → configure**.
|
||||
- The widget card shows the parent service name.
|
||||
|
||||
### 7.4 Types / API / hooks
|
||||
|
||||
- `frontend/src/api/services.ts` + `hooks/useServices.ts` for the services API.
|
||||
- `frontend/src/types/index.ts` gains `ServiceInstance`, `ServiceInstanceInput`,
|
||||
`ServiceTypeInfo`, `ServiceWidgetKind`.
|
||||
|
||||
## 8. Migration and breaking changes
|
||||
|
||||
- **DB migration on startup:** add `services` table; add `service_id` / `widget_kind`
|
||||
columns to `dashboard_widgets`; drop `addon_id`.
|
||||
- **Machine app fields removed:** `jellyfin_url`, `jellyfin_user_id`, `jellyfin_api_key`,
|
||||
`jellyseerr_url`, `jellyseerr_api_key` are dropped from machine records and the
|
||||
`MonitoringMachine` model. Machines keep SSH + node_exporter transport fields only.
|
||||
- **Env vars removed from `config.py`:** `grafana_url`, `prometheus_url`. (Grafana/Prometheus
|
||||
URLs now live on service records.) `MANAGE_ENCRYPTION_KEY` is added as required.
|
||||
- **Default widget seeding** is removed; a fresh install starts with no widgets. The user
|
||||
adds Jellyfin/Backups widgets after configuring the corresponding services.
|
||||
- **`docs/REQUIREMENTS.md` and `README.md`** updated to describe services, the
|
||||
`MANAGE_ENCRYPTION_KEY` requirement, and the breaking upgrade note.
|
||||
|
||||
## 9. File-level plan
|
||||
|
||||
### Create (backend)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `integrations/__init__.py` | Package marker. |
|
||||
| `integrations/base.py` | `ServiceDefinition`, `WidgetKind`, `SecretField`, `ServiceConfigBase`. |
|
||||
| `integrations/registry.py` | Closed `SERVICE_DEFINITIONS` + helpers. |
|
||||
| `integrations/grafana.py`, `prometheus.py`, `jellyfin.py`, `nextcloud.py`, `ssh_tasks.py` | One module per service. |
|
||||
| `services/secrets.py` | Fernet encrypt/decrypt + key validation. |
|
||||
| `services/service_store.py` | CRUD for `services` table; decrypt-on-read for adapters. |
|
||||
| `routers/services.py` | `/api/services*` endpoints. |
|
||||
| `models/services.py` | Pydantic request/response models. |
|
||||
|
||||
### Modify (backend)
|
||||
|
||||
| File | Change |
|
||||
|-------|--------|
|
||||
| `services/settings_store.py` | `services` table; widget columns; drop machine app fields. |
|
||||
| `widgets/sources.py` | Adapters take a `ServiceRecord`. |
|
||||
| `widgets/registry.py` | Retired (metadata served by `integrations/registry.py`). |
|
||||
| `routers/widgets.py` | Validate against service widget schema; resolve service on data fetch. |
|
||||
| `config.py` | Remove `grafana_url`/`prometheus_url`; document `MANAGE_ENCRYPTION_KEY` (read in `secrets.py`). |
|
||||
| `main.py` | Register `services_router`; validate encryption key on startup. |
|
||||
| `dependencies.py` | Jellyfin/SSH resolution now goes via services, not machine app fields. |
|
||||
|
||||
### Create (frontend)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `integrations/registry.ts` | Closed frontend service registry. |
|
||||
| `api/services.ts`, `hooks/useServices.ts` | Services API + hooks. |
|
||||
| `pages/ServicePage.tsx` | Generic `/services/:type/:id` page. |
|
||||
| `integrations/components/*` | Per-service page components. |
|
||||
|
||||
### Modify (frontend)
|
||||
|
||||
| File | Change |
|
||||
|-------|--------|
|
||||
| `App.tsx` | Replace `/addons/:addonId` with `/services/:serviceType/:serviceId`. |
|
||||
| `components/WidgetConfigDialog.tsx` | Service → widget-kind picker. |
|
||||
| `widgets/registry.ts` | Retired; widgets derived from service registry. |
|
||||
| `types/index.ts` | Service types; widget gains `service_id` + `widget_kind`. |
|
||||
| `pages/Settings.tsx` | Remove machine Jellyfin/Jellyseerr fields. |
|
||||
|
||||
## 10. Slice boundaries (chained PRs)
|
||||
|
||||
Each slice keeps `pytest`, `ruff`, `npm run lint`, and `npm run build` green.
|
||||
|
||||
1. **Backend foundation** — encryption helper, `integrations/` base + 5 definitions +
|
||||
registry, `services` table + store, `/api/services*` endpoints, tests. No widget
|
||||
changes yet.
|
||||
2. **Backend widget rebind** — add `service_id`/`widget_kind` to widgets, refactor
|
||||
adapters to take a `ServiceRecord`, retire old `widgets/registry.py`, update data
|
||||
endpoint.
|
||||
3. **Frontend services runtime** — types, API, hooks, `integrations/registry.ts`,
|
||||
service pages, route swap, remove addon pages.
|
||||
4. **Frontend dashboard + settings rework** — service-based widget picker, drop machine
|
||||
app fields from Settings, remove `grafana_url`/`prometheus_url` from config,
|
||||
re-seed behavior, docs (`README.md`, `REQUIREMENTS.md`), changelog breaking-change
|
||||
note.
|
||||
|
||||
Estimated total: ~2,000–2,400 changed lines across four PRs.
|
||||
|
||||
## 11. Decisions resolved
|
||||
|
||||
1. **Deleting a service that still has widgets** → **cascade delete.** The store deletes
|
||||
every `dashboard_widgets` row referencing the service inside the same transaction as
|
||||
the service delete. Simple and safe in SQLite; no 409 pre-check.
|
||||
2. **`MANAGE_ENCRYPTION_KEY` dev default** → **always required.** No fallback, even when
|
||||
`AUTH_ENABLED=false`. Startup fails fast if it is missing or not a valid Fernet key.
|
||||
3. **SSH task runner shape** → **multi-instance, reusable tasks, persisted run history.**
|
||||
See §12 for the full model.
|
||||
|
||||
## 12. SSH task runner model
|
||||
|
||||
The SSH task runner is the most involved service type. Instances absorb the SSH task
|
||||
execution role currently held by machines; tasks stay global and reusable; every
|
||||
invocation is logged.
|
||||
|
||||
### 12.1 Instances
|
||||
|
||||
- `service_type = "ssh_tasks"`.
|
||||
- Each instance is an SSH endpoint: `host`, `port`, `username`, `ssh_key_id`, optional
|
||||
`passphrase`. Connection config lives on the service record; the SSH key itself stays
|
||||
in the existing saved-key registry (referenced by `ssh_key_id`).
|
||||
- Multi-instance by design ("home server", "media box", …).
|
||||
|
||||
### 12.2 Tasks (global, reusable)
|
||||
|
||||
- Saved tasks remain a **global** registry (`name`, `task_type` shell/python, `content`,
|
||||
`enabled`). A task is **not** owned by an instance.
|
||||
- Each task gains `default_service_id` (replaces the old `default_machine_id`) — the
|
||||
instance it targets by default. At run time the caller may override the target
|
||||
instance.
|
||||
- A task can therefore run against any instance; the link is captured per-run.
|
||||
|
||||
### 12.3 Run history (logs)
|
||||
|
||||
A new `service_task_runs` table records every invocation:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS service_task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
service_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- success | failure | timeout | error
|
||||
exit_status INTEGER,
|
||||
duration_ms INTEGER,
|
||||
stdout_tail TEXT,
|
||||
stderr_tail TEXT,
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_task_runs_service ON service_task_runs(service_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC);
|
||||
```
|
||||
|
||||
- Populated by the SSH task adapter on every widget data fetch and by the Actions
|
||||
runner on manual runs.
|
||||
- Surfaced on the instance's service page as a log/history list, and on the task detail
|
||||
as recent runs.
|
||||
- Replaces the legacy `saved_task_runs` concept once the Actions page is rebuilt on
|
||||
services (Slice 4 / a follow-up).
|
||||
|
||||
### 12.4 SSH task widget
|
||||
|
||||
Widget config for `ssh_tasks` becomes `{ task_id, service_id? }`:
|
||||
|
||||
- If `service_id` is omitted, the task's `default_service_id` is used.
|
||||
- The adapter loads the task, resolves the instance, runs it, appends a
|
||||
`service_task_runs` row, and returns the trimmed stdout/stderr/exit status.
|
||||
|
||||
### 12.5 Relationship to machines
|
||||
|
||||
- The SSH task execution role moves **out of machines** into `ssh_tasks` instances.
|
||||
- Machines **keep** their role for the File Browser and node_exporter monitoring
|
||||
transport in this change, to avoid also reworking Files/Monitoring here.
|
||||
- Practical consequence: an SSH host used for both files and tasks may be defined twice
|
||||
(once as a machine, once as an ssh_tasks instance) during the transition. Unifying
|
||||
machines under services is an explicit **follow-up change**, not part of this one.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Proposal: Runtime Service Registry
|
||||
|
||||
**Change:** `service-registry`
|
||||
**Phase:** proposal
|
||||
**Date:** 2026-06-19
|
||||
**Status:** awaiting review (design only — no implementation yet)
|
||||
|
||||
## Context and problem
|
||||
|
||||
Phase 1 shipped a configurable dashboard widget system whose service URLs (Grafana,
|
||||
Prometheus) and app credentials (Jellyfin, Jellyseerr) are driven by environment
|
||||
variables and machine-level fields. This has three problems:
|
||||
|
||||
1. **Operators cannot change services without a redeploy.** Adding a second Grafana,
|
||||
pointing Prometheus at a different host, or rotating a Jellyfin API key requires
|
||||
editing env vars and restarting containers.
|
||||
2. **Configuration is split across three places.** Service URLs live in env vars
|
||||
(`GRAFANA_URL`, `PROMETHEUS_URL`), Jellyfin/Jellyseerr live on machine records, and
|
||||
widget instances live in the widget table. There is no single "what is configured"
|
||||
view.
|
||||
3. **The widget registry is decoupled from the services it depends on.** A Grafana
|
||||
widget does not know which Grafana instance it talks to; the widget config holds a
|
||||
`dashboard_uid` while the base URL is global.
|
||||
|
||||
## Proposal
|
||||
|
||||
Introduce a **runtime service registry** persisted in the backend SQLite database:
|
||||
|
||||
- Each **service instance** (e.g. "Production Grafana", "Home Jellyfin") is a DB record
|
||||
carrying its non-secret config and encrypted secret fields.
|
||||
- **Service definitions** live as Python modules with Pydantic classes in the repo. Each
|
||||
definition declares its config schema, its secret fields, and the **widget kinds** it
|
||||
provides (with their own config schemas).
|
||||
- **Service pages** at `/services/:serviceType/:serviceId` render the service-specific UI
|
||||
and list the widgets that service can contribute to the dashboard. These replace the
|
||||
existing addon pages.
|
||||
- **Dashboard widgets** become service-bound: a widget instance references a `service_id`
|
||||
and a `widget_kind` drawn from that service's definition.
|
||||
- Machine records are reduced to **transport only** (SSH + node_exporter); the
|
||||
machine-level Jellyfin/Jellyseerr app fields are removed.
|
||||
|
||||
## Goals
|
||||
|
||||
- One source of truth for every external service the app talks to.
|
||||
- Add/reconfigure/rotate a service from the UI with no redeploy.
|
||||
- Multiple instances per service type (two Grafanas, two Jellyfins).
|
||||
- Centralized, version-controlled service definitions that are easy to extend.
|
||||
- Widgets discoverable per-service and individually addable to the dashboard.
|
||||
- Secrets (API keys / tokens) encrypted at rest.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No general-purpose plugin/marketplace system.** Service definitions are closed,
|
||||
compile-time code. Adding a brand-new service still requires a backend deploy and a
|
||||
Python module.
|
||||
- **No OAuth token exchange per service in this change.** Only API keys / tokens are
|
||||
stored (encrypted). OAuth-proxy flows (e.g. Grafana behind Authentik) continue to be
|
||||
handled externally.
|
||||
- **No drag-and-drop dashboard layout, no grid, no per-user dashboards.** This change
|
||||
keeps the existing single stacked-column dashboard model.
|
||||
- **No in-app charting.** The thin-dashboard observability rule still holds; service
|
||||
pages surface deep-links and metadata only.
|
||||
- **No silent data migration.** Machine-level Jellyfin/Jellyseerr config is removed
|
||||
without an automatic converter (see Decisions).
|
||||
|
||||
## Decisions (from grilling)
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Scope of services | All current services: Grafana, Prometheus, Jellyfin, Nextcloud, and the SSH task runner. Definitions centralized in repo. |
|
||||
| Definition format | Python modules with Pydantic classes for service config and widget config, combined under each service definition. |
|
||||
| Auth storage | API keys / tokens only, encrypted at rest. |
|
||||
| Encryption key | Single env-provided master key (`MANAGE_ENCRYPTION_KEY`). |
|
||||
| Machine app config | Services **replace** machine-level Jellyfin/Jellyseerr app config. Machines become SSH/monitoring transport only. |
|
||||
| Migration | **Break backwards compatibility.** Users re-enter service config after upgrade; no automatic converter. |
|
||||
| Multi-instance | Yes — multiple service records per service type. |
|
||||
| Addon pages | Replaced by generic service pages at `/services/:serviceType/:serviceId`. |
|
||||
| Widget binding | The service definition **owns** its widget config schemas. Widgets are instantiated from a service instance + a widget kind. |
|
||||
|
||||
## Risks
|
||||
|
||||
- **Breaking upgrade.** Existing deployments lose their Jellyfin config and must re-enter
|
||||
it. We must document this loudly in the changelog and README.
|
||||
- **Encryption key management.** Losing `MANAGE_ENCRYPTION_KEY` makes all stored secrets
|
||||
unrecoverable. Key rotation requires re-encrypting every service record.
|
||||
- **Large surface area.** This change touches backend models, settings store, widget
|
||||
registry, adapters, frontend routing, dashboard config UI, and docs. It must be split
|
||||
into reviewable PRs (see `tasks.md`).
|
||||
- **SSH task runner as a service** needs care: saved tasks already have their own
|
||||
registry. The service record should hold connection/auth; the task registry stays.
|
||||
- **Env vars are not fully eliminated.** The encryption key and core auth/OIDC settings
|
||||
still require env vars; only service URLs/credentials move to the DB.
|
||||
|
||||
## Out of scope for this proposal
|
||||
|
||||
- Automatic migration tooling from machine app config to service records.
|
||||
- Secret rotation UI or key-rotation workflow.
|
||||
- Per-user or multi-dashboard support.
|
||||
- Runtime/hot-reload of service definition files (definitions are loaded at startup).
|
||||
@@ -0,0 +1,212 @@
|
||||
# Tasks: Runtime Service Registry
|
||||
|
||||
**Change:** `service-registry`
|
||||
**Phase:** tasks
|
||||
**Date:** 2026-06-19
|
||||
|
||||
## Review workload forecast
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Estimated changed lines | ~2,000–2,400 |
|
||||
| 400-line budget risk | High |
|
||||
| Chained PRs recommended | Yes (4 PRs) |
|
||||
| Chain strategy | stacked-to-main |
|
||||
|
||||
```text
|
||||
Decision needed before apply: Yes (see design §11 open questions)
|
||||
Chained PRs recommended: Yes
|
||||
Chain strategy: stacked-to-main
|
||||
```
|
||||
|
||||
## Slice 1: Backend service foundation (no widget changes)
|
||||
|
||||
**Goal:** Persist service instances with encrypted secrets and expose CRUD + metadata.
|
||||
|
||||
- [ ] **1.1 Add encryption helper**
|
||||
- Files: `backend/src/media_library_viewer_api/services/secrets.py` (new)
|
||||
- Lines: ~60
|
||||
- Details: Fernet-based `encrypt_secrets` / `decrypt_secrets` / `get_encryption_key`.
|
||||
Raise on missing `MANAGE_ENCRYPTION_KEY`. Add `cryptography` dependency if missing.
|
||||
- [ ] **1.2 Add integrations base classes**
|
||||
- Files: `integrations/__init__.py`, `integrations/base.py` (new)
|
||||
- Lines: ~80
|
||||
- Details: `ServiceDefinition`, `WidgetKind`, `SecretField`, `ServiceConfigBase`.
|
||||
- [ ] **1.3 Add five service definitions + registry**
|
||||
- Files: `integrations/grafana.py`, `prometheus.py`, `jellyfin.py`, `nextcloud.py`,
|
||||
`ssh_tasks.py`, `integrations/registry.py` (new)
|
||||
- Lines: ~220
|
||||
- Details: One `ServiceDefinition` per service with config schema, secret fields, and
|
||||
widget kinds. `SERVICE_DEFINITIONS` + `get_service_definition` /
|
||||
`get_widget_kind` helpers.
|
||||
- [ ] **1.4 Add service store + `services` table**
|
||||
- Files: `services/settings_store.py` (modify), `services/service_store.py` (new)
|
||||
- Lines: ~120
|
||||
- Details: `services` table in `init_schema`; CRUD helpers; decrypt-on-read for
|
||||
adapters; "set" flags for the API without plaintext. **Cascade delete:** removing a
|
||||
service deletes its widgets in the same transaction. Also add the
|
||||
`service_task_runs` table (design §12.3) now so later slices can populate it.
|
||||
- [ ] **1.5 Add service Pydantic models + router**
|
||||
- Files: `models/services.py` (new), `routers/services.py` (new), `main.py` (modify)
|
||||
- Lines: ~110
|
||||
- Details: `GET /api/services/types`, `GET /api/services`, `POST/PUT/DELETE
|
||||
/api/services/{id}`. Validate type, config, and secret schema against the definition.
|
||||
- [ ] **1.6 Validate encryption key on startup**
|
||||
- Files: `auth.py` or `main.py` lifespan (modify)
|
||||
- Lines: ~10
|
||||
- Details: Extend startup validation to require `MANAGE_ENCRYPTION_KEY`.
|
||||
- [ ] **1.7 Add backend tests**
|
||||
- Files: `backend/tests/test_services.py` (new)
|
||||
- Lines: ~140
|
||||
- Details: Registry contents, CRUD round-trip, secret encryption/decryption,
|
||||
unknown service type → 422, missing/invalid encryption key → startup error,
|
||||
cascade-delete removes a service's widgets.
|
||||
- [ ] **1.8 Verify**
|
||||
- Run: `cd backend && .venv/bin/ruff check . && PYTHONPATH=src .venv/bin/python -m pytest`
|
||||
|
||||
**Slice 1 total:** ~720 changed lines (smallest coherent backend foundation).
|
||||
|
||||
## Slice 2: Backend widget rebind to services
|
||||
|
||||
**Goal:** Widgets reference a service instance + widget kind; adapters resolve services.
|
||||
|
||||
- [ ] **2.1 Add widget columns + migrate table**
|
||||
- Files: `services/settings_store.py` (modify)
|
||||
- Lines: ~40
|
||||
- Details: Add `service_id`, `widget_kind` to `dashboard_widgets`; keep `widget_type`
|
||||
as `{service_type}.{kind}` during transition; drop `addon_id`.
|
||||
- [ ] **2.2 Refactor source adapters**
|
||||
- Files: `widgets/sources.py` (modify)
|
||||
- Lines: ~160
|
||||
- Details: Each adapter takes `(service: ServiceRecord, widget_kind, config)`.
|
||||
`SOURCE_ADAPTERS` keyed by `service_type`. Jellyfin/Grafana/Prometheus/SSH adapters
|
||||
resolve connection from the service record. The SSH adapter resolves the task +
|
||||
instance, runs it, and **appends a `service_task_runs` row** (design §12.3).
|
||||
- [ ] **2.3 Retire old widget registry**
|
||||
- Files: `widgets/registry.py` (delete or hollow out), `widgets/__init__.py`
|
||||
- Lines: ~-60
|
||||
- Details: Widget metadata now comes from `integrations/registry.py`.
|
||||
- [ ] **2.4 Update widgets router + models**
|
||||
- Files: `routers/widgets.py`, `models/widgets.py` (modify)
|
||||
- Lines: ~90
|
||||
- Details: Validation uses the service definition's widget schema; data endpoint
|
||||
loads service, builds `ServiceRecord`, calls adapter.
|
||||
- [ ] **2.5 Update widget tests**
|
||||
- Files: `backend/tests/test_widgets.py` (modify)
|
||||
- Lines: ~120
|
||||
- Details: Rewrite adapter/data tests around service instances; cover
|
||||
service-missing, wrong-kind, and encrypted-secret resolution.
|
||||
- [ ] **2.6 Verify**
|
||||
- Run: `cd backend && .venv/bin/ruff check . && PYTHONPATH=src .venv/bin/python -m pytest`
|
||||
|
||||
**Slice 2 total:** ~330 changed lines.
|
||||
|
||||
## Slice 3: Frontend services runtime
|
||||
|
||||
**Goal:** Service types/API/hooks, frontend service registry, service pages, route swap.
|
||||
|
||||
- [ ] **3.1 Add service types**
|
||||
- Files: `frontend/src/types/index.ts` (modify)
|
||||
- Lines: ~50
|
||||
- Details: `ServiceInstance`, `ServiceInstanceInput`, `ServiceTypeInfo`,
|
||||
`ServiceWidgetKind`. Widget gains `service_id`, `widget_kind`.
|
||||
- [ ] **3.2 Add services API + hooks**
|
||||
- Files: `frontend/src/api/services.ts`, `frontend/src/hooks/useServices.ts` (new)
|
||||
- Lines: ~110
|
||||
- Details: Fetch/create/update/delete service instances and types.
|
||||
- [ ] **3.3 Add frontend service registry**
|
||||
- Files: `frontend/src/integrations/registry.ts` (new)
|
||||
- Lines: ~120
|
||||
- Details: Closed registry mirroring backend: config fields, secret fields
|
||||
(`secret: true`), widget kinds, service page components.
|
||||
- [ ] **3.4 Add service page + components**
|
||||
- Files: `frontend/src/pages/ServicePage.tsx`, `frontend/src/integrations/components/*`
|
||||
(new)
|
||||
- Lines: ~180
|
||||
- Details: Generic page dispatches by service type; renders config editor + widget
|
||||
kinds. Add per-service components (Grafana, Prometheus, Jellyfin, Nextcloud,
|
||||
SSH tasks).
|
||||
- [ ] **3.5 Swap routes; remove addon pages**
|
||||
- Files: `frontend/src/App.tsx`, `frontend/src/pages/AddonPage.tsx`,
|
||||
`frontend/src/addons/*` (modify/delete)
|
||||
- Lines: ~-40 net
|
||||
- Details: `/services/:serviceType/:serviceId`; redirect old `/addons/*` to the
|
||||
default service of that type.
|
||||
- [ ] **3.6 Add frontend registry test**
|
||||
- Files: `frontend/src/integrations/registry.test.ts` (new)
|
||||
- Lines: ~40
|
||||
- Details: Assert all five service types and their widget kinds.
|
||||
- [ ] **3.7 Verify**
|
||||
- Run: `cd frontend && npm run lint && npm run build && npm run test -- src/integrations/registry.test.ts`
|
||||
|
||||
**Slice 3 total:** ~460 changed lines.
|
||||
|
||||
## Slice 4: Dashboard picker, settings rework, cleanup, docs
|
||||
|
||||
**Goal:** End-to-end service-based dashboard; remove legacy machine app config + env vars.
|
||||
|
||||
- [ ] **4.1 Rework widget config dialog**
|
||||
- Files: `frontend/src/components/WidgetConfigDialog.tsx` (modify)
|
||||
- Lines: ~120
|
||||
- Details: "Add widget" = pick service → pick widget kind → configure. Widget cards
|
||||
show parent service name.
|
||||
- [ ] **4.2 Update widget components to service model**
|
||||
- Files: `frontend/src/widgets/*` (modify)
|
||||
- Lines: ~120
|
||||
- Details: Components read `widget_kind`; data shapes unchanged but sourced from the
|
||||
service adapter. SSH task widget shows last run status from `service_task_runs`.
|
||||
- [ ] **4.3 Remove machine Jellyfin/Jellyseerr fields**
|
||||
- Files: `frontend/src/pages/Settings.tsx`, `frontend/src/types/index.ts`
|
||||
(modify)
|
||||
- Lines: ~-60
|
||||
- Details: Machines are SSH/monitoring transport only.
|
||||
- [ ] **4.4 Remove grafana_url / prometheus_url from backend config**
|
||||
- Files: `backend/src/media_library_viewer_api/config.py`,
|
||||
`docker-compose.yml`, `docker-compose.dev.yml`, `.env.example`
|
||||
- Lines: ~-10
|
||||
- Details: URLs now live on service records. Add `MANAGE_ENCRYPTION_KEY` to compose
|
||||
- `.env.example`.
|
||||
- [ ] **4.5 Stop default widget seeding**
|
||||
- Files: `services/settings_store.py` (modify)
|
||||
- Lines: ~-20
|
||||
- Details: Fresh installs start with no widgets; user adds them after configuring
|
||||
services.
|
||||
- [ ] **4.6 Docs + changelog**
|
||||
- Files: `docs/REQUIREMENTS.md`, `README.md`, `docs/CHANGELOG.md` (new or modify)
|
||||
- Lines: ~80
|
||||
- Details: Service registry section; `MANAGE_ENCRYPTION_KEY` requirement; breaking
|
||||
upgrade note (re-enter Jellyfin config).
|
||||
- [ ] **4.7 Verify full stack**
|
||||
- Run: backend `ruff` + `pytest`; frontend `lint` + `build` + `test`.
|
||||
|
||||
**Slice 4 total:** ~330 changed lines.
|
||||
|
||||
## Integration and acceptance
|
||||
|
||||
- [ ] **5.1 Backend full test run** — `PYTHONPATH=src pytest`, all green.
|
||||
- [ ] **5.2 Frontend full build/lint/test** — `npm run lint && npm run build && npm run test`.
|
||||
- [ ] **5.3 Manual dev-stack check** — `docker compose -f docker-compose.dev.yml up --build`:
|
||||
- Create a Grafana service from the UI; verify the dashboard link widget works.
|
||||
- Create a Jellyfin service; verify the activity widget resolves it.
|
||||
- Delete a service with widgets → widgets are cascade-deleted and the service is gone.
|
||||
- Restart the stack; secrets remain usable (key stable).
|
||||
- Missing `MANAGE_ENCRYPTION_KEY` → backend refuses to start.
|
||||
- SSH task runner: define two instances, run the same reusable task against each,
|
||||
and see both runs in the instance's history log.
|
||||
|
||||
## Guards
|
||||
|
||||
```text
|
||||
Decision needed before apply: No (design §11 resolved)
|
||||
Chained PRs recommended: Yes
|
||||
Chain strategy: stacked-to-main
|
||||
400-line budget risk: High
|
||||
```
|
||||
|
||||
## Explicit follow-ups (out of scope for this change)
|
||||
|
||||
- Rebuild the Actions page UI on top of services (global reusable tasks +
|
||||
`default_service_id`), replacing the current machine-based saved-task runner.
|
||||
- Unify machines under services so an SSH host is defined once (today machines still
|
||||
own File Browser + node_exporter transport; see design §12.5).
|
||||
- Key rotation / re-encrypt workflow for `MANAGE_ENCRYPTION_KEY`.
|
||||
Reference in New Issue
Block a user