Files
manage/openspec/changes/configurable-dashboard-widgets/specs/dashboard-widgets/spec.md
T
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

577 lines
23 KiB
Markdown

# Dashboard Widgets Specification
> Domain: `dashboard-widgets` · Change: `configurable-dashboard-widgets`
> Full spec (no prior canonical spec exists for this domain).
## Purpose
Define WHAT must be true after Phase 1 of the configurable dashboard widgets change: the Manage dashboard becomes a persisted, configurable stack of widget instances backed by a closed, compile-time registry. Authenticated users can add, edit, enable/disable, reorder, and remove widgets; widget data is fetched independently; misconfigured widgets fail gracefully; and addon pages render under `/addons/{addon_id}`.
## Scope Summary
### In scope
- Closed compile-time widget/source registries in the backend and frontend.
- SQLite persistence of widget instances (`dashboard_widgets` table).
- Source adapters for `jellyfin`, `backups`, `grafana`, `prometheus`, and `ssh_task`, plus a `static` text/markdown widget.
- REST API for widget instance CRUD and per-instance data fetch.
- Dashboard rendering loop in `Dashboard.tsx` using widget instances.
- Configuration UI for add/edit/reorder/remove widgets.
- Addon page route `/addons/:addonId` with a static addon page registry.
- Default widget seeding on first install.
### Out of scope (reminders)
- Runtime addon discovery or dynamic component loading.
- Per-user widget configuration.
- Grid, drag, or resize layout engine.
- Iframe embedding of Grafana or any external UI.
- Public/unauthenticated widget access.
- Generic "run any script" widget; only saved tasks from the existing registry are allowed.
- Real-time WebSocket updates.
## Requirements
### Requirement: Widget instance persistence
The backend MUST persist widget instances in a `dashboard_widgets` table with the following columns and invariants:
- `id` TEXT PRIMARY KEY
- `addon_id` TEXT NOT NULL
- `widget_type` TEXT NOT NULL
- `title` TEXT NOT NULL
- `config_json` TEXT NOT NULL (source-specific JSON config)
- `enabled` INTEGER NOT NULL DEFAULT 1
- `sort_order` INTEGER NOT NULL DEFAULT 0
- `created_at` INTEGER NOT NULL
- `updated_at` INTEGER NOT NULL
The table MUST have an index on `sort_order` named `idx_dashboard_widgets_sort`.
The `SettingsStore` MUST provide CRUD helpers that return plain Python dicts matching the API response shape. `config_json` MUST be stored as JSON text and validated on write.
#### Scenario: Create and read a widget instance
- GIVEN an empty `dashboard_widgets` table
- WHEN the store creates a widget instance with `addon_id="core"`, `widget_type="static"`, `title="Notes"`, `config_json={"text":"hello"}`, `enabled=true`, `sort_order=1`
- THEN `list_widgets()` returns a list containing one item with the same field values
- AND `created_at` and `updated_at` are Unix epoch seconds
#### Scenario: Update enabled and sort_order
- GIVEN an existing widget instance
- WHEN the store updates `enabled` to `false` and `sort_order` to `5`
- THEN subsequent reads reflect the new values
- AND `updated_at` is greater than or equal to the write time
#### Scenario: Delete a widget instance
- GIVEN an existing widget instance
- WHEN the store deletes it by `id`
- THEN `list_widgets()` no longer returns that instance
---
### Requirement: Default widget seeding on first install
On first install (when `dashboard_widgets` is empty during startup or `ensure_defaults`), the system MUST seed exactly two default widget instances:
1. `addon_id="core"`, `widget_type="jellyfin"`, `title="Jellyfin activity"`, enabled, sort_order before backups.
2. `addon_id="backups"`, `widget_type="backups"`, `title="Backups"`, enabled, sort_order after Jellyfin.
Existing installations with one or more widget rows MUST NOT be modified by the seeding logic.
#### Scenario: Fresh install shows default widgets
- GIVEN a fresh settings database with no `dashboard_widgets` rows
- WHEN the backend starts or `ensure_defaults()` runs
- THEN `GET /api/widgets/instances` returns exactly the Jellyfin activity and Backups widgets in that order
- AND both are enabled
#### Scenario: Existing install is not re-seeded
- GIVEN a settings database with at least one `dashboard_widgets` row
- WHEN the backend starts
- THEN the existing widget rows remain unchanged
- AND no new default rows are inserted
---
### Requirement: Closed widget and source registries
The widget system MUST use a closed, compile-time registry. The backend MUST reject any `widget_type` not in the registry and any `source_type` without a registered adapter.
Phase 1 built-in widget types:
| `widget_type` | `addon_id` | Source adapter | Purpose |
|---|---|---|---|
| `jellyfin` | `core` | `jellyfin` | Activity/counts from a Jellyfin machine |
| `backups` | `backups` | `backups` | Backup summary stats |
| `grafana-link` | `grafana` | `grafana` | Deep-link to a Grafana dashboard or panel |
| `prometheus-metric` | `prometheus` | `prometheus` | Instant query rendered as a metric |
| `ssh-task` | `ssh-tasks` | `ssh_task` | Output of a saved task |
| `static` | `core` | `static` | Plain text/markdown widget |
#### Scenario: Unknown widget type is rejected
- GIVEN a `POST /api/widgets/instances` request with `widget_type="unknown"`
- WHEN the request is processed
- THEN the response status is `422 Unprocessable Entity`
- AND the response body contains a validation error naming the unsupported widget type
#### Scenario: Source registry is fixed
- GIVEN `GET /api/widgets/sources`
- WHEN the endpoint responds
- THEN the list contains exactly `jellyfin`, `backups`, `grafana`, `prometheus`, `ssh_task`, and `static`
---
### Requirement: Widget config validation
Each widget type MUST have a JSON config schema. The backend MUST validate `config_json` against the schema on create and update and reject credential fields.
The following keys are forbidden anywhere in `config_json` (case-insensitive):
- `password`, `token`, `secret`, `api_key`, `apikey`, `private_key`, `passphrase`, `credential`
Any value that is a non-empty string and looks like a secret (e.g., starts with `sk-`, `eyJ`, or is longer than 64 random-looking characters) SHOULD be rejected as a defense-in-depth measure.
#### Scenario: Valid static widget config passes
- GIVEN a `POST /api/widgets/instances` request with `widget_type="static"` and `config_json={"text":"Hello"}`
- WHEN the request is processed
- THEN the response status is `200 OK` or `201 Created`
- AND the stored `config_json` equals the submitted value
#### Scenario: Credential field in config is rejected
- GIVEN a `POST /api/widgets/instances` request with `config_json={"api_key":"abc123"}`
- WHEN the request is processed
- THEN the response status is `422 Unprocessable Entity`
- AND the error message indicates that credential fields are not allowed
#### Scenario: Jellyfin config requires machine_id
- GIVEN a `POST` for `widget_type="jellyfin"` with `config_json={}`
- WHEN the request is processed
- THEN the response status is `422 Unprocessable Entity`
- AND the error indicates that `machine_id` is required
---
### Requirement: Widget source adapters
Each source adapter MUST implement a uniform async interface:
```python
class WidgetSource(Protocol):
source_type: str
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ...
```
Adapters MUST reuse existing dependency-injection helpers and MUST NOT reimplement client logic:
- `jellyfin`: `get_jellyfin_client` + existing `client.sessions()` / counts.
- `backups`: `BackupDashboardSummary` building logic from `dashboard.py`.
- `grafana`: `get_settings()` Grafana URL; only returns deep-link metadata, never embeds.
- `prometheus`: `get_settings()` Prometheus URL; performs an instant query via HTTP.
- `ssh_task`: existing saved task registry + `run_task` helper.
- `static`: returns the text/markdown from `config_json` unchanged.
Adapters MUST catch their own exceptions and return an error payload; they MUST NOT raise unhandled exceptions into the endpoint.
#### Scenario: Jellyfin adapter returns sessions
- GIVEN a Jellyfin widget configured with a valid `machine_id`
- WHEN `GET /api/widgets/instances/{id}/data` is called
- THEN the response contains a `data` field with activity rows
- AND `error` is null
#### Scenario: SSH task adapter times out gracefully
- GIVEN an `ssh-task` widget configured with a slow task
- WHEN the adapter exceeds its timeout
- THEN it returns `{ "error": "Widget data fetch timed out" }`
- AND the HTTP endpoint still responds with `200 OK` carrying the error payload
---
### Requirement: API contract
The backend MUST expose the following endpoints under `/api/widgets`, protected by the existing JWT/API-key auth:
| Method | Path | Purpose | Success | Error |
|---|---|---|---|---|
| GET | `/api/widgets/sources` | List source types | `200 OK` + list of strings | 401/403 |
| GET | `/api/widgets/types` | List widget types per addon | `200 OK` + `WidgetTypeInfo[]` | 401/403 |
| GET | `/api/widgets/instances` | List persisted instances | `200 OK` + `WidgetInstance[]` | 401/403 |
| POST | `/api/widgets/instances` | Create instance | `201 Created` + `WidgetInstance` | 400/401/403/422 |
| PUT | `/api/widgets/instances/{id}` | Update instance | `200 OK` + `WidgetInstance` | 400/401/403/404/422 |
| DELETE | `/api/widgets/instances/{id}` | Delete instance | `200 OK` + `{status:"deleted"}` | 401/403/404 |
| GET | `/api/widgets/instances/{id}/data` | Fetch widget data | `200 OK` + `WidgetDataResponse` | 401/403/404/500 |
`WidgetInstance` response fields (exact names):
- `id`: string
- `addon_id`: string
- `widget_type`: string
- `title`: string
- `config`: object (parsed JSON)
- `enabled`: boolean
- `sort_order`: number
- `created_at`: number
- `updated_at`: number
`WidgetInstanceInput` request fields:
- `id`: string | null (optional on create)
- `addon_id`: string
- `widget_type`: string
- `title`: string
- `config`: object
- `enabled`: boolean
- `sort_order`: number
`WidgetTypeInfo` fields:
- `addon_id`: string
- `widget_type`: string
- `name`: string
- `description`: string
- `source_type`: string
- `config_schema`: JSON Schema object
`WidgetDataResponse` fields:
- `widget_id`: string
- `widget_type`: string
- `data`: object | null
- `error`: string | null
- `fetched_at`: number (Unix epoch seconds)
#### Scenario: Create widget instance via API
- GIVEN an authenticated `POST /api/widgets/instances` with a valid `WidgetInstanceInput`
- WHEN the request is processed
- THEN the response status is `201 Created`
- AND the response body contains the created `WidgetInstance` with a generated `id`
#### Scenario: Update nonexistent widget returns 404
- GIVEN an authenticated `PUT /api/widgets/instances/does-not-exist`
- WHEN the request is processed
- THEN the response status is `404 Not Found`
#### Scenario: Data endpoint returns error for misconfigured widget
- GIVEN a widget whose adapter returns an error payload
- WHEN `GET /api/widgets/instances/{id}/data` is called
- THEN the response status is `200 OK`
- AND `error` is a non-empty string
- AND `data` is null
---
### Requirement: Type contracts
The Pydantic models in the backend and the TypeScript interfaces in the frontend MUST use the exact field names listed above.
Backend Pydantic models MUST live in `backend/src/media_library_viewer_api/models/widgets.py` and MUST include:
- `WidgetInstance`
- `WidgetInstanceInput`
- `WidgetTypeInfo`
- `WidgetDataResponse`
Frontend TypeScript interfaces MUST be added to `frontend/src/types/index.ts`:
- `WidgetInstance`
- `WidgetInstanceInput`
- `WidgetTypeInfo`
- `WidgetDataResponse`
- `WidgetSource` (string union of source types)
#### Scenario: Backend model serializes config as object
- GIVEN a `WidgetInstance` model initialized from a database row with `config_json='{"text":"x"}'`
- WHEN it is serialized with `model_dump()`
- THEN `config` is the parsed object `{"text":"x"}`
#### Scenario: Frontend type matches API response
- GIVEN the `WidgetInstance` TypeScript interface
- WHEN a widget instance payload from `GET /api/widgets/instances` is typed with it
- THEN `npm run build` succeeds without type errors
---
### Requirement: Dashboard rendering loop
`frontend/src/pages/Dashboard.tsx` MUST render widget instances returned by `useWidgetInstances()` instead of the three hard-coded sections.
The dashboard MUST:
- Query widget instances on mount.
- Render only instances with `enabled === true`.
- Sort enabled instances by `sort_order` ascending.
- Render each widget inside the existing `SectionCard` container.
- Pass the widget instance to a registered widget component.
- Preserve the existing stacked layout (`flex flex-col gap-4`).
- Keep the existing Shortcuts functionality as a widget type or continue to support it as a first-class widget instance (`widget_type="shortcuts"` or equivalent) so that no data is lost.
#### Scenario: Fresh install rendering
- GIVEN a fresh install with default widgets
- WHEN the Dashboard page loads
- THEN it renders the Jellyfin activity widget followed by the Backups widget
- AND both fetch their own data independently
#### Scenario: Disabled widget is hidden
- GIVEN a widget instance with `enabled=false`
- WHEN the Dashboard renders
- THEN that widget is not rendered
- AND the remaining widgets maintain their sort order
#### Scenario: Misconfigured widget fails gracefully
- GIVEN a dashboard with one valid widget and one widget whose data endpoint returns an error
- WHEN the Dashboard renders
- THEN the valid widget displays normally
- AND the failing widget renders an inline `Alert` with the error message
- AND the rest of the dashboard is not blocked
---
### Requirement: Independent widget data fetching
Each widget MUST fetch its own data independently via `useWidgetData(widgetId, refreshInterval)`. The hook MUST use TanStack Query with a per-widget `refetchInterval`.
Default refresh intervals:
- `jellyfin`: 30 seconds
- `backups`: 60 seconds
- `grafana`: 0 (no polling; static link)
- `prometheus`: 30 seconds
- `ssh_task`: 0 (fetch on mount only; heavy)
- `static`: 0
A widget component MUST show a loading state while data is being fetched for the first time and MUST show an error state if `error` is non-null.
#### Scenario: Jellyfin widget auto-refreshes
- GIVEN a rendered Jellyfin widget
- WHEN 30 seconds elapse
- THEN `useWidgetData` refetches the data automatically
#### Scenario: Grafana widget does not poll
- GIVEN a rendered Grafana-link widget
- WHEN it mounts
- THEN it fetches data once to build the deep-link
- AND it does not refetch automatically
---
### Requirement: Configuration UI
The Dashboard MUST provide an "Edit dashboard" action that opens a configuration panel or dialog. The panel MUST allow the user to:
- See all widget instances (enabled and disabled).
- Add a new widget by choosing a widget type from the closed registry.
- Edit a widget's `title`, `enabled` flag, `sort_order`, and source-specific `config`.
- Remove a widget with a confirmation step.
- Reorder widgets by changing `sort_order` (simple numeric input or up/down buttons).
Source-specific config fields MUST be rendered by small sub-forms registered next to each widget type in the frontend registry.
The UI MUST reuse existing shadcn/ui form patterns (`Dialog`, `Input`, `Label`, `Switch`, `Select`, `Button`, `Alert`).
#### Scenario: User adds a Grafana-link widget
- GIVEN the dashboard configuration panel is open
- WHEN the user selects widget type `grafana-link`, enters `title="Grafana Overview"`, `config.dashboard_uid="overview"`, and saves
- THEN a new widget instance is persisted
- AND it appears on the dashboard with a deep-link to Grafana
#### Scenario: User disables a widget
- GIVEN the dashboard configuration panel is open and a widget is enabled
- WHEN the user toggles its `enabled` switch off and saves
- THEN the widget disappears from the dashboard
- AND it remains in the instances list with `enabled=false`
#### Scenario: Reorder widgets
- GIVEN two widgets with sort_order 0 and 1
- WHEN the user swaps their sort_order values and saves
- THEN the dashboard re-renders them in the new order
---
### Requirement: Addon pages
The frontend MUST register a route `/addons/:addonId` in `App.tsx`. The `AddonPage` component MUST look up `addonId` in a static addon registry and render the matching page component.
Phase 1 addon registry MUST include at least:
- `grafana``GrafanaAddonPage`
- `prometheus``PrometheusAddonPage`
- `ssh-tasks``SshTasksAddonPage`
Navigating to an unknown `addonId` MUST render a 404-style message inside the page shell.
Grafana widgets MUST deep-link to Grafana (using env-configured URL) instead of embedding.
#### Scenario: Addon page navigation
- GIVEN the user clicks "Open Grafana addon" from a Grafana widget
- WHEN the browser navigates to `/addons/grafana`
- THEN the `GrafanaAddonPage` component renders
- AND the page shows Grafana deep-links and no iframe
#### Scenario: Unknown addon page
- GIVEN a navigation to `/addons/unknown`
- WHEN the route resolves
- THEN the page renders an `Alert` stating the addon is not found
- AND the sidebar and shell remain intact
---
## Non-Functional Requirements
### Requirement: Security — no secrets in widget config
The system MUST ensure that widget `config_json` never stores secrets. Credential detection MUST be applied both at the Pydantic model level and at the store write level. Backend adapters MUST resolve credentials from the existing machine/SSH-key store or environment settings.
#### Scenario: Secret-looking value rejected
- GIVEN a widget config containing `"token": "super-secret-api-token-value"`
- WHEN the create/update endpoint processes it
- THEN the request is rejected with `422 Unprocessable Entity`
---
### Requirement: Performance — independent fetches and timeouts
Each widget data fetch MUST be independent. A slow or failing adapter MUST NOT block other widgets or the dashboard render. Adapters MUST apply a short timeout:
- `jellyfin`: 10 seconds
- `backups`: 10 seconds
- `prometheus`: 10 seconds
- `ssh_task`: 30 seconds
- `grafana`: 5 seconds
- `static`: no fetch
The dashboard MUST render the widget chrome immediately and show loading skeletons while data loads.
#### Scenario: Slow widget does not block dashboard
- GIVEN a dashboard with three widgets, one of which takes 25 seconds
- WHEN the dashboard loads
- THEN the other two widgets render their data immediately
- AND the slow widget shows a loading skeleton until it completes or times out
---
### Requirement: Maintainability — closed registry
The widget and source registries MUST be closed and compile-time. Adding a new widget type or source adapter MUST require a code change in both backend and frontend registries. There MUST be no plugin loading, dynamic imports, or runtime manifests in Phase 1.
#### Scenario: Registry is discoverable in source
- GIVEN the source code
- WHEN searching for the list of supported widget types
- THEN it is found as an explicit map/list in the backend and frontend source files
---
## Invariants and Validation Rules
1. `widget_type` MUST be in the closed registry.
2. `addon_id` MUST match the addon registered for the widget type.
3. `config_json` MUST be valid JSON and MUST validate against the widget type's JSON schema.
4. `config_json` MUST NOT contain keys matching the forbidden credential list.
5. `sort_order` MUST be a non-negative integer.
6. `enabled` MUST be a boolean.
7. The data endpoint for a disabled widget MUST still function if called directly, but the dashboard MUST NOT render it.
8. A widget instance's `id` MUST be immutable after creation.
9. Source adapters MUST be stateless and MUST NOT persist widget-specific secrets.
10. Addon page components MUST NOT embed external iframes.
## Error Handling Requirements
| Flow / Endpoint | Expected Error Condition | Response |
|---|---|---|
| `GET /api/widgets/instances` | Unauthenticated | `401 Unauthorized` |
| `POST /api/widgets/instances` | Invalid JSON | `400 Bad Request` |
| `POST /api/widgets/instances` | Unknown `widget_type` | `422 Unprocessable Entity` |
| `POST /api/widgets/instances` | Config fails schema validation | `422 Unprocessable Entity` |
| `POST /api/widgets/instances` | Config contains credential key | `422 Unprocessable Entity` |
| `PUT /api/widgets/instances/{id}` | Widget not found | `404 Not Found` |
| `PUT /api/widgets/instances/{id}` | ID in path mismatches body | `400 Bad Request` |
| `DELETE /api/widgets/instances/{id}` | Widget not found | `404 Not Found` |
| `GET /api/widgets/instances/{id}/data` | Widget not found | `404 Not Found` |
| `GET /api/widgets/instances/{id}/data` | Adapter raises unhandled exception | `500 Internal Server Error` with a safe message |
| `GET /api/widgets/instances/{id}/data` | Adapter returns error payload | `200 OK` with `error` set |
| Dashboard render | Widget data hook errors | Inline error state; dashboard continues |
| Configuration UI | Network error on save | Inline `Alert`; form remains open |
## Scenario Catalog
### Scenario: Fresh install shows default widgets
- GIVEN a fresh settings database
- WHEN the backend starts and the Dashboard page loads
- THEN `GET /api/widgets/instances` returns two enabled widgets: Jellyfin activity and Backups
- AND the Dashboard renders them in order
### Scenario: User adds a Grafana-link widget
- GIVEN the Dashboard configuration panel is open
- WHEN the user chooses `grafana-link`, sets `title="Grafana Overview"`, `config.dashboard_uid="overview"`, and saves
- THEN `POST /api/widgets/instances` succeeds
- AND the new widget appears on the dashboard
- AND clicking the widget opens the Grafana dashboard in a new tab
### Scenario: User disables a widget
- GIVEN a widget is enabled and visible on the dashboard
- WHEN the user opens the configuration panel, toggles the widget off, and saves
- THEN `PUT /api/widgets/instances/{id}` returns `enabled=false`
- AND the widget is no longer rendered on the dashboard
### Scenario: Misconfigured widget fails gracefully
- GIVEN a `prometheus-metric` widget with an invalid `promql` query
- WHEN the dashboard renders
- THEN the widget shows an error Alert with a message from the adapter
- AND all other widgets render normally
- AND the dashboard remains scrollable and interactive
### Scenario: Addon page navigation
- GIVEN a Grafana widget with a configured dashboard
- WHEN the user clicks the addon deep-link
- THEN the browser navigates to `/addons/grafana`
- AND the `GrafanaAddonPage` renders with relevant deep-links
- AND no iframe is present
## File Targets (Informative)
- Backend models: `backend/src/media_library_viewer_api/models/widgets.py`
- Backend router: `backend/src/media_library_viewer_api/routers/widgets.py`
- Backend source adapters: `backend/src/media_library_viewer_api/widgets/*.py`
- Backend store: extend `backend/src/media_library_viewer_api/services/settings_store.py`
- Backend main: register router in `backend/src/media_library_viewer_api/main.py`
- Frontend types: `frontend/src/types/index.ts`
- Frontend API client: `frontend/src/api/widgets.ts`
- Frontend hooks: `frontend/src/hooks/useWidgets.ts`
- Frontend widget registry: `frontend/src/widgets/registry.ts`
- Frontend widget components: `frontend/src/widgets/*.tsx`
- Frontend dashboard: `frontend/src/pages/Dashboard.tsx`
- Frontend addon page: `frontend/src/pages/AddonPage.tsx`
- Frontend app routes: `frontend/src/App.tsx`