feat(services): frontend services runtime and widget rebind

PR 3 of 4 for the runtime service registry change.

- Add service + new-shape widget TypeScript types; widgets carry service_id
  + widget_kind (service-bound) or null (built-in).
- Add services API client + TanStack Query hooks; reconcile the widget API
  client/hooks to the new endpoints (remove sources/types; add builtin kinds).
- Add closed frontend service registry (integrations/registry.ts) mirroring the
  backend, with resolveWidget(widget, services) mapping a widget to its
  component + refresh interval.
- Add ServicePage at /services/:serviceType/:serviceId with config view,
  empty-on-edit secret inputs + 'set' badges, enable toggle, delete, and the
  service's widget-kind list.
- Register /services/:serviceType/:serviceId in App.tsx.
- Reconcile the six widget components to refreshIntervalMs + description props;
  rewrite WidgetConfigDialog around a service -> widget-kind picker.
- Update Dashboard test; add integrations/registry.test.ts.

Verification: frontend lint 0 errors, build success, 70 tests passed; backend
ruff clean, 222 tests passed.
This commit is contained in:
Developer
2026-06-22 18:59:41 +00:00
parent 41dddbccc0
commit 1da67f38c7
23 changed files with 1018 additions and 543 deletions
+174 -224
View File
@@ -23,36 +23,29 @@ import {
useDeleteWidgetInstance,
useSaveWidgetInstance,
useWidgetInstances,
useWidgetTypes,
} from "../hooks/useWidgets";
import { useMonitoringSettings, useTasks } from "../hooks/useSettings";
import type {
MonitoringMachine,
SavedTask,
WidgetInstance,
WidgetInstanceInput,
} from "../types";
import { useServiceInstances } from "../hooks/useServices";
import { useTasks } from "../hooks/useSettings";
import type { WidgetInstance, WidgetInstanceInput } from "../types";
import {
getWidgetDefinition,
listWidgetTypes,
type WidgetDefinition,
} from "../widgets/registry";
BUILTIN_WIDGETS,
SERVICE_REGISTRY,
type ServiceWidgetBinding,
} from "../integrations/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,
};
interface Draft {
id?: string;
serviceId: string | null;
widgetKind: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sortOrder: number;
}
function Field({
@@ -77,126 +70,84 @@ function Field({
);
}
function WidgetConfigFields({
definition,
function bindingLabel(serviceId: string | null, widgetKind: string): string {
if (serviceId === null) return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind;
return widgetKind;
}
function WidgetConfigEditor({
binding,
isTaskOutput,
config,
onChange,
machines,
tasks,
}: {
definition: WidgetDefinition;
binding: ServiceWidgetBinding | undefined;
isTaskOutput: boolean;
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
machines: MonitoringMachine[];
tasks: SavedTask[];
tasks: { id: string; name: string; enabled: boolean }[];
}) {
// SSH task output gets a dedicated task picker; everything else gets a
// generic text field per top-level schema property.
if (isTaskOutput) {
return (
<Field label="Saved task" htmlFor="widget-task-id">
<Select
value={String(config.task_id ?? "")}
onValueChange={(v) => onChange({ ...config, task_id: v })}
>
<SelectTrigger id="widget-task-id">
<SelectValue placeholder="Select a task" />
</SelectTrigger>
<SelectContent>
{tasks
.filter((t) => t.enabled)
.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
const properties = binding
? Object.entries(
(binding.configSchema as { properties?: Record<string, unknown> } | undefined)
?.properties ?? {},
)
: [];
if (properties.length === 0) return null;
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>
);
}
{properties.map(([key, schema]) => {
const isNumber =
(schema as { type?: string }).type === "integer" ||
(schema as { type?: string }).type === "number";
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
key={key}
label={key}
htmlFor={`widget-cfg-${key}`}
helper={(schema as { description?: string }).description}
>
<Input
id={field.key}
value={String(value)}
id={`widget-cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(config[key] ?? "")}
onChange={(e) =>
onChange({
...config,
[field.key]: e.target.value,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
@@ -209,16 +160,12 @@ function WidgetConfigFields({
export function WidgetConfigDialog({ open, onClose }: Props) {
const { data: instances = [] } = useWidgetInstances();
const { data: types = [] } = useWidgetTypes();
const { data: machines = [] } = useMonitoringSettings();
const { data: services = [] } = useServiceInstances();
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 [draft, setDraft] = useState<Draft | null>(null);
const sortedInstances = useMemo(
() =>
@@ -228,39 +175,71 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
[instances],
);
function startAdd(widgetType: string) {
setDraft(emptyDraft(widgetType));
setEditingId(null);
function startAddBuiltIn(kind: string) {
const binding = BUILTIN_WIDGETS[kind];
setDraft({
serviceId: null,
widgetKind: kind,
title: binding?.name ?? kind,
config: { ...(binding?.defaultConfig ?? {}) },
enabled: true,
sortOrder: 0,
});
}
function startAddService(serviceId: string, kind: string) {
const binding = SERVICE_REGISTRY[services.find((s) => s.id === serviceId)?.service_type ?? ""]
?.widgets.find((w) => w.kind === kind);
setDraft({
serviceId,
widgetKind: kind,
title: binding?.name ?? kind,
config: { ...(binding?.defaultConfig ?? {}) },
enabled: true,
sortOrder: 0,
});
}
function startEdit(instance: WidgetInstance) {
setDraft({
id: instance.id,
addon_id: instance.addon_id,
widget_type: instance.widget_type,
serviceId: instance.service_id,
widgetKind: instance.widget_kind,
title: instance.title,
config: instance.config,
enabled: instance.enabled,
sort_order: instance.sort_order,
sortOrder: instance.sort_order,
});
setEditingId(instance.id);
}
function reset() {
setDraft(null);
setEditingId(null);
}
async function saveDraft() {
if (!draft) return;
await saveWidget.mutateAsync(draft);
const input: WidgetInstanceInput = {
id: draft.id ?? null,
service_id: draft.serviceId,
widget_kind: draft.widgetKind,
title: draft.title,
config: draft.config,
enabled: draft.enabled,
sort_order: draft.sortOrder,
};
await saveWidget.mutateAsync(input);
reset();
}
async function toggleEnabled(instance: WidgetInstance) {
await saveWidget.mutateAsync({
...instance,
id: instance.id,
service_id: instance.service_id,
widget_kind: instance.widget_kind,
title: instance.title,
config: instance.config,
enabled: !instance.enabled,
sort_order: instance.sort_order,
});
}
@@ -286,46 +265,42 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
}
}
const definition = draft ? getWidgetDefinition(draft.widget_type) : undefined;
const draftBinding = draft
? draft.serviceId
? SERVICE_REGISTRY[services.find((s) => s.id === draft.serviceId)?.service_type ?? ""]
?.widgets.find((w) => w.kind === draft.widgetKind)
: BUILTIN_WIDGETS[draft.widgetKind]
: undefined;
const isTaskOutput =
draft?.serviceId !== null &&
services.find((s) => s.id === draft?.serviceId)?.service_type === "ssh_tasks";
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{draft
? editingId
? "Edit widget"
: "Add widget"
: "Dashboard widgets"}
</DialogTitle>
<DialogTitle>{draft ? (draft.id ? "Edit widget" : "Add widget") : "Dashboard widgets"}</DialogTitle>
</DialogHeader>
{draft && definition ? (
{draft ? (
<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 })
}
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)}
value={String(draft.sortOrder)}
onChange={(e) =>
setDraft({
...draft,
sort_order:
e.target.value === "" ? 0 : Number(e.target.value),
sortOrder: e.target.value === "" ? 0 : Number(e.target.value),
})
}
/>
@@ -335,17 +310,15 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<Switch
id="widget-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
}
onCheckedChange={(checked) => setDraft({ ...draft, enabled: checked })}
/>
<Label htmlFor="widget-enabled">Enabled</Label>
</div>
<WidgetConfigFields
definition={definition}
<WidgetConfigEditor
binding={draftBinding}
isTaskOutput={!!isTaskOutput}
config={draft.config}
onChange={(config) => setDraft({ ...draft, config })}
machines={machines}
tasks={tasks}
/>
<div className="flex justify-end gap-2">
@@ -361,68 +334,40 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<div className="flex flex-col gap-4">
{sortedInstances.length === 0 ? (
<Alert>
<AlertDescription>
No widgets yet. Add one below.
</AlertDescription>
<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);
const serviceName = instance.service_id
? services.find((s) => s.id === instance.service_id)?.name
: "Built-in";
return (
<div
key={instance.id}
className="flex items-center gap-2 rounded border p-2"
>
<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}
{bindingLabel(instance.service_id, instance.widget_kind)}
</Badge>
{!instance.enabled ? (
<Badge variant="secondary">disabled</Badge>
{serviceName ? (
<span className="text-xs text-muted-foreground">{serviceName}</span>
) : null}
{!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)}
>
<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)}
>
<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)}
>
<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)}
>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => removeInstance(instance)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -435,27 +380,32 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<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)}
>
{Object.values(BUILTIN_WIDGETS).map((b) => (
<Button key={b.kind} variant="outline" size="sm" onClick={() => startAddBuiltIn(b.kind)}>
<Plus className="mr-1 h-3 w-3" />
{def.name}
{b.name}
</Button>
))}
{services
.filter((s) => s.enabled)
.flatMap((s) =>
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
<Button
key={`${s.id}:${w.kind}`}
variant="outline"
size="sm"
onClick={() => startAddService(s.id, w.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{w.name} · {s.name}
</Button>
)),
)}
</div>
<p className="text-xs text-muted-foreground">
Configure services on their service pages to unlock more widgets.
</p>
</div>
{types.length === 0 ? (
<Alert>
<AlertDescription>
Widget registry is empty. Backend may not be running.
</AlertDescription>
</Alert>
) : null}
</div>
)}
</DialogContent>