DashboardScene: Support for dashboard PanelContext actions via state hook (#76192)

* DashboardScene: Support for dashboard PanelContext actions via state hook

* Update

* Progress

* Update

* Update

* update
pull/76525/head
Torkel Ödegaard 2 years ago committed by GitHub
parent b6fb1e52f2
commit de8ab7efe7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      .betterer.results
  2. 1
      docs/sources/developers/kinds/core/dashboard/schema-reference.md
  3. 3
      kinds/dashboard/dashboard_kind.cue
  4. 2
      package.json
  5. 5
      packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts
  6. 3
      pkg/kinds/dashboard/dashboard_spec_gen.go
  7. 4
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  8. 2
      public/app/features/dashboard-scene/scene/LibraryVizPanel.tsx
  9. 238
      public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts
  10. 188
      public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts
  11. 6
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts
  12. 4
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
  13. 1
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts
  14. 10
      yarn.lock

@ -2999,6 +2999,9 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
],
"public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/dashboard-scene/serialization/angularMigration.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]

@ -117,6 +117,7 @@ FROM: AnnotationQuery in grafana-data/src/types/annotations.ts
| `enable` | boolean | **Yes** | `true` | When enabled the annotation query is issued with every dashboard refresh |
| `iconColor` | string | **Yes** | | Color to use for the annotation event markers |
| `name` | string | **Yes** | | Name of annotation. |
| `builtIn` | number | No | `0` | Set to 1 for the standard annotation query all dashboards have by default. |
| `filter` | [AnnotationPanelFilter](#annotationpanelfilter) | No | | |
| `hide` | boolean | No | `false` | Annotation queries can be toggled on or off at the top of the dashboard.<br/>When hide is true, the toggle is not shown in the dashboard. |
| `target` | [AnnotationTarget](#annotationtarget) | No | | TODO: this should be a regular DataQuery that depends on the selected dashboard<br/>these match the properties of the "grafana" datasouce that is default in most dashboards |

@ -176,6 +176,9 @@ lineage: schemas: [{
// TODO -- this should not exist here, it is based on the --grafana-- datasource
type?: string @grafanamaturity(NeedsExpertReview)
// Set to 1 for the standard annotation query all dashboards have by default.
builtIn?: number | *0
// unless datasources have migrated to the target+mapping,
// they just spread their query into the base object :(
...

@ -251,7 +251,7 @@
"@grafana/lezer-traceql": "0.0.7",
"@grafana/monaco-logql": "^0.0.7",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^1.15.0",
"@grafana/scenes": "^1.18.0",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"@kusto/monaco-kusto": "^7.4.0",

@ -77,6 +77,10 @@ export const defaultAnnotationContainer: Partial<AnnotationContainer> = {
* FROM: AnnotationQuery in grafana-data/src/types/annotations.ts
*/
export interface AnnotationQuery {
/**
* Set to 1 for the standard annotation query all dashboards have by default.
*/
builtIn?: number;
/**
* Datasource where the annotations data is
*/
@ -113,6 +117,7 @@ export interface AnnotationQuery {
}
export const defaultAnnotationQuery: Partial<AnnotationQuery> = {
builtIn: 0,
enable: true,
hide: false,
};

@ -187,6 +187,9 @@ type AnnotationPanelFilter struct {
// TODO docs
// FROM: AnnotationQuery in grafana-data/src/types/annotations.ts
type AnnotationQuery struct {
// Set to 1 for the standard annotation query all dashboards have by default.
BuiltIn *float32 `json:"builtIn,omitempty"`
// Ref to a DataSource instance
Datasource DataSourceRef `json:"datasource"`

@ -213,4 +213,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
panelId: (panel && getPanelIdForVizPanel(panel)) ?? 0,
};
}
canEditDashboard() {
return Boolean(this.state.meta.canEdit || this.state.meta.canMakeEditable);
}
}

@ -42,7 +42,7 @@ export class LibraryVizPanel extends SceneObjectBase<LibraryVizPanelState> {
});
} catch (err) {
vizPanel.setState({
pluginLoadError: 'Unable to load library panel: ' + this.state.uid,
_pluginLoadError: 'Unable to load library panel: ' + this.state.uid,
});
}

@ -0,0 +1,238 @@
import { EventBusSrv } from '@grafana/data';
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
import { PanelContext } from '@grafana/ui';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { findVizPanelByKey } from '../utils/utils';
import { getAdHocFilterSetFor, setDashboardPanelContext } from './setDashboardPanelContext';
const postFn = jest.fn();
const putFn = jest.fn();
const deleteFn = jest.fn();
setBackendSrv({
post: postFn,
put: putFn,
delete: deleteFn,
} as any as BackendSrv);
describe('setDashboardPanelContext', () => {
describe('canAddAnnotations', () => {
it('Can add when builtIn is enabled and permissions allow', () => {
const { context } = buildTestScene({ builtInAnnotationsEnabled: true, dashboardCanEdit: true, canAdd: true });
expect(context.canAddAnnotations!()).toBe(true);
});
it('Can not when builtIn is disabled', () => {
const { context } = buildTestScene({ builtInAnnotationsEnabled: false, dashboardCanEdit: true, canAdd: true });
expect(context.canAddAnnotations!()).toBe(false);
});
it('Can not when permission do not allow', () => {
const { context } = buildTestScene({ builtInAnnotationsEnabled: true, dashboardCanEdit: true, canAdd: false });
expect(context.canAddAnnotations!()).toBe(false);
});
});
describe('canEditAnnotations', () => {
it('Can edit global event when user has org permission', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, orgCanEdit: true });
expect(context.canEditAnnotations!()).toBe(true);
});
it('Can not edit global event when has no org permission', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, orgCanEdit: false });
expect(context.canEditAnnotations!()).toBe(false);
});
it('Can edit dashboard event when has dashboard permission', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, canEdit: true });
expect(context.canEditAnnotations!('dash-uid')).toBe(true);
});
it('Can not edit dashboard event when has no dashboard permission', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, canEdit: false });
expect(context.canEditAnnotations!('dash-uid')).toBe(false);
});
});
describe('canDeleteAnnotations', () => {
it('Can delete global event when user has org permission', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, canDelete: true });
expect(context.canDeleteAnnotations!()).toBe(true);
});
it('Can not delete global event when has no org permission', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, canDelete: false });
expect(context.canDeleteAnnotations!()).toBe(false);
});
it('Can delete dashboard event when has dashboard permission', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, canDelete: true });
expect(context.canDeleteAnnotations!('dash-uid')).toBe(true);
});
it('Can not delete dashboard event when has no dashboard permission', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, canDelete: false });
expect(context.canDeleteAnnotations!('dash-uid')).toBe(false);
});
});
describe('onAnnotationCreate', () => {
it('should create annotation', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, canAdd: true });
context.onAnnotationCreate!({ from: 100, to: 200, description: 'save it', tags: [] });
expect(postFn).toHaveBeenCalledWith('/api/annotations', {
dashboardUID: 'dash-1',
isRegion: true,
panelId: 4,
tags: [],
text: 'save it',
time: 100,
timeEnd: 200,
});
});
});
describe('onAnnotationUpdate', () => {
it('should update annotation', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, canAdd: true });
context.onAnnotationUpdate!({ from: 100, to: 200, id: 'event-id-123', description: 'updated', tags: [] });
expect(putFn).toHaveBeenCalledWith('/api/annotations/event-id-123', {
id: 'event-id-123',
dashboardUID: 'dash-1',
isRegion: true,
panelId: 4,
tags: [],
text: 'updated',
time: 100,
timeEnd: 200,
});
});
});
describe('onAnnotationDelete', () => {
it('should update annotation', () => {
const { context } = buildTestScene({ dashboardCanEdit: true, canAdd: true });
context.onAnnotationDelete!('I-do-not-want-you');
expect(deleteFn).toHaveBeenCalledWith('/api/annotations/I-do-not-want-you');
});
});
describe('onAddAdHocFilter', () => {
it('Should add new filter set', () => {
const { scene, context } = buildTestScene({});
context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' });
const set = getAdHocFilterSetFor(scene, { uid: 'my-ds-uid' });
expect(set.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]);
});
it('Should update and add filter to existing set', () => {
const { scene, context } = buildTestScene({ existingFilterSet: true });
const set = getAdHocFilterSetFor(scene, { uid: 'my-ds-uid' });
set.setState({ filters: [{ key: 'existing', value: 'world', operator: '=' }] });
context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' });
expect(set.state.filters.length).toBe(2);
// Can update existing filter value without adding a new filter
context.onAddAdHocFilter!({ key: 'hello', value: 'world2', operator: '=' });
// Verify existing filter value updated
expect(set.state.filters[1].value).toBe('world2');
});
});
});
interface SceneOptions {
builtInAnnotationsEnabled?: boolean;
dashboardCanEdit?: boolean;
canAdd?: boolean;
canEdit?: boolean;
canDelete?: boolean;
orgCanEdit?: boolean;
existingFilterSet?: boolean;
}
function buildTestScene(options: SceneOptions) {
const scene = transformSaveModelToScene({
dashboard: {
title: 'hello',
uid: 'dash-1',
schemaVersion: 38,
annotations: {
list: [
{
builtIn: 1,
datasource: {
type: 'grafana',
uid: '-- Grafana --',
},
enable: options.builtInAnnotationsEnabled ?? false,
hide: true,
iconColor: 'rgba(0, 211, 255, 1)',
name: 'Annotations & Alerts',
target: { refId: 'A' },
type: 'dashboard',
},
],
},
panels: [
{
type: 'timeseries',
id: 4,
datasource: { uid: 'my-ds-uid', type: 'prometheus' },
targets: [],
},
],
templating: {
list: options.existingFilterSet
? [
{
type: 'adhoc',
name: 'Filters',
datasource: { uid: 'my-ds-uid' },
},
]
: [],
},
},
meta: {
canEdit: options.dashboardCanEdit,
annotationsPermissions: {
dashboard: {
canAdd: options.canAdd ?? false,
canEdit: options.canEdit ?? false,
canDelete: options.canDelete ?? false,
},
organization: {
canAdd: false,
canEdit: options.orgCanEdit ?? false,
canDelete: options.canDelete ?? false,
},
},
},
});
const vizPanel = findVizPanelByKey(scene, 'panel-4')!;
const context: PanelContext = {
eventBus: new EventBusSrv(),
eventsScope: 'global',
};
setDashboardPanelContext(vizPanel, context);
return { scene, vizPanel, context };
}

@ -0,0 +1,188 @@
import { AnnotationChangeEvent, AnnotationEventUIModel, CoreApp, DataFrame } from '@grafana/data';
import { AdHocFilterSet, dataLayers, SceneDataLayers, VizPanel } from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema';
import { AdHocFilterItem, PanelContext } from '@grafana/ui';
import { deleteAnnotation, saveAnnotation, updateAnnotation } from 'app/features/annotations/api';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelContext) {
context.app = CoreApp.Dashboard;
context.canAddAnnotations = () => {
const dashboard = getDashboardSceneFor(vizPanel);
const builtInLayer = getBuiltInAnnotationsLayer(dashboard);
// When there is no builtin annotations query we disable the ability to add annotations
if (!builtInLayer || !dashboard.canEditDashboard()) {
return false;
}
// If RBAC is enabled there are additional conditions to check.
return Boolean(dashboard.state.meta.annotationsPermissions?.dashboard.canAdd);
};
context.canEditAnnotations = (dashboardUID?: string) => {
const dashboard = getDashboardSceneFor(vizPanel);
if (!dashboard.canEditDashboard()) {
return false;
}
if (dashboardUID) {
return Boolean(dashboard.state.meta.annotationsPermissions?.dashboard.canEdit);
}
return Boolean(dashboard.state.meta.annotationsPermissions?.organization.canEdit);
};
context.canDeleteAnnotations = (dashboardUID?: string) => {
const dashboard = getDashboardSceneFor(vizPanel);
if (!dashboard.canEditDashboard()) {
return false;
}
if (dashboardUID) {
return Boolean(dashboard.state.meta.annotationsPermissions?.dashboard.canDelete);
}
return Boolean(dashboard.state.meta.annotationsPermissions?.organization.canDelete);
};
context.onAnnotationCreate = async (event: AnnotationEventUIModel) => {
const dashboard = getDashboardSceneFor(vizPanel);
const isRegion = event.from !== event.to;
const anno = {
dashboardUID: dashboard.state.uid,
panelId: getPanelIdForVizPanel(vizPanel),
isRegion,
time: event.from,
timeEnd: isRegion ? event.to : 0,
tags: event.tags,
text: event.description,
};
await saveAnnotation(anno);
reRunBuiltInAnnotationsLayer(dashboard);
context.eventBus.publish(new AnnotationChangeEvent(anno));
};
context.onAnnotationUpdate = async (event: AnnotationEventUIModel) => {
const dashboard = getDashboardSceneFor(vizPanel);
const isRegion = event.from !== event.to;
const anno = {
id: event.id,
dashboardUID: dashboard.state.uid,
panelId: getPanelIdForVizPanel(vizPanel),
isRegion,
time: event.from,
timeEnd: isRegion ? event.to : 0,
tags: event.tags,
text: event.description,
};
await updateAnnotation(anno);
reRunBuiltInAnnotationsLayer(dashboard);
context.eventBus.publish(new AnnotationChangeEvent(anno));
};
context.onAnnotationDelete = async (id: string) => {
await deleteAnnotation({ id });
reRunBuiltInAnnotationsLayer(getDashboardSceneFor(vizPanel));
context.eventBus.publish(new AnnotationChangeEvent({ id }));
};
context.onAddAdHocFilter = (newFilter: AdHocFilterItem) => {
const dashboard = getDashboardSceneFor(vizPanel);
const queryRunner = getQueryRunnerFor(vizPanel);
if (!queryRunner) {
return;
}
const filterSet = getAdHocFilterSetFor(dashboard, queryRunner.state.datasource);
updateAdHocFilterSet(filterSet, newFilter);
};
context.onUpdateData = (frames: DataFrame[]): Promise<boolean> => {
// TODO
//return onUpdatePanelSnapshotData(this.props.panel, frames);
return Promise.resolve(true);
};
}
function getBuiltInAnnotationsLayer(scene: DashboardScene): dataLayers.AnnotationsDataLayer | undefined {
// When there is no builtin annotations query we disable the ability to add annotations
if (scene.state.$data instanceof SceneDataLayers) {
for (const layer of scene.state.$data.state.layers) {
if (layer instanceof dataLayers.AnnotationsDataLayer) {
if (layer.state.isEnabled && layer.state.query.builtIn) {
return layer;
}
}
}
}
return undefined;
}
function reRunBuiltInAnnotationsLayer(scene: DashboardScene) {
const layer = getBuiltInAnnotationsLayer(scene);
if (layer) {
layer.runLayer();
}
}
export function getAdHocFilterSetFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
const controls = scene.state.controls ?? [];
for (const control of controls) {
if (control instanceof AdHocFilterSet) {
if (control.state.datasource === ds || control.state.datasource?.uid === ds?.uid) {
return control;
}
}
}
const newSet = new AdHocFilterSet({ datasource: ds });
// Add it to the scene
scene.setState({
controls: [controls[0], newSet, ...controls.slice(1)],
});
return newSet;
}
function updateAdHocFilterSet(filterSet: AdHocFilterSet, newFilter: AdHocFilterItem) {
// Check if we need to update an existing filter
for (const filter of filterSet.state.filters) {
if (filter.key === newFilter.key) {
filterSet.setState({
filters: filterSet.state.filters.map((f) => {
if (f.key === newFilter.key) {
return newFilter;
}
return f;
}),
});
return;
}
}
// Add new filter
filterSet.setState({
filters: [...filterSet.state.filters, newFilter],
});
}

@ -91,8 +91,8 @@ describe('transformSaveModelToScene', () => {
expect(scene.state?.$timeRange?.state.weekStart).toEqual('saturday');
expect(scene.state?.$variables?.state.variables).toHaveLength(1);
expect(scene.state.controls).toBeDefined();
expect(scene.state.controls![2]).toBeInstanceOf(AdHocFilterSet);
expect((scene.state.controls![2] as AdHocFilterSet).state.name).toBe('CoolFilters');
expect(scene.state.controls![1]).toBeInstanceOf(AdHocFilterSet);
expect((scene.state.controls![1] as AdHocFilterSet).state.name).toBe('CoolFilters');
});
it('should apply cursor sync behavior', () => {
@ -668,7 +668,7 @@ describe('transformSaveModelToScene', () => {
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
expect(scene.state.controls![0]).toBeInstanceOf(SceneDataLayerControls);
expect(scene.state.controls![2]).toBeInstanceOf(SceneDataLayerControls);
const dataLayers = scene.state.$data as SceneDataLayers;
expect(dataLayers.state.layers).toHaveLength(4);

@ -43,6 +43,7 @@ import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { createPanelDataProvider } from '../utils/createPanelDataProvider';
import { getVizPanelKeyForPanelId } from '../utils/utils';
@ -200,9 +201,9 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
}
const controls: SceneObject[] = [
new SceneDataLayerControls(),
new VariableValueSelectors({}),
...filtersSets,
new SceneDataLayerControls(),
new SceneControlsSpacer(),
new SceneTimePicker({}),
new SceneRefreshPicker({
@ -340,6 +341,7 @@ export function buildGridItemForPanel(panel: PanelModel): SceneGridItemLike {
menu: new VizPanelMenu({
$behaviors: [panelMenuBehavior],
}),
extendPanelContext: setDashboardPanelContext,
_UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel),
};

@ -336,7 +336,6 @@ export function trimDashboardForSnapshot(title: string, time: TimeRange, dash: D
enable: annotation.enable,
iconColor: annotation.iconColor,
type: annotation.type,
// @ts-expect-error
builtIn: annotation.builtIn,
hide: annotation.hide,
// TODO: Remove when we migrate snapshots to snapshot queries.

@ -3226,9 +3226,9 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes@npm:^1.15.0":
version: 1.17.0
resolution: "@grafana/scenes@npm:1.17.0"
"@grafana/scenes@npm:^1.18.0":
version: 1.18.0
resolution: "@grafana/scenes@npm:1.18.0"
dependencies:
"@grafana/e2e-selectors": 10.0.2
react-grid-layout: 1.3.4
@ -3240,7 +3240,7 @@ __metadata:
"@grafana/runtime": 10.0.3
"@grafana/schema": 10.0.3
"@grafana/ui": 10.0.3
checksum: 0bd8603ae59c199c595a45a7bc31e318a1928b18307469bcd5ee5f83c3aaee89c5c27a1341a479440d928505681ab6a2479809a5cac56e8e93ba7acf06adff93
checksum: 395e4ef7a01b963df5f3aac3adc2e82c8d409e2679c8ed428137f66ebbbc84570458d1a36db31080622848e86c8e2e82f780e2a1257de0ea61152a41e8650516
languageName: node
linkType: hard
@ -17521,7 +17521,7 @@ __metadata:
"@grafana/lezer-traceql": 0.0.7
"@grafana/monaco-logql": ^0.0.7
"@grafana/runtime": "workspace:*"
"@grafana/scenes": ^1.15.0
"@grafana/scenes": ^1.18.0
"@grafana/schema": "workspace:*"
"@grafana/tsconfig": ^1.3.0-rc1
"@grafana/ui": "workspace:*"

Loading…
Cancel
Save