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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 129 additions and 211 deletions

View File

@ -27,6 +27,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
tabContent: css`
padding: ${theme.spacing.md};
background-color: ${theme.colors.bodyBg};
height: 100%;
`,
close: css`
position: absolute;

View File

@ -24,6 +24,7 @@ export type IconName =
| 'bolt'
| 'book-open'
| 'book'
| 'brackets-curly'
| 'bug'
| 'calculator-alt'
| 'calendar-alt'
@ -157,6 +158,7 @@ export const getAvailableIcons = (): IconName[] => [
'bolt',
'book-open',
'book',
'brackets-curly',
'bug',
'calculator-alt',
'calendar-alt',

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

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

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

@ -6,7 +6,7 @@ import { getLocationSrv, getTemplateSrv } from '@grafana/runtime';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { InspectTab } from '../../components/Inspector/types';
import { InspectTab } from 'app/features/inspector/types';
import { selectors } from '@grafana/e2e-selectors';
enum InfoMode {

View File

@ -7,18 +7,18 @@ import { ExploreQueryInspector } from './ExploreQueryInspector';
type ExploreQueryInspectorProps = ComponentProps<typeof ExploreQueryInspector>;
jest.mock('../dashboard/components/Inspector/styles', () => ({
jest.mock('../inspector/styles', () => ({
getPanelInspectorStyles: () => ({}),
}));
jest.mock('app/core/services/backend_srv', () => ({
getBackendSrv: () => ({
backendSrv: {
getInspectorStream: () =>
new Observable((subscriber) => {
subscriber.next(response());
subscriber.next(response(true));
}) as any,
}),
},
}));
jest.mock('app/core/services/context_srv', () => ({
@ -38,6 +38,7 @@ const setup = () => {
series: [],
timeRange: {} as TimeRange,
},
runQueries: jest.fn(),
};
return render(<ExploreQueryInspector {...props} />);

View File

@ -1,65 +1,20 @@
import React, { useState } from 'react';
import { Button, JSONFormatter, LoadingPlaceholder, TabbedContainer, TabConfig } from '@grafana/ui';
import React from 'react';
import { TabbedContainer, TabConfig } from '@grafana/ui';
import { PanelData, TimeZone } from '@grafana/data';
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
import { runQueries } from './state/query';
import { StoreState, ExploreItemState, ExploreId } from 'app/types';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { ExploreDrawer } from 'app/features/explore/ExploreDrawer';
import { useEffectOnce } from 'react-use';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { InspectStatsTab } from '../dashboard/components/Inspector/InspectStatsTab';
import { getPanelInspectorStyles } from '../dashboard/components/Inspector/styles';
import { dispatch } from 'app/store/store';
import { notifyApp } from 'app/core/actions';
import { createSuccessNotification } from 'app/core/copy/appNotification';
import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab';
import { QueryInspector } from 'app/features/inspector/QueryInspector';
import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab';
import { InspectDataTab } from 'app/features/inspector/InspectDataTab';
function stripPropsFromResponse(response: any) {
// ignore silent requests and return early
if (response.config?.hideFromInspector) {
return;
}
const clonedResponse = { ...response }; // clone - dont modify the response
if (clonedResponse.headers) {
delete clonedResponse.headers;
}
if (clonedResponse.config) {
clonedResponse.request = clonedResponse.config;
delete clonedResponse.config;
delete clonedResponse.request.transformRequest;
delete clonedResponse.request.transformResponse;
delete clonedResponse.request.paramSerializer;
delete clonedResponse.request.jsonpCallbackParam;
delete clonedResponse.request.headers;
delete clonedResponse.request.requestId;
delete clonedResponse.request.inspect;
delete clonedResponse.request.retry;
delete clonedResponse.request.timeout;
}
if (clonedResponse.data) {
clonedResponse.response = clonedResponse.data;
delete clonedResponse.config;
delete clonedResponse.data;
delete clonedResponse.status;
delete clonedResponse.statusText;
delete clonedResponse.ok;
delete clonedResponse.url;
delete clonedResponse.redirected;
delete clonedResponse.type;
delete clonedResponse.$$config;
}
return clonedResponse;
interface DispatchProps {
runQueries: typeof runQueries;
}
interface Props {
interface Props extends DispatchProps {
loading: boolean;
width: number;
exploreId: ExploreId;
@ -68,50 +23,9 @@ interface Props {
}
export function ExploreQueryInspector(props: Props) {
const [formattedJSON, setFormattedJSON] = useState({});
const getTextForClipboard = () => {
return JSON.stringify(formattedJSON, null, 2);
};
const onClipboardSuccess = () => {
dispatch(notifyApp(createSuccessNotification('Content copied to clipboard')));
};
const [allNodesExpanded, setAllNodesExpanded] = useState(false);
const getOpenNodeCount = () => {
if (allNodesExpanded === null) {
return 3; // 3 is default, ie when state is null
} else if (allNodesExpanded) {
return 20;
}
return 1;
};
const onToggleExpand = () => {
setAllNodesExpanded(!allNodesExpanded);
};
const { loading, width, onClose, queryResponse } = props;
const dataFrames = queryResponse?.series || [];
const [response, setResponse] = useState<PanelData>({} as PanelData);
useEffectOnce(() => {
const inspectorStreamSub = getBackendSrv()
.getInspectorStream()
.subscribe((resp) => {
const strippedResponse = stripPropsFromResponse(resp);
if (strippedResponse) {
setResponse(strippedResponse);
}
});
return () => {
inspectorStreamSub?.unsubscribe();
};
});
const haveData = response && Object.keys(response).length > 0;
const styles = getPanelInspectorStyles();
const statsTab: TabConfig = {
label: 'Stats',
value: 'stats',
@ -119,52 +33,34 @@ export function ExploreQueryInspector(props: Props) {
content: <InspectStatsTab data={queryResponse!} timeZone={queryResponse?.request?.timezone as TimeZone} />,
};
const inspectorTab: TabConfig = {
label: 'Query Inspector',
value: 'query_inspector',
icon: 'info-circle',
content: (
<>
<div className={styles.toolbar}>
{haveData && (
<>
<Button
icon={allNodesExpanded ? 'minus' : 'plus'}
variant="secondary"
className={styles.toolbarItem}
onClick={onToggleExpand}
>
{allNodesExpanded ? 'Collapse' : 'Expand'} all
</Button>
const jsonTab: TabConfig = {
label: 'JSON',
value: 'json',
icon: 'brackets-curly',
content: <InspectJSONTab data={queryResponse} onClose={onClose} />,
};
<CopyToClipboard
text={getTextForClipboard}
onSuccess={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}>
{loading && <LoadingPlaceholder text="Loading query inspector..." />}
{!loading && haveData && (
<JSONFormatter json={response!} open={getOpenNodeCount()} onDidRender={setFormattedJSON} />
)}
{!loading && !haveData && (
<p className="muted">No request & response collected yet. Run query to collect request & response.</p>
)}
</div>
</>
const dataTab: TabConfig = {
label: 'Data',
value: 'data',
icon: 'database',
content: (
<InspectDataTab
data={dataFrames}
isLoading={loading}
options={{ withTransforms: false, withFieldConfig: false }}
/>
),
};
const tabs = [statsTab, inspectorTab];
const queryInspectorTab: TabConfig = {
label: 'Query Inspector',
value: 'query_inspector',
icon: 'info-circle',
content: <QueryInspector data={dataFrames} onRefreshQuery={() => props.runQueries(props.exploreId)} />,
};
const tabs = [statsTab, queryInspectorTab, jsonTab, dataTab];
return (
<ExploreDrawer width={width} onResize={() => {}}>
<TabbedContainer tabs={tabs} onClose={onClose} closeIconTooltip="Close query inspector" />
@ -183,4 +79,8 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI
};
}
export default hot(module)(connect(mapStateToProps)(ExploreQueryInspector));
const mapDispatchToProps: DispatchProps = {
runQueries,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(ExploreQueryInspector));

View File

@ -54,7 +54,7 @@ export function SecondaryActions(props: Props) {
onClick={props.onClickQueryInspectorButton}
icon="info-circle"
>
Query inspector
Inspector
</Button>
</HorizontalGroup>
</div>

View File

@ -19,17 +19,17 @@ 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 { GetDataOptions } from 'app/features/query/state/PanelQueryRunner';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { PanelModel } from 'app/features/dashboard/state';
import { DetailText } from './DetailText';
import { DetailText } from 'app/features/inspector/DetailText';
interface Props {
panel: PanelModel;
data?: DataFrame[];
isLoading: boolean;
options: GetDataOptions;
onOptionsChange: (options: GetDataOptions) => void;
data?: DataFrame[];
panel?: PanelModel;
onOptionsChange?: (options: GetDataOptions) => void;
}
interface State {
@ -93,8 +93,9 @@ export class InspectDataTab extends PureComponent<Props, State> {
const blob = new Blob([String.fromCharCode(0xfeff), dataFrameCsv], {
type: 'text/csv;charset=utf-8',
});
const displayTitle = panel ? panel.getDisplayTitle() : 'Explore';
const transformation = transformId !== DataTransformerID.noop ? '-as-' + transformId.toLocaleLowerCase() : '';
const fileName = `${panel.getDisplayTitle()}-data${transformation}-${dateTimeFormat(new Date())}.csv`;
const fileName = `${displayTitle}-data${transformation}-${dateTimeFormat(new Date())}.csv`;
saveAs(blob, fileName);
};
@ -108,10 +109,10 @@ export class InspectDataTab extends PureComponent<Props, State> {
};
getProcessedData(): DataFrame[] {
const { options } = this.props;
const { options, panel } = this.props;
const data = this.state.transformedData;
if (!options.withFieldConfig) {
if (!options.withFieldConfig || !panel) {
return applyRawFieldOverrides(data);
}
@ -120,7 +121,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
return applyFieldOverrides({
data,
theme: config.theme,
fieldConfig: this.props.panel.fieldConfig,
fieldConfig: panel.fieldConfig,
replaceVariables: (value: string) => {
return value;
},
@ -166,6 +167,9 @@ export class InspectDataTab extends PureComponent<Props, State> {
renderDataOptions(dataFrames: DataFrame[]) {
const { options, onOptionsChange, panel, data } = this.props;
if (!panel || !onOptionsChange) {
return null;
}
const { transformId, transformationOptions, selectedDataFrame } = this.state;
const styles = getPanelInspectorStyles();

View File

@ -5,12 +5,12 @@ 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';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { getPanelInspectorStyles } from '../inspector/styles';
enum ShowContent {
PanelJSON = 'panel',
PanelData = 'data',
DataJSON = 'data',
DataStructure = 'structure',
}
@ -21,9 +21,9 @@ const options: Array<SelectableValue<ShowContent>> = [
value: ShowContent.PanelJSON,
},
{
label: 'Panel data',
label: 'Data',
description: 'The raw model passed to the panel visualization',
value: ShowContent.PanelData,
value: ShowContent.DataJSON,
},
{
label: 'DataFrame structure',
@ -33,10 +33,10 @@ const options: Array<SelectableValue<ShowContent>> = [
];
interface Props {
dashboard: DashboardModel;
panel: PanelModel;
data?: PanelData;
onClose: () => void;
dashboard?: DashboardModel;
panel?: PanelModel;
data?: PanelData;
}
interface State {
@ -45,11 +45,15 @@ interface State {
}
export class InspectJSONTab extends PureComponent<Props, State> {
hasPanelJSON: boolean;
constructor(props: Props) {
super(props);
this.hasPanelJSON = !!(props.panel && props.dashboard);
// If we are in panel, we want to show PanelJSON, otherwise show DataJSON
this.state = {
show: ShowContent.PanelJSON,
text: getPrettyJSON(props.panel.getSaveModel()),
show: this.hasPanelJSON ? ShowContent.PanelJSON : ShowContent.DataJSON,
text: this.hasPanelJSON ? getPrettyJSON(props.panel!.getSaveModel()) : getPrettyJSON(props.data),
};
}
@ -65,16 +69,17 @@ export class InspectJSONTab extends PureComponent<Props, State> {
};
getJSONObject(show: ShowContent) {
if (show === ShowContent.PanelData) {
return this.props.data;
const { data, panel } = this.props;
if (show === ShowContent.DataJSON) {
return data;
}
if (show === ShowContent.DataStructure) {
const series = this.props.data?.series;
const series = data?.series;
if (!series) {
return { note: 'Missing Response Data' };
}
return this.props.data!.series.map((frame) => {
return data!.series.map((frame) => {
const { table, fields, ...rest } = frame as any; // remove 'table' from arrow response
return {
...rest,
@ -85,8 +90,8 @@ export class InspectJSONTab extends PureComponent<Props, State> {
});
}
if (show === ShowContent.PanelJSON) {
return this.props.panel.getSaveModel();
if (this.hasPanelJSON && show === ShowContent.PanelJSON) {
return panel!.getSaveModel();
}
return { note: `Unknown Object: ${show}` };
@ -94,39 +99,41 @@ export class InspectJSONTab extends PureComponent<Props, State> {
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']);
if (this.hasPanelJSON) {
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']);
}
} catch (err) {
console.error('Error applying updates', err);
appEvents.emit(AppEvents.alertError, ['Invalid JSON text']);
}
onClose();
onClose();
}
};
render() {
const { dashboard } = this.props;
const { show, text } = this.state;
const jsonOptions = this.hasPanelJSON ? options : options.slice(1, options.length);
const selected = options.find((v) => v.value === show);
const isPanelJSON = show === ShowContent.PanelJSON;
const canEdit = dashboard.meta.canEdit;
const canEdit = dashboard && 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} />
<Select options={jsonOptions} value={selected} onChange={this.onSelectChanged} />
</Field>
{isPanelJSON && canEdit && (
{this.hasPanelJSON && isPanelJSON && canEdit && (
<Button className={styles.toolbarItem} onClick={this.onApplyPanelModel}>
Apply
</Button>

View File

@ -1,8 +1,8 @@
import React, { FC } from 'react';
import { css } from 'emotion';
import { stylesFactory, Tab, TabsBar, useTheme } from '@grafana/ui';
import { stylesFactory, useTheme, Tab, TabsBar } from '@grafana/ui';
import { GrafanaTheme, SelectableValue, PanelData, getValueFormat, formattedValueToString } from '@grafana/data';
import { InspectTab } from './types';
import { InspectTab } from '../inspector/types';
interface Props {
tab: InspectTab;

View File

@ -7,7 +7,7 @@ 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 { supportsDataQuery } from 'app/features/dashboard/components/PanelEditor/utils';
import { config } from '@grafana/runtime';
import { css } from 'emotion';
import { Subscription } from 'rxjs';
@ -27,8 +27,9 @@ interface ExecutedQueryInfo {
}
interface Props {
panel: PanelModel;
data: DataFrame[];
onRefreshQuery: () => void;
panel?: PanelModel;
}
interface State {
@ -66,8 +67,10 @@ export class QueryInspector extends PureComponent<Props, State> {
})
);
this.subs.add(panel.events.subscribe(RefreshEvent, this.onPanelRefresh));
this.updateQueryList();
if (panel) {
this.subs.add(panel.events.subscribe(RefreshEvent, this.onPanelRefresh));
this.updateQueryList();
}
}
componentDidUpdate(oldProps: Props) {
@ -111,10 +114,6 @@ export class QueryInspector extends PureComponent<Props, State> {
this.setState({ executedQueries });
}
onIssueNewQuery = () => {
this.props.panel.refresh();
};
componentWillUnmount() {
this.subs.unsubscribe();
}
@ -255,12 +254,13 @@ export class QueryInspector extends PureComponent<Props, State> {
render() {
const { allNodesExpanded, executedQueries } = this.state;
const { panel, onRefreshQuery } = this.props;
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)) {
if (panel && !supportsDataQuery(panel.plugin)) {
return null;
}
@ -277,7 +277,7 @@ export class QueryInspector extends PureComponent<Props, State> {
<div className={styles.toolbar}>
<Button
icon="sync"
onClick={this.onIssueNewQuery}
onClick={onRefreshQuery}
aria-label={selectors.components.PanelInspector.Query.refreshButton}
>
Refresh

View File

@ -24,6 +24,7 @@ export const getPanelInspectorStyles = stylesFactory(() => {
`,
content: css`
flex-grow: 1;
height: 100%;
padding-bottom: 16px;
`,
contentQueryInspector: css`