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.
30 KiB
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
SectionCardlayout; 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)— parseconfig_jsoninto aconfigdict._normalize_widget_payload(payload, widget_id=None)— validate/assign defaults, generateidif missing.list_widgets()— return all rows ordered bysort_order ASC, created_at ASC.get_widget(widget_id)— single row.upsert_widget(payload, widget_id=None)— insert or replace; preservecreated_at.delete_widget(widget_id)— delete by id.seed_default_widgets()— called fromensure_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 sbackups: 10 sprometheus: 10 sssh_task: 30 sgrafana: 5 sstatic: 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:
- Validate
WidgetInstanceInputPydantic model. - Reject forbidden credential keys anywhere in
config. - Verify
widget_typeis inWIDGET_REGISTRY. - Verify
addon_idmatches the registry entry for that type. - Validate
configagainst the widget type's JSON schema. - 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_000backups: 60_000grafana-link: 0prometheus-metric: 30_000ssh-task: 0static: 0
Widget components live in frontend/src/widgets/*.tsx:
JellyfinWidget.tsx— wrapsNowPlaying/ activity data.BackupsWidget.tsx— reusesBackupDashboardWidgetinternals 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
-
Config CRUD
- User opens config dialog →
useWidgetInstances()anduseWidgetTypes()load. - Add/edit form →
useSaveWidgetInstance().mutate(input)→POST/PUT /api/widgets/instances→ backend validates, persists, returnsWidgetInstance→ query cache invalidated → dashboard re-renders.
- User opens config dialog →
-
Per-widget data fetch
Dashboard.tsxmaps enabled instances to<WidgetInstance />.- Each
WidgetInstancecallsuseWidgetData(widget.id, refreshInterval). - Hook calls
GET /api/widgets/instances/{id}/data. - Endpoint loads the instance, picks the adapter by
source_type, callsadapter.fetch(config), wraps inWidgetDataResponse. - Adapter resolves credentials from machine store / env / SSH-key store and returns data or error payload.
-
Error boundaries and loading states
- Adapter exceptions are caught by the endpoint and returned as
errorwith HTTP 200; unhandled exceptions return 500. WidgetInstanceshows a skeleton on initial load.- If
data.erroris set, render an inlineAlertinside the widget'sSectionCard. - A failing widget does not block sibling widgets because each has its own query.
- Adapter exceptions are caught by the endpoint and returned as
5. Security design
- No secrets in
config_json: forbidden key list enforced by Pydantic validator and store write path. Values starting withsk-/eyJor long alphanumeric strings are rejected. - Credential resolution: adapters use
get_settings_store().get_machine_config(),get_ssh_key(), andget_settings()for Grafana/Prometheus URLs. No widget config stores URLs with embedded credentials. - Saved-task registry reuse:
ssh_taskadapter only runs tasks from the existing saved-task registry; no arbitrary command execution. - Auth: all
/api/widgetsendpoints 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/sourcesand/api/widgets/typesreturn 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
errorin 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(viatsc -b) validates new TypeScript types and component imports.- Add
frontend/tests/widgets.test.mjsusing the existingnode:test+node:assert/strictsetup to test:getWidgetDefinitionreturns 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.pyfor CRUD + metadata endpoints. - Extend
settings_store.pywith table schema, helpers, and_seed_dashboard_widgets(). - Register router in
main.py. - Add
backend/tests/test_widgets.pyfor CRUD/registry tests. - Estimated: ~350–400 changed lines.
Slice 2: Backend source adapters and data endpoint
- Create
widgets/sources.pywith all six adapters. - Add
GET /api/widgets/instances/{id}/dataendpoint. - Add
grafana_urltoconfig.py. - Extract/share
dashboard.pyactivity 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.tsandhooks/useWidgets.ts. - Create
widgets/registry.tsand 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.tsxto render widget instances. - Create
WidgetInstance.tsxandWidgetConfigDialog.tsx. - Create
AddonPage.tsxand 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
- Grafana URL source: Add
grafana_urltoSettingsinconfig.py(defaulthttp://grafana:3000). This is the minimal change; alternatively derive fromALERTMANAGER_URLor an env var, but explicit is clearer. - 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.
- Prometheus URL: Reuse existing
prometheus_file_sd_dir/ convention or addprometheus_urlsetting. For instant queries the adapter needs a query URL; addprometheus_url: str = "http://prometheus:9090"toSettings.