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.
23 KiB
Dashboard Widgets Specification
Domain:
dashboard-widgets· Change:configurable-dashboard-widgetsFull 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_widgetstable). - Source adapters for
jellyfin,backups,grafana,prometheus, andssh_task, plus astatictext/markdown widget. - REST API for widget instance CRUD and per-instance data fetch.
- Dashboard rendering loop in
Dashboard.tsxusing widget instances. - Configuration UI for add/edit/reorder/remove widgets.
- Addon page route
/addons/:addonIdwith 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:
idTEXT PRIMARY KEYaddon_idTEXT NOT NULLwidget_typeTEXT NOT NULLtitleTEXT NOT NULLconfig_jsonTEXT NOT NULL (source-specific JSON config)enabledINTEGER NOT NULL DEFAULT 1sort_orderINTEGER NOT NULL DEFAULT 0created_atINTEGER NOT NULLupdated_atINTEGER 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_widgetstable - 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_atandupdated_atare Unix epoch seconds
Scenario: Update enabled and sort_order
- GIVEN an existing widget instance
- WHEN the store updates
enabledtofalseandsort_orderto5 - THEN subsequent reads reflect the new values
- AND
updated_atis 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:
addon_id="core",widget_type="jellyfin",title="Jellyfin activity", enabled, sort_order before backups.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_widgetsrows - WHEN the backend starts or
ensure_defaults()runs - THEN
GET /api/widgets/instancesreturns 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_widgetsrow - 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/instancesrequest withwidget_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, andstatic
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/instancesrequest withwidget_type="static"andconfig_json={"text":"Hello"} - WHEN the request is processed
- THEN the response status is
200 OKor201 Created - AND the stored
config_jsonequals the submitted value
Scenario: Credential field in config is rejected
- GIVEN a
POST /api/widgets/instancesrequest withconfig_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
POSTforwidget_type="jellyfin"withconfig_json={} - WHEN the request is processed
- THEN the response status is
422 Unprocessable Entity - AND the error indicates that
machine_idis 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+ existingclient.sessions()/ counts.backups:BackupDashboardSummarybuilding logic fromdashboard.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_taskhelper.static: returns the text/markdown fromconfig_jsonunchanged.
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}/datais called - THEN the response contains a
datafield with activity rows - AND
erroris null
Scenario: SSH task adapter times out gracefully
- GIVEN an
ssh-taskwidget 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 OKcarrying 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: stringaddon_id: stringwidget_type: stringtitle: stringconfig: object (parsed JSON)enabled: booleansort_order: numbercreated_at: numberupdated_at: number
WidgetInstanceInput request fields:
id: string | null (optional on create)addon_id: stringwidget_type: stringtitle: stringconfig: objectenabled: booleansort_order: number
WidgetTypeInfo fields:
addon_id: stringwidget_type: stringname: stringdescription: stringsource_type: stringconfig_schema: JSON Schema object
WidgetDataResponse fields:
widget_id: stringwidget_type: stringdata: object | nullerror: string | nullfetched_at: number (Unix epoch seconds)
Scenario: Create widget instance via API
- GIVEN an authenticated
POST /api/widgets/instanceswith a validWidgetInstanceInput - WHEN the request is processed
- THEN the response status is
201 Created - AND the response body contains the created
WidgetInstancewith a generatedid
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}/datais called - THEN the response status is
200 OK - AND
erroris a non-empty string - AND
datais 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:
WidgetInstanceWidgetInstanceInputWidgetTypeInfoWidgetDataResponse
Frontend TypeScript interfaces MUST be added to frontend/src/types/index.ts:
WidgetInstanceWidgetInstanceInputWidgetTypeInfoWidgetDataResponseWidgetSource(string union of source types)
Scenario: Backend model serializes config as object
- GIVEN a
WidgetInstancemodel initialized from a database row withconfig_json='{"text":"x"}' - WHEN it is serialized with
model_dump() - THEN
configis the parsed object{"text":"x"}
Scenario: Frontend type matches API response
- GIVEN the
WidgetInstanceTypeScript interface - WHEN a widget instance payload from
GET /api/widgets/instancesis typed with it - THEN
npm run buildsucceeds 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_orderascending. - Render each widget inside the existing
SectionCardcontainer. - 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
Alertwith 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 secondsbackups: 60 secondsgrafana: 0 (no polling; static link)prometheus: 30 secondsssh_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
useWidgetDatarefetches 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,enabledflag,sort_order, and source-specificconfig. - 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, enterstitle="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
enabledswitch 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—GrafanaAddonPageprometheus—PrometheusAddonPagessh-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
GrafanaAddonPagecomponent 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
Alertstating 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 secondsbackups: 10 secondsprometheus: 10 secondsssh_task: 30 secondsgrafana: 5 secondsstatic: 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
widget_typeMUST be in the closed registry.addon_idMUST match the addon registered for the widget type.config_jsonMUST be valid JSON and MUST validate against the widget type's JSON schema.config_jsonMUST NOT contain keys matching the forbidden credential list.sort_orderMUST be a non-negative integer.enabledMUST be a boolean.- The data endpoint for a disabled widget MUST still function if called directly, but the dashboard MUST NOT render it.
- A widget instance's
idMUST be immutable after creation. - Source adapters MUST be stateless and MUST NOT persist widget-specific secrets.
- 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/instancesreturns 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, setstitle="Grafana Overview",config.dashboard_uid="overview", and saves - THEN
POST /api/widgets/instancessucceeds - 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}returnsenabled=false - AND the widget is no longer rendered on the dashboard
Scenario: Misconfigured widget fails gracefully
- GIVEN a
prometheus-metricwidget with an invalidpromqlquery - 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
GrafanaAddonPagerenders 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