447775048c
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.
7.0 KiB
7.0 KiB
New widgets: Jellyfin now_playing + Grafana panel embed
Files changed (10 files, ~390 lines)
| File | Status | Lines |
|---|---|---|
backend/src/media_library_viewer_api/integrations/jellyfin.py |
modified | +12 (new widget kind + config model) |
backend/src/media_library_viewer_api/integrations/grafana.py |
modified | +17 (new widget kind + config model) |
backend/src/media_library_viewer_api/widgets/sources.py |
modified | +12 (now_playing filter + panel embed URL) |
backend/tests/test_services.py |
modified | +3 (updated widget-kind assertions) |
backend/tests/test_widgets.py |
modified | +85 (import + 6 new tests) |
frontend/src/widgets/JellyfinNowPlayingWidget.tsx |
new | 41 |
frontend/src/widgets/GrafanaPanelWidget.tsx |
new | 50 |
frontend/src/integrations/registry.ts |
modified | +24 (2 new widget bindings) |
frontend/src/integrations/registry.test.ts |
modified | +1 (updated grafana kinds) |
frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx |
new | 72 |
frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx |
new | 72 |
Session-filter logic for now_playing
if widget_kind == "now_playing":
sessions = [
s for s in sessions
if s.get("NowPlayingItem")
and not s.get("PlayState", {}).get("IsPaused", True)
]
Filters raw Jellyfin sessions BEFORE _map_sessions_to_activity_rows. A session is "actively playing" when it has a NowPlayingItem (something is playing, not just idle) AND PlayState.IsPaused is false. The activity kind (default) is unchanged — shows all sessions including idle and paused.
Embed URL format for panel
embed_url = f"{base_url}/d-solo/{dashboard_uid}/manage?panelId={panel_id}&from={from_ts}&to={to_ts}&kiosk=tv"
Uses Grafana's /d-solo/ endpoint which renders a single panel without dashboard chrome. kiosk=tv hides the top nav. Defaults: from_ts="now-1h", to_ts="now".
Validation
cd backend && .venv/bin/ruff check src/ tests/ → All checks passed!
cd backend && .venv/bin/python -m pytest tests/ → 278 passed, 2 warnings (pre-existing)
cd frontend && npm run lint → 0 errors, 0 warnings
cd frontend && npm run build → ✓ built (tsc + vite)
cd frontend && npm run test → 39 files / 127 tests passed
Backend: +6 new tests (definition assertions x2, grafana panel URL x2, jellyfin now_playing filter x1, jellyfin activity shows all x1). Frontend: +6 new tests (JellyfinNowPlayingWidget x3, GrafanaPanelWidget x3).
Deviations
- No deviations from spec. Both widgets are additive — no existing behavior changed. The
activityandlinkkinds work exactly as before. - GrafanaPanelWidget pi-lens advisory for
<Button asChild><a>is a false positive (Radix Slot merges props, doesn't create nested<a>). Same pattern as GrafanaLinkWidget, ObservabilityPage, and PinnedServiceLink. Build and lint pass.
skill_resolution
none — no project/user SKILL.md paths were injected; no .atl/skill-registry.md found.
Residual risks
- Grafana embedding may be blocked by
X-Frame-Optionsor CSP depending on Grafana config. The fallback "Open in Grafana" link is provided. - GrafanaPanelWidget iframe height is fixed at 300px — not responsive to panel content height. A follow-up could use Grafana's panel-content-height API or a ResizeObserver.
- now_playing filter operates on raw sessions before mapping — if Jellyfin changes its session shape (e.g. moves
NowPlayingItem/PlayState), the filter silently passes all sessions. Same fragility as the existing activity mapping.
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "Implements two additive widget kinds (jellyfin now_playing + grafana panel embed) without changing any existing behavior. Backend: new widget configs + definitions + source adapter logic + 6 tests. Frontend: 2 new components + registry bindings + 6 tests. 278 backend + 127 frontend tests pass; ruff/eslint/tsc/vite all green. No staged files."
}
],
"changedFiles": [
"backend/src/media_library_viewer_api/integrations/jellyfin.py",
"backend/src/media_library_viewer_api/integrations/grafana.py",
"backend/src/media_library_viewer_api/widgets/sources.py",
"backend/tests/test_services.py",
"backend/tests/test_widgets.py",
"frontend/src/widgets/JellyfinNowPlayingWidget.tsx",
"frontend/src/widgets/GrafanaPanelWidget.tsx",
"frontend/src/integrations/registry.ts",
"frontend/src/integrations/registry.test.ts",
"frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
],
"testsAddedOrUpdated": [
"backend/tests/test_services.py",
"backend/tests/test_widgets.py",
"frontend/src/integrations/registry.test.ts",
"frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
],
"commandsRun": [
{
"command": "cd backend && .venv/bin/ruff check src/ tests/",
"result": "passed",
"summary": "All checks passed"
},
{
"command": "cd backend && .venv/bin/python -m pytest tests/ -q",
"result": "passed",
"summary": "278 passed, 2 warnings (pre-existing deprecation)"
},
{
"command": "cd frontend && npm run lint",
"result": "passed",
"summary": "0 errors, 0 warnings"
},
{
"command": "cd frontend && npm run build",
"result": "passed",
"summary": "tsc + vite build clean"
},
{
"command": "cd frontend && npm run test",
"result": "passed",
"summary": "39 files / 127 tests passed"
}
],
"validationOutput": [
"Backend ruff clean; 278 tests pass (+6 new).",
"Frontend eslint clean; tsc + vite build clean; 127 tests pass (+6 new).",
"Jellyfin now_playing filters: session has NowPlayingItem + IsPaused=false.",
"Grafana panel embed URL: /d-solo/{uid}/manage?panelId={id}&from={from}&to={to}&kiosk=tv.",
"Existing activity + link widget kinds unchanged (tested)."
],
"residualRisks": [
"Grafana iframe may be blocked by X-Frame-Options/CSP; fallback link provided.",
"Iframe height fixed at 300px (not responsive to panel content).",
"now_playing filter depends on Jellyfin session shape (NowPlayingItem/PlayState)."
],
"noStagedFiles": true,
"diffSummary": "~390 lines across 11 files: 2 new backend widget kinds (jellyfin now_playing + grafana panel) with source adapter logic, 2 new frontend components, registry bindings, and 12 new tests (6 backend + 6 frontend). Purely additive — no existing behavior changed.",
"reviewFindings": [
"no blockers"
],
"manualNotes": "The JellyfinClient mock approach uses patch on the class directly (not asyncio.to_thread) — let real asyncio handle the threading. The pi-lens nested-<a> advisory on GrafanaPanelWidget is a false positive (Button asChild uses Radix Slot)."
}