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:
Ivana Huckova
2021-03-18 17:38:09 +01:00
committed by GitHub
parent a6cb9fb295
commit 664b13d83e
20 changed files with 129 additions and 211 deletions

View File

@@ -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>;
};

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>;
};

View File

@@ -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);
}

View File

@@ -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} />;
};

View File

@@ -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>
);
};

View File

@@ -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;
`,
};
});

View File

@@ -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}`;
}

View File

@@ -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;

View File

@@ -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>
</>
);
}
}

View File

@@ -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

View File

@@ -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};
}
`,
};
});

View File

@@ -1,8 +0,0 @@
export enum InspectTab {
Data = 'data',
Meta = 'meta', // When result metadata exists
Error = 'error',
Stats = 'stats',
JSON = 'json',
Query = 'query',
}