mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Scenes: List annotations in dashboard settings (#80995)
* wip listing/viewing annotations * list annotations in settings * fix tests * PR mods
This commit is contained in:
parent
ce39af21a2
commit
63c7096d32
@ -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),
|
||||
|
@ -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 };
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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) {}
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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) {anno.name}</em>;
|
||||
}
|
||||
|
||||
if (anno.builtIn) {
|
||||
return <em className="muted">{anno.name} (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 & 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',
|
||||
}),
|
||||
});
|
@ -0,0 +1 @@
|
||||
export { AnnotationSettingsList } from './AnnotationSettingsList';
|
@ -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: [],
|
||||
|
@ -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() {}
|
||||
|
Loading…
Reference in New Issue
Block a user