feat(grafana-metric-gateway): slice 1 — backend gateway transport

Route all prometheus widget queries through Grafana /api/ds/query instead of
direct Prom HTTP. PrometheusConfig: drop base_url, add grafana_url +
datasource_uid; secret grafana_api_key (required). PrometheusWidgetSource →
MetricSource with _gateway_query POST method. normalize_grafana_frames
recovered from 65bae95 + shared _dedup_label helper. Gateway-path status
check. Startup old-config validation. CHANGELOG migration note. All adapter
tests rewritten for POST /api/ds/query + Grafana frames mock. Backend: 331
pytest pass, ruff clean. Frontend: build green (unchanged in S1).
This commit is contained in:
Developer
2026-07-09 21:24:44 +00:00
parent 798196ffc7
commit df80c68f89
10 changed files with 573 additions and 250 deletions
@@ -30,7 +30,8 @@ from media_library_viewer_api.services.settings_store import SettingsStore, get_
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,
normalize_grafana_frames,
normalize_prometheus_matrix, # noqa: F401 — kept for future direct_url path (design decision 5)
step_for_window,
)
@@ -104,113 +105,118 @@ class StaticWidgetSource:
# ---------------------------------------------------------------------------
class PrometheusWidgetSource:
"""Run PromQL queries against a Prometheus service (instant + range)."""
class MetricSource:
"""Run PromQL queries through a Grafana gateway (``/api/ds/query``)."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "Prometheus widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/")
grafana_url = str(service.config.get("grafana_url") or "").rstrip("/")
api_key = str(service.secrets.get("grafana_api_key") or "")
datasource_uid = str(service.config.get("datasource_uid") or "prometheus")
timeout = int(service.config.get("timeout_seconds") or 10)
if widget_kind == "chart":
return await self._fetch_chart(base_url, timeout, config)
return await self._fetch_chart(grafana_url, api_key, datasource_uid, timeout, config)
if widget_kind == "gauge":
return await self._fetch_gauge(base_url, timeout, config)
return await self._fetch_gauge(grafana_url, api_key, datasource_uid, timeout, config)
if widget_kind == "mean":
return await self._fetch_mean(base_url, timeout, config)
# Default: instant-query metric path (unchanged).
raw = await self._instant_query(base_url, timeout, config.get("promql", ""))
return raw
return await self._fetch_mean(grafana_url, api_key, datasource_uid, timeout, config)
# Default: instant-query metric path.
return await self._fetch_metric(grafana_url, api_key, datasource_uid, timeout, config)
except Exception as exc:
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).
async def _gateway_query(
self,
grafana_url: str,
api_key: str,
datasource_uid: str,
timeout: int,
promql: str,
window_seconds: int | None = None,
max_data_points: int = 200,
) -> dict[str, Any]:
"""POST ``{grafana_url}/api/ds/query``; return raw Grafana JSON or ``{error}``.
Shared by the ``chart`` (SC-101) and ``mean`` widget kinds. Returns
``{"matrix": result}`` on success or ``{"error": str}`` (never raises,
per SC-103).
- ``window_seconds=None`` → instant mapping (``from=now-1m, maxDataPoints=1``).
- ``window_seconds=<N>`` → range query (``from=now-Ns``, step derived).
"""
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,
),
if not grafana_url:
return {"error": "grafana_url is required"}
if not api_key:
return {"error": "grafana_api_key is required"}
step = step_for_window(window_seconds) if window_seconds else 15
interval_ms = step * 1000
body = {
"queries": [
{
"datasource": {"uid": datasource_uid, "type": "prometheus"},
"expr": promql,
"format": "time_series",
"intervalMs": interval_ms,
"maxDataPoints": 1 if window_seconds is None else max_data_points,
"refId": "A",
}
],
"from": f"now-{window_seconds or 60}s" if window_seconds else "now-1m",
"to": "now",
}
def _do_post() -> dict[str, Any]:
resp = requests.post(
f"{grafana_url}/api/ds/query",
json=body,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
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}
resp.raise_for_status()
return resp.json()
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()
return await asyncio.wait_for(asyncio.to_thread(_do_post), timeout=timeout)
except asyncio.TimeoutError:
return {"error": "Prometheus query timed out"}
return {"error": "Grafana 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", {})}
logger.exception("grafana gateway query failed")
return {"error": f"Grafana query failed: {exc}"}
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)."""
async def _fetch_chart(
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
) -> dict[str, Any]:
"""Range query → ``{series}`` for the chart widget (GM-106)."""
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)
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=window)
if "error" in raw:
return raw
return {"series": normalize_prometheus_matrix(raw["matrix"])}
return {"series": normalize_grafana_frames(raw)}
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).
async def _fetch_gauge(
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
) -> dict[str, Any]:
"""Instant query → scalar for the gauge widget (GM-107).
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.
Scalar-only: a multi-series query returns an error. Threshold config is
passed through for the frontend renderer.
"""
raw = await self._instant_query(base_url, timeout, config.get("promql") or "")
promql = config.get("promql") or ""
if not promql:
return {"error": "promql is required"}
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=None)
if "error" in raw:
return raw
result = raw["result"].get("result", [])
if len(result) != 1:
series = normalize_grafana_frames(raw)
if len(series) != 1:
return {"error": "Gauge requires a single-series query; refine your PromQL"}
try:
value = float(result[0]["value"][1])
except (KeyError, IndexError, ValueError, TypeError):
points = series[0]["points"]
if not points:
return {"error": "Gauge query returned no scalar value"}
value = points[-1]["v"]
if value is None:
return {"error": "Gauge query returned no scalar value"}
return {
"value": value,
@@ -221,36 +227,46 @@ class PrometheusWidgetSource:
"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).
async def _fetch_mean(
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
) -> dict[str, Any]:
"""Range query → client-side mean for the mean widget (GM-108).
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).
Runs a gateway range query 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.
"""
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)
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=window)
if "error" in raw:
return raw
result = raw["matrix"]
if len(result) != 1:
series = normalize_grafana_frames(raw)
if len(series) != 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
nums = [p["v"] for p in series[0]["points"] if p["v"] is not None]
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")}
return {"value": sum(nums) / len(nums), "unit": config.get("unit")}
async def _fetch_metric(
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
) -> dict[str, Any]:
"""Instant query → ``{result}`` for the metric widget (GM-109).
Returns ``{result: [{label, points}]}`` — the normalized series shape.
The frontend ``PrometheusMetricWidget`` renders the last point of each
series.
"""
promql = config.get("promql") or ""
if not promql:
return {"error": "promql is required"}
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=None)
if "error" in raw:
return raw
return {"result": normalize_grafana_frames(raw)}
class AlertmanagerWidgetSource:
@@ -437,7 +453,7 @@ class QbittorrentWidgetSource:
# ---------------------------------------------------------------------------
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
"prometheus": PrometheusWidgetSource(),
"prometheus": MetricSource(),
"qbittorrent": QbittorrentWidgetSource(),
"alertmanager": AlertmanagerWidgetSource(),
"jellyfin": JellyfinWidgetSource(),