Files
manage/frontend/src/integrations/registry.ts
T
Developer 7e4222ef00 fix(widgets): expose unit/scale options in the frontend widget registry
The config dialog reads each widget kind's schema from the static frontend
SERVICE_REGISTRY (registry.ts), not the backend pydantic schema. The previous
commit added unit/scale to the backend configs but not to the frontend mirror,
so the options never appeared in the dialog — the Prometheus "chart" binding
still listed only promql/window and the qBittorrent "speed" binding had an
empty configSchema.

Add a shared AXIS_FORMAT_PROPERTIES fragment (unit + scale enums) and spread
it into the prometheus chart and qbittorrent speed bindings, with matching
defaultConfig (chart: none/auto; speed: bytes_per_sec/auto). Combined with the
enum <Select> rendering already added to WidgetConfigDialog, the options now
show up as dropdowns when editing those widgets.

Test: registry exposes unit/scale enums on chart + speed; speed defaults to
bytes_per_sec. 180/180 frontend tests pass; tsc + ESLint clean.
2026-07-12 12:00:11 +00:00

362 lines
9.7 KiB
TypeScript

import type { ComponentType } from "react";
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget";
import { MetricChartWidget } from "../widgets/MetricChartWidget";
import { MetricGaugeWidget } from "../widgets/MetricGaugeWidget";
import { MetricMeanWidget } from "../widgets/MetricMeanWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
import { QbittorrentActiveTorrentsWidget } from "../widgets/QbittorrentActiveTorrentsWidget";
import { QbittorrentSpeedWidget } from "../widgets/QbittorrentSpeedWidget";
import { QbittorrentTotalsWidget } from "../widgets/QbittorrentTotalsWidget";
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[];
}
/** Shared Y-axis format options mirrored on every graph widget kind. */
const UNIT_VALUES = [
"none",
"bytes",
"bytes_per_sec",
"bits_per_sec",
"bits",
"percent",
"seconds",
];
const SCALE_VALUES = ["auto", "k", "m", "g", "t"];
const AXIS_FORMAT_PROPERTIES = {
unit: {
type: "string",
enum: UNIT_VALUES,
description:
"Display unit; auto-scales the Y axis + tooltip (kB/MB/GB, kbps/Mbps, …)",
},
scale: {
type: "string",
enum: SCALE_VALUES,
description: "auto picks a prefix from the data; k/M/G/T force one",
},
};
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
alertmanager: {
serviceType: "alertmanager",
name: "Alertmanager",
description: "Alertmanager alerts and status.",
widgets: [
{
kind: "active_alerts",
name: "Active alerts",
description: "Firing alerts summary from Alertmanager.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: {
type: "object",
properties: {
severity_filter: { type: "string" },
},
required: [],
},
component: AlertmanagerAlertsWidget,
},
],
},
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,
},
{
kind: "chart",
name: "Chart",
description: "Multi-series line chart from a PromQL range query.",
refreshIntervalMs: 60_000,
defaultConfig: { promql: "", window: "1h", unit: "none", scale: "auto" },
configSchema: {
type: "object",
properties: {
promql: {
type: "string",
description: "PromQL range query expression",
},
window: {
type: "string",
description: "Time window preset (1h, 6h, 24h, 7d)",
},
...AXIS_FORMAT_PROPERTIES,
},
required: ["promql"],
},
component: MetricChartWidget,
},
{
kind: "gauge",
name: "Gauge",
description:
"Instant query rendered as a gauge with optional threshold bands.",
refreshIntervalMs: 30_000,
defaultConfig: { promql: "" },
configSchema: {
type: "object",
properties: {
promql: {
type: "string",
description: "PromQL instant query (must return a single scalar)",
},
warn_at: { type: "number", description: "Warning threshold" },
crit_at: { type: "number", description: "Critical threshold" },
min: { type: "number" },
max: { type: "number" },
unit: { type: "string" },
},
required: ["promql"],
},
component: MetricGaugeWidget,
},
{
kind: "mean",
name: "Mean",
description: "Average value of a PromQL query over a time window.",
refreshIntervalMs: 60_000,
defaultConfig: { promql: "", window: "1h" },
configSchema: {
type: "object",
properties: {
promql: {
type: "string",
description: "PromQL range query (must return a single series)",
},
window: {
type: "string",
description: "Time window preset (1h, 6h, 24h, 7d)",
},
unit: { type: "string" },
},
required: ["promql"],
},
component: MetricMeanWidget,
},
],
},
qbittorrent: {
serviceType: "qbittorrent",
name: "qBittorrent",
description: "Torrent client activity, speeds, and item counts.",
widgets: [
{
kind: "totals",
name: "Totals",
description: "Count of all listed torrents, broken down by state.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentTotalsWidget,
},
{
kind: "active",
name: "Active torrents",
description: "Torrents currently downloading or uploading.",
refreshIntervalMs: 15_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentActiveTorrentsWidget,
},
{
kind: "speed",
name: "Speed chart",
description: "Live download/upload speed over a short window.",
refreshIntervalMs: 5_000,
defaultConfig: { unit: "bytes_per_sec", scale: "auto" },
configSchema: {
type: "object",
properties: { ...AXIS_FORMAT_PROPERTIES },
required: [],
},
component: QbittorrentSpeedWidget,
},
],
},
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,
},
{
kind: "now_playing",
name: "Now Playing",
description: "Only sessions actively playing media.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: JellyfinNowPlayingWidget,
},
],
},
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;
}