mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Inspector: move Panel JSON and query inspector to the inspector (#23354)
* move Panel JSON to inspector * move Panel JSON to inspector * update test * use stats display options * move query inspector to inspector * open inspector from the queries section * subscribe to results * subscribe to results * open the right tab * apply review feedback * update menus (inspect tabs) * Dashboard: extend dashnav to add custom content (#23433) * Dashlist: Fixed dashlist broken in edit mode (#23426) * Chore: Fix bunch of strict null error to fix master CI (#23443) * Fix bunch of null error * Fix failing test * Another test fix * Docs: Add SQL region annotation examples (#23268) Add region annotation examples for SQL data sources in docs. Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com> * Docs: Update contributing doc to install node@12. (#23450) * NewPanelEdit: Minor style and description tweaks, AND PanelQueryRunner & autoMinMax (#23445) * NewPanelEdit: Minor style and description tweaks * Removed the worst snapshot of all time * ReactTable: adds color text to field options (#23427) * Feature: adds text color field config * Refactor: created an extension point * Refactor: uses HOC for extension instead * Fix: fixes background styling from affecting cells without display.color * Chore: export OptionsUIRegistryBuilder on grafana/data (#23444) * export the ui registry * add to utils index also * DataLinks: Do not full page reload data links links (#23429) * Templating: Fix global variable "__org.id" (#23362) * Fixed global variable __org.id value * correct orgId value * reverted the change as variables moved to new file * Chore: reduce null check errors to 788 (currently over 798) (#23449) * Fixed ts errors so build will succeed * Update packages/grafana-data/src/types/graph.ts Co-Authored-By: Ryan McKinley <ryantxu@gmail.com> * Feedback from code review * Leaving out trivial typing's * Fix error with color being undefined now. * fix test with timezone issue * Fixed test Co-authored-by: Ryan McKinley <ryantxu@gmail.com> Co-authored-by: Torkel Ödegaard <torkel@grafana.com> * Cloudwatch: prefer webIdentity over EC2 role (#23452) * Plugins: add a signature status flag (#23420) * Progress * fixed button * Final touches * now works from edit mode * fix layout * show raw objects * move query inspector buttons to the bottom * update snapshot * Updated design * Made full page reload work * Fixed minor style issue * Updated * More fixes * Removed unused imports * Updated * Moved to data tab out to seperate component * fixed ts issue Co-authored-by: Torkel Ödegaard <torkel@grafana.com> Co-authored-by: Agnès Toulet <35176601+AgnesToulet@users.noreply.github.com> Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com> Co-authored-by: Alexandre de Verteuil <alexandre@grafana.com> Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com> Co-authored-by: Cyril Tovena <cyril.tovena@gmail.com> Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com> Co-authored-by: Vikky Omkar <vikkyomkar@gmail.com> Co-authored-by: Stephanie Closson <srclosson@gmail.com> Co-authored-by: Dário Nascimento <dfrnascimento@gmail.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { DataFrame, applyFieldOverrides, toCSV, SelectableValue } from '@grafana/data';
|
||||
import { Button, Select, Icon, Table } from '@grafana/ui';
|
||||
import { getPanelInspectorStyles } from './styles';
|
||||
import { config } from 'app/core/config';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
interface Props {
|
||||
data: DataFrame[];
|
||||
dataFrameIndex: number;
|
||||
isLoading: boolean;
|
||||
onSelectedFrameChanged: (item: SelectableValue<number>) => void;
|
||||
}
|
||||
|
||||
export class InspectDataTab extends PureComponent<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
exportCsv = (dataFrame: DataFrame) => {
|
||||
const dataFrameCsv = toCSV([dataFrame]);
|
||||
|
||||
const blob = new Blob([dataFrameCsv], {
|
||||
type: 'application/csv;charset=utf-8',
|
||||
});
|
||||
|
||||
saveAs(blob, dataFrame.name + '-' + new Date().getUTCDate() + '.csv');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { data, dataFrameIndex, isLoading, onSelectedFrameChanged } = this.props;
|
||||
const styles = getPanelInspectorStyles();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
Loading <Icon name="fa fa-spinner" className="fa-spin" size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !data.length) {
|
||||
return <div>No Data</div>;
|
||||
}
|
||||
|
||||
const choices = data.map((frame, index) => {
|
||||
return {
|
||||
value: index,
|
||||
label: `${frame.name} (${index})`,
|
||||
};
|
||||
});
|
||||
|
||||
const processed = applyFieldOverrides({
|
||||
data,
|
||||
theme: config.theme,
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
replaceVariables: (value: string) => {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.dataTabContent}>
|
||||
<div className={styles.toolbar}>
|
||||
{choices.length > 1 && (
|
||||
<div className={styles.dataFrameSelect}>
|
||||
<Select options={choices} value={dataFrameIndex} onChange={onSelectedFrameChanged} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.downloadCsv}>
|
||||
<Button variant="primary" onClick={() => this.exportCsv(processed[dataFrameIndex])}>
|
||||
Download CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => {
|
||||
if (width === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width, height }}>
|
||||
<Table width={width} height={height} data={processed[dataFrameIndex]} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,12 @@ export const InspectHeader: FC<Props> = ({
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.actions}>
|
||||
<IconButton name="angle-left" size="xl" onClick={onToggleExpand} surface="header" />
|
||||
{!isExpanded && <IconButton name="angle-left" size="xl" onClick={onToggleExpand} surface="header" />}
|
||||
{isExpanded && <IconButton name="angle-right" size="xl" onClick={onToggleExpand} surface="header" />}
|
||||
<IconButton name="times" size="xl" onClick={onClose} surface="header" />
|
||||
</div>
|
||||
<div className={styles.titleWrapper}>
|
||||
<h3>{panel.title}</h3>
|
||||
<h3>{panel.title || 'Panel inspect'}</h3>
|
||||
<div className="muted">{formatStats(panelData)}</div>
|
||||
</div>
|
||||
<TabsBar className={styles.tabsBar}>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { chain } from 'lodash';
|
||||
import { PanelData, SelectableValue, AppEvents } from '@grafana/data';
|
||||
import { TextArea, Button, Select, ClipboardButton, JSONFormatter, Field } from '@grafana/ui';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import { PanelModel, DashboardModel } from '../../state';
|
||||
import { getPanelInspectorStyles } from './styles';
|
||||
|
||||
enum ShowContent {
|
||||
PanelJSON = 'panel',
|
||||
PanelData = 'data',
|
||||
DataStructure = 'structure',
|
||||
}
|
||||
|
||||
const options: Array<SelectableValue<ShowContent>> = [
|
||||
{
|
||||
label: 'Panel JSON',
|
||||
description: 'The model saved in the dashboard JSON that configures how everythign works.',
|
||||
value: ShowContent.PanelJSON,
|
||||
},
|
||||
{
|
||||
label: 'Panel data',
|
||||
description: 'The raw model passed to the panel visualization',
|
||||
value: ShowContent.PanelData,
|
||||
},
|
||||
{
|
||||
label: 'DataFrame structure',
|
||||
description: 'Response info without any values',
|
||||
value: ShowContent.DataStructure,
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
dashboard: DashboardModel;
|
||||
panel: PanelModel;
|
||||
data: PanelData;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
show: ShowContent;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export class InspectJSONTab extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
show: ShowContent.PanelJSON,
|
||||
text: getSaveModelJSON(props.panel),
|
||||
};
|
||||
}
|
||||
|
||||
onSelectChanged = (item: SelectableValue<ShowContent>) => {
|
||||
let text = '';
|
||||
if (item.value === ShowContent.PanelJSON) {
|
||||
text = getSaveModelJSON(this.props.panel);
|
||||
}
|
||||
this.setState({ text, show: item.value });
|
||||
};
|
||||
|
||||
onTextChanged = (e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.currentTarget.value;
|
||||
this.setState({ text });
|
||||
};
|
||||
|
||||
getJSONObject = (show: ShowContent): any => {
|
||||
if (show === ShowContent.PanelData) {
|
||||
return this.props.data;
|
||||
}
|
||||
if (show === ShowContent.DataStructure) {
|
||||
const series = this.props.data?.series;
|
||||
if (!series) {
|
||||
return { note: 'Missing Response Data' };
|
||||
}
|
||||
return this.props.data.series.map(frame => {
|
||||
const fields = frame.fields.map(field => {
|
||||
return chain(field)
|
||||
.omit('values')
|
||||
.omit('calcs')
|
||||
.omit('display')
|
||||
.value();
|
||||
});
|
||||
return {
|
||||
...frame,
|
||||
fields,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (show === ShowContent.PanelJSON) {
|
||||
return this.props.panel.getSaveModel();
|
||||
}
|
||||
|
||||
return { note: 'Unknown Object', show };
|
||||
};
|
||||
|
||||
getClipboardText = () => {
|
||||
const { show } = this.state;
|
||||
const obj = this.getJSONObject(show);
|
||||
return JSON.stringify(obj, null, 2);
|
||||
};
|
||||
|
||||
onClipboardCopied = () => {
|
||||
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
|
||||
alert('TODO... the notice is behind the inspector!');
|
||||
};
|
||||
|
||||
onApplyPanelModel = () => {
|
||||
const { panel, dashboard, onClose } = this.props;
|
||||
|
||||
try {
|
||||
if (!dashboard.meta.canEdit) {
|
||||
appEvents.emit(AppEvents.alertError, ['Unable to apply']);
|
||||
} else {
|
||||
const updates = JSON.parse(this.state.text);
|
||||
panel.restoreModel(updates);
|
||||
panel.refresh();
|
||||
appEvents.emit(AppEvents.alertSuccess, ['Panel model updated']);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Error applyign updates', err);
|
||||
appEvents.emit(AppEvents.alertError, ['Invalid JSON text']);
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
renderPanelJSON(styles: any) {
|
||||
return (
|
||||
<TextArea spellCheck={false} value={this.state.text} onChange={this.onTextChanged} className={styles.editor} />
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dashboard } = this.props;
|
||||
const { show } = this.state;
|
||||
const selected = options.find(v => v.value === show);
|
||||
const isPanelJSON = show === ShowContent.PanelJSON;
|
||||
const canEdit = dashboard.meta.canEdit;
|
||||
const styles = getPanelInspectorStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.toolbar}>
|
||||
<Field label="Select source" className="flex-grow-1">
|
||||
<Select options={options} value={selected} onChange={this.onSelectChanged} />
|
||||
</Field>
|
||||
<ClipboardButton
|
||||
variant="secondary"
|
||||
className={styles.toolbarItem}
|
||||
getText={this.getClipboardText}
|
||||
onClipboardCopy={this.onClipboardCopied}
|
||||
>
|
||||
Copy to clipboard
|
||||
</ClipboardButton>
|
||||
{isPanelJSON && canEdit && (
|
||||
<Button className={styles.toolbarItem} onClick={this.onApplyPanelModel}>
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{isPanelJSON ? (
|
||||
this.renderPanelJSON(styles)
|
||||
) : (
|
||||
<div className={styles.viewer}>
|
||||
<JSONFormatter json={this.getJSONObject(show)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getSaveModelJSON(panel: PanelModel): string {
|
||||
return JSON.stringify(panel.getSaveModel(), null, 2);
|
||||
}
|
||||
@@ -1,99 +1,122 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { css } from 'emotion';
|
||||
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
import { connect, MapStateToProps } from 'react-redux';
|
||||
import { InspectHeader } from './InspectHeader';
|
||||
import { InspectJSONTab } from './InspectJSONTab';
|
||||
import { QueryInspector } from './QueryInspector';
|
||||
|
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||
import {
|
||||
JSONFormatter,
|
||||
Drawer,
|
||||
LegacyForms,
|
||||
Table,
|
||||
TabContent,
|
||||
stylesFactory,
|
||||
CustomScrollbar,
|
||||
Button,
|
||||
} from '@grafana/ui';
|
||||
const { Select } = LegacyForms;
|
||||
import { JSONFormatter, Drawer, TabContent, CustomScrollbar } from '@grafana/ui';
|
||||
import { getLocationSrv, getDataSourceSrv } from '@grafana/runtime';
|
||||
import {
|
||||
DataFrame,
|
||||
DataSourceApi,
|
||||
SelectableValue,
|
||||
applyFieldOverrides,
|
||||
toCSV,
|
||||
getDisplayProcessor,
|
||||
DataQueryError,
|
||||
PanelData,
|
||||
getValueFormat,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
QueryResultMetaStat,
|
||||
LoadingState,
|
||||
PanelPlugin,
|
||||
} from '@grafana/data';
|
||||
import { config } from 'app/core/config';
|
||||
import { getPanelInspectorStyles } from './styles';
|
||||
import { StoreState } from 'app/types';
|
||||
import { InspectDataTab } from './InspectDataTab';
|
||||
|
||||
interface Props {
|
||||
interface OwnProps {
|
||||
dashboard: DashboardModel;
|
||||
panel: PanelModel;
|
||||
selectedTab: InspectTab;
|
||||
defaultTab: InspectTab;
|
||||
}
|
||||
|
||||
export interface ConnectedProps {
|
||||
plugin?: PanelPlugin | null;
|
||||
}
|
||||
|
||||
export type Props = OwnProps & ConnectedProps;
|
||||
|
||||
export enum InspectTab {
|
||||
Data = 'data',
|
||||
Request = 'request',
|
||||
Issue = 'issue',
|
||||
Meta = 'meta', // When result metadata exists
|
||||
Error = 'error',
|
||||
Stats = 'stats',
|
||||
PanelJson = 'paneljson',
|
||||
JSON = 'json',
|
||||
Query = 'query',
|
||||
}
|
||||
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
// The last raw response
|
||||
last: PanelData;
|
||||
|
||||
// Data from the last response
|
||||
data: DataFrame[];
|
||||
|
||||
// The selected data frame
|
||||
selected: number;
|
||||
|
||||
selectedDataFrame: number;
|
||||
// The Selected Tab
|
||||
tab: InspectTab;
|
||||
|
||||
currentTab: InspectTab;
|
||||
// If the datasource supports custom metadata
|
||||
metaDS?: DataSourceApi;
|
||||
|
||||
// drawer width
|
||||
drawerWidth: string;
|
||||
}
|
||||
|
||||
export class PanelInspector extends PureComponent<Props, State> {
|
||||
export class PanelInspectorUnconnected extends PureComponent<Props, State> {
|
||||
querySubscription?: Unsubscribable;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isLoading: true,
|
||||
last: {} as PanelData,
|
||||
data: [],
|
||||
selected: 0,
|
||||
tab: props.selectedTab || InspectTab.Data,
|
||||
selectedDataFrame: 0,
|
||||
currentTab: props.defaultTab ?? InspectTab.Data,
|
||||
drawerWidth: '50%',
|
||||
};
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const { panel } = this.props;
|
||||
componentDidMount() {
|
||||
const { plugin } = this.props;
|
||||
|
||||
if (!panel) {
|
||||
this.onDismiss(); // Try to close the component
|
||||
return;
|
||||
if (plugin) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
const lastResult = panel.getQueryRunner().getLastResult();
|
||||
|
||||
if (!lastResult) {
|
||||
this.onDismiss(); // Usually opened from refresh?
|
||||
return;
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.plugin !== this.props.plugin) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This init process where we do not have a plugin to start with is to handle full page reloads with inspect url parameter
|
||||
* When this inspect drawer loads the plugin is not yet loaded.
|
||||
*/
|
||||
init() {
|
||||
const { plugin, panel } = this.props;
|
||||
|
||||
if (plugin && !plugin.meta.skipDataQuery) {
|
||||
this.querySubscription = panel
|
||||
.getQueryRunner()
|
||||
.getData()
|
||||
.subscribe({
|
||||
next: data => this.onUpdateData(data),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.querySubscription) {
|
||||
this.querySubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
async onUpdateData(lastResult: PanelData) {
|
||||
let metaDS: DataSourceApi;
|
||||
const data = lastResult.series;
|
||||
const error = lastResult.error;
|
||||
@@ -117,16 +140,17 @@ export class PanelInspector extends PureComponent<Props, State> {
|
||||
|
||||
// Set last result, but no metadata inspector
|
||||
this.setState(prevState => ({
|
||||
isLoading: lastResult.state === LoadingState.Loading,
|
||||
last: lastResult,
|
||||
data,
|
||||
metaDS,
|
||||
tab: error ? InspectTab.Error : prevState.tab,
|
||||
currentTab: error ? InspectTab.Error : prevState.currentTab,
|
||||
}));
|
||||
}
|
||||
|
||||
onDismiss = () => {
|
||||
onClose = () => {
|
||||
getLocationSrv().update({
|
||||
query: { inspect: null, tab: null },
|
||||
query: { inspect: null, inspectTab: null },
|
||||
partial: true,
|
||||
});
|
||||
};
|
||||
@@ -138,21 +162,11 @@ export class PanelInspector extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
onSelectTab = (item: SelectableValue<InspectTab>) => {
|
||||
this.setState({ tab: item.value || InspectTab.Data });
|
||||
this.setState({ currentTab: item.value || InspectTab.Data });
|
||||
};
|
||||
|
||||
onSelectedFrameChanged = (item: SelectableValue<number>) => {
|
||||
this.setState({ selected: item.value || 0 });
|
||||
};
|
||||
|
||||
exportCsv = (dataFrame: DataFrame) => {
|
||||
const dataFrameCsv = toCSV([dataFrame]);
|
||||
|
||||
const blob = new Blob([dataFrameCsv], {
|
||||
type: 'application/csv;charset=utf-8',
|
||||
});
|
||||
|
||||
saveAs(blob, dataFrame.name + '-' + new Date().getUTCDate() + '.csv');
|
||||
this.setState({ selectedDataFrame: item.value || 0 });
|
||||
};
|
||||
|
||||
renderMetadataInspector() {
|
||||
@@ -164,63 +178,15 @@ export class PanelInspector extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
renderDataTab() {
|
||||
const { data, selected } = this.state;
|
||||
const styles = getStyles();
|
||||
|
||||
if (!data || !data.length) {
|
||||
return <div>No Data</div>;
|
||||
}
|
||||
|
||||
const choices = data.map((frame, index) => {
|
||||
return {
|
||||
value: index,
|
||||
label: `${frame.name} (${index})`,
|
||||
};
|
||||
});
|
||||
|
||||
const processed = applyFieldOverrides({
|
||||
data,
|
||||
theme: config.theme,
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
replaceVariables: (value: string) => {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
const { last, isLoading, selectedDataFrame } = this.state;
|
||||
|
||||
return (
|
||||
<div className={styles.dataTabContent}>
|
||||
<div className={styles.toolbar}>
|
||||
{choices.length > 1 && (
|
||||
<div className={styles.dataFrameSelect}>
|
||||
<Select
|
||||
options={choices}
|
||||
value={choices.find(t => t.value === selected)}
|
||||
onChange={this.onSelectedFrameChanged}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.downloadCsv}>
|
||||
<Button variant="primary" onClick={() => this.exportCsv(processed[selected])}>
|
||||
Download CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => {
|
||||
if (width === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width, height }}>
|
||||
<Table width={width} height={height} data={processed[selected]} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</div>
|
||||
<InspectDataTab
|
||||
data={last.series}
|
||||
isLoading={isLoading}
|
||||
dataFrameIndex={selectedDataFrame}
|
||||
onSelectedFrameChanged={this.onSelectedFrameChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,18 +205,6 @@ export class PanelInspector extends PureComponent<Props, State> {
|
||||
return <div>{error.message}</div>;
|
||||
}
|
||||
|
||||
renderRequestTab() {
|
||||
return (
|
||||
<CustomScrollbar>
|
||||
<JSONFormatter json={this.state.last} open={2} />
|
||||
</CustomScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
renderJsonModelTab() {
|
||||
return <JSONFormatter json={this.props.panel.getSaveModel()} open={2} />;
|
||||
}
|
||||
|
||||
renderStatsTab() {
|
||||
const { last } = this.state;
|
||||
const { request } = last;
|
||||
@@ -285,12 +239,16 @@ export class PanelInspector extends PureComponent<Props, State> {
|
||||
return (
|
||||
<>
|
||||
{this.renderStatsTable('Stats', stats)}
|
||||
{dataStats.length && this.renderStatsTable('Data source stats', dataStats)}
|
||||
{this.renderStatsTable('Data source stats', dataStats)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderStatsTable(name: string, stats: QueryResultMetaStat[]) {
|
||||
if (!stats || !stats.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: '16px' }}>
|
||||
<div className="section-heading">{name}</div>
|
||||
@@ -300,7 +258,7 @@ export class PanelInspector extends PureComponent<Props, State> {
|
||||
return (
|
||||
<tr key={`${stat.title}-${index}`}>
|
||||
<td>{stat.title}</td>
|
||||
<td style={{ textAlign: 'right' }}>{formatStat(stat.value, stat.unit)}</td>
|
||||
<td style={{ textAlign: 'right' }}>{formatStat(stat)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -310,93 +268,109 @@ export class PanelInspector extends PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
drawerHeader = () => {
|
||||
const { tab, last } = this.state;
|
||||
drawerHeader(tabs: Array<{ label: string; value: InspectTab }>, activeTab: InspectTab) {
|
||||
const { panel } = this.props;
|
||||
const { last } = this.state;
|
||||
|
||||
return (
|
||||
<InspectHeader
|
||||
tabs={tabs}
|
||||
tab={activeTab}
|
||||
panelData={last}
|
||||
onSelectTab={this.onSelectTab}
|
||||
onClose={this.onClose}
|
||||
panel={panel}
|
||||
onToggleExpand={this.onToggleExpand}
|
||||
isExpanded={this.state.drawerWidth === '100%'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
getTabs() {
|
||||
const { dashboard, plugin } = this.props;
|
||||
const { last } = this.state;
|
||||
const error = last?.error;
|
||||
const tabs = [];
|
||||
|
||||
if (last && last?.series?.length > 0) {
|
||||
if (plugin && !plugin.meta.skipDataQuery) {
|
||||
tabs.push({ label: 'Data', value: InspectTab.Data });
|
||||
tabs.push({ label: 'Stats', value: InspectTab.Stats });
|
||||
}
|
||||
|
||||
tabs.push({ label: 'Stats', value: InspectTab.Stats });
|
||||
tabs.push({ label: 'Request', value: InspectTab.Request });
|
||||
tabs.push({ label: 'Panel JSON', value: InspectTab.PanelJson });
|
||||
|
||||
if (this.state.metaDS) {
|
||||
tabs.push({ label: 'Meta Data', value: InspectTab.Meta });
|
||||
}
|
||||
|
||||
tabs.push({ label: 'JSON', value: InspectTab.JSON });
|
||||
|
||||
if (error && error.message) {
|
||||
tabs.push({ label: 'Error', value: InspectTab.Error });
|
||||
}
|
||||
|
||||
return (
|
||||
<InspectHeader
|
||||
tabs={tabs}
|
||||
tab={tab}
|
||||
panelData={last}
|
||||
onSelectTab={this.onSelectTab}
|
||||
onClose={this.onDismiss}
|
||||
panel={this.props.panel}
|
||||
onToggleExpand={this.onToggleExpand}
|
||||
isExpanded={this.state.drawerWidth === '100%'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
if (dashboard.meta.canEdit) {
|
||||
tabs.push({ label: 'Query', value: InspectTab.Query });
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { last, tab, drawerWidth } = this.state;
|
||||
const styles = getStyles();
|
||||
const { panel, dashboard, plugin } = this.props;
|
||||
const { currentTab } = this.state;
|
||||
|
||||
if (!plugin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { last, drawerWidth } = this.state;
|
||||
const styles = getPanelInspectorStyles();
|
||||
const error = last?.error;
|
||||
const tabs = this.getTabs();
|
||||
|
||||
// Validate that the active tab is actually valid and allowed
|
||||
let activeTab = currentTab;
|
||||
if (!tabs.find(item => item.value === currentTab)) {
|
||||
activeTab = InspectTab.JSON;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer title={this.drawerHeader} width={drawerWidth} onClose={this.onDismiss}>
|
||||
<TabContent className={styles.tabContent}>
|
||||
{tab === InspectTab.Data && this.renderDataTab()}
|
||||
<CustomScrollbar autoHeightMin="100%">
|
||||
{tab === InspectTab.Meta && this.renderMetadataInspector()}
|
||||
{tab === InspectTab.Request && this.renderRequestTab()}
|
||||
{tab === InspectTab.Error && this.renderErrorTab(error)}
|
||||
{tab === InspectTab.Stats && this.renderStatsTab()}
|
||||
{tab === InspectTab.PanelJson && this.renderJsonModelTab()}
|
||||
</CustomScrollbar>
|
||||
</TabContent>
|
||||
<Drawer title={this.drawerHeader(tabs, activeTab)} width={drawerWidth} onClose={this.onClose}>
|
||||
{activeTab === InspectTab.Data && this.renderDataTab()}
|
||||
<CustomScrollbar autoHeightMin="100%">
|
||||
<TabContent className={styles.tabContent}>
|
||||
{activeTab === InspectTab.Meta && this.renderMetadataInspector()}
|
||||
{activeTab === InspectTab.JSON && (
|
||||
<InspectJSONTab panel={panel} dashboard={dashboard} data={last} onClose={this.onClose} />
|
||||
)}
|
||||
{activeTab === InspectTab.Error && this.renderErrorTab(error)}
|
||||
{activeTab === InspectTab.Stats && this.renderStatsTab()}
|
||||
{activeTab === InspectTab.Query && <QueryInspector panel={panel} />}
|
||||
</TabContent>
|
||||
</CustomScrollbar>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatStat(value: any, unit?: string): string {
|
||||
if (unit) {
|
||||
return formattedValueToString(getValueFormat(unit)(value));
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
function formatStat(stat: QueryResultMetaStat): string {
|
||||
const display = getDisplayProcessor({
|
||||
field: {
|
||||
type: FieldType.number,
|
||||
config: stat,
|
||||
},
|
||||
theme: config.theme,
|
||||
});
|
||||
return formattedValueToString(display(stat.value));
|
||||
}
|
||||
|
||||
const getStyles = stylesFactory(() => {
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
|
||||
const panelState = state.dashboard.panels[props.panel.id];
|
||||
if (!panelState) {
|
||||
return { plugin: null };
|
||||
}
|
||||
|
||||
return {
|
||||
toolbar: css`
|
||||
display: flex;
|
||||
margin: 8px 0;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
`,
|
||||
dataFrameSelect: css`
|
||||
flex-grow: 2;
|
||||
`,
|
||||
downloadCsv: css`
|
||||
margin-left: 16px;
|
||||
`,
|
||||
tabContent: css`
|
||||
height: calc(100% - 32px);
|
||||
`,
|
||||
dataTabContent: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`,
|
||||
plugin: panelState.plugin,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const PanelInspector = connect(mapStateToProps)(PanelInspectorUnconnected);
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
|
||||
import { JSONFormatter, LoadingPlaceholder, Button } from '@grafana/ui';
|
||||
import { CoreEvents } from 'app/types';
|
||||
import { AppEvents, PanelEvents } from '@grafana/data';
|
||||
import { PanelModel } from 'app/features/dashboard/state';
|
||||
import { getPanelInspectorStyles } from './styles';
|
||||
|
||||
interface DsQuery {
|
||||
isLoading: boolean;
|
||||
response: {};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
}
|
||||
|
||||
interface State {
|
||||
allNodesExpanded: boolean;
|
||||
isMocking: boolean;
|
||||
mockedResponse: string;
|
||||
dsQuery: DsQuery;
|
||||
}
|
||||
|
||||
export class QueryInspector extends PureComponent<Props, State> {
|
||||
formattedJson: any;
|
||||
clipboard: any;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
allNodesExpanded: null,
|
||||
isMocking: false,
|
||||
mockedResponse: '',
|
||||
dsQuery: {
|
||||
isLoading: false,
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
appEvents.on(CoreEvents.dsRequestResponse, this.onDataSourceResponse);
|
||||
appEvents.on(CoreEvents.dsRequestError, this.onRequestError);
|
||||
this.props.panel.events.on(PanelEvents.refresh, this.onPanelRefresh);
|
||||
}
|
||||
|
||||
onIssueNewQuery = () => {
|
||||
this.props.panel.refresh();
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
const { panel } = this.props;
|
||||
|
||||
appEvents.off(CoreEvents.dsRequestResponse, this.onDataSourceResponse);
|
||||
appEvents.on(CoreEvents.dsRequestError, this.onRequestError);
|
||||
|
||||
panel.events.off(PanelEvents.refresh, this.onPanelRefresh);
|
||||
}
|
||||
|
||||
handleMocking(response: any) {
|
||||
const { mockedResponse } = this.state;
|
||||
let mockedData;
|
||||
try {
|
||||
mockedData = JSON.parse(mockedResponse);
|
||||
} catch (err) {
|
||||
appEvents.emit(AppEvents.alertError, ['R: Failed to parse mocked response']);
|
||||
return;
|
||||
}
|
||||
|
||||
response.data = mockedData;
|
||||
}
|
||||
|
||||
onPanelRefresh = () => {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
dsQuery: {
|
||||
isLoading: true,
|
||||
response: {},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
onRequestError = (err: any) => {
|
||||
this.onDataSourceResponse(err);
|
||||
};
|
||||
|
||||
onDataSourceResponse = (response: any = {}) => {
|
||||
if (this.state.isMocking) {
|
||||
this.handleMocking(response);
|
||||
return;
|
||||
}
|
||||
|
||||
response = { ...response }; // clone - dont modify the response
|
||||
|
||||
if (response.headers) {
|
||||
delete response.headers;
|
||||
}
|
||||
|
||||
if (response.config) {
|
||||
response.request = response.config;
|
||||
|
||||
delete response.config;
|
||||
delete response.request.transformRequest;
|
||||
delete response.request.transformResponse;
|
||||
delete response.request.paramSerializer;
|
||||
delete response.request.jsonpCallbackParam;
|
||||
delete response.request.headers;
|
||||
delete response.request.requestId;
|
||||
delete response.request.inspect;
|
||||
delete response.request.retry;
|
||||
delete response.request.timeout;
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
response.response = response.data;
|
||||
|
||||
delete response.config;
|
||||
delete response.data;
|
||||
delete response.status;
|
||||
delete response.statusText;
|
||||
delete response.ok;
|
||||
delete response.url;
|
||||
delete response.redirected;
|
||||
delete response.type;
|
||||
delete response.$$config;
|
||||
}
|
||||
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
dsQuery: {
|
||||
isLoading: false,
|
||||
response: response,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
setFormattedJson = (formattedJson: any) => {
|
||||
this.formattedJson = formattedJson;
|
||||
};
|
||||
|
||||
getTextForClipboard = () => {
|
||||
return JSON.stringify(this.formattedJson, null, 2);
|
||||
};
|
||||
|
||||
onClipboardSuccess = () => {
|
||||
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
|
||||
};
|
||||
|
||||
onToggleExpand = () => {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
allNodesExpanded: !this.state.allNodesExpanded,
|
||||
}));
|
||||
};
|
||||
|
||||
onToggleMocking = () => {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
isMocking: !this.state.isMocking,
|
||||
}));
|
||||
};
|
||||
|
||||
getNrOfOpenNodes = () => {
|
||||
if (this.state.allNodesExpanded === null) {
|
||||
return 3; // 3 is default, ie when state is null
|
||||
} else if (this.state.allNodesExpanded) {
|
||||
return 20;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
setMockedResponse = (evt: any) => {
|
||||
const mockedResponse = evt.target.value;
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
mockedResponse,
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { allNodesExpanded } = this.state;
|
||||
const { response, isLoading } = this.state.dsQuery;
|
||||
const openNodes = this.getNrOfOpenNodes();
|
||||
const styles = getPanelInspectorStyles();
|
||||
const haveData = Object.keys(response).length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="section-heading">Query inspector</h3>
|
||||
<p className="small muted">
|
||||
Query inspector allows you to view raw request and response. To collect this data Grafana needs to issue a
|
||||
new query. Hit refresh button below to trigger a new query.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.toolbar}>
|
||||
<Button icon="sync" onClick={this.onIssueNewQuery}>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
{haveData && allNodesExpanded && (
|
||||
<Button icon="minus" variant="secondary" className={styles.toolbarItem} onClick={this.onToggleExpand}>
|
||||
Collapse all
|
||||
</Button>
|
||||
)}
|
||||
{haveData && !allNodesExpanded && (
|
||||
<Button icon="plus" variant="secondary" className={styles.toolbarItem} onClick={this.onToggleExpand}>
|
||||
Expand all
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{haveData && (
|
||||
<CopyToClipboard
|
||||
text={this.getTextForClipboard}
|
||||
onSuccess={this.onClipboardSuccess}
|
||||
elType="div"
|
||||
className={styles.toolbarItem}
|
||||
>
|
||||
<Button icon="copy" variant="secondary">
|
||||
Copy to clipboard
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.contentQueryInspector}>
|
||||
{isLoading && <LoadingPlaceholder text="Loading query inspector..." />}
|
||||
{!isLoading && haveData && (
|
||||
<JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />
|
||||
)}
|
||||
{!isLoading && !haveData && <p className="muted">No request & response collected yet. Hit refresh button</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
58
public/app/features/dashboard/components/Inspector/styles.ts
Normal file
58
public/app/features/dashboard/components/Inspector/styles.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { css } from 'emotion';
|
||||
import { config } from 'app/core/config';
|
||||
import { stylesFactory } from '@grafana/ui';
|
||||
|
||||
export const getPanelInspectorStyles = stylesFactory(() => {
|
||||
return {
|
||||
wrap: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1 1 0;
|
||||
`,
|
||||
toolbar: css`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-grow: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`,
|
||||
toolbarItem: css`
|
||||
margin-left: ${config.theme.spacing.md};
|
||||
`,
|
||||
content: css`
|
||||
flex-grow: 1;
|
||||
padding-bottom: 16px;
|
||||
`,
|
||||
contentQueryInspector: css`
|
||||
flex-grow: 1;
|
||||
padding: ${config.theme.spacing.md} 0;
|
||||
`,
|
||||
editor: css`
|
||||
font-family: monospace;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
viewer: css`
|
||||
overflow: scroll;
|
||||
`,
|
||||
dataFrameSelect: css`
|
||||
flex-grow: 2;
|
||||
`,
|
||||
downloadCsv: css`
|
||||
margin-left: 16px;
|
||||
`,
|
||||
tabContent: css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
dataTabContent: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user