DashboardScene: Share snapshot (#76132)

* Dashboard snapshot creation logic and tests

* Add variable type to serialized json, add refresh prop to datasource var

* WIP Snapshots tab UI

* Use Grafana snapshot query for a generated snapshot

* Make anno snapshots backwards compatible

* Share snapshot tab UI

* Single panel snapshot

* Remove unused param

* Snap update

* Ts fix

* One more snap fix

* Basic rows support

* Add tests for basic rows

* Bettererupdate
This commit is contained in:
Dominik Prokop 2023-10-11 13:24:18 +02:00 committed by GitHub
parent 555acdf180
commit 6968f4d6ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1479 additions and 32 deletions

View File

@ -3009,7 +3009,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"]
],
"public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],

View File

@ -61,7 +61,7 @@ export function getPanelPlugin(
},
screenshots: [],
updated: '',
version: '',
version: '1',
},
hideFromList: options.hideFromList === true,
module: options.module ?? '',

View File

@ -36,6 +36,9 @@ export interface AnnotationQuery<TQuery extends DataQuery = DataQuery>
extends Omit<raw.AnnotationQuery, 'target' | 'datasource'> {
datasource?: DataSourceRef | null;
target?: TQuery;
// TODO: When migrating to snapshot queries, remove this property.
// With snapshot queries annotations become a part of the panel snapshot data.
snapshotData?: unknown;
}
export interface AnnotationContainer extends Omit<raw.AnnotationContainer, 'list'> {

View File

@ -209,6 +209,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
"name": "server",
"options": [],
"query": "A,B,C,D,E,F,E,G,H,I,J,K,L",
"type": "custom",
},
{
"current": {
@ -226,6 +227,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
"name": "pod",
"options": [],
"query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5",
"type": "custom",
},
],
},

View File

@ -0,0 +1,4 @@
export const GRAFANA_DATASOURCE_REF = {
name: 'grafana',
uid: 'grafana',
};

View File

@ -0,0 +1,20 @@
import { SceneDataLayerProvider, dataLayers } from '@grafana/scenes';
import { AnnotationQuery } from '@grafana/schema';
export function dataLayersToAnnotations(layers: SceneDataLayerProvider[]) {
const annotations: AnnotationQuery[] = [];
for (const layer of layers) {
if (!(layer instanceof dataLayers.AnnotationsDataLayer)) {
continue;
}
const result = {
...layer.state.query,
enable: Boolean(layer.state.isEnabled),
hide: Boolean(layer.state.isHidden),
};
annotations.push(result);
}
return annotations;
}

View File

@ -122,6 +122,7 @@ describe('sceneVariablesSetToVariables', () => {
"query": "query",
"refresh": 1,
"regex": "",
"type": "query",
}
`);
});
@ -165,7 +166,9 @@ describe('sceneVariablesSetToVariables', () => {
"name": "test",
"options": [],
"query": "fake-std",
"refresh": 1,
"regex": "",
"type": "datasource",
}
`);
});
@ -214,6 +217,7 @@ describe('sceneVariablesSetToVariables', () => {
"name": "test",
"options": [],
"query": "test,test1,test2",
"type": "custom",
}
`);
});
@ -245,6 +249,7 @@ describe('sceneVariablesSetToVariables', () => {
"name": "test",
"query": "constant value",
"skipUrlSync": true,
"type": "constant",
}
`);
});

View File

@ -1,5 +1,5 @@
import { SceneVariableSet, QueryVariable, CustomVariable, DataSourceVariable, ConstantVariable } from '@grafana/scenes';
import { VariableModel, VariableHide, VariableSort } from '@grafana/schema';
import { VariableModel, VariableHide, VariableRefresh, VariableSort } from '@grafana/schema';
export function sceneVariablesSetToVariables(set: SceneVariableSet) {
const variables: VariableModel[] = [];
@ -10,6 +10,7 @@ export function sceneVariablesSetToVariables(set: SceneVariableSet) {
description: variable.state.description,
skipUrlSync: Boolean(variable.state.skipUrlSync),
hide: variable.state.hide || VariableHide.dontHide,
type: variable.state.type,
};
if (variable instanceof QueryVariable) {
variables.push({
@ -58,6 +59,7 @@ export function sceneVariablesSetToVariables(set: SceneVariableSet) {
},
options: [],
regex: variable.state.regex,
refresh: VariableRefresh.onDashboardLoad,
query: variable.state.pluginId,
multi: variable.state.isMulti,
allValue: variable.state.allValue,

View File

@ -0,0 +1,360 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
},
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"enable": true,
"iconColor": "red",
"name": "New annotation",
"target": {
"lines": 10,
"refId": "Anno1111",
"scenarioId": "annotations"
}
}
]
},
"links": [
{
"asDropdown": false,
"icon": "external link",
"includeVars": false,
"keepTime": false,
"tags": [],
"targetBlank": false,
"title": "New link",
"tooltip": "",
"type": "link",
"url": "https://test.link.co"
}
],
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 2306,
"liveNow": false,
"panels": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "Panel 1",
"type": "timeseries"
},
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true
},
"pluginVersion": "10.2.0-pre",
"targets": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"refId": "B",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "Panel 2",
"transformations": [
{
"id": "reduce",
"options": {}
}
],
"type": "table"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A"
}
],
"title": "Panel 3",
"type": "timeseries"
}
],
"refresh": "",
"schemaVersion": 38,
"tags": [],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": "annotations",
"value": "annotations"
},
"datasource": {
"type": "prometheus",
"uid": "gdev-prometheus"
},
"definition": "label_values(backend)",
"hide": 0,
"includeAll": false,
"multi": false,
"name": "query0",
"options": [],
"query": {
"qryType": 1,
"query": "label_values(backend)",
"refId": "VariableQuery"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"type": "query"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Snapshots repro",
"uid": "d6657f14-9c6a-4eaa-9593-54c9c46b6394",
"version": 4,
"weekStart": ""
}

View File

@ -0,0 +1,425 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 2312,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "Panel 1",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 8
},
"id": 2,
"panels": [],
"title": "Expanded row",
"type": "row"
},
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 9
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"refId": "B",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "Panel 2",
"type": "timeseries"
},
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 9
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"refId": "C",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "Panel 3",
"type": "timeseries"
},
{
"collapsed": true,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 17
},
"id": 4,
"panels": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 10
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"title": "Panel inside colapsed row",
"type": "timeseries"
}
],
"title": "Collapsed row",
"type": "row"
}
],
"refresh": "",
"schemaVersion": 38,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Snapshots - basic rows",
"uid": "a3b23921-287d-4f90-9a14-c04228057dfa",
"version": 4,
"weekStart": ""
}

View File

@ -1,3 +1,20 @@
import { advanceTo } from 'jest-date-mock';
import { map, of } from 'rxjs';
import {
DataFrame,
DataQueryRequest,
DataSourceApi,
dateTime,
FieldType,
PanelData,
standardTransformersRegistry,
StandardVariableQuery,
toDataFrame,
VariableSupportType,
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import {
MultiValueVariable,
SceneDataLayers,
@ -5,22 +22,121 @@ import {
SceneGridLayout,
SceneGridRow,
SceneVariable,
VizPanel,
} from '@grafana/scenes';
import { Panel, RowPanel } from '@grafana/schema';
import { Dashboard, LoadingState, Panel, RowPanel, VariableRefresh } from '@grafana/schema';
import { PanelModel } from 'app/features/dashboard/state';
import { getTimeRange } from 'app/features/dashboard/utils/timeRange';
import { reduceTransformRegistryItem } from 'app/features/transformers/editors/ReduceTransformerEditor';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { activateFullSceneTree } from '../utils/test-utils';
import { getVizPanelKeyForPanelId } from '../utils/utils';
import { GRAFANA_DATASOURCE_REF } from './const';
import dashboard_to_load1 from './testfiles/dashboard_to_load1.json';
import repeatingRowsAndPanelsDashboardJson from './testfiles/repeating_rows_and_panels.json';
import snapshotableDashboardJson from './testfiles/snapshotable_dashboard.json';
import snapshotableWithRowsDashboardJson from './testfiles/snapshotable_with_rows.json';
import {
buildGridItemForLibPanel,
buildGridItemForPanel,
transformSaveModelToScene,
} from './transformSaveModelToScene';
import { gridItemToPanel, transformSceneToSaveModel } from './transformSceneToSaveModel';
import { gridItemToPanel, transformSceneToSaveModel, trimDashboardForSnapshot } from './transformSceneToSaveModel';
standardTransformersRegistry.setInit(() => [reduceTransformRegistryItem]);
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
const AFrame = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'values', type: FieldType.number, values: [1, 2, 3] },
],
});
const BFrame = toDataFrame({
name: 'B',
fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'values', type: FieldType.number, values: [10, 20, 30] },
],
});
const CFrame = toDataFrame({
name: 'C',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000] },
{ name: 'values', type: FieldType.number, values: [100, 200, 300] },
],
});
const AnnoFrame = toDataFrame({
fields: [
{ name: 'time', values: [1, 2, 2, 5, 5] },
{ name: 'id', values: ['1', '2', '2', '5', '5'] },
{ name: 'text', values: ['t1', 't2', 't3', 't4', 't5'] },
],
});
const VariableQueryFrame = toDataFrame({
fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }],
});
const testSeries: Record<string, DataFrame> = {
A: AFrame,
B: BFrame,
C: CFrame,
Anno: AnnoFrame,
VariableQuery: VariableQueryFrame,
};
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;
const refId = request.targets[0].refId;
result.series = [testSeries[refId]];
return result;
})
);
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
get: () => ({
getRef: () => ({ type: 'mock-ds', uid: 'mock-uid' }),
variables: {
getType: () => VariableSupportType.Standard,
toDataQuery: (q: StandardVariableQuery) => q,
},
}),
}),
getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => {
return runRequestMock(ds, request);
},
config: {
panels: [],
theme2: {
visualization: {
getColorByName: jest.fn().mockReturnValue('red'),
},
},
},
}));
describe('transformSceneToSaveModel', () => {
describe('Given a simple scene', () => {
it('Should transform back to peristed model', () => {
@ -360,6 +476,206 @@ describe('transformSceneToSaveModel', () => {
});
});
});
describe('Snapshots', () => {
const fakeCurrentDate = dateTime('2023-01-01T20:00:00.000Z').toDate();
beforeEach(() => {
advanceTo(fakeCurrentDate);
});
it('attaches snapshot data to panels using Grafana snapshot query', async () => {
const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as any, meta: {} });
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
const snapshot = transformSceneToSaveModel(scene, true);
expect(snapshot.panels?.length).toBe(3);
// Regular panel with SceneQueryRunner
// @ts-expect-error
expect(snapshot.panels?.[0].datasource).toEqual(GRAFANA_DATASOURCE_REF);
// @ts-expect-error
expect(snapshot.panels?.[0].targets?.[0].datasource).toEqual(GRAFANA_DATASOURCE_REF);
// @ts-expect-error
expect(snapshot.panels?.[0].targets?.[0].snapshot[0].data).toEqual({
values: [
[100, 200, 300],
[1, 2, 3],
],
});
// Panel with transformations
// @ts-expect-error
expect(snapshot.panels?.[1].datasource).toEqual(GRAFANA_DATASOURCE_REF);
// @ts-expect-error
expect(snapshot.panels?.[1].targets?.[0].datasource).toEqual(GRAFANA_DATASOURCE_REF);
// @ts-expect-error
expect(snapshot.panels?.[1].targets?.[0].snapshot[0].data).toEqual({
values: [
[100, 200, 300],
[10, 20, 30],
],
});
// @ts-expect-error
expect(snapshot.panels?.[1].transformations).toEqual([
{
id: 'reduce',
options: {},
},
]);
// Panel with a shared query (dahsboard query)
// @ts-expect-error
expect(snapshot.panels?.[2].datasource).toEqual(GRAFANA_DATASOURCE_REF);
// @ts-expect-error
expect(snapshot.panels?.[2].targets?.[0].datasource).toEqual(GRAFANA_DATASOURCE_REF);
// @ts-expect-error
expect(snapshot.panels?.[2].targets?.[0].snapshot[0].data).toEqual({
values: [
[100, 200, 300],
[1, 2, 3],
],
});
});
it('handles basic rows', async () => {
const scene = transformSaveModelToScene({ dashboard: snapshotableWithRowsDashboardJson as any, meta: {} });
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
const snapshot = transformSceneToSaveModel(scene, true);
expect(snapshot.panels?.length).toBe(5);
// @ts-expect-error
expect(snapshot.panels?.[0].targets?.[0].datasource).toEqual(GRAFANA_DATASOURCE_REF);
// @ts-expect-error
expect(snapshot.panels?.[0].targets?.[0].snapshot[0].data).toEqual({
values: [
[100, 200, 300],
[1, 2, 3],
],
});
// @ts-expect-error
expect(snapshot.panels?.[1].targets).toBeUndefined();
// @ts-expect-error
expect(snapshot.panels?.[1].panels).toEqual([]);
// @ts-expect-error
expect(snapshot.panels?.[1].collapsed).toEqual(false);
// @ts-expect-error
expect(snapshot.panels?.[2].targets?.[0].datasource).toEqual(GRAFANA_DATASOURCE_REF);
// @ts-expect-error
expect(snapshot.panels?.[2].targets?.[0].snapshot[0].data).toEqual({
values: [
[100, 200, 300],
[10, 20, 30],
],
});
// @ts-expect-error
expect(snapshot.panels?.[3].targets?.[0].datasource).toEqual(GRAFANA_DATASOURCE_REF);
// @ts-expect-error
expect(snapshot.panels?.[3].targets?.[0].snapshot[0].data).toEqual({
values: [
[1000, 2000, 3000],
[100, 200, 300],
],
});
// @ts-expect-error
expect(snapshot.panels?.[4].targets).toBeUndefined();
// @ts-expect-error
expect(snapshot.panels?.[4].panels).toHaveLength(1);
// @ts-expect-error
expect(snapshot.panels?.[4].collapsed).toEqual(true);
});
describe('trimDashboardForSnapshot', () => {
let snapshot: Dashboard = {} as Dashboard;
beforeEach(() => {
const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as any, meta: {} });
activateFullSceneTree(scene);
snapshot = transformSceneToSaveModel(scene, true);
});
it('should not mutate provided dashboard', () => {
const result = trimDashboardForSnapshot('Snap title', getTimeRange({ from: 'now-6h', to: 'now' }), snapshot);
expect(result).not.toBe(snapshot);
});
it('should apply provided title and absolute time range', async () => {
const result = trimDashboardForSnapshot('Snap title', getTimeRange({ from: 'now-6h', to: 'now' }), snapshot);
expect(result.title).toBe('Snap title');
expect(result.time).toBeDefined();
expect(result.time!.from).toEqual('2023-01-01T14:00:00.000Z');
expect(result.time!.to).toEqual('2023-01-01T20:00:00.000Z');
});
it('should remove queries from annotations and attach empty snapshotData', () => {
expect(snapshot.annotations?.list?.[0].target).toBeDefined();
expect(snapshot.annotations?.list?.[1].target).toBeDefined();
const result = trimDashboardForSnapshot('Snap title', getTimeRange({ from: 'now-6h', to: 'now' }), snapshot);
expect(result.annotations?.list?.length).toBe(2);
expect(result.annotations?.list?.[0].target).toBeUndefined();
expect(result.annotations?.list?.[0].snapshotData).toEqual([]);
expect(result.annotations?.list?.[1].target).toBeUndefined();
expect(result.annotations?.list?.[1].snapshotData).toEqual([]);
});
it('should remove queries from variables', () => {
expect(snapshot.templating?.list?.length).toBe(1);
const result = trimDashboardForSnapshot('Snap title', getTimeRange({ from: 'now-6h', to: 'now' }), snapshot);
expect(result.templating?.list?.length).toBe(1);
expect(result.templating?.list?.[0].query).toBe('');
expect(result.templating?.list?.[0].refresh).toBe(VariableRefresh.never);
expect(result.templating?.list?.[0].options).toHaveLength(1);
expect(result.templating?.list?.[0].options?.[0]).toEqual({
text: 'annotations',
value: 'annotations',
});
});
it('should snapshot a single panel when provided', () => {
const vizPanel = new VizPanel({
key: getVizPanelKeyForPanelId(2),
});
const result = trimDashboardForSnapshot(
'Snap title',
getTimeRange({ from: 'now-6h', to: 'now' }),
snapshot,
vizPanel
);
expect(snapshot.panels?.length).toBe(3);
expect(result.panels?.length).toBe(1);
// @ts-expect-error
expect(result.panels?.[0].gridPos).toEqual({ w: 24, x: 0, y: 0, h: 20 });
});
// TODO: Uncomment when we support links
// it('should remove links', async () => {
// const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as any, meta: {} });
// activateFullSceneTree(scene);
// const snapshot = transformSceneToSaveModel(scene, true);
// expect(snapshot.links?.length).toBe(1);
// const result = trimDashboardForSnapshot('Snap title', getTimeRange({ from: 'now-6h', to: 'now' }), snapshot);
// expect(result.links?.length).toBe(0);
// });
});
});
});
export function buildGridItemFromPanelSchema(panel: Partial<Panel>): SceneGridItemLike {

View File

@ -1,3 +1,4 @@
import { isEmptyObject, TimeRange } from '@grafana/data';
import {
SceneDataLayers,
SceneGridItem,
@ -5,8 +6,6 @@ import {
SceneGridLayout,
SceneGridRow,
VizPanel,
dataLayers,
SceneDataLayerProvider,
SceneQueryRunner,
SceneDataTransformer,
SceneVariableSet,
@ -21,9 +20,12 @@ import {
Panel,
RowPanel,
VariableModel,
VariableRefresh,
} from '@grafana/schema';
import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard/utils';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
@ -33,9 +35,11 @@ import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { ShareQueryDataProvider } from '../scene/ShareQueryDataProvider';
import { getPanelIdForVizPanel } from '../utils/utils';
import { GRAFANA_DATASOURCE_REF } from './const';
import { dataLayersToAnnotations } from './dataLayersToAnnotations';
import { sceneVariablesSetToVariables } from './sceneVariablesSetToVariables';
export function transformSceneToSaveModel(scene: DashboardScene): Dashboard {
export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = false): Dashboard {
const state = scene.state;
const timeRange = state.$timeRange!.state;
const data = state.$data;
@ -48,7 +52,7 @@ export function transformSceneToSaveModel(scene: DashboardScene): Dashboard {
if (body instanceof SceneGridLayout) {
for (const child of body.state.children) {
if (child instanceof SceneGridItem) {
panels.push(gridItemToPanel(child));
panels.push(gridItemToPanel(child, isSnapshot));
}
if (child instanceof SceneGridRow) {
@ -56,7 +60,7 @@ export function transformSceneToSaveModel(scene: DashboardScene): Dashboard {
if (child.state.key!.indexOf('-clone-') > 0) {
continue;
}
gridRowToSaveModel(child, panels);
gridRowToSaveModel(child, panels, isSnapshot);
}
}
}
@ -64,6 +68,7 @@ export function transformSceneToSaveModel(scene: DashboardScene): Dashboard {
let annotations: AnnotationQuery[] = [];
if (data instanceof SceneDataLayers) {
const layers = data.state.layers;
annotations = dataLayersToAnnotations(layers);
}
@ -106,7 +111,7 @@ export function transformSceneToSaveModel(scene: DashboardScene): Dashboard {
return sortedDeepCloneWithoutNulls(dashboard);
}
export function gridItemToPanel(gridItem: SceneGridItemLike): Panel {
export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false): Panel {
let vizPanel: VizPanel | undefined;
let x = 0,
y = 0,
@ -164,7 +169,7 @@ export function gridItemToPanel(gridItem: SceneGridItemLike): Panel {
options: vizPanel.state.options,
fieldConfig: (vizPanel.state.fieldConfig as FieldConfigSource) ?? { defaults: {}, overrides: [] },
transformations: [],
transparent: false,
transparent: vizPanel.state.displayMode === 'transparent',
};
const panelTime = vizPanel.state.$timeRange;
@ -227,8 +232,23 @@ export function gridItemToPanel(gridItem: SceneGridItemLike): Panel {
panel.transformations = dataProvider.state.transformations as DataTransformerConfig[];
}
if (vizPanel.state.displayMode === 'transparent') {
panel.transparent = true;
if (dataProvider && isSnapshot) {
panel.datasource = GRAFANA_DATASOURCE_REF;
let data = getPanelDataFrames(dataProvider.state.data);
if (dataProvider instanceof SceneDataTransformer) {
// For transformations the non-transformed data is snapshoted
data = getPanelDataFrames(dataProvider.state.$data!.state.data);
}
panel.targets = [
{
refId: 'A',
datasource: panel.datasource,
queryType: GrafanaQueryType.Snapshot,
snapshot: data,
},
];
}
if (gridItem instanceof PanelRepeaterGridItem) {
@ -240,7 +260,7 @@ export function gridItemToPanel(gridItem: SceneGridItemLike): Panel {
return panel;
}
export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array<Panel | RowPanel>) {
export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array<Panel | RowPanel>, isSnapshot = false) {
const rowPanel: RowPanel = {
type: 'row',
id: getPanelIdForVizPanel(gridRow),
@ -265,7 +285,7 @@ export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array<Pan
panelsArray.push(rowPanel);
const panelsInsideRow = gridRow.state.children.map(gridItemToPanel);
const panelsInsideRow = gridRow.state.children.map((c) => gridItemToPanel(c, isSnapshot));
if (gridRow.state.isCollapsed) {
rowPanel.panels = panelsInsideRow;
@ -274,19 +294,75 @@ export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array<Pan
}
}
export function dataLayersToAnnotations(layers: SceneDataLayerProvider[]) {
const annotations: AnnotationQuery[] = [];
for (const layer of layers) {
if (!(layer instanceof dataLayers.AnnotationsDataLayer)) {
continue;
}
export function trimDashboardForSnapshot(title: string, time: TimeRange, dash: Dashboard, panel?: VizPanel) {
let result = {
...dash,
title,
time: {
from: time.from.toISOString(),
to: time.to.toISOString(),
},
links: [],
};
annotations.push({
...layer.state.query,
enable: Boolean(layer.state.isEnabled),
hide: Boolean(layer.state.isHidden),
// When VizPanel is present, we are snapshoting a single panel. The rest of the panels is removed from the dashboard,
// and the panel is resized to 24x20 grid and placed at the top of the dashboard.
if (panel) {
// @ts-expect-error Due to legacy panels types. Id is present on such panels too.
const singlePanel = dash.panels?.find((p) => p.id === getPanelIdForVizPanel(panel));
if (singlePanel) {
// @ts-expect-error Due to legacy panels types. Id is present on such panels too.
singlePanel.gridPos = { w: 24, x: 0, y: 0, h: 20 };
result = {
...result,
panels: [singlePanel],
};
}
}
// Remove links from all panels
result.panels?.forEach((panel) => {
if ('links' in panel) {
panel.links = [];
}
});
// Remove annotation queries, attach snapshotData: [] for backwards compatibility
if (result.annotations) {
const annotations = result.annotations.list?.filter((annotation) => annotation.enable) || [];
const trimedAnnotations = annotations.map((annotation) => {
return {
name: annotation.name,
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.
// For now leaving this in here to avoid annotation queries in snapshots.
// Annotations per panel are part of the snapshot query, so we don't need to store them here.
snapshotData: [],
};
});
result.annotations.list = trimedAnnotations;
}
if (result.templating) {
result.templating.list?.forEach((variable) => {
if ('query' in variable) {
variable.query = '';
}
if ('options' in variable) {
variable.options = variable.current && !isEmptyObject(variable.current) ? [variable.current] : [];
}
if ('refresh' in variable) {
variable.refresh = VariableRefresh.never;
}
});
}
return annotations;
return result;
}

View File

@ -46,7 +46,7 @@ export class ShareModal extends SceneObjectBase<ShareModalState> implements Moda
}
if (contextSrv.isSignedIn && config.snapshotEnabled) {
tabs.push(new ShareSnapshotTab({ panelRef, modalRef: this.getRef() }));
tabs.push(new ShareSnapshotTab({ panelRef, dashboardRef, modalRef: this.getRef() }));
}
this.setState({ tabs });

View File

@ -1,20 +1,249 @@
import React from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { SelectableValue } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes';
import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { trackDashboardSharingActionPerType } from 'app/features/dashboard/components/ShareModal/analytics';
import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { DashboardScene } from '../scene/DashboardScene';
import { transformSceneToSaveModel, trimDashboardForSnapshot } from '../serialization/transformSceneToSaveModel';
import { SceneShareTabState } from './types';
const SNAPSHOTS_API_ENDPOINT = '/api/snapshots';
const DEFAULT_EXPIRE_OPTION: SelectableValue<number> = {
label: t('share-modal.snapshot.expire-never', `Never`),
value: 0,
};
const EXPIRE_OPTIONS = [
DEFAULT_EXPIRE_OPTION,
{
label: t('share-modal.snapshot.expire-hour', `1 Hour`),
value: 60 * 60,
},
{
label: t('share-modal.snapshot.expire-day', `1 Day`),
value: 60 * 60 * 24,
},
{
label: t('share-modal.snapshot.expire-week', `7 Days`),
value: 60 * 60 * 24 * 7,
},
];
type SnapshotSharingOptions = {
externalEnabled: boolean;
externalSnapshotName: string;
externalSnapshotURL: string;
snapshotEnabled: boolean;
};
export interface ShareSnapshotTabState extends SceneShareTabState {
panelRef?: SceneObjectRef<VizPanel>;
dashboardRef: SceneObjectRef<DashboardScene>;
snapshotName?: string;
selectedExpireOption?: SelectableValue<number>;
snapshotSharingOptions?: SnapshotSharingOptions;
}
export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> {
static Component = ShareSnapshoTabRenderer;
public constructor(state: ShareSnapshotTabState) {
super({
...state,
snapshotName: state.dashboardRef.resolve().state.title,
selectedExpireOption: DEFAULT_EXPIRE_OPTION,
});
this.addActivationHandler(() => {
this._onActivate();
});
}
private _onActivate() {
getBackendSrv()
.get('/api/snapshot/shared-options')
.then((shareOptions: SnapshotSharingOptions) => {
if (this.isActive) {
this.setState({
snapshotSharingOptions: shareOptions,
});
}
});
}
public getTabLabel() {
return t('share-modal.tab-title.snapshot', 'Snapshot');
}
static Component = ({ model }: SceneComponentProps<ShareSnapshotTab>) => {
return <div>Snapshot</div>;
public onSnasphotNameChange = (snapshotName: string) => {
this.setState({ snapshotName: snapshotName.trim() });
};
public onExpireChange = (option: number) => {
this.setState({
selectedExpireOption: EXPIRE_OPTIONS.find((o) => o.value === option),
});
};
private prepareSnapshot() {
const timeRange = sceneGraph.getTimeRange(this);
const { dashboardRef, panelRef } = this.state;
const saveModel = transformSceneToSaveModel(dashboardRef.resolve(), true);
return trimDashboardForSnapshot(
this.state.snapshotName || '',
timeRange.state.value,
saveModel,
panelRef?.resolve()
);
}
public onSnapshotCreate = async (external = false) => {
const { selectedExpireOption } = this.state;
const snapshot = this.prepareSnapshot();
// TODO
// snapshot.snapshot = {
// originalUrl: window.location.href,
// };
const cmdData = {
dashboard: snapshot,
name: snapshot.title,
expires: selectedExpireOption?.value,
external,
};
try {
const results: { deleteUrl: string; url: string } = await getBackendSrv().post(SNAPSHOTS_API_ENDPOINT, cmdData);
return results;
} finally {
trackDashboardSharingActionPerType(external ? 'publish_snapshot' : 'local_snapshot', shareDashboardType.snapshot);
}
};
}
function ShareSnapshoTabRenderer({ model }: SceneComponentProps<ShareSnapshotTab>) {
const { snapshotName, selectedExpireOption, modalRef, snapshotSharingOptions } = model.useState();
const [snapshotResult, createSnapshot] = useAsyncFn(async (external = false) => {
return model.onSnapshotCreate(external);
});
const [deleteSnapshotResult, deleteSnapshot] = useAsyncFn(async (url: string) => {
return await getBackendSrv().get(url);
});
// If snapshot has been deleted - show message and allow to close modal
if (deleteSnapshotResult.value) {
return (
<Trans i18nKey="share-modal.snapshot.deleted-message">
The snapshot has been deleted. If you have already accessed it once, then it might take up to an hour before
before it is removed from browser caches or CDN caches.
</Trans>
);
}
return (
<>
{/* Before snapshot has been created show configuration */}
{!Boolean(snapshotResult.value) && (
<>
<div>
<p className="share-modal-info-text">
<Trans i18nKey="share-modal.snapshot.info-text-1">
A snapshot is an instant way to share an interactive dashboard publicly. When created, we strip
sensitive data like queries (metric, template, and annotation) and panel links, leaving only the visible
metric data and series names embedded in your dashboard.
</Trans>
</p>
<p className="share-modal-info-text">
<Trans i18nKey="share-modal.snapshot.info-text-2">
Keep in mind, your snapshot <em>can be viewed by anyone</em> that has the link and can access the URL.
Share wisely.
</Trans>
</p>
</div>
<Field label={t('share-modal.snapshot.name', `Snapshot name`)}>
<Input
id="snapshot-name-input"
width={30}
defaultValue={snapshotName}
onBlur={(e) => model.onSnasphotNameChange(e.target.value)}
/>
</Field>
<Field label={t('share-modal.snapshot.expire', `Expire`)}>
<RadioButtonGroup<number>
id="expire-select-input"
options={EXPIRE_OPTIONS}
value={selectedExpireOption?.value}
onChange={model.onExpireChange}
/>
</Field>
<Modal.ButtonRow>
<Button
variant="secondary"
onClick={() => {
modalRef?.resolve().onDismiss();
}}
fill="outline"
>
<Trans i18nKey="share-modal.snapshot.cancel-button">Cancel</Trans>
</Button>
{snapshotSharingOptions?.externalEnabled && (
<Button variant="secondary" disabled={snapshotResult.loading} onClick={() => createSnapshot(true)}>
{snapshotSharingOptions?.externalSnapshotName}
</Button>
)}
<Button variant="primary" disabled={snapshotResult.loading} onClick={() => createSnapshot()}>
<Trans i18nKey="share-modal.snapshot.local-button">Local Snapshot</Trans>
</Button>
</Modal.ButtonRow>
</>
)}
{/* When snapshot has been created - show link and allow copy/deletion */}
{snapshotResult.value && (
<>
<Field label={t('share-modal.snapshot.url-label', 'Snapshot URL')}>
<Input
id="snapshot-url-input"
value={snapshotResult.value.url}
readOnly
addonAfter={
<ClipboardButton icon="copy" variant="primary" getText={() => snapshotResult.value!.url}>
<Trans i18nKey="share-modal.snapshot.copy-link-button">Copy</Trans>
</ClipboardButton>
}
/>
</Field>
<div className="pull-right" style={{ padding: '5px' }}>
<Trans i18nKey="share-modal.snapshot.mistake-message">Did you make a mistake? </Trans>&nbsp;
<Button
fill="outline"
size="md"
variant="destructive"
onClick={() => {
deleteSnapshot(snapshotResult.value!.deleteUrl);
}}
>
<Trans i18nKey="share-modal.snapshot.delete-button">Delete snapshot.</Trans>
</Button>
</div>
</>
)}
</>
);
}

View File

@ -98,6 +98,7 @@ export class ShareSnapshot extends PureComponent<Props, State> {
saveSnapshot = async (dashboard: DashboardModel, external?: boolean) => {
const { snapshotExpires } = this.state;
const dash = this.dashboard.getSaveModelClone();
this.scrubDashboard(dash);
const cmdData = {
@ -141,6 +142,7 @@ export class ShareSnapshot extends PureComponent<Props, State> {
// remove annotation queries
const annotations = dash.annotations.list.filter((annotation) => annotation.enable);
dash.annotations.list = annotations.map((annotation) => {
return {
name: annotation.name,