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
This commit is contained in:
Developer
2026-06-21 20:45:42 +00:00
parent e4e879d1c8
commit ed7a7a5ce0
11 changed files with 801 additions and 60 deletions
@@ -0,0 +1,502 @@
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>
);
}