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:
@@ -31,7 +31,7 @@ import {
|
||||
useDeleteDashboardShortcut,
|
||||
useSaveDashboardShortcut,
|
||||
} from "../hooks/useDashboard";
|
||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||
import { useWidgetInstances, useWidgetReferences } from "../hooks/useWidgets";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import type {
|
||||
@@ -452,16 +452,18 @@ export function Dashboard() {
|
||||
undefined,
|
||||
"dashboard",
|
||||
);
|
||||
const { data: widgetReferences = [] } = useWidgetReferences("main");
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const visibleWidgets = useMemo(
|
||||
() =>
|
||||
widgetInstances
|
||||
.filter((w) => w.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order),
|
||||
[widgetInstances],
|
||||
);
|
||||
const visibleWidgets = useMemo(() => {
|
||||
const refs = widgetReferences
|
||||
.filter((r) => r.widget.enabled)
|
||||
.map((r) => r.widget);
|
||||
return [...widgetInstances, ...refs]
|
||||
.filter((w) => w.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
}, [widgetInstances, widgetReferences]);
|
||||
|
||||
const mobileSections = useMemo(
|
||||
() => groupWidgetsBySection(visibleWidgets, services),
|
||||
@@ -592,6 +594,7 @@ export function Dashboard() {
|
||||
<WidgetConfigDialog
|
||||
open={widgetDialogOpen}
|
||||
onClose={() => setWidgetDialogOpen(false)}
|
||||
dashboardScope="main"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("../../hooks/useSettings", () => ({
|
||||
}));
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: () => ({ data: [] }),
|
||||
useWidgetReferences: () => ({ data: [] }),
|
||||
}));
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: [] }),
|
||||
|
||||
Reference in New Issue
Block a user