Compare commits

...

6 Commits

Author SHA1 Message Date
Developer 09eb76bf0f style(widgets): apply formatter to dashboard and addon files 2026-06-22 08:05:44 +00:00
Developer ed7a7a5ce0 feat(widgets): dashboard loop, widget config UI, and addon pages
PR 4 of 4 for configurable dashboard widgets.

- Replace hard-coded Jellyfin/Backups dashboard sections with a loop that
  renders enabled widget instances by sort_order.
- Add WidgetInstance renderer and WidgetConfigDialog for adding, editing,
  enabling/disabling, deleting, and reordering widgets.
- Add addon pages for grafana, prometheus, and ssh-tasks at /addons/:addonId.
- Register /addons/:addonId route in App.tsx.
- Update docs/REQUIREMENTS.md with the widget system design and API.

Verification:
- backend ruff clean; pytest 200 passed
- frontend npm run lint: 0 errors
- frontend npm run build: success
- frontend npm run test -- src/widgets/registry.test.ts: 3 passed
2026-06-21 20:45:42 +00:00
Developer e4e879d1c8 Merge pull request 'feat(widgets): add frontend widget runtime (types, API, hooks, registry, components)' (#4) from feat/dashboard-widgets-frontend-runtime into main 2026-06-21 16:55:13 +00:00
Developer 2557185fb7 style(widgets): apply formatter to widget runtime files 2026-06-21 16:55:12 +00:00
Developer e1356b20f1 feat(widgets): add frontend widget runtime (types, API, hooks, registry, components)
PR 3 of 4 for configurable dashboard widgets.

- Add TypeScript widget interfaces (WidgetInstance, WidgetInstanceInput,
  WidgetTypeInfo, WidgetDataResponse).
- Create widget API client for CRUD, registry metadata, and per-widget data.
- Create TanStack Query hooks for instances, data, sources, types, and mutations.
- Create closed frontend widget registry with metadata, source type, refresh
  intervals, and config fields.
- Add six shadcn/ui-based widget components: Jellyfin, Backups, Grafana link,
  Prometheus metric, SSH task output, and static text.
- Add Vitest unit tests for registry metadata.

Verification:
- backend ruff clean; pytest 200 passed
- frontend npm run lint: 0 errors
- frontend npm run build: success
- frontend npm run test -- src/widgets/registry.test.ts: 3 passed
2026-06-21 16:24:44 +00:00
Developer e6d333ef7b Merge pull request 'feat(widgets): add backend source adapters and per-widget data endpoint' (#3) from feat/dashboard-widgets-backend-adapters into main 2026-06-21 16:01:44 +00:00
23 changed files with 1443 additions and 58 deletions
+48
View File
@@ -256,6 +256,54 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
- Job templates should remain centralized in `jobs.py` for future extension.
- Remote job template values must be shell-quoted before execution.
## Configurable Dashboard Widgets
### Overview
The dashboard is composed of persisted widget instances stored in the backend SQLite
settings database. Each widget has a type, title, configuration, enabled flag, and
sort order. The frontend renders enabled widgets in sort order and fetches data
independently through the backend source adapters.
### Widget types
- **Jellyfin activity** — live sessions and idle users from a configured Jellyfin machine.
- **Backups** — backup job summary and active alerts.
- **Grafana link** — deep-link to a Grafana dashboard or panel (no iframe embedding).
- **Prometheus metric** — result of a PromQL instant query.
- **SSH task output** — output of a saved task run on a machine.
- **Static text** — plain text or markdown note.
### Security
- Widget `config` may not contain credential keys such as `password`, `token`,
`secret`, `api_key`, `private_key`, or `passphrase`, or values that look like
secrets (e.g., base64 blobs, `sk-` prefixes).
- Widgets reuse machine-level Jellyfin/SSH credentials and environment settings for
Grafana/Prometheus URLs; no secrets are stored in widget configuration.
- SSH task widgets only run tasks from the saved-task registry; arbitrary commands
are not accepted.
### Addon pages
Each non-core addon gets a dedicated page at `/addons/:addonId`:
- `/addons/grafana`
- `/addons/prometheus`
- `/addons/ssh-tasks`
Unknown addons render a "not installed" alert.
### API
- `GET /api/widgets/sources` — list source types.
- `GET /api/widgets/types` — list widget type metadata.
- `GET /api/widgets/instances` — list widget instances.
- `POST /api/widgets/instances` — create instance.
- `PUT /api/widgets/instances/{id}` — update instance.
- `DELETE /api/widgets/instances/{id}` — delete instance.
- `GET /api/widgets/instances/{id}/data` — fetch widget data.
## Decision Log
- 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template.
+3
View File
@@ -22,6 +22,7 @@ import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage";
import { AddonPage } from "./pages/AddonPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
@@ -449,6 +450,7 @@ function AppInner() {
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} />
</Route>
</Routes>
</BrowserRouter>
@@ -480,6 +482,7 @@ function AppInner() {
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} />
</Route>
</Routes>
</BrowserRouter>
+37
View File
@@ -0,0 +1,37 @@
import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function GrafanaAddonPage() {
const grafanaUrl =
(import.meta.env.VITE_GRAFANA_URL as string | undefined) ||
"http://localhost:3000";
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Grafana</h2>
<Card>
<CardHeader>
<CardTitle>Metrics & logs</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Open the full Grafana instance for dashboards, metrics, and log
exploration.
</p>
<Button asChild>
<a
href={grafanaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
Open Grafana
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,36 @@
import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function PrometheusAddonPage() {
const prometheusUrl =
(import.meta.env.VITE_PROMETHEUS_URL as string | undefined) ||
"http://localhost:9090";
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Prometheus</h2>
<Card>
<CardHeader>
<CardTitle>Metrics explorer</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Open Prometheus to run ad-hoc PromQL queries and inspect targets.
</p>
<Button asChild>
<a
href={prometheusUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
Open Prometheus
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { Terminal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useNavigate } from "react-router-dom";
export function SshTasksAddonPage() {
const navigate = useNavigate();
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">SSH tasks</h2>
<Card>
<CardHeader>
<CardTitle>Saved actions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Create, edit, and run saved shell or Python tasks against local or
remote machines.
</p>
<Button onClick={() => navigate("/actions")}>
<Terminal className="mr-2 h-4 w-4" />
Open Actions
</Button>
</CardContent>
</Card>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { GrafanaAddonPage } from "./GrafanaAddonPage";
export { PrometheusAddonPage } from "./PrometheusAddonPage";
export { SshTasksAddonPage } from "./SshTasksAddonPage";
+69
View File
@@ -0,0 +1,69 @@
import type {
WidgetDataResponse,
WidgetInstance,
WidgetInstanceInput,
WidgetTypeInfo,
} from "../types";
const API_BASE = "/api";
export async function fetchWidgetSources(): Promise<string[]> {
const res = await fetch(`${API_BASE}/widgets/sources`);
if (!res.ok) throw new Error("Failed to fetch widget sources");
return res.json();
}
export async function fetchWidgetTypes(): Promise<WidgetTypeInfo[]> {
const res = await fetch(`${API_BASE}/widgets/types`);
if (!res.ok) throw new Error("Failed to fetch widget types");
return res.json();
}
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
const res = await fetch(`${API_BASE}/widgets/instances`);
if (!res.ok) throw new Error("Failed to fetch widget instances");
return res.json();
}
export async function createWidgetInstance(
input: WidgetInstanceInput,
): Promise<WidgetInstance> {
const res = await fetch(`${API_BASE}/widgets/instances`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to create widget instance");
return res.json();
}
export async function updateWidgetInstance(
input: WidgetInstanceInput,
): Promise<WidgetInstance> {
if (!input.id) throw new Error("Widget ID is required for update");
const res = await fetch(`${API_BASE}/widgets/instances/${input.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to update widget instance");
return res.json();
}
export async function deleteWidgetInstance(
widgetId: string,
): Promise<{ status: string }> {
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete widget instance");
return res.json();
}
export async function fetchWidgetData(
widgetId: string,
): Promise<WidgetDataResponse> {
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}/data`);
if (!res.ok) throw new Error("Failed to fetch widget data");
return res.json();
}
@@ -0,0 +1,464 @@
import { useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
import {
useDeleteWidgetInstance,
useSaveWidgetInstance,
useWidgetInstances,
useWidgetTypes,
} from "../hooks/useWidgets";
import { useMonitoringSettings, useTasks } from "../hooks/useSettings";
import type {
MonitoringMachine,
SavedTask,
WidgetInstance,
WidgetInstanceInput,
} from "../types";
import {
getWidgetDefinition,
listWidgetTypes,
type WidgetDefinition,
} from "../widgets/registry";
interface Props {
open: boolean;
onClose: () => void;
}
function emptyDraft(widgetType: string): WidgetInstanceInput {
const def = getWidgetDefinition(widgetType);
return {
addon_id: def?.addonId ?? "",
widget_type: widgetType,
title: def?.name ?? "",
config: { ...(def?.defaultConfig ?? {}) },
enabled: true,
sort_order: 0,
};
}
function Field({
label,
htmlFor,
helper,
children,
}: {
label: string;
htmlFor: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{helper ? (
<p className="text-xs text-muted-foreground">{helper}</p>
) : null}
</div>
);
}
function WidgetConfigFields({
definition,
config,
onChange,
machines,
tasks,
}: {
definition: WidgetDefinition;
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
machines: MonitoringMachine[];
tasks: SavedTask[];
}) {
return (
<div className="flex flex-col gap-3">
{definition.configFields.map((field) => {
const value = config[field.key] ?? "";
if (
definition.widgetType === "jellyfin" &&
field.key === "machine_id"
) {
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Select
value={String(value)}
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
>
<SelectTrigger id={field.key}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Default</SelectItem>
{machines
.filter((m) => m.enabled && m.services.includes("jellyfin"))
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
if (definition.widgetType === "ssh-task" && field.key === "task_id") {
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Select
value={String(value)}
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
>
<SelectTrigger id={field.key}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{tasks
.filter((t) => t.enabled)
.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
if (field.type === "number") {
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Input
id={field.key}
type="number"
value={String(value)}
onChange={(e) =>
onChange({
...config,
[field.key]:
e.target.value === ""
? undefined
: Number(e.target.value),
})
}
/>
</Field>
);
}
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Input
id={field.key}
value={String(value)}
onChange={(e) =>
onChange({
...config,
[field.key]: e.target.value,
})
}
/>
</Field>
);
})}
</div>
);
}
export function WidgetConfigDialog({ open, onClose }: Props) {
const { data: instances = [] } = useWidgetInstances();
const { data: types = [] } = useWidgetTypes();
const { data: machines = [] } = useMonitoringSettings();
const { data: tasks = [] } = useTasks();
const saveWidget = useSaveWidgetInstance();
const deleteWidget = useDeleteWidgetInstance();
const [draft, setDraft] = useState<WidgetInstanceInput | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const registryDefinitions = useMemo(() => listWidgetTypes(), []);
const sortedInstances = useMemo(
() =>
[...instances].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
),
[instances],
);
function startAdd(widgetType: string) {
setDraft(emptyDraft(widgetType));
setEditingId(null);
}
function startEdit(instance: WidgetInstance) {
setDraft({
id: instance.id,
addon_id: instance.addon_id,
widget_type: instance.widget_type,
title: instance.title,
config: instance.config,
enabled: instance.enabled,
sort_order: instance.sort_order,
});
setEditingId(instance.id);
}
function reset() {
setDraft(null);
setEditingId(null);
}
async function saveDraft() {
if (!draft) return;
await saveWidget.mutateAsync(draft);
reset();
}
async function toggleEnabled(instance: WidgetInstance) {
await saveWidget.mutateAsync({
...instance,
enabled: !instance.enabled,
});
}
async function moveInstance(index: number, direction: -1 | 1) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
const a = sortedInstances[index];
const b = sortedInstances[targetIndex];
await Promise.all([
saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }),
saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }),
]);
}
async function removeInstance(instance: WidgetInstance) {
await deleteWidget.mutateAsync(instance.id);
}
function handleClose(next: boolean) {
if (!next) {
reset();
onClose();
}
}
const definition = draft ? getWidgetDefinition(draft.widget_type) : undefined;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{draft
? editingId
? "Edit widget"
: "Add widget"
: "Dashboard widgets"}
</DialogTitle>
</DialogHeader>
{draft && definition ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
{definition.description}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Title" htmlFor="widget-title">
<Input
id="widget-title"
value={draft.title}
onChange={(e) =>
setDraft({ ...draft, title: e.target.value })
}
/>
</Field>
<Field label="Sort order" htmlFor="widget-sort-order">
<Input
id="widget-sort-order"
type="number"
value={String(draft.sort_order)}
onChange={(e) =>
setDraft({
...draft,
sort_order:
e.target.value === "" ? 0 : Number(e.target.value),
})
}
/>
</Field>
</div>
<div className="flex items-center gap-2">
<Switch
id="widget-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
}
/>
<Label htmlFor="widget-enabled">Enabled</Label>
</div>
<WidgetConfigFields
definition={definition}
config={draft.config}
onChange={(config) => setDraft({ ...draft, config })}
machines={machines}
tasks={tasks}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={reset}>
Back
</Button>
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
Save widget
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
{sortedInstances.length === 0 ? (
<Alert>
<AlertDescription>
No widgets yet. Add one below.
</AlertDescription>
</Alert>
) : (
<div className="flex flex-col gap-2">
{sortedInstances.map((instance, index) => {
const typeDef = getWidgetDefinition(instance.widget_type);
return (
<div
key={instance.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{instance.title}</span>
<Badge variant="outline">
{typeDef?.name ?? instance.widget_type}
</Badge>
{!instance.enabled ? (
<Badge variant="secondary">disabled</Badge>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === 0}
onClick={() => moveInstance(index, -1)}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === sortedInstances.length - 1}
onClick={() => moveInstance(index, 1)}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Switch
checked={instance.enabled}
onCheckedChange={() => toggleEnabled(instance)}
aria-label={`Toggle ${instance.title}`}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(instance)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => removeInstance(instance)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Add widget</p>
<div className="flex flex-wrap gap-2">
{registryDefinitions.map((def) => (
<Button
key={def.widgetType}
variant="outline"
size="sm"
onClick={() => startAdd(def.widgetType)}
>
<Plus className="mr-1 h-3 w-3" />
{def.name}
</Button>
))}
</div>
</div>
{types.length === 0 ? (
<Alert>
<AlertDescription>
Widget registry is empty. Backend may not be running.
</AlertDescription>
</Alert>
) : null}
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,26 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { getWidgetDefinition } from "../widgets/registry";
import type { WidgetInstance } from "../types";
import { SectionCard } from "./SectionCard";
interface Props {
widget: WidgetInstance;
}
export function WidgetInstance({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
if (!def) {
return (
<SectionCard title={widget.title}>
<Alert>
<AlertDescription>
Unknown widget type: {widget.widget_type}
</AlertDescription>
</Alert>
</SectionCard>
);
}
const Component = def.component;
return <Component widget={widget} />;
}
+64
View File
@@ -0,0 +1,64 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createWidgetInstance,
deleteWidgetInstance,
fetchWidgetData,
fetchWidgetInstances,
fetchWidgetSources,
fetchWidgetTypes,
updateWidgetInstance,
} 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,
enabled: !!widgetId,
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,
});
}
+28
View File
@@ -0,0 +1,28 @@
import { useParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
GrafanaAddonPage,
PrometheusAddonPage,
SshTasksAddonPage,
} from "../addons";
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 />;
}
+28 -56
View File
@@ -21,18 +21,17 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
useActivity,
useDashboardShortcuts,
useDeleteDashboardShortcut,
useSaveDashboardShortcut,
} from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings";
import { useWidgetInstances } from "../hooks/useWidgets";
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { NowPlaying } from "../components/NowPlaying";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import BackupDashboardWidget from "../components/BackupDashboardWidget";
import { WidgetInstance } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
function emptyShortcut(): DashboardShortcutInput {
return {
@@ -327,19 +326,6 @@ function ShortcutCard({
export function Dashboard() {
const navigate = useNavigate();
const { data: machines = [] } = useMonitoringSettings();
const jellyfinMachines = useMemo(
() =>
machines.filter(
(machine) => machine.enabled && machine.services.includes("jellyfin"),
),
[machines],
);
const [activeJellyfinMachineId, setActiveJellyfinMachineId] =
useState<string>("");
const selectedJellyfinId =
activeJellyfinMachineId || jellyfinMachines[0]?.id || "";
const { data: activity } = useActivity(selectedJellyfinId || undefined);
const { data: shortcuts = [] } = useDashboardShortcuts();
const saveShortcut = useSaveDashboardShortcut();
const deleteShortcut = useDeleteDashboardShortcut();
@@ -348,6 +334,16 @@ export function Dashboard() {
emptyShortcut(),
);
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
const { data: widgetInstances = [] } = useWidgetInstances();
const visibleWidgets = useMemo(
() =>
widgetInstances
.filter((w) => w.enabled)
.sort((a, b) => a.sort_order - b.sort_order),
[widgetInstances],
);
const openCreateShortcut = () => {
setShortcutDraft(emptyShortcut());
@@ -382,9 +378,14 @@ export function Dashboard() {
title="Shortcuts"
description="Quick links to websites today, with room for action and user shortcuts later."
action={
<Button variant="outline" onClick={openCreateShortcut}>
Add shortcut
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
Edit dashboard
</Button>
<Button variant="outline" onClick={openCreateShortcut}>
Add shortcut
</Button>
</div>
}
>
{shortcuts.length ? (
@@ -416,42 +417,9 @@ export function Dashboard() {
)}
</SectionCard>
<SectionCard
title="Jellyfin activity"
description="Live sessions and idle users from Jellyfin."
action={
jellyfinMachines.length > 1 ? (
<Select
value={selectedJellyfinId}
onValueChange={(value) => setActiveJellyfinMachineId(value)}
>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jellyfinMachines.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : jellyfinMachines.length === 1 ? (
<Badge variant="outline">{jellyfinMachines[0].name}</Badge>
) : null
}
>
{activity ? (
<NowPlaying
sessions={activity}
onSelectSession={(session) =>
navigate(`/users?user=${encodeURIComponent(session.user)}`)
}
/>
) : null}
</SectionCard>
<BackupDashboardWidget />
{visibleWidgets.map((widget) => (
<WidgetInstance key={widget.id} widget={widget} />
))}
<ShortcutDialog
open={shortcutDialogOpen}
@@ -473,6 +441,10 @@ export function Dashboard() {
setDeleteShortcutId(null);
}}
/>
<WidgetConfigDialog
open={widgetDialogOpen}
onClose={() => setWidgetDialogOpen(false)}
/>
</div>
);
}
+39
View File
@@ -443,3 +443,42 @@ export interface PrometheusTarget {
labels: Record<string, string>;
targets: string[];
}
export interface WidgetInstance {
id: string;
addon_id: string;
widget_type: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sort_order: number;
created_at: number;
updated_at: number;
}
export interface WidgetInstanceInput {
id?: string | null;
addon_id: string;
widget_type: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sort_order: number;
}
export interface WidgetTypeInfo {
addon_id: string;
widget_type: string;
name: string;
description: string;
source_type: string;
config_schema: Record<string, unknown>;
}
export interface WidgetDataResponse {
widget_id: string;
widget_type: string;
data: Record<string, unknown> | null;
error: string | null;
fetched_at: number;
}
+66
View File
@@ -0,0 +1,66 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { BackupDashboardSummary } from "../types/backups";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function BackupsWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const summary = data?.data as BackupDashboardSummary | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<div className="flex flex-row flex-wrap gap-6">
<Skeleton className="h-10 w-20" />
<Skeleton className="h-10 w-20" />
<Skeleton className="h-10 w-20" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : summary ? (
<div className="flex flex-row flex-wrap gap-6">
<div>
<div className="text-2xl font-semibold">{summary.total_jobs}</div>
<div className="text-xs text-muted-foreground">Jobs</div>
</div>
<div>
<div className="text-2xl font-semibold">
{summary.success_rate_24h}%
</div>
<div className="text-xs text-muted-foreground">24h Success</div>
</div>
<div>
<div className="text-2xl font-semibold">
{summary.active_alerts > 0 ? (
<Badge variant="destructive">{summary.active_alerts}</Badge>
) : (
0
)}
</div>
<div className="text-xs text-muted-foreground">Alerts</div>
</div>
{summary.last_failed_at ? (
<div className="self-center text-xs text-destructive">
Last failed:{" "}
{new Date(summary.last_failed_at * 1000).toLocaleString()}
</div>
) : null}
</div>
) : null}
</SectionCard>
);
}
@@ -0,0 +1,44 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ExternalLink } from "lucide-react";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function GrafanaLinkWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const url = data?.data?.url as string | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<Skeleton className="h-10 w-48" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : url ? (
<Button asChild>
<a href={url} target="_blank" rel="noopener noreferrer">
Open Grafana
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
) : (
<Alert>
<AlertDescription>No Grafana URL configured.</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SessionActivityPanel } from "../components/SessionActivityPanel";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { NowPlayingSession, WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function JellyfinWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : Array.isArray(sessions) ? (
<SessionActivityPanel
sessions={sessions}
emptyMessage="No recent user activity sessions right now."
/>
) : null}
</SectionCard>
);
}
@@ -0,0 +1,61 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
type PromQLResult = {
resultType?: string;
result?: unknown;
};
type PromQLVectorSample = {
metric?: Record<string, string>;
value?: [number, string];
};
function formatPrometheusValue(result: PromQLResult | undefined): string {
if (!result) return "No data";
if (result.resultType === "scalar" && Array.isArray(result.result)) {
return String(result.result[1] ?? "No data");
}
if (
result.resultType === "vector" &&
Array.isArray(result.result) &&
result.result.length > 0
) {
const first = result.result[0] as PromQLVectorSample;
if (first.value) return String(first.value[1]);
}
return JSON.stringify(result, null, 2);
}
export function PrometheusMetricWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const result = data?.data?.result as PromQLResult | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<Skeleton className="h-10 w-32" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : (
<pre className="whitespace-pre-wrap text-sm">
{formatPrometheusValue(result)}
</pre>
)}
</SectionCard>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
type SshTaskResult = {
exit_status: number;
stdout: string;
stderr: string;
};
export function SshTaskWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const result = data?.data as SshTaskResult | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : result ? (
<div className="flex flex-col gap-3">
<div className="text-xs text-muted-foreground">
Exit status:{" "}
<span
className={
result.exit_status === 0 ? "text-green-600" : "text-destructive"
}
>
{result.exit_status}
</span>
</div>
{result.stdout ? (
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
{result.stdout}
</pre>
) : null}
{result.stderr ? (
<pre className="max-h-64 overflow-auto rounded bg-destructive/10 p-2 text-xs text-destructive">
{result.stderr}
</pre>
) : null}
</div>
) : null}
</SectionCard>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function StaticWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
const text = data?.data?.text as string | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{text ? (
<p className="whitespace-pre-wrap text-sm">{text}</p>
) : (
<p className="text-sm text-muted-foreground">No content configured.</p>
)}
</SectionCard>
);
}
+12
View File
@@ -0,0 +1,12 @@
export { BackupsWidget } from "./BackupsWidget";
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { SshTaskWidget } from "./SshTaskWidget";
export { StaticWidget } from "./StaticWidget";
export {
getWidgetDefinition,
listWidgetTypes,
WIDGET_REGISTRY,
} from "./registry";
export type { WidgetConfigField, WidgetDefinition } from "./registry";
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
getWidgetDefinition,
listWidgetTypes,
WIDGET_REGISTRY,
} from "./registry";
describe("widget registry", () => {
it("contains exactly six Phase 1 types", () => {
const types = listWidgetTypes();
expect(types).toHaveLength(6);
expect(types.map((t) => t.widgetType).sort()).toEqual([
"backups",
"grafana-link",
"jellyfin",
"prometheus-metric",
"ssh-task",
"static",
]);
});
it("has refresh intervals matching the spec", () => {
expect(getWidgetDefinition("jellyfin")?.refreshInterval).toBe(30_000);
expect(getWidgetDefinition("backups")?.refreshInterval).toBe(60_000);
expect(getWidgetDefinition("grafana-link")?.refreshInterval).toBe(0);
expect(getWidgetDefinition("prometheus-metric")?.refreshInterval).toBe(
30_000,
);
expect(getWidgetDefinition("ssh-task")?.refreshInterval).toBe(0);
expect(getWidgetDefinition("static")?.refreshInterval).toBe(0);
});
it("defines required metadata for every widget", () => {
for (const def of Object.values(WIDGET_REGISTRY)) {
expect(def.widgetType).toBeTruthy();
expect(def.addonId).toBeTruthy();
expect(def.name).toBeTruthy();
expect(def.sourceType).toBeTruthy();
expect(def.component).toBeDefined();
}
});
});
+122
View File
@@ -0,0 +1,122 @@
import type { ComponentType } from "react";
import type { WidgetInstance } from "../types";
import { BackupsWidget } from "./BackupsWidget";
import { GrafanaLinkWidget } from "./GrafanaLinkWidget";
import { JellyfinWidget } from "./JellyfinWidget";
import { PrometheusMetricWidget } from "./PrometheusMetricWidget";
import { SshTaskWidget } from "./SshTaskWidget";
import { StaticWidget } from "./StaticWidget";
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;
defaultConfig: Record<string, unknown>;
configFields: WidgetConfigField[];
component: ComponentType<{ widget: WidgetInstance }>;
}
export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
jellyfin: {
widgetType: "jellyfin",
addonId: "core",
name: "Jellyfin activity",
description: "Live sessions and idle users from a Jellyfin server.",
sourceType: "jellyfin",
refreshInterval: 30_000,
defaultConfig: { machine_id: "" },
configFields: [
{
key: "machine_id",
label: "Machine ID",
type: "string",
helper: "Jellyfin machine id (empty = default)",
},
],
component: JellyfinWidget,
},
backups: {
widgetType: "backups",
addonId: "backups",
name: "Backups",
description: "Backup job summary and active alerts.",
sourceType: "backups",
refreshInterval: 60_000,
defaultConfig: {},
configFields: [],
component: BackupsWidget,
},
"grafana-link": {
widgetType: "grafana-link",
addonId: "grafana",
name: "Grafana link",
description: "Deep-link to a Grafana dashboard or panel.",
sourceType: "grafana",
refreshInterval: 0,
defaultConfig: { dashboard_uid: "" },
configFields: [
{ key: "dashboard_uid", label: "Dashboard UID", type: "string" },
{
key: "panel_id",
label: "Panel ID",
type: "number",
helper: "Optional",
},
],
component: GrafanaLinkWidget,
},
"prometheus-metric": {
widgetType: "prometheus-metric",
addonId: "prometheus",
name: "Prometheus metric",
description: "Instant query result rendered as a metric.",
sourceType: "prometheus",
refreshInterval: 30_000,
defaultConfig: { promql: "" },
configFields: [{ key: "promql", label: "PromQL query", type: "string" }],
component: PrometheusMetricWidget,
},
"ssh-task": {
widgetType: "ssh-task",
addonId: "ssh-tasks",
name: "SSH task output",
description: "Output of a saved task run on a machine.",
sourceType: "ssh_task",
refreshInterval: 0,
defaultConfig: { task_id: "" },
configFields: [{ key: "task_id", label: "Saved task ID", type: "string" }],
component: SshTaskWidget,
},
static: {
widgetType: "static",
addonId: "core",
name: "Static text",
description: "Plain text or markdown note.",
sourceType: "static",
refreshInterval: 0,
defaultConfig: { text: "" },
configFields: [{ key: "text", label: "Text", type: "string" }],
component: StaticWidget,
},
};
export function getWidgetDefinition(
widgetType: string,
): WidgetDefinition | undefined {
return WIDGET_REGISTRY[widgetType];
}
export function listWidgetTypes(): WidgetDefinition[] {
return Object.values(WIDGET_REGISTRY);
}
@@ -93,10 +93,103 @@ Focused widget test output: `27 passed`.
- Adapters currently call `get_settings_store()` internally for `backups`/`ssh_task` sources. The router-level endpoint uses FastAPI DI, but adapter unit tests patch `get_settings_store` to inject a test store. A future refactor can pass `store` and `settings` explicitly into `adapter.fetch()` for cleaner testability.
## Completed tasks (Slice 3)
All Slice 3 tasks are marked `- [x]` in `tasks.md`:
- [x] 3.1 Add TypeScript widget interfaces (`WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse`)
- [x] 3.2 Create widget API client (`frontend/src/api/widgets.ts`)
- [x] 3.3 Create widget TanStack Query hooks (`frontend/src/hooks/useWidgets.ts`)
- [x] 3.4 Create frontend widget registry (`frontend/src/widgets/registry.ts`)
- [x] 3.5 Implement six widget presentational components (`frontend/src/widgets/*.tsx`)
- [x] 3.6 Add frontend registry unit test (`frontend/src/widgets/registry.test.ts`)
## Files changed (Slice 3)
### New files
- `frontend/src/api/widgets.ts` — API functions for widget CRUD, registry metadata, and per-widget data.
- `frontend/src/hooks/useWidgets.ts` — TanStack Query hooks for instances, data, sources, types, and mutations.
- `frontend/src/widgets/registry.ts` — Closed frontend registry with metadata, refresh intervals, and config fields.
- `frontend/src/widgets/JellyfinWidget.tsx` — Renders Jellyfin session activity.
- `frontend/src/widgets/BackupsWidget.tsx` — Renders backup dashboard summary.
- `frontend/src/widgets/GrafanaLinkWidget.tsx` — Renders a deep-link to Grafana (no iframe).
- `frontend/src/widgets/PrometheusMetricWidget.tsx` — Renders PromQL instant query result.
- `frontend/src/widgets/SshTaskWidget.tsx` — Renders saved SSH task output.
- `frontend/src/widgets/StaticWidget.tsx` — Renders static text.
- `frontend/src/widgets/index.ts` — Barrel exports.
- `frontend/src/widgets/registry.test.ts` — Vitest unit tests for registry metadata.
### Modified files
- `frontend/src/types/index.ts` — Added widget TypeScript interfaces.
## Verification (Slice 3)
```bash
cd backend
.venv/bin/python -m ruff check . # All checks passed
PYTHONPATH=src .venv/bin/python -m pytest # 200 passed, 2 warnings
cd ../frontend
npm run lint # 2 pre-existing warnings, 0 errors
npm run build # Built successfully
npm run test -- src/widgets/registry.test.ts # 3 passed
```
## Deviations from design (Slice 3)
- Registry unit test is colocated at `frontend/src/widgets/registry.test.ts` and runs with Vitest, matching the project's existing `npm run test` setup, instead of `frontend/tests/widgets.test.mjs`.
- `JellyfinWidget` uses `SessionActivityPanel` directly because `NowPlaying` does not expose an `emptyMessage` prop.
## Completed tasks (Slice 4)
All Slice 4 tasks are marked `- [x]` in `tasks.md`:
- [x] 4.1 Refactor `Dashboard.tsx` to render enabled widget instances in sort order
- [x] 4.2 Create `WidgetInstance` renderer component
- [x] 4.3 Create `WidgetConfigDialog` for add/edit/reorder/delete widgets
- [x] 4.4 Create addon pages (`AddonPage`, `GrafanaAddonPage`, `PrometheusAddonPage`, `SshTasksAddonPage`)
- [x] 4.5 Register `/addons/:addonId` route in `App.tsx`
- [x] 4.6 Update `docs/REQUIREMENTS.md` with widget system documentation
## Files changed (Slice 4)
### New files
- `frontend/src/components/WidgetInstance.tsx` — Renders a widget instance by looking up its definition and dispatching to the registered component.
- `frontend/src/components/WidgetConfigDialog.tsx` — Dashboard widget configuration UI: list, add, edit, delete, reorder, enable/disable.
- `frontend/src/pages/AddonPage.tsx` — Route mapper for `/addons/:addonId`.
- `frontend/src/addons/GrafanaAddonPage.tsx` — Grafana addon landing page (deep-link only).
- `frontend/src/addons/PrometheusAddonPage.tsx` — Prometheus addon landing page.
- `frontend/src/addons/SshTasksAddonPage.tsx` — SSH tasks addon landing page.
- `frontend/src/addons/index.ts` — Barrel exports.
### Modified files
- `frontend/src/pages/Dashboard.tsx` — Replaced hard-coded Jellyfin/Backups sections with widget instance loop; kept Shortcuts section; added "Edit dashboard" button.
- `frontend/src/App.tsx` — Registered `/addons/:addonId` route in both OIDC and non-OIDC route trees.
- `docs/REQUIREMENTS.md` — Added Configurable Dashboard Widgets section.
## Verification (Slice 4)
```bash
cd backend
.venv/bin/python -m ruff check . # All checks passed
PYTHONPATH=src .venv/bin/python -m pytest # 200 passed, 2 warnings
cd ../frontend
npm run lint # 2 pre-existing warnings, 0 errors
npm run build # Built successfully
npm run test -- src/widgets/registry.test.ts # 3 passed
```
## Deviations from design (Slice 4)
- The "Edit dashboard" button lives in the Shortcuts section action area for now. A future UI pass can move it to a dedicated dashboard header.
- Machine/task selectors in the config dialog filter to enabled Jellyfin machines / enabled tasks, which is slightly stricter than the design's generic string field.
## Remaining work
- Slice 3: Frontend types/API/hooks/registry/components
- Slice 4: Dashboard loop + configuration UI + addon pages
- Phase 1 widget system is complete. Future work could include widget grid layout, drag-and-drop reorder, richer Prometheus visualizations, or migrating shortcuts into the widget system.
## PR boundary