200d319fb0
Introduce a closed, compile-time widget registry and backend CRUD for dashboard widget instances. - Add dashboard_widgets SQLite table in SettingsStore with CRUD helpers and default seeding (Jellyfin + Backups) on first install. - Add Pydantic models with credential-key and secret-value rejection. - Add widgets router: /api/widgets/sources, /types, /instances CRUD. - Call ensure_defaults() in app lifespan so fresh installs seed defaults. - Add backend tests covering registry, CRUD, validation, and seeding. - Include SDD artifacts: exploration, proposal, spec, design, tasks.
750 lines
30 KiB
Markdown
750 lines
30 KiB
Markdown
# 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`.
|