# 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<string, unknown>;
  configFields: WidgetConfigField[];
  component: React.ComponentType<{ widget: WidgetInstance }>;
}

export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
  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 (
    <div className="flex flex-col gap-4">
      {/* Shortcuts remain a first-class section to avoid data migration */}
      <ShortcutsSection />

      {visible.map((widget) => (
        <WidgetInstance key={widget.id} widget={widget} />
      ))}

      <WidgetConfigDialog />
      <ConfirmDialog ... />
    </div>
  );
}
```

`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 (
      <SectionCard title={widget.title}>
        <Alert><AlertDescription>Unknown widget type: {widget.widget_type}</AlertDescription></Alert>
      </SectionCard>
    );
  }

  const Component = def.component;
  return (
    <SectionCard title={widget.title}>
      {isLoading && !data ? <SkeletonWidget /> : <Component widget={widget} />}
    </SectionCard>
  );
}
```

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<string, unknown>;
  onChange: (config: Record<string, unknown>) => void;
}) {
  return (
    <div className="flex flex-col gap-3">
      {definition.configFields.map((field) => (
        <Field key={field.key} label={field.label} htmlFor={field.key}>
          {field.type === "select" ? (
            <Select
              value={String(config[field.key] ?? "")}
              onValueChange={(v) => onChange({ ...config, [field.key]: v })}
            >
              {/* ... */}
            </Select>
          ) : (
            <Input
              id={field.key}
              value={String(config[field.key] ?? "")}
              onChange={(e) => onChange({ ...config, [field.key]: e.target.value })}
            />
          )}
        </Field>
      ))}
    </div>
  );
}
```

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<string, React.ComponentType> = {
  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 (
      <Alert>
        <AlertDescription>Addon "{addonId}" is not installed.</AlertDescription>
      </Alert>
    );
  }
  return <Page />;
}
```

Register in `frontend/src/App.tsx` inside both route trees:

```tsx
<Route path="/addons/:addonId" element={<AddonPage />} />
```

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 `<WidgetInstance />`.
   - 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`.
