feat(prometheus-direct-charting): slice 2 — gauge + mean widgets

Add gauge widget (recharts RadialBarChart with configurable threshold
bands, scalar-only per SC-111) and mean widget (client-side average over
range-query window, scalar-only per SC-114). Extract shared _instant_query
helper from the metric path; _fetch_gauge and _fetch_mean dispatch in
PrometheusWidgetSource.fetch(). Both new widget kinds declared in
integrations/prometheus.py and frontend registry.

Backend: 305 pytest pass, ruff clean. Frontend: 136 vitest pass, build+lint green.
This commit is contained in:
Developer
2026-07-08 22:09:10 +00:00
parent 5dad98231f
commit 65bae95e3c
12 changed files with 707 additions and 32 deletions
@@ -27,6 +27,8 @@ describe("service registry", () => {
expect(SERVICE_REGISTRY.prometheus.widgets.map((w) => w.kind)).toEqual([
"metric",
"chart",
"gauge",
"mean",
]);
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
"active_alerts",
+49
View File
@@ -3,6 +3,8 @@ import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget";
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
import { PrometheusGaugeWidget } from "../widgets/PrometheusGaugeWidget";
import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
@@ -130,6 +132,53 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
component: PrometheusChartWidget,
},
{
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: PrometheusGaugeWidget,
},
{
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: PrometheusMeanWidget,
},
],
},
jellyfin: {
@@ -0,0 +1,137 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import {
RadialBarChart,
RadialBar,
ResponsiveContainer,
PolarAngleAxis,
} from "recharts";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
interface GaugeData {
value: number;
warn_at?: number | null;
crit_at?: number | null;
min?: number | null;
max?: number | null;
unit?: string | null;
}
function formatValue(value: number, unit?: string | null): string {
let formatted: string;
if (Math.abs(value) >= 100) {
formatted = value.toFixed(0);
} else if (Math.abs(value) >= 1) {
formatted = value.toFixed(2).replace(/\.?0+$/, "");
} else {
formatted = value.toFixed(4).replace(/\.?0+$/, "");
}
return unit ? `${formatted} ${unit}` : formatted;
}
export function PrometheusGaugeWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const gauge = data?.data as GaugeData | undefined;
// Compute gauge domain and threshold bands.
const value = gauge?.value ?? 0;
const min = gauge?.min ?? 0;
const max =
gauge?.max ?? Math.max(value, gauge?.warn_at ?? 0, gauge?.crit_at ?? 0, 1);
const warnAt = gauge?.warn_at;
const critAt = gauge?.crit_at;
const hasBands = warnAt != null && critAt != null;
// recharts RadialBarChart uses a 0100 domain for the angle axis.
// Map our [min, max] domain to [0, 100].
const range = max - min || 1;
const toPercent = (v: number) => Math.round(((v - min) / range) * 100);
const valuePct = Math.max(0, Math.min(100, toPercent(value)));
// Build track cells: green / amber / red when bands are set, else neutral.
const trackCells = hasBands
? [
{ pct: toPercent(warnAt!), fill: "hsl(var(--chart-1))" }, // green
{ pct: toPercent(critAt!), fill: "hsl(var(--chart-4))" }, // amber
{ pct: 100, fill: "hsl(var(--destructive))" }, // red
]
: [{ pct: 100, fill: "hsl(var(--muted))" }];
const valueColor = hasBands
? value >= critAt!
? "hsl(var(--destructive))"
: value >= warnAt!
? "hsl(var(--chart-4))"
: "hsl(var(--chart-1))"
: "hsl(var(--primary))";
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-[220px] w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : gauge ? (
<div className="flex flex-col items-center">
<ResponsiveContainer width="100%" height={200}>
<RadialBarChart
innerRadius="65%"
outerRadius="100%"
data={[
...trackCells.map((c) => ({
name: "track",
pct: c.pct,
fill: c.fill,
})),
{ name: "value", pct: valuePct, fill: valueColor },
]}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis
type="number"
domain={[0, 100]}
angleAxisId={0}
tick={false}
/>
<RadialBar
background={{ fill: "hsl(var(--muted))" }}
dataKey="pct"
cornerRadius={6}
/>
</RadialBarChart>
</ResponsiveContainer>
<div className="-mt-12 flex flex-col items-center">
<span className="text-2xl font-bold" style={{ color: valueColor }}>
{formatValue(value, gauge.unit)}
</span>
{hasBands ? (
<span className="text-xs text-muted-foreground">
warn {formatValue(warnAt!, gauge.unit)} · crit{" "}
{formatValue(critAt!, gauge.unit)}
</span>
) : null}
</div>
</div>
) : (
<Alert>
<AlertDescription>No data. Check your PromQL query.</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
@@ -0,0 +1,59 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
interface MeanData {
value: number;
unit?: string | null;
}
function formatMean(value: number, unit?: string | null): string {
let formatted: string;
if (Math.abs(value) >= 1000) {
formatted = value.toFixed(0);
} else if (Math.abs(value) >= 1) {
formatted = value.toFixed(2).replace(/\.?0+$/, "");
} else {
formatted = value.toFixed(4).replace(/\.?0+$/, "");
}
return unit ? `${formatted} ${unit}` : formatted;
}
export function PrometheusMeanWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const mean = data?.data as MeanData | undefined;
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-16 w-32" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : mean ? (
<div className="flex flex-col items-center justify-center py-4">
<span className="text-3xl font-bold">
{formatMean(mean.value, mean.unit)}
</span>
</div>
) : (
<Alert>
<AlertDescription>No data. Check your PromQL query.</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { PrometheusGaugeWidget } from "../PrometheusGaugeWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "wg1",
service_id: "s1",
widget_kind: "gauge",
title: "CPU Gauge",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "wg1", error, fetched_at: 0 }
: { widget_id: "wg1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("PrometheusGaugeWidget", () => {
it("renders a gauge with value and threshold bands", () => {
mockData({
value: 0.75,
warn_at: 0.8,
crit_at: 0.95,
unit: "%",
});
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText("CPU Gauge")).toBeInTheDocument();
expect(screen.getByText(/0.75 %/)).toBeInTheDocument();
// Threshold labels present when bands are set.
expect(screen.getByText(/warn/i)).toBeInTheDocument();
expect(screen.getByText(/crit/i)).toBeInTheDocument();
});
it("renders a gauge without threshold bands (single color)", () => {
mockData({ value: 42, unit: "req/s" });
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText(/42 req\/s/)).toBeInTheDocument();
// No threshold labels when bands are absent.
expect(screen.queryByText(/warn/i)).not.toBeInTheDocument();
});
it("shows error Alert on error", () => {
mockData(null, "Gauge requires a single-series query");
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText(/single-series/i)).toBeInTheDocument();
});
it("shows empty state when no data", () => {
mockData(null);
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { PrometheusMeanWidget } from "../PrometheusMeanWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "wm1",
service_id: "s1",
widget_kind: "mean",
title: "Avg CPU",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "wm1", error, fetched_at: 0 }
: { widget_id: "wm1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("PrometheusMeanWidget", () => {
it("renders the mean value with unit", () => {
mockData({ value: 23.5, unit: "%" });
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText("Avg CPU")).toBeInTheDocument();
expect(screen.getByText(/23.5 %/)).toBeInTheDocument();
});
it("renders the mean value without unit", () => {
mockData({ value: 1500, unit: null });
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/1500/)).toBeInTheDocument();
});
it("shows error Alert on error", () => {
mockData(null, "Mean requires a single-series query");
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/single-series/i)).toBeInTheDocument();
});
it("shows empty state when no data", () => {
mockData(null);
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
+2
View File
@@ -2,6 +2,8 @@ export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
export { BackupsWidget } from "./BackupsWidget";
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
export { PrometheusChartWidget } from "./PrometheusChartWidget";
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { SshTaskWidget } from "./SshTaskWidget";