mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Panels: Add troubleshooting snapshot (#54417)
This commit is contained in:
parent
37a0207463
commit
7e53bd107c
@ -0,0 +1,280 @@
|
|||||||
|
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 { getTemplateSrv, locationService } 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 { pendingNewDashboard } 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 debug snapshot',
|
||||||
|
description: 'Dashboard to help debug any visualization issues',
|
||||||
|
value: ShowMessge.PanelSnapshot,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DebugWizard = ({ panel, plugin, onClose }: Props) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const [currentTab, setCurrentTab] = useState(InspectTab.Debug);
|
||||||
|
const [showMessage, setShowMessge] = useState(ShowMessge.GithubComment);
|
||||||
|
const [snapshotText, setDashboardText] = useState('...');
|
||||||
|
const [rand, setRand] = useState<Randomize>({});
|
||||||
|
const [_, copyToClipboard] = useCopyToClipboard();
|
||||||
|
const info = useAsync(async () => {
|
||||||
|
const dash = await getDebugDashboard(panel, rand, getTimeSrv().timeRange());
|
||||||
|
setDashboardText(JSON.stringify(dash, null, 2));
|
||||||
|
}, [rand, panel, plugin, setDashboardText]);
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
pendingNewDashboard.dashboard = JSON.parse(snapshotText);
|
||||||
|
locationService.push('/dashboard/new'); // will load the above body
|
||||||
|
appEvents.emit(AppEvents.alertSuccess, ['Panel snapshot dashboard']);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doDownloadDashboard = () => {
|
||||||
|
const blob = new Blob([snapshotText], {
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
const fileName = `debug-${panelTitle}-${dateTimeFormat(new Date())}.json.txt`;
|
||||||
|
saveAs(blob, fileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doCopyMarkdown = () => {
|
||||||
|
copyToClipboard(markdownText);
|
||||||
|
appEvents.emit(AppEvents.alertSuccess, [`Message copied`]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ label: 'Snapshot', value: InspectTab.Debug },
|
||||||
|
{ label: 'Code', 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={`Debug: ${panelTitle}`}
|
||||||
|
width="50%"
|
||||||
|
onClose={onClose}
|
||||||
|
expandable
|
||||||
|
scrollableContent
|
||||||
|
subtitle={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<FeatureBadge featureState={FeatureState.alpha} />
|
||||||
|
</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.Debug)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<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="Debug snapshot"
|
||||||
|
description="A panel debug snapshot creates a dashboard that can reproduce visualization issues while disconnected from the original data sources."
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<HorizontalGroup>
|
||||||
|
<Button icon="download-alt" onClick={doDownloadDashboard}>
|
||||||
|
Download ({snapshotSize})
|
||||||
|
</Button>
|
||||||
|
<Button icon="github" onClick={doCopyMarkdown}>
|
||||||
|
Copy for github
|
||||||
|
</Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
<div>
|
||||||
|
<br />
|
||||||
|
<Button onClick={doImportDashboard} variant="secondary">
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
Requires <a href="/plugins/testdata">Testdata DB</a> to be installed
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* TODO: can we iframe in the preview? */}
|
||||||
|
{false && <iframe src={`/dashboard/new?orgId=${contextSrv.user.orgId}&kiosk`} width="100%" height={300} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,77 @@
|
|||||||
|
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) {
|
||||||
|
const v = data.values[idx].map((v) => rand(v));
|
||||||
|
data.values[idx] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { schema, data };
|
||||||
|
});
|
||||||
|
}
|
305
public/app/features/dashboard/components/DebugWizard/utils.ts
Normal file
305
public/app/features/dashboard/components/DebugWizard/utils.ts
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
dateTimeFormat,
|
||||||
|
TimeRange,
|
||||||
|
DataQuery,
|
||||||
|
PanelData,
|
||||||
|
DataTransformerConfig,
|
||||||
|
getValueFormat,
|
||||||
|
formattedValueToString,
|
||||||
|
DataFrameJSON,
|
||||||
|
LoadingState,
|
||||||
|
dataFrameToJSON,
|
||||||
|
DataTopic,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { PanelModel } from 'app/features/dashboard/state';
|
||||||
|
|
||||||
|
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 frames = randomizeData(getPanelDataFrames(data), rand);
|
||||||
|
const rawFrameContent = JSON.stringify(frames);
|
||||||
|
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 (${queries.length})</th>
|
||||||
|
<td>${queries
|
||||||
|
.map((t: DataQuery) => {
|
||||||
|
return `${t.refId}[${t.datasource?.type}]`;
|
||||||
|
})
|
||||||
|
.join(', ')}</td>
|
||||||
|
</tr>
|
||||||
|
${getTransformsRow(saveModel)}
|
||||||
|
${getDataRow(data, rawFrameContent)}
|
||||||
|
${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: 'testdata',
|
||||||
|
uid: '${testdata}',
|
||||||
|
},
|
||||||
|
rawFrameContent,
|
||||||
|
scenarioId: 'raw_frame',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 'testdata',
|
||||||
|
uid: '${testdata}',
|
||||||
|
},
|
||||||
|
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, raw: string): string {
|
||||||
|
let frameCount = data.series.length ?? 0;
|
||||||
|
let fieldCount = 0;
|
||||||
|
for (const frame of data.series) {
|
||||||
|
fieldCount += frame.fields.length;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
'<tr>' +
|
||||||
|
'<th>Data</th>' +
|
||||||
|
'<td>' +
|
||||||
|
`${data.state !== LoadingState.Done ? data.state : ''} ` +
|
||||||
|
`${frameCount} frames, ${fieldCount} fields` +
|
||||||
|
`(${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: 'testdata',
|
||||||
|
uid: '${testdata}',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
templating: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
current: {
|
||||||
|
selected: true,
|
||||||
|
text: 'gdev-testdata',
|
||||||
|
value: 'gdev-testdata',
|
||||||
|
},
|
||||||
|
hide: 0,
|
||||||
|
includeAll: false,
|
||||||
|
multi: false,
|
||||||
|
name: 'testdata',
|
||||||
|
options: [],
|
||||||
|
query: 'testdata',
|
||||||
|
skipUrlSync: false,
|
||||||
|
type: 'datasource',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
@ -10,6 +10,7 @@ import { getPanelStateForModel } from 'app/features/panel/state/selectors';
|
|||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
|
|
||||||
import { GetDataOptions } from '../../../query/state/PanelQueryRunner';
|
import { GetDataOptions } from '../../../query/state/PanelQueryRunner';
|
||||||
|
import { DebugWizard } from '../DebugWizard/DebugWizard';
|
||||||
import { usePanelLatestData } from '../PanelEditor/usePanelLatestData';
|
import { usePanelLatestData } from '../PanelEditor/usePanelLatestData';
|
||||||
|
|
||||||
import { InspectContent } from './InspectContent';
|
import { InspectContent } from './InspectContent';
|
||||||
@ -49,6 +50,10 @@ const PanelInspectorUnconnected = ({ panel, dashboard, plugin }: Props) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (defaultTab === InspectTab.Debug) {
|
||||||
|
return <DebugWizard panel={panel} plugin={plugin} onClose={onClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InspectContent
|
<InspectContent
|
||||||
dashboard={dashboard}
|
dashboard={dashboard}
|
||||||
|
@ -253,6 +253,15 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global access to support importing a dashboard from elsewhere in the application.
|
||||||
|
* Alternativly this could be in redux, but given the size (potentially LARGE) and how
|
||||||
|
* infrequently it will be used, a simple global object seems reasonable.
|
||||||
|
*/
|
||||||
|
export const pendingNewDashboard = {
|
||||||
|
dashboard: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
export function getNewDashboardModelData(urlFolderId?: string, panelType?: string): any {
|
export function getNewDashboardModelData(urlFolderId?: string, panelType?: string): any {
|
||||||
const data = {
|
const data = {
|
||||||
meta: {
|
meta: {
|
||||||
@ -262,7 +271,7 @@ export function getNewDashboardModelData(urlFolderId?: string, panelType?: strin
|
|||||||
isNew: true,
|
isNew: true,
|
||||||
folderId: 0,
|
folderId: 0,
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: pendingNewDashboard.dashboard ?? {
|
||||||
title: 'New dashboard',
|
title: 'New dashboard',
|
||||||
panels: [
|
panels: [
|
||||||
{
|
{
|
||||||
@ -273,6 +282,7 @@ export function getNewDashboardModelData(urlFolderId?: string, panelType?: strin
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
pendingNewDashboard.dashboard = undefined;
|
||||||
|
|
||||||
if (urlFolderId) {
|
if (urlFolderId) {
|
||||||
data.meta.folderId = parseInt(urlFolderId, 10);
|
data.meta.folderId = parseInt(urlFolderId, 10);
|
||||||
|
@ -3,22 +3,18 @@ import React, { PureComponent } from 'react';
|
|||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import {
|
import { AppEvents, PanelData, SelectableValue, LoadingState } from '@grafana/data';
|
||||||
AppEvents,
|
|
||||||
DataFrameJSON,
|
|
||||||
dataFrameToJSON,
|
|
||||||
DataTopic,
|
|
||||||
PanelData,
|
|
||||||
SelectableValue,
|
|
||||||
LoadingState,
|
|
||||||
} from '@grafana/data';
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
import { locationService } from '@grafana/runtime';
|
||||||
import { Button, CodeEditor, Field, Select } from '@grafana/ui';
|
import { Button, CodeEditor, Field, Select } from '@grafana/ui';
|
||||||
import { appEvents } from 'app/core/core';
|
import { appEvents } from 'app/core/core';
|
||||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||||
|
|
||||||
|
import { getPanelDataFrames } from '../dashboard/components/DebugWizard/utils';
|
||||||
import { getPanelInspectorStyles } from '../inspector/styles';
|
import { getPanelInspectorStyles } from '../inspector/styles';
|
||||||
|
|
||||||
|
import { InspectTab } from './types';
|
||||||
|
|
||||||
enum ShowContent {
|
enum ShowContent {
|
||||||
PanelJSON = 'panel',
|
PanelJSON = 'panel',
|
||||||
PanelData = 'data',
|
PanelData = 'data',
|
||||||
@ -138,6 +134,12 @@ export class InspectJSONTab extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onShowSupportWizard = () => {
|
||||||
|
const queryParms = locationService.getSearch();
|
||||||
|
queryParms.set('inspectTab', InspectTab.Debug.toString());
|
||||||
|
locationService.push('?' + queryParms.toString());
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { dashboard } = this.props;
|
const { dashboard } = this.props;
|
||||||
const { show, text } = this.state;
|
const { show, text } = this.state;
|
||||||
@ -166,6 +168,11 @@ export class InspectJSONTab extends PureComponent<Props, State> {
|
|||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{show === ShowContent.DataFrames && (
|
||||||
|
<Button className={styles.toolbarItem} onClick={this.onShowSupportWizard}>
|
||||||
|
Debug
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<AutoSizer disableWidth>
|
<AutoSizer disableWidth>
|
||||||
@ -188,26 +195,6 @@ export class InspectJSONTab extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPrettyJSON(obj: any): string {
|
function getPrettyJSON(obj: any): string {
|
||||||
return JSON.stringify(obj, null, 2);
|
return JSON.stringify(obj, null, 2);
|
||||||
}
|
}
|
||||||
|
@ -6,4 +6,5 @@ export enum InspectTab {
|
|||||||
JSON = 'json',
|
JSON = 'json',
|
||||||
Query = 'query',
|
Query = 'query',
|
||||||
Actions = 'actions', // ALPHA!
|
Actions = 'actions', // ALPHA!
|
||||||
|
Debug = 'debug', // get info required for support+debugging
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,8 @@ export const textPanelMigrationHandler = (panel: PanelModel<PanelOptions>): Part
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The 'text' mode has been removed so we need to update any panels still using it to markdown
|
// The 'text' mode has been removed so we need to update any panels still using it to markdown
|
||||||
if (options.mode !== 'html' && options.mode !== 'markdown') {
|
const modes = [TextMode.Code, TextMode.HTML, TextMode.Markdown];
|
||||||
|
if (!modes.find((f) => f === options.mode)) {
|
||||||
options = { ...options, mode: TextMode.Markdown };
|
options = { ...options, mode: TextMode.Markdown };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user