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
+67
View File
@@ -667,6 +667,73 @@ async def test_grafana_adapter_chart_handles_http_failure():
assert "failed" in result["error"].lower()
@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
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090", "timeout_seconds": 5},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{
"metric": {"__name__": "up", "instance": "h:9100"},
"values": [[100, "1"], [130, "1"]],
}
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
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__).
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}]
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090"})
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."""
import requests as req_mod
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090", "timeout_seconds": 2}
)
with patch("media_library_viewer_api.widgets.sources.requests.get", 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()
@pytest.mark.asyncio
async def test_jellyfin_now_playing_filters_active_sessions():
"""now_playing should exclude idle (no NowPlayingItem) and paused sessions."""