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:
@@ -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",
|
||||
|
||||
@@ -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 0–100 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,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";
|
||||
|
||||
Reference in New Issue
Block a user