feat(prometheus-direct-charting): slice 1 — prom range query + chart rebrand

Add PrometheusWidgetSource._fetch_chart hitting /api/v1/query_range directly
(SC-101..104). New shared helpers in widgets/prometheus_range.py:
step_for_window (window preset -> step, ~200pts) and normalize_prometheus_matrix
(extracted label/dedup rule, retargeted at Prom matrix, robust to malformed
data). Chart widget kind moved grafana->prometheus in both registries;
GrafanaChartWidget renamed -> PrometheusChartWidget (git mv, recharts body
preserved). Grafana binding/service untouched (removed in slice 3).

Backend: 298 pytest pass, ruff clean. Frontend: 128 vitest pass, build+lint green.
This commit is contained in:
Developer
2026-07-08 21:51:49 +00:00
parent d906b0392b
commit 5dad98231f
12 changed files with 383 additions and 56 deletions
@@ -23,6 +23,9 @@ describe("service registry", () => {
it("binds widget kinds per service", () => {
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
"link",
]);
expect(SERVICE_REGISTRY.prometheus.widgets.map((w) => w.kind)).toEqual([
"metric",
"chart",
]);
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
+23 -34
View File
@@ -2,7 +2,7 @@ import type { ComponentType } from "react";
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget";
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
import { GrafanaChartWidget } from "../widgets/GrafanaChartWidget";
import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
@@ -88,39 +88,6 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
component: GrafanaLinkWidget,
},
{
kind: "chart",
name: "Chart",
description: "Live time-series chart from a Grafana datasource query.",
refreshIntervalMs: 60_000,
defaultConfig: {
datasource_uid: "prometheus",
query: "",
from_ts: "now-1h",
to_ts: "now",
interval_ms: 30_000,
max_data_points: 100,
},
configSchema: {
type: "object",
properties: {
datasource_uid: {
type: "string",
description: "Grafana datasource UID (e.g. 'prometheus')",
},
query: {
type: "string",
description: "Query expression (e.g. PromQL)",
},
from_ts: { type: "string" },
to_ts: { type: "string" },
interval_ms: { type: "integer" },
max_data_points: { type: "integer" },
},
required: ["query"],
},
component: GrafanaChartWidget,
},
],
},
prometheus: {
@@ -141,6 +108,28 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
component: PrometheusMetricWidget,
},
{
kind: "chart",
name: "Chart",
description: "Multi-series line chart from a PromQL range query.",
refreshIntervalMs: 60_000,
defaultConfig: { promql: "", window: "1h" },
configSchema: {
type: "object",
properties: {
promql: {
type: "string",
description: "PromQL range query expression",
},
window: {
type: "string",
description: "Time window preset (1h, 6h, 24h, 7d)",
},
},
required: ["promql"],
},
component: PrometheusChartWidget,
},
],
},
jellyfin: {
@@ -59,7 +59,7 @@ const CHART_COLORS = [
"var(--color-chart-5)",
];
export function GrafanaChartWidget({
export function PrometheusChartWidget({
widget,
refreshIntervalMs,
description,
@@ -111,7 +111,7 @@ export function GrafanaChartWidget({
) : (
<Alert>
<AlertDescription>
No data. Check your query and datasource_uid in the widget config.
No data. Check your PromQL query and window in the widget config.
</AlertDescription>
</Alert>
)}
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { GrafanaChartWidget } from "../GrafanaChartWidget";
import { PrometheusChartWidget } from "../PrometheusChartWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
@@ -29,7 +29,7 @@ function mockData(data: unknown, error?: string) {
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("GrafanaChartWidget", () => {
describe("PrometheusChartWidget", () => {
it("renders a chart with series data", () => {
mockData({
series: [
@@ -42,20 +42,20 @@ describe("GrafanaChartWidget", () => {
},
],
});
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
render(<PrometheusChartWidget 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, "Grafana api_key is required for chart queries");
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/api_key is required/i)).toBeInTheDocument();
mockData(null, "promql is required");
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/promql is required/i)).toBeInTheDocument();
});
it("shows empty state when no series", () => {
mockData({ series: [] });
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
export { BackupsWidget } from "./BackupsWidget";
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
export { GrafanaChartWidget } from "./GrafanaChartWidget";
export { PrometheusChartWidget } from "./PrometheusChartWidget";
export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { SshTaskWidget } from "./SshTaskWidget";