Scenes: List annotations in dashboard settings (#80995)

* wip listing/viewing annotations

* list annotations in settings

* fix tests

* PR mods
This commit is contained in:
Victor Marin 2024-01-30 18:38:26 +02:00 committed by GitHub
parent ce39af21a2
commit 63c7096d32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 417 additions and 20 deletions

View File

@ -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),

View File

@ -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 };
}

View File

@ -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<AnnotationsEditViewState> implements DashboardEditView {
static Component = AnnotationsSettingsView;
public getUrlKey(): string {
return 'annotations';
}
static Component = ({ model }: SceneComponentProps<AnnotationsEditView>) => {
const dashboard = getDashboardSceneFor(model);
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
protected _urlSync = new EditListViewSceneUrlSync(this);
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<div>Annotations todo</div>
</Page>
);
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<AnnotationsEditView>) {
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 (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
{!isEditing && (
<AnnotationSettingsList
annotations={annotations}
onNew={model.onNew}
onEdit={model.onEdit}
onDelete={model.onDelete}
onMove={model.onMove}
/>
)}
</Page>
);
}

View File

@ -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) {}

View File

@ -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',

View File

@ -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 <em className="muted">(Disabled) &nbsp; {anno.name}</em>;
}
if (anno.builtIn) {
return <em className="muted">{anno.name} &nbsp; (Built-in)</em>;
}
return <>{anno.name}</>;
};
const dataSourceSrv = getDataSourceSrv();
return (
<VerticalGroup>
{annotations.length > 0 && (
<div className={styles.table}>
<table role="grid" className="filter-table filter-table--hover">
<thead>
<tr>
<th>Query name</th>
<th>Data source</th>
<th colSpan={3}></th>
</tr>
</thead>
<tbody>
{annotations.map((annotation, idx) => (
<tr key={`${annotation.name}-${idx}`}>
{annotation.builtIn ? (
<td role="gridcell" style={{ width: '90%' }} className="pointer" onClick={() => onEdit(idx)}>
<Button size="sm" fill="text" variant="secondary">
{getAnnotationName(annotation)}
</Button>
</td>
) : (
<td role="gridcell" className="pointer" onClick={() => onEdit(idx)}>
<Button size="sm" fill="text" variant="secondary">
{getAnnotationName(annotation)}
</Button>
</td>
)}
<td role="gridcell" className="pointer" onClick={() => onEdit(idx)}>
{dataSourceSrv.getInstanceSettings(annotation.datasource)?.name || annotation.datasource?.uid}
</td>
<td role="gridcell" style={{ width: '1%' }}>
{idx !== 0 && <IconButton name="arrow-up" onClick={() => onMove(idx, -1)} tooltip="Move up" />}
</td>
<td role="gridcell" style={{ width: '1%' }}>
{annotations.length > 1 && idx !== annotations.length - 1 ? (
<IconButton name="arrow-down" onClick={() => onMove(idx, 1)} tooltip="Move down" />
) : null}
</td>
<td role="gridcell" style={{ width: '1%' }}>
{!annotation.builtIn && (
<DeleteButton
size="sm"
onConfirm={() => onDelete(idx)}
aria-label={`Delete query with title "${annotation.name}"`}
/>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showEmptyListCTA && (
<EmptyListCTA
onClick={onNew}
title="There are no custom annotation queries added yet"
buttonIcon="comment-alt"
buttonTitle="Add annotation query"
infoBoxTitle="What are annotation queries?"
infoBox={{
__html: `<p>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 &amp; 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.
</p>
Checkout the
<a class='external-link' target='_blank' href='http://docs.grafana.org/reference/annotations/'
>Annotations documentation</a
>
for more information.`,
}}
/>
)}
{!showEmptyListCTA && <ListNewButton onClick={onNew}>New query</ListNewButton>}
</VerticalGroup>
);
};
const getStyles = () => ({
table: css({
width: '100%',
overflowX: 'scroll',
}),
});

View File

@ -0,0 +1 @@
export { AnnotationSettingsList } from './AnnotationSettingsList';

View File

@ -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: [],

View File

@ -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() {}