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.
This commit is contained in:
@@ -12,6 +12,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
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.integrations.base import validate_config
|
||||
@@ -34,6 +35,15 @@ from media_library_viewer_api.widgets.sources import (
|
||||
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"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -221,3 +231,52 @@ async def fetch_data(
|
||||
error=data.get("error"),
|
||||
fetched_at=int(time.time()),
|
||||
).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")
|
||||
if "widget_kind" not in widget_cols:
|
||||
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("""
|
||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1491,6 +1504,98 @@ class SettingsStore:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
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
|
||||
|
||||
@@ -185,21 +185,14 @@ class GrafanaWidgetSource:
|
||||
# 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")
|
||||
)
|
||||
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("__")
|
||||
]
|
||||
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")
|
||||
|
||||
@@ -724,3 +724,133 @@ async def test_jellyfin_activity_shows_all_sessions():
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user