Files
manage/openspec/changes/configurable-dashboard-widgets/design.md
Developer 200d319fb0 feat(widgets): add backend CRUD, registry, and default seeding
Introduce a closed, compile-time widget registry and backend CRUD for
dashboard widget instances.

- Add dashboard_widgets SQLite table in SettingsStore with CRUD helpers and
  default seeding (Jellyfin + Backups) on first install.
- Add Pydantic models with credential-key and secret-value rejection.
- Add widgets router: /api/widgets/sources, /types, /instances CRUD.
- Call ensure_defaults() in app lifespan so fresh installs seed defaults.
- Add backend tests covering registry, CRUD, validation, and seeding.
- Include SDD artifacts: exploration, proposal, spec, design, tasks.
2026-06-19 20:07:47 +00:00

750 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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,0001,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:** ~350400 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:** ~300350 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:** ~350400 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:** ~350400 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`.