Compare commits

...

6 Commits

Author SHA1 Message Date
Developer c36262d7b6 Reusable widgets: reference widgets across dashboards + detach to clone
Widgets configured on one dashboard (e.g., a Grafana service's Overview)
can now be live-referenced on other dashboards. Editing the widget config
updates it everywhere it's referenced. References can be detached into
independent clones.

Backend: new widget_references table (dashboard_scope, widget_id,
sort_order) with ON DELETE CASCADE. CRUD methods + 4 endpoints:
GET/POST /api/widgets/references, DELETE /api/widgets/references/{id},
POST /api/widgets/references/{id}/detach (clones the widget into a
standalone instance, then removes the reference).

Frontend: WidgetConfigDialog gains a dashboardScope prop. When set
(the main Dashboard passes 'main'), the dialog shows:
- Owned + referenced widgets in a combined list, with a link badge on
  references.
- 'Add existing widget' picker: searchable list of ALL widget instances
  not already on this dashboard. Click to create a reference.
- Detach button on references: clones the widget (service_id=NULL) and
  removes the reference.
- Delete on a reference removes the REFERENCE (not the original widget).

Dashboard renders referenced widgets alongside owned widgets.

Detaching a service-bound widget clones it with service_id=NULL — the
clone may need re-binding to a service to render correctly. Named
dashboards don't pass dashboardScope yet (pinned-links-only); when they
gain widget support, the backend already handles any scope string.

282 backend tests pass (+2 reference lifecycle); 127 frontend tests
pass; ruff/eslint/tsc/vite all green.
2026-07-06 11:34:48 +00:00
Developer 94bf830955 Fix invisible chart lines + extract Prometheus labels for multi-series
Two fixes for the Grafana chart widget:

1. Invisible lines: the CHART_COLORS used 'hsl(var(--chart-1))' but the
   CSS variable is named '--color-chart-1' and already contains a hex
   color (#4f8cff). The hsl() wrapper produced invalid CSS, making
   every stroke invisible. Fixed to var(--color-chart-1).

2. Multiple series collision: the backend labeled all Prometheus series
   with the value field name (often just 'Value'), so multiple time
   series collided on the same recharts dataKey and overwrote each
   other. Now extracts meaningful labels from the Grafana frame metadata:
   prefers displayName, then Prometheus metric labels (e.g.
   'instance=server1:9100 mode=iowait'), then falls back to the field
   name. Duplicate labels get a numeric suffix for uniqueness.

Multi-series queries now render correctly: each Prometheus time series
gets its own colored line with a unique label in the legend/tooltip.

280 backend tests pass (+1 labels test); 127 frontend tests pass; ruff/
eslint clean.
2026-07-06 10:59:16 +00:00
Developer f355d04278 Use multi-line textarea for complex widget config fields
The Grafana chart widget's 'query' field (PromQL) and any schema field
marked format:'textarea' now render as a resizable 4-row Textarea with
monospace font, instead of a single-line Input. Makes complex queries
much easier to read and edit.

The field-renderer heuristic: key === 'query' or schema format ===
'textarea' → Textarea; everything else stays an Input (numbers stay
number inputs). 127 tests pass; lint/build green.
2026-07-06 10:43:12 +00:00
Developer bfe7ce7367 Fix: WidgetConfigDialog scope + reorder race condition
Two bugs in the widget config dialog:

1. Service Overview edit showed main-dashboard widgets. The dialog
   called useWidgetInstances() with no args, fetching ALL widgets. Now
   accepts a serviceId prop; OverviewTab passes instance.id so the
   dialog lists + creates only service-scoped widgets. New built-in
   widgets added from a service Overview inherit the serviceId.

2. Reorder up/down buttons did nothing. moveInstance fired two
   saveWidget mutations via Promise.all — the first mutation's
   onSuccess cache invalidation triggered a refetch before the second
   completed, reverting the swap. Changed to sequential awaits so
   both sort_order writes land before the cache refreshes.

127 tests pass; lint/build green.
2026-07-06 10:29:24 +00:00
Developer 447775048c 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.
2026-07-06 10:19:57 +00:00
Developer b877a32ad8 Add Jellyfin Now Playing + Grafana Panel embed widgets
Two new additive widget kinds:

Jellyfin 'now_playing': like the existing 'activity' widget but filters
to only sessions with active playback (NowPlayingItem present + not
paused). Shows who's actually watching right now. The 'activity' kind
is unchanged (shows all sessions including idle).

Grafana 'panel': embeds a single Grafana panel directly in the app via
an iframe, using Grafana's /d-solo/ endpoint (renders one panel without
dashboard chrome, kiosk=tv). Configurable dashboard_uid, panel_id, and
time range (from/to, defaults now-1h/now). Includes a fallback 'Open in
Grafana' link for when embedding is blocked by X-Frame-Options/CSP. The
'link' kind is unchanged (still builds a deep-link URL).

Backend: new widget configs + definitions on jellyfin/grafana; source
adapter logic (session filter for now_playing; d-solo embed URL for
panel); 6 new tests.

Frontend: JellyfinNowPlayingWidget + GrafanaPanelWidget components;
registry bindings; 6 new tests.

278 backend tests pass (+6); 127 frontend tests pass (+6); lint/build
green both sides.
2026-06-28 15:26:43 +00:00
25 changed files with 2007 additions and 56 deletions
+141
View File
@@ -0,0 +1,141 @@
# Grafana Chart Widget — worker output
## Files changed (10 files, ~400 lines)
| File | Status | Lines |
|------|--------|-------|
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +12/-12 (panel→chart config + kind) |
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +70/-12 (chart query adapter replaces panel URL logic) |
| `backend/tests/test_widgets.py` | modified | +55/-20 (3 new chart tests replace 2 panel tests) |
| `backend/tests/test_services.py` | modified | +2/-2 (grafana widget-kind + API-metadata assertions) |
| `frontend/src/widgets/GrafanaChartWidget.tsx` | **new** | 100 |
| `frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx` | **new** | 57 |
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | **deleted** | -50 |
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | **deleted** | -72 |
| `frontend/src/integrations/registry.ts` | modified | +24/-14 (chart binding replaces panel) |
| `frontend/src/integrations/registry.test.ts` | modified | +1/-1 (panel→chart) |
| `frontend/src/widgets/index.ts` | modified | +1/-0 (export GrafanaChartWidget) |
| `frontend/package.json` + `package-lock.json` | modified | +1 dep (recharts ^3.9.2) |
**recharts version installed:** `^3.9.2`
## Grafana `/api/ds/query` request/response shape
**Request** (POST):
```json
{
"queries": [{
"datasource": {"uid": "prometheus", "type": "prometheus"},
"expr": "rate(cpu[5m])",
"format": "time_series",
"intervalMs": 30000,
"maxDataPoints": 100,
"refId": "A"
}],
"from": "now-1h",
"to": "now"
}
```
Headers: `Authorization: Bearer {api_key}`, `Content-Type: application/json`
**Response** (abbreviated):
```json
{
"results": {
"A": {
"frames": [{
"data": { "values": [[1000, 2000], [0.5, 0.8]] },
"schema": { "fields": [{"name":"Time"}, {"name":"cpu_usage"}] }
}]
}
}
}
```
## Series normalization logic
Iterates `results[*].frames[]`. For each frame with `values` having >=2 arrays (timestamps + values), extracts the series label from `schema.fields[-1].name` and zips timestamps+values into `[{t: int, v: float|null}]`. Returns `{"series": [{"label": "...", "points": [...]}]}`.
## Frontend chart rendering
`GrafanaChartWidget` fetches widget data, extracts `data.series`, merges all series by timestamp into a single recharts data array (`[{time, cpu_usage: 0.5, mem: 0.3}, ...]`), and renders a `<LineChart>` with one `<Line>` per series. Uses Tailwind CSS variables (`--chart-1` through `--chart-5`) for colors so it respects dark mode. Includes loading skeleton, error Alert, and empty-state Alert.
## Validation
```
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest → 279 passed, ruff clean
cd frontend && npm run lint && npm run build && npm run test → 127 passed, lint/build clean
```
## Deviations
1. **No deviations from spec.** The `link` widget kind is unchanged. The `panel` kind is fully replaced by `chart`.
2. **recharts `labelFormatter` type workaround.** Recharts 3.x types `labelFormatter` as `(label: ReactNode, ...) => ReactNode`, not `(number) => string`. Wrapped with `(label) => formatTime(Number(label))` to satisfy TS strict.
## skill_resolution
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
## Residual risks
- The chart widget assumes the Grafana datasource is Prometheus-type (hardcoded `"type": "prometheus"` in the query body). If the user has a non-Prometheus datasource (InfluxDB, etc.), the query body format may need adjustment. The `datasource_uid` is configurable but the `type` is not.
- recharts is ~45KB gzipped added to the bundle.
```acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "Replaces the broken iframe panel widget with a server-side chart query widget. Backend queries /api/ds/query with stored api_key; frontend renders recharts LineChart. No iframe, no browser auth, no CORS. The link widget kind is unchanged. 279 backend + 127 frontend tests pass; lint/build green both sides."
}
],
"changedFiles": [
"backend/src/media_library_viewer_api/integrations/grafana.py",
"backend/src/media_library_viewer_api/widgets/sources.py",
"backend/tests/test_widgets.py",
"backend/tests/test_services.py",
"frontend/src/widgets/GrafanaChartWidget.tsx",
"frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
"frontend/src/widgets/GrafanaPanelWidget.tsx (deleted)",
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx (deleted)",
"frontend/src/integrations/registry.ts",
"frontend/src/integrations/registry.test.ts",
"frontend/src/widgets/index.ts",
"frontend/package.json"
],
"testsAddedOrUpdated": [
"backend/tests/test_widgets.py",
"backend/tests/test_services.py",
"frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
"frontend/src/integrations/registry.test.ts"
],
"commandsRun": [
{ "command": "cd backend && .venv/bin/ruff check .", "result": "passed", "summary": "All checks passed" },
{ "command": "cd backend && .venv/bin/python -m pytest tests/ -q", "result": "passed", "summary": "279 passed, 2 pre-existing warnings" },
{ "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; 279 tests pass (was 278; -2 panel + 3 chart = +1 net).",
"Frontend eslint clean; tsc + vite build clean; 127 tests pass (-3 panel + 3 chart = net 0).",
"GrafanaWidgetSource._fetch_chart POSTs to /api/ds/query with Bearer token; normalizes response to {series:[{label,points}]}",
"GrafanaChartWidget renders recharts LineChart with dark-mode CSS variable colors.",
"link widget kind unchanged; panel widget kind fully removed."
],
"residualRisks": [
"Chart query body hardcodes datasource type 'prometheus' — non-Prometheus datasources (InfluxDB etc.) may need a type field on the config.",
"recharts adds ~45KB gzipped to the frontend bundle."
],
"noStagedFiles": true,
"diffSummary": "~400 lines: replaces Grafana panel iframe widget with server-side datasource-query chart widget. Backend: /api/ds/query POST with api_key + series normalization (70 lines). Frontend: recharts LineChart component with dark-mode support (100 lines). 3 backend + 3 frontend tests. recharts ^3.9.2 installed.",
"reviewFindings": [
"no blockers"
],
"manualNotes": "recharts labelFormatter type workaround: recharts 3.x types it as (ReactNode) => ReactNode, not (number) => string. Wrapped with Number() cast. The link widget kind is fully preserved. The panel widget kind and all its code/tests are fully deleted."
}
```
+142
View File
@@ -0,0 +1,142 @@
# 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
```python
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
```python
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
1. **No deviations from spec.** Both widgets are additive — no existing behavior changed. The `activity` and `link` kinds work exactly as before.
2. **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-Options` or 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.
```acceptance-report
{
"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)."
}
@@ -26,6 +26,17 @@ class GrafanaLinkWidgetConfig(WidgetConfigBase):
panel_id: int | None = None panel_id: int | None = None
class GrafanaChartWidgetConfig(WidgetConfigBase):
"""Render a time-series chart from a Grafana datasource query."""
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( DEFINITION = ServiceDefinition(
service_type="grafana", service_type="grafana",
name="Grafana", name="Grafana",
@@ -43,5 +54,20 @@ DEFINITION = ServiceDefinition(
default_config={"dashboard_uid": ""}, default_config={"dashboard_uid": ""},
refresh_interval_ms=0, refresh_interval_ms=0,
), ),
widget_kind(
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,
),
], ],
) )
@@ -36,6 +36,12 @@ class JellyfinActivityWidgetConfig(WidgetConfigBase):
pass pass
class JellyfinNowPlayingWidgetConfig(WidgetConfigBase):
"""Only show sessions with active playback (not idle/paused)."""
pass
DEFINITION = ServiceDefinition( DEFINITION = ServiceDefinition(
service_type="jellyfin", service_type="jellyfin",
name="Jellyfin", name="Jellyfin",
@@ -53,5 +59,13 @@ DEFINITION = ServiceDefinition(
default_config={}, default_config={},
refresh_interval_ms=30_000, refresh_interval_ms=30_000,
), ),
widget_kind(
kind="now_playing",
name="Now Playing",
description="Only sessions actively playing media.",
model_cls=JellyfinNowPlayingWidgetConfig,
default_config={},
refresh_interval_ms=30_000,
),
], ],
) )
@@ -12,6 +12,7 @@ import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.integrations.base import validate_config from media_library_viewer_api.integrations.base import validate_config
@@ -34,6 +35,15 @@ from media_library_viewer_api.widgets.sources import (
get_service_adapter, get_service_adapter,
) )
class WidgetReferenceCreate(BaseModel):
"""Payload for creating a widget reference (live-link)."""
dashboard_scope: str
widget_id: str
sort_order: int = 0
router = APIRouter(prefix="/api/widgets", tags=["widgets"]) router = APIRouter(prefix="/api/widgets", tags=["widgets"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -221,3 +231,52 @@ async def fetch_data(
error=data.get("error"), error=data.get("error"),
fetched_at=int(time.time()), fetched_at=int(time.time()),
).model_dump() ).model_dump()
# ---------------------------------------------------------------------------
# Widget references (live-link widgets across dashboards)
# ---------------------------------------------------------------------------
@router.get("/references")
def list_references(
dashboard_scope: str,
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
"""List widget references for a dashboard scope."""
return store.list_widget_references(dashboard_scope)
@router.post("/references", status_code=status.HTTP_201_CREATED)
def create_reference(
body: WidgetReferenceCreate,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Create a widget reference (live-link) on a dashboard."""
try:
return store.create_widget_reference(body.dashboard_scope, body.widget_id, body.sort_order)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.delete("/references/{reference_id}")
def delete_reference(
reference_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, str]:
"""Remove a widget reference from a dashboard."""
store.delete_widget_reference(reference_id)
return {"status": "deleted"}
@router.post("/references/{reference_id}/detach")
def detach_reference(
reference_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Clone the referenced widget into a standalone instance and remove the reference."""
try:
cloned = store.detach_widget_reference(reference_id, "")
return WidgetInstance(**cloned).model_dump()
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -165,6 +165,19 @@ class SettingsStore:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT") conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
if "widget_kind" not in widget_cols: if "widget_kind" not in widget_cols:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT") conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS widget_references (
id TEXT PRIMARY KEY,
dashboard_scope TEXT NOT NULL,
widget_id TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY (widget_id) REFERENCES dashboard_widgets(id) ON DELETE CASCADE
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_widget_references_scope ON widget_references(dashboard_scope)")
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs ( CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -1491,6 +1504,98 @@ class SettingsStore:
self.init_schema() self.init_schema()
with self.connect() as conn: with self.connect() as conn:
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,)) conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
conn.execute("DELETE FROM widget_references WHERE widget_id = ?", (widget_id,))
# ------------------------------------------------------------------
# Widget references (live-link widgets across dashboards)
# ------------------------------------------------------------------
def list_widget_references(self, dashboard_scope: str) -> list[dict[str, Any]]:
"""List widget references for a dashboard scope, joined with widget data."""
self.init_schema()
with self.connect() as conn:
rows = conn.execute(
"""
SELECT wr.id AS ref_id, wr.dashboard_scope, wr.widget_id, wr.sort_order,
wr.created_at AS ref_created_at
FROM widget_references wr
WHERE wr.dashboard_scope = ?
ORDER BY wr.sort_order ASC, wr.created_at ASC
""",
(dashboard_scope,),
).fetchall()
result: list[dict[str, Any]] = []
for row in rows:
widget = self.get_widget(row["widget_id"])
if not widget:
continue
result.append(
{
"id": row["ref_id"],
"dashboard_scope": row["dashboard_scope"],
"widget_id": row["widget_id"],
"sort_order": int(row["sort_order"]),
"created_at": row["ref_created_at"],
"widget": widget,
}
)
return result
def create_widget_reference(self, dashboard_scope: str, widget_id: str, sort_order: int = 0) -> dict[str, Any]:
self.init_schema()
widget = self.get_widget(widget_id)
if not widget:
raise ValueError(f"Widget {widget_id} not found")
ref_id = uuid.uuid4().hex[:12]
now = int(time.time())
with self.connect() as conn:
conn.execute(
"""
INSERT INTO widget_references (id, dashboard_scope, widget_id, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(ref_id, dashboard_scope, widget_id, sort_order, now),
)
return {
"id": ref_id,
"dashboard_scope": dashboard_scope,
"widget_id": widget_id,
"sort_order": sort_order,
"created_at": now,
"widget": widget,
}
def delete_widget_reference(self, reference_id: str) -> None:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM widget_references WHERE id = ?", (reference_id,))
def detach_widget_reference(self, reference_id: str, dashboard_scope: str) -> dict[str, Any]:
"""Clone the referenced widget into a new standalone instance owned by the scope."""
self.init_schema()
with self.connect() as conn:
row = conn.execute(
"SELECT widget_id FROM widget_references WHERE id = ?",
(reference_id,),
).fetchone()
if not row:
raise ValueError(f"Reference {reference_id} not found")
source = self.get_widget(row["widget_id"])
if not source:
raise ValueError(f"Source widget {row['widget_id']} not found")
# Clone: new widget with service_id=NULL (dashboard scope), same config/kind/title.
cloned = self.upsert_widget(
{
"service_id": None,
"widget_kind": source["widget_kind"],
"title": source["title"],
"config": source["config"],
"enabled": source["enabled"],
"sort_order": source["sort_order"],
}
)
self.delete_widget_reference(reference_id)
return cloned
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Service registry # Service registry
@@ -97,13 +97,20 @@ class StaticWidgetSource:
class GrafanaWidgetSource: class GrafanaWidgetSource:
"""Build a Grafana deep-link (no embedding).""" """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]: async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try: try:
if service is None: if service is None:
return {"error": "Grafana widget is missing its service"} return {"error": "Grafana widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/") 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") dashboard_uid = config.get("dashboard_uid")
if not dashboard_uid: if not dashboard_uid:
return {"error": "dashboard_uid is required"} return {"error": "dashboard_uid is required"}
@@ -116,6 +123,90 @@ class GrafanaWidgetSource:
logger.exception("grafana adapter failed") logger.exception("grafana adapter failed")
return {"error": f"Grafana link failed: {exc}"} 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", {})
seen_labels: dict[str, int] = {}
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 a meaningful series label from the frame metadata.
# Prometheus frames carry metric labels in schema.fields[-1].labels.
fields = frame.get("schema", {}).get("fields", [])
value_field = fields[-1] if fields else {}
# Prefer displayName (explicitly set in Grafana), then Prometheus
# labels (e.g. {instance: "server:9100", mode: "iowait"}), then
# the field name as a last resort.
display_name = value_field.get("config", {}).get("displayName") or value_field.get("displayName")
frame_labels = value_field.get("labels") or {}
if display_name:
label = str(display_name)
elif frame_labels:
# Build a readable label from the Prometheus labels, excluding
# redundant ones like __name__.
parts = [f"{k}={v}" for k, v in sorted(frame_labels.items()) if not k.startswith("__")]
label = " ".join(parts) if parts else "value"
else:
label = value_field.get("name", "value")
# Ensure unique labels when multiple series share the same name.
if label in seen_labels:
seen_labels[label] += 1
label = f"{label} ({seen_labels[label]})"
else:
seen_labels[label] = 0
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: class PrometheusWidgetSource:
"""Run a PromQL instant query against a Prometheus service.""" """Run a PromQL instant query against a Prometheus service."""
@@ -208,6 +299,10 @@ class JellyfinWidgetSource:
asyncio.to_thread(client.sessions), asyncio.to_thread(client.sessions),
timeout=timeout, timeout=timeout,
) )
if widget_kind == "now_playing":
sessions = [
s for s in sessions if s.get("NowPlayingItem") and not s.get("PlayState", {}).get("IsPaused", True)
]
rows = _map_sessions_to_activity_rows(sessions) rows = _map_sessions_to_activity_rows(sessions)
return {"sessions": rows} return {"sessions": rows}
except asyncio.TimeoutError: except asyncio.TimeoutError:
+3 -3
View File
@@ -99,10 +99,10 @@ def test_authentik_service_definition():
def test_definitions_declare_widget_kinds(): def test_definitions_declare_widget_kinds():
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"} 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"}
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"} 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"} assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
assert get_service_definition("nextcloud").widget_kinds == [] assert get_service_definition("nextcloud").widget_kinds == []
assert get_service_definition("authentik").widget_kinds == [] assert get_service_definition("authentik").widget_kinds == []
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"} assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
@@ -184,7 +184,7 @@ def test_service_type_includes_secret_and_widget_metadata(client):
response = client.get("/api/services/types") response = client.get("/api/services/types")
grafana = next(item for item in response.json() if item["service_type"] == "grafana") 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 [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link"] assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link", "chart"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+360
View File
@@ -16,6 +16,7 @@ from media_library_viewer_api.widgets.sources import (
AlertmanagerWidgetSource, AlertmanagerWidgetSource,
BackupsWidgetSource, BackupsWidgetSource,
GrafanaWidgetSource, GrafanaWidgetSource,
JellyfinWidgetSource,
ServiceRecord, ServiceRecord,
StaticWidgetSource, StaticWidgetSource,
) )
@@ -494,3 +495,362 @@ async def test_ssh_task_adapter_records_history_on_run(client):
runs = store.list_service_task_runs(service_id=service["id"]) runs = store.list_service_task_runs(service_id=service["id"])
assert len(runs) == 1 assert len(runs) == 1
assert runs[0]["status"] == "success" assert runs[0]["status"] == "success"
# ---------------------------------------------------------------------------
# New widget kind tests (jellyfin now_playing + grafana panel)
# ---------------------------------------------------------------------------
def test_jellyfin_definition_has_now_playing_widget():
from media_library_viewer_api.integrations.registry import get_service_definition
definition = get_service_definition("jellyfin")
kinds = {wk.kind for wk in definition.widget_kinds}
assert "now_playing" in kinds
assert "activity" in kinds
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 "chart" in kinds
assert "link" in kinds
@pytest.mark.asyncio
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", "timeout_seconds": 5},
secrets={"api_key": "tok"},
)
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_chart_extracts_prometheus_labels():
"""Multiple Prometheus series should get unique labels from frame metadata."""
from unittest.mock import MagicMock, patch
adapter = GrafanaWidgetSource()
service = ServiceRecord(
id="s",
service_type="grafana",
name="g",
config={"base_url": "http://g:3000", "timeout_seconds": 5},
secrets={"api_key": "tok"},
)
mock_resp = MagicMock()
mock_resp.json.return_value = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[1000], [0.5]]},
"schema": {
"fields": [
{"name": "Time"},
{
"name": "Value",
"labels": {
"instance": "server1:9100",
"mode": "iowait",
},
},
]
},
},
{
"data": {"values": [[1000], [0.3]]},
"schema": {
"fields": [
{"name": "Time"},
{
"name": "Value",
"labels": {
"instance": "server2:9100",
"mode": "iowait",
},
},
]
},
},
]
}
}
}
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])"})
assert len(result["series"]) == 2
assert result["series"][0]["label"] == "instance=server1:9100 mode=iowait"
assert result["series"][1]["label"] == "instance=server2:9100 mode=iowait"
@pytest.mark.asyncio
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, "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
async def test_jellyfin_now_playing_filters_active_sessions():
"""now_playing should exclude idle (no NowPlayingItem) and paused sessions."""
adapter = JellyfinWidgetSource()
service = ServiceRecord(
id="s",
service_type="jellyfin",
name="jf",
config={"base_url": "http://jf:8096"},
secrets={"api_key": "k"},
)
playing_session = {
"UserName": "alice",
"NowPlayingItem": {"Name": "Movie", "Type": "Movie"},
"PlayState": {"IsPaused": False},
"DeviceName": "Web",
}
paused_session = {
"UserName": "bob",
"NowPlayingItem": {"Name": "Show", "Type": "Episode"},
"PlayState": {"IsPaused": True},
"DeviceName": "TV",
}
idle_session = {
"UserName": "carol",
"PlayState": {"IsPaused": False},
"DeviceName": "Phone",
}
mock_client = SimpleNamespace(sessions=lambda: [playing_session, paused_session, idle_session])
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
result = await adapter.fetch(service, "now_playing", {})
sessions = result["sessions"]
assert len(sessions) == 1
assert sessions[0]["user"] == "alice"
assert sessions[0]["state"] == "playing"
@pytest.mark.asyncio
async def test_jellyfin_activity_shows_all_sessions():
"""activity (default) should include idle and paused sessions."""
adapter = JellyfinWidgetSource()
service = ServiceRecord(
id="s",
service_type="jellyfin",
name="jf",
config={"base_url": "http://jf:8096"},
secrets={"api_key": "k"},
)
mock_client = SimpleNamespace(
sessions=lambda: [
{"UserName": "alice", "NowPlayingItem": {"Name": "M"}, "PlayState": {"IsPaused": False}},
{"UserName": "bob", "PlayState": {"IsPaused": False}},
]
)
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
result = await adapter.fetch(service, "activity", {})
assert len(result["sessions"]) == 2
# ---------------------------------------------------------------------------
# Widget references (live-link widgets across dashboards)
# ---------------------------------------------------------------------------
@pytest.fixture
def widget_ref_client(monkeypatch):
"""TestClient with an isolated SettingsStore + encryption key."""
monkeypatch.setenv(
"MANAGE_ENCRYPTION_KEY",
Fernet.generate_key().decode(),
)
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
reset_encryption_key_cache()
import tempfile
from pathlib import Path
store = SettingsStore(str(Path(tempfile.mkdtemp()) / "test.db"))
store.ensure_defaults()
def get_store_override():
return store
app.dependency_overrides[get_settings_store] = get_store_override
client = TestClient(app)
yield client, store
app.dependency_overrides.pop(get_settings_store, None)
def test_widget_reference_lifecycle(widget_ref_client):
"""Create a widget, reference it on 'main', verify it appears, delete reference."""
client, store = widget_ref_client
# Create a service-bound widget (simulating one on a Grafana Overview).
store.upsert_service(
{
"service_type": "grafana",
"name": "Grafana",
"config": {"base_url": "https://grafana.example.com"},
"secrets": {"api_key": "tok"},
"enabled": True,
},
)
service = store.list_services("grafana")[0]
widget = store.upsert_widget({
"service_id": service["id"],
"widget_kind": "chart",
"title": "CPU IOWait",
"config": {"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
"enabled": True,
"sort_order": 0,
})
# Reference it on "main" dashboard.
resp = client.post("/api/widgets/references", json={
"dashboard_scope": "main",
"widget_id": widget["id"],
"sort_order": 5,
})
assert resp.status_code == 201
ref = resp.json()
assert ref["dashboard_scope"] == "main"
assert ref["widget_id"] == widget["id"]
ref_id = ref["id"]
# List references for "main" — should include our widget.
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
assert resp.status_code == 200
refs = resp.json()
assert len(refs) == 1
assert refs[0]["widget"]["title"] == "CPU IOWait"
# Delete the reference.
resp = client.delete(f"/api/widgets/references/{ref_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
# Reference is gone, original widget still exists.
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
assert len(resp.json()) == 0
assert store.get_widget(widget["id"]) is not None
def test_widget_reference_detach(widget_ref_client):
"""Detach clones the widget into a standalone instance and removes the reference."""
client, store = widget_ref_client
store.upsert_service(
{
"service_type": "grafana",
"name": "Grafana",
"config": {"base_url": "https://grafana.example.com"},
"secrets": {"api_key": "tok"},
"enabled": True,
},
)
service = store.list_services("grafana")[0]
widget = store.upsert_widget({
"service_id": service["id"],
"widget_kind": "chart",
"title": "Memory",
"config": {"query": "mem", "datasource_uid": "prometheus"},
"enabled": True,
"sort_order": 0,
})
# Reference on "main".
resp = client.post("/api/widgets/references", json={
"dashboard_scope": "main",
"widget_id": widget["id"],
})
ref_id = resp.json()["id"]
# Detach.
resp = client.post(f"/api/widgets/references/{ref_id}/detach")
assert resp.status_code == 200
cloned = resp.json()
assert cloned["title"] == "Memory"
assert cloned["widget_kind"] == "chart"
assert cloned["service_id"] is None # dashboard-scoped clone
assert cloned["config"]["query"] == "mem"
assert cloned["id"] != widget["id"] # new independent widget
# Reference is gone.
refs = client.get("/api/widgets/references", params={"dashboard_scope": "main"}).json()
assert len(refs) == 0
# Original still exists.
assert store.get_widget(widget["id"]) is not None
+367 -1
View File
@@ -20,6 +20,7 @@
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-oidc-context": "^3.3.1", "react-oidc-context": "^3.3.1",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
"recharts": "^3.9.2",
"shadcn": "^4.7.0", "shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
@@ -2997,6 +2998,32 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
@@ -3283,7 +3310,12 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true, "license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
@@ -3772,6 +3804,69 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3852,6 +3947,12 @@
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validate-npm-package-name": { "node_modules/@types/validate-npm-package-name": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -4990,6 +5091,127 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -5037,6 +5259,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -5311,6 +5539,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5553,6 +5791,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/eventsource": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -6291,6 +6535,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -6333,6 +6587,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.2.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
@@ -8109,6 +8372,13 @@
"react": "^19.2.5" "react": "^19.2.5"
} }
}, },
"node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT",
"peer": true
},
"node_modules/react-oidc-context": { "node_modules/react-oidc-context": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.1.tgz", "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.1.tgz",
@@ -8122,6 +8392,29 @@
"react": ">=16.14.0" "react": ">=16.14.0"
} }
}, },
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -8254,6 +8547,36 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/recharts": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -8268,6 +8591,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -8286,6 +8624,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -9377,6 +9721,28 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.10", "version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
+1
View File
@@ -25,6 +25,7 @@
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-oidc-context": "^3.3.1", "react-oidc-context": "^3.3.1",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
"recharts": "^3.9.2",
"shadcn": "^4.7.0", "shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
+41
View File
@@ -6,6 +6,21 @@ import type {
WidgetInstanceInput, WidgetInstanceInput,
} from "../types"; } from "../types";
export interface WidgetReference {
id: string;
dashboard_scope: string;
widget_id: string;
sort_order: number;
created_at: number;
widget: WidgetInstance;
}
export interface WidgetReferenceInput {
dashboard_scope: string;
widget_id: string;
sort_order?: number;
}
export async function fetchBuiltinWidgetKinds(): Promise< export async function fetchBuiltinWidgetKinds(): Promise<
BuiltinWidgetKindInfo[] BuiltinWidgetKindInfo[]
> { > {
@@ -46,3 +61,29 @@ export async function fetchWidgetData(
): Promise<WidgetDataResponse> { ): Promise<WidgetDataResponse> {
return get<WidgetDataResponse>(`/api/widgets/instances/${widgetId}/data`); return get<WidgetDataResponse>(`/api/widgets/instances/${widgetId}/data`);
} }
export async function fetchWidgetReferences(
dashboardScope: string,
): Promise<WidgetReference[]> {
return get<WidgetReference[]>("/api/widgets/references", {
dashboard_scope: dashboardScope,
});
}
export async function createWidgetReference(
input: WidgetReferenceInput,
): Promise<WidgetReference> {
return post<WidgetReference>("/api/widgets/references", input);
}
export async function deleteWidgetReference(
referenceId: string,
): Promise<{ status: string }> {
return del<{ status: string }>(`/api/widgets/references/${referenceId}`);
}
export async function detachWidgetReference(
referenceId: string,
): Promise<WidgetInstance> {
return post<WidgetInstance>(`/api/widgets/references/${referenceId}/detach`);
}
+235 -43
View File
@@ -7,6 +7,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import {
@@ -18,11 +19,23 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
import { import {
ChevronDown,
ChevronUp,
Link2,
Pencil,
Plus,
Trash2,
Split,
} from "lucide-react";
import {
useCreateWidgetReference,
useDeleteWidgetInstance, useDeleteWidgetInstance,
useDeleteWidgetReference,
useDetachWidgetReference,
useSaveWidgetInstance, useSaveWidgetInstance,
useWidgetInstances, useWidgetInstances,
useWidgetReferences,
} from "../hooks/useWidgets"; } from "../hooks/useWidgets";
import { useServiceInstances } from "../hooks/useServices"; import { useServiceInstances } from "../hooks/useServices";
import { useTasks } from "../hooks/useSettings"; import { useTasks } from "../hooks/useSettings";
@@ -38,6 +51,10 @@ import {
interface Props { interface Props {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
/** When set, scope the dialog to a specific service instance's widgets. */
serviceId?: string;
/** When set, enable widget references ("Add existing") for this dashboard scope. */
dashboardScope?: string;
} }
interface Draft { interface Draft {
@@ -135,6 +152,12 @@ function WidgetConfigEditor({
const isNumber = const isNumber =
(schema as { type?: string }).type === "integer" || (schema as { type?: string }).type === "integer" ||
(schema as { type?: string }).type === "number"; (schema as { type?: string }).type === "number";
// Use a multi-line textarea for fields that tend to hold complex
// multi-line values (PromQL, text blocks, etc.). The widget kind's
// config schema can opt in via `format: "textarea"`; the well-known
// `query` field is treated as textarea by default.
const schemaFormat = (schema as { format?: string }).format;
const isTextarea = schemaFormat === "textarea" || key === "query";
return ( return (
<Field <Field
key={key} key={key}
@@ -142,21 +165,31 @@ function WidgetConfigEditor({
htmlFor={`widget-cfg-${key}`} htmlFor={`widget-cfg-${key}`}
helper={(schema as { description?: string }).description} helper={(schema as { description?: string }).description}
> >
<Input {isTextarea ? (
id={`widget-cfg-${key}`} <Textarea
type={isNumber ? "number" : "text"} id={`widget-cfg-${key}`}
value={String(config[key] ?? "")} rows={4}
onChange={(e) => className="resize-y font-mono text-xs"
onChange({ value={String(config[key] ?? "")}
...config, onChange={(e) => onChange({ ...config, [key]: e.target.value })}
[key]: isNumber />
? e.target.value === "" ) : (
? undefined <Input
: Number(e.target.value) id={`widget-cfg-${key}`}
: e.target.value, type={isNumber ? "number" : "text"}
}) value={String(config[key] ?? "")}
} onChange={(e) =>
/> onChange({
...config,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
)}
</Field> </Field>
); );
})} })}
@@ -164,12 +197,24 @@ function WidgetConfigEditor({
); );
} }
export function WidgetConfigDialog({ open, onClose }: Props) { export function WidgetConfigDialog({
const { data: instances = [] } = useWidgetInstances(); open,
onClose,
serviceId,
dashboardScope,
}: Props) {
const { data: instances = [] } = useWidgetInstances(serviceId);
const { data: services = [] } = useServiceInstances(); const { data: services = [] } = useServiceInstances();
const { data: tasks = [] } = useTasks(); const { data: tasks = [] } = useTasks();
const saveWidget = useSaveWidgetInstance(); const saveWidget = useSaveWidgetInstance();
const deleteWidget = useDeleteWidgetInstance(); const deleteWidget = useDeleteWidgetInstance();
const { data: references = [] } = useWidgetReferences(dashboardScope);
const createRef = useCreateWidgetReference();
const deleteRef = useDeleteWidgetReference();
const detachRef = useDetachWidgetReference();
const { data: allWidgets = [] } = useWidgetInstances();
const [showExisting, setShowExisting] = useState(false);
const [existingSearch, setExistingSearch] = useState("");
const [draft, setDraft] = useState<Draft | null>(null); const [draft, setDraft] = useState<Draft | null>(null);
@@ -184,7 +229,7 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
function startAddBuiltIn(kind: string) { function startAddBuiltIn(kind: string) {
const binding = BUILTIN_WIDGETS[kind]; const binding = BUILTIN_WIDGETS[kind];
setDraft({ setDraft({
serviceId: null, serviceId: serviceId ?? null,
widgetKind: kind, widgetKind: kind,
title: binding?.name ?? kind, title: binding?.name ?? kind,
config: { ...(binding?.defaultConfig ?? {}) }, config: { ...(binding?.defaultConfig ?? {}) },
@@ -255,16 +300,65 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return; if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
const a = sortedInstances[index]; const a = sortedInstances[index];
const b = sortedInstances[targetIndex]; const b = sortedInstances[targetIndex];
await Promise.all([ // Sequential (not Promise.all) to avoid a race where the first mutation's
saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }), // cache invalidation refetches before the second completes, reverting the swap.
saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }), await saveWidget.mutateAsync({ ...a, sort_order: b.sort_order });
]); await saveWidget.mutateAsync({ ...b, sort_order: a.sort_order });
} }
async function removeInstance(instance: WidgetInstance) { async function removeInstance(instance: WidgetInstance) {
await deleteWidget.mutateAsync(instance.id); await deleteWidget.mutateAsync(instance.id);
} }
// Build a combined view of owned widgets + references for display.
const referencedWidgetIds = new Set(references.map((r) => r.widget_id));
const combinedWidgets = useMemo(() => {
const owned = [...instances].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
const refs = references.map((r) => ({
...r.widget,
_ref_id: r.id,
_is_reference: true as const,
}));
return [...owned, ...refs].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
}, [instances, references]);
// Available widgets for the "Add existing" picker: all widgets not already
// on this dashboard (owned or referenced).
const availableWidgets = useMemo(() => {
const onDashboard = new Set([
...instances.map((w) => w.id),
...referencedWidgetIds,
]);
const search = existingSearch.toLowerCase().trim();
return allWidgets
.filter((w) => !onDashboard.has(w.id))
.filter(
(w) =>
!search ||
w.title.toLowerCase().includes(search) ||
w.widget_kind.toLowerCase().includes(search),
);
}, [allWidgets, instances, referencedWidgetIds, existingSearch]);
async function handleAddReference(widgetId: string) {
await createRef.mutateAsync({
dashboard_scope: dashboardScope!,
widget_id: widgetId,
});
}
async function handleRemoveReference(refId: string) {
await deleteRef.mutateAsync(refId);
}
async function handleDetach(refId: string) {
await detachRef.mutateAsync(refId);
}
function handleClose(next: boolean) { function handleClose(next: boolean) {
if (!next) { if (!next) {
reset(); reset();
@@ -332,10 +426,18 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
/> />
{!isMobile ? ( {!isMobile ? (
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={reset} className="mobile-touch-target"> <Button
variant="outline"
onClick={reset}
className="mobile-touch-target"
>
Back Back
</Button> </Button>
<Button onClick={saveDraft} disabled={saveWidget.isPending} className="mobile-touch-target"> <Button
onClick={saveDraft}
disabled={saveWidget.isPending}
className="mobile-touch-target"
>
Save widget Save widget
</Button> </Button>
</div> </div>
@@ -343,16 +445,19 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{sortedInstances.length === 0 ? ( {combinedWidgets.length === 0 ? (
<Alert> <Alert>
<AlertDescription>No widgets yet. Add one below.</AlertDescription> <AlertDescription>No widgets yet. Add one below.</AlertDescription>
</Alert> </Alert>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{sortedInstances.map((instance, index) => { {combinedWidgets.map((instance, index) => {
const serviceName = instance.service_id const serviceName = instance.service_id
? services.find((s) => s.id === instance.service_id)?.name ? services.find((s) => s.id === instance.service_id)?.name
: "Built-in"; : "Built-in";
const isRef =
(instance as { _is_reference?: boolean })._is_reference === true;
const refId = (instance as { _ref_id?: string })._ref_id;
return ( return (
<div <div
key={instance.id} key={instance.id}
@@ -361,6 +466,12 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<div className="flex flex-1 flex-col gap-1"> <div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">{instance.title}</span> <span className="font-medium">{instance.title}</span>
{isRef ? (
<Badge variant="secondary">
<Link2 className="mr-1 h-3 w-3" />
linked
</Badge>
) : null}
<Badge variant="outline"> <Badge variant="outline">
{bindingLabel(instance.service_id, instance.widget_kind)} {bindingLabel(instance.service_id, instance.widget_kind)}
</Badge> </Badge>
@@ -388,30 +499,52 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="mobile-touch-target h-8 w-8" className="mobile-touch-target h-8 w-8"
disabled={index === sortedInstances.length - 1} disabled={index === combinedWidgets.length - 1}
onClick={() => moveInstance(index, 1)} onClick={() => moveInstance(index, 1)}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</Button> </Button>
<Switch {!isRef ? (
className="mobile-touch-target" <Switch
checked={instance.enabled} className="mobile-touch-target"
onCheckedChange={() => toggleEnabled(instance)} checked={instance.enabled}
aria-label={`Toggle ${instance.title}`} onCheckedChange={() => toggleEnabled(instance)}
/> aria-label={`Toggle ${instance.title}`}
<Button />
variant="ghost" ) : null}
size="icon" {!isRef ? (
className="mobile-touch-target h-8 w-8" <Button
onClick={() => startEdit(instance)} variant="ghost"
> size="icon"
<Pencil className="h-4 w-4" /> className="mobile-touch-target h-8 w-8"
</Button> onClick={() => startEdit(instance)}
>
<Pencil className="h-4 w-4" />
</Button>
) : null}
{isRef && refId ? (
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
title="Make an independent copy"
onClick={() => handleDetach(refId)}
>
<Split className="h-4 w-4" />
</Button>
) : null}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="mobile-touch-target h-8 w-8 text-destructive" className="mobile-touch-target h-8 w-8 text-destructive"
onClick={() => removeInstance(instance)} title={
isRef ? "Remove from this dashboard" : "Delete widget"
}
onClick={() =>
isRef && refId
? handleRemoveReference(refId)
: removeInstance(instance)
}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -422,6 +555,65 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
</div> </div>
)} )}
{dashboardScope ? (
<div className="flex flex-col gap-2">
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => setShowExisting(!showExisting)}
>
<Link2 className="mr-1 h-3 w-3" />
{showExisting ? "Hide" : "Add existing widget"}
</Button>
{showExisting ? (
<div className="flex flex-col gap-2">
<Input
placeholder="Search widgets..."
value={existingSearch}
onChange={(e) => setExistingSearch(e.target.value)}
/>
{availableWidgets.length === 0 ? (
<p className="text-xs text-muted-foreground">
No widgets available to reuse.
</p>
) : (
<div className="flex flex-col gap-1">
{availableWidgets.map((w) => {
const owner = w.service_id
? services.find((s) => s.id === w.service_id)?.name
: "Dashboard";
return (
<div
key={w.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col">
<span className="text-sm font-medium">{w.title}</span>
<span className="text-xs text-muted-foreground">
{bindingLabel(w.service_id, w.widget_kind)} ·{" "}
{owner}
</span>
</div>
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => handleAddReference(w.id)}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
);
})}
</div>
)}
</div>
) : null}
</div>
) : null}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-sm font-medium">Add widget</p> <p className="text-sm font-medium">Add widget</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -18,8 +18,12 @@ function setMatchMedia(matches: boolean) {
vi.mock("../../hooks/useWidgets", () => ({ vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: [] }), useWidgetInstances: () => ({ data: [] }),
useWidgetReferences: () => ({ data: [] }),
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }), useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }), useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCreateWidgetReference: () => ({ mutateAsync: vi.fn() }),
useDeleteWidgetReference: () => ({ mutateAsync: vi.fn() }),
useDetachWidgetReference: () => ({ mutateAsync: vi.fn() }),
})); }));
vi.mock("../../hooks/useServices", () => ({ vi.mock("../../hooks/useServices", () => ({
+43
View File
@@ -1,10 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
createWidgetInstance, createWidgetInstance,
createWidgetReference,
deleteWidgetInstance, deleteWidgetInstance,
deleteWidgetReference,
detachWidgetReference,
fetchBuiltinWidgetKinds, fetchBuiltinWidgetKinds,
fetchWidgetData, fetchWidgetData,
fetchWidgetInstances, fetchWidgetInstances,
fetchWidgetReferences,
updateWidgetInstance, updateWidgetInstance,
} from "../api/widgets"; } from "../api/widgets";
import type { WidgetInstanceInput } from "../types"; import type { WidgetInstanceInput } from "../types";
@@ -58,3 +62,42 @@ export function useBuiltinWidgetKinds() {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
export function useWidgetReferences(dashboardScope: string | undefined) {
return useQuery({
queryKey: ["widgets", "references", dashboardScope ?? null],
queryFn: () => fetchWidgetReferences(dashboardScope!),
enabled: !!dashboardScope,
refetchInterval: 60_000,
});
}
export function useCreateWidgetReference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createWidgetReference,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets", "references"] });
},
});
}
export function useDeleteWidgetReference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteWidgetReference,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets", "references"] });
},
});
}
export function useDetachWidgetReference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: detachWidgetReference,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets"] });
},
});
}
@@ -23,6 +23,7 @@ describe("service registry", () => {
it("binds widget kinds per service", () => { it("binds widget kinds per service", () => {
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
"link", "link",
"chart",
]); ]);
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
"active_alerts", "active_alerts",
+44
View File
@@ -2,7 +2,9 @@ import type { ComponentType } from "react";
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget"; import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget"; import { BackupsWidget } from "../widgets/BackupsWidget";
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget"; import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
import { GrafanaChartWidget } from "../widgets/GrafanaChartWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget"; import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget"; import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
import { SshTaskWidget } from "../widgets/SshTaskWidget"; import { SshTaskWidget } from "../widgets/SshTaskWidget";
import { StaticWidget } from "../widgets/StaticWidget"; import { StaticWidget } from "../widgets/StaticWidget";
@@ -86,6 +88,39 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
}, },
component: GrafanaLinkWidget, component: GrafanaLinkWidget,
}, },
{
kind: "chart",
name: "Chart",
description: "Live time-series chart from a Grafana datasource query.",
refreshIntervalMs: 60_000,
defaultConfig: {
datasource_uid: "prometheus",
query: "",
from_ts: "now-1h",
to_ts: "now",
interval_ms: 30_000,
max_data_points: 100,
},
configSchema: {
type: "object",
properties: {
datasource_uid: {
type: "string",
description: "Grafana datasource UID (e.g. 'prometheus')",
},
query: {
type: "string",
description: "Query expression (e.g. PromQL)",
},
from_ts: { type: "string" },
to_ts: { type: "string" },
interval_ms: { type: "integer" },
max_data_points: { type: "integer" },
},
required: ["query"],
},
component: GrafanaChartWidget,
},
], ],
}, },
prometheus: { prometheus: {
@@ -122,6 +157,15 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
configSchema: { type: "object", properties: {}, required: [] }, configSchema: { type: "object", properties: {}, required: [] },
component: JellyfinWidget, component: JellyfinWidget,
}, },
{
kind: "now_playing",
name: "Now Playing",
description: "Only sessions actively playing media.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: JellyfinNowPlayingWidget,
},
], ],
}, },
nextcloud: { nextcloud: {
+11 -8
View File
@@ -31,7 +31,7 @@ import {
useDeleteDashboardShortcut, useDeleteDashboardShortcut,
useSaveDashboardShortcut, useSaveDashboardShortcut,
} from "../hooks/useDashboard"; } from "../hooks/useDashboard";
import { useWidgetInstances } from "../hooks/useWidgets"; import { useWidgetInstances, useWidgetReferences } from "../hooks/useWidgets";
import { useServiceInstances } from "../hooks/useServices"; import { useServiceInstances } from "../hooks/useServices";
import { useIsMobile } from "../hooks/useIsMobile"; import { useIsMobile } from "../hooks/useIsMobile";
import type { import type {
@@ -452,16 +452,18 @@ export function Dashboard() {
undefined, undefined,
"dashboard", "dashboard",
); );
const { data: widgetReferences = [] } = useWidgetReferences("main");
const { data: services = [] } = useServiceInstances(); const { data: services = [] } = useServiceInstances();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const visibleWidgets = useMemo( const visibleWidgets = useMemo(() => {
() => const refs = widgetReferences
widgetInstances .filter((r) => r.widget.enabled)
.filter((w) => w.enabled) .map((r) => r.widget);
.sort((a, b) => a.sort_order - b.sort_order), return [...widgetInstances, ...refs]
[widgetInstances], .filter((w) => w.enabled)
); .sort((a, b) => a.sort_order - b.sort_order);
}, [widgetInstances, widgetReferences]);
const mobileSections = useMemo( const mobileSections = useMemo(
() => groupWidgetsBySection(visibleWidgets, services), () => groupWidgetsBySection(visibleWidgets, services),
@@ -592,6 +594,7 @@ export function Dashboard() {
<WidgetConfigDialog <WidgetConfigDialog
open={widgetDialogOpen} open={widgetDialogOpen}
onClose={() => setWidgetDialogOpen(false)} onClose={() => setWidgetDialogOpen(false)}
dashboardScope="main"
/> />
</div> </div>
); );
@@ -23,6 +23,7 @@ vi.mock("../../hooks/useSettings", () => ({
})); }));
vi.mock("../../hooks/useWidgets", () => ({ vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: [] }), useWidgetInstances: () => ({ data: [] }),
useWidgetReferences: () => ({ data: [] }),
})); }));
vi.mock("../../hooks/useServices", () => ({ vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [] }), useServiceInstances: () => ({ data: [] }),
@@ -72,6 +72,7 @@ export function OverviewTab({ instance }: { instance: ServiceInstance }) {
<WidgetConfigDialog <WidgetConfigDialog
open={configOpen} open={configOpen}
onClose={() => setConfigOpen(false)} onClose={() => setConfigOpen(false)}
serviceId={instance.id}
/> />
</div> </div>
); );
+120
View File
@@ -0,0 +1,120 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
interface SeriesPoint {
t: number;
v: number | null;
}
interface ChartSeries {
label: string;
points: SeriesPoint[];
}
/** Merge multiple time-series into a single recharts-friendly array. */
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
const map = new Map<number, Record<string, unknown>>();
for (const s of series) {
for (const p of s.points) {
const existing = map.get(p.t) ?? { time: p.t };
existing[s.label] = p.v;
map.set(p.t, existing);
}
}
return [...map.values()].sort(
(a, b) => (a.time as number) - (b.time as number),
);
}
function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
export function GrafanaChartWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const series = data?.data?.series as ChartSeries[] | undefined;
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-[300px] w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : series && series.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={mergeSeries(series)}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tickFormatter={formatTime}
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
/>
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<Tooltip
labelFormatter={(label) => formatTime(Number(label))}
contentStyle={{
backgroundColor: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
color: "hsl(var(--popover-foreground))",
}}
/>
{series.map((s, i) => (
<Line
key={s.label}
type="monotone"
dataKey={s.label}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
dot={false}
strokeWidth={2}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
) : (
<Alert>
<AlertDescription>
No data. Check your query and datasource_uid in the widget config.
</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
@@ -0,0 +1,41 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SessionActivityPanel } from "../components/SessionActivityPanel";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { NowPlayingSession, WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function JellyfinNowPlayingWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : Array.isArray(sessions) ? (
<SessionActivityPanel
sessions={sessions}
emptyMessage="No one is playing right now."
/>
) : null}
</SectionCard>
);
}
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { GrafanaChartWidget } from "../GrafanaChartWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "chart",
title: "CPU Usage",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "w1", error, fetched_at: 0 }
: { widget_id: "w1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("GrafanaChartWidget", () => {
it("renders a chart with series data", () => {
mockData({
series: [
{
label: "cpu",
points: [
{ t: 1000, v: 0.5 },
{ t: 2000, v: 0.8 },
],
},
],
});
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
// recharts renders an SVG; the title from SectionCard should be present.
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
});
it("shows error Alert on error", () => {
mockData(null, "Grafana api_key is required for chart queries");
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/api_key is required/i)).toBeInTheDocument();
});
it("shows empty state when no series", () => {
mockData({ series: [] });
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { JellyfinNowPlayingWidget } from "../JellyfinNowPlayingWidget";
import type { WidgetInstance } from "../../types";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "now_playing",
title: "Now Playing",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
describe("JellyfinNowPlayingWidget", () => {
it("renders sessions when data is present", async () => {
const { useWidgetData } = await import("../../hooks/useWidgets");
vi.mocked(useWidgetData).mockReturnValue({
data: {
widget_id: "w1",
data: {
sessions: [
{
user: "alice",
title: "Movie",
state: "playing",
type: "Movie",
device: "Web",
session_id: "s1",
transcoding: "no",
transcoding_type: "",
},
],
},
fetched_at: 0,
},
isLoading: false,
} as never);
render(
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(screen.getByText("alice")).toBeInTheDocument();
expect(screen.getAllByText("Movie").length).toBeGreaterThan(0);
});
it("renders the now-playing empty message when no sessions", async () => {
const { useWidgetData } = await import("../../hooks/useWidgets");
vi.mocked(useWidgetData).mockReturnValue({
data: {
widget_id: "w1",
data: { sessions: [] },
fetched_at: 0,
},
isLoading: false,
} as never);
render(
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(
screen.getByText("No one is playing right now."),
).toBeInTheDocument();
});
it("renders error state", async () => {
const { useWidgetData } = await import("../../hooks/useWidgets");
vi.mocked(useWidgetData).mockReturnValue({
data: {
widget_id: "w1",
error: "Connection failed",
fetched_at: 0,
},
isLoading: false,
} as never);
render(
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(screen.getByText("Connection failed")).toBeInTheDocument();
});
});
+1
View File
@@ -1,6 +1,7 @@
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget"; export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
export { BackupsWidget } from "./BackupsWidget"; export { BackupsWidget } from "./BackupsWidget";
export { GrafanaLinkWidget } from "./GrafanaLinkWidget"; export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
export { GrafanaChartWidget } from "./GrafanaChartWidget";
export { JellyfinWidget } from "./JellyfinWidget"; export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget"; export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { SshTaskWidget } from "./SshTaskWidget"; export { SshTaskWidget } from "./SshTaskWidget";