SupportSnapshots: improved iframe support (#55059)

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Ryan McKinley 2022-09-14 12:54:09 -07:00 committed by GitHub
parent e37420f0a8
commit 7700b529f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 438 additions and 180 deletions

View File

@ -99,6 +99,9 @@ export class GrafanaApp {
async init() {
try {
// Let iframe container know grafana has started loading
parent.postMessage('GrafanaAppInit', '*');
setBackendSrv(backendSrv);
initEchoSrv();
addClassIfNoOverlayScrollbar();

View File

@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { Subject } from 'rxjs';
import { useForceUpdate } from '@grafana/ui';
export class StateManagerBase<TState> {
subject = new Subject<TState>();
state: TState;
constructor(state: TState) {
this.state = state;
this.subject.next(state);
}
useState() {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useLatestState(this);
}
setState(update: Partial<TState>) {
this.state = {
...this.state,
...update,
};
this.subject.next(this.state);
}
}
/**
* This hook is always returning model.state instead of a useState that remembers the last state emitted on the subject
* The reason for this is so that if the model instance change this function will always return the latest state.
*/
function useLatestState<TState>(model: StateManagerBase<TState>): TState {
const forceUpdate = useForceUpdate();
useEffect(() => {
const s = model.subject.subscribe(forceUpdate);
return () => s.unsubscribe();
}, [model, forceUpdate]);
return model.state;
}

View File

@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { FieldType, getDefaultTimeRange, LoadingState, toDataFrame } from '@grafana/data';
import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks';
import { PanelModel } from '../../state/PanelModel';
import { SupportSnapshot } from './SupportSnapshot';
function setup() {
const panel = new PanelModel({});
panel.plugin = getPanelPlugin({});
panel.getQueryRunner().setLastResult({
timeRange: getDefaultTimeRange(),
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] },
],
}),
],
});
panel.getQueryRunner().resendLastResult();
return render(<SupportSnapshot panel={panel} onClose={() => {}} plugin={panel.plugin} />);
}
describe('SupportSnapshot', () => {
it('Can render', async () => {
setup();
expect(await screen.findByRole('button', { name: 'Dashboard (2.94 KiB)' })).toBeInTheDocument();
});
});

View File

@ -1,20 +1,9 @@
import { css } from '@emotion/css';
import { saveAs } from 'file-saver';
import React, { useState, useMemo } from 'react';
import { useAsync, useCopyToClipboard } from 'react-use';
import React, { useMemo, useEffect } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import {
PanelPlugin,
GrafanaTheme2,
AppEvents,
SelectableValue,
dateTimeFormat,
getValueFormat,
FeatureState,
formattedValueToString,
} from '@grafana/data';
import { config, getTemplateSrv } from '@grafana/runtime';
import { PanelPlugin, GrafanaTheme2, FeatureState } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
Drawer,
Tab,
@ -29,16 +18,13 @@ import {
Alert,
FeatureBadge,
Select,
ClipboardButton,
Stack,
} from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/core';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { contextSrv } from 'app/core/services/context_srv';
import { PanelModel } from 'app/features/dashboard/state';
import { setDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard';
import { InspectTab } from 'app/features/inspector/types';
import { Randomize } from './randomizer';
import { getGithubMarkdown, getDebugDashboard } from './utils';
import { ShowMessage, SnapshotTab, SupportSnapshotService } from './SupportSnapshotService';
interface Props {
panel: PanelModel;
@ -46,145 +32,90 @@ interface Props {
onClose: () => void;
}
enum ShowMessge {
PanelSnapshot = 'snap',
GithubComment = 'github',
}
const options: Array<SelectableValue<ShowMessge>> = [
{
label: 'Github comment',
description: 'Copy and paste this message into a github issue or comment',
value: ShowMessge.GithubComment,
},
{
label: 'Panel support snapshot',
description: 'Dashboard JSON used to help troubleshoot visualization issues',
value: ShowMessge.PanelSnapshot,
},
];
export function SupportSnapshot({ panel, plugin, onClose }: Props) {
const styles = useStyles2(getStyles);
const [currentTab, setCurrentTab] = useState(InspectTab.Support);
const [showMessage, setShowMessge] = useState(ShowMessge.GithubComment);
const [snapshotText, setDashboardText] = useState('...');
const [rand, setRand] = useState<Randomize>({});
const [_, copyToClipboard] = useCopyToClipboard();
const info = useAsync(async () => {
const dashboard = await getDebugDashboard(panel, rand, getTimeSrv().timeRange());
setDashboardToFetchFromLocalStorage({ meta: {}, dashboard });
setDashboardText(JSON.stringify(dashboard, null, 2));
}, [rand, panel, plugin, setDashboardText, currentTab]);
const service = useMemo(() => new SupportSnapshotService(panel), [panel]);
const snapshotSize = useMemo(() => {
return formattedValueToString(getValueFormat('bytes')(snapshotText?.length ?? 0));
}, [snapshotText]);
const {
currentTab,
loading,
error,
iframeLoading,
options,
showMessage,
snapshotSize,
markdownText,
snapshotText,
randomize,
panelTitle,
snapshotUpdate,
} = service.useState();
const markdownText = useMemo(() => {
return getGithubMarkdown(panel, snapshotText);
}, [snapshotText, panel]);
useEffect(() => {
service.buildDebugDashboard();
}, [service, plugin, randomize]);
useEffect(() => {
// Listen for messages from loaded iframe
return service.subscribeToIframeLoadingMessage();
}, [service]);
if (!plugin) {
return null;
}
const panelTitle = getTemplateSrv().replace(panel.title, panel.scopedVars, 'text') || 'Panel';
const toggleRandomize = (k: keyof Randomize) => {
setRand({ ...rand, [k]: !rand[k] });
};
const doImportDashboard = () => {
setDashboardToFetchFromLocalStorage({ meta: {}, dashboard: JSON.parse(snapshotText) });
global.open(config.appUrl + 'dashboard/new', '_blank');
};
const doDownloadDashboard = () => {
const blob = new Blob([snapshotText], {
type: 'text/plain',
});
const fileName = `debug-${panelTitle}-${dateTimeFormat(new Date())}.json.txt`;
saveAs(blob, fileName);
};
const doCopyMarkdown = () => {
const maxLen = Math.pow(1024, 2) * 1.5; // 1.5MB
if (markdownText.length > maxLen) {
appEvents.emit(AppEvents.alertError, [
`Snapshot is too large`,
'Consider downloading and attaching the file instead',
]);
return;
}
copyToClipboard(markdownText);
appEvents.emit(AppEvents.alertSuccess, [`Message copied`]);
};
const tabs = [
{ label: 'Support', value: InspectTab.Support },
{ label: 'Data', value: InspectTab.JSON },
{ label: 'Support', value: SnapshotTab.Support },
{ label: 'Data', value: SnapshotTab.Data },
];
let activeTab = currentTab;
if (!tabs.find((item) => item.value === currentTab)) {
activeTab = InspectTab.JSON;
}
const renderError = () => {
console.error('Error', info.error);
return <Alert title="Error loading dashboard">{`${info.error}`}</Alert>;
};
return (
<Drawer
title={`Panel: ${panelTitle}`}
title={`Support snapshot`}
width="90%"
onClose={onClose}
expandable
scrollableContent
subtitle={
<div>
<p>
<Stack direction="column" gap={1}>
<span>
<FeatureBadge featureState={FeatureState.beta} />
</p>
</div>
</span>
<span className="muted">
A support snapshot contains the query response data and raw panel settings. Include this snapshot in support
requests to help identify issues faster
</span>
</Stack>
}
tabs={
<TabsBar>
{tabs.map((t, index) => {
return (
<Tab
key={`${t.value}-${index}`}
label={t.label}
active={t.value === activeTab}
onChangeTab={() => setCurrentTab(t.value || InspectTab.Support)}
/>
);
})}
{tabs.map((t, index) => (
<Tab
key={`${t.value}-${index}`}
label={t.label}
active={t.value === currentTab}
onChangeTab={() => service.onCurrentTabChange(t.value!)}
/>
))}
</TabsBar>
}
>
{info.loading && <Spinner />}
{info.error && renderError()}
{loading && <Spinner />}
{error && <Alert title={error.title}>{error.message}</Alert>}
{activeTab === InspectTab.JSON ? (
{currentTab === SnapshotTab.Data && (
<div className={styles.code}>
<div className={styles.opts}>
<Field label="Template" className={styles.field}>
<Select
options={options}
value={options.find((v) => v.value === showMessage) ?? options[0]}
onChange={(v) => setShowMessge(v.value ?? options[0].value!)}
/>
<Select options={options} value={showMessage} onChange={service.onShowMessageChange} />
</Field>
{showMessage === ShowMessge.GithubComment ? (
<Button icon="github" onClick={doCopyMarkdown}>
Copy
</Button>
{showMessage === ShowMessage.GithubComment ? (
<ClipboardButton icon="copy" getText={service.onGetMarkdownForClipboard}>
Copy to clipboard
</ClipboardButton>
) : (
<Button icon="download-alt" onClick={doDownloadDashboard}>
<Button icon="download-alt" onClick={service.onDownloadDashboard}>
Download ({snapshotSize})
</Button>
)}
@ -194,73 +125,85 @@ export function SupportSnapshot({ panel, plugin, onClose }: Props) {
<CodeEditor
width="100%"
height={height}
language={showMessage === ShowMessge.GithubComment ? 'markdown' : 'json'}
language={showMessage === ShowMessage.GithubComment ? 'markdown' : 'json'}
showLineNumbers={true}
showMiniMap={true}
value={showMessage === ShowMessge.GithubComment ? markdownText : snapshotText}
value={showMessage === ShowMessage.GithubComment ? markdownText : snapshotText}
readOnly={false}
onBlur={setDashboardText}
onBlur={service.onSetSnapshotText}
/>
)}
</AutoSizer>
</div>
) : (
)}
{currentTab === SnapshotTab.Support && (
<>
{false && (
<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"
showLabel={true}
value={Boolean(rand.labels)}
onChange={(v) => toggleRandomize('labels')}
/>
{/* <InlineSwitch
label="Field names"
showLabel={true}
value={Boolean(rand.names)}
onChange={(v) => toggleRandomize('names')}
/>
<InlineSwitch
label="String values"
showLabel={true}
value={Boolean(rand.values)}
onChange={(v) => toggleRandomize('values')}
/> */}
</HorizontalGroup>
</Field>
)}
<Field
label="Support snapshot"
description="This snapshot contains the query response data and raw panel settings. Including this snapshot in support requests can help identify issues faster."
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>
<Button icon="download-alt" onClick={doDownloadDashboard}>
Dashboard ({snapshotSize})
</Button>
<Button icon="github" onClick={doCopyMarkdown} title="Paste this into a github issue">
Copy for github
</Button>
<Button onClick={doImportDashboard} variant="secondary">
Preview
</Button>
</HorizontalGroup>
</>
<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>
<Button
onClick={service.onPreviewDashboard}
variant="secondary"
title="Open support snapshot dashboard in a new tab"
>
Preview
</Button>
</Stack>
</Field>
<AutoSizer disableWidth>
{({ height }) => (
<iframe
src={`/dashboard/new?orgId=${contextSrv.user.orgId}&kiosk`}
width="100%"
height={height - 100}
frameBorder="0"
/>
<>
<iframe
src={`${config.appUrl}dashboard/new?orgId=${contextSrv.user.orgId}&kiosk&${snapshotUpdate}`}
width="100%"
height={height - 100}
frameBorder="0"
style={{
display: iframeLoading ? 'block' : 'none',
marginTop: 16,
}}
/>
{!iframeLoading && <div>&nbsp;</div>}
</>
)}
</AutoSizer>
</>

View File

@ -0,0 +1,78 @@
import { FieldType, getDefaultTimeRange, LoadingState, toDataFrame } from '@grafana/data';
import { PanelModel } from '../../state/PanelModel';
import { SnapshotTab, SupportSnapshotService } from './SupportSnapshotService';
describe('SupportSnapshotService', () => {
const panel = new PanelModel({});
panel.getQueryRunner().setLastResult({
timeRange: getDefaultTimeRange(),
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] },
],
}),
],
});
panel.getQueryRunner().resendLastResult();
it('Can create it with default state', () => {
const service = new SupportSnapshotService(panel);
expect(service.state.currentTab).toBe(SnapshotTab.Support);
});
it('Can can build support snapshot dashboard', async () => {
const service = new SupportSnapshotService(panel);
await service.buildDebugDashboard();
expect(service.state.snapshot.panels[0].targets[0]).toMatchInlineSnapshot(`
Object {
"datasource": Object {
"type": "grafana",
"uid": "grafana",
},
"queryType": "snapshot",
"refId": "A",
"snapshot": Array [
Object {
"data": Object {
"values": Array [
Array [
1,
2,
3,
],
Array [
11,
22,
33,
],
],
},
"schema": Object {
"fields": Array [
Object {
"config": Object {},
"name": "Time",
"type": "time",
},
Object {
"config": Object {},
"name": "Value",
"type": "number",
},
],
"meta": undefined,
"name": "http_requests_total",
"refId": undefined,
},
},
],
}
`);
});
});

View File

@ -0,0 +1,151 @@
import saveAs from 'file-saver';
import { dateTimeFormat, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { getTimeSrv } from '../../services/TimeSrv';
import { PanelModel } from '../../state';
import { setDashboardToFetchFromLocalStorage } from '../../state/initDashboard';
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;
iframeLoading?: boolean;
loading?: boolean;
error?: {
title: string;
message: string;
};
panel: PanelModel;
panelTitle: string;
// eslint-disable-next-line
snapshot?: any;
snapshotUpdate: number;
}
export enum SnapshotTab {
Support,
Data,
}
export enum ShowMessage {
PanelSnapshot,
GithubComment,
}
export class SupportSnapshotService extends StateManagerBase<SupportSnapshotState> {
constructor(panel: PanelModel) {
super({
panel,
panelTitle: panel.replaceVariables(panel.title, undefined, 'text') || 'Panel',
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, iframeLoading, currentTab } = this.state;
const snapshot = await getDebugDashboard(panel, randomize, getTimeSrv().timeRange());
const snapshotText = JSON.stringify(snapshot, null, 2);
const markdownText = getGithubMarkdown(panel, snapshotText);
const snapshotSize = formattedValueToString(getValueFormat('bytes')(snapshotText?.length ?? 0));
if (iframeLoading && currentTab === SnapshotTab.Support) {
setDashboardToFetchFromLocalStorage({ meta: {}, dashboard: snapshot });
}
this.setState({ snapshot, snapshotText, markdownText, snapshotSize, snapshotUpdate: snapshotUpdate + 1 });
}
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] } });
};
onPreviewDashboard = () => {
const { snapshot } = this.state;
if (snapshot) {
setDashboardToFetchFromLocalStorage({ meta: {}, dashboard: snapshot });
global.open(config.appUrl + 'dashboard/new', '_blank');
}
};
subscribeToIframeLoadingMessage() {
const handleEvent = (evt: MessageEvent<string>) => {
if (evt.data === 'GrafanaAppInit') {
setDashboardToFetchFromLocalStorage({ meta: {}, dashboard: this.state.snapshot });
this.setState({ iframeLoading: true });
}
};
window.addEventListener('message', handleEvent, false);
return function cleanup() {
window.removeEventListener('message', handleEvent);
};
}
}

View File

@ -73,6 +73,7 @@ export async function getDebugDashboard(panel: PanelModel, rand: Randomize, time
withTransforms: false,
})
);
const dsref = panel.datasource;
const frames = randomizeData(getPanelDataFrames(data), rand);
const grafanaVersion = `${config.buildInfo.version} (${config.buildInfo.commit})`;

View File

@ -351,6 +351,11 @@ export class PanelQueryRunner {
}
}
/** Useful from tests */
setLastResult(data: PanelData) {
this.lastResult = data;
}
getLastResult(): PanelData | undefined {
return this.lastResult;
}