200d319fb0
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.
577 lines
23 KiB
Markdown
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`
|