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
@@ -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."""