mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Explore: Support full inspect drawer (#32005)
* Move InspectSubtitle to grafana/ui * Move inspect elements to features/inspector * Move InspectJSON and use it also in Explore * Move and use QueryInspector * Move all to features/inspector * WIP Data tab implementation * Process dataframes for explores inspector * Clean up * Update test * Clean up * Fix Explore Inspector button name
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { useStyles } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => css`
|
||||
margin: 0;
|
||||
margin-left: ${theme.spacing.md};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
color: ${theme.colors.textWeak};
|
||||
`;
|
||||
|
||||
export const DetailText: FC = ({ children }) => {
|
||||
const collapsedTextStyles = useStyles(getStyles);
|
||||
return <p className={collapsedTextStyles}>{children}</p>;
|
||||
};
|
||||
@@ -2,15 +2,15 @@ import React, { useState } from 'react';
|
||||
import { DataSourceApi, PanelData, PanelPlugin } from '@grafana/data';
|
||||
import { getTemplateSrv } from '@grafana/runtime';
|
||||
import { CustomScrollbar, Drawer, TabContent } from '@grafana/ui';
|
||||
import { getPanelInspectorStyles } from './styles';
|
||||
import { InspectSubtitle } from './InspectSubtitle';
|
||||
import { InspectDataTab } from './InspectDataTab';
|
||||
import { InspectMetadataTab } from './InspectMetadataTab';
|
||||
import { InspectJSONTab } from './InspectJSONTab';
|
||||
import { InspectErrorTab } from './InspectErrorTab';
|
||||
import { InspectStatsTab } from './InspectStatsTab';
|
||||
import { QueryInspector } from './QueryInspector';
|
||||
import { InspectTab } from './types';
|
||||
import { getPanelInspectorStyles } from 'app/features/inspector/styles';
|
||||
import { InspectMetadataTab } from 'app/features/inspector/InspectMetadataTab';
|
||||
import { InspectSubtitle } from 'app/features/inspector/InspectSubtitle';
|
||||
import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab';
|
||||
import { QueryInspector } from 'app/features/inspector/QueryInspector';
|
||||
import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab';
|
||||
import { InspectErrorTab } from 'app/features/inspector/InspectErrorTab';
|
||||
import { InspectDataTab } from 'app/features/inspector/InspectDataTab';
|
||||
import { InspectTab } from 'app/features/inspector/types';
|
||||
import { DashboardModel, PanelModel } from '../../state';
|
||||
import { GetDataOptions } from '../../../query/state/PanelQueryRunner';
|
||||
|
||||
@@ -94,7 +94,9 @@ export const InspectContent: React.FC<Props> = ({
|
||||
)}
|
||||
{activeTab === InspectTab.Error && <InspectErrorTab error={error} />}
|
||||
{data && activeTab === InspectTab.Stats && <InspectStatsTab data={data} timeZone={dashboard.getTimezone()} />}
|
||||
{data && activeTab === InspectTab.Query && <QueryInspector panel={panel} data={data.series} />}
|
||||
{data && activeTab === InspectTab.Query && (
|
||||
<QueryInspector panel={panel} data={data.series} onRefreshQuery={() => panel.refresh()} />
|
||||
)}
|
||||
</TabContent>
|
||||
</CustomScrollbar>
|
||||
</Drawer>
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import {
|
||||
applyFieldOverrides,
|
||||
applyRawFieldOverrides,
|
||||
CSVConfig,
|
||||
DataFrame,
|
||||
DataTransformerID,
|
||||
dateTimeFormat,
|
||||
getFrameDisplayName,
|
||||
SelectableValue,
|
||||
toCSV,
|
||||
transformDataFrame,
|
||||
} from '@grafana/data';
|
||||
import { Button, Container, Field, HorizontalGroup, Select, Spinner, Switch, Table, VerticalGroup } from '@grafana/ui';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { getPanelInspectorStyles } from './styles';
|
||||
import { config } from 'app/core/config';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { css } from 'emotion';
|
||||
import { GetDataOptions } from '../../../query/state/PanelQueryRunner';
|
||||
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
|
||||
import { PanelModel } from 'app/features/dashboard/state';
|
||||
import { DetailText } from './DetailText';
|
||||
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
data?: DataFrame[];
|
||||
isLoading: boolean;
|
||||
options: GetDataOptions;
|
||||
onOptionsChange: (options: GetDataOptions) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
/** The string is seriesToColumns transformation. Otherwise it is a dataframe index */
|
||||
selectedDataFrame: number | DataTransformerID;
|
||||
transformId: DataTransformerID;
|
||||
dataFrameIndex: number;
|
||||
transformationOptions: Array<SelectableValue<DataTransformerID>>;
|
||||
transformedData: DataFrame[];
|
||||
downloadForExcel: boolean;
|
||||
}
|
||||
|
||||
export class InspectDataTab extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedDataFrame: 0,
|
||||
dataFrameIndex: 0,
|
||||
transformId: DataTransformerID.noop,
|
||||
transformationOptions: buildTransformationOptions(),
|
||||
transformedData: props.data ?? [],
|
||||
downloadForExcel: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (!this.props.data) {
|
||||
this.setState({ transformedData: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.options.withTransforms) {
|
||||
this.setState({ transformedData: this.props.data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevProps.data !== this.props.data || prevState.transformId !== this.state.transformId) {
|
||||
const currentTransform = this.state.transformationOptions.find((item) => item.value === this.state.transformId);
|
||||
|
||||
if (currentTransform && currentTransform.transformer.id !== DataTransformerID.noop) {
|
||||
const selectedDataFrame = this.state.selectedDataFrame;
|
||||
const dataFrameIndex = this.state.dataFrameIndex;
|
||||
const subscription = transformDataFrame([currentTransform.transformer], this.props.data).subscribe((data) => {
|
||||
this.setState({ transformedData: data, selectedDataFrame, dataFrameIndex }, () => subscription.unsubscribe());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ transformedData: this.props.data });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
exportCsv = (dataFrame: DataFrame, csvConfig: CSVConfig = {}) => {
|
||||
const { panel } = this.props;
|
||||
const { transformId } = this.state;
|
||||
|
||||
const dataFrameCsv = toCSV([dataFrame], csvConfig);
|
||||
|
||||
const blob = new Blob([String.fromCharCode(0xfeff), dataFrameCsv], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
});
|
||||
const transformation = transformId !== DataTransformerID.noop ? '-as-' + transformId.toLocaleLowerCase() : '';
|
||||
const fileName = `${panel.getDisplayTitle()}-data${transformation}-${dateTimeFormat(new Date())}.csv`;
|
||||
saveAs(blob, fileName);
|
||||
};
|
||||
|
||||
onDataFrameChange = (item: SelectableValue<DataTransformerID | number>) => {
|
||||
this.setState({
|
||||
transformId:
|
||||
item.value === DataTransformerID.seriesToColumns ? DataTransformerID.seriesToColumns : DataTransformerID.noop,
|
||||
dataFrameIndex: typeof item.value === 'number' ? item.value : 0,
|
||||
selectedDataFrame: item.value!,
|
||||
});
|
||||
};
|
||||
|
||||
getProcessedData(): DataFrame[] {
|
||||
const { options } = this.props;
|
||||
const data = this.state.transformedData;
|
||||
|
||||
if (!options.withFieldConfig) {
|
||||
return applyRawFieldOverrides(data);
|
||||
}
|
||||
|
||||
// We need to apply field config even though it was already applied in the PanelQueryRunner.
|
||||
// That's because transformers create new fields and data frames, so i.e. display processor is no longer there
|
||||
return applyFieldOverrides({
|
||||
data,
|
||||
theme: config.theme,
|
||||
fieldConfig: this.props.panel.fieldConfig,
|
||||
replaceVariables: (value: string) => {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getActiveString() {
|
||||
const { selectedDataFrame } = this.state;
|
||||
const { options, data } = this.props;
|
||||
let activeString = '';
|
||||
|
||||
if (!data) {
|
||||
return activeString;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (selectedDataFrame === DataTransformerID.seriesToColumns) {
|
||||
parts.push('Series joined by time');
|
||||
} else if (data.length > 1) {
|
||||
parts.push(getFrameDisplayName(data[selectedDataFrame as number]));
|
||||
}
|
||||
|
||||
if (options.withTransforms || options.withFieldConfig) {
|
||||
if (options.withTransforms) {
|
||||
parts.push('Panel transforms');
|
||||
}
|
||||
|
||||
if (options.withTransforms && options.withFieldConfig) {
|
||||
}
|
||||
|
||||
if (options.withFieldConfig) {
|
||||
parts.push('Formatted data');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.downloadForExcel) {
|
||||
parts.push('Excel header');
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
renderDataOptions(dataFrames: DataFrame[]) {
|
||||
const { options, onOptionsChange, panel, data } = this.props;
|
||||
const { transformId, transformationOptions, selectedDataFrame } = this.state;
|
||||
|
||||
const styles = getPanelInspectorStyles();
|
||||
|
||||
const panelTransformations = panel.getTransformations();
|
||||
const showPanelTransformationsOption =
|
||||
panelTransformations && panelTransformations.length > 0 && (transformId as any) !== 'join by time';
|
||||
const showFieldConfigsOption = !panel.plugin?.fieldConfigRegistry.isEmpty();
|
||||
const showDataOptions = showPanelTransformationsOption || showFieldConfigsOption;
|
||||
|
||||
let dataSelect = dataFrames;
|
||||
if (selectedDataFrame === DataTransformerID.seriesToColumns) {
|
||||
dataSelect = data!;
|
||||
}
|
||||
|
||||
const choices = dataSelect.map((frame, index) => {
|
||||
return {
|
||||
value: index,
|
||||
label: `${getFrameDisplayName(frame)} (${index})`,
|
||||
} as SelectableValue<number>;
|
||||
});
|
||||
|
||||
const selectableOptions = [...transformationOptions, ...choices];
|
||||
|
||||
if (!showDataOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryOperationRow
|
||||
id="Data options"
|
||||
index={0}
|
||||
title="Data options"
|
||||
headerElement={<DetailText>{this.getActiveString()}</DetailText>}
|
||||
isOpen={false}
|
||||
>
|
||||
<div className={styles.options}>
|
||||
<VerticalGroup spacing="none">
|
||||
{data!.length > 1 && (
|
||||
<Field label="Show data frame">
|
||||
<Select
|
||||
options={selectableOptions}
|
||||
value={selectedDataFrame}
|
||||
onChange={this.onDataFrameChange}
|
||||
width={30}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<HorizontalGroup>
|
||||
{showPanelTransformationsOption && (
|
||||
<Field
|
||||
label="Apply panel transformations"
|
||||
description="Table data is displayed with transformations defined in the panel Transform tab."
|
||||
>
|
||||
<Switch
|
||||
value={!!options.withTransforms}
|
||||
onChange={() => onOptionsChange({ ...options, withTransforms: !options.withTransforms })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{showFieldConfigsOption && (
|
||||
<Field
|
||||
label="Formatted data"
|
||||
description="Table data is formatted with options defined in the Field and Override tabs."
|
||||
>
|
||||
<Switch
|
||||
value={!!options.withFieldConfig}
|
||||
onChange={() => onOptionsChange({ ...options, withFieldConfig: !options.withFieldConfig })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Download for Excel" description="Adds header to CSV for use with Excel">
|
||||
<Switch
|
||||
value={this.state.downloadForExcel}
|
||||
onChange={() => this.setState({ downloadForExcel: !this.state.downloadForExcel })}
|
||||
/>
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</QueryOperationRow>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading } = this.props;
|
||||
const { dataFrameIndex } = this.state;
|
||||
const styles = getPanelInspectorStyles();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner inline={true} /> Loading
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dataFrames = this.getProcessedData();
|
||||
|
||||
if (!dataFrames || !dataFrames.length) {
|
||||
return <div>No Data</div>;
|
||||
}
|
||||
|
||||
// let's make sure we don't try to render a frame that doesn't exists
|
||||
const index = !dataFrames[dataFrameIndex] ? 0 : dataFrameIndex;
|
||||
const data = dataFrames[index];
|
||||
|
||||
return (
|
||||
<div className={styles.dataTabContent} aria-label={selectors.components.PanelInspector.Data.content}>
|
||||
<div className={styles.actionsWrapper}>
|
||||
<div className={styles.dataDisplayOptions}>{this.renderDataOptions(dataFrames)}</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => this.exportCsv(dataFrames[dataFrameIndex], { useExcelHeader: this.state.downloadForExcel })}
|
||||
className={css`
|
||||
margin-bottom: 10px;
|
||||
`}
|
||||
>
|
||||
Download CSV
|
||||
</Button>
|
||||
</div>
|
||||
<Container grow={1}>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => {
|
||||
if (width === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width, height }}>
|
||||
<Table width={width} height={height} data={data} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTransformationOptions() {
|
||||
const transformations: Array<SelectableValue<DataTransformerID>> = [
|
||||
{
|
||||
value: DataTransformerID.seriesToColumns,
|
||||
label: 'Series joined by time',
|
||||
transformer: {
|
||||
id: DataTransformerID.seriesToColumns,
|
||||
options: { byField: 'Time' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return transformations;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
import { DataQueryError } from '@grafana/data';
|
||||
import { JSONFormatter } from '@grafana/ui';
|
||||
|
||||
interface InspectErrorTabProps {
|
||||
error?: DataQueryError;
|
||||
}
|
||||
|
||||
export const InspectErrorTab: React.FC<InspectErrorTabProps> = ({ error }) => {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
if (error.data) {
|
||||
return (
|
||||
<>
|
||||
<h3>{error.data.message}</h3>
|
||||
<JSONFormatter json={error} open={2} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <div>{error.message}</div>;
|
||||
};
|
||||
@@ -1,158 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { chain } from 'lodash';
|
||||
import { AppEvents, PanelData, SelectableValue } from '@grafana/data';
|
||||
import { Button, CodeEditor, Field, Select } from '@grafana/ui';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import { DashboardModel, PanelModel } 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 everything 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: getPrettyJSON(props.panel.getSaveModel()),
|
||||
};
|
||||
}
|
||||
|
||||
onSelectChanged = (item: SelectableValue<ShowContent>) => {
|
||||
const show = this.getJSONObject(item.value!);
|
||||
const text = getPrettyJSON(show);
|
||||
this.setState({ text, show: item.value! });
|
||||
};
|
||||
|
||||
// Called onBlur
|
||||
onTextChanged = (text: string) => {
|
||||
this.setState({ text });
|
||||
};
|
||||
|
||||
getJSONObject(show: ShowContent) {
|
||||
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 { table, fields, ...rest } = frame as any; // remove 'table' from arrow response
|
||||
return {
|
||||
...rest,
|
||||
fields: frame.fields.map((field) => {
|
||||
return chain(field).omit('values').omit('state').omit('display').value();
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (show === ShowContent.PanelJSON) {
|
||||
return this.props.panel.getSaveModel();
|
||||
}
|
||||
|
||||
return { note: `Unknown Object: ${show}` };
|
||||
}
|
||||
|
||||
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.error('Error applying updates', err);
|
||||
appEvents.emit(AppEvents.alertError, ['Invalid JSON text']);
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { dashboard } = this.props;
|
||||
const { show, text } = 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} aria-label={selectors.components.PanelInspector.Json.content}>
|
||||
<Field label="Select source" className="flex-grow-1">
|
||||
<Select options={options} value={selected} onChange={this.onSelectChanged} />
|
||||
</Field>
|
||||
{isPanelJSON && canEdit && (
|
||||
<Button className={styles.toolbarItem} onClick={this.onApplyPanelModel}>
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }) => (
|
||||
<CodeEditor
|
||||
width="100%"
|
||||
height={height}
|
||||
language="json"
|
||||
showLineNumbers={true}
|
||||
showMiniMap={(text && text.length) > 100}
|
||||
value={text || ''}
|
||||
readOnly={!isPanelJSON}
|
||||
onBlur={this.onTextChanged}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getPrettyJSON(obj: any): string {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from 'react';
|
||||
import { DataSourceApi, PanelData } from '@grafana/data';
|
||||
|
||||
interface InspectMetadataTabProps {
|
||||
data: PanelData;
|
||||
metadataDatasource?: DataSourceApi;
|
||||
}
|
||||
export const InspectMetadataTab: React.FC<InspectMetadataTabProps> = ({ data, metadataDatasource }) => {
|
||||
if (!metadataDatasource || !metadataDatasource.components?.MetadataInspector) {
|
||||
return <div>No Metadata Inspector</div>;
|
||||
}
|
||||
return <metadataDatasource.components.MetadataInspector datasource={metadataDatasource} data={data.series} />;
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
import { PanelData, QueryResultMetaStat, TimeZone } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { InspectStatsTable } from './InspectStatsTable';
|
||||
import React from 'react';
|
||||
|
||||
interface InspectStatsTabProps {
|
||||
data: PanelData;
|
||||
timeZone: TimeZone;
|
||||
}
|
||||
|
||||
export const InspectStatsTab: React.FC<InspectStatsTabProps> = ({ data, timeZone }) => {
|
||||
if (!data.request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let stats: QueryResultMetaStat[] = [];
|
||||
|
||||
const requestTime = data.request.endTime ? data.request.endTime - data.request.startTime : -1;
|
||||
const processingTime = data.timings?.dataProcessingTime || -1;
|
||||
let dataRows = 0;
|
||||
|
||||
for (const frame of data.series) {
|
||||
dataRows += frame.length;
|
||||
}
|
||||
|
||||
if (requestTime > 0) {
|
||||
stats.push({ displayName: 'Total request time', value: requestTime, unit: 'ms' });
|
||||
}
|
||||
if (processingTime > 0) {
|
||||
stats.push({ displayName: 'Data processing time', value: processingTime, unit: 'ms' });
|
||||
}
|
||||
stats.push({ displayName: 'Number of queries', value: data.request.targets.length });
|
||||
stats.push({ displayName: 'Total number rows', value: dataRows });
|
||||
|
||||
let dataStats: QueryResultMetaStat[] = [];
|
||||
|
||||
for (const series of data.series) {
|
||||
if (series.meta && series.meta.stats) {
|
||||
dataStats = dataStats.concat(series.meta.stats);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-label={selectors.components.PanelInspector.Stats.content}>
|
||||
<InspectStatsTable timeZone={timeZone} name={'Stats'} stats={stats} />
|
||||
<InspectStatsTable timeZone={timeZone} name={'Data source stats'} stats={dataStats} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getDisplayProcessor,
|
||||
GrafanaTheme,
|
||||
QueryResultMetaStat,
|
||||
TimeZone,
|
||||
} from '@grafana/data';
|
||||
import { config } from 'app/core/config';
|
||||
import { stylesFactory, useTheme } from '@grafana/ui';
|
||||
import { css } from 'emotion';
|
||||
|
||||
interface InspectStatsTableProps {
|
||||
timeZone: TimeZone;
|
||||
name: string;
|
||||
stats: QueryResultMetaStat[];
|
||||
}
|
||||
|
||||
export const InspectStatsTable: React.FC<InspectStatsTableProps> = ({ timeZone, name, stats }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
if (!stats || !stats.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className="section-heading">{name}</div>
|
||||
<table className="filter-table width-30">
|
||||
<tbody>
|
||||
{stats.map((stat, index) => {
|
||||
return (
|
||||
<tr key={`${stat.displayName}-${index}`}>
|
||||
<td>{stat.displayName}</td>
|
||||
<td className={styles.cell}>{formatStat(stat, timeZone)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function formatStat(stat: QueryResultMetaStat, timeZone?: TimeZone): string {
|
||||
const display = getDisplayProcessor({
|
||||
field: {
|
||||
type: FieldType.number,
|
||||
config: stat,
|
||||
},
|
||||
theme: config.theme,
|
||||
timeZone,
|
||||
});
|
||||
return formattedValueToString(display(stat.value));
|
||||
}
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
wrapper: css`
|
||||
padding-bottom: ${theme.spacing.md};
|
||||
`,
|
||||
cell: css`
|
||||
text-align: right;
|
||||
`,
|
||||
};
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { stylesFactory, Tab, TabsBar, useTheme } from '@grafana/ui';
|
||||
import { GrafanaTheme, SelectableValue, PanelData, getValueFormat, formattedValueToString } from '@grafana/data';
|
||||
import { InspectTab } from './types';
|
||||
|
||||
interface Props {
|
||||
tab: InspectTab;
|
||||
tabs: Array<{ label: string; value: InspectTab }>;
|
||||
data?: PanelData;
|
||||
onSelectTab: (tab: SelectableValue<InspectTab>) => void;
|
||||
}
|
||||
|
||||
export const InspectSubtitle: FC<Props> = ({ tab, tabs, onSelectTab, data }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && <div className="muted">{formatStats(data)}</div>}
|
||||
<TabsBar className={styles.tabsBar}>
|
||||
{tabs.map((t, index) => {
|
||||
return (
|
||||
<Tab
|
||||
key={`${t.value}-${index}`}
|
||||
label={t.label}
|
||||
active={t.value === tab}
|
||||
onChangeTab={() => onSelectTab(t)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TabsBar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
tabsBar: css`
|
||||
padding-left: ${theme.spacing.md};
|
||||
margin: ${theme.spacing.lg} -${theme.spacing.sm} -${theme.spacing.lg} -${theme.spacing.lg};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
function formatStats(data: PanelData) {
|
||||
const { request } = data;
|
||||
if (!request) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryCount = request.targets.length;
|
||||
const requestTime = request.endTime ? request.endTime - request.startTime : 0;
|
||||
const formatted = formattedValueToString(getValueFormat('ms')(requestTime));
|
||||
|
||||
return `${queryCount} queries with total query time of ${formatted}`;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { usePanelLatestData } from '../PanelEditor/usePanelLatestData';
|
||||
import { InspectContent } from './InspectContent';
|
||||
import { useDatasourceMetadata, useInspectTabs } from './hooks';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { InspectTab } from './types';
|
||||
import { InspectTab } from 'app/features/inspector/types';
|
||||
|
||||
interface OwnProps {
|
||||
dashboard: DashboardModel;
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { Button, JSONFormatter, LoadingPlaceholder } from '@grafana/ui';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { AppEvents, DataFrame } from '@grafana/data';
|
||||
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
|
||||
import { PanelModel } from 'app/features/dashboard/state';
|
||||
import { getPanelInspectorStyles } from './styles';
|
||||
import { supportsDataQuery } from '../PanelEditor/utils';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { css } from 'emotion';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { RefreshEvent } from 'app/types/events';
|
||||
|
||||
interface DsQuery {
|
||||
isLoading: boolean;
|
||||
response: {};
|
||||
}
|
||||
|
||||
interface ExecutedQueryInfo {
|
||||
refId: string;
|
||||
query: string;
|
||||
frames: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
data: DataFrame[];
|
||||
}
|
||||
|
||||
interface State {
|
||||
allNodesExpanded: boolean | null;
|
||||
isMocking: boolean;
|
||||
mockedResponse: string;
|
||||
dsQuery: DsQuery;
|
||||
executedQueries: ExecutedQueryInfo[];
|
||||
}
|
||||
|
||||
export class QueryInspector extends PureComponent<Props, State> {
|
||||
private formattedJson: any;
|
||||
private subs = new Subscription();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
executedQueries: [],
|
||||
allNodesExpanded: null,
|
||||
isMocking: false,
|
||||
mockedResponse: '',
|
||||
dsQuery: {
|
||||
isLoading: false,
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { panel } = this.props;
|
||||
|
||||
this.subs.add(
|
||||
backendSrv.getInspectorStream().subscribe({
|
||||
next: (response) => this.onDataSourceResponse(response),
|
||||
})
|
||||
);
|
||||
|
||||
this.subs.add(panel.events.subscribe(RefreshEvent, this.onPanelRefresh));
|
||||
this.updateQueryList();
|
||||
}
|
||||
|
||||
componentDidUpdate(oldProps: Props) {
|
||||
if (this.props.data !== oldProps.data) {
|
||||
this.updateQueryList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the list of executed queries
|
||||
*/
|
||||
updateQueryList() {
|
||||
const { data } = this.props;
|
||||
const executedQueries: ExecutedQueryInfo[] = [];
|
||||
|
||||
if (data?.length) {
|
||||
let last: ExecutedQueryInfo | undefined = undefined;
|
||||
|
||||
data.forEach((frame, idx) => {
|
||||
const query = frame.meta?.executedQueryString;
|
||||
|
||||
if (query) {
|
||||
const refId = frame.refId || '?';
|
||||
|
||||
if (last?.refId === refId) {
|
||||
last.frames++;
|
||||
last.rows += frame.length;
|
||||
} else {
|
||||
last = {
|
||||
refId,
|
||||
frames: 0,
|
||||
rows: frame.length,
|
||||
query,
|
||||
};
|
||||
executedQueries.push(last);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({ executedQueries });
|
||||
}
|
||||
|
||||
onIssueNewQuery = () => {
|
||||
this.props.panel.refresh();
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
this.subs.unsubscribe();
|
||||
}
|
||||
|
||||
onPanelRefresh = () => {
|
||||
this.setState((prevState) => ({
|
||||
...prevState,
|
||||
dsQuery: {
|
||||
isLoading: true,
|
||||
response: {},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
onDataSourceResponse(response: any) {
|
||||
// ignore silent requests
|
||||
if (response.config?.hideFromInspector) {
|
||||
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,
|
||||
}));
|
||||
};
|
||||
|
||||
renderExecutedQueries(executedQueries: ExecutedQueryInfo[]) {
|
||||
if (!executedQueries.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
refId: css`
|
||||
font-weight: ${config.theme.typography.weight.semibold};
|
||||
color: ${config.theme.colors.textBlue};
|
||||
margin-right: 8px;
|
||||
`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{executedQueries.map((info) => {
|
||||
return (
|
||||
<div key={info.refId}>
|
||||
<div>
|
||||
<span className={styles.refId}>{info.refId}:</span>
|
||||
{info.frames > 1 && <span>{info.frames} frames, </span>}
|
||||
<span>{info.rows} rows</span>
|
||||
</div>
|
||||
<pre>{info.query}</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { allNodesExpanded, executedQueries } = this.state;
|
||||
const { response, isLoading } = this.state.dsQuery;
|
||||
const openNodes = this.getNrOfOpenNodes();
|
||||
const styles = getPanelInspectorStyles();
|
||||
const haveData = Object.keys(response).length > 0;
|
||||
|
||||
if (!supportsDataQuery(this.props.panel.plugin)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div aria-label={selectors.components.PanelInspector.Query.content}>
|
||||
<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>
|
||||
{this.renderExecutedQueries(executedQueries)}
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
icon="sync"
|
||||
onClick={this.onIssueNewQuery}
|
||||
aria-label={selectors.components.PanelInspector.Query.refreshButton}
|
||||
>
|
||||
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 className="flex-grow-1" />
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { DashboardModel } from 'app/features/dashboard/state';
|
||||
import { useMemo } from 'react';
|
||||
import { supportsDataQuery } from '../PanelEditor/utils';
|
||||
import { InspectTab } from './types';
|
||||
import { InspectTab } from 'app/features/inspector/types';
|
||||
|
||||
/**
|
||||
* Given PanelData return first data source supporting metadata inspector
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
margin-bottom: ${config.theme.spacing.sm};
|
||||
`,
|
||||
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;
|
||||
`,
|
||||
tabContent: css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
dataTabContent: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`,
|
||||
actionsWrapper: css`
|
||||
display: flex;
|
||||
`,
|
||||
leftActions: css`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
|
||||
max-width: 85%;
|
||||
@media (max-width: 1345px) {
|
||||
max-width: 75%;
|
||||
}
|
||||
`,
|
||||
options: css`
|
||||
padding-top: ${config.theme.spacing.sm};
|
||||
`,
|
||||
dataDisplayOptions: css`
|
||||
flex-grow: 1;
|
||||
min-width: 300px;
|
||||
margin-right: ${config.theme.spacing.sm};
|
||||
`,
|
||||
selects: css`
|
||||
display: flex;
|
||||
> * {
|
||||
margin-right: ${config.theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
export enum InspectTab {
|
||||
Data = 'data',
|
||||
Meta = 'meta', // When result metadata exists
|
||||
Error = 'error',
|
||||
Stats = 'stats',
|
||||
JSON = 'json',
|
||||
Query = 'query',
|
||||
}
|
||||
Reference in New Issue
Block a user