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:
@@ -26,13 +26,15 @@ class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
||||
panel_id: int | None = None
|
||||
|
||||
|
||||
class GrafanaPanelWidgetConfig(WidgetConfigBase):
|
||||
"""Embed a single Grafana panel via iframe."""
|
||||
class GrafanaChartWidgetConfig(WidgetConfigBase):
|
||||
"""Render a time-series chart from a Grafana datasource query."""
|
||||
|
||||
dashboard_uid: str
|
||||
panel_id: int
|
||||
datasource_uid: str = "prometheus"
|
||||
query: str = ""
|
||||
from_ts: str = "now-1h"
|
||||
to_ts: str = "now"
|
||||
interval_ms: int = 30_000
|
||||
max_data_points: int = 100
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
@@ -53,12 +55,19 @@ DEFINITION = ServiceDefinition(
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
widget_kind(
|
||||
kind="panel",
|
||||
name="Panel embed",
|
||||
description="Embed a Grafana panel directly.",
|
||||
model_cls=GrafanaPanelWidgetConfig,
|
||||
default_config={"dashboard_uid": "", "panel_id": 1, "from_ts": "now-1h", "to_ts": "now"},
|
||||
refresh_interval_ms=0,
|
||||
kind="chart",
|
||||
name="Chart",
|
||||
description="Live time-series chart from a Grafana datasource query.",
|
||||
model_cls=GrafanaChartWidgetConfig,
|
||||
default_config={
|
||||
"datasource_uid": "prometheus",
|
||||
"query": "",
|
||||
"from_ts": "now-1h",
|
||||
"to_ts": "now",
|
||||
"interval_ms": 30_000,
|
||||
"max_data_points": 100,
|
||||
},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -97,29 +97,23 @@ class StaticWidgetSource:
|
||||
|
||||
|
||||
class GrafanaWidgetSource:
|
||||
"""Build a Grafana deep-link or panel embed URL."""
|
||||
"""Build a Grafana deep-link or query datasource for a chart."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "Grafana widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
api_key = str(service.secrets.get("api_key") or "")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
|
||||
if widget_kind == "chart":
|
||||
return await self._fetch_chart(base_url, api_key, timeout, config)
|
||||
|
||||
# Default: deep-link
|
||||
dashboard_uid = config.get("dashboard_uid")
|
||||
if not dashboard_uid:
|
||||
return {"error": "dashboard_uid is required"}
|
||||
|
||||
if widget_kind == "panel":
|
||||
panel_id = config.get("panel_id")
|
||||
if panel_id is None:
|
||||
return {"error": "panel_id is required"}
|
||||
from_ts = config.get("from_ts", "now-1h")
|
||||
to_ts = config.get("to_ts", "now")
|
||||
embed_url = (
|
||||
f"{base_url}/d-solo/{dashboard_uid}/manage?panelId={panel_id}&from={from_ts}&to={to_ts}&kiosk=tv"
|
||||
)
|
||||
return {"embed_url": embed_url}
|
||||
|
||||
# Default: deep-link
|
||||
url = f"{base_url}/d/{dashboard_uid}"
|
||||
panel_id = config.get("panel_id")
|
||||
if panel_id is not None:
|
||||
@@ -129,6 +123,68 @@ class GrafanaWidgetSource:
|
||||
logger.exception("grafana adapter failed")
|
||||
return {"error": f"Grafana link failed: {exc}"}
|
||||
|
||||
async def _fetch_chart(self, base_url: str, api_key: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Query Grafana /api/ds/query and normalize to {series: [...]}."""
|
||||
if not api_key:
|
||||
return {"error": "Grafana api_key is required for chart queries"}
|
||||
query = config.get("query", "")
|
||||
if not query:
|
||||
return {"error": "query is required"}
|
||||
|
||||
datasource_uid = config.get("datasource_uid", "prometheus")
|
||||
body = {
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||
"expr": query,
|
||||
"format": "time_series",
|
||||
"intervalMs": int(config.get("interval_ms", 30_000)),
|
||||
"maxDataPoints": int(config.get("max_data_points", 100)),
|
||||
"refId": "A",
|
||||
}
|
||||
],
|
||||
"from": config.get("from_ts", "now-1h"),
|
||||
"to": config.get("to_ts", "now"),
|
||||
}
|
||||
|
||||
def _do_post() -> dict[str, Any]:
|
||||
resp = requests.post(
|
||||
f"{base_url}/api/ds/query",
|
||||
json=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
try:
|
||||
raw = await asyncio.wait_for(asyncio.to_thread(_do_post), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Grafana query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
return {"error": f"Grafana query failed: {exc}"}
|
||||
|
||||
# Normalize Grafana's /api/ds/query response into series.
|
||||
series: list[dict[str, Any]] = []
|
||||
results = raw.get("results", {})
|
||||
for ref_id, ref_data in results.items():
|
||||
for frame in ref_data.get("frames", []):
|
||||
values = frame.get("data", {}).get("values", [])
|
||||
if len(values) < 2:
|
||||
continue
|
||||
timestamps = values[0]
|
||||
vals = values[1]
|
||||
# Derive series label from the field schema.
|
||||
fields = frame.get("schema", {}).get("fields", [])
|
||||
label = fields[-1].get("name", "value") if fields else "value"
|
||||
points = [{"t": int(t), "v": float(v) if v is not None else None} for t, v in zip(timestamps, vals)]
|
||||
series.append({"label": label, "points": points})
|
||||
|
||||
return {"series": series}
|
||||
|
||||
|
||||
class PrometheusWidgetSource:
|
||||
"""Run a PromQL instant query against a Prometheus service."""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user