Dashboard Scene: All panel menu items available (#81678)

* WIP: removing panel functionality

* wip

* Deleting works with old model and with unified alerting

* Add shortcut for removing panel

* Add duplicate panel functionality; improve remove panel logic

* Copy and new alert rule

* Hide legend

* WIP: Help wizard

* Fix PanelMenuBehavior tests

* Got help wizard to work in scenes

* Fix HelpWizard and SupportSnapshotService tests

* Use object for writing styles

* betterer

* Fix create lib panel

* PanelRepeaterItem should be duplicated

* Share randomizer from dashboard-scenes

* share randomizer

* Fix import

* Update error message

* Fix test

* When duplicating PanelRepeaterGridItem's child use  PanelRepeaterGridItem.state.source

* Don't use getResultsStream

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
pull/82080/head
Haris Rozajac 2 years ago committed by GitHub
parent b5f26560c2
commit 00aa876e46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      .betterer.results
  2. 94
      public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.test.tsx
  3. 229
      public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx
  4. 140
      public/app/features/dashboard-scene/inspect/HelpWizard/SupportSnapshotService.test.ts
  5. 132
      public/app/features/dashboard-scene/inspect/HelpWizard/SupportSnapshotService.ts
  6. 0
      public/app/features/dashboard-scene/inspect/HelpWizard/randomizer.test.ts
  7. 0
      public/app/features/dashboard-scene/inspect/HelpWizard/randomizer.ts
  8. 307
      public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts
  9. 10
      public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx
  10. 2
      public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx
  11. 71
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  12. 16
      public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx
  13. 176
      public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx
  14. 34
      public/app/features/dashboard-scene/scene/keyboardShortcuts.ts
  15. 2
      public/app/features/dashboard/components/HelpWizard/SupportSnapshotService.ts
  16. 3
      public/app/features/dashboard/components/HelpWizard/utils.ts

@ -2571,6 +2571,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
],
@ -2600,9 +2603,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]

@ -0,0 +1,94 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { FieldType, getDefaultTimeRange, LoadingState, toDataFrame } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import {
SceneGridItem,
SceneGridLayout,
SceneQueryRunner,
SceneTimeRange,
VizPanel,
VizPanelMenu,
} from '@grafana/scenes';
import { DashboardScene } from '../../scene/DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks';
import { panelMenuBehavior } from '../../scene/PanelMenuBehavior';
import { HelpWizard } from './HelpWizard';
async function setup() {
const { panel } = await buildTestScene();
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
return render(<HelpWizard panel={panel} onClose={() => {}} />);
}
describe('SupportSnapshot', () => {
it('Can render', async () => {
setup();
expect(await screen.findByRole('button', { name: 'Dashboard (3.50 KiB)' })).toBeInTheDocument();
});
});
async function buildTestScene() {
const menu = new VizPanelMenu({
$behaviors: [panelMenuBehavior],
});
const panel = new VizPanel({
title: 'Panel A',
pluginId: 'timeseries',
key: 'panel-12',
menu,
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
$data: new SceneQueryRunner({
data: {
state: LoadingState.Done,
series: [
toDataFrame({
name: 'http_requests_total',
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'Value', type: FieldType.number, values: [11, 22, 33] },
],
}),
],
timeRange: getDefaultTimeRange(),
},
datasource: { uid: 'my-uid' },
queries: [{ query: 'QueryA', refId: 'A' }],
}),
});
const scene = new DashboardScene({
title: 'My dashboard',
uid: 'dash-1',
tags: ['database', 'panel'],
$timeRange: new SceneTimeRange({
from: 'now-5m',
to: 'now',
timeZone: 'Africa/Abidjan',
}),
meta: {
canEdit: true,
isEmbedded: false,
},
body: new SceneGridLayout({
children: [
new SceneGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: panel,
}),
],
}),
});
await new Promise((r) => setTimeout(r, 1));
return { scene, panel, menu };
}

@ -0,0 +1,229 @@
import { css } from '@emotion/css';
import React, { useMemo, useEffect } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2, FeatureState } from '@grafana/data';
import { config } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import {
Drawer,
Tab,
TabsBar,
CodeEditor,
useStyles2,
Field,
HorizontalGroup,
InlineSwitch,
Button,
Spinner,
Alert,
FeatureBadge,
Select,
ClipboardButton,
Icon,
Stack,
} from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types';
import { ShowMessage, SnapshotTab, SupportSnapshotService } from './SupportSnapshotService';
interface Props {
panel: VizPanel;
onClose: () => void;
}
export function HelpWizard({ panel, onClose }: Props) {
const styles = useStyles2(getStyles);
const service = useMemo(() => new SupportSnapshotService(panel), [panel]);
const plugin = panel.getPlugin();
const {
currentTab,
loading,
error,
options,
showMessage,
snapshotSize,
markdownText,
snapshotText,
randomize,
panelTitle,
scene,
} = service.useState();
useEffect(() => {
service.buildDebugDashboard();
}, [service, plugin, randomize]);
if (!plugin) {
return null;
}
const tabs = [
{ label: 'Snapshot', value: SnapshotTab.Support },
{ label: 'Data', value: SnapshotTab.Data },
];
const hasSupportBundleAccess =
config.supportBundlesEnabled && contextSrv.hasPermission(AccessControlAction.ActionSupportBundlesCreate);
return (
<Drawer
title={`Get help with this panel`}
size="lg"
onClose={onClose}
subtitle={
<Stack direction="column" gap={1}>
<Stack direction="row" gap={1}>
<FeatureBadge featureState={FeatureState.beta} />
<a
href="https://grafana.com/docs/grafana/latest/troubleshooting/"
target="blank"
className="external-link"
rel="noopener noreferrer"
>
Troubleshooting docs <Icon name="external-link-alt" />
</a>
</Stack>
<span className="muted">
To request troubleshooting help, send a snapshot of this panel to Grafana Labs Technical Support. The
snapshot contains query response data and panel settings.
</span>
{hasSupportBundleAccess && (
<span className="muted">
You can also retrieve a support bundle containing information concerning your Grafana instance and
configured datasources in the <a href="/support-bundles">support bundles section</a>.
</span>
)}
</Stack>
}
tabs={
<TabsBar>
{tabs.map((t, index) => (
<Tab
key={`${t.value}-${index}`}
label={t.label}
active={t.value === currentTab}
onChangeTab={() => service.onCurrentTabChange(t.value!)}
/>
))}
</TabsBar>
}
>
{loading && <Spinner />}
{error && <Alert title={error.title}>{error.message}</Alert>}
{currentTab === SnapshotTab.Data && (
<div className={styles.code}>
<div className={styles.opts}>
<Field label="Template" className={styles.field}>
<Select options={options} value={showMessage} onChange={service.onShowMessageChange} />
</Field>
{showMessage === ShowMessage.GithubComment ? (
<ClipboardButton icon="copy" getText={service.onGetMarkdownForClipboard}>
Copy to clipboard
</ClipboardButton>
) : (
<Button icon="download-alt" onClick={service.onDownloadDashboard}>
Download ({snapshotSize})
</Button>
)}
</div>
<AutoSizer disableWidth>
{({ height }) => (
<CodeEditor
width="100%"
height={height}
language={showMessage === ShowMessage.GithubComment ? 'markdown' : 'json'}
showLineNumbers={true}
showMiniMap={true}
value={showMessage === ShowMessage.GithubComment ? markdownText : snapshotText}
readOnly={false}
onBlur={service.onSetSnapshotText}
/>
)}
</AutoSizer>
</div>
)}
{currentTab === SnapshotTab.Support && (
<>
<Field
label="Randomize data"
description="Modify the original data to hide sensitve information. Note the lengths will stay the same, and duplicate values will be equal."
>
<HorizontalGroup>
<InlineSwitch
label="Labels"
id="randomize-labels"
showLabel={true}
value={Boolean(randomize.labels)}
onChange={() => service.onToggleRandomize('labels')}
/>
<InlineSwitch
label="Field names"
id="randomize-field-names"
showLabel={true}
value={Boolean(randomize.names)}
onChange={() => service.onToggleRandomize('names')}
/>
<InlineSwitch
label="String values"
id="randomize-string-values"
showLabel={true}
value={Boolean(randomize.values)}
onChange={() => service.onToggleRandomize('values')}
/>
</HorizontalGroup>
</Field>
<Field label="Support snapshot" description={`Panel: ${panelTitle}`}>
<Stack>
<Button icon="download-alt" onClick={service.onDownloadDashboard}>
Dashboard ({snapshotSize})
</Button>
<ClipboardButton
icon="github"
getText={service.onGetMarkdownForClipboard}
title="Copy a complete GitHub comment to the clipboard"
>
Copy to clipboard
</ClipboardButton>
</Stack>
</Field>
<AutoSizer disableWidth>
{({ height }) => (
<div style={{ height, overflow: 'auto' }}>{scene && <scene.Component model={scene} />}</div>
)}
</AutoSizer>
</>
)}
</Drawer>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
code: css({
flexGrow: 1,
height: '100%',
overflow: 'scroll',
}),
field: css({
width: '100%',
}),
opts: css({
display: 'flex',
width: '100%',
flexGrow: 0,
alignItems: 'center',
justifyContent: 'flex-end',
'& button': {
marginLeft: theme.spacing(1),
},
}),
};
};

@ -0,0 +1,140 @@
import { FieldType, getDefaultTimeRange, LoadingState, toDataFrame } from '@grafana/data';
import {
SceneGridItem,
SceneGridLayout,
SceneQueryRunner,
SceneTimeRange,
VizPanel,
VizPanelMenu,
} from '@grafana/scenes';
import { DashboardScene } from '../../scene/DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks';
import { panelMenuBehavior } from '../../scene/PanelMenuBehavior';
import { SnapshotTab, SupportSnapshotService } from './SupportSnapshotService';
async function setup() {
const { panel } = await buildTestScene();
return new SupportSnapshotService(panel);
}
describe('SupportSnapshotService', () => {
it('Can create it with default state', async () => {
const service = await setup();
expect(service.state.currentTab).toBe(SnapshotTab.Support);
});
it('Can can build support snapshot dashboard', async () => {
const service = await setup();
await service.buildDebugDashboard();
expect(service.state.snapshot.panels[0].targets[0]).toMatchInlineSnapshot(`
{
"datasource": {
"type": "grafana",
"uid": "grafana",
},
"queryType": "snapshot",
"refId": "A",
"snapshot": [
{
"data": {
"values": [
[
1,
2,
3,
],
[
11,
22,
33,
],
],
},
"schema": {
"fields": [
{
"config": {},
"name": "Time",
"type": "time",
},
{
"config": {},
"name": "Value",
"type": "number",
},
],
"meta": undefined,
"name": "http_requests_total",
"refId": undefined,
},
},
],
}
`);
});
});
async function buildTestScene() {
const menu = new VizPanelMenu({
$behaviors: [panelMenuBehavior],
});
const panel = new VizPanel({
title: 'Panel A',
pluginId: 'timeseries',
key: 'panel-12',
menu,
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
$data: new SceneQueryRunner({
data: {
state: LoadingState.Done,
series: [
toDataFrame({
name: 'http_requests_total',
fields: [
{ name: 'Time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'Value', type: FieldType.number, values: [11, 22, 33] },
],
}),
],
timeRange: getDefaultTimeRange(),
},
datasource: { uid: 'my-uid' },
queries: [{ query: 'QueryA', refId: 'A' }],
}),
});
const scene = new DashboardScene({
title: 'My dashboard',
uid: 'dash-1',
tags: ['database', 'panel'],
$timeRange: new SceneTimeRange({
from: 'now-5m',
to: 'now',
timeZone: 'Africa/Abidjan',
}),
meta: {
canEdit: true,
isEmbedded: false,
},
body: new SceneGridLayout({
children: [
new SceneGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: panel,
}),
],
}),
});
await new Promise((r) => setTimeout(r, 1));
return { scene, panel, menu };
}

@ -0,0 +1,132 @@
import saveAs from 'file-saver';
import { dateTimeFormat, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data';
import { sceneGraph, SceneObject, VizPanel } from '@grafana/scenes';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene';
import { Randomize } from './randomizer';
import { getDebugDashboard, getGithubMarkdown } from './utils';
interface SupportSnapshotState {
currentTab: SnapshotTab;
showMessage: ShowMessage;
options: Array<SelectableValue<ShowMessage>>;
snapshotText: string;
markdownText: string;
snapshotSize?: string;
randomize: Randomize;
loading?: boolean;
error?: {
title: string;
message: string;
};
panel: VizPanel;
panelTitle: string;
// eslint-disable-next-line
snapshot?: any;
snapshotUpdate: number;
scene?: SceneObject;
}
export enum SnapshotTab {
Support,
Data,
}
export enum ShowMessage {
PanelSnapshot,
GithubComment,
}
export class SupportSnapshotService extends StateManagerBase<SupportSnapshotState> {
constructor(panel: VizPanel) {
super({
panel,
panelTitle: sceneGraph.interpolate(panel, panel.state.title, {}, 'text'),
currentTab: SnapshotTab.Support,
showMessage: ShowMessage.GithubComment,
snapshotText: '',
markdownText: '',
randomize: {},
snapshotUpdate: 0,
options: [
{
label: 'GitHub comment',
description: 'Copy and paste this message into a GitHub issue or comment',
value: ShowMessage.GithubComment,
},
{
label: 'Panel support snapshot',
description: 'Dashboard JSON used to help troubleshoot visualization issues',
value: ShowMessage.PanelSnapshot,
},
],
});
}
async buildDebugDashboard() {
const { panel, randomize, snapshotUpdate } = this.state;
const snapshot = await getDebugDashboard(panel, randomize, sceneGraph.getTimeRange(panel).state.value);
const snapshotText = JSON.stringify(snapshot, null, 2);
const markdownText = getGithubMarkdown(panel, snapshotText);
const snapshotSize = formattedValueToString(getValueFormat('bytes')(snapshotText?.length ?? 0));
let scene: SceneObject | undefined = undefined;
try {
const dash = transformSaveModelToScene({ dashboard: snapshot, meta: { isEmbedded: true } });
scene = dash.state.body; // skip the wrappers
} catch (ex) {
console.log('Error creating scene:', ex);
}
this.setState({ snapshot, snapshotText, markdownText, snapshotSize, snapshotUpdate: snapshotUpdate + 1, scene });
}
onCurrentTabChange = (value: SnapshotTab) => {
this.setState({ currentTab: value });
};
onShowMessageChange = (value: SelectableValue<ShowMessage>) => {
this.setState({ showMessage: value.value! });
};
onGetMarkdownForClipboard = () => {
const { markdownText } = this.state;
const maxLen = Math.pow(1024, 2) * 1.5; // 1.5MB
if (markdownText.length > maxLen) {
this.setState({
error: {
title: 'Copy to clipboard failed',
message: 'Snapshot is too large, consider download and attaching a file instead',
},
});
return '';
}
return markdownText;
};
onDownloadDashboard = () => {
const { snapshotText, panelTitle } = this.state;
const blob = new Blob([snapshotText], {
type: 'text/plain',
});
const fileName = `debug-${panelTitle}-${dateTimeFormat(new Date())}.json.txt`;
saveAs(blob, fileName);
};
onSetSnapshotText = (snapshotText: string) => {
this.setState({ snapshotText });
};
onToggleRandomize = (k: keyof Randomize) => {
const { randomize } = this.state;
this.setState({ randomize: { ...randomize, [k]: !randomize[k] } });
};
}

@ -0,0 +1,307 @@
import { cloneDeep } from 'lodash';
import {
dateTimeFormat,
TimeRange,
PanelData,
DataTransformerConfig,
DataFrameJSON,
LoadingState,
dataFrameToJSON,
DataTopic,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneGridItem, VizPanel } from '@grafana/scenes';
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { gridItemToPanel } from '../../serialization/transformSceneToSaveModel';
import { getQueryRunnerFor } from '../../utils/utils';
import { Randomize, randomizeData } from './randomizer';
export function getPanelDataFrames(data?: PanelData): DataFrameJSON[] {
const frames: DataFrameJSON[] = [];
if (data?.series) {
for (const f of data.series) {
frames.push(dataFrameToJSON(f));
}
}
if (data?.annotations) {
for (const f of data.annotations) {
const json = dataFrameToJSON(f);
if (!json.schema?.meta) {
json.schema!.meta = {};
}
json.schema!.meta.dataTopic = DataTopic.Annotations;
frames.push(json);
}
}
return frames;
}
export function getGithubMarkdown(panel: VizPanel, snapshot: string): string {
const info = {
panelType: panel.state.pluginId,
datasource: '??',
};
const grafanaVersion = `${config.buildInfo.version} (${config.buildInfo.commit})`;
let md = `| Key | Value |
|--|--|
| Panel | ${info.panelType} @ ${panel.state.pluginVersion ?? grafanaVersion} |
| Grafana | ${grafanaVersion} // ${config.buildInfo.edition} |
`;
if (snapshot) {
md += '<details><summary>Panel debug snapshot dashboard</summary>\n\n```json\n' + snapshot + '\n```\n</details>';
}
return md;
}
export async function getDebugDashboard(panel: VizPanel, rand: Randomize, timeRange: TimeRange) {
const saveModel = gridItemToPanel(panel.parent as SceneGridItem);
const dashboard = cloneDeep(embeddedDataTemplate);
const info = {
panelType: saveModel.type,
datasource: '??',
};
// reproducable
const queryRunner = getQueryRunnerFor(panel)!;
if (!queryRunner.state.data) {
return;
}
const data = queryRunner.state.data;
const dsref = queryRunner?.state.datasource;
const frames = randomizeData(getPanelDataFrames(data), rand);
const grafanaVersion = `${config.buildInfo.version} (${config.buildInfo.commit})`;
const queries = queryRunner.state.queries ?? [];
const html = `<table width="100%">
<tr>
<th width="2%">Panel</th>
<td >${info.panelType} @ ${saveModel.pluginVersion ?? grafanaVersion}</td>
</tr>
<tr>
<th>Queries</th>
<td>${queries
.map((t) => {
const ds = t.datasource ?? dsref;
return `${t.refId}[${ds?.type}]`;
})
.join(', ')}</td>
</tr>
${getTransformsRow(saveModel)}
${getDataRow(data, frames)}
${getAnnotationsRow(data)}
<tr>
<th>Grafana</th>
<td>${grafanaVersion} // ${config.buildInfo.edition}</td>
</tr>
</table>`.trim();
// Replace the panel with embedded data
dashboard.panels[0] = {
...saveModel,
...dashboard.panels[0],
targets: [
{
refId: 'A',
datasource: {
type: 'grafana',
uid: 'grafana',
},
queryType: GrafanaQueryType.Snapshot,
snapshot: frames,
},
],
};
if (saveModel.transformations?.length) {
const last = dashboard.panels[dashboard.panels.length - 1];
last.title = last.title + ' (after transformations)';
const before = cloneDeep(last);
before.id = 100;
before.title = 'Data (before transformations)';
before.gridPos.w = 24; // full width
before.targets[0].withTransforms = false;
dashboard.panels.push(before);
}
if (data.annotations?.length) {
dashboard.panels.push({
id: 7,
gridPos: {
h: 6,
w: 24,
x: 0,
y: 20,
},
type: 'table',
title: 'Annotations',
datasource: {
type: 'datasource',
uid: '-- Dashboard --',
},
options: {
showTypeIcons: true,
},
targets: [
{
datasource: {
type: 'datasource',
uid: '-- Dashboard --',
},
panelId: 2,
withTransforms: true,
topic: DataTopic.Annotations,
refId: 'A',
},
],
});
}
dashboard.panels[1].options.content = html;
dashboard.panels[2].options.content = JSON.stringify(saveModel, null, 2);
dashboard.title = `Debug: ${saveModel.title} // ${dateTimeFormat(new Date())}`;
dashboard.tags = ['debug', `debug-${info.panelType}`];
dashboard.time = {
from: timeRange.from.toISOString(),
to: timeRange.to.toISOString(),
};
return dashboard;
}
// eslint-disable-next-line
function getTransformsRow(saveModel: any): string {
if (!saveModel.transformations) {
return '';
}
return `<tr>
<th>Transform</th>
<td>${saveModel.transformations.map((t: DataTransformerConfig) => t.id).join(', ')}</td>
</tr>`;
}
function getDataRow(data: PanelData, frames: DataFrameJSON[]): string {
let frameCount = data.series.length ?? 0;
let fieldCount = 0;
let rowCount = 0;
for (const frame of data.series) {
fieldCount += frame.fields.length;
rowCount += frame.length;
}
return (
'<tr>' +
'<th>Data</th>' +
'<td>' +
`${data.state !== LoadingState.Done ? data.state : ''} ` +
`${frameCount} frames, ${fieldCount} fields, ` +
`${rowCount} rows ` +
// `(${formattedValueToString(getValueFormat('decbytes')(raw?.length))} JSON)` +
'</td>' +
'</tr>'
);
}
function getAnnotationsRow(data: PanelData): string {
if (!data.annotations?.length) {
return '';
}
return `<tr>
<th>Annotations</th>
<td>${data.annotations.map((a, idx) => `<span>${a.length}</span>`)}</td>
</tr>`;
}
// eslint-disable-next-line
const embeddedDataTemplate: any = {
// should be dashboard model when that is accurate enough
panels: [
{
id: 2,
title: 'Reproduced with embedded data',
datasource: {
type: 'grafana',
uid: 'grafana',
},
gridPos: {
h: 13,
w: 15,
x: 0,
y: 0,
},
},
{
gridPos: {
h: 7,
w: 9,
x: 15,
y: 0,
},
id: 5,
options: {
content: '...',
mode: 'html',
},
title: 'Debug info',
type: 'text',
},
{
id: 6,
title: 'Original Panel JSON',
type: 'text',
gridPos: {
h: 13,
w: 9,
x: 15,
y: 7,
},
options: {
content: '...',
mode: 'code',
code: {
language: 'json',
showLineNumbers: true,
showMiniMap: true,
},
},
},
{
id: 3,
title: 'Data from panel above',
type: 'table',
datasource: {
type: 'datasource',
uid: '-- Dashboard --',
},
gridPos: {
h: 7,
w: 15,
x: 0,
y: 13,
},
options: {
showTypeIcons: true,
},
targets: [
{
datasource: {
type: 'datasource',
uid: '-- Dashboard --',
},
panelId: 2,
withTransforms: true,
refId: 'A',
},
],
},
],
schemaVersion: 37,
};

@ -14,10 +14,12 @@ import {
import { Alert, Drawer, Tab, TabsBar } from '@grafana/ui';
import { getDataSourceWithInspector } from 'app/features/dashboard/components/Inspector/hooks';
import { supportsDataQuery } from 'app/features/dashboard/components/PanelEditor/utils';
import { InspectTab } from 'app/features/inspector/types';
import { getDashboardUrl } from '../utils/urlBuilders';
import { getDashboardSceneFor } from '../utils/utils';
import { HelpWizard } from './HelpWizard/HelpWizard';
import { InspectDataTab } from './InspectDataTab';
import { InspectJsonTab } from './InspectJsonTab';
import { InspectMetaDataTab } from './InspectMetaDataTab';
@ -106,7 +108,7 @@ export class PanelInspectDrawer extends SceneObjectBase<PanelInspectDrawerState>
}
function PanelInspectRenderer({ model }: SceneComponentProps<PanelInspectDrawer>) {
const { tabs, pluginNotLoaded } = model.useState();
const { tabs, pluginNotLoaded, panelRef } = model.useState();
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
@ -117,6 +119,12 @@ function PanelInspectRenderer({ model }: SceneComponentProps<PanelInspectDrawer>
const urlTab = queryParams.get('inspectTab');
const currentTab = tabs.find((tab) => tab.getTabValue() === urlTab) ?? tabs[0];
const vizPanel = panelRef!.resolve();
if (urlTab === InspectTab.Help) {
return <HelpWizard panel={vizPanel} onClose={model.onClose} />;
}
return (
<Drawer
title={model.getDrawerTitle()}

@ -39,7 +39,7 @@ interface VizPanelManagerState extends SceneObjectState {
dsSettings?: DataSourceInstanceSettings;
}
// VizPanelManager serves as an API to manipulate VizPanel state from the outside. It allows panel type, options and data maniulation.
// VizPanelManager serves as an API to manipulate VizPanel state from the outside. It allows panel type, options and data manipulation.
export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
public static Component = ({ model }: SceneComponentProps<VizPanelManager>) => {
const { panel } = model.useState();

@ -3,11 +3,12 @@ import * as H from 'history';
import React from 'react';
import { Unsubscribable } from 'rxjs';
import { CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil, textUtil } from '@grafana/data';
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil, textUtil } from '@grafana/data';
import { locationService, config } from '@grafana/runtime';
import {
getUrlSyncManager,
SceneFlexLayout,
sceneGraph,
SceneGridItem,
SceneGridLayout,
SceneObject,
@ -19,11 +20,14 @@ import {
sceneUtils,
SceneVariable,
SceneVariableDependencyConfigLike,
VizPanel,
} from '@grafana/scenes';
import { Dashboard, DashboardLink } from '@grafana/schema';
import { ConfirmModal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { getNavModel } from 'app/core/selectors/navModel';
import store from 'app/core/store';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
@ -35,6 +39,7 @@ import { PanelEditor } from '../panel-edit/PanelEditor';
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { gridItemToPanel } from '../serialization/transformSceneToSaveModel';
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
import { DashboardEditView } from '../settings/utils';
import { historySrv } from '../settings/version-history';
@ -45,6 +50,7 @@ import { forceRenderChildren, getClosestVizPanel, getPanelIdForVizPanel, isPanel
import { DashboardControls } from './DashboardControls';
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
import { PanelRepeaterGridItem } from './PanelRepeaterGridItem';
import { ViewPanelScene } from './ViewPanelScene';
import { setupKeyboardShortcuts } from './keyboardShortcuts';
@ -135,6 +141,8 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
}
private _activationHandler() {
let prevSceneContext = window.__grafanaSceneContext;
window.__grafanaSceneContext = this;
if (this.state.isEditing) {
@ -153,7 +161,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
// Deactivation logic
return () => {
window.__grafanaSceneContext = undefined;
window.__grafanaSceneContext = prevSceneContext;
clearKeyBindings();
this.stopTrackingChanges();
this.stopUrlSync();
@ -399,6 +407,65 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
return this._initialState;
}
public duplicatePanel(vizPanel: VizPanel) {
if (!vizPanel.parent) {
return;
}
const gridItem = vizPanel.parent;
if (!(gridItem instanceof SceneGridItem || PanelRepeaterGridItem)) {
console.error('Trying to duplicate a panel in a layout that is not SceneGridItem or PanelRepeaterGridItem');
return;
}
let panelState;
let panelData;
if (gridItem instanceof PanelRepeaterGridItem) {
const { key, ...gridRepeaterSourceState } = sceneUtils.cloneSceneObjectState(gridItem.state.source.state);
panelState = { ...gridRepeaterSourceState };
panelData = sceneGraph.getData(gridItem.state.source).clone();
} else {
const { key, ...gridItemPanelState } = sceneUtils.cloneSceneObjectState(vizPanel.state);
panelState = { ...gridItemPanelState };
panelData = sceneGraph.getData(vizPanel).clone();
}
// when we duplicate a panel we don't want to clone the alert state
delete panelData.state.data?.alertState;
const { key: gridItemKey, ...gridItemToDuplicateState } = sceneUtils.cloneSceneObjectState(gridItem.state);
const newGridItem = new SceneGridItem({
...gridItemToDuplicateState,
body: new VizPanel({ ...panelState, $data: panelData }),
});
if (!(this.state.body instanceof SceneGridLayout)) {
console.error('Trying to duplicate a panel in a layout that is not SceneGridLayout ');
return;
}
const sceneGridLayout = this.state.body;
sceneGridLayout.setState({
children: [...sceneGridLayout.state.children, newGridItem],
});
}
public copyPanel(vizPanel: VizPanel) {
if (!vizPanel.parent) {
return;
}
const gridItem = vizPanel.parent;
const jsonData = gridItemToPanel(gridItem);
store.set(LS_PANEL_COPY_KEY, JSON.stringify(jsonData));
appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Click **Add panel** icon to paste.']);
}
public showModal(modal: SceneObject) {
this.setState({ overlay: modal });
}

@ -23,6 +23,7 @@ import { contextSrv } from 'app/core/services/context_srv';
import { GetExploreUrlArguments } from 'app/core/utils/explore';
import { DashboardScene } from './DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
import { panelMenuBehavior } from './PanelMenuBehavior';
const mocks = {
@ -69,7 +70,7 @@ describe('panelMenuBehavior', () => {
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(6);
expect(menu.state.items?.length).toBe(8);
// verify view panel url keeps url params and adds viewPanel=<panel-key>
expect(menu.state.items?.[0].href).toBe('/d/dash-1?from=now-5m&to=now&viewPanel=panel-12');
// verify edit url keeps url time range
@ -118,7 +119,7 @@ describe('panelMenuBehavior', () => {
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
expect(menu.state.items?.length).toBe(9);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
@ -157,7 +158,7 @@ describe('panelMenuBehavior', () => {
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
expect(menu.state.items?.length).toBe(9);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
@ -198,7 +199,7 @@ describe('panelMenuBehavior', () => {
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
expect(menu.state.items?.length).toBe(9);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
const menuItem = extensionsSubMenu?.find((i) => (i.text = 'Declare incident when...'));
@ -346,7 +347,7 @@ describe('panelMenuBehavior', () => {
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
expect(menu.state.items?.length).toBe(9);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
@ -391,7 +392,7 @@ describe('panelMenuBehavior', () => {
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
expect(menu.state.items?.length).toBe(9);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
@ -444,7 +445,7 @@ describe('panelMenuBehavior', () => {
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
expect(menu.state.items?.length).toBe(9);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
@ -501,6 +502,7 @@ async function buildTestScene(options: SceneOptions) {
pluginId: 'table',
key: 'panel-12',
menu,
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
$variables: new SceneVariableSet({
variables: [new LocalValueVariable({ name: 'a', value: 'a', text: 'a' })],
}),

@ -5,16 +5,33 @@ import {
PluginExtensionPanelContext,
PluginExtensionPoints,
getTimeZone,
urlUtil,
} from '@grafana/data';
import { config, getPluginLinkExtensions, locationService } from '@grafana/runtime';
import { LocalValueVariable, SceneGridRow, VizPanel, VizPanelMenu, sceneGraph } from '@grafana/scenes';
import { DataQuery } from '@grafana/schema';
import {
LocalValueVariable,
SceneFlexLayout,
SceneGridItem,
SceneGridLayout,
SceneGridRow,
SceneObject,
VizPanel,
VizPanelMenu,
sceneGraph,
} from '@grafana/scenes';
import { DataQuery, OptionsWithLegend } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { t } from 'app/core/internationalization';
import { panelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form';
import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { InspectTab } from 'app/features/inspector/types';
import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegration';
import { ShowConfirmModalEvent } from 'app/types/events';
import { gridItemToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { ShareModal } from '../sharing/ShareModal';
import { DashboardInteractions } from '../utils/interactions';
import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
@ -39,6 +56,10 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
const panelId = getPanelIdForVizPanel(panel);
const dashboard = getDashboardSceneFor(panel);
const { isEmbedded } = dashboard.state.meta;
const panelJson = gridItemToPanel(panel.parent as SceneGridItem);
const panelModel = new PanelModel(panelJson);
const dashboardJson = transformSceneToSaveModel(dashboard);
const dashboardModel = new DashboardModel(dashboardJson);
const exploreMenuItem = await getExploreMenuItem(panel);
@ -80,25 +101,69 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
shortcut: 'p s',
});
moreSubMenu.push({
text: t('panel.header-menu.duplicate', `Duplicate`),
onClick: () => {
DashboardInteractions.panelMenuItemClicked('duplicate');
dashboard.duplicatePanel(panel);
},
shortcut: 'p d',
});
moreSubMenu.push({
text: t('panel.header-menu.copy', `Copy`),
onClick: () => {
DashboardInteractions.panelMenuItemClicked('copy');
dashboard.copyPanel(panel);
},
});
if (panel.parent instanceof LibraryVizPanel) {
// TODO: Implement lib panel unlinking
} else {
moreSubMenu.push({
text: t('panel.header-menu.create-library-panel', `Create library panel`),
iconClassName: 'share-alt',
onClick: () => {
DashboardInteractions.panelMenuItemClicked('createLibraryPanel');
dashboard.showModal(
new ShareModal({
panelRef: panel.getRef(),
dashboardRef: dashboard.getRef(),
activeTab: 'Library panel',
activeTab: shareDashboardType.libraryPanel,
})
);
},
});
}
moreSubMenu.push({
text: t('panel.header-menu.new-alert-rule', `New alert rule`),
onClick: (e) => onCreateAlert(e, panelModel, dashboardModel),
});
if (hasLegendOptions(panel.state.options)) {
moreSubMenu.push({
text: panel.state.options.legend.showLegend
? t('panel.header-menu.hide-legend', 'Hide legend')
: t('panel.header-menu.show-legend', 'Show legend'),
onClick: (e) => {
e.preventDefault();
toggleVizPanelLegend(panel);
},
shortcut: 'p l',
});
}
if (dashboard.canEditDashboard() && plugin && !plugin.meta.skipDataQuery) {
moreSubMenu.push({
text: t('panel.header-menu.get-help', 'Get help'),
onClick: (e: React.MouseEvent) => {
e.preventDefault();
onInspectPanel(panel, InspectTab.Help);
},
});
}
if (config.featureToggles.datatrails) {
addDataTrailPanelAction(dashboard, panel, items);
}
@ -136,6 +201,21 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
});
}
items.push({
text: '',
type: 'divider',
});
items.push({
text: t('panel.header-menu.remove', `Remove`),
iconClassName: 'trash-alt',
onClick: () => {
DashboardInteractions.panelMenuItemClicked('remove');
removePanel(dashboard, panel, true);
},
shortcut: 'p r',
});
menu.setState({ items });
};
@ -304,3 +384,91 @@ function createExtensionContext(panel: VizPanel, dashboard: DashboardScene): Plu
data: queryRunner?.state.data,
};
}
export function removePanel(dashboard: DashboardScene, panel: VizPanel, ask: boolean) {
const vizPanelData = sceneGraph.getData(panel);
let panelHasAlert = false;
if (vizPanelData.state.data?.alertState) {
panelHasAlert = true;
}
if (ask !== false) {
const text2 =
panelHasAlert && !config.unifiedAlertingEnabled
? 'Panel includes an alert rule. removing the panel will also remove the alert rule'
: undefined;
const confirmText = panelHasAlert ? 'YES' : undefined;
appEvents.publish(
new ShowConfirmModalEvent({
title: 'Remove panel',
text: 'Are you sure you want to remove this panel?',
text2: text2,
icon: 'trash-alt',
confirmText: confirmText,
yesText: 'Remove',
onConfirm: () => removePanel(dashboard, panel, false),
})
);
return;
}
const panels: SceneObject[] = [];
dashboard.state.body.forEachChild((child: SceneObject) => {
if (child.state.key !== panel.parent?.state.key) {
panels.push(child);
}
});
const layout = dashboard.state.body;
if (layout instanceof SceneGridLayout || SceneFlexLayout) {
layout.setState({
children: panels,
});
}
}
const onCreateAlert = (event: React.MouseEvent, panel: PanelModel, dashboard: DashboardModel) => {
event.preventDefault();
createAlert(panel, dashboard);
DashboardInteractions.panelMenuItemClicked('create-alert');
};
const createAlert = async (panel: PanelModel, dashboard: DashboardModel) => {
const formValues = await panelToRuleFormValues(panel, dashboard);
const ruleFormUrl = urlUtil.renderUrl('/alerting/new', {
defaults: JSON.stringify(formValues),
returnTo: location.pathname + location.search,
});
locationService.push(ruleFormUrl);
};
export function toggleVizPanelLegend(vizPanel: VizPanel): void {
const options = vizPanel.state.options;
if (hasLegendOptions(options) && typeof options.legend.showLegend === 'boolean') {
vizPanel.onOptionsChange({
legend: {
showLegend: options.legend.showLegend ? false : true,
},
});
}
DashboardInteractions.panelMenuItemClicked('toggleLegend');
}
function hasLegendOptions(optionsWithLegend: unknown): optionsWithLegend is OptionsWithLegend {
return optionsWithLegend != null && typeof optionsWithLegend === 'object' && 'legend' in optionsWithLegend;
}
const onInspectPanel = (vizPanel: VizPanel, tab?: InspectTab) => {
locationService.partial({
inspect: vizPanel.state.key,
inspectTab: tab,
});
DashboardInteractions.panelMenuInspectClicked(tab ?? InspectTab.Data);
};

@ -1,6 +1,5 @@
import { locationService } from '@grafana/runtime';
import { sceneGraph, VizPanel } from '@grafana/scenes';
import { OptionsWithLegend } from '@grafana/schema';
import { KeybindingSet } from 'app/core/services/KeybindingSet';
import { ShareModal } from '../sharing/ShareModal';
@ -9,6 +8,7 @@ import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPan
import { getPanelIdForVizPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { removePanel, toggleVizPanelLegend } from './PanelMenuBehavior';
export function setupKeyboardShortcuts(scene: DashboardScene) {
const keybindings = new KeybindingSet();
@ -116,7 +116,22 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
});
// toggle all panel legends (TODO)
// delete panel (TODO when we work on editing)
// delete panel
keybindings.addBinding({
key: 'p r',
onTrigger: withFocusedPanel(scene, (vizPanel: VizPanel) => {
removePanel(scene, vizPanel, true);
}),
});
// duplicate panel
keybindings.addBinding({
key: 'p d',
onTrigger: withFocusedPanel(scene, (vizPanel: VizPanel) => {
scene.duplicatePanel(vizPanel);
}),
});
// toggle all exemplars (TODO)
// collapse all rows (TODO)
// expand all rows (TODO)
@ -144,21 +159,6 @@ export function withFocusedPanel(scene: DashboardScene, fn: (vizPanel: VizPanel)
};
}
export function toggleVizPanelLegend(vizPanel: VizPanel) {
const options = vizPanel.state.options;
if (hasLegendOptions(options) && typeof options.legend.showLegend === 'boolean') {
vizPanel.onOptionsChange({
legend: {
showLegend: options.legend.showLegend ? false : true,
},
});
}
}
function hasLegendOptions(optionsWithLegend: unknown): optionsWithLegend is OptionsWithLegend {
return optionsWithLegend != null && typeof optionsWithLegend === 'object' && 'legend' in optionsWithLegend;
}
function handleZoomOut(scene: DashboardScene) {
const timePicker = dashboardSceneGraph.getTimePicker(scene);
timePicker?.onZoom();

@ -4,13 +4,13 @@ import { dateTimeFormat, formattedValueToString, getValueFormat, SelectableValue
import { config } from '@grafana/runtime';
import { SceneObject } from '@grafana/scenes';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { Randomize } from 'app/features/dashboard-scene/inspect/HelpWizard/randomizer';
import { createDashboardSceneFromDashboardModel } from 'app/features/dashboard-scene/serialization/transformSaveModelToScene';
import { getTimeSrv } from '../../services/TimeSrv';
import { DashboardModel, PanelModel } from '../../state';
import { setDashboardToFetchFromLocalStorage } from '../../state/initDashboard';
import { Randomize } from './randomizer';
import { getDebugDashboard, getGithubMarkdown } from './utils';
interface SupportSnapshotState {

@ -14,10 +14,9 @@ import {
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { PanelModel } from 'app/features/dashboard/state';
import { Randomize, randomizeData } from 'app/features/dashboard-scene/inspect/HelpWizard/randomizer';
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { Randomize, randomizeData } from './randomizer';
export function getPanelDataFrames(data?: PanelData): DataFrameJSON[] {
const frames: DataFrameJSON[] = [];
if (data?.series) {

Loading…
Cancel
Save