Panels: Add panel debug support helper (#54678)

This commit is contained in:
Ryan McKinley
2022-09-09 14:35:52 -07:00
committed by GitHub
parent c49c238974
commit 4125dd57ee
9 changed files with 48 additions and 30 deletions

View File

@@ -0,0 +1,293 @@
import { css } from '@emotion/css';
import { saveAs } from 'file-saver';
import React, { useState, useMemo } from 'react';
import { useAsync, useCopyToClipboard } from 'react-use';
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 {
Drawer,
Tab,
TabsBar,
CodeEditor,
useStyles2,
Field,
HorizontalGroup,
InlineSwitch,
Button,
Spinner,
Alert,
FeatureBadge,
Select,
} 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 { 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';
interface Props {
panel: PanelModel;
plugin?: PanelPlugin | null;
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 snapshotSize = useMemo(() => {
return formattedValueToString(getValueFormat('bytes')(snapshotText?.length ?? 0));
}, [snapshotText]);
const markdownText = useMemo(() => {
return getGithubMarkdown(panel, snapshotText);
}, [snapshotText, panel]);
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 },
];
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}`}
width="90%"
onClose={onClose}
expandable
scrollableContent
subtitle={
<div>
<p>
<FeatureBadge featureState={FeatureState.beta} />
</p>
</div>
}
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)}
/>
);
})}
</TabsBar>
}
>
{info.loading && <Spinner />}
{info.error && renderError()}
{activeTab === InspectTab.JSON ? (
<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!)}
/>
</Field>
{showMessage === ShowMessge.GithubComment ? (
<Button icon="github" onClick={doCopyMarkdown}>
Copy
</Button>
) : (
<Button icon="download-alt" onClick={doDownloadDashboard}>
Download ({snapshotSize})
</Button>
)}
</div>
<AutoSizer disableWidth>
{({ height }) => (
<CodeEditor
width="100%"
height={height}
language={showMessage === ShowMessge.GithubComment ? 'markdown' : 'json'}
showLineNumbers={true}
showMiniMap={true}
value={showMessage === ShowMessge.GithubComment ? markdownText : snapshotText}
readOnly={false}
onBlur={setDashboardText}
/>
)}
</AutoSizer>
</div>
) : (
<>
{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."
>
<>
<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>
</>
</Field>
<AutoSizer disableWidth>
{({ height }) => (
<iframe
src={`/dashboard/new?orgId=${contextSrv.user.orgId}&kiosk`}
width="100%"
height={height - 100}
frameBorder="0"
/>
)}
</AutoSizer>
</>
)}
</Drawer>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
code: css`
flex-grow: 1;
height: 100%;
overflow: scroll;
`,
field: css`
width: 100%;
`,
opts: css`
display: flex;
display: flex;
width: 100%;
flex-grow: 0;
align-items: center;
justify-content: flex-end;
button {
margin-left: 8px;
}
`,
});

View File

@@ -0,0 +1,20 @@
import { newLetterRandomizer } from './randomizer';
describe('Randomizer', () => {
it('should randomize letters', () => {
const rand = newLetterRandomizer();
const a = rand('Hello-World');
const b = rand('Hello-World');
expect(a).toEqual(b);
expect(a.indexOf('-')).toBe(5);
// expect(a).toEqual('x');
});
it('should keep numbers', () => {
const rand = newLetterRandomizer();
const a = rand('123-Abc');
const b = rand('123-Abc');
expect(a).toEqual(b);
expect(a.startsWith('123-')).toBeTruthy();
});
});

View File

@@ -0,0 +1,78 @@
import { DataFrameJSON, Labels, FieldType } from '@grafana/data';
export function newLetterRandomizer(): (v: string) => string {
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const lower = 'abcdefghijklmnopqrstuvwxyz';
const charactersLength = upper.length;
const history = new Map<string, string>();
return (v: string) => {
const old = history.get(v);
if (old != null) {
return old;
}
const r = [...v]
.map((c) => {
if (c.toLowerCase() && c !== c.toUpperCase()) {
return lower.charAt(Math.floor(Math.random() * charactersLength));
}
if (c.toUpperCase() && c !== c.toUpperCase()) {
return upper.charAt(Math.floor(Math.random() * charactersLength));
}
return c;
})
.join('');
history.set(v, r);
return r;
};
}
export interface Randomize {
names?: boolean;
labels?: boolean;
values?: boolean;
}
export function randomizeData(data: DataFrameJSON[], opts: Randomize): DataFrameJSON[] {
if (!(opts.labels || opts.names || opts.values)) {
return data;
}
const keepNames = new Set(['time', 'value', 'exemplar', 'traceid', 'id', 'uid', 'uuid', '__name__', 'le', 'name']);
const rand = newLetterRandomizer();
return data.map((s) => {
let { schema, data } = s;
if (schema && data) {
if (opts.labels) {
for (const f of schema.fields) {
if (f.labels) {
const labels: Labels = {};
for (const [key, value] of Object.entries(f.labels)) {
labels[key] = rand(value);
}
f.labels = labels;
}
}
}
if (opts.names) {
for (const f of schema.fields) {
if (f.name?.length && !keepNames.has(f.name.toLowerCase())) {
f.name = rand(f.name);
}
}
}
// Change values
if (opts.values) {
schema.fields.forEach((f, idx) => {
if (f.type === FieldType.string && data) {
// eslint-ignore-next-line
const v = data.values[idx].map((v) => rand(v as string));
data.values[idx] = v;
}
});
}
}
return { schema, data };
});
}

View File

@@ -0,0 +1,289 @@
import { cloneDeep } from 'lodash';
import { firstValueFrom } from 'rxjs';
import {
dateTimeFormat,
TimeRange,
DataQuery,
PanelData,
DataTransformerConfig,
DataFrameJSON,
LoadingState,
dataFrameToJSON,
DataTopic,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { PanelModel } from 'app/features/dashboard/state';
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) {
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: PanelModel, snapshot: string): string {
const saveModel = panel.getSaveModel();
const info = {
panelType: saveModel.type,
datasource: '??',
};
const grafanaVersion = `${config.buildInfo.version} (${config.buildInfo.commit})`;
let md = `| Key | Value |
|--|--|
| Panel | ${info.panelType} @ ${saveModel.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: PanelModel, rand: Randomize, timeRange: TimeRange) {
const saveModel = panel.getSaveModel();
const dashboard = cloneDeep(embeddedDataTemplate);
const info = {
panelType: saveModel.type,
datasource: '??',
};
// reproducable
const data = await firstValueFrom(
panel.getQueryRunner().getData({
withFieldConfig: false,
withTransforms: false,
})
);
const dsref = panel.datasource;
const frames = randomizeData(getPanelDataFrames(data), rand);
const grafanaVersion = `${config.buildInfo.version} (${config.buildInfo.commit})`;
const queries = saveModel?.targets ?? [];
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: DataQuery) => {
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 (data.annotations?.length) {
const anno: DataFrameJSON[] = [];
for (const f of frames) {
if (f.schema?.meta?.dataTopic) {
delete f.schema.meta.dataTopic;
anno.push(f);
}
}
dashboard.panels.push({
id: 7,
gridPos: {
h: 6,
w: 24,
x: 0,
y: 20,
},
type: 'table',
title: 'Annotations',
datasource: {
type: 'grafana',
uid: 'grafana',
},
targets: [
{
refId: 'A',
rawFrameContent: JSON.stringify(anno),
scenarioId: 'raw_frame',
},
],
});
}
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>Transforms (${saveModel.transformations.length})</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,
},
targets: [
{
datasource: {
type: 'datasource',
uid: '-- Dashboard --',
},
panelId: 2,
refId: 'A',
},
],
},
],
schemaVersion: 37,
};