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
|
||||
|
||||
Reference in New Issue
Block a user