Commit e5d21461 by Dominik Prokop Committed by GitHub

Refactor panel inspector (#24480)

* Inspect: Should not subscribe to transformed data

* PQR- allow controll whether or not field overrides and transformations should be applied

* UI for inspector data options

* fix

* Null check fix

* Refactor PanelInspector

* TS fix

* Merge fix

* fix test

* Update public/app/features/dashboard/components/Inspector/InspectDataTab.tsx

Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com>

* review batch 1

* Update API of usePanelLatestData

* css

* review batch 2

* Update usePanelLatestData hook

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com>
parent 4a30c65a
......@@ -13,7 +13,7 @@ export interface DataSourceSrv {
* @param name - name of the datasource plugin you want to use.
* @param scopedVars - variables used to interpolate a templated passed as name.
*/
get(name?: string, scopedVars?: ScopedVars): Promise<DataSourceApi>;
get(name?: string | null, scopedVars?: ScopedVars): Promise<DataSourceApi>;
/**
* Returns metadata based on UID.
......
import React, { useState } from 'react';
import { getPanelInspectorStyles } from './styles';
import { CustomScrollbar, Drawer, TabContent } from '@grafana/ui';
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 { DashboardModel, PanelModel } from '../../state';
import { DataSourceApi, PanelData, PanelPlugin } from '@grafana/data';
import { GetDataOptions } from '../../state/PanelQueryRunner';
interface Props {
dashboard: DashboardModel;
panel: PanelModel;
plugin?: PanelPlugin | null;
defaultTab: InspectTab;
tabs: Array<{ label: string; value: InspectTab }>;
// The last raw response
data?: PanelData;
isDataLoading: boolean;
dataOptions: GetDataOptions;
// If the datasource supports custom metadata
metadataDatasource?: DataSourceApi;
onDataOptionsChange: (options: GetDataOptions) => void;
onClose: () => void;
}
export const InspectContent: React.FC<Props> = ({
panel,
plugin,
dashboard,
tabs,
data,
isDataLoading,
dataOptions,
metadataDatasource,
defaultTab,
onDataOptionsChange,
onClose,
}) => {
const [currentTab, setCurrentTab] = useState(defaultTab ?? InspectTab.Data);
if (!plugin) {
return null;
}
const styles = getPanelInspectorStyles();
const error = data?.error;
// 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={`Inspect: ${panel.title}` || 'Panel inspect'}
subtitle={
<InspectSubtitle
tabs={tabs}
tab={activeTab}
data={data}
onSelectTab={item => setCurrentTab(item.value || InspectTab.Data)}
/>
}
width="50%"
onClose={onClose}
expandable
>
{activeTab === InspectTab.Data && (
<InspectDataTab
panel={panel}
data={data && data.series}
isLoading={isDataLoading}
options={dataOptions}
onOptionsChange={onDataOptionsChange}
/>
)}
<CustomScrollbar autoHeightMin="100%">
<TabContent className={styles.tabContent}>
{data && activeTab === InspectTab.Meta && (
<InspectMetadataTab data={data} metadataDatasource={metadataDatasource} />
)}
{activeTab === InspectTab.JSON && (
<InspectJSONTab panel={panel} dashboard={dashboard} data={data} onClose={onClose} />
)}
{activeTab === InspectTab.Error && <InspectErrorTab error={error} />}
{data && activeTab === InspectTab.Stats && <InspectStatsTab data={data} dashboard={dashboard} />}
{data && activeTab === InspectTab.Query && <QueryInspector panel={panel} data={data.series} />}
</TabContent>
</CustomScrollbar>
</Drawer>
);
};
......@@ -14,7 +14,17 @@ import {
DisplayProcessor,
getDisplayProcessor,
} from '@grafana/data';
import { Button, Field, Icon, LegacyForms, Select, Table } from '@grafana/ui';
import {
Button,
Container,
Field,
HorizontalGroup,
Icon,
LegacyForms,
Select,
Table,
VerticalGroup,
} from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import AutoSizer from 'react-virtualized-auto-sizer';
......@@ -23,15 +33,13 @@ import { config } from 'app/core/config';
import { saveAs } from 'file-saver';
import { css, cx } from 'emotion';
import { GetDataOptions } from '../../state/PanelQueryRunner';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { QueryOperationRow } from '../../../../core/components/QueryOperationRow/QueryOperationRow';
import { PanelModel } from '../../state';
const { Switch } = LegacyForms;
interface Props {
dashboard: DashboardModel;
panel: PanelModel;
data: DataFrame[];
data?: DataFrame[];
isLoading: boolean;
options: GetDataOptions;
onOptionsChange: (options: GetDataOptions) => void;
......@@ -187,7 +195,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
};
render() {
const { isLoading, data } = this.props;
const { isLoading, data, options, onOptionsChange } = this.props;
const { dataFrameIndex, transformId, transformationOptions } = this.state;
const styles = getPanelInspectorStyles();
......@@ -212,48 +220,77 @@ export class InspectDataTab extends PureComponent<Props, State> {
};
});
const panelTransformations = this.props.panel.getTransformations();
return (
<div className={styles.dataTabContent} aria-label={selectors.components.PanelInspector.Data.content}>
<div className={styles.actionsWrapper}>
<div className={styles.leftActions}>
<div className={styles.selects}>
{data.length > 1 && (
<Field
label="Transformer"
className={css`
margin-bottom: 0;
`}
>
<Select
options={transformationOptions}
value={transformId}
onChange={this.onTransformationChange}
width={15}
<Container>
<VerticalGroup spacing={'md'}>
<HorizontalGroup justify={'space-between'} align={'flex-end'} wrap>
<HorizontalGroup>
{data.length > 1 && (
<Container grow={1}>
<Field
label="Transformer"
className={css`
margin-bottom: 0;
`}
>
<Select
options={transformationOptions}
value={transformId}
onChange={this.onTransformationChange}
width={15}
/>
</Field>
</Container>
)}
{choices.length > 1 && (
<Container grow={1}>
<Field
label="Select result"
className={css`
margin-bottom: 0;
`}
>
<Select options={choices} value={dataFrameIndex} onChange={this.onSelectedFrameChanged} />
</Field>
</Container>
)}
</HorizontalGroup>
<Button variant="primary" onClick={() => this.exportCsv(dataFrames[dataFrameIndex])}>
Download CSV
</Button>
</HorizontalGroup>
<Container grow={1}>
<QueryOperationRow title={'Data display options'} isOpen={false}>
{panelTransformations && panelTransformations.length > 0 && (
<div className="gf-form-inline">
<Switch
tooltip="Data shown in the table will be transformed using transformations defined in the panel"
label="Apply panel transformations"
labelClass="width-12"
checked={!!options.withTransforms}
onChange={() => onOptionsChange({ ...options, withTransforms: !options.withTransforms })}
/>
</div>
)}
<div className="gf-form-inline">
<Switch
tooltip="Data shown in the table will have panel field configuration applied, for example units or title"
label="Apply field configuration"
labelClass="width-12"
checked={!!options.withFieldConfig}
onChange={() => onOptionsChange({ ...options, withFieldConfig: !options.withFieldConfig })}
/>
</Field>
)}
{choices.length > 1 && (
<Field
label="Select result"
className={css`
margin-bottom: 0;
`}
>
<Select options={choices} value={dataFrameIndex} onChange={this.onSelectedFrameChanged} width={30} />
</Field>
)}
</div>
{this.renderDataOptions()}
</div>
<div className={styles.options}>
<Button variant="primary" onClick={() => this.exportCsv(dataFrames[dataFrameIndex])}>
Download CSV
</Button>
</div>
</div>
</div>
</QueryOperationRow>
</Container>
</VerticalGroup>
</Container>
<div style={{ flexGrow: 1 }}>
<Container grow={1}>
<AutoSizer>
{({ width, height }) => {
if (width === 0) {
......@@ -267,7 +304,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
);
}}
</AutoSizer>
</div>
</Container>
</div>
);
}
......
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>;
};
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} />;
};
import { PanelData, QueryResultMetaStat } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { InspectStatsTable } from './InspectStatsTable';
import React from 'react';
import { DashboardModel } from 'app/features/dashboard/state';
interface InspectStatsTabProps {
data: PanelData;
dashboard: DashboardModel;
}
export const InspectStatsTab: React.FC<InspectStatsTabProps> = ({ data, dashboard }) => {
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;
}
stats.push({ displayName: 'Total request time', value: requestTime, unit: 'ms' });
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 dashboard={dashboard} name={'Stats'} stats={stats} />
<InspectStatsTable dashboard={dashboard} name={'Data source stats'} stats={dataStats} />
</div>
);
};
import React from 'react';
import {
FieldType,
formattedValueToString,
getDisplayProcessor,
GrafanaTheme,
QueryResultMetaStat,
TimeZone,
} from '@grafana/data';
import { DashboardModel } from 'app/features/dashboard/state';
import { config } from 'app/core/config';
import { stylesFactory, useTheme } from '@grafana/ui';
import { css } from 'emotion';
interface InspectStatsTableProps {
dashboard: DashboardModel;
name: string;
stats: QueryResultMetaStat[];
}
export const InspectStatsTable: React.FC<InspectStatsTableProps> = ({ dashboard, 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, dashboard.getTimezone())}</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;
`,
};
});
......@@ -2,22 +2,22 @@ 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 './PanelInspector';
import { InspectTab } from './types';
interface Props {
tab: InspectTab;
tabs: Array<{ label: string; value: InspectTab }>;
panelData: PanelData;
data?: PanelData;
onSelectTab: (tab: SelectableValue<InspectTab>) => void;
}
export const InspectSubtitle: FC<Props> = ({ tab, tabs, onSelectTab, panelData }) => {
export const InspectSubtitle: FC<Props> = ({ tab, tabs, onSelectTab, data }) => {
const theme = useTheme();
const styles = getStyles(theme);
return (
<>
<div className="muted">{formatStats(panelData)}</div>
{data && <div className="muted">{formatStats(data)}</div>}
<TabsBar className={styles.tabsBar}>
{tabs.map((t, index) => {
return (
......@@ -43,8 +43,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
};
});
function formatStats(panelData: PanelData) {
const { request } = panelData;
function formatStats(data: PanelData) {
const { request } = data;
if (!request) {
return '';
}
......
import { DataQueryError, DataSourceApi, PanelData, PanelPlugin } from '@grafana/data';
import useAsync from 'react-use/lib/useAsync';
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';
/**
* Given PanelData return first data source supporting metadata inspector
*/
export const useDatasourceMetadata = (data?: PanelData) => {
const state = useAsync<DataSourceApi | undefined>(async () => {
const targets = data?.request?.targets || [];
if (data && data.series && targets.length) {
for (const frame of data.series) {
if (frame.meta && frame.meta.custom) {
// get data source from first query
const dataSource = await getDataSourceSrv().get(targets[0].datasource);
if (dataSource && dataSource.components?.MetadataInspector) {
return dataSource;
}
}
}
}
return undefined;
}, [data]);
return state.value;
};
/**
* Configures tabs for PanelInspector
*/
export const useInspectTabs = (
plugin: PanelPlugin,
dashboard: DashboardModel,
error?: DataQueryError,
metaDs?: DataSourceApi
) => {
return useMemo(() => {
const tabs = [];
if (supportsDataQuery(plugin)) {
tabs.push({ label: 'Data', value: InspectTab.Data });
tabs.push({ label: 'Stats', value: InspectTab.Stats });
}
if (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 });
}
if (dashboard.meta.canEdit && supportsDataQuery(plugin)) {
tabs.push({ label: 'Query', value: InspectTab.Query });
}
return tabs;
}, [plugin, metaDs, dashboard, error]);
};
export enum InspectTab {
Data = 'data',
Meta = 'meta', // When result metadata exists
Error = 'error',
Stats = 'stats',
JSON = 'json',
Query = 'query',
}
......@@ -35,7 +35,7 @@ export const OptionsPaneContent: React.FC<Props> = ({
const styles = getStyles(theme);
const [activeTab, setActiveTab] = useState('options');
const [isSearching, setSearchMode] = useState(false);
const [currentData, hasSeries] = usePanelLatestData(panel, { withTransforms: true, withFieldConfig: false });
const { data, hasSeries } = usePanelLatestData(panel, { withTransforms: true, withFieldConfig: false });
const renderFieldOptions = useCallback(
(plugin: PanelPlugin) => {
......@@ -51,11 +51,11 @@ export const OptionsPaneContent: React.FC<Props> = ({
plugin={plugin}
onChange={onFieldConfigsChange}
/* hasSeries makes sure current data is there */
data={currentData!.series}
data={data!.series}
/>
);
},
[currentData, plugin, panel, onFieldConfigsChange]
[data, plugin, panel, onFieldConfigsChange]
);
const renderFieldOverrideOptions = useCallback(
......@@ -72,11 +72,11 @@ export const OptionsPaneContent: React.FC<Props> = ({
plugin={plugin}
onChange={onFieldConfigsChange}
/* hasSeries makes sure current data is there */
data={currentData!.series}
data={data!.series}
/>
);
},
[currentData, plugin, panel, onFieldConfigsChange]
[data, plugin, panel, onFieldConfigsChange]
);
// When the panel has no query only show the main tab
......@@ -106,7 +106,7 @@ export const OptionsPaneContent: React.FC<Props> = ({
panel={panel}
plugin={plugin}
dashboard={dashboard}
data={currentData}
data={data}
onPanelConfigChange={onPanelConfigChange}
onPanelOptionsChanged={onPanelOptionsChanged}
/>
......
import { PanelData } from '@grafana/data';
import { DataQueryError, LoadingState, PanelData } from '@grafana/data';
import { useEffect, useRef, useState } from 'react';
import { PanelModel } from '../../state';
import { Unsubscribable } from 'rxjs';
import { GetDataOptions } from '../../state/PanelQueryRunner';
export const usePanelLatestData = (panel: PanelModel, options: GetDataOptions): [PanelData | null, boolean] => {
interface UsePanelLatestData {
data?: PanelData;
error?: DataQueryError;
isLoading: boolean;
hasSeries: boolean;
}
/**
* Subscribes and returns latest panel data from PanelQueryRunner
*/
export const usePanelLatestData = (panel: PanelModel, options: GetDataOptions): UsePanelLatestData => {
const querySubscription = useRef<Unsubscribable>(null);
const [latestData, setLatestData] = useState<PanelData>(null);
const [latestData, setLatestData] = useState<PanelData>();
useEffect(() => {
querySubscription.current = panel
......@@ -18,15 +28,19 @@ export const usePanelLatestData = (panel: PanelModel, options: GetDataOptions):
return () => {
if (querySubscription.current) {
console.log('unsubscribing');
querySubscription.current.unsubscribe();
}
};
}, [panel]);
/**
* Adding separate options to dependencies array to avoid additional hook for comparing previous options with current.
* Otherwise, passing different references to the same object may cause troubles.
*/
}, [panel, options.withFieldConfig, options.withTransforms]);
return [
latestData,
// TODO: make this more clever, use PanelData.state
!!(latestData && latestData.series),
];
return {
data: latestData,
error: latestData && latestData.error,
isLoading: latestData ? latestData.state === LoadingState.Loading : true,
hasSeries: latestData ? !!latestData.series : false,
};
};
......@@ -27,7 +27,8 @@ import {
} from 'app/types';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { InspectTab, PanelInspector } from '../components/Inspector/PanelInspector';
import { InspectTab } from '../components/Inspector/types';
import { PanelInspector } from '../components/Inspector/PanelInspector';
import { SubMenu } from '../components/SubMenu/SubMenu';
import { cleanUpDashboardAndVariables } from '../state/actions';
import { cancelVariables } from '../../variables/state/actions';
......
......@@ -7,7 +7,7 @@ import { getLocationSrv } from '@grafana/runtime';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import templateSrv from 'app/features/templating/template_srv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { InspectTab } from '../../components/Inspector/PanelInspector';
import { InspectTab } from '../../components/Inspector/types';
enum InfoMode {
Error = 'Error',
......
......@@ -298,4 +298,48 @@ describe('PanelQueryRunner', () => {
getTransformations: () => [({} as unknown) as DataTransformerConfig],
}
);
describeQueryRunnerScenario(
'getData',
ctx => {
it('should not apply transformations when transform option is false', async () => {
const spy = jest.spyOn(grafanaData, 'transformDataFrame');
spy.mockClear();
ctx.runner.getData({ withTransforms: false, withFieldConfig: true }).subscribe({
next: (data: PanelData) => {
return data;
},
});
expect(spy).not.toBeCalled();
});
it('should not apply field config when applyFieldConfig option is false', async () => {
const spy = jest.spyOn(grafanaData, 'applyFieldOverrides');
spy.mockClear();
ctx.runner.getData({ withFieldConfig: false, withTransforms: true }).subscribe({
next: (data: PanelData) => {
return data;
},
});
expect(spy).not.toBeCalled();
});
},
{
getFieldOverrideOptions: () => ({
fieldConfig: {
defaults: {
unit: 'm/s',
},
// @ts-ignore
overrides: [],
},
replaceVariables: v => v,
theme: {} as GrafanaTheme,
}),
// @ts-ignore
getTransformations: () => [{}],
}
);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment