feat(grafana-metric-gateway): slice 2 — rename widgets to Metric*

git mv PrometheusChartWidget→MetricChartWidget, PrometheusGaugeWidget→
MetricGaugeWidget, PrometheusMeanWidget→MetricMeanWidget (+ 3 test files,
R100 history preserved). Update registry imports/refs + barrel exports.
Adapt PrometheusMetricWidget for §3.4 Option A: read normalized {result:
[{label,points}]} series shape (last-point extraction) instead of old Prom
{resultType,result} vector. PrometheusMetricWidget NOT renamed (design §3.1).
GM-111/112/116 satisfied. All gates: 151 vitest, build exit 0, lint 0 errors.
This commit is contained in:
Developer
2026-07-09 21:34:26 +00:00
parent df80c68f89
commit 7e91e7f931
9 changed files with 52 additions and 54 deletions
+6 -6
View File
@@ -1,9 +1,9 @@
import type { ComponentType } from "react";
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget";
import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
import { PrometheusGaugeWidget } from "../widgets/PrometheusGaugeWidget";
import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
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";
@@ -109,7 +109,7 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
required: ["promql"],
},
component: PrometheusChartWidget,
component: MetricChartWidget,
},
{
kind: "gauge",
@@ -133,7 +133,7 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
required: ["promql"],
},
component: PrometheusGaugeWidget,
component: MetricGaugeWidget,
},
{
kind: "mean",
@@ -156,7 +156,7 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
required: ["promql"],
},
component: PrometheusMeanWidget,
component: MetricMeanWidget,
},
],
},
@@ -12,7 +12,7 @@ interface Props {
description?: string;
}
export function PrometheusChartWidget({
export function MetricChartWidget({
widget,
refreshIntervalMs,
description,
@@ -37,7 +37,7 @@ function formatValue(value: number, unit?: string | null): string {
return unit ? `${formatted} ${unit}` : formatted;
}
export function PrometheusGaugeWidget({
export function MetricGaugeWidget({
widget,
refreshIntervalMs,
description,
@@ -27,7 +27,7 @@ function formatMean(value: number, unit?: string | null): string {
return unit ? `${formatted} ${unit}` : formatted;
}
export function PrometheusMeanWidget({
export function MetricMeanWidget({
widget,
refreshIntervalMs,
description,
+20 -22
View File
@@ -10,30 +10,28 @@ interface Props {
description?: string;
}
type PromQLResult = {
resultType?: string;
result?: unknown;
type SeriesPoint = {
t: number;
v: number | null;
};
type PromQLVectorSample = {
metric?: Record<string, string>;
value?: [number, string];
type Series = {
label: string;
points: SeriesPoint[];
};
function formatPrometheusValue(result: PromQLResult | undefined): string {
if (!result) return "No data";
if (result.resultType === "scalar" && Array.isArray(result.result)) {
return String(result.result[1] ?? "No data");
function formatMetricResult(series: Series[] | undefined): string {
if (!series || series.length === 0) return "No data";
if (series.length === 1) {
const last = [...series[0].points].reverse().find((p) => p.v !== null);
return last ? String(last.v) : "No data";
}
if (
result.resultType === "vector" &&
Array.isArray(result.result) &&
result.result.length > 0
) {
const first = result.result[0] as PromQLVectorSample;
if (first.value) return String(first.value[1]);
}
return JSON.stringify(result, null, 2);
return series
.map((s) => {
const last = [...s.points].reverse().find((p) => p.v !== null);
return `${s.label}: ${last ? last.v : "n/a"}`;
})
.join("\n");
}
export function PrometheusMetricWidget({
@@ -42,7 +40,7 @@ export function PrometheusMetricWidget({
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const result = data?.data?.result as PromQLResult | undefined;
const series = data?.data?.result as Series[] | undefined;
return (
<SectionCard title={widget.title} description={description}>
@@ -52,9 +50,9 @@ export function PrometheusMetricWidget({
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : (
) : (
<pre className="whitespace-pre-wrap text-sm">
{formatPrometheusValue(result)}
{formatMetricResult(series)}
</pre>
)}
</SectionCard>
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { PrometheusChartWidget } from "../PrometheusChartWidget";
import { MetricChartWidget } from "../MetricChartWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
@@ -29,13 +29,13 @@ function mockData(data: unknown, error?: string) {
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("PrometheusChartWidget", () => {
describe("MetricChartWidget", () => {
it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(
document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument();
@@ -53,20 +53,20 @@ describe("PrometheusChartWidget", () => {
},
],
});
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
// recharts renders an SVG; the title from SectionCard should be present.
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
});
it("shows error Alert on error", () => {
mockData(null, "promql is required");
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/promql is required/i)).toBeInTheDocument();
});
it("shows empty state when no series", () => {
mockData({ series: [] });
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { PrometheusGaugeWidget } from "../PrometheusGaugeWidget";
import { MetricGaugeWidget } from "../MetricGaugeWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
@@ -29,13 +29,13 @@ function mockData(data: unknown, error?: string) {
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("PrometheusGaugeWidget", () => {
describe("MetricGaugeWidget", () => {
it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
render(<MetricGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(
document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument();
@@ -48,7 +48,7 @@ describe("PrometheusGaugeWidget", () => {
crit_at: 0.95,
unit: "%",
});
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
render(<MetricGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText("CPU Gauge")).toBeInTheDocument();
expect(screen.getByText(/0.75 %/)).toBeInTheDocument();
// Threshold labels present when bands are set.
@@ -58,7 +58,7 @@ describe("PrometheusGaugeWidget", () => {
it("renders a gauge without threshold bands (single color)", () => {
mockData({ value: 42, unit: "req/s" });
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
render(<MetricGaugeWidget 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();
@@ -66,13 +66,13 @@ describe("PrometheusGaugeWidget", () => {
it("shows error Alert on error", () => {
mockData(null, "Gauge requires a single-series query");
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
render(<MetricGaugeWidget 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} />);
render(<MetricGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { PrometheusMeanWidget } from "../PrometheusMeanWidget";
import { MetricMeanWidget } from "../MetricMeanWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
@@ -29,13 +29,13 @@ function mockData(data: unknown, error?: string) {
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("PrometheusMeanWidget", () => {
describe("MetricMeanWidget", () => {
it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
render(<MetricMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(
document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument();
@@ -43,26 +43,26 @@ describe("PrometheusMeanWidget", () => {
it("renders the mean value with unit", () => {
mockData({ value: 23.5, unit: "%" });
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
render(<MetricMeanWidget 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} />);
render(<MetricMeanWidget 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} />);
render(<MetricMeanWidget 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} />);
render(<MetricMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
+3 -3
View File
@@ -1,8 +1,8 @@
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
export { BackupsWidget } from "./BackupsWidget";
export { PrometheusChartWidget } from "./PrometheusChartWidget";
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
export { MetricChartWidget } from "./MetricChartWidget";
export { MetricGaugeWidget } from "./MetricGaugeWidget";
export { MetricMeanWidget } from "./MetricMeanWidget";
export { JellyfinWidget } from "./JellyfinWidget";
export { JellyfinNowPlayingWidget } from "./JellyfinNowPlayingWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";