# SDD Design: Configurable Dashboard Widgets **Change:** `configurable-dashboard-widgets` **Phase:** design **Date:** 2026-06-19 ## 1. Architecture overview The widget system introduces a thin, closed registry layer between the existing FastAPI backend and the React dashboard. It reuses the existing `SettingsStore` SQLite database, dependency-injection helpers (`get_jellyfin_client`, `get_ssh_client`, saved-task registry), and shadcn/ui component patterns. ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Browser │ │ Dashboard.tsx ──► WidgetInstance renderer ──► widget registry │ │ │ │ │ │ │ │ useWidgetData() addon pages │ │ │ │ │ │ │ └──────────────► /api/widgets/instances/{id}/data ◄────────┘ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────┐ │ FastAPI /api/widgets router │ │ - CRUD instances │ │ - registry metadata │ │ - data fetch via source adapters │ └────────────────────────────────────────┘ │ ┌─────────────────────────┼─────────────────────────┐ ▼ ▼ ▼ SettingsStore source adapters existing routers (SQLite) (stateless) /api/dashboard dashboard_widgets jellyfin /api/tasks backups /api/settings grafana prometheus ssh_task static ``` **Key constraints carried from the spec:** - Closed, compile-time registries in both backend and frontend. No runtime plugin loading. - No secrets in `config_json`; credentials come from the machine/SSH-key store or environment settings. - Stacked `SectionCard` layout; no grid/drag/resize. - Each widget fetches its own data independently with per-type polling intervals and timeouts. --- ## 2. Backend design ### 2.1 `dashboard_widgets` table schema Extend `SettingsStore.init_schema()` in `backend/src/media_library_viewer_api/services/settings_store.py`: ```sql CREATE TABLE IF NOT EXISTS dashboard_widgets ( id TEXT PRIMARY KEY, addon_id TEXT NOT NULL, widget_type TEXT NOT NULL, title TEXT NOT NULL, config_json TEXT NOT NULL DEFAULT '{}', enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order); ``` Store helper additions: - `_row_to_widget(row)` — parse `config_json` into a `config` dict. - `_normalize_widget_payload(payload, widget_id=None)` — validate/assign defaults, generate `id` if missing. - `list_widgets()` — return all rows ordered by `sort_order ASC, created_at ASC`. - `get_widget(widget_id)` — single row. - `upsert_widget(payload, widget_id=None)` — insert or replace; preserve `created_at`. - `delete_widget(widget_id)` — delete by id. - `seed_default_widgets()` — called from `ensure_defaults()`; inserts the two defaults only when the table is empty. `ensure_defaults()` already runs on startup (called via `get_settings_store()`). Seeding logic: ```python def ensure_defaults(self) -> None: self.init_schema() # existing local-machine seeding ... self._seed_dashboard_widgets() def _seed_dashboard_widgets(self) -> None: with self.connect() as conn: row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone() if row and int(row[0]) > 0: return now = int(time.time()) defaults = [ { "id": "jellyfin-activity-default", "addon_id": "core", "widget_type": "jellyfin", "title": "Jellyfin activity", "config": {"machine_id": ""}, "enabled": True, "sort_order": 0, }, { "id": "backups-summary-default", "addon_id": "backups", "widget_type": "backups", "title": "Backups", "config": {}, "enabled": True, "sort_order": 1, }, ] for w in defaults: self.upsert_widget(w) ``` IDs are hard-coded so repeated startups are idempotent. Empty `config` for `jellyfin` resolves to the first enabled Jellyfin machine via existing DI. ### 2.2 Widget source adapter protocol Adapters live in `backend/src/media_library_viewer_api/widgets/sources.py` (single file is sufficient for Phase 1). ```python from typing import Any, Protocol class WidgetSource(Protocol): source_type: str async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ... ``` Concrete adapters: | source_type | class | implementation notes | |-------------|-------|----------------------| | `jellyfin` | `JellyfinWidgetSource` | Build a Starlette `Request` with `machine_id` query param, call `get_jellyfin_client(req)` and `get_user_id(req)`, then `client.sessions()`; reuse `_map_sessions_to_activity_rows` from `routers/dashboard.py` or move the helper to a shared `domain/dashboard.py`. | | `backups` | `BackupsWidgetSource` | Call `SettingsStore.list_backup_jobs`, `list_backup_runs`, `list_backup_alerts` and compute the same summary as `GET /api/dashboard/backups`; reuse `BackupDashboardSummary`. | | `grafana` | `GrafanaWidgetSource` | Read `grafana_url` from `get_settings()` (new setting, default `http://grafana:3000`) and `config.dashboard_uid`/`panel_id`; return `{url: "{grafana_url}/d/{dashboard_uid}?..."}`. No embedding. | | `prometheus` | `PrometheusWidgetSource` | Read `prometheus_url` from settings (env or default `http://prometheus:9090`), run instant query `config.promql`, return scalar/vector result. Apply 10 s timeout. | | `ssh_task` | `SshTaskWidgetSource` | Look up saved task by `config.task_id` in `SettingsStore`, resolve machine via existing `_resolve_machine_for_task` logic or a shared helper, run via `LocalCommandClient`/`RemoteSSHClient`, return trimmed stdout/stderr/exit_status. | | `static` | `StaticWidgetSource` | Return `{"text": config.get("text", "")}`; no network call. | Adapter registry: ```python SOURCE_REGISTRY: dict[str, WidgetSource] = { "jellyfin": JellyfinWidgetSource(), "backups": BackupsWidgetSource(), "grafana": GrafanaWidgetSource(), "prometheus": PrometheusWidgetSource(), "ssh_task": SshTaskWidgetSource(), "static": StaticWidgetSource(), } ``` Adapters must catch all exceptions and return `{"error": "human-readable message"}`. The only 500 case is an unhandled exception in the adapter, which the endpoint catches and logs. Timeouts (adapter-level, not HTTP client-level where possible): - `jellyfin`: 10 s - `backups`: 10 s - `prometheus`: 10 s - `ssh_task`: 30 s - `grafana`: 5 s - `static`: no fetch ### 2.3 Router layout New file: `backend/src/media_library_viewer_api/routers/widgets.py` ```python from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.models.widgets import ( WidgetInstance, WidgetInstanceInput, WidgetTypeInfo, WidgetDataResponse, ) from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY from media_library_viewer_api.widgets.sources import SOURCE_REGISTRY router = APIRouter(prefix="/api/widgets", tags=["widgets"]) ``` Endpoints: | Method | Path | Handler | |--------|------|---------| | GET | `/sources` | `list_sources()` — returns `["jellyfin", "backups", "grafana", "prometheus", "ssh_task", "static"]` | | GET | `/types` | `list_types()` — returns `list[WidgetTypeInfo]` built from `WIDGET_REGISTRY` | | GET | `/instances` | `list_instances(store)` — `store.list_widgets()` mapped to `WidgetInstance` | | POST | `/instances` | `create_instance(body, store)` — status 201 | | PUT | `/instances/{widget_id}` | `update_instance(widget_id, body, store)` — 404 if missing, 400 if `body.id != widget_id` | | DELETE | `/instances/{widget_id}` | `delete_instance(widget_id, store)` — 404 if missing | | GET | `/instances/{widget_id}/data` | `fetch_data(widget_id, store)` — look up widget, resolve source adapter, return `WidgetDataResponse` | Validation flow in create/update: 1. Validate `WidgetInstanceInput` Pydantic model. 2. Reject forbidden credential keys anywhere in `config`. 3. Verify `widget_type` is in `WIDGET_REGISTRY`. 4. Verify `addon_id` matches the registry entry for that type. 5. Validate `config` against the widget type's JSON schema. 6. Persist via `store.upsert_widget()`. ### 2.4 Pydantic models New file: `backend/src/media_library_viewer_api/models/widgets.py` ```python from typing import Any from pydantic import BaseModel, Field, field_validator, model_validator FORBIDDEN_CONFIG_KEYS = { "password", "token", "secret", "api_key", "apikey", "private_key", "passphrase", "credential", } def _looks_secret(value: Any) -> bool: if not isinstance(value, str) or not value.strip(): return False lowered = value.lower() if value.startswith("sk-") or value.startswith("eyJ"): return True if len(value) > 64 and lowered.isalnum(): return True return False def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]: for key, value in config.items(): if key.lower() in FORBIDDEN_CONFIG_KEYS: raise ValueError(f"Credential key '{key}' is not allowed in widget config") if _looks_secret(value): raise ValueError(f"Value for '{key}' looks like a secret") if isinstance(value, dict): _validate_config_keys(value) return config class WidgetInstanceInput(BaseModel): id: str | None = None addon_id: str widget_type: str title: str = Field(..., min_length=1) config: dict[str, Any] = Field(default_factory=dict) enabled: bool = True sort_order: int = Field(default=0, ge=0) @field_validator("config") @classmethod def reject_credential_keys(cls, v): return _validate_config_keys(v or {}) class WidgetInstance(WidgetInstanceInput): id: str created_at: int updated_at: int class WidgetTypeInfo(BaseModel): addon_id: str widget_type: str name: str description: str source_type: str config_schema: dict[str, Any] class WidgetDataResponse(BaseModel): widget_id: str widget_type: str data: dict[str, Any] | None error: str | None fetched_at: int ``` Widget registry file: `backend/src/media_library_viewer_api/widgets/registry.py` ```python WIDGET_REGISTRY: dict[str, dict[str, Any]] = { "jellyfin": { "addon_id": "core", "name": "Jellyfin activity", "description": "Live sessions and idle users from a Jellyfin server.", "source_type": "jellyfin", "config_schema": { "type": "object", "properties": { "machine_id": {"type": "string", "description": "Jellyfin machine id (empty = default)"}, }, "required": [], }, }, "backups": { "addon_id": "backups", ... }, "grafana-link": { "addon_id": "grafana", ... }, "prometheus-metric": { "addon_id": "prometheus", ... }, "ssh-task": { "addon_id": "ssh-tasks", ... }, "static": { "addon_id": "core", ... }, } ``` The registry explicitly maps `widget_type -> addon_id` so the backend can enforce invariant #2. ### 2.5 Main.py registration Add to `backend/src/media_library_viewer_api/main.py`: ```python from media_library_viewer_api.routers import widgets as widgets_router ... app.include_router(widgets_router.router) ``` Because all `/api/widgets` endpoints are under the existing JWT/API-key middleware (`enforce_jwt_auth`), no additional auth decorator is needed. --- ## 3. Frontend design ### 3.1 Widget registry New file: `frontend/src/widgets/registry.ts` ```typescript import type { WidgetInstance, WidgetInstanceInput } from "../types"; export interface WidgetConfigField { key: string; label: string; type: "string" | "select" | "boolean" | "number"; options?: { label: string; value: string }[]; helper?: string; } export interface WidgetDefinition { widgetType: string; addonId: string; name: string; description: string; sourceType: string; refreshInterval: number; // ms, 0 = no polling defaultConfig: Record; configFields: WidgetConfigField[]; component: React.ComponentType<{ widget: WidgetInstance }>; } export const WIDGET_REGISTRY: Record = { jellyfin: { ... }, backups: { ... }, "grafana-link": { ... }, "prometheus-metric": { ... }, "ssh-task": { ... }, static: { ... }, }; export function getWidgetDefinition(widgetType: string): WidgetDefinition | undefined { return WIDGET_REGISTRY[widgetType]; } ``` Refresh intervals (ms): - `jellyfin`: 30_000 - `backups`: 60_000 - `grafana-link`: 0 - `prometheus-metric`: 30_000 - `ssh-task`: 0 - `static`: 0 Widget components live in `frontend/src/widgets/*.tsx`: - `JellyfinWidget.tsx` — wraps `NowPlaying` / activity data. - `BackupsWidget.tsx` — reuses `BackupDashboardWidget` internals or extracts a shared presentational component. - `GrafanaLinkWidget.tsx` — renders a deep-link card. - `PrometheusMetricWidget.tsx` — metric value/sparkline card. - `SshTaskWidget.tsx` — preformatted output panel. - `StaticWidget.tsx` — markdown/text block. ### 3.2 Dashboard rendering loop Modify `frontend/src/pages/Dashboard.tsx`: ```tsx import { useWidgetInstances } from "../hooks/useWidgets"; import { WidgetInstance } from "../components/WidgetInstance"; export function Dashboard() { const { data: instances = [] } = useWidgetInstances(); const visible = useMemo( () => instances.filter((w) => w.enabled).sort((a, b) => a.sort_order - b.sort_order), [instances], ); return (
{/* Shortcuts remain a first-class section to avoid data migration */} {visible.map((widget) => ( ))}
); } ``` `WidgetInstance` renderer (`frontend/src/components/WidgetInstance.tsx`): ```tsx import { SectionCard } from "./SectionCard"; import { useWidgetData } from "../hooks/useWidgets"; import { getWidgetDefinition } from "../widgets/registry"; export function WidgetInstance({ widget }: { widget: WidgetInstance }) { const def = getWidgetDefinition(widget.widget_type); const { data, isLoading } = useWidgetData(widget.id, def?.refreshInterval ?? 0); if (!def) { return ( Unknown widget type: {widget.widget_type} ); } const Component = def.component; return ( {isLoading && !data ? : } ); } ``` Each widget component receives the `widget` instance and reads `data?.data` / `data?.error` from its own `useWidgetData` query (or the parent can pass it; both work, but passing avoids a second hook call). Prefer passing `data` and `isLoading` from `WidgetInstance` to the component to keep components pure. ### 3.3 TanStack Query hooks New file: `frontend/src/hooks/useWidgets.ts`: ```typescript import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { fetchWidgetSources, fetchWidgetTypes, fetchWidgetInstances, createWidgetInstance, updateWidgetInstance, deleteWidgetInstance, fetchWidgetData, } from "../api/widgets"; import type { WidgetInstanceInput } from "../types"; export function useWidgetInstances() { return useQuery({ queryKey: ["widgets", "instances"], queryFn: fetchWidgetInstances, refetchInterval: 60_000, }); } export function useWidgetData(widgetId: string, refreshInterval: number) { return useQuery({ queryKey: ["widgets", "data", widgetId], queryFn: () => fetchWidgetData(widgetId), refetchInterval: refreshInterval || false, retry: 1, }); } export function useSaveWidgetInstance() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input: WidgetInstanceInput) => input.id ? updateWidgetInstance(input) : createWidgetInstance(input), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] }), }); } export function useDeleteWidgetInstance() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (widgetId: string) => deleteWidgetInstance(widgetId), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] }), }); } export function useWidgetSources() { return useQuery({ queryKey: ["widgets", "sources"], queryFn: fetchWidgetSources }); } export function useWidgetTypes() { return useQuery({ queryKey: ["widgets", "types"], queryFn: fetchWidgetTypes }); } ``` ### 3.4 Configuration UI Add a new `WidgetConfigDialog` component (can live in `frontend/src/components/WidgetConfigDialog.tsx` or inline in `Dashboard.tsx`). Behavior: - "Edit dashboard" button in the Dashboard header opens the dialog. - Dialog lists all instances (enabled and disabled) with sort-order inputs, enabled toggle, edit/delete actions, and up/down reorder buttons. - "Add widget" sub-flow: select widget type from registry, then render source-specific config fields. - Form fields reuse `Dialog`, `Input`, `Label`, `Switch`, `Select`, `Button`, `Alert`. Source-specific config rendering: ```tsx function WidgetConfigFields({ definition, config, onChange, }: { definition: WidgetDefinition; config: Record; onChange: (config: Record) => void; }) { return (
{definition.configFields.map((field) => ( {field.type === "select" ? ( ) : ( onChange({ ...config, [field.key]: e.target.value })} /> )} ))}
); } ``` For fields that need dynamic options (e.g., machine selection for `jellyfin`, saved task selection for `ssh-task`), the dialog can use `useMonitoringSettings()` and `useTasks()` to populate select options and map them to `machine_id`/`task_id` config values. ### 3.5 Addon pages New file: `frontend/src/pages/AddonPage.tsx`: ```tsx import { useParams } from "react-router-dom"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { GrafanaAddonPage } from "../addons/GrafanaAddonPage"; import { PrometheusAddonPage } from "../addons/PrometheusAddonPage"; import { SshTasksAddonPage } from "../addons/SshTasksAddonPage"; const ADDON_PAGES: Record = { grafana: GrafanaAddonPage, prometheus: PrometheusAddonPage, "ssh-tasks": SshTasksAddonPage, }; export function AddonPage() { const { addonId } = useParams<{ addonId: string }>(); const Page = addonId ? ADDON_PAGES[addonId] : undefined; if (!Page) { return ( Addon "{addonId}" is not installed. ); } return ; } ``` Register in `frontend/src/App.tsx` inside both route trees: ```tsx } /> ``` Grafana widgets render a link to `/addons/grafana` or directly to the external Grafana URL; either is acceptable. The spec requires the addon page route exists and Grafana widgets deep-link rather than embed. --- ## 4. Data flow 1. **Config CRUD** - User opens config dialog → `useWidgetInstances()` and `useWidgetTypes()` load. - Add/edit form → `useSaveWidgetInstance().mutate(input)` → `POST/PUT /api/widgets/instances` → backend validates, persists, returns `WidgetInstance` → query cache invalidated → dashboard re-renders. 2. **Per-widget data fetch** - `Dashboard.tsx` maps enabled instances to ``. - Each `WidgetInstance` calls `useWidgetData(widget.id, refreshInterval)`. - Hook calls `GET /api/widgets/instances/{id}/data`. - Endpoint loads the instance, picks the adapter by `source_type`, calls `adapter.fetch(config)`, wraps in `WidgetDataResponse`. - Adapter resolves credentials from machine store / env / SSH-key store and returns data or error payload. 3. **Error boundaries and loading states** - Adapter exceptions are caught by the endpoint and returned as `error` with HTTP 200; unhandled exceptions return 500. - `WidgetInstance` shows a skeleton on initial load. - If `data.error` is set, render an inline `Alert` inside the widget's `SectionCard`. - A failing widget does not block sibling widgets because each has its own query. --- ## 5. Security design - **No secrets in `config_json`**: forbidden key list enforced by Pydantic validator and store write path. Values starting with `sk-`/`eyJ` or long alphanumeric strings are rejected. - **Credential resolution**: adapters use `get_settings_store().get_machine_config()`, `get_ssh_key()`, and `get_settings()` for Grafana/Prometheus URLs. No widget config stores URLs with embedded credentials. - **Saved-task registry reuse**: `ssh_task` adapter only runs tasks from the existing saved-task registry; no arbitrary command execution. - **Auth**: all `/api/widgets` endpoints inherit existing JWT/API-key middleware. - **No iframes**: addon pages and Grafana widgets render links only. - **Validation at two layers**: Pydantic model rejects malformed/credential-laden configs; store-level normalization also rejects forbidden keys as defense-in-depth. --- ## 6. Testing approach ### Backend New test file: `backend/tests/test_widgets.py` - `TestWidgetRegistry`: `GET /api/widgets/sources` and `/api/widgets/types` return expected closed lists. - `TestWidgetCrud`: - create static widget → 201, config round-trips. - update nonexistent → 404. - delete → 404 after delete. - unknown widget type → 422. - credential key in config → 422. - `TestWidgetData`: - static widget data returns text unchanged. - misconfigured jellyfin widget returns `error` in payload with HTTP 200. - `TestWidgetSeeding`: - fresh store seeds Jellyfin + Backups widgets. - existing widget rows prevent re-seeding. Use existing `test_client` fixture pattern from `test_api.py` with mocked Jellyfin/SSH clients where needed. ### Frontend - `npm run build` (via `tsc -b`) validates new TypeScript types and component imports. - Add `frontend/tests/widgets.test.mjs` using the existing `node:test` + `node:assert/strict` setup to test: - `getWidgetDefinition` returns correct refresh intervals. - registry contains exactly the six Phase 1 widget types. - If/when the project adopts Vitest, add hook tests with MSW; for Phase 1, rely on build + manual component tests. ### Integration / manual - Fresh Docker dev stack shows Jellyfin activity + Backups widgets by default. - Add each widget type via config UI and verify render + polling behavior. - Verify disabled widget is hidden and reorder changes dashboard order. --- ## 7. File-level plan ### Create | File | Rationale | |------|-----------| | `backend/src/media_library_viewer_api/models/widgets.py` | Pydantic models: `WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse` plus credential validators. | | `backend/src/media_library_viewer_api/widgets/__init__.py` | Package marker for widget subsystem. | | `backend/src/media_library_viewer_api/widgets/registry.py` | Closed widget-type registry mapping widget_type → addon_id, source_type, JSON schema. | | `backend/src/media_library_viewer_api/widgets/sources.py` | Stateless source adapters for all six source types. | | `backend/src/media_library_viewer_api/routers/widgets.py` | REST endpoints for CRUD, registry metadata, and data fetch. | | `frontend/src/types/index.ts` additions | TypeScript interfaces matching backend models. | | `frontend/src/api/widgets.ts` | API functions for widget endpoints. | | `frontend/src/hooks/useWidgets.ts` | TanStack Query hooks for instances, data, mutations. | | `frontend/src/widgets/registry.ts` | Frontend closed widget registry. | | `frontend/src/widgets/*.tsx` | Six widget presentational components. | | `frontend/src/components/WidgetInstance.tsx` | Renderer that loads data and dispatches to widget component. | | `frontend/src/components/WidgetConfigDialog.tsx` | Add/edit/reorder/remove configuration UI. | | `frontend/src/pages/AddonPage.tsx` | Route target for `/addons/:addonId`. | | `frontend/src/addons/GrafanaAddonPage.tsx` | Grafana addon page (links only, no iframe). | | `frontend/src/addons/PrometheusAddonPage.tsx` | Prometheus addon page. | | `frontend/src/addons/SshTasksAddonPage.tsx` | SSH tasks addon page. | | `backend/tests/test_widgets.py` | Backend API and store tests. | | `frontend/tests/widgets.test.mjs` | Frontend registry unit tests. | ### Modify | File | Rationale | |------|-----------| | `backend/src/media_library_viewer_api/services/settings_store.py` | Add `dashboard_widgets` schema, CRUD helpers, default seeding in `ensure_defaults()`. | | `backend/src/media_library_viewer_api/config.py` | Add `grafana_url: str` setting (default `http://grafana:3000`) so adapters can build deep-links. Optional if Grafana URL is already derivable from env; for Phase 1 add it explicitly. | | `backend/src/media_library_viewer_api/main.py` | Register `widgets_router`. | | `frontend/src/pages/Dashboard.tsx` | Replace hard-coded Jellyfin/Backups sections with widget instance loop; keep Shortcuts section intact; add "Edit dashboard" action. | | `frontend/src/App.tsx` | Add `/addons/:addonId` route in both OIDC and non-OIDC route trees. | | `docs/REQUIREMENTS.md` | Document new widget system behavior and security rule. | --- ## 8. Slice boundaries A full Phase 1 implementation is expected to touch ~1,000–1,200 lines across backend and frontend, exceeding the ~400-line review budget. Recommended reviewable slices: ### Slice 1: Backend CRUD and default seeding - Create `models/widgets.py`. - Create `widgets/registry.py`. - Create `routers/widgets.py` for CRUD + metadata endpoints. - Extend `settings_store.py` with table schema, helpers, and `_seed_dashboard_widgets()`. - Register router in `main.py`. - Add `backend/tests/test_widgets.py` for CRUD/registry tests. - **Estimated:** ~350–400 changed lines. ### Slice 2: Backend source adapters and data endpoint - Create `widgets/sources.py` with all six adapters. - Add `GET /api/widgets/instances/{id}/data` endpoint. - Add `grafana_url` to `config.py`. - Extract/share `dashboard.py` activity mapping if needed. - Extend tests with data-fetch scenarios. - **Estimated:** ~300–350 changed lines. ### Slice 3: Frontend types, API, hooks, and widget registry - Add TypeScript interfaces to `types/index.ts`. - Create `api/widgets.ts` and `hooks/useWidgets.ts`. - Create `widgets/registry.ts` and the six widget components. - Add `frontend/tests/widgets.test.mjs`. - **Estimated:** ~350–400 changed lines. ### Slice 4: Dashboard rendering loop, config UI, and addon pages - Modify `Dashboard.tsx` to render widget instances. - Create `WidgetInstance.tsx` and `WidgetConfigDialog.tsx`. - Create `AddonPage.tsx` and addon pages. - Register addon route in `App.tsx`. - Update `docs/REQUIREMENTS.md`. - **Estimated:** ~350–400 changed lines. **Recommended order:** Slice 1 → Slice 2 → Slice 3 → Slice 4. Each slice is independently testable and leaves the app in a working state. Slices 1 and 2 can be merged into one PR if the backend-only change stays under the budget; otherwise keep them separate. --- ## 9. Open questions / decisions 1. **Grafana URL source**: Add `grafana_url` to `Settings` in `config.py` (default `http://grafana:3000`). This is the minimal change; alternatively derive from `ALERTMANAGER_URL` or an env var, but explicit is clearer. 2. **Shortcuts migration**: Keep Shortcuts as a hard-coded section above widgets for Phase 1. This avoids a data migration and satisfies "no data is lost". A future phase can migrate shortcuts into the widget system. 3. **Prometheus URL**: Reuse existing `prometheus_file_sd_dir` / convention or add `prometheus_url` setting. For instant queries the adapter needs a query URL; add `prometheus_url: str = "http://prometheus:9090"` to `Settings`.