feat(services): frontend services runtime and widget rebind

PR 3 of 4 for the runtime service registry change.

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

Verification: frontend lint 0 errors, build success, 70 tests passed; backend
ruff clean, 222 tests passed.
This commit is contained in:
Developer
2026-06-22 18:59:41 +00:00
parent 41dddbccc0
commit 1da67f38c7
23 changed files with 1018 additions and 543 deletions
@@ -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();
});
});
+204
View File
@@ -0,0 +1,204 @@
import type { ComponentType } from "react";
import { BackupsWidget } from "../widgets/BackupsWidget";
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
import { SshTaskWidget } from "../widgets/SshTaskWidget";
import { StaticWidget } from "../widgets/StaticWidget";
import type {
ServiceInstance,
ServiceTypeInfo,
WidgetInstance,
} from "../types";
/**
* Closed frontend registry mirroring the backend service definitions.
*
* Each service type maps its widget kinds to a presentational component and a
* refresh interval. Built-in (service-less) kinds are listed separately.
*/
export interface WidgetComponentProps {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export interface ServiceWidgetBinding {
kind: string;
name: string;
description: string;
refreshIntervalMs: number;
defaultConfig: Record<string, unknown>;
configSchema: Record<string, unknown>;
component: ComponentType<WidgetComponentProps>;
}
export interface ServiceBinding {
serviceType: string;
name: string;
description: string;
widgets: ServiceWidgetBinding[];
}
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
grafana: {
serviceType: "grafana",
name: "Grafana",
description: "Dashboards, metrics, and logs.",
widgets: [
{
kind: "link",
name: "Dashboard link",
description: "Deep-link to a Grafana dashboard or panel.",
refreshIntervalMs: 0,
defaultConfig: { dashboard_uid: "" },
configSchema: {
type: "object",
properties: { dashboard_uid: { type: "string" }, panel_id: { type: "integer" } },
required: ["dashboard_uid"],
},
component: GrafanaLinkWidget,
},
],
},
prometheus: {
serviceType: "prometheus",
name: "Prometheus",
description: "Metrics storage and PromQL queries.",
widgets: [
{
kind: "metric",
name: "Metric",
description: "Instant query result rendered as a metric.",
refreshIntervalMs: 30_000,
defaultConfig: { promql: "" },
configSchema: {
type: "object",
properties: { promql: { type: "string" } },
required: ["promql"],
},
component: PrometheusMetricWidget,
},
],
},
jellyfin: {
serviceType: "jellyfin",
name: "Jellyfin",
description: "Media server with live session activity.",
widgets: [
{
kind: "activity",
name: "Activity",
description: "Live sessions and idle users.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: JellyfinWidget,
},
],
},
nextcloud: {
serviceType: "nextcloud",
name: "Nextcloud",
description: "Self-hosted files and collaboration.",
widgets: [],
},
ssh_tasks: {
serviceType: "ssh_tasks",
name: "SSH task runner",
description: "Run reusable saved tasks over SSH and keep run history.",
widgets: [
{
kind: "task_output",
name: "Task output",
description: "Output of a saved task run.",
refreshIntervalMs: 0,
defaultConfig: { task_id: "" },
configSchema: {
type: "object",
properties: { task_id: { type: "string" } },
required: ["task_id"],
},
component: SshTaskWidget,
},
],
},
};
export const BUILTIN_WIDGETS: Record<string, ServiceWidgetBinding> = {
backups: {
kind: "backups",
name: "Backups",
description: "Backup job summary and active alerts.",
refreshIntervalMs: 60_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: BackupsWidget,
},
static: {
kind: "static",
name: "Static text",
description: "Plain text or markdown note.",
refreshIntervalMs: 0,
defaultConfig: { text: "" },
configSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
component: StaticWidget,
},
};
export function getServiceBinding(serviceType: string): ServiceBinding | undefined {
return SERVICE_REGISTRY[serviceType];
}
export function getBuiltinBinding(kind: string): ServiceWidgetBinding | undefined {
return BUILTIN_WIDGETS[kind];
}
export interface ResolvedWidget {
component: ComponentType<WidgetComponentProps>;
description: string;
refreshIntervalMs: number;
}
/**
* Resolve a widget instance to its component + metadata.
*
* Service-bound widgets are resolved via the parent service's type (looked up
* from the services list); built-in widgets are resolved directly.
*/
export function resolveWidget(
widget: WidgetInstance,
services: ServiceInstance[],
): ResolvedWidget | undefined {
if (widget.service_id) {
const service = services.find((s) => s.id === widget.service_id);
if (!service) return undefined;
const binding = getServiceBinding(service.service_type);
const widgetBinding = binding?.widgets.find(
(w) => w.kind === widget.widget_kind,
);
if (!widgetBinding) return undefined;
return {
component: widgetBinding.component,
description: widgetBinding.description,
refreshIntervalMs: widgetBinding.refreshIntervalMs,
};
}
const builtin = getBuiltinBinding(widget.widget_kind);
if (!builtin) return undefined;
return {
component: builtin.component,
description: builtin.description,
refreshIntervalMs: builtin.refreshIntervalMs,
};
}
/** Merge backend type metadata (config_schema, secret_fields) onto bindings. */
export function enrichServiceTypes(types: ServiceTypeInfo[]): ServiceTypeInfo[] {
return types;
}