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
+151
View File
@@ -6,6 +6,8 @@ import pytest
from media_library_viewer_api.widgets.prometheus_range import (
WINDOW_PRESETS,
_dedup_label,
normalize_grafana_frames,
normalize_prometheus_matrix,
step_for_window,
)
@@ -104,3 +106,152 @@ class TestNormalizePrometheusMatrix:
{"t": 1, "v": 3.5},
{"t": 3, "v": None},
]
class TestDedupLabel:
"""The shared label-dedup helper used by both normalizers (GM-104)."""
def test_first_use_returns_label_unchanged(self) -> None:
seen: dict[str, int] = {}
assert _dedup_label("value", seen) == "value"
assert seen == {"value": 0}
def test_collision_appends_suffix(self) -> None:
seen: dict[str, int] = {}
assert _dedup_label("job=x", seen) == "job=x"
assert _dedup_label("job=x", seen) == "job=x (1)"
assert _dedup_label("job=x", seen) == "job=x (2)"
def test_different_labels_dont_collide(self) -> None:
seen: dict[str, int] = {}
assert _dedup_label("a", seen) == "a"
assert _dedup_label("b", seen) == "b"
class TestNormalizeGrafanaFrames:
"""GM-104: frames normalizer recovered from 65bae95 + shared dedup."""
def test_empty_response(self) -> None:
assert normalize_grafana_frames({"results": {}}) == []
assert normalize_grafana_frames({}) == []
def test_single_frame_with_values(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[1000, 2000], [1.5, 2.5]]},
"schema": {"fields": [{"name": "Time"}, {"name": "Value"}]},
}
]
}
}
}
out = normalize_grafana_frames(raw)
assert len(out) == 1
assert out[0]["label"] == "Value"
assert out[0]["points"] == [
{"t": 1000, "v": 1.5},
{"t": 2000, "v": 2.5},
]
def test_display_name_takes_priority(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[100, 200], [0.75, 0.80]]},
"schema": {
"fields": [
{"name": "Time"},
{
"name": "Value",
"labels": {"instance": "host:9100"},
"config": {"displayName": "CPU Usage"},
},
]
},
}
]
}
}
}
out = normalize_grafana_frames(raw)
assert out[0]["label"] == "CPU Usage"
def test_labels_fallback_when_no_display_name(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[100], [1.0]]},
"schema": {
"fields": [
{"name": "Time"},
{
"name": "Value",
"labels": {"__name__": "up", "instance": "h:9100"},
},
]
},
}
]
}
}
}
out = normalize_grafana_frames(raw)
assert out[0]["label"] == "instance=h:9100"
def test_falls_back_to_value_when_no_metadata(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[100], [1.0]]},
"schema": {"fields": [{"name": "Time"}, {}]},
}
]
}
}
}
out = normalize_grafana_frames(raw)
assert out[0]["label"] == "value"
def test_dedup_collisions(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[100], [1.0]]},
"schema": {"fields": [{}, {"name": "Value"}]},
},
{
"data": {"values": [[100], [2.0]]},
"schema": {"fields": [{}, {"name": "Value"}]},
},
]
}
}
}
out = normalize_grafana_frames(raw)
labels = [s["label"] for s in out]
assert labels == ["Value", "Value (1)"]
def test_skips_frames_with_insufficient_values(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{"data": {"values": [[100]]}, "schema": {"fields": []}},
{"data": {"values": [[100], [1.0]]}, "schema": {"fields": [{}, {}]}},
]
}
}
}
out = normalize_grafana_frames(raw)
assert len(out) == 1