diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 07e4b2c410c..87c213ff3e6 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -215,7 +215,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) layers = oldModel.annotations?.list.map((a) => { // Each annotation query is an individual data layer return new DashboardAnnotationsDataLayer({ - key: `annnotations-${a.name}`, + key: `annotations-${a.name}`, query: a, name: a.name, isEnabled: Boolean(a.enable), diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx new file mode 100644 index 00000000000..5ac0d3c824d --- /dev/null +++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx @@ -0,0 +1,126 @@ +import { map, of } from 'rxjs'; + +import { DataQueryRequest, DataSourceApi, LoadingState, PanelData } from '@grafana/data'; +import { SceneDataLayers, SceneGridItem, SceneGridLayout, SceneTimeRange } from '@grafana/scenes'; + +import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; +import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; +import { DashboardScene } from '../scene/DashboardScene'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { AnnotationsEditView } from './AnnotationsEditView'; + +const getDataSourceSrvSpy = jest.fn(); +const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { + const result: PanelData = { + state: LoadingState.Loading, + series: [], + timeRange: request.range, + }; + + return of([]).pipe( + map(() => { + result.state = LoadingState.Done; + result.series = []; + + return result; + }) + ); +}); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => { + getDataSourceSrvSpy(); + }, + getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { + return runRequestMock(ds, request); + }, + config: { + publicDashboardAccessToken: 'ac123', + }, +})); + +describe('AnnotationsEditView', () => { + describe('Dashboard annotations state', () => { + let annotationsView: AnnotationsEditView; + + beforeEach(async () => { + const result = await buildTestScene(); + annotationsView = result.annotationsView; + }); + + it('should return the correct urlKey', () => { + expect(annotationsView.getUrlKey()).toBe('annotations'); + }); + + it('should return the scene data layers', () => { + const dataLayers = annotationsView.getSceneDataLayers(); + + expect(dataLayers).toBeInstanceOf(SceneDataLayers); + expect(dataLayers?.state.layers.length).toBe(2); + }); + + it('should return the annotations length', () => { + expect(annotationsView.getAnnotationsLength()).toBe(1); + }); + }); +}); + +async function buildTestScene() { + const annotationsView = new AnnotationsEditView({}); + const dashboard = new DashboardScene({ + $timeRange: new SceneTimeRange({}), + title: 'hello', + uid: 'dash-1', + version: 4, + meta: { + canEdit: true, + }, + $data: new SceneDataLayers({ + layers: [ + new DashboardAnnotationsDataLayer({ + key: `annotations-test`, + query: { + enable: true, + iconColor: 'red', + name: 'test', + datasource: { + type: 'grafana', + uid: '-- Grafana --', + }, + }, + name: 'test', + isEnabled: true, + isHidden: false, + }), + new AlertStatesDataLayer({ + key: 'alert-states', + name: 'Alert States', + }), + ], + }), + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: undefined, + }), + ], + }), + editview: annotationsView, + }); + + activateFullSceneTree(dashboard); + + await new Promise((r) => setTimeout(r, 1)); + + dashboard.onEnterEditMode(); + annotationsView.activate(); + + return { dashboard, annotationsView }; +} diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx index 89c2af02cea..a7b78675914 100644 --- a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx @@ -1,30 +1,107 @@ import React from 'react'; -import { PageLayoutType } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; +import { AnnotationQuery, DataTopic, PageLayoutType } from '@grafana/data'; +import { + SceneComponentProps, + SceneDataLayerProvider, + SceneDataLayers, + SceneObjectBase, + sceneGraph, +} from '@grafana/scenes'; import { Page } from 'app/core/components/Page/Page'; +import { DashboardScene } from '../scene/DashboardScene'; import { NavToolbarActions } from '../scene/NavToolbarActions'; +import { dataLayersToAnnotations } from '../serialization/dataLayersToAnnotations'; import { getDashboardSceneFor } from '../utils/utils'; +import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync'; +import { AnnotationSettingsList } from './annotations'; import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; -export interface AnnotationsEditViewState extends DashboardEditViewState {} +export interface AnnotationsEditViewState extends DashboardEditViewState { + editIndex?: number | undefined; +} export class AnnotationsEditView extends SceneObjectBase implements DashboardEditView { + static Component = AnnotationsSettingsView; + public getUrlKey(): string { return 'annotations'; } - static Component = ({ model }: SceneComponentProps) => { - const dashboard = getDashboardSceneFor(model); - const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + protected _urlSync = new EditListViewSceneUrlSync(this); - return ( - - -
Annotations todo
-
- ); + private get _dashboard(): DashboardScene { + return getDashboardSceneFor(this); + } + + private get _dataLayers(): SceneDataLayerProvider[] { + return sceneGraph.getDataLayers(this._dashboard); + } + + public getSceneDataLayers(): SceneDataLayers | undefined { + const data = sceneGraph.getData(this); + + if (!(data instanceof SceneDataLayers)) { + return undefined; + } + + return data; + } + + public getAnnotationsLength(): number { + return this._dataLayers.filter((layer) => layer.topic === DataTopic.Annotations).length; + } + + public getDashboard(): DashboardScene { + return this._dashboard; + } + + public onNew = () => { + console.log('todo: onNew'); + }; + + public onEdit = (idx: number) => { + console.log('todo: onEdit'); + }; + + public onMove = (idx: number, direction: number) => { + console.log('todo: onMove'); + }; + + public onDelete = (idx: number) => { + console.log('todo: onDelete'); }; } + +function AnnotationsSettingsView({ model }: SceneComponentProps) { + const dashboard = model.getDashboard(); + const sceneDataLayers = model.getSceneDataLayers(); + const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + const { editIndex } = model.useState(); + + let annotations: AnnotationQuery[] = []; + + if (sceneDataLayers) { + const { layers } = sceneDataLayers.useState(); + annotations = dataLayersToAnnotations(layers); + } + + const isEditing = editIndex != null && editIndex < model.getAnnotationsLength(); + + return ( + + + {!isEditing && ( + + )} + + ); +} diff --git a/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts b/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts index a81fda1cb6b..9821bcf13c5 100644 --- a/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts @@ -1,10 +1,11 @@ import { SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '@grafana/scenes'; +import { AnnotationsEditView, AnnotationsEditViewState } from './AnnotationsEditView'; import { DashboardLinksEditView, DashboardLinksEditViewState } from './DashboardLinksEditView'; import { VariablesEditView, VariablesEditViewState } from './VariablesEditView'; -type EditListViewUrlSync = DashboardLinksEditView | VariablesEditView; -type EditListViewState = DashboardLinksEditViewState | VariablesEditViewState; +type EditListViewUrlSync = DashboardLinksEditView | VariablesEditView | AnnotationsEditView; +type EditListViewState = DashboardLinksEditViewState | VariablesEditViewState | AnnotationsEditViewState; export class EditListViewSceneUrlSync implements SceneObjectUrlSyncHandler { constructor(private _scene: EditListViewUrlSync) {} diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx index 51b6fb8fd91..afafdc5089b 100644 --- a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx @@ -153,7 +153,7 @@ function getVersions() { } async function buildTestScene() { - const versionsView = new VersionsEditView({ versions: [] }); + const versionsView = new VersionsEditView({}); const dashboard = new DashboardScene({ $timeRange: new SceneTimeRange({}), title: 'hello', diff --git a/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.tsx b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.tsx new file mode 100644 index 00000000000..80476dce9f2 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.tsx @@ -0,0 +1,121 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { AnnotationQuery } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { Button, DeleteButton, IconButton, useStyles2, VerticalGroup } from '@grafana/ui'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import { ListNewButton } from 'app/features/dashboard/components/DashboardSettings/ListNewButton'; + +type Props = { + annotations: AnnotationQuery[]; + onNew: () => void; + onEdit: (idx: number) => void; + onMove: (idx: number, dir: number) => void; + onDelete: (idx: number) => void; +}; + +export const AnnotationSettingsList = ({ annotations, onNew, onEdit, onMove, onDelete }: Props) => { + const styles = useStyles2(getStyles); + + const showEmptyListCTA = annotations.length === 0 || (annotations.length === 1 && annotations[0].builtIn); + + const getAnnotationName = (anno: AnnotationQuery) => { + if (anno.enable === false) { + return (Disabled)   {anno.name}; + } + + if (anno.builtIn) { + return {anno.name}   (Built-in); + } + + return <>{anno.name}; + }; + + const dataSourceSrv = getDataSourceSrv(); + return ( + + {annotations.length > 0 && ( +
+ + + + + + + + + + {annotations.map((annotation, idx) => ( + + {annotation.builtIn ? ( + + ) : ( + + )} + + + + + + ))} + +
Query nameData source
onEdit(idx)}> + + onEdit(idx)}> + + onEdit(idx)}> + {dataSourceSrv.getInstanceSettings(annotation.datasource)?.name || annotation.datasource?.uid} + + {idx !== 0 && onMove(idx, -1)} tooltip="Move up" />} + + {annotations.length > 1 && idx !== annotations.length - 1 ? ( + onMove(idx, 1)} tooltip="Move down" /> + ) : null} + + {!annotation.builtIn && ( + onDelete(idx)} + aria-label={`Delete query with title "${annotation.name}"`} + /> + )} +
+
+ )} + {showEmptyListCTA && ( + Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines + and icons on all graph panels. When you hover over an annotation icon you can get event text & tags for + the event. You can add annotation events directly from grafana by holding CTRL or CMD + click on graph (or + drag region). These will be stored in Grafana's annotation database. +

+ Checkout the + Annotations documentation + for more information.`, + }} + /> + )} + {!showEmptyListCTA && New query} +
+ ); +}; + +const getStyles = () => ({ + table: css({ + width: '100%', + overflowX: 'scroll', + }), +}); diff --git a/public/app/features/dashboard-scene/settings/annotations/index.tsx b/public/app/features/dashboard-scene/settings/annotations/index.tsx new file mode 100644 index 00000000000..512165263b8 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/annotations/index.tsx @@ -0,0 +1 @@ +export { AnnotationSettingsList } from './AnnotationSettingsList'; diff --git a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts index a5ded6539e9..93c7a45b45f 100644 --- a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts +++ b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts @@ -9,10 +9,13 @@ import { VizPanel, SceneTimePicker, SceneDataTransformer, + SceneDataLayers, } from '@grafana/scenes'; import { DashboardCursorSync } from '@grafana/schema'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; +import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; +import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; import { DashboardLinksControls } from '../scene/DashboardLinksControls'; import { DashboardScene } from '../scene/DashboardScene'; @@ -38,6 +41,9 @@ describe('DashboardModelCompatibilityWrapper', () => { expect(wrapper.timepicker.hidden).toEqual(true); expect(wrapper.panels).toHaveLength(5); + expect(wrapper.annotations.list).toHaveLength(1); + expect(wrapper.annotations.list[0].name).toBe('test'); + expect(wrapper.panels[0].targets).toHaveLength(1); expect(wrapper.panels[0].targets[0]).toEqual({ refId: 'A' }); expect(wrapper.panels[1].targets).toHaveLength(0); @@ -104,6 +110,22 @@ describe('DashboardModelCompatibilityWrapper', () => { expect((scene.state.body as SceneGridLayout).state.children.length).toBe(4); }); + + it('Checks if annotations are editable', () => { + const { wrapper, scene } = setup(); + + expect(wrapper.canEditAnnotations()).toBe(true); + expect(wrapper.canEditAnnotations(scene.state.uid)).toBe(false); + + scene.setState({ + meta: { + canEdit: false, + canMakeEditable: false, + }, + }); + + expect(wrapper.canEditAnnotations()).toBe(false); + }); }); function setup() { @@ -114,10 +136,45 @@ function setup() { links: [NEW_LINK], uid: 'dash-1', editable: false, + meta: { + canEdit: true, + canMakeEditable: true, + annotationsPermissions: { + organization: { + canEdit: true, + canAdd: true, + canDelete: true, + }, + dashboard: { + canEdit: false, + canAdd: false, + canDelete: false, + }, + }, + }, $timeRange: new SceneTimeRange({ weekStart: 'friday', timeZone: 'America/New_York', }), + $data: new SceneDataLayers({ + layers: [ + new DashboardAnnotationsDataLayer({ + key: `annotations-test`, + query: { + enable: true, + iconColor: 'red', + name: 'test', + }, + name: 'test', + isEnabled: true, + isHidden: false, + }), + new AlertStatesDataLayer({ + key: 'alert-states', + name: 'Alert States', + }), + ], + }), controls: [ new DashboardControls({ variableControls: [], diff --git a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts index 4d1e5f4027f..81f3afa51b3 100644 --- a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts +++ b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts @@ -4,6 +4,7 @@ import { AnnotationQuery, DashboardCursorSync, dateTimeFormat, DateTimeInput, Ev import { TimeRangeUpdatedEvent } from '@grafana/runtime'; import { behaviors, + SceneDataLayers, SceneDataTransformer, sceneGraph, SceneGridItem, @@ -16,6 +17,7 @@ import { DataSourceRef } from '@grafana/schema'; import { DashboardScene } from '../scene/DashboardScene'; import { LibraryVizPanel } from '../scene/LibraryVizPanel'; +import { dataLayersToAnnotations } from '../serialization/dataLayersToAnnotations'; import { dashboardSceneGraph } from './dashboardSceneGraph'; import { findVizPanelByKey, getPanelIdForVizPanel, getQueryRunnerFor, getVizPanelKeyForPanelId } from './utils'; @@ -109,8 +111,13 @@ export class DashboardModelCompatibilityWrapper { * Used from from timeseries migration handler to migrate time regions to dashboard annotations */ public get annotations(): { list: AnnotationQuery[] } { - console.error('Scenes DashboardModelCompatibilityWrapper.annotations not implemented (yet)'); - return { list: [] }; + const annotations: { list: AnnotationQuery[] } = { list: [] }; + + if (this._scene.state.$data instanceof SceneDataLayers) { + annotations.list = dataLayersToAnnotations(this._scene.state.$data.state.layers); + } + + return annotations; } public getTimezone() { @@ -204,8 +211,15 @@ export class DashboardModelCompatibilityWrapper { } public canEditAnnotations(dashboardUID?: string) { - // TOOD - return false; + if (!this._scene.canEditDashboard()) { + return false; + } + + if (dashboardUID) { + return Boolean(this._scene.state.meta.annotationsPermissions?.dashboard.canEdit); + } + + return Boolean(this._scene.state.meta.annotationsPermissions?.organization.canEdit); } public panelInitialized() {}