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 type { ComponentType } from "react";
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget"; import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget"; import { BackupsWidget } from "../widgets/BackupsWidget";
import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget"; import { MetricChartWidget } from "../widgets/MetricChartWidget";
import { PrometheusGaugeWidget } from "../widgets/PrometheusGaugeWidget"; import { MetricGaugeWidget } from "../widgets/MetricGaugeWidget";
import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget"; import { MetricMeanWidget } from "../widgets/MetricMeanWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget"; import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget"; import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget"; import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
@@ -109,7 +109,7 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
}, },
required: ["promql"], required: ["promql"],
}, },
component: PrometheusChartWidget, component: MetricChartWidget,
}, },
{ {
kind: "gauge", kind: "gauge",
@@ -133,7 +133,7 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
}, },
required: ["promql"], required: ["promql"],
}, },
component: PrometheusGaugeWidget, component: MetricGaugeWidget,
}, },
{ {
kind: "mean", kind: "mean",
@@ -156,7 +156,7 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
}, },
required: ["promql"], required: ["promql"],
}, },
component: PrometheusMeanWidget, component: MetricMeanWidget,
}, },
], ],
}, },
@@ -12,7 +12,7 @@ interface Props {
description?: string; description?: string;
} }
export function PrometheusChartWidget({ export function MetricChartWidget({
widget, widget,
refreshIntervalMs, refreshIntervalMs,
description, description,
@@ -37,7 +37,7 @@ function formatValue(value: number, unit?: string | null): string {
return unit ? `${formatted} ${unit}` : formatted; return unit ? `${formatted} ${unit}` : formatted;
} }
export function PrometheusGaugeWidget({ export function MetricGaugeWidget({
widget, widget,
refreshIntervalMs, refreshIntervalMs,
description, description,
@@ -27,7 +27,7 @@ function formatMean(value: number, unit?: string | null): string {
return unit ? `${formatted} ${unit}` : formatted; return unit ? `${formatted} ${unit}` : formatted;
} }
export function PrometheusMeanWidget({ export function MetricMeanWidget({
widget, widget,
refreshIntervalMs, refreshIntervalMs,
description, description,
+20 -22
View File
@@ -10,30 +10,28 @@ interface Props {
description?: string; description?: string;
} }
type PromQLResult = { type SeriesPoint = {
resultType?: string; t: number;
result?: unknown; v: number | null;
}; };
type PromQLVectorSample = { type Series = {
metric?: Record<string, string>; label: string;
value?: [number, string]; points: SeriesPoint[];
}; };
function formatPrometheusValue(result: PromQLResult | undefined): string { function formatMetricResult(series: Series[] | undefined): string {
if (!result) return "No data"; if (!series || series.length === 0) return "No data";
if (result.resultType === "scalar" && Array.isArray(result.result)) { if (series.length === 1) {
return String(result.result[1] ?? "No data"); const last = [...series[0].points].reverse().find((p) => p.v !== null);
return last ? String(last.v) : "No data";
} }
if ( return series
result.resultType === "vector" && .map((s) => {
Array.isArray(result.result) && const last = [...s.points].reverse().find((p) => p.v !== null);
result.result.length > 0 return `${s.label}: ${last ? last.v : "n/a"}`;
) { })
const first = result.result[0] as PromQLVectorSample; .join("\n");
if (first.value) return String(first.value[1]);
}
return JSON.stringify(result, null, 2);
} }
export function PrometheusMetricWidget({ export function PrometheusMetricWidget({
@@ -42,7 +40,7 @@ export function PrometheusMetricWidget({
description, description,
}: Props) { }: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const result = data?.data?.result as PromQLResult | undefined; const series = data?.data?.result as Series[] | undefined;
return ( return (
<SectionCard title={widget.title} description={description}> <SectionCard title={widget.title} description={description}>
@@ -52,9 +50,9 @@ export function PrometheusMetricWidget({
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription> <AlertDescription>{data.error}</AlertDescription>
</Alert> </Alert>
) : ( ) : (
<pre className="whitespace-pre-wrap text-sm"> <pre className="whitespace-pre-wrap text-sm">
{formatPrometheusValue(result)} {formatMetricResult(series)}
</pre> </pre>
)} )}
</SectionCard> </SectionCard>
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { PrometheusChartWidget } from "../PrometheusChartWidget"; import { MetricChartWidget } from "../MetricChartWidget";
import type { WidgetInstance } from "../../types"; import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets"; import * as useWidgets from "../../hooks/useWidgets";
@@ -29,13 +29,13 @@ function mockData(data: unknown, error?: string) {
} as unknown as ReturnType<typeof useWidgets.useWidgetData>); } as unknown as ReturnType<typeof useWidgets.useWidgetData>);
} }
describe("PrometheusChartWidget", () => { describe("MetricChartWidget", () => {
it("renders skeleton while loading", () => { it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({ vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined, data: undefined,
isLoading: true, isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>); } as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />); render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
expect( expect(
document.querySelector('[data-slot="skeleton"]'), document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument(); ).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. // recharts renders an SVG; the title from SectionCard should be present.
expect(screen.getByText("CPU Usage")).toBeInTheDocument(); expect(screen.getByText("CPU Usage")).toBeInTheDocument();
}); });
it("shows error Alert on error", () => { it("shows error Alert on error", () => {
mockData(null, "promql is required"); 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(); expect(screen.getByText(/promql is required/i)).toBeInTheDocument();
}); });
it("shows empty state when no series", () => { it("shows empty state when no series", () => {
mockData({ series: [] }); mockData({ series: [] });
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />); render(<MetricChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument(); expect(screen.getByText(/No data/i)).toBeInTheDocument();
}); });
}); });
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { PrometheusGaugeWidget } from "../PrometheusGaugeWidget"; import { MetricGaugeWidget } from "../MetricGaugeWidget";
import type { WidgetInstance } from "../../types"; import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets"; import * as useWidgets from "../../hooks/useWidgets";
@@ -29,13 +29,13 @@ function mockData(data: unknown, error?: string) {
} as unknown as ReturnType<typeof useWidgets.useWidgetData>); } as unknown as ReturnType<typeof useWidgets.useWidgetData>);
} }
describe("PrometheusGaugeWidget", () => { describe("MetricGaugeWidget", () => {
it("renders skeleton while loading", () => { it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({ vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined, data: undefined,
isLoading: true, isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>); } as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />); render(<MetricGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect( expect(
document.querySelector('[data-slot="skeleton"]'), document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -48,7 +48,7 @@ describe("PrometheusGaugeWidget", () => {
crit_at: 0.95, crit_at: 0.95,
unit: "%", unit: "%",
}); });
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />); render(<MetricGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText("CPU Gauge")).toBeInTheDocument(); expect(screen.getByText("CPU Gauge")).toBeInTheDocument();
expect(screen.getByText(/0.75 %/)).toBeInTheDocument(); expect(screen.getByText(/0.75 %/)).toBeInTheDocument();
// Threshold labels present when bands are set. // Threshold labels present when bands are set.
@@ -58,7 +58,7 @@ describe("PrometheusGaugeWidget", () => {
it("renders a gauge without threshold bands (single color)", () => { it("renders a gauge without threshold bands (single color)", () => {
mockData({ value: 42, unit: "req/s" }); 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(); expect(screen.getByText(/42 req\/s/)).toBeInTheDocument();
// No threshold labels when bands are absent. // No threshold labels when bands are absent.
expect(screen.queryByText(/warn/i)).not.toBeInTheDocument(); expect(screen.queryByText(/warn/i)).not.toBeInTheDocument();
@@ -66,13 +66,13 @@ describe("PrometheusGaugeWidget", () => {
it("shows error Alert on error", () => { it("shows error Alert on error", () => {
mockData(null, "Gauge requires a single-series query"); 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(); expect(screen.getByText(/single-series/i)).toBeInTheDocument();
}); });
it("shows empty state when no data", () => { it("shows empty state when no data", () => {
mockData(null); mockData(null);
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />); render(<MetricGaugeWidget widget={widget} refreshIntervalMs={30000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument(); expect(screen.getByText(/No data/i)).toBeInTheDocument();
}); });
}); });
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { PrometheusMeanWidget } from "../PrometheusMeanWidget"; import { MetricMeanWidget } from "../MetricMeanWidget";
import type { WidgetInstance } from "../../types"; import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets"; import * as useWidgets from "../../hooks/useWidgets";
@@ -29,13 +29,13 @@ function mockData(data: unknown, error?: string) {
} as unknown as ReturnType<typeof useWidgets.useWidgetData>); } as unknown as ReturnType<typeof useWidgets.useWidgetData>);
} }
describe("PrometheusMeanWidget", () => { describe("MetricMeanWidget", () => {
it("renders skeleton while loading", () => { it("renders skeleton while loading", () => {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({ vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: undefined, data: undefined,
isLoading: true, isLoading: true,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>); } as unknown as ReturnType<typeof useWidgets.useWidgetData>);
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />); render(<MetricMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect( expect(
document.querySelector('[data-slot="skeleton"]'), document.querySelector('[data-slot="skeleton"]'),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -43,26 +43,26 @@ describe("PrometheusMeanWidget", () => {
it("renders the mean value with unit", () => { it("renders the mean value with unit", () => {
mockData({ value: 23.5, 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("Avg CPU")).toBeInTheDocument();
expect(screen.getByText(/23.5 %/)).toBeInTheDocument(); expect(screen.getByText(/23.5 %/)).toBeInTheDocument();
}); });
it("renders the mean value without unit", () => { it("renders the mean value without unit", () => {
mockData({ value: 1500, unit: null }); mockData({ value: 1500, unit: null });
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />); render(<MetricMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/1500/)).toBeInTheDocument(); expect(screen.getByText(/1500/)).toBeInTheDocument();
}); });
it("shows error Alert on error", () => { it("shows error Alert on error", () => {
mockData(null, "Mean requires a single-series query"); 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(); expect(screen.getByText(/single-series/i)).toBeInTheDocument();
}); });
it("shows empty state when no data", () => { it("shows empty state when no data", () => {
mockData(null); mockData(null);
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />); render(<MetricMeanWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument(); expect(screen.getByText(/No data/i)).toBeInTheDocument();
}); });
}); });
+3 -3
View File
@@ -1,8 +1,8 @@
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget"; export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
export { BackupsWidget } from "./BackupsWidget"; export { BackupsWidget } from "./BackupsWidget";
export { PrometheusChartWidget } from "./PrometheusChartWidget"; export { MetricChartWidget } from "./MetricChartWidget";
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget"; export { MetricGaugeWidget } from "./MetricGaugeWidget";
export { PrometheusMeanWidget } from "./PrometheusMeanWidget"; export { MetricMeanWidget } from "./MetricMeanWidget";
export { JellyfinWidget } from "./JellyfinWidget"; export { JellyfinWidget } from "./JellyfinWidget";
export { JellyfinNowPlayingWidget } from "./JellyfinNowPlayingWidget"; export { JellyfinNowPlayingWidget } from "./JellyfinNowPlayingWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget"; export { PrometheusMetricWidget } from "./PrometheusMetricWidget";