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
+142 -107
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
import pytest
@@ -42,7 +43,7 @@ def client(tmp_path):
def _make_prometheus_service(client, name="Production Prometheus", **config_overrides):
config = {"base_url": "https://prometheus.example.com"}
config = {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"}
config.update(config_overrides)
return client.post(
"/api/services/instances",
@@ -302,7 +303,7 @@ def test_fetch_widget_service_disabled(client):
json={
"service_type": "prometheus",
"name": service["name"],
"config": {"base_url": "https://prometheus.example.com"},
"config": {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"},
"enabled": False,
},
)
@@ -469,39 +470,45 @@ def test_jellyfin_definition_has_now_playing_widget():
@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
"""GM-106: chart kind hits /api/ds/query and returns {series}."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = PrometheusWidgetSource()
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090", "timeout_seconds": 5},
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus", "timeout_seconds": 5},
secrets={"grafana_api_key": "key"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{
"metric": {"__name__": "up", "instance": "h:9100"},
"values": [[100, "1"], [130, "1"]],
}
]
"results": {
"A": {
"frames": [
{
"data": {"values": [[100, 130], [1.0, 1.0]]},
"schema": {
"fields": [
{"name": "Time"},
{"name": "Value", "labels": {"__name__": "up", "instance": "h:9100"}},
]
},
}
]
}
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload) as mock_post:
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__).
call = mock_post.call_args
assert call.args[0].endswith("/api/ds/query")
body = call.kwargs["json"]
assert body["queries"][0]["expr"] == "up"
assert body["queries"][0]["datasource"]["uid"] == "prometheus"
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}]
@@ -509,26 +516,43 @@ async def test_prometheus_chart_adapter_runs_range_query():
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
from media_library_viewer_api.widgets.sources import MetricSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090"})
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
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."""
"""GM-103: a connection error returns {error} rather than raising."""
import requests as req_mod
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
from media_library_viewer_api.widgets.sources import MetricSource
adapter = PrometheusWidgetSource()
adapter = MetricSource()
service = ServiceRecord(
id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090", "timeout_seconds": 2}
id="s",
service_type="prometheus",
name="p",
config={
"grafana_url": "http://grafana:3000",
"datasource_uid": "prometheus",
"timeout_seconds": 2,
},
secrets={"grafana_api_key": "key"},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", side_effect=req_mod.ConnectionError("refused")):
with patch(
"media_library_viewer_api.widgets.sources.requests.post",
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()
@@ -632,7 +656,7 @@ def test_widget_reference_lifecycle(widget_ref_client):
{
"service_type": "prometheus",
"name": "Prometheus",
"config": {"base_url": "https://prometheus.example.com"},
"config": {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"},
"secrets": {"api_key": "tok"},
"enabled": True,
},
@@ -690,7 +714,7 @@ def test_widget_reference_detach(widget_ref_client):
{
"service_type": "prometheus",
"name": "Prometheus",
"config": {"base_url": "https://prometheus.example.com"},
"config": {"grafana_url": "https://grafana.example.com", "datasource_uid": "prometheus"},
"secrets": {"api_key": "tok"},
"enabled": True,
},
@@ -794,46 +818,57 @@ def test_widget_reference_update_sort_order(widget_ref_client):
# ---------------------------------------------------------------------------
# Prometheus gauge + mean adapter tests (SC-109..SC-114)
# Prometheus gauge + mean adapter tests (GM-107..GM-108)
# ---------------------------------------------------------------------------
def _grafana_single_frame(values, labels=None, display_name=None):
"""Build a Grafana /api/ds/query frames response for a single series."""
field: dict[str, Any] = {"name": "Value"}
if labels:
field["labels"] = labels
if display_name:
field["config"] = {"displayName": display_name}
return {
"results": {
"A": {
"frames": [
{
"data": {"values": values},
"schema": {"fields": [{"name": "Time"}, field]},
}
]
}
}
}
@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
"""GM-107: gauge kind hits /api/ds/query and returns {value, thresholds}."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = PrometheusWidgetSource()
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090", "timeout_seconds": 5},
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus", "timeout_seconds": 5},
secrets={"grafana_api_key": "key"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{"metric": {"__name__": "cpu"}, "value": [100, "0.75"]},
]
}
},
json=lambda: _grafana_single_frame([[100], [0.75]]),
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload) as mock_post:
result = await adapter.fetch(
service,
"gauge",
{
"promql": "cpu_usage",
"warn_at": 0.8,
"crit_at": 0.95,
"unit": "%",
},
{"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"
call = mock_post.call_args
assert call.args[0].endswith("/api/ds/query")
assert call.kwargs["json"]["queries"][0]["expr"] == "cpu_usage"
assert result["value"] == 0.75
assert result["warn_at"] == 0.8
assert result["crit_at"] == 0.95
@@ -842,28 +877,31 @@ async def test_prometheus_gauge_adapter_returns_scalar():
@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
"""GM-107: gauge must be scalar-only; multi-series returns error."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = PrometheusWidgetSource()
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{"metric": {"instance": "a"}, "value": [100, "1"]},
{"metric": {"instance": "b"}, "value": [100, "2"]},
]
"results": {
"A": {
"frames": [
{"data": {"values": [[100], [1.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
{"data": {"values": [[100], [2.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
]
}
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
result = await adapter.fetch(service, "gauge", {"promql": "up"})
assert "error" in result
assert "single-series" in result["error"].lower()
@@ -871,14 +909,15 @@ async def test_prometheus_gauge_adapter_rejects_multi_series():
@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
from media_library_viewer_api.widgets.sources import MetricSource
adapter = PrometheusWidgetSource()
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
result = await adapter.fetch(service, "gauge", {"promql": ""})
assert result == {"error": "promql is required"}
@@ -886,30 +925,22 @@ async def test_prometheus_gauge_adapter_requires_promql():
@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
"""GM-108: mean kind averages non-null values over the window."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = PrometheusWidgetSource()
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090", "timeout_seconds": 5},
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus", "timeout_seconds": 5},
secrets={"grafana_api_key": "key"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{
"metric": {"__name__": "cpu"},
"values": [[100, "1.0"], [130, "2.0"], [160, "3.0"]],
}
]
}
},
json=lambda: _grafana_single_frame([[100, 130, 160], [1.0, 2.0, 3.0]]),
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "cpu", "window": "1h"})
assert result["value"] == 2.0
assert result["unit"] is None
@@ -917,28 +948,31 @@ async def test_prometheus_mean_adapter_computes_average():
@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
"""GM-108: mean must be scalar-only; multi-series returns error."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = PrometheusWidgetSource()
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{"metric": {"instance": "a"}, "values": [[100, "1"]]},
{"metric": {"instance": "b"}, "values": [[100, "2"]]},
]
"results": {
"A": {
"frames": [
{"data": {"values": [[100], [1.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
{"data": {"values": [[100], [2.0]]}, "schema": {"fields": [{}, {"name": "A"}]}},
]
}
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
assert "error" in result
assert "single-series" in result["error"].lower()
@@ -946,45 +980,46 @@ async def test_prometheus_mean_adapter_rejects_multi_series():
@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
"""GM-108: NaN values are excluded from the mean computation."""
from media_library_viewer_api.widgets.sources import MetricSource
adapter = PrometheusWidgetSource()
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
# Grafana frames shape with NaN — normalize_grafana_frames converts string "NaN" to None
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{
"metric": {},
"values": [[100, "2.0"], [130, "NaN"], [160, "4.0"]],
}
]
"results": {
"A": {
"frames": [
{"data": {"values": [[100, 130, 160], [2.0, "NaN", 4.0]]}, "schema": {"fields": [{}, {}]}}
]
}
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
with patch("media_library_viewer_api.widgets.sources.requests.post", 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
from media_library_viewer_api.widgets.sources import MetricSource
adapter = PrometheusWidgetSource()
adapter = MetricSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
result = await adapter.fetch(service, "mean", {"promql": ""})
assert result == {"error": "promql is required"}