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:
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user