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
@@ -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."""