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:
Developer
2026-07-06 11:34:48 +00:00
parent 94bf830955
commit c36262d7b6
10 changed files with 581 additions and 37 deletions
+41
View File
@@ -6,6 +6,21 @@ import type {
WidgetInstanceInput,
} 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<
BuiltinWidgetKindInfo[]
> {
@@ -46,3 +61,29 @@ export async function fetchWidgetData(
): Promise<WidgetDataResponse> {
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`);
}