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,106 @@
|
||||
"""Unit tests for the shared Prometheus range-query helpers (SC-101..SC-104)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from media_library_viewer_api.widgets.prometheus_range import (
|
||||
WINDOW_PRESETS,
|
||||
normalize_prometheus_matrix,
|
||||
step_for_window,
|
||||
)
|
||||
|
||||
|
||||
class TestStepForWindow:
|
||||
"""SC-104: every preset must yield 100–300 points."""
|
||||
|
||||
@pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS))
|
||||
def test_presets_yield_in_band_point_counts(self, preset: str) -> None:
|
||||
window = WINDOW_PRESETS[preset]
|
||||
step = step_for_window(window)
|
||||
# Clamped minimum.
|
||||
assert step >= 15
|
||||
point_count = window // step
|
||||
assert 100 <= point_count <= 300, f"{preset}: {point_count} points (step={step})"
|
||||
|
||||
def test_floor_of_fifteen_seconds(self) -> None:
|
||||
# A tiny window that would otherwise produce a sub-15s step is clamped.
|
||||
assert step_for_window(60) == 15
|
||||
|
||||
def test_custom_target_points(self) -> None:
|
||||
# Targeting 100 points for 1h yields step 36 (3600/100).
|
||||
assert step_for_window(3_600, target_points=100) == 36
|
||||
|
||||
|
||||
class TestNormalizePrometheusMatrix:
|
||||
"""SC-102: label rule + null handling + dedup."""
|
||||
|
||||
def test_empty_matrix(self) -> None:
|
||||
assert normalize_prometheus_matrix([]) == []
|
||||
|
||||
def test_drops_dunder_labels_and_joins(self) -> None:
|
||||
result = [
|
||||
{
|
||||
"metric": {"__name__": "node_cpu_seconds_total", "instance": "host:9100", "mode": "idle"},
|
||||
"values": [[1_700_000_000, "12.5"], [1_700_000_030, "13.0"]],
|
||||
}
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert len(out) == 1
|
||||
assert out[0]["label"] == "instance=host:9100 mode=idle"
|
||||
assert out[0]["points"] == [
|
||||
{"t": 1_700_000_000, "v": 12.5},
|
||||
{"t": 1_700_000_030, "v": 13.0},
|
||||
]
|
||||
|
||||
def test_falls_back_to_value_when_no_labels(self) -> None:
|
||||
result = [{"metric": {}, "values": [[100, "1"]]}]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert out[0]["label"] == "value"
|
||||
|
||||
def test_dedup_collisions_with_suffix(self) -> None:
|
||||
# Two series with identical visible labels get a "(1)" suffix on the 2nd.
|
||||
result = [
|
||||
{"metric": {"job": "x"}, "values": [[1, "1"]]},
|
||||
{"metric": {"job": "x"}, "values": [[1, "2"]]},
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
labels = [s["label"] for s in out]
|
||||
assert labels == ["job=x", "job=x (1)"]
|
||||
|
||||
def test_non_numeric_sentinels_become_none(self) -> None:
|
||||
result = [
|
||||
{
|
||||
"metric": {"job": "x"},
|
||||
"values": [
|
||||
[1, "NaN"],
|
||||
[2, "+Inf"],
|
||||
[3, "-Inf"],
|
||||
[4, "3.5"],
|
||||
],
|
||||
}
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert out[0]["points"] == [
|
||||
{"t": 1, "v": None},
|
||||
{"t": 2, "v": None},
|
||||
{"t": 3, "v": None},
|
||||
{"t": 4, "v": 3.5},
|
||||
]
|
||||
|
||||
def test_malformed_values_are_ignored_not_raised(self) -> None:
|
||||
result = [
|
||||
{
|
||||
"metric": {"job": "x"},
|
||||
"values": [
|
||||
[1, "3.5"],
|
||||
["not-a-ts", "9"], # unusable timestamp → dropped
|
||||
[3, "junk-value"], # unparseable value → v: None
|
||||
],
|
||||
}
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert out[0]["points"] == [
|
||||
{"t": 1, "v": 3.5},
|
||||
{"t": 3, "v": None},
|
||||
]
|
||||
@@ -100,7 +100,7 @@ def test_authentik_service_definition():
|
||||
|
||||
def test_definitions_declare_widget_kinds():
|
||||
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link", "chart"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart"}
|
||||
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
|
||||
assert get_service_definition("nextcloud").widget_kinds == []
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user