chore(openspec): archive completed changes

Move finished change directories to openspec/changes/archive/:
- configurable-dashboard-widgets
- decommission-monitoring-poller
- service-registry
- unify-tasks-on-services

All associated implementation has been merged to main.
This commit is contained in:
Developer
2026-06-23 19:38:34 +00:00
parent a39dbf272c
commit ca8927834e
14 changed files with 0 additions and 0 deletions
@@ -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,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`.