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

23 KiB

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:

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).

  • 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:

  • grafanaGrafanaAddonPage
  • prometheusPrometheusAddonPage
  • ssh-tasksSshTasksAddonPage

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
  • 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