From 65bae95e3cee249aefde3fbc34237b654a1b75fa Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 8 Jul 2026 22:09:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(prometheus-direct-charting):=20slice=202?= =?UTF-8?q?=20=E2=80=94=20gauge=20+=20mean=20widgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../integrations/prometheus.py | 35 ++++ .../widgets/sources.py | 112 ++++++++-- backend/tests/test_services.py | 2 +- backend/tests/test_widgets.py | 197 ++++++++++++++++++ frontend/src/integrations/registry.test.ts | 2 + frontend/src/integrations/registry.ts | 49 +++++ .../src/widgets/PrometheusGaugeWidget.tsx | 137 ++++++++++++ frontend/src/widgets/PrometheusMeanWidget.tsx | 59 ++++++ .../__tests__/PrometheusGaugeWidget.test.tsx | 67 ++++++ .../__tests__/PrometheusMeanWidget.test.tsx | 57 +++++ frontend/src/widgets/index.ts | 2 + .../prometheus-direct-charting/tasks.md | 20 +- 12 files changed, 707 insertions(+), 32 deletions(-) create mode 100644 frontend/src/widgets/PrometheusGaugeWidget.tsx create mode 100644 frontend/src/widgets/PrometheusMeanWidget.tsx create mode 100644 frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx create mode 100644 frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx diff --git a/backend/src/media_library_viewer_api/integrations/prometheus.py b/backend/src/media_library_viewer_api/integrations/prometheus.py index 4d2e184..cb042ea 100644 --- a/backend/src/media_library_viewer_api/integrations/prometheus.py +++ b/backend/src/media_library_viewer_api/integrations/prometheus.py @@ -32,6 +32,25 @@ class PrometheusChartWidgetConfig(WidgetConfigBase): window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS) +class PrometheusGaugeWidgetConfig(WidgetConfigBase): + """A PromQL instant query rendered as a gauge with optional threshold bands (SC-109..SC-111).""" + + promql: str + warn_at: float | None = None + crit_at: float | None = None + min: float | None = None + max: float | None = None + unit: str | None = None + + +class PrometheusMeanWidgetConfig(WidgetConfigBase): + """A PromQL range query averaged client-side into a single value (SC-112..SC-114).""" + + promql: str + window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS) + unit: str | None = None + + DEFINITION = ServiceDefinition( service_type="prometheus", name="Prometheus", @@ -57,5 +76,21 @@ DEFINITION = ServiceDefinition( default_config={"promql": "", "window": "1h"}, refresh_interval_ms=60_000, ), + widget_kind( + kind="gauge", + name="Gauge", + description="Instant query rendered as a gauge with optional threshold bands.", + model_cls=PrometheusGaugeWidgetConfig, + default_config={"promql": ""}, + refresh_interval_ms=30_000, + ), + widget_kind( + kind="mean", + name="Mean", + description="Average value of a PromQL query over a time window.", + model_cls=PrometheusMeanWidgetConfig, + default_config={"promql": "", "window": "1h"}, + refresh_interval_ms=60_000, + ), ], ) diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index dd7ca0e..86931ee 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -225,28 +225,13 @@ class PrometheusWidgetSource: timeout = int(service.config.get("timeout_seconds") or 10) if widget_kind == "chart": return await self._fetch_chart(base_url, timeout, config) + if widget_kind == "gauge": + return await self._fetch_gauge(base_url, timeout, config) + if widget_kind == "mean": + return await self._fetch_mean(base_url, timeout, config) # Default: instant-query metric path (unchanged). - promql = config.get("promql") - if not promql: - return {"error": "promql is required"} - url = f"{base_url}/api/v1/query" - response = await asyncio.wait_for( - asyncio.to_thread( - requests.get, - url, - params={"query": promql}, - timeout=timeout, - ), - timeout=timeout, - ) - response.raise_for_status() - payload = response.json() - return {"result": payload.get("data", {})} - except asyncio.TimeoutError: - return {"error": "Widget data fetch timed out"} - except requests.RequestException as exc: - logger.exception("prometheus adapter failed") - return {"error": f"Prometheus query failed: {exc}"} + raw = await self._instant_query(base_url, timeout, config.get("promql", "")) + return raw except Exception as exc: logger.exception("prometheus adapter failed") return {"error": f"Prometheus query failed: {exc}"} @@ -281,6 +266,34 @@ class PrometheusWidgetSource: result = payload.get("data", {}).get("result", []) return {"matrix": result} + async def _instant_query(self, base_url: str, timeout: int, promql: str) -> dict[str, Any]: + """Run a Prometheus ``/api/v1/query`` instant query. + + Shared by the ``metric`` and ``gauge`` widget kinds. Returns + ``{"result": data}`` on success or ``{"error": str}`` (never raises, + per SC-103). + """ + if not promql: + return {"error": "promql is required"} + try: + response = await asyncio.wait_for( + asyncio.to_thread( + requests.get, + f"{base_url}/api/v1/query", + params={"query": promql}, + timeout=timeout, + ), + timeout=timeout, + ) + response.raise_for_status() + payload = response.json() + except asyncio.TimeoutError: + return {"error": "Prometheus query timed out"} + except requests.RequestException as exc: + logger.exception("prometheus instant query failed") + return {"error": f"Prometheus query failed: {exc}"} + return {"result": payload.get("data", {})} + async def _fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]: """Range query → ``{series}`` for the chart widget (SC-101..SC-104).""" promql = config.get("promql") @@ -292,6 +305,63 @@ class PrometheusWidgetSource: return raw return {"series": normalize_prometheus_matrix(raw["matrix"])} + async def _fetch_gauge(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]: + """Instant query → scalar for the gauge widget (SC-109, SC-110, SC-111). + + Scalar-only: a multi-series query returns an error (SC-111). Threshold + config (``warn_at``/``crit_at``/``min``/``max``/``unit``) is passed + through for the frontend renderer. + """ + raw = await self._instant_query(base_url, timeout, config.get("promql") or "") + if "error" in raw: + return raw + result = raw["result"].get("result", []) + if len(result) != 1: + return {"error": "Gauge requires a single-series query; refine your PromQL"} + try: + value = float(result[0]["value"][1]) + except (KeyError, IndexError, ValueError, TypeError): + return {"error": "Gauge query returned no scalar value"} + return { + "value": value, + "warn_at": config.get("warn_at"), + "crit_at": config.get("crit_at"), + "min": config.get("min"), + "max": config.get("max"), + "unit": config.get("unit"), + } + + async def _fetch_mean(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]: + """Range query → client-side mean for the mean widget (SC-112..SC-114). + + Runs ``query_range`` over the configured window preset, averages all + non-null numeric samples of the single series, and returns a scalar. + Scalar-only: a multi-series query returns an error (SC-114). + """ + promql = config.get("promql") + if not promql: + return {"error": "promql is required"} + window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"]) + raw = await self._range_query(base_url, timeout, promql, window) + if "error" in raw: + return raw + result = raw["matrix"] + if len(result) != 1: + return {"error": "Mean requires a single-series query; refine your PromQL"} + points = result[0].get("values") or [] + nums: list[float] = [] + for _, v in points: + if v in (None, "NaN", "+Inf", "-Inf"): + continue + try: + nums.append(float(v)) + except (TypeError, ValueError): + continue + if not nums: + return {"error": "Mean query returned no numeric samples in the window"} + mean = sum(nums) / len(nums) + return {"value": mean, "unit": config.get("unit")} + class AlertmanagerWidgetSource: """Fetch firing alerts from an Alertmanager service and summarize them.""" diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 8487d0d..643ac8b 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -100,7 +100,7 @@ def test_authentik_service_definition(): def test_definitions_declare_widget_kinds(): assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link", "chart"} - assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart"} + assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"} assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"} assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"} assert get_service_definition("nextcloud").widget_kinds == [] diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index 5b0787c..26c3ca9 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -991,3 +991,200 @@ def test_widget_reference_update_sort_order(widget_ref_client): # Widget instances themselves are unchanged. assert store.get_widget(widget_a["id"])["sort_order"] == 0 assert store.get_widget(widget_b["id"])["sort_order"] == 1 + + +# --------------------------------------------------------------------------- +# Prometheus gauge + mean adapter tests (SC-109..SC-114) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_prometheus_gauge_adapter_returns_scalar(): + """SC-109: gauge kind hits /api/v1/query and returns {value, thresholds}.""" + from media_library_viewer_api.widgets.sources import PrometheusWidgetSource + + adapter = PrometheusWidgetSource() + service = ServiceRecord( + id="s", + service_type="prometheus", + name="p", + config={"base_url": "http://p:9090", "timeout_seconds": 5}, + ) + payload = SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: { + "data": { + "result": [ + {"metric": {"__name__": "cpu"}, "value": [100, "0.75"]}, + ] + } + }, + ) + with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get: + result = await adapter.fetch( + service, + "gauge", + { + "promql": "cpu_usage", + "warn_at": 0.8, + "crit_at": 0.95, + "unit": "%", + }, + ) + call = mock_get.call_args + assert call.args[0].endswith("/api/v1/query") + assert call.kwargs["params"]["query"] == "cpu_usage" + assert result["value"] == 0.75 + assert result["warn_at"] == 0.8 + assert result["crit_at"] == 0.95 + assert result["unit"] == "%" + + +@pytest.mark.asyncio +async def test_prometheus_gauge_adapter_rejects_multi_series(): + """SC-111: gauge must be scalar-only; multi-series returns error.""" + from media_library_viewer_api.widgets.sources import PrometheusWidgetSource + + adapter = PrometheusWidgetSource() + service = ServiceRecord( + id="s", + service_type="prometheus", + name="p", + config={"base_url": "http://p:9090"}, + ) + payload = SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: { + "data": { + "result": [ + {"metric": {"instance": "a"}, "value": [100, "1"]}, + {"metric": {"instance": "b"}, "value": [100, "2"]}, + ] + } + }, + ) + with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload): + result = await adapter.fetch(service, "gauge", {"promql": "up"}) + assert "error" in result + assert "single-series" in result["error"].lower() + + +@pytest.mark.asyncio +async def test_prometheus_gauge_adapter_requires_promql(): + from media_library_viewer_api.widgets.sources import PrometheusWidgetSource + + adapter = PrometheusWidgetSource() + service = ServiceRecord( + id="s", + service_type="prometheus", + name="p", + config={"base_url": "http://p:9090"}, + ) + result = await adapter.fetch(service, "gauge", {"promql": ""}) + assert result == {"error": "promql is required"} + + +@pytest.mark.asyncio +async def test_prometheus_mean_adapter_computes_average(): + """SC-112: mean kind averages non-null values over the window.""" + from media_library_viewer_api.widgets.sources import PrometheusWidgetSource + + adapter = PrometheusWidgetSource() + service = ServiceRecord( + id="s", + service_type="prometheus", + name="p", + config={"base_url": "http://p:9090", "timeout_seconds": 5}, + ) + payload = SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: { + "data": { + "result": [ + { + "metric": {"__name__": "cpu"}, + "values": [[100, "1.0"], [130, "2.0"], [160, "3.0"]], + } + ] + } + }, + ) + with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload): + result = await adapter.fetch(service, "mean", {"promql": "cpu", "window": "1h"}) + assert result["value"] == 2.0 + assert result["unit"] is None + + +@pytest.mark.asyncio +async def test_prometheus_mean_adapter_rejects_multi_series(): + """SC-114: mean must be scalar-only; multi-series returns error.""" + from media_library_viewer_api.widgets.sources import PrometheusWidgetSource + + adapter = PrometheusWidgetSource() + service = ServiceRecord( + id="s", + service_type="prometheus", + name="p", + config={"base_url": "http://p:9090"}, + ) + payload = SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: { + "data": { + "result": [ + {"metric": {"instance": "a"}, "values": [[100, "1"]]}, + {"metric": {"instance": "b"}, "values": [[100, "2"]]}, + ] + } + }, + ) + with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload): + result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"}) + assert "error" in result + assert "single-series" in result["error"].lower() + + +@pytest.mark.asyncio +async def test_prometheus_mean_adapter_skips_nan_values(): + """SC-112: NaN / Inf values are excluded from the mean computation.""" + from media_library_viewer_api.widgets.sources import PrometheusWidgetSource + + adapter = PrometheusWidgetSource() + service = ServiceRecord( + id="s", + service_type="prometheus", + name="p", + config={"base_url": "http://p:9090"}, + ) + payload = SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: { + "data": { + "result": [ + { + "metric": {}, + "values": [[100, "2.0"], [130, "NaN"], [160, "4.0"]], + } + ] + } + }, + ) + with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload): + result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"}) + # (2.0 + 4.0) / 2 = 3.0 (NaN excluded) + assert result["value"] == 3.0 + + +@pytest.mark.asyncio +async def test_prometheus_mean_adapter_requires_promql(): + from media_library_viewer_api.widgets.sources import PrometheusWidgetSource + + adapter = PrometheusWidgetSource() + service = ServiceRecord( + id="s", + service_type="prometheus", + name="p", + config={"base_url": "http://p:9090"}, + ) + result = await adapter.fetch(service, "mean", {"promql": ""}) + assert result == {"error": "promql is required"} diff --git a/frontend/src/integrations/registry.test.ts b/frontend/src/integrations/registry.test.ts index d80adfc..4b3a558 100644 --- a/frontend/src/integrations/registry.test.ts +++ b/frontend/src/integrations/registry.test.ts @@ -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", diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts index 014e3ac..2b7b984 100644 --- a/frontend/src/integrations/registry.ts +++ b/frontend/src/integrations/registry.ts @@ -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 = { }, 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: { diff --git a/frontend/src/widgets/PrometheusGaugeWidget.tsx b/frontend/src/widgets/PrometheusGaugeWidget.tsx new file mode 100644 index 0000000..79a2f39 --- /dev/null +++ b/frontend/src/widgets/PrometheusGaugeWidget.tsx @@ -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 ( + + {isLoading && !data ? ( + + ) : data?.error ? ( + + {data.error} + + ) : gauge ? ( +
+ + ({ + name: "track", + pct: c.pct, + fill: c.fill, + })), + { name: "value", pct: valuePct, fill: valueColor }, + ]} + startAngle={90} + endAngle={-270} + > + + + + +
+ + {formatValue(value, gauge.unit)} + + {hasBands ? ( + + warn {formatValue(warnAt!, gauge.unit)} · crit{" "} + {formatValue(critAt!, gauge.unit)} + + ) : null} +
+
+ ) : ( + + No data. Check your PromQL query. + + )} +
+ ); +} diff --git a/frontend/src/widgets/PrometheusMeanWidget.tsx b/frontend/src/widgets/PrometheusMeanWidget.tsx new file mode 100644 index 0000000..bed8a7a --- /dev/null +++ b/frontend/src/widgets/PrometheusMeanWidget.tsx @@ -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 ( + + {isLoading && !data ? ( + + ) : data?.error ? ( + + {data.error} + + ) : mean ? ( +
+ + {formatMean(mean.value, mean.unit)} + +
+ ) : ( + + No data. Check your PromQL query. + + )} +
+ ); +} diff --git a/frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx b/frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx new file mode 100644 index 0000000..96a2890 --- /dev/null +++ b/frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx @@ -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); +} + +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(); + 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(); + 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(); + expect(screen.getByText(/single-series/i)).toBeInTheDocument(); + }); + + it("shows empty state when no data", () => { + mockData(null); + render(); + expect(screen.getByText(/No data/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx b/frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx new file mode 100644 index 0000000..0f724dd --- /dev/null +++ b/frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx @@ -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); +} + +describe("PrometheusMeanWidget", () => { + it("renders the mean value with unit", () => { + mockData({ value: 23.5, unit: "%" }); + render(); + 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(); + expect(screen.getByText(/1500/)).toBeInTheDocument(); + }); + + it("shows error Alert on error", () => { + mockData(null, "Mean requires a single-series query"); + render(); + expect(screen.getByText(/single-series/i)).toBeInTheDocument(); + }); + + it("shows empty state when no data", () => { + mockData(null); + render(); + expect(screen.getByText(/No data/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/widgets/index.ts b/frontend/src/widgets/index.ts index 3acc010..2f1c008 100644 --- a/frontend/src/widgets/index.ts +++ b/frontend/src/widgets/index.ts @@ -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"; diff --git a/openspec/changes/prometheus-direct-charting/tasks.md b/openspec/changes/prometheus-direct-charting/tasks.md index 8f76a37..48baa91 100644 --- a/openspec/changes/prometheus-direct-charting/tasks.md +++ b/openspec/changes/prometheus-direct-charting/tasks.md @@ -113,61 +113,61 @@ This ordering ensures the chart capability is proven against Prometheus before t **Satisfies:** SC-109, SC-110, SC-111, SC-112, SC-113, SC-114. -- [ ] **2.1 Extract shared `_instant_query` helper + add `_fetch_gauge` to `PrometheusWidgetSource`** +- [x] **2.1 Extract shared `_instant_query` helper + add `_fetch_gauge` to `PrometheusWidgetSource`** - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify) - Lines: ~40 - Dependencies: Slice 1 (1.3) - Details: Extract the instant-query HTTP call from the existing `metric` path into a private `_instant_query(base_url, timeout, promql) -> dict` returning `{"result": [...]}` or `{"error": ...}`. Refactor the `metric` path to use it (behavior unchanged). Add `_fetch_gauge(self, base_url, timeout, config)` using `_instant_query`: assert `len(result) == 1` (scalar-only, SC-111); parse `float(result[0]["value"][1])`; return `{"value": float, "warn_at": config.get("warn_at"), "crit_at": config.get("crit_at"), "min": config.get("min"), "max": config.get("max"), "unit": config.get("unit")}`. Dispatch `widget_kind == "gauge"` in `.fetch()`. -- [ ] **2.2 Add `_fetch_mean` to `PrometheusWidgetSource`** +- [x] **2.2 Add `_fetch_mean` to `PrometheusWidgetSource`** - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify) - Lines: ~30 - Dependencies: 2.1, Slice 1 (1.3 for `_range_query`) - Details: Add `_fetch_mean(self, base_url, timeout, config)` using the shared `_range_query` from S1. Assert `len(result) == 1` (scalar-only, SC-114). Collect non-null numeric values from the single series; compute arithmetic mean; return `{"value": mean, "unit": config.get("unit")}`. If no numeric samples → `{"error": "..."}`. Dispatch `widget_kind == "mean"` in `.fetch()`. -- [ ] **2.3 Declare `gauge` + `mean` widget kinds in Prometheus integration** +- [x] **2.3 Declare `gauge` + `mean` widget kinds in Prometheus integration** - Files: `backend/src/media_library_viewer_api/integrations/prometheus.py` (modify) - Lines: ~25 - Dependencies: 2.1, 2.2 - Details: Add `PrometheusGaugeWidgetConfig` (`promql: str`, `warn_at: float|None`, `crit_at: float|None`, `min: float|None`, `max: float|None`, `unit: str|None`) and `PrometheusMeanWidgetConfig` (`promql: str`, `window: str = "1h"`, `unit: str|None`). Add `widget_kind(...)` entries: `gauge` (refresh 30s), `mean` (refresh 60s). -- [ ] **2.4 Add backend tests for gauge + mean adapters** +- [x] **2.4 Add backend tests for gauge + mean adapters** - Files: `backend/tests/test_widgets.py` (modify) or `backend/tests/test_prometheus_range.py` (modify) - Lines: ~60 - Dependencies: 2.1, 2.2 - Details: Mock `requests.get` for gauge: instant query returning 1 series → assert `{value, ...}` shape; returning 2 series → assert `{"error": ...}` (SC-111). Mock for mean: range query returning 1 series with known values `[1.0, 2.0, 3.0]` → assert mean `2.0`; returning 2 series → assert `{"error": ...}` (SC-114). Test timeout/RequestException → `{"error": ...}`. -- [ ] **2.5 Create `PrometheusGaugeWidget` component** +- [x] **2.5 Create `PrometheusGaugeWidget` component** - Files: `frontend/src/widgets/PrometheusGaugeWidget.tsx` (new) - Lines: ~90 - Dependencies: Slice 1 (1.5 for widget pattern) - Details: Render via recharts `RadialBarChart` (no new dep; SC-110). Threshold bands: three stacked `RadialBar` track cells (green `0→warn`, amber `warn→crit`, red `crit→max`) + a value cell. When `warn_at`/`crit_at` absent → single neutral-color track. `min`/`max` default to `0`/`max(value, 1)`. Reuse `SectionCard` + `Alert`/`Skeleton` for loading/error states. Consume `data?.data?.value`, `warn_at`, etc. off `useWidgetData`. -- [ ] **2.6 Create `PrometheusMeanWidget` component** +- [x] **2.6 Create `PrometheusMeanWidget` component** - Files: `frontend/src/widgets/PrometheusMeanWidget.tsx` (new) - Lines: ~50 - Dependencies: Slice 1 - Details: Single-value display reusing the `MetricCard` pattern (big number + optional `unit` suffix + subtext "mean over last {window}"). Loading/error/empty via `Skeleton`/`Alert`. No charting library — it's a number (SC-112). -- [ ] **2.7 Create gauge + mean frontend tests** +- [x] **2.7 Create gauge + mean frontend tests** - Files: `frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx` (new), `frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx` (new) - Lines: ~60 - Dependencies: 2.5, 2.6 - Details: Each covers loading, error, and rendered-data case (SC-125). Gauge test: render with bands (`warn_at`/`crit_at` set) and without (single color). Mean test: render with `value` + `unit`. -- [ ] **2.8 Add gauge + mean bindings to frontend registry** +- [x] **2.8 Add gauge + mean bindings to frontend registry** - Files: `frontend/src/integrations/registry.ts` (modify) - Lines: ~35 - Dependencies: 2.5, 2.6 - Details: Import `PrometheusGaugeWidget` + `PrometheusMeanWidget`. Add `gauge` (refresh 30s, configSchema with `promql`, `warn_at`, `crit_at`, `min`, `max`, `unit`) and `mean` (refresh 60s, configSchema with `promql`, `window`, `unit`) entries to the `prometheus` binding's `widgets` array alongside `metric` and `chart`. -- [ ] **2.9 Update widgets barrel + registry tests** +- [x] **2.9 Update widgets barrel + registry tests** - Files: `frontend/src/widgets/index.ts` (modify), `frontend/src/integrations/registry.test.ts` (modify) - Lines: ~10 - Dependencies: 2.5, 2.6, 2.8 - Details: Export `PrometheusGaugeWidget` + `PrometheusMeanWidget`. Assert `prometheus` binding has `metric`, `chart`, `gauge`, `mean` (four kinds). -- [ ] **2.10 Verify Slice 2 (build + lint + test)** +- [x] **2.10 Verify Slice 2 (build + lint + test)** - Run: `cd backend && PYTHONPATH=src pytest tests/test_prometheus_range.py tests/test_widgets.py && cd ../frontend && npm run build && npm run lint` - Verify: gauge/mean adapter tests pass; frontend typechecks and lints; all four prometheus widget kinds resolve.