From 1da67f38c7b1ea22d1b74f40f0fc57aa7fef62c7 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 22 Jun 2026 18:59:41 +0000 Subject: [PATCH 1/2] 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. --- frontend/src/App.tsx | 3 + frontend/src/api/services.ts | 57 +++ frontend/src/api/widgets.ts | 14 +- .../src/components/WidgetConfigDialog.tsx | 398 ++++++++---------- frontend/src/components/WidgetInstance.tsx | 28 +- frontend/src/hooks/useServices.ts | 47 +++ frontend/src/hooks/useWidgets.ts | 17 +- frontend/src/integrations/registry.test.ts | 98 +++++ frontend/src/integrations/registry.ts | 204 +++++++++ frontend/src/pages/Dashboard.tsx | 4 +- frontend/src/pages/ServicePage.tsx | 248 +++++++++++ .../src/pages/__tests__/Dashboard.test.tsx | 13 +- frontend/src/types/index.ts | 72 +++- frontend/src/widgets/BackupsWidget.tsx | 13 +- frontend/src/widgets/GrafanaLinkWidget.tsx | 13 +- frontend/src/widgets/JellyfinWidget.tsx | 13 +- .../src/widgets/PrometheusMetricWidget.tsx | 13 +- frontend/src/widgets/SshTaskWidget.tsx | 13 +- frontend/src/widgets/StaticWidget.tsx | 10 +- frontend/src/widgets/index.ts | 6 - frontend/src/widgets/registry.test.ts | 42 -- frontend/src/widgets/registry.ts | 122 ------ .../service-registry/apply-progress.md | 113 ++--- 23 files changed, 1018 insertions(+), 543 deletions(-) create mode 100644 frontend/src/api/services.ts create mode 100644 frontend/src/hooks/useServices.ts create mode 100644 frontend/src/integrations/registry.test.ts create mode 100644 frontend/src/integrations/registry.ts create mode 100644 frontend/src/pages/ServicePage.tsx delete mode 100644 frontend/src/widgets/registry.test.ts delete mode 100644 frontend/src/widgets/registry.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca7000e..570c775 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> @@ -483,6 +485,7 @@ function AppInner() { } /> } /> } /> + } /> diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts new file mode 100644 index 0000000..e011076 --- /dev/null +++ b/frontend/src/api/services.ts @@ -0,0 +1,57 @@ +import type { + ServiceInstance, + ServiceInstanceInput, + ServiceTypeInfo, +} from "../types"; + +const API_BASE = "/api"; + +export async function fetchServiceTypes(): Promise { + 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 { + 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 { + 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 { + 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(); +} diff --git a/frontend/src/api/widgets.ts b/frontend/src/api/widgets.ts index 7265984..bed9574 100644 --- a/frontend/src/api/widgets.ts +++ b/frontend/src/api/widgets.ts @@ -1,21 +1,15 @@ import type { + BuiltinWidgetKindInfo, WidgetDataResponse, WidgetInstance, WidgetInstanceInput, - WidgetTypeInfo, } from "../types"; const API_BASE = "/api"; -export async function fetchWidgetSources(): Promise { - 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 { - const res = await fetch(`${API_BASE}/widgets/types`); - if (!res.ok) throw new Error("Failed to fetch widget types"); +export async function fetchBuiltinWidgetKinds(): Promise { + const res = await fetch(`${API_BASE}/widgets/builtin`); + if (!res.ok) throw new Error("Failed to fetch built-in widget kinds"); return res.json(); } diff --git a/frontend/src/components/WidgetConfigDialog.tsx b/frontend/src/components/WidgetConfigDialog.tsx index 97ecc6e..4d75426 100644 --- a/frontend/src/components/WidgetConfigDialog.tsx +++ b/frontend/src/components/WidgetConfigDialog.tsx @@ -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; + 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; onChange: (config: Record) => 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 ( + + + + ); + } + + const properties = binding + ? Object.entries( + (binding.configSchema as { properties?: Record } | undefined) + ?.properties ?? {}, + ) + : []; + + if (properties.length === 0) return null; + return (
- {definition.configFields.map((field) => { - const value = config[field.key] ?? ""; - - if ( - definition.widgetType === "jellyfin" && - field.key === "machine_id" - ) { - return ( - - - - ); - } - - if (definition.widgetType === "ssh-task" && field.key === "task_id") { - return ( - - - - ); - } - - if (field.type === "number") { - return ( - - - onChange({ - ...config, - [field.key]: - e.target.value === "" - ? undefined - : Number(e.target.value), - }) - } - /> - - ); - } - + {properties.map(([key, schema]) => { + const isNumber = + (schema as { type?: string }).type === "integer" || + (schema as { type?: string }).type === "number"; return ( 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(null); - const [editingId, setEditingId] = useState(null); - - const registryDefinitions = useMemo(() => listWidgetTypes(), []); + const [draft, setDraft] = useState(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 ( - - {draft - ? editingId - ? "Edit widget" - : "Add widget" - : "Dashboard widgets"} - + {draft ? (draft.id ? "Edit widget" : "Add widget") : "Dashboard widgets"} - {draft && definition ? ( + {draft ? (
-

- {definition.description} -

- setDraft({ ...draft, title: e.target.value }) - } + onChange={(e) => setDraft({ ...draft, title: e.target.value })} /> 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) { - setDraft({ ...draft, enabled: checked }) - } + onCheckedChange={(checked) => setDraft({ ...draft, enabled: checked })} />
- setDraft({ ...draft, config })} - machines={machines} tasks={tasks} />
@@ -361,68 +334,40 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
{sortedInstances.length === 0 ? ( - - No widgets yet. Add one below. - + No widgets yet. Add one below. ) : (
{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 ( -
+
{instance.title} - {typeDef?.name ?? instance.widget_type} + {bindingLabel(instance.service_id, instance.widget_kind)} - {!instance.enabled ? ( - disabled + {serviceName ? ( + {serviceName} ) : null} + {!instance.enabled ? disabled : null}
- - - toggleEnabled(instance)} - aria-label={`Toggle ${instance.title}`} - /> - -
@@ -435,27 +380,32 @@ export function WidgetConfigDialog({ open, onClose }: Props) {

Add widget

- {registryDefinitions.map((def) => ( - ))} + {services + .filter((s) => s.enabled) + .flatMap((s) => + (SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => ( + + )), + )}
+

+ Configure services on their service pages to unlock more widgets. +

- - {types.length === 0 ? ( - - - Widget registry is empty. Backend may not be running. - - - ) : null}
)} diff --git a/frontend/src/components/WidgetInstance.tsx b/frontend/src/components/WidgetInstance.tsx index cf692ae..d0e934b 100644 --- a/frontend/src/components/WidgetInstance.tsx +++ b/frontend/src/components/WidgetInstance.tsx @@ -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 ( - - Unknown widget type: {widget.widget_type} - + {label} ); } - const Component = def.component; - return ; + const Component = resolved.component; + return ( + + ); } diff --git a/frontend/src/hooks/useServices.ts b/frontend/src/hooks/useServices.ts new file mode 100644 index 0000000..a83848e --- /dev/null +++ b/frontend/src/hooks/useServices.ts @@ -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"] }); + }, + }); +} diff --git a/frontend/src/hooks/useWidgets.ts b/frontend/src/hooks/useWidgets.ts index 128caed..960103b 100644 --- a/frontend/src/hooks/useWidgets.ts +++ b/frontend/src/hooks/useWidgets.ts @@ -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, }); } diff --git a/frontend/src/integrations/registry.test.ts b/frontend/src/integrations/registry.test.ts new file mode 100644 index 0000000..9026191 --- /dev/null +++ b/frontend/src/integrations/registry.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts new file mode 100644 index 0000000..f7113ef --- /dev/null +++ b/frontend/src/integrations/registry.ts @@ -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; + configSchema: Record; + component: ComponentType; +} + +export interface ServiceBinding { + serviceType: string; + name: string; + description: string; + widgets: ServiceWidgetBinding[]; +} + +export const SERVICE_REGISTRY: Record = { + 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 = { + 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; + 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; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0192daa..0545080 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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() { {visibleWidgets.map((widget) => ( - + ))} + + {children} + {helper ?

{helper}

: null} +
+ ); +} + +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 ( + + + Unknown service type: {serviceType} + + + ); + } + + if (!instance) { + return ( + + Service not found. + + ); + } + + 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 ( +
+
+
+

{instance.name}

+

{binding.description}

+
+ {binding.name} +
+ + +
+ + setName(e.target.value)} + /> + +
+ + +
+
+ + +
+
+
+ + + + {binding.widgets.length > 0 ? ( + +
+ {binding.widgets.map((w) => ( +
+
+
{w.name}
+
+ {w.description} +
+
+ {w.kind} +
+ ))} +

+ Add these to the dashboard from the dashboard's edit dialog. +

+
+
+ ) : null} + + setDeleteOpen(false)} + onConfirm={() => { + deleteService.mutate(instance.id); + setDeleteOpen(false); + }} + /> +
+ ); +} + +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>({}); + + return ( + +
+ {Object.entries(instance.config).length === 0 ? ( +

No connection config.

+ ) : ( +
+ {Object.entries(instance.config).map(([key, value]) => ( +
+
{key}
+
{String(value)}
+
+ ))} +
+ )} + + {Object.keys(instance.secrets_set).length === 0 ? ( +

No secret fields.

+ ) : ( +
+ {Object.entries(instance.secrets_set).map(([key, isSet]) => ( +
+ + + setDraftSecrets({ ...draftSecrets, [key]: e.target.value }) + } + /> + + {isSet ? set : null} +
+ ))} + +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/__tests__/Dashboard.test.tsx b/frontend/src/pages/__tests__/Dashboard.test.tsx index f149a0b..6c33411 100644 --- a/frontend/src/pages/__tests__/Dashboard.test.tsx +++ b/frontend/src/pages/__tests__/Dashboard.test.tsx @@ -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: () =>
, +// (shortcut CRUD) without rendering widgets or their data queries. +vi.mock("../../components/WidgetInstance", () => ({ + WidgetInstanceCard: () =>
, })); -vi.mock("../../components/BackupDashboardWidget", () => ({ - default: () =>
, +vi.mock("../../components/WidgetConfigDialog", () => ({ + WidgetConfigDialog: () =>
, })); 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(); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 375dad7..edf671d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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; 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; enabled: boolean; sort_order: number; } -export interface WidgetTypeInfo { - addon_id: string; - widget_type: string; - name: string; - description: string; - source_type: string; - config_schema: Record; -} - export interface WidgetDataResponse { widget_id: string; - widget_type: string; data: Record | 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; + default_config: Record; + refresh_interval_ms: number; +} + +export interface ServiceTypeInfo { + service_type: string; + name: string; + description: string; + config_schema: Record; + secret_fields: SecretFieldInfo[]; + widget_kinds: ServiceWidgetKindInfo[]; +} + +export interface ServiceInstance { + id: string; + service_type: string; + name: string; + config: Record; + secrets_set: Record; + enabled: boolean; + created_at: number; + updated_at: number; +} + +export interface ServiceInstanceInput { + id?: string | null; + service_type: string; + name: string; + config: Record; + secrets: Record; + enabled: boolean; +} + +export interface BuiltinWidgetKindInfo { + kind: string; + name: string; + description: string; + config_schema: Record; + default_config: Record; + refresh_interval_ms: number; +} diff --git a/frontend/src/widgets/BackupsWidget.tsx b/frontend/src/widgets/BackupsWidget.tsx index 7ff1684..f577bc1 100644 --- a/frontend/src/widgets/BackupsWidget.tsx +++ b/frontend/src/widgets/BackupsWidget.tsx @@ -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 ( - + {isLoading && !data ? (
diff --git a/frontend/src/widgets/GrafanaLinkWidget.tsx b/frontend/src/widgets/GrafanaLinkWidget.tsx index a774b5e..8001534 100644 --- a/frontend/src/widgets/GrafanaLinkWidget.tsx +++ b/frontend/src/widgets/GrafanaLinkWidget.tsx @@ -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 ( - + {isLoading && !data ? ( ) : data?.error ? ( diff --git a/frontend/src/widgets/JellyfinWidget.tsx b/frontend/src/widgets/JellyfinWidget.tsx index ed9de46..127fed0 100644 --- a/frontend/src/widgets/JellyfinWidget.tsx +++ b/frontend/src/widgets/JellyfinWidget.tsx @@ -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 ( - + {isLoading && !data ? (
diff --git a/frontend/src/widgets/PrometheusMetricWidget.tsx b/frontend/src/widgets/PrometheusMetricWidget.tsx index 6b53d8f..bc4862c 100644 --- a/frontend/src/widgets/PrometheusMetricWidget.tsx +++ b/frontend/src/widgets/PrometheusMetricWidget.tsx @@ -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 ( - + {isLoading && !data ? ( ) : data?.error ? ( diff --git a/frontend/src/widgets/SshTaskWidget.tsx b/frontend/src/widgets/SshTaskWidget.tsx index f521395..6013682 100644 --- a/frontend/src/widgets/SshTaskWidget.tsx +++ b/frontend/src/widgets/SshTaskWidget.tsx @@ -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 ( - + {isLoading && !data ? (
diff --git a/frontend/src/widgets/StaticWidget.tsx b/frontend/src/widgets/StaticWidget.tsx index 512da9b..3edcc80 100644 --- a/frontend/src/widgets/StaticWidget.tsx +++ b/frontend/src/widgets/StaticWidget.tsx @@ -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 ( - + {text ? (

{text}

) : ( diff --git a/frontend/src/widgets/index.ts b/frontend/src/widgets/index.ts index 8e6ef18..3d35d2f 100644 --- a/frontend/src/widgets/index.ts +++ b/frontend/src/widgets/index.ts @@ -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"; diff --git a/frontend/src/widgets/registry.test.ts b/frontend/src/widgets/registry.test.ts deleted file mode 100644 index 859c9d8..0000000 --- a/frontend/src/widgets/registry.test.ts +++ /dev/null @@ -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(); - } - }); -}); diff --git a/frontend/src/widgets/registry.ts b/frontend/src/widgets/registry.ts deleted file mode 100644 index f0adc10..0000000 --- a/frontend/src/widgets/registry.ts +++ /dev/null @@ -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; - configFields: WidgetConfigField[]; - component: ComponentType<{ widget: WidgetInstance }>; -} - -export const WIDGET_REGISTRY: Record = { - 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); -} diff --git a/openspec/changes/service-registry/apply-progress.md b/openspec/changes/service-registry/apply-progress.md index d89c895..69244c4 100644 --- a/openspec/changes/service-registry/apply-progress.md +++ b/openspec/changes/service-registry/apply-progress.md @@ -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). From 739ad38e29404241659b1f54d179af87d66bb7cd Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 22 Jun 2026 19:13:59 +0000 Subject: [PATCH 2/2] style(services): apply formatter to frontend services runtime --- frontend/src/App.tsx | 10 +- frontend/src/api/services.ts | 4 +- frontend/src/api/widgets.ts | 4 +- .../src/components/WidgetConfigDialog.tsx | 134 +++++++++++++----- frontend/src/integrations/registry.test.ts | 4 +- frontend/src/integrations/registry.ts | 17 ++- frontend/src/pages/ServicePage.tsx | 29 ++-- frontend/src/widgets/BackupsWidget.tsx | 6 +- frontend/src/widgets/GrafanaLinkWidget.tsx | 6 +- frontend/src/widgets/JellyfinWidget.tsx | 6 +- .../src/widgets/PrometheusMetricWidget.tsx | 6 +- frontend/src/widgets/SshTaskWidget.tsx | 6 +- frontend/src/widgets/StaticWidget.tsx | 6 +- 13 files changed, 177 insertions(+), 61 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 570c775..1f285dc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -452,7 +452,10 @@ function AppInner() { } /> } /> } /> - } /> + } + /> @@ -485,7 +488,10 @@ function AppInner() { } /> } /> } /> - } /> + } + /> diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts index e011076..7322e42 100644 --- a/frontend/src/api/services.ts +++ b/frontend/src/api/services.ts @@ -15,7 +15,9 @@ export async function fetchServiceTypes(): Promise { export async function fetchServiceInstances( serviceType?: string, ): Promise { - const query = serviceType ? `?service_type=${encodeURIComponent(serviceType)}` : ""; + 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(); diff --git a/frontend/src/api/widgets.ts b/frontend/src/api/widgets.ts index bed9574..c395f84 100644 --- a/frontend/src/api/widgets.ts +++ b/frontend/src/api/widgets.ts @@ -7,7 +7,9 @@ import type { const API_BASE = "/api"; -export async function fetchBuiltinWidgetKinds(): Promise { +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(); diff --git a/frontend/src/components/WidgetConfigDialog.tsx b/frontend/src/components/WidgetConfigDialog.tsx index 4d75426..f898b30 100644 --- a/frontend/src/components/WidgetConfigDialog.tsx +++ b/frontend/src/components/WidgetConfigDialog.tsx @@ -71,7 +71,8 @@ function Field({ } function bindingLabel(serviceId: string | null, widgetKind: string): string { - if (serviceId === null) return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind; + if (serviceId === null) + return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind; return widgetKind; } @@ -116,8 +117,11 @@ function WidgetConfigEditor({ const properties = binding ? Object.entries( - (binding.configSchema as { properties?: Record } | undefined) - ?.properties ?? {}, + ( + binding.configSchema as + | { properties?: Record } + | undefined + )?.properties ?? {}, ) : []; @@ -188,8 +192,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) { } function startAddService(serviceId: string, kind: string) { - const binding = SERVICE_REGISTRY[services.find((s) => s.id === serviceId)?.service_type ?? ""] - ?.widgets.find((w) => w.kind === kind); + const binding = SERVICE_REGISTRY[ + services.find((s) => s.id === serviceId)?.service_type ?? "" + ]?.widgets.find((w) => w.kind === kind); setDraft({ serviceId, widgetKind: kind, @@ -267,19 +272,27 @@ export function WidgetConfigDialog({ open, onClose }: Props) { const draftBinding = draft ? draft.serviceId - ? SERVICE_REGISTRY[services.find((s) => s.id === draft.serviceId)?.service_type ?? ""] - ?.widgets.find((w) => w.kind === draft.widgetKind) + ? 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"; + services.find((s) => s.id === draft?.serviceId)?.service_type === + "ssh_tasks"; return ( - {draft ? (draft.id ? "Edit widget" : "Add widget") : "Dashboard widgets"} + + {draft + ? draft.id + ? "Edit widget" + : "Add widget" + : "Dashboard widgets"} + {draft ? ( @@ -289,7 +302,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) { setDraft({ ...draft, title: e.target.value })} + onChange={(e) => + setDraft({ ...draft, title: e.target.value }) + } /> @@ -300,7 +315,8 @@ export function WidgetConfigDialog({ open, onClose }: Props) { onChange={(e) => setDraft({ ...draft, - sortOrder: e.target.value === "" ? 0 : Number(e.target.value), + sortOrder: + e.target.value === "" ? 0 : Number(e.target.value), }) } /> @@ -310,7 +326,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) { setDraft({ ...draft, enabled: checked })} + onCheckedChange={(checked) => + setDraft({ ...draft, enabled: checked }) + } />
@@ -334,7 +352,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
{sortedInstances.length === 0 ? ( - No widgets yet. Add one below. + + No widgets yet. Add one below. + ) : (
@@ -343,31 +363,67 @@ export function WidgetConfigDialog({ open, onClose }: Props) { ? services.find((s) => s.id === instance.service_id)?.name : "Built-in"; return ( -
+
{instance.title} - {bindingLabel(instance.service_id, instance.widget_kind)} + {bindingLabel( + instance.service_id, + instance.widget_kind, + )} {serviceName ? ( - {serviceName} + + {serviceName} + + ) : null} + {!instance.enabled ? ( + disabled ) : null} - {!instance.enabled ? disabled : null}
- - - toggleEnabled(instance)} aria-label={`Toggle ${instance.title}`} /> - -
@@ -381,7 +437,12 @@ export function WidgetConfigDialog({ open, onClose }: Props) {

Add widget

{Object.values(BUILTIN_WIDGETS).map((b) => ( - @@ -389,21 +450,24 @@ export function WidgetConfigDialog({ open, onClose }: Props) { {services .filter((s) => s.enabled) .flatMap((s) => - (SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => ( - - )), + (SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map( + (w) => ( + + ), + ), )}

- Configure services on their service pages to unlock more widgets. + Configure services on their service pages to unlock more + widgets.

diff --git a/frontend/src/integrations/registry.test.ts b/frontend/src/integrations/registry.test.ts index 9026191..376e99c 100644 --- a/frontend/src/integrations/registry.test.ts +++ b/frontend/src/integrations/registry.test.ts @@ -20,7 +20,9 @@ describe("service registry", () => { }); it("binds widget kinds per service", () => { - expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual(["link"]); + expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([ + "link", + ]); expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([ "task_output", ]); diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts index f7113ef..16c87d3 100644 --- a/frontend/src/integrations/registry.ts +++ b/frontend/src/integrations/registry.ts @@ -55,7 +55,10 @@ export const SERVICE_REGISTRY: Record = { defaultConfig: { dashboard_uid: "" }, configSchema: { type: "object", - properties: { dashboard_uid: { type: "string" }, panel_id: { type: "integer" } }, + properties: { + dashboard_uid: { type: "string" }, + panel_id: { type: "integer" }, + }, required: ["dashboard_uid"], }, component: GrafanaLinkWidget, @@ -151,11 +154,15 @@ export const BUILTIN_WIDGETS: Record = { }, }; -export function getServiceBinding(serviceType: string): ServiceBinding | undefined { +export function getServiceBinding( + serviceType: string, +): ServiceBinding | undefined { return SERVICE_REGISTRY[serviceType]; } -export function getBuiltinBinding(kind: string): ServiceWidgetBinding | undefined { +export function getBuiltinBinding( + kind: string, +): ServiceWidgetBinding | undefined { return BUILTIN_WIDGETS[kind]; } @@ -199,6 +206,8 @@ export function resolveWidget( } /** Merge backend type metadata (config_schema, secret_fields) onto bindings. */ -export function enrichServiceTypes(types: ServiceTypeInfo[]): ServiceTypeInfo[] { +export function enrichServiceTypes( + types: ServiceTypeInfo[], +): ServiceTypeInfo[] { return types; } diff --git a/frontend/src/pages/ServicePage.tsx b/frontend/src/pages/ServicePage.tsx index 880323b..ac5012e 100644 --- a/frontend/src/pages/ServicePage.tsx +++ b/frontend/src/pages/ServicePage.tsx @@ -31,7 +31,9 @@ function Field({
{children} - {helper ?

{helper}

: null} + {helper ? ( +

{helper}

+ ) : null}
); } @@ -66,9 +68,7 @@ export function ServicePage() { if (!binding) { return ( - - Unknown service type: {serviceType} - + Unknown service type: {serviceType} ); } @@ -127,10 +127,7 @@ export function ServicePage() { -
@@ -140,7 +137,10 @@ export function ServicePage() { {binding.widgets.length > 0 ? ( - +
{binding.widgets.map((w) => (
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
- + - setDraftSecrets({ ...draftSecrets, [key]: e.target.value }) + setDraftSecrets({ + ...draftSecrets, + [key]: e.target.value, + }) } /> diff --git a/frontend/src/widgets/BackupsWidget.tsx b/frontend/src/widgets/BackupsWidget.tsx index f577bc1..74f2b0d 100644 --- a/frontend/src/widgets/BackupsWidget.tsx +++ b/frontend/src/widgets/BackupsWidget.tsx @@ -12,7 +12,11 @@ interface Props { description?: string; } -export function BackupsWidget({ widget, refreshIntervalMs, description }: Props) { +export function BackupsWidget({ + widget, + refreshIntervalMs, + description, +}: Props) { const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const summary = data?.data as BackupDashboardSummary | undefined; diff --git a/frontend/src/widgets/GrafanaLinkWidget.tsx b/frontend/src/widgets/GrafanaLinkWidget.tsx index 8001534..5840fd8 100644 --- a/frontend/src/widgets/GrafanaLinkWidget.tsx +++ b/frontend/src/widgets/GrafanaLinkWidget.tsx @@ -12,7 +12,11 @@ interface Props { description?: string; } -export function GrafanaLinkWidget({ widget, refreshIntervalMs, description }: Props) { +export function GrafanaLinkWidget({ + widget, + refreshIntervalMs, + description, +}: Props) { const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const url = data?.data?.url as string | undefined; diff --git a/frontend/src/widgets/JellyfinWidget.tsx b/frontend/src/widgets/JellyfinWidget.tsx index 127fed0..68aaf72 100644 --- a/frontend/src/widgets/JellyfinWidget.tsx +++ b/frontend/src/widgets/JellyfinWidget.tsx @@ -11,7 +11,11 @@ interface Props { description?: string; } -export function JellyfinWidget({ widget, refreshIntervalMs, description }: Props) { +export function JellyfinWidget({ + widget, + refreshIntervalMs, + description, +}: Props) { const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const sessions = data?.data?.sessions as NowPlayingSession[] | undefined; diff --git a/frontend/src/widgets/PrometheusMetricWidget.tsx b/frontend/src/widgets/PrometheusMetricWidget.tsx index bc4862c..dc617a2 100644 --- a/frontend/src/widgets/PrometheusMetricWidget.tsx +++ b/frontend/src/widgets/PrometheusMetricWidget.tsx @@ -36,7 +36,11 @@ function formatPrometheusValue(result: PromQLResult | undefined): string { return JSON.stringify(result, null, 2); } -export function PrometheusMetricWidget({ widget, refreshIntervalMs, description }: Props) { +export function PrometheusMetricWidget({ + widget, + refreshIntervalMs, + description, +}: Props) { const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const result = data?.data?.result as PromQLResult | undefined; diff --git a/frontend/src/widgets/SshTaskWidget.tsx b/frontend/src/widgets/SshTaskWidget.tsx index 6013682..c51b037 100644 --- a/frontend/src/widgets/SshTaskWidget.tsx +++ b/frontend/src/widgets/SshTaskWidget.tsx @@ -16,7 +16,11 @@ type SshTaskResult = { stderr: string; }; -export function SshTaskWidget({ widget, refreshIntervalMs, description }: Props) { +export function SshTaskWidget({ + widget, + refreshIntervalMs, + description, +}: Props) { const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const result = data?.data as SshTaskResult | undefined; diff --git a/frontend/src/widgets/StaticWidget.tsx b/frontend/src/widgets/StaticWidget.tsx index 3edcc80..ba876b5 100644 --- a/frontend/src/widgets/StaticWidget.tsx +++ b/frontend/src/widgets/StaticWidget.tsx @@ -8,7 +8,11 @@ interface Props { description?: string; } -export function StaticWidget({ widget, refreshIntervalMs, description }: Props) { +export function StaticWidget({ + widget, + refreshIntervalMs, + description, +}: Props) { const { data } = useWidgetData(widget.id, refreshIntervalMs); const text = data?.data?.text as string | undefined;