Files
Developer 200d319fb0 feat(widgets): add backend CRUD, registry, and default seeding
Introduce a closed, compile-time widget registry and backend CRUD for
dashboard widget instances.

- Add dashboard_widgets SQLite table in SettingsStore with CRUD helpers and
  default seeding (Jellyfin + Backups) on first install.
- Add Pydantic models with credential-key and secret-value rejection.
- Add widgets router: /api/widgets/sources, /types, /instances CRUD.
- Call ensure_defaults() in app lifespan so fresh installs seed defaults.
- Add backend tests covering registry, CRUD, validation, and seeding.
- Include SDD artifacts: exploration, proposal, spec, design, tasks.
2026-06-19 20:07:47 +00:00

30 KiB
Raw Permalink Blame History

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:

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:

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

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:

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

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

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

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:

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

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:

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

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:

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:

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:

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:

<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,0001,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: ~350400 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: ~300350 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: ~350400 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: ~350400 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.