mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
@@ -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] } });
|
||||
};
|
||||
}
|
||||
307
public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts
Normal file
307
public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts
Normal file
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user