@@ -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 (
+
+ );
+ }
+
+ 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 (
+
+ );
+}
+
+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