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:
@@ -23,6 +23,7 @@ import { Actions } from "./pages/Actions";
|
||||
import BackupsPage from "./components/BackupsPage";
|
||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||
import { AddonPage } from "./pages/AddonPage";
|
||||
import { ServicePage } from "./pages/ServicePage";
|
||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||
import { fetchAppVersion } from "./api/client";
|
||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||
@@ -451,6 +452,7 @@ function AppInner() {
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||
<Route path="/services/:serviceType/:serviceId" element={<ServicePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
@@ -483,6 +485,7 @@ function AppInner() {
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||
<Route path="/services/:serviceType/:serviceId" element={<ServicePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
ServiceInstance,
|
||||
ServiceInstanceInput,
|
||||
ServiceTypeInfo,
|
||||
} from "../types";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
export async function fetchServiceTypes(): Promise<ServiceTypeInfo[]> {
|
||||
const res = await fetch(`${API_BASE}/services/types`);
|
||||
if (!res.ok) throw new Error("Failed to fetch service types");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchServiceInstances(
|
||||
serviceType?: string,
|
||||
): Promise<ServiceInstance[]> {
|
||||
const query = serviceType ? `?service_type=${encodeURIComponent(serviceType)}` : "";
|
||||
const res = await fetch(`${API_BASE}/services/instances${query}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch service instances");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createServiceInstance(
|
||||
input: ServiceInstanceInput,
|
||||
): Promise<ServiceInstance> {
|
||||
const res = await fetch(`${API_BASE}/services/instances`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create service instance");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateServiceInstance(
|
||||
input: ServiceInstanceInput,
|
||||
): Promise<ServiceInstance> {
|
||||
if (!input.id) throw new Error("Service ID is required for update");
|
||||
const res = await fetch(`${API_BASE}/services/instances/${input.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update service instance");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteServiceInstance(
|
||||
serviceId: string,
|
||||
): Promise<{ status: string }> {
|
||||
const res = await fetch(`${API_BASE}/services/instances/${serviceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete service instance");
|
||||
return res.json();
|
||||
}
|
||||
@@ -1,21 +1,15 @@
|
||||
import type {
|
||||
BuiltinWidgetKindInfo,
|
||||
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");
|
||||
export async function fetchBuiltinWidgetKinds(): Promise<BuiltinWidgetKindInfo[]> {
|
||||
const res = await fetch(`${API_BASE}/widgets/builtin`);
|
||||
if (!res.ok) throw new Error("Failed to fetch built-in widget kinds");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { getWidgetDefinition } from "../widgets/registry";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { resolveWidget } from "../integrations/registry";
|
||||
import type { WidgetInstance } from "../types";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
@@ -7,20 +8,29 @@ interface Props {
|
||||
widget: WidgetInstance;
|
||||
}
|
||||
|
||||
export function WidgetInstance({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
if (!def) {
|
||||
export function WidgetInstanceCard({ widget }: Props) {
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const resolved = resolveWidget(widget, services);
|
||||
|
||||
if (!resolved) {
|
||||
const label = widget.service_id
|
||||
? `Unknown widget: ${widget.widget_kind} (service-bound)`
|
||||
: `Unknown widget: ${widget.widget_kind} (built-in)`;
|
||||
return (
|
||||
<SectionCard title={widget.title}>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Unknown widget type: {widget.widget_type}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{label}</AlertDescription>
|
||||
</Alert>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = def.component;
|
||||
return <Component widget={widget} />;
|
||||
const Component = resolved.component;
|
||||
return (
|
||||
<Component
|
||||
widget={widget}
|
||||
refreshIntervalMs={resolved.refreshIntervalMs}
|
||||
description={resolved.description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createServiceInstance,
|
||||
deleteServiceInstance,
|
||||
fetchServiceInstances,
|
||||
fetchServiceTypes,
|
||||
updateServiceInstance,
|
||||
} from "../api/services";
|
||||
import type { ServiceInstanceInput } from "../types";
|
||||
|
||||
export function useServiceTypes() {
|
||||
return useQuery({
|
||||
queryKey: ["services", "types"],
|
||||
queryFn: fetchServiceTypes,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useServiceInstances(serviceType?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["services", "instances", serviceType ?? "all"],
|
||||
queryFn: () => fetchServiceInstances(serviceType),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveServiceInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: ServiceInstanceInput) =>
|
||||
input.id ? updateServiceInstance(input) : createServiceInstance(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["services", "instances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteServiceInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (serviceId: string) => deleteServiceInstance(serviceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["services", "instances"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,10 +2,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createWidgetInstance,
|
||||
deleteWidgetInstance,
|
||||
fetchBuiltinWidgetKinds,
|
||||
fetchWidgetData,
|
||||
fetchWidgetInstances,
|
||||
fetchWidgetSources,
|
||||
fetchWidgetTypes,
|
||||
updateWidgetInstance,
|
||||
} from "../api/widgets";
|
||||
import type { WidgetInstanceInput } from "../types";
|
||||
@@ -49,16 +48,10 @@ export function useDeleteWidgetInstance() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useWidgetSources() {
|
||||
export function useBuiltinWidgetKinds() {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "sources"],
|
||||
queryFn: fetchWidgetSources,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWidgetTypes() {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "types"],
|
||||
queryFn: fetchWidgetTypes,
|
||||
queryKey: ["widgets", "builtin"],
|
||||
queryFn: fetchBuiltinWidgetKinds,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BUILTIN_WIDGETS,
|
||||
SERVICE_REGISTRY,
|
||||
getBuiltinBinding,
|
||||
getServiceBinding,
|
||||
resolveWidget,
|
||||
} from "./registry";
|
||||
import type { ServiceInstance, WidgetInstance } from "../types";
|
||||
|
||||
describe("service registry", () => {
|
||||
it("registers the five backend service types", () => {
|
||||
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
"ssh_tasks",
|
||||
]);
|
||||
});
|
||||
|
||||
it("binds widget kinds per service", () => {
|
||||
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual(["link"]);
|
||||
expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([
|
||||
"task_output",
|
||||
]);
|
||||
expect(SERVICE_REGISTRY.nextcloud.widgets).toEqual([]);
|
||||
});
|
||||
|
||||
it("registers the two built-in widget kinds", () => {
|
||||
expect(Object.keys(BUILTIN_WIDGETS).sort()).toEqual(["backups", "static"]);
|
||||
});
|
||||
|
||||
it("resolves a service-bound widget via the services list", () => {
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "link",
|
||||
title: "Dashboard",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
const services: ServiceInstance[] = [
|
||||
{
|
||||
id: "s1",
|
||||
service_type: "grafana",
|
||||
name: "Grafana",
|
||||
config: { base_url: "https://grafana.example.com" },
|
||||
secrets_set: { api_key: true },
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
},
|
||||
];
|
||||
const resolved = resolveWidget(widget, services);
|
||||
expect(resolved).toBeDefined();
|
||||
expect(resolved?.refreshIntervalMs).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves a built-in widget without a service", () => {
|
||||
const widget: WidgetInstance = {
|
||||
id: "w2",
|
||||
service_id: null,
|
||||
widget_kind: "static",
|
||||
title: "Note",
|
||||
config: { text: "hi" },
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
const resolved = resolveWidget(widget, []);
|
||||
expect(resolved).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown widget kind", () => {
|
||||
const widget: WidgetInstance = {
|
||||
id: "w3",
|
||||
service_id: null,
|
||||
widget_kind: "bogus",
|
||||
title: "x",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
expect(resolveWidget(widget, [])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lookups return undefined for unknown types", () => {
|
||||
expect(getServiceBinding("nope")).toBeUndefined();
|
||||
expect(getBuiltinBinding("nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { BackupsWidget } from "../widgets/BackupsWidget";
|
||||
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
||||
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
||||
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
|
||||
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
||||
import { StaticWidget } from "../widgets/StaticWidget";
|
||||
import type {
|
||||
ServiceInstance,
|
||||
ServiceTypeInfo,
|
||||
WidgetInstance,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Closed frontend registry mirroring the backend service definitions.
|
||||
*
|
||||
* Each service type maps its widget kinds to a presentational component and a
|
||||
* refresh interval. Built-in (service-less) kinds are listed separately.
|
||||
*/
|
||||
|
||||
export interface WidgetComponentProps {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ServiceWidgetBinding {
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
refreshIntervalMs: number;
|
||||
defaultConfig: Record<string, unknown>;
|
||||
configSchema: Record<string, unknown>;
|
||||
component: ComponentType<WidgetComponentProps>;
|
||||
}
|
||||
|
||||
export interface ServiceBinding {
|
||||
serviceType: string;
|
||||
name: string;
|
||||
description: string;
|
||||
widgets: ServiceWidgetBinding[];
|
||||
}
|
||||
|
||||
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
grafana: {
|
||||
serviceType: "grafana",
|
||||
name: "Grafana",
|
||||
description: "Dashboards, metrics, and logs.",
|
||||
widgets: [
|
||||
{
|
||||
kind: "link",
|
||||
name: "Dashboard link",
|
||||
description: "Deep-link to a Grafana dashboard or panel.",
|
||||
refreshIntervalMs: 0,
|
||||
defaultConfig: { dashboard_uid: "" },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: { dashboard_uid: { type: "string" }, panel_id: { type: "integer" } },
|
||||
required: ["dashboard_uid"],
|
||||
},
|
||||
component: GrafanaLinkWidget,
|
||||
},
|
||||
],
|
||||
},
|
||||
prometheus: {
|
||||
serviceType: "prometheus",
|
||||
name: "Prometheus",
|
||||
description: "Metrics storage and PromQL queries.",
|
||||
widgets: [
|
||||
{
|
||||
kind: "metric",
|
||||
name: "Metric",
|
||||
description: "Instant query result rendered as a metric.",
|
||||
refreshIntervalMs: 30_000,
|
||||
defaultConfig: { promql: "" },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: { promql: { type: "string" } },
|
||||
required: ["promql"],
|
||||
},
|
||||
component: PrometheusMetricWidget,
|
||||
},
|
||||
],
|
||||
},
|
||||
jellyfin: {
|
||||
serviceType: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
description: "Media server with live session activity.",
|
||||
widgets: [
|
||||
{
|
||||
kind: "activity",
|
||||
name: "Activity",
|
||||
description: "Live sessions and idle users.",
|
||||
refreshIntervalMs: 30_000,
|
||||
defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: JellyfinWidget,
|
||||
},
|
||||
],
|
||||
},
|
||||
nextcloud: {
|
||||
serviceType: "nextcloud",
|
||||
name: "Nextcloud",
|
||||
description: "Self-hosted files and collaboration.",
|
||||
widgets: [],
|
||||
},
|
||||
ssh_tasks: {
|
||||
serviceType: "ssh_tasks",
|
||||
name: "SSH task runner",
|
||||
description: "Run reusable saved tasks over SSH and keep run history.",
|
||||
widgets: [
|
||||
{
|
||||
kind: "task_output",
|
||||
name: "Task output",
|
||||
description: "Output of a saved task run.",
|
||||
refreshIntervalMs: 0,
|
||||
defaultConfig: { task_id: "" },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: { task_id: { type: "string" } },
|
||||
required: ["task_id"],
|
||||
},
|
||||
component: SshTaskWidget,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_WIDGETS: Record<string, ServiceWidgetBinding> = {
|
||||
backups: {
|
||||
kind: "backups",
|
||||
name: "Backups",
|
||||
description: "Backup job summary and active alerts.",
|
||||
refreshIntervalMs: 60_000,
|
||||
defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: BackupsWidget,
|
||||
},
|
||||
static: {
|
||||
kind: "static",
|
||||
name: "Static text",
|
||||
description: "Plain text or markdown note.",
|
||||
refreshIntervalMs: 0,
|
||||
defaultConfig: { text: "" },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
required: ["text"],
|
||||
},
|
||||
component: StaticWidget,
|
||||
},
|
||||
};
|
||||
|
||||
export function getServiceBinding(serviceType: string): ServiceBinding | undefined {
|
||||
return SERVICE_REGISTRY[serviceType];
|
||||
}
|
||||
|
||||
export function getBuiltinBinding(kind: string): ServiceWidgetBinding | undefined {
|
||||
return BUILTIN_WIDGETS[kind];
|
||||
}
|
||||
|
||||
export interface ResolvedWidget {
|
||||
component: ComponentType<WidgetComponentProps>;
|
||||
description: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a widget instance to its component + metadata.
|
||||
*
|
||||
* Service-bound widgets are resolved via the parent service's type (looked up
|
||||
* from the services list); built-in widgets are resolved directly.
|
||||
*/
|
||||
export function resolveWidget(
|
||||
widget: WidgetInstance,
|
||||
services: ServiceInstance[],
|
||||
): ResolvedWidget | undefined {
|
||||
if (widget.service_id) {
|
||||
const service = services.find((s) => s.id === widget.service_id);
|
||||
if (!service) return undefined;
|
||||
const binding = getServiceBinding(service.service_type);
|
||||
const widgetBinding = binding?.widgets.find(
|
||||
(w) => w.kind === widget.widget_kind,
|
||||
);
|
||||
if (!widgetBinding) return undefined;
|
||||
return {
|
||||
component: widgetBinding.component,
|
||||
description: widgetBinding.description,
|
||||
refreshIntervalMs: widgetBinding.refreshIntervalMs,
|
||||
};
|
||||
}
|
||||
const builtin = getBuiltinBinding(widget.widget_kind);
|
||||
if (!builtin) return undefined;
|
||||
return {
|
||||
component: builtin.component,
|
||||
description: builtin.description,
|
||||
refreshIntervalMs: builtin.refreshIntervalMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge backend type metadata (config_schema, secret_fields) onto bindings. */
|
||||
export function enrichServiceTypes(types: ServiceTypeInfo[]): ServiceTypeInfo[] {
|
||||
return types;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { WidgetInstance } from "../components/WidgetInstance";
|
||||
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||
|
||||
function emptyShortcut(): DashboardShortcutInput {
|
||||
@@ -418,7 +418,7 @@ export function Dashboard() {
|
||||
</SectionCard>
|
||||
|
||||
{visibleWidgets.map((widget) => (
|
||||
<WidgetInstance key={widget.id} widget={widget} />
|
||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||
))}
|
||||
|
||||
<ShortcutDialog
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 {
|
||||
useDeleteServiceInstance,
|
||||
useSaveServiceInstance,
|
||||
useServiceInstances,
|
||||
} from "../hooks/useServices";
|
||||
import type { ServiceInstance, ServiceInstanceInput } from "../types";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { getServiceBinding } from "../integrations/registry";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicePage() {
|
||||
const { serviceType = "", serviceId = "" } = useParams<{
|
||||
serviceType: string;
|
||||
serviceId: string;
|
||||
}>();
|
||||
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
||||
const saveService = useSaveServiceInstance();
|
||||
const deleteService = useDeleteServiceInstance();
|
||||
|
||||
const instance = useMemo(
|
||||
() => services.find((s) => s.id === serviceId),
|
||||
[services, serviceId],
|
||||
);
|
||||
const binding = getServiceBinding(serviceType);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
// Hydrate local form state once the instance loads.
|
||||
if (instance && !hydrated) {
|
||||
setName(instance.name);
|
||||
setEnabled(instance.enabled);
|
||||
setHydrated(true);
|
||||
}
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Unknown service type: {serviceType}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!instance) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>Service not found.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function buildInput(): ServiceInstanceInput {
|
||||
return {
|
||||
id: instance!.id,
|
||||
service_type: instance!.service_type,
|
||||
name,
|
||||
config: instance!.config,
|
||||
secrets: {}, // secrets are managed via the dedicated inputs below
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await saveService.mutateAsync(buildInput());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{binding.name}</Badge>
|
||||
</div>
|
||||
|
||||
<SectionCard title="General">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field label="Name" htmlFor="service-name">
|
||||
<Input
|
||||
id="service-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="service-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<Label htmlFor="service-enabled">Enabled</Label>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button onClick={save} disabled={saveService.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<ServiceSecretsCard instance={instance} />
|
||||
|
||||
{binding.widgets.length > 0 ? (
|
||||
<SectionCard title="Widgets" description="Widget kinds this service provides.">
|
||||
<div className="flex flex-col gap-2">
|
||||
{binding.widgets.map((w) => (
|
||||
<div
|
||||
key={w.kind}
|
||||
className="flex items-center justify-between rounded border p-2"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{w.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{w.description}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">{w.kind}</Badge>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add these to the dashboard from the dashboard's edit dialog.
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
title="Delete service?"
|
||||
message="This removes the service and any widgets that reference it. This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
onConfirm={() => {
|
||||
deleteService.mutate(instance.id);
|
||||
setDeleteOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
|
||||
const saveService = useSaveServiceInstance();
|
||||
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
|
||||
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Connection"
|
||||
description="Non-secret config is read-only here for now; edit secret values below."
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{Object.entries(instance.config).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No connection config.</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||
{Object.entries(instance.config).map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col">
|
||||
<dt className="text-xs text-muted-foreground">{key}</dt>
|
||||
<dd className="truncate font-mono text-xs">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{Object.keys(instance.secrets_set).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No secret fields.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<Field label={key} htmlFor={`secret-${key}`} helper="Leave blank to keep the current value.">
|
||||
<Input
|
||||
id={`secret-${key}`}
|
||||
type="password"
|
||||
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
||||
value={draftSecrets[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setDraftSecrets({ ...draftSecrets, [key]: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{isSet ? <Badge variant="secondary">set</Badge> : null}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => {
|
||||
const onlyChanged = Object.fromEntries(
|
||||
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
||||
);
|
||||
saveService.mutate({
|
||||
id: instance.id,
|
||||
service_type: instance.service_type,
|
||||
name: instance.name,
|
||||
config: instance.config,
|
||||
secrets: onlyChanged,
|
||||
enabled: instance.enabled,
|
||||
});
|
||||
setDraftSecrets({});
|
||||
}}
|
||||
>
|
||||
Update secrets
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import { Dashboard } from "../Dashboard";
|
||||
import type { DashboardShortcut } from "../../types";
|
||||
|
||||
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
||||
// (shortcut CRUD) without rendering the session panel or the backup query.
|
||||
vi.mock("../../components/NowPlaying", () => ({
|
||||
NowPlaying: () => <div data-testid="now-playing-stub" />,
|
||||
// (shortcut CRUD) without rendering widgets or their data queries.
|
||||
vi.mock("../../components/WidgetInstance", () => ({
|
||||
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
|
||||
}));
|
||||
vi.mock("../../components/BackupDashboardWidget", () => ({
|
||||
default: () => <div data-testid="backup-widget-stub" />,
|
||||
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
||||
}));
|
||||
|
||||
const navigate = vi.fn();
|
||||
@@ -21,6 +21,9 @@ vi.mock("react-router-dom", () => ({
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: [] }),
|
||||
}));
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||
const deleteShortcutMutate = vi.fn();
|
||||
|
||||
+58
-14
@@ -446,8 +446,8 @@ export interface PrometheusTarget {
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
addon_id: string;
|
||||
widget_type: string;
|
||||
service_id: string | null;
|
||||
widget_kind: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
@@ -458,27 +458,71 @@ export interface WidgetInstance {
|
||||
|
||||
export interface WidgetInstanceInput {
|
||||
id?: string | null;
|
||||
addon_id: string;
|
||||
widget_type: string;
|
||||
service_id: string | null;
|
||||
widget_kind: 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;
|
||||
}
|
||||
|
||||
export interface SecretFieldInfo {
|
||||
key: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
helper?: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceWidgetKindInfo {
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
default_config: Record<string, unknown>;
|
||||
refresh_interval_ms: number;
|
||||
}
|
||||
|
||||
export interface ServiceTypeInfo {
|
||||
service_type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
secret_fields: SecretFieldInfo[];
|
||||
widget_kinds: ServiceWidgetKindInfo[];
|
||||
}
|
||||
|
||||
export interface ServiceInstance {
|
||||
id: string;
|
||||
service_type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets_set: Record<string, boolean>;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ServiceInstanceInput {
|
||||
id?: string | null;
|
||||
service_type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface BuiltinWidgetKindInfo {
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
default_config: Record<string, unknown>;
|
||||
refresh_interval_ms: number;
|
||||
}
|
||||
|
||||
@@ -5,22 +5,19 @@ 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;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function BackupsWidget({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data, isLoading } = useWidgetData(
|
||||
widget.id,
|
||||
def?.refreshInterval ?? 0,
|
||||
);
|
||||
export function BackupsWidget({ widget, refreshIntervalMs, description }: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const summary = data?.data as BackupDashboardSummary | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex flex-row flex-wrap gap-6">
|
||||
<Skeleton className="h-10 w-20" />
|
||||
|
||||
@@ -5,22 +5,19 @@ 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;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function GrafanaLinkWidget({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data, isLoading } = useWidgetData(
|
||||
widget.id,
|
||||
def?.refreshInterval ?? 0,
|
||||
);
|
||||
export function GrafanaLinkWidget({ widget, refreshIntervalMs, description }: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const url = data?.data?.url as string | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-10 w-48" />
|
||||
) : data?.error ? (
|
||||
|
||||
@@ -4,22 +4,19 @@ 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;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function JellyfinWidget({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data, isLoading } = useWidgetData(
|
||||
widget.id,
|
||||
def?.refreshInterval ?? 0,
|
||||
);
|
||||
export function JellyfinWidget({ widget, refreshIntervalMs, description }: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
|
||||
@@ -3,10 +3,11 @@ 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;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type PromQLResult = {
|
||||
@@ -35,16 +36,12 @@ function formatPrometheusValue(result: PromQLResult | undefined): string {
|
||||
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,
|
||||
);
|
||||
export function PrometheusMetricWidget({ widget, refreshIntervalMs, description }: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const result = data?.data?.result as PromQLResult | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-10 w-32" />
|
||||
) : data?.error ? (
|
||||
|
||||
@@ -3,10 +3,11 @@ 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;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type SshTaskResult = {
|
||||
@@ -15,16 +16,12 @@ type SshTaskResult = {
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export function SshTaskWidget({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data, isLoading } = useWidgetData(
|
||||
widget.id,
|
||||
def?.refreshInterval ?? 0,
|
||||
);
|
||||
export function SshTaskWidget({ widget, refreshIntervalMs, description }: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const result = data?.data as SshTaskResult | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
import { getWidgetDefinition } from "./registry";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function StaticWidget({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
|
||||
export function StaticWidget({ widget, refreshIntervalMs, description }: Props) {
|
||||
const { data } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const text = data?.data?.text as string | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={def?.description}>
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{text ? (
|
||||
<p className="whitespace-pre-wrap text-sm">{text}</p>
|
||||
) : (
|
||||
|
||||
@@ -4,9 +4,3 @@ 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";
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,78 +1,87 @@
|
||||
# Apply Progress: Runtime Service Registry
|
||||
|
||||
**Change:** `service-registry`
|
||||
**Apply run:** PR 1 + PR 2 / Slice 1 + Slice 2
|
||||
**Apply run:** PR 1 + PR 2 + PR 3 (Slices 1–3)
|
||||
**Date:** 2026-06-19
|
||||
|
||||
## Slice 1 — Backend service foundation (MERGED)
|
||||
## Slice 1 — Backend service foundation (MERGED, PR #7)
|
||||
|
||||
Completed in PR #7. See git history. Summary: Fernet secrets helper, closed
|
||||
`integrations/` registry with Pydantic config + widget-config definitions for
|
||||
grafana/prometheus/jellyfin/nextcloud/ssh_tasks, `services` + `service_task_runs`
|
||||
tables with cascade delete, `/api/services*` CRUD, `MANAGE_ENCRYPTION_KEY`
|
||||
required at startup, 25 tests.
|
||||
Fernet secrets, closed `integrations/` registry (Pydantic config + widget-config
|
||||
for grafana/prometheus/jellyfin/nextcloud/ssh_tasks), `services` +
|
||||
`service_task_runs` tables with cascade delete, `/api/services*` CRUD,
|
||||
`MANAGE_ENCRYPTION_KEY` required at startup.
|
||||
|
||||
## Slice 2 — Backend widget rebind to services (this PR)
|
||||
## Slice 2 — Backend widget rebind (MERGED, PR #8)
|
||||
|
||||
Widgets carry `service_id` + `widget_kind`; adapters take
|
||||
`fetch(service: ServiceRecord | None, widget_kind, config)`; backups + static
|
||||
stay as service-less built-ins; SSH adapter logs to `service_task_runs`; old
|
||||
`widgets/registry.py` retired; default seeding removed.
|
||||
|
||||
## Slice 3 — Frontend services runtime (this PR)
|
||||
|
||||
### Completed tasks
|
||||
|
||||
- [x] 2.1 Add `service_id` / `widget_kind` columns to `dashboard_widgets`
|
||||
(additive ALTER; legacy `addon_id`/`widget_type` kept but unused).
|
||||
- [x] 2.2 Refactor source adapters to `fetch(service, widget_kind, config)`
|
||||
with `ServiceRecord | None`. `SERVICE_ADAPTERS` keyed by service_type;
|
||||
`BUILTIN_ADAPTERS` for backups/static. SSH adapter resolves the task +
|
||||
instance, runs, and appends a `service_task_runs` row (success/failure/
|
||||
timeout/error).
|
||||
- [x] 2.3 Retire old `widgets/registry.py` (deleted; metadata now comes from
|
||||
`integrations/registry` + `widgets/builtin`).
|
||||
- [x] 2.4 Update widgets router + models for service-bound + built-in widgets.
|
||||
Removed `/api/widgets/types` and `/api/widgets/sources`; added
|
||||
`/api/widgets/builtin`.
|
||||
- [x] 2.5 Rewrite widget tests around the new model.
|
||||
- [x] 2.6 Stop default widget seeding (fresh install = empty dashboard).
|
||||
- [x] 3.1 Service + new widget TypeScript types (`ServiceInstance`,
|
||||
`ServiceInstanceInput`, `ServiceTypeInfo`, `ServiceWidgetKindInfo`,
|
||||
`SecretFieldInfo`, `BuiltinWidgetKindInfo`; widget gains `service_id` +
|
||||
`widget_kind`).
|
||||
- [x] 3.2 Services API + hooks (`api/services.ts`, `hooks/useServices.ts`).
|
||||
Reconciled `api/widgets.ts` + `hooks/useWidgets.ts` to the new shape
|
||||
(removed sources/types; added builtin kinds).
|
||||
- [x] 3.3 Closed frontend service registry (`integrations/registry.ts`)
|
||||
mirroring the backend; `resolveWidget(widget, services)` maps a widget to
|
||||
its component + refresh interval.
|
||||
- [x] 3.4 Service page at `/services/:serviceType/:serviceId` with config view,
|
||||
empty-on-edit secret inputs + "set" badges, enable toggle, delete, and the
|
||||
service's widget-kind list.
|
||||
- [x] 3.5 Route swap: added `/services/:serviceType/:serviceId`; addon route
|
||||
retained for now (removed in Slice 4 cleanup).
|
||||
- [x] 3.6 Reconciled widget components to take `refreshIntervalMs` +
|
||||
`description` props; rewrote `WidgetConfigDialog` around the
|
||||
service → widget-kind picker (pulled 4.1 forward to keep the build whole).
|
||||
- [x] 3.7 Registry + Dashboard tests updated; new
|
||||
`integrations/registry.test.ts`.
|
||||
|
||||
### Decision resolved mid-slice
|
||||
|
||||
Backups and static widgets stay as **service-less built-ins** (`service_id`
|
||||
nullable), per product decision. The data endpoint resolves built-ins via
|
||||
`BUILTIN_ADAPTERS` and service-bound widgets via `SERVICE_ADAPTERS` + a
|
||||
decrypted `ServiceRecord`.
|
||||
Secret edit UX = **empty-on-edit + "set" badge** (blank = keep existing; typing
|
||||
= replace). Applied on the ServicePage secrets card.
|
||||
|
||||
### Files changed (Slice 2)
|
||||
### Files changed (Slice 3)
|
||||
|
||||
- New: `widgets/builtin.py` (built-in kinds + light config validation).
|
||||
- Rewritten: `widgets/sources.py` (`ServiceRecord`, new protocol, service +
|
||||
built-in adapters, SSH run logging, `_build_ssh_client`).
|
||||
- Deleted: `widgets/registry.py`.
|
||||
- Modified: `models/widgets.py` (service_id + widget_kind; `BuiltinWidgetKindInfo`).
|
||||
- Modified: `routers/widgets.py` (new validation, `/builtin`, data resolution).
|
||||
- Modified: `services/settings_store.py` (widget columns; no-op seeding).
|
||||
- Modified: `integrations/base.py` (`WidgetKind.config_model` for Pydantic
|
||||
widget-config validation).
|
||||
- Rewritten: `tests/test_widgets.py` (26 tests).
|
||||
- New: `api/services.ts`, `hooks/useServices.ts`, `integrations/registry.ts`,
|
||||
`integrations/registry.test.ts`, `pages/ServicePage.tsx`.
|
||||
- Modified: `types/index.ts`, `api/widgets.ts`, `hooks/useWidgets.ts`,
|
||||
`components/WidgetInstance.tsx`, `components/WidgetConfigDialog.tsx`,
|
||||
`pages/Dashboard.tsx`, `pages/__tests__/Dashboard.test.tsx`, `App.tsx`,
|
||||
all six `widgets/*.tsx` components, `widgets/index.ts`.
|
||||
- Deleted: `widgets/registry.ts`, `widgets/registry.test.ts`.
|
||||
|
||||
### Verification (Slice 2)
|
||||
### Verification (Slice 3)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
.venv/bin/ruff check . # All checks passed
|
||||
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
|
||||
cd ../frontend
|
||||
cd frontend
|
||||
npm run lint # 0 errors
|
||||
npm run build # success
|
||||
npm run test # 70 passed
|
||||
cd ../backend
|
||||
.venv/bin/ruff check . # clean
|
||||
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
|
||||
```
|
||||
|
||||
### Known transient state (resolved by Slice 3)
|
||||
### Deviations / notes
|
||||
|
||||
Slice 2 is a backend-only breaking change to the widget API. Until Slice 3
|
||||
lands, the frontend still calls the removed `/api/widgets/types` and
|
||||
`/api/widgets/sources` endpoints and uses the old `widget_type` shape, so the
|
||||
dashboard widget config UI is non-functional at runtime. Build/lint stay green.
|
||||
This is the accepted transient state for a stacked backend→frontend rebind.
|
||||
- `WidgetConfigDialog` was rewritten in this slice (pulled forward from task
|
||||
4.1) because the old dialog imported the deleted widget registry and would
|
||||
not compile. The SSH task-output widget keeps a dedicated task picker; other
|
||||
widget configs use a generic schema-driven field editor.
|
||||
- Addon pages (`/addons/:addonId`) are kept compiling but superseded by service
|
||||
pages; Slice 4 removes them and the now-unused machine Jellyfin/Jellyseerr
|
||||
fields + `grafana_url`/`prometheus_url` env vars, and writes the changelog.
|
||||
|
||||
## Remaining work
|
||||
|
||||
- Slice 3: Frontend services runtime (types, API, hooks, frontend service
|
||||
registry, service pages, route swap, remove addon pages, reconcile widget UI).
|
||||
- Slice 4: Dashboard picker on services, settings rework, remove
|
||||
`grafana_url`/`prometheus_url` env vars, docs + changelog.
|
||||
- Slice 4: remove addon pages + machine app fields, remove
|
||||
`grafana_url`/`prometheus_url` from config + compose, docs + changelog
|
||||
(breaking upgrade note).
|
||||
|
||||
Reference in New Issue
Block a user