# 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`