From 5dad98231f9b34d45dc4fd0c990425da57886d50 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 8 Jul 2026 21:51:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(prometheus-direct-charting):=20slice=201?= =?UTF-8?q?=20=E2=80=94=20prom=20range=20query=20+=20chart=20rebrand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../integrations/prometheus.py | 15 +++ .../widgets/prometheus_range.py | 97 ++++++++++++++++ .../widgets/sources.py | 52 ++++++++- backend/tests/test_prometheus_range.py | 106 ++++++++++++++++++ backend/tests/test_services.py | 2 +- backend/tests/test_widgets.py | 67 +++++++++++ frontend/src/integrations/registry.test.ts | 3 + frontend/src/integrations/registry.ts | 57 ++++------ ...rtWidget.tsx => PrometheusChartWidget.tsx} | 4 +- ...est.tsx => PrometheusChartWidget.test.tsx} | 14 +-- frontend/src/widgets/index.ts | 2 +- .../prometheus-direct-charting/tasks.md | 20 ++-- 12 files changed, 383 insertions(+), 56 deletions(-) create mode 100644 backend/src/media_library_viewer_api/widgets/prometheus_range.py create mode 100644 backend/tests/test_prometheus_range.py rename frontend/src/widgets/{GrafanaChartWidget.tsx => PrometheusChartWidget.tsx} (96%) rename frontend/src/widgets/__tests__/{GrafanaChartWidget.test.tsx => PrometheusChartWidget.test.tsx} (73%) diff --git a/backend/src/media_library_viewer_api/integrations/prometheus.py b/backend/src/media_library_viewer_api/integrations/prometheus.py index a862997..4d2e184 100644 --- a/backend/src/media_library_viewer_api/integrations/prometheus.py +++ b/backend/src/media_library_viewer_api/integrations/prometheus.py @@ -25,6 +25,13 @@ class PrometheusMetricWidgetConfig(WidgetConfigBase): promql: str +class PrometheusChartWidgetConfig(WidgetConfigBase): + """A PromQL range query rendered as a multi-series line chart (SC-101..SC-104).""" + + promql: str + window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS) + + DEFINITION = ServiceDefinition( service_type="prometheus", name="Prometheus", @@ -42,5 +49,13 @@ DEFINITION = ServiceDefinition( default_config={"promql": ""}, refresh_interval_ms=30_000, ), + widget_kind( + kind="chart", + name="Chart", + description="Multi-series line chart from a PromQL range query.", + model_cls=PrometheusChartWidgetConfig, + default_config={"promql": "", "window": "1h"}, + refresh_interval_ms=60_000, + ), ], ) diff --git a/backend/src/media_library_viewer_api/widgets/prometheus_range.py b/backend/src/media_library_viewer_api/widgets/prometheus_range.py new file mode 100644 index 0000000..aadef3c --- /dev/null +++ b/backend/src/media_library_viewer_api/widgets/prometheus_range.py @@ -0,0 +1,97 @@ +"""Shared helpers for Prometheus range queries. + +These two pieces were called out by the spec's downstream-notes as needing a +home: the step-derivation function (SC-104) and the series normalization helper +(SC-102). Keeping them in their own module makes them unit-testable in isolation +and reusable by the chart and mean widget paths (and, later, the in-service data +path of the service-storage-harness change) without ``sources.py`` growing +unbounded. + +``normalize_prometheus_matrix`` is a direct extraction of the metric-label → +readable-label rule that previously lived inside the Grafana datasource-proxy +path, retargeted at the native Prometheus ``/api/v1/query_range`` matrix shape so +users migrating a ``grafana/chart`` widget to ``prometheus/chart`` see identical +labels. +""" + +from __future__ import annotations + +from typing import Any + +#: Window presets (SC-108, SC-112). Users pick one of these rather than typing +#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds. +WINDOW_PRESETS: dict[str, int] = { + "1h": 3_600, + "6h": 21_600, + "24h": 86_400, + "7d": 604_800, +} + +#: Sentinel values Prometheus serialises for non-finite floats; map these to +#: ``None`` so the frontend renderer can skip them via ``connectNulls``. +_NON_NUMERIC = (None, "NaN", "+Inf", "-Inf") + + +def step_for_window(window_seconds: int, target_points: int = 200) -> int: + """Derive a scrape ``step`` for a window that yields ~``target_points`` samples. + + Clamped to a minimum of 15 seconds so Prometheus does not reject + sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires + the resulting point count to land in the 100–300 band; with + ``target_points=200`` every preset yields 200 points. + """ + return max(15, round(window_seconds / target_points)) + + +def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the + ``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart + renderer consumes. + + Label rule (matches the removed Grafana path so labels are stable on + migration): + + 1. Drop ``__name__`` (and any other ``__``-prefixed) metric labels. + 2. If labels remain, join them as ``k=v k=v`` (sorted for determinism). + 3. Else fall back to ``"value"``. + 4. Dedup label collisions with a `` (n)`` suffix. + """ + series: list[dict[str, Any]] = [] + seen: dict[str, int] = {} + for entry in result: + metric = entry.get("metric") or {} + values = entry.get("values") or [] + parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(k).startswith("__")] + label = " ".join(parts) if parts else "value" + if label in seen: + seen[label] += 1 + label = f"{label} ({seen[label]})" + else: + seen[label] = 0 + points: list[dict[str, Any]] = [] + for ts, raw in values: + t = _safe_int(ts) + if t is None: + # Drop samples whose timestamp is unusable rather than raising. + continue + points.append({"t": t, "v": _safe_float(raw)}) + series.append({"label": label, "points": points}) + return series + + +def _safe_float(raw: Any) -> float | None: + """Best-effort float conversion; Prometheus sentinels and junk → ``None``.""" + if raw in _NON_NUMERIC: + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _safe_int(ts: Any) -> int | None: + """Best-effort int conversion for a Prometheus timestamp.""" + try: + return int(float(ts)) + except (TypeError, ValueError): + return None diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index b4b2e79..dd7ca0e 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio import logging +import time from dataclasses import dataclass, field from typing import Any, Protocol @@ -25,6 +26,11 @@ from media_library_viewer_api.domain.dashboard import ( from media_library_viewer_api.integrations.alertmanager import summarize_alerts from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store from media_library_viewer_api.services.task_runner import run_saved_task +from media_library_viewer_api.widgets.prometheus_range import ( + WINDOW_PRESETS, + normalize_prometheus_matrix, + step_for_window, +) logger = logging.getLogger(__name__) @@ -209,7 +215,7 @@ class GrafanaWidgetSource: class PrometheusWidgetSource: - """Run a PromQL instant query against a Prometheus service.""" + """Run PromQL queries against a Prometheus service (instant + range).""" async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: try: @@ -217,6 +223,9 @@ class PrometheusWidgetSource: return {"error": "Prometheus widget is missing its service"} base_url = str(service.config.get("base_url") or "").rstrip("/") timeout = int(service.config.get("timeout_seconds") or 10) + if widget_kind == "chart": + return await self._fetch_chart(base_url, timeout, config) + # Default: instant-query metric path (unchanged). promql = config.get("promql") if not promql: return {"error": "promql is required"} @@ -242,6 +251,47 @@ class PrometheusWidgetSource: logger.exception("prometheus adapter failed") return {"error": f"Prometheus query failed: {exc}"} + async def _range_query(self, base_url: str, timeout: int, promql: str, window: int) -> dict[str, Any]: + """Run a Prometheus ``/api/v1/query_range`` over a window (seconds). + + Shared by the ``chart`` (SC-101) and ``mean`` widget kinds. Returns + ``{"matrix": result}`` on success or ``{"error": str}`` (never raises, + per SC-103). + """ + step = step_for_window(window) + end = int(time.time()) + start = end - window + try: + response = await asyncio.wait_for( + asyncio.to_thread( + requests.get, + f"{base_url}/api/v1/query_range", + params={"query": promql, "start": start, "end": end, "step": step}, + 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 range query failed") + return {"error": f"Prometheus query failed: {exc}"} + result = payload.get("data", {}).get("result", []) + return {"matrix": result} + + 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") + 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 + return {"series": normalize_prometheus_matrix(raw["matrix"])} + class AlertmanagerWidgetSource: """Fetch firing alerts from an Alertmanager service and summarize them.""" diff --git a/backend/tests/test_prometheus_range.py b/backend/tests/test_prometheus_range.py new file mode 100644 index 0000000..59593cc --- /dev/null +++ b/backend/tests/test_prometheus_range.py @@ -0,0 +1,106 @@ +"""Unit tests for the shared Prometheus range-query helpers (SC-101..SC-104).""" + +from __future__ import annotations + +import pytest + +from media_library_viewer_api.widgets.prometheus_range import ( + WINDOW_PRESETS, + normalize_prometheus_matrix, + step_for_window, +) + + +class TestStepForWindow: + """SC-104: every preset must yield 100–300 points.""" + + @pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS)) + def test_presets_yield_in_band_point_counts(self, preset: str) -> None: + window = WINDOW_PRESETS[preset] + step = step_for_window(window) + # Clamped minimum. + assert step >= 15 + point_count = window // step + assert 100 <= point_count <= 300, f"{preset}: {point_count} points (step={step})" + + def test_floor_of_fifteen_seconds(self) -> None: + # A tiny window that would otherwise produce a sub-15s step is clamped. + assert step_for_window(60) == 15 + + def test_custom_target_points(self) -> None: + # Targeting 100 points for 1h yields step 36 (3600/100). + assert step_for_window(3_600, target_points=100) == 36 + + +class TestNormalizePrometheusMatrix: + """SC-102: label rule + null handling + dedup.""" + + def test_empty_matrix(self) -> None: + assert normalize_prometheus_matrix([]) == [] + + def test_drops_dunder_labels_and_joins(self) -> None: + result = [ + { + "metric": {"__name__": "node_cpu_seconds_total", "instance": "host:9100", "mode": "idle"}, + "values": [[1_700_000_000, "12.5"], [1_700_000_030, "13.0"]], + } + ] + out = normalize_prometheus_matrix(result) + assert len(out) == 1 + assert out[0]["label"] == "instance=host:9100 mode=idle" + assert out[0]["points"] == [ + {"t": 1_700_000_000, "v": 12.5}, + {"t": 1_700_000_030, "v": 13.0}, + ] + + def test_falls_back_to_value_when_no_labels(self) -> None: + result = [{"metric": {}, "values": [[100, "1"]]}] + out = normalize_prometheus_matrix(result) + assert out[0]["label"] == "value" + + def test_dedup_collisions_with_suffix(self) -> None: + # Two series with identical visible labels get a "(1)" suffix on the 2nd. + result = [ + {"metric": {"job": "x"}, "values": [[1, "1"]]}, + {"metric": {"job": "x"}, "values": [[1, "2"]]}, + ] + out = normalize_prometheus_matrix(result) + labels = [s["label"] for s in out] + assert labels == ["job=x", "job=x (1)"] + + def test_non_numeric_sentinels_become_none(self) -> None: + result = [ + { + "metric": {"job": "x"}, + "values": [ + [1, "NaN"], + [2, "+Inf"], + [3, "-Inf"], + [4, "3.5"], + ], + } + ] + out = normalize_prometheus_matrix(result) + assert out[0]["points"] == [ + {"t": 1, "v": None}, + {"t": 2, "v": None}, + {"t": 3, "v": None}, + {"t": 4, "v": 3.5}, + ] + + def test_malformed_values_are_ignored_not_raised(self) -> None: + result = [ + { + "metric": {"job": "x"}, + "values": [ + [1, "3.5"], + ["not-a-ts", "9"], # unusable timestamp → dropped + [3, "junk-value"], # unparseable value → v: None + ], + } + ] + out = normalize_prometheus_matrix(result) + assert out[0]["points"] == [ + {"t": 1, "v": 3.5}, + {"t": 3, "v": None}, + ] diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 7a6d147..8487d0d 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"} + assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart"} 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 f82ed0b..5b0787c 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -667,6 +667,73 @@ async def test_grafana_adapter_chart_handles_http_failure(): assert "failed" in result["error"].lower() +@pytest.mark.asyncio +async def test_prometheus_chart_adapter_runs_range_query(): + """SC-101: chart kind hits /api/v1/query_range and returns {series}.""" + 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__": "up", "instance": "h:9100"}, + "values": [[100, "1"], [130, "1"]], + } + ] + } + }, + ) + with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get: + result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"}) + + # query_range endpoint + window-derived start/end/step params. + call = mock_get.call_args + assert call.args[0].endswith("/api/v1/query_range") + params = call.kwargs["params"] + assert params["query"] == "up" + assert {"start", "end", "step"}.issubset(params) + # {series} shape with the shared normalization (label drops __name__). + assert "series" in result + assert result["series"][0]["label"] == "instance=h:9100" + assert result["series"][0]["points"] == [{"t": 100, "v": 1.0}, {"t": 130, "v": 1.0}] + + +@pytest.mark.asyncio +async def test_prometheus_chart_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, "chart", {"promql": ""}) + assert result == {"error": "promql is required"} + + +@pytest.mark.asyncio +async def test_prometheus_chart_adapter_degrades_on_http_error(): + """SC-103: a connection error returns {error} rather than raising.""" + import requests as req_mod + + 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": 2} + ) + with patch("media_library_viewer_api.widgets.sources.requests.get", side_effect=req_mod.ConnectionError("refused")): + result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"}) + assert "error" in result + assert "failed" in result["error"].lower() + + @pytest.mark.asyncio async def test_jellyfin_now_playing_filters_active_sessions(): """now_playing should exclude idle (no NowPlayingItem) and paused sessions.""" diff --git a/frontend/src/integrations/registry.test.ts b/frontend/src/integrations/registry.test.ts index 8cb2ef5..d80adfc 100644 --- a/frontend/src/integrations/registry.test.ts +++ b/frontend/src/integrations/registry.test.ts @@ -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([ diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts index 010b984..014e3ac 100644 --- a/frontend/src/integrations/registry.ts +++ b/frontend/src/integrations/registry.ts @@ -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 = { }, 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 = { }, 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: { diff --git a/frontend/src/widgets/GrafanaChartWidget.tsx b/frontend/src/widgets/PrometheusChartWidget.tsx similarity index 96% rename from frontend/src/widgets/GrafanaChartWidget.tsx rename to frontend/src/widgets/PrometheusChartWidget.tsx index 86f1179..5931f37 100644 --- a/frontend/src/widgets/GrafanaChartWidget.tsx +++ b/frontend/src/widgets/PrometheusChartWidget.tsx @@ -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({ ) : ( - No data. Check your query and datasource_uid in the widget config. + No data. Check your PromQL query and window in the widget config. )} diff --git a/frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx b/frontend/src/widgets/__tests__/PrometheusChartWidget.test.tsx similarity index 73% rename from frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx rename to frontend/src/widgets/__tests__/PrometheusChartWidget.test.tsx index fadab2f..ee5f2bd 100644 --- a/frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx +++ b/frontend/src/widgets/__tests__/PrometheusChartWidget.test.tsx @@ -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); } -describe("GrafanaChartWidget", () => { +describe("PrometheusChartWidget", () => { it("renders a chart with series data", () => { mockData({ series: [ @@ -42,20 +42,20 @@ describe("GrafanaChartWidget", () => { }, ], }); - render(); + render(); // 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(); - expect(screen.getByText(/api_key is required/i)).toBeInTheDocument(); + mockData(null, "promql is required"); + render(); + expect(screen.getByText(/promql is required/i)).toBeInTheDocument(); }); it("shows empty state when no series", () => { mockData({ series: [] }); - render(); + render(); expect(screen.getByText(/No data/i)).toBeInTheDocument(); }); }); diff --git a/frontend/src/widgets/index.ts b/frontend/src/widgets/index.ts index deebfa8..3acc010 100644 --- a/frontend/src/widgets/index.ts +++ b/frontend/src/widgets/index.ts @@ -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"; diff --git a/openspec/changes/prometheus-direct-charting/tasks.md b/openspec/changes/prometheus-direct-charting/tasks.md index e61b9bf..8f76a37 100644 --- a/openspec/changes/prometheus-direct-charting/tasks.md +++ b/openspec/changes/prometheus-direct-charting/tasks.md @@ -45,61 +45,61 @@ This ordering ensures the chart capability is proven against Prometheus before t **Satisfies:** SC-101, SC-102, SC-103, SC-104, SC-105, SC-106, SC-107, SC-108. -- [ ] **1.1 Create shared Prometheus range helpers module** +- [x] **1.1 Create shared Prometheus range helpers module** - Files: `backend/src/media_library_viewer_api/widgets/prometheus_range.py` (new) - Lines: ~60 - Dependencies: none - Details: Implement `WINDOW_PRESETS = {"1h": 3600, "6h": 21600, "24h": 86400, "7d": 604800}`, `step_for_window(window_seconds, target_points=200) -> int` returning `max(15, round(window_seconds / target_points))`, and `normalize_prometheus_matrix(result: list[dict]) -> list[dict]` that converts a Prometheus `/api/v1/query_range` `data.result` matrix into `{label, points:[{t:int, v:float|None}]}` series. Label rule: drop `__name__` from metric labels; join remaining as `k=v`; fall back to `"value"`; dedup collisions with `(n)` suffix. Null handling: `"NaN"`, `"+Inf"`, `"-Inf"`, `None` → `v: None`. -- [ ] **1.2 Add backend unit tests for range helpers** +- [x] **1.2 Add backend unit tests for range helpers** - Files: `backend/tests/test_prometheus_range.py` (new) - Lines: ~70 - Dependencies: 1.1 - Details: Test `step_for_window` for all four presets asserts result yields 100–300 points. Test `normalize_prometheus_matrix`: feed a two-entry sample matrix (one with `__name__`, one colliding label) → assert `{series}` shape, label dedup `(1)` suffix, null handling for `"NaN"` string. -- [ ] **1.3 Add `_fetch_chart` + shared range-query plumbing to `PrometheusWidgetSource`** +- [x] **1.3 Add `_fetch_chart` + shared range-query plumbing to `PrometheusWidgetSource`** - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify) - Lines: ~55 - Dependencies: 1.1 - Details: Import `WINDOW_PRESETS`, `step_for_window`, `normalize_prometheus_matrix` from `prometheus_range`. Add a private `_range_query(base_url, timeout, promql, window) -> dict` returning `{"matrix": result}` or `{"error": ...}` (shared by chart now and mean in S2). Add `_fetch_chart(self, base_url, timeout, config)` calling `_range_query` and returning `{"series": normalize_prometheus_matrix(matrix)}`. Dispatch `widget_kind == "chart"` in `.fetch()`. Existing `metric` path stays byte-for-byte unchanged. Errors (timeout, `RequestException`) → `{"error": str}`, never raise. -- [ ] **1.4 Declare `chart` widget kind in Prometheus integration** +- [x] **1.4 Declare `chart` widget kind in Prometheus integration** - Files: `backend/src/media_library_viewer_api/integrations/prometheus.py` (modify) - Lines: ~15 - Dependencies: 1.3 - Details: Add `PrometheusChartWidgetConfig(WidgetConfigBase)` with `promql: str` and `window: str = "1h"`. Add a `widget_kind(...)` entry for `chart` (refresh 60s, default config `{"promql": "", "window": "1h"}`). Leave `metric` kind untouched. -- [ ] **1.5 Rename `GrafanaChartWidget` → `PrometheusChartWidget`** +- [x] **1.5 Rename `GrafanaChartWidget` → `PrometheusChartWidget`** - Files: `frontend/src/widgets/GrafanaChartWidget.tsx` → `frontend/src/widgets/PrometheusChartWidget.tsx` (git mv) - Lines: ~5 changed (rename export, fix empty-state copy) - Dependencies: none - Details: `git mv` to preserve history. Rename exported function `GrafanaChartWidget` → `PrometheusChartWidget`. The recharts body (`LineChart`, `Line`, `XAxis`, `YAxis`, `CartesianGrid`, `Tooltip`, `ResponsiveContainer`, `mergeSeries`, `CHART_COLORS`, `formatTime`) is preserved unchanged (SC-106). Fix empty-state copy: "Check your query and datasource_uid" → "Check your PromQL query and window." -- [ ] **1.6 Rename chart widget test** +- [x] **1.6 Rename chart widget test** - Files: `frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx` → `frontend/src/widgets/__tests__/PrometheusChartWidget.test.tsx` (git mv) - Lines: ~10 changed (import path, component name, error-message assertion) - Dependencies: 1.5 - Details: `git mv`. Update import to `PrometheusChartWidget`. Update error-state assertion: the old Grafana-specific error string (`"Grafana api_key is required"`) → a Prom error string (e.g. `"promql is required"`). Keep loading + rendered-data test cases. -- [ ] **1.7 Rebind `chart` from grafana to prometheus in frontend registry** +- [x] **1.7 Rebind `chart` from grafana to prometheus in frontend registry** - Files: `frontend/src/integrations/registry.ts` (modify) - Lines: ~30 - Dependencies: 1.5 - Details: Import `PrometheusChartWidget`. Add a `chart` entry to the `prometheus` binding's `widgets` array (kind `chart`, refresh 60s, configSchema with `promql` + `window`). Remove the `chart` entry from the `grafana` binding's `widgets` array (leave `link` intact). Do NOT delete the `grafana` binding itself. -- [ ] **1.8 Update frontend widgets barrel export** +- [x] **1.8 Update frontend widgets barrel export** - Files: `frontend/src/widgets/index.ts` (modify) - Lines: ~2 - Dependencies: 1.5 - Details: Rename the `GrafanaChartWidget` export to `PrometheusChartWidget`. Leave `GrafanaLinkWidget` export intact. -- [ ] **1.9 Update registry tests for chart rebind** +- [x] **1.9 Update registry tests for chart rebind** - Files: `frontend/src/integrations/registry.test.ts` (modify) - Lines: ~15 - Dependencies: 1.7 - Details: Assert `prometheus` binding has `metric` + `chart` kinds. Assert `grafana` binding has only `link` (no `chart`). -- [ ] **1.10 Verify Slice 1 (build + lint + test)** +- [x] **1.10 Verify Slice 1 (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: helpers tests pass; existing widget tests pass (grafana link adapter still wired); frontend typechecks and lints; `prometheus/chart` widget resolves to `PrometheusChartWidget`.