Replace Grafana iframe panel with server-side chart widget

The iframe-based 'panel' widget didn't work: the browser couldn't
authenticate against the OIDC-protected Grafana (Authentik), and
iframes can't carry Bearer tokens or share cross-origin session
cookies. Result: blank iframe or login redirect.

Replace it with a 'chart' widget that queries Grafana's datasource
API server-side:

Backend (GrafanaWidgetSource): POSTs to /api/ds/query with the stored
api_key (which bypasses OIDC), using the widget's configured PromQL
query, datasource_uid, time range, and resolution. Normalizes Grafana's
frame-based response into a simple {series: [{label, points: [{t, v}]}]}
shape. The api_key is never exposed to the browser.

Frontend (GrafanaChartWidget): renders the series data as a recharts
LineChart with dark-mode-aware colors (Tailwind --chart-* tokens),
responsive container, custom tooltip, and per-series lines. Loading
skeleton, error Alert, and empty state. recharts ^3.9.2 added.

The 'link' widget kind (deep-link URL) is unchanged. The 'panel' kind
and GrafanaPanelWidget are fully removed.

Backend: 279 tests pass (+1 net: -2 panel + 3 chart). Frontend: 127
tests pass (net 0: -3 panel + 3 chart). Lint/build green both sides.
This commit is contained in:
Developer
2026-07-06 10:19:57 +00:00
parent b877a32ad8
commit 447775048c
15 changed files with 1023 additions and 185 deletions
+2 -2
View File
@@ -99,7 +99,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", "panel"}
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("alertmanager").widget_kinds} == {"active_alerts"}
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
@@ -184,7 +184,7 @@ def test_service_type_includes_secret_and_widget_metadata(client):
response = client.get("/api/services/types")
grafana = next(item for item in response.json() if item["service_type"] == "grafana")
assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link", "panel"]
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link", "chart"]
# ---------------------------------------------------------------------------
+77 -15
View File
@@ -511,35 +511,97 @@ def test_jellyfin_definition_has_now_playing_widget():
assert "activity" in kinds
def test_grafana_definition_has_panel_widget():
def test_grafana_definition_has_chart_widget():
from media_library_viewer_api.integrations.registry import get_service_definition
definition = get_service_definition("grafana")
kinds = {wk.kind for wk in definition.widget_kinds}
assert "panel" in kinds
assert "chart" in kinds
assert "link" in kinds
@pytest.mark.asyncio
async def test_grafana_adapter_builds_panel_embed_url():
async def test_grafana_adapter_chart_queries_datasource():
"""Chart widget should POST to /api/ds/query and normalize the response."""
from unittest.mock import MagicMock, patch
adapter = GrafanaWidgetSource()
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
result = await adapter.fetch(
service,
"panel",
{"dashboard_uid": "ov", "panel_id": 4, "from_ts": "now-6h", "to_ts": "now"},
service = ServiceRecord(
id="s",
service_type="grafana",
name="g",
config={"base_url": "http://g:3000", "timeout_seconds": 5},
secrets={"api_key": "tok"},
)
assert "embed_url" in result
assert result["embed_url"] == ("http://g:3000/d-solo/ov/manage?panelId=4&from=now-6h&to=now&kiosk=tv")
mock_resp = MagicMock()
mock_resp.json.return_value = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[1000, 2000], [0.5, 0.8]]},
"schema": {"fields": [{"name": "Time"}, {"name": "cpu_usage"}]},
}
]
}
}
}
mock_resp.raise_for_status = MagicMock()
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=mock_resp):
result = await adapter.fetch(
service,
"chart",
{"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
)
assert "series" in result
assert len(result["series"]) == 1
assert result["series"][0]["label"] == "cpu_usage"
assert result["series"][0]["points"] == [
{"t": 1000, "v": 0.5},
{"t": 2000, "v": 0.8},
]
@pytest.mark.asyncio
async def test_grafana_adapter_panel_uses_defaults():
async def test_grafana_adapter_chart_requires_api_key():
adapter = GrafanaWidgetSource()
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
result = await adapter.fetch(service, "panel", {"dashboard_uid": "ov", "panel_id": 2})
assert "from=now-1h" in result["embed_url"]
assert "to=now" in result["embed_url"]
service = ServiceRecord(
id="s",
service_type="grafana",
name="g",
config={"base_url": "http://g:3000"},
)
result = await adapter.fetch(service, "chart", {"query": "up"})
assert "error" in result
assert "api_key" in result["error"]
@pytest.mark.asyncio
async def test_grafana_adapter_chart_handles_http_failure():
from unittest.mock import patch
import requests as req_mod
adapter = GrafanaWidgetSource()
service = ServiceRecord(
id="s",
service_type="grafana",
name="g",
config={"base_url": "http://g:3000", "timeout_seconds": 2},
secrets={"api_key": "tok"},
)
with patch(
"media_library_viewer_api.widgets.sources.requests.post",
side_effect=req_mod.ConnectionError("refused"),
):
result = await adapter.fetch(service, "chart", {"query": "up"})
assert "error" in result
assert "failed" in result["error"].lower()
@pytest.mark.asyncio