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