# 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 ``.
- [ ] **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 `} />` 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
```