diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca7000e..1f285dc 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,10 @@ function AppInner() { } /> } /> } /> + } + /> @@ -483,6 +488,10 @@ function AppInner() { } /> } /> } /> + } + /> diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts new file mode 100644 index 0000000..7322e42 --- /dev/null +++ b/frontend/src/api/services.ts @@ -0,0 +1,59 @@ +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..c395f84 100644 --- a/frontend/src/api/widgets.ts +++ b/frontend/src/api/widgets.ts @@ -1,21 +1,17 @@ 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< + 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 97ecc6e..f898b30 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,88 @@ 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 +164,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 +179,72 @@ 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,7 +270,17 @@ 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 ( @@ -294,18 +288,15 @@ export function WidgetConfigDialog({ open, onClose }: Props) { {draft - ? editingId + ? draft.id ? "Edit widget" : "Add widget" : "Dashboard widgets"} - {draft && definition ? ( + {draft ? (
-

- {definition.description} -

setDraft({ ...draft, - sort_order: + sortOrder: e.target.value === "" ? 0 : Number(e.target.value), }) } @@ -341,11 +332,11 @@ export function WidgetConfigDialog({ open, onClose }: Props) { />
- setDraft({ ...draft, config })} - machines={machines} tasks={tasks} />
@@ -368,7 +359,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) { ) : (
{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, + )} + {serviceName ? ( + + {serviceName} + + ) : null} {!instance.enabled ? ( disabled ) : null} @@ -435,27 +436,40 @@ export function WidgetConfigDialog({ open, onClose }: Props) {

Add widget

- {registryDefinitions.map((def) => ( + {Object.values(BUILTIN_WIDGETS).map((b) => ( ))} + {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..376e99c --- /dev/null +++ b/frontend/src/integrations/registry.test.ts @@ -0,0 +1,100 @@ +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..16c87d3 --- /dev/null +++ b/frontend/src/integrations/registry.ts @@ -0,0 +1,213 @@ +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..74f2b0d 100644 --- a/frontend/src/widgets/BackupsWidget.tsx +++ b/frontend/src/widgets/BackupsWidget.tsx @@ -5,22 +5,23 @@ 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..5840fd8 100644 --- a/frontend/src/widgets/GrafanaLinkWidget.tsx +++ b/frontend/src/widgets/GrafanaLinkWidget.tsx @@ -5,22 +5,23 @@ 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..68aaf72 100644 --- a/frontend/src/widgets/JellyfinWidget.tsx +++ b/frontend/src/widgets/JellyfinWidget.tsx @@ -4,22 +4,23 @@ 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..dc617a2 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,16 @@ 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..c51b037 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,16 @@ 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..ba876b5 100644 --- a/frontend/src/widgets/StaticWidget.tsx +++ b/frontend/src/widgets/StaticWidget.tsx @@ -1,19 +1,23 @@ 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).