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 { ...@@ -13,7 +13,7 @@ export interface DataSourceSrv {
* @param name - name of the datasource plugin you want to use. * @param name - name of the datasource plugin you want to use.
* @param scopedVars - variables used to interpolate a templated passed as name. * @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. * 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 { ...@@ -14,7 +14,17 @@ import {
DisplayProcessor, DisplayProcessor,
getDisplayProcessor, getDisplayProcessor,
} from '@grafana/data'; } 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 { selectors } from '@grafana/e2e-selectors';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
...@@ -23,15 +33,13 @@ import { config } from 'app/core/config'; ...@@ -23,15 +33,13 @@ import { config } from 'app/core/config';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import { GetDataOptions } from '../../state/PanelQueryRunner'; import { GetDataOptions } from '../../state/PanelQueryRunner';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; import { QueryOperationRow } from '../../../../core/components/QueryOperationRow/QueryOperationRow';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { PanelModel } from '../../state';
const { Switch } = LegacyForms; const { Switch } = LegacyForms;
interface Props { interface Props {
dashboard: DashboardModel;
panel: PanelModel; panel: PanelModel;
data: DataFrame[]; data?: DataFrame[];
isLoading: boolean; isLoading: boolean;
options: GetDataOptions; options: GetDataOptions;
onOptionsChange: (options: GetDataOptions) => void; onOptionsChange: (options: GetDataOptions) => void;
...@@ -187,7 +195,7 @@ export class InspectDataTab extends PureComponent<Props, State> { ...@@ -187,7 +195,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
}; };
render() { render() {
const { isLoading, data } = this.props; const { isLoading, data, options, onOptionsChange } = this.props;
const { dataFrameIndex, transformId, transformationOptions } = this.state; const { dataFrameIndex, transformId, transformationOptions } = this.state;
const styles = getPanelInspectorStyles(); const styles = getPanelInspectorStyles();
...@@ -212,12 +220,16 @@ export class InspectDataTab extends PureComponent<Props, State> { ...@@ -212,12 +220,16 @@ export class InspectDataTab extends PureComponent<Props, State> {
}; };
}); });
const panelTransformations = this.props.panel.getTransformations();
return ( return (
<div className={styles.dataTabContent} aria-label={selectors.components.PanelInspector.Data.content}> <div className={styles.dataTabContent} aria-label={selectors.components.PanelInspector.Data.content}>
<div className={styles.actionsWrapper}> <Container>
<div className={styles.leftActions}> <VerticalGroup spacing={'md'}>
<div className={styles.selects}> <HorizontalGroup justify={'space-between'} align={'flex-end'} wrap>
<HorizontalGroup>
{data.length > 1 && ( {data.length > 1 && (
<Container grow={1}>
<Field <Field
label="Transformer" label="Transformer"
className={css` className={css`
...@@ -231,29 +243,54 @@ export class InspectDataTab extends PureComponent<Props, State> { ...@@ -231,29 +243,54 @@ export class InspectDataTab extends PureComponent<Props, State> {
width={15} width={15}
/> />
</Field> </Field>
</Container>
)} )}
{choices.length > 1 && ( {choices.length > 1 && (
<Container grow={1}>
<Field <Field
label="Select result" label="Select result"
className={css` className={css`
margin-bottom: 0; margin-bottom: 0;
`} `}
> >
<Select options={choices} value={dataFrameIndex} onChange={this.onSelectedFrameChanged} width={30} /> <Select options={choices} value={dataFrameIndex} onChange={this.onSelectedFrameChanged} />
</Field> </Field>
</Container>
)} )}
</div> </HorizontalGroup>
{this.renderDataOptions()}
</div>
<div className={styles.options}>
<Button variant="primary" onClick={() => this.exportCsv(dataFrames[dataFrameIndex])}> <Button variant="primary" onClick={() => this.exportCsv(dataFrames[dataFrameIndex])}>
Download CSV Download CSV
</Button> </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>
)}
<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 })}
/>
</div> </div>
</QueryOperationRow>
</Container>
</VerticalGroup>
</Container>
<div style={{ flexGrow: 1 }}> <Container grow={1}>
<AutoSizer> <AutoSizer>
{({ width, height }) => { {({ width, height }) => {
if (width === 0) { if (width === 0) {
...@@ -267,7 +304,7 @@ export class InspectDataTab extends PureComponent<Props, State> { ...@@ -267,7 +304,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
); );
}} }}
</AutoSizer> </AutoSizer>
</div> </Container>
</div> </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'; ...@@ -2,22 +2,22 @@ import React, { FC } from 'react';
import { css } from 'emotion'; import { css } from 'emotion';
import { stylesFactory, Tab, TabsBar, useTheme } from '@grafana/ui'; import { stylesFactory, Tab, TabsBar, useTheme } from '@grafana/ui';
import { GrafanaTheme, SelectableValue, PanelData, getValueFormat, formattedValueToString } from '@grafana/data'; import { GrafanaTheme, SelectableValue, PanelData, getValueFormat, formattedValueToString } from '@grafana/data';
import { InspectTab } from './PanelInspector'; import { InspectTab } from './types';
interface Props { interface Props {
tab: InspectTab; tab: InspectTab;
tabs: Array<{ label: string; value: InspectTab }>; tabs: Array<{ label: string; value: InspectTab }>;
panelData: PanelData; data?: PanelData;
onSelectTab: (tab: SelectableValue<InspectTab>) => void; 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 theme = useTheme();
const styles = getStyles(theme); const styles = getStyles(theme);
return ( return (
<> <>
<div className="muted">{formatStats(panelData)}</div> {data && <div className="muted">{formatStats(data)}</div>}
<TabsBar className={styles.tabsBar}> <TabsBar className={styles.tabsBar}>
{tabs.map((t, index) => { {tabs.map((t, index) => {
return ( return (
...@@ -43,8 +43,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { ...@@ -43,8 +43,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
}; };
}); });
function formatStats(panelData: PanelData) { function formatStats(data: PanelData) {
const { request } = panelData; const { request } = data;
if (!request) { if (!request) {
return ''; return '';
} }
......
import React, { PureComponent } from 'react'; import React, { useCallback, useState } from 'react';
import { Unsubscribable } from 'rxjs'; import { connect, MapStateToProps, useDispatch } from 'react-redux';
import { connect, MapStateToProps } from 'react-redux';
import { InspectSubtitle } from './InspectSubtitle';
import { InspectJSONTab } from './InspectJSONTab';
import { QueryInspector } from './QueryInspector';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { CustomScrollbar, Drawer, JSONFormatter, TabContent } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors'; import { PanelPlugin } from '@grafana/data';
import { getDataSourceSrv, getLocationSrv } from '@grafana/runtime';
import {
DataFrame,
DataQueryError,
DataSourceApi,
FieldType,
formattedValueToString,
getDisplayProcessor,
LoadingState,
PanelData,
PanelPlugin,
QueryResultMetaStat,
SelectableValue,
TimeZone,
} from '@grafana/data';
import { config } from 'app/core/config';
import { getPanelInspectorStyles } from './styles';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { InspectDataTab } from './InspectDataTab';
import { supportsDataQuery } from '../PanelEditor/utils';
import { GetDataOptions } from '../../state/PanelQueryRunner'; import { GetDataOptions } from '../../state/PanelQueryRunner';
import { usePanelLatestData } from '../PanelEditor/usePanelLatestData';
import { InspectContent } from './InspectContent';
import { useDatasourceMetadata, useInspectTabs } from './hooks';
import { InspectTab } from './types';
import { updateLocation } from 'app/core/actions';
interface OwnProps { interface OwnProps {
dashboard: DashboardModel; dashboard: DashboardModel;
...@@ -42,337 +24,44 @@ export interface ConnectedProps { ...@@ -42,337 +24,44 @@ export interface ConnectedProps {
export type Props = OwnProps & ConnectedProps; export type Props = OwnProps & ConnectedProps;
export enum InspectTab { const PanelInspectorUnconnected: React.FC<Props> = ({ panel, dashboard, defaultTab, plugin }) => {
Data = 'data', const dispatch = useDispatch();
Meta = 'meta', // When result metadata exists const [dataOptions, setDataOptions] = useState<GetDataOptions>({
Error = 'error', withTransforms: false,
Stats = 'stats',
JSON = 'json',
Query = 'query',
}
interface State {
isLoading: boolean;
// The last raw response
last: PanelData;
// Data from the last response
data: DataFrame[];
// The Selected Tab
currentTab: InspectTab;
// If the datasource supports custom metadata
metaDS?: DataSourceApi;
// drawer width
drawerWidth: string;
withTransforms: boolean;
withFieldConfig: boolean;
}
export class PanelInspectorUnconnected extends PureComponent<Props, State> {
querySubscription?: Unsubscribable;
constructor(props: Props) {
super(props);
this.state = {
isLoading: true,
last: {} as PanelData,
data: [],
currentTab: props.defaultTab ?? InspectTab.Data,
drawerWidth: '50%',
withTransforms: true,
withFieldConfig: false, withFieldConfig: false,
};
}
componentDidMount() {
const { plugin } = this.props;
if (plugin) {
this.init();
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (
prevProps.plugin !== this.props.plugin ||
this.state.withTransforms !== prevState.withTransforms ||
this.state.withFieldConfig !== prevState.withFieldConfig
) {
this.init();
}
}
/**
* This init process where we do not have a plugin to start with is to handle full page reloads with inspect url parameter
* When this inspect drawer loads the plugin is not yet loaded.
*/
init() {
const { plugin, panel } = this.props;
const { withTransforms, withFieldConfig } = this.state;
if (plugin && !plugin.meta.skipDataQuery) {
if (this.querySubscription) {
this.querySubscription.unsubscribe();
}
this.querySubscription = panel
.getQueryRunner()
.getData({ withTransforms, withFieldConfig })
.subscribe({
next: data => this.onUpdateData(data),
}); });
} const { data, isLoading, error } = usePanelLatestData(panel, dataOptions);
} const metaDs = useDatasourceMetadata(data);
const tabs = useInspectTabs(plugin, dashboard, error, metaDs);
componentWillUnmount() { const onClose = useCallback(() => {
if (this.querySubscription) { dispatch(
this.querySubscription.unsubscribe(); updateLocation({
}
}
async onUpdateData(lastResult: PanelData) {
let metaDS: DataSourceApi;
const data = lastResult.series;
const error = lastResult.error;
const targets = lastResult.request?.targets || [];
// Find the first DataSource wanting to show custom metadata
if (data && targets.length) {
for (const frame of data) {
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) {
metaDS = dataSource;
break;
}
}
}
}
// Set last result, but no metadata inspector
this.setState(prevState => ({
isLoading: lastResult.state === LoadingState.Loading,
last: lastResult,
data,
metaDS,
currentTab: error ? InspectTab.Error : prevState.currentTab,
}));
}
onClose = () => {
getLocationSrv().update({
query: { inspect: null, inspectTab: null }, query: { inspect: null, inspectTab: null },
partial: true, partial: true,
}); })
};
onToggleExpand = () => {
this.setState(prevState => ({
drawerWidth: prevState.drawerWidth === '100%' ? '40%' : '100%',
}));
};
onSelectTab = (item: SelectableValue<InspectTab>) => {
this.setState({ currentTab: item.value || InspectTab.Data });
};
onDataTabOptionsChange = (options: GetDataOptions) => {
this.setState({ withTransforms: !!options.withTransforms, withFieldConfig: !!options.withFieldConfig });
};
renderMetadataInspector() {
const { metaDS, data } = this.state;
if (!metaDS || !metaDS.components?.MetadataInspector) {
return <div>No Metadata Inspector</div>;
}
return <metaDS.components.MetadataInspector datasource={metaDS} data={data} />;
}
renderDataTab() {
const { last, isLoading, withFieldConfig, withTransforms } = this.state;
return (
<InspectDataTab
dashboard={this.props.dashboard}
panel={this.props.panel}
data={last.series}
isLoading={isLoading}
options={{
withFieldConfig,
withTransforms,
}}
onOptionsChange={this.onDataTabOptionsChange}
/>
);
}
renderErrorTab(error?: DataQueryError) {
if (!error) {
return null;
}
if (error.data) {
return (
<>
<h3>{error.data.message}</h3>
<JSONFormatter json={error} open={2} />
</>
); );
} }, [updateLocation]);
return <div>{error.message}</div>;
}
renderStatsTab() {
const { last } = this.state;
const { request } = last;
if (!request) {
return null;
}
let stats: QueryResultMetaStat[] = [];
const requestTime = request.endTime ? request.endTime - request.startTime : -1;
const processingTime = last.timings?.dataProcessingTime || -1;
let dataRows = 0;
for (const frame of last.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: request.targets.length });
stats.push({ displayName: 'Total number rows', value: dataRows });
let dataStats: QueryResultMetaStat[] = [];
for (const series of last.series) {
if (series.meta && series.meta.stats) {
dataStats = dataStats.concat(series.meta.stats);
}
}
return (
<div aria-label={selectors.components.PanelInspector.Stats.content}>
{this.renderStatsTable('Stats', stats)}
{this.renderStatsTable('Data source stats', dataStats)}
</div>
);
}
renderStatsTable(name: string, stats: QueryResultMetaStat[]) {
if (!stats || !stats.length) {
return null;
}
const { dashboard } = this.props;
return (
<div style={{ paddingBottom: '16px' }}>
<table className="filter-table width-30">
<tbody>
{stats.map((stat, index) => {
return (
<tr key={`${stat.displayName}-${index}`}>
<td>{stat.displayName}</td>
<td style={{ textAlign: 'right' }}>{formatStat(stat, dashboard.getTimezone())}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
drawerSubtitle(tabs: Array<{ label: string; value: InspectTab }>, activeTab: InspectTab) {
const { last } = this.state;
return <InspectSubtitle tabs={tabs} tab={activeTab} panelData={last} onSelectTab={this.onSelectTab} />;
}
getTabs() {
const { dashboard, plugin } = this.props;
const { last } = this.state;
const error = last?.error;
const tabs = [];
if (supportsDataQuery(plugin)) {
tabs.push({ label: 'Data', value: InspectTab.Data });
tabs.push({ label: 'Stats', value: InspectTab.Stats });
}
if (this.state.metaDS) {
tabs.push({ label: 'Meta Data', value: InspectTab.Meta });
}
tabs.push({ label: 'JSON', value: InspectTab.JSON });
if (error && error.message) {
tabs.push({ label: 'Error', value: InspectTab.Error });
}
if (dashboard.meta.canEdit && supportsDataQuery(plugin)) {
tabs.push({ label: 'Query', value: InspectTab.Query });
}
return tabs;
}
render() {
const { panel, dashboard, plugin } = this.props;
const { currentTab } = this.state;
if (!plugin) { if (!plugin) {
return null; return null;
} }
const { last, drawerWidth } = this.state;
const styles = getPanelInspectorStyles();
const error = last?.error;
const tabs = this.getTabs();
// Validate that the active tab is actually valid and allowed
let activeTab = currentTab;
if (!tabs.find(item => item.value === currentTab)) {
activeTab = InspectTab.JSON;
}
return ( return (
<Drawer <InspectContent
title={`Inspect: ${panel.title}` || 'Panel inspect'} dashboard={dashboard}
subtitle={this.drawerSubtitle(tabs, activeTab)} panel={panel}
width={drawerWidth} plugin={plugin}
onClose={this.onClose} defaultTab={defaultTab}
expandable tabs={tabs}
> data={data}
{activeTab === InspectTab.Data && this.renderDataTab()} isDataLoading={isLoading}
<CustomScrollbar autoHeightMin="100%"> dataOptions={dataOptions}
<TabContent className={styles.tabContent}> onDataOptionsChange={setDataOptions}
{activeTab === InspectTab.Meta && this.renderMetadataInspector()} metadataDatasource={metaDs}
{activeTab === InspectTab.JSON && ( onClose={onClose}
<InspectJSONTab panel={panel} dashboard={dashboard} data={last} onClose={this.onClose} /> />
)}
{activeTab === InspectTab.Error && this.renderErrorTab(error)}
{activeTab === InspectTab.Stats && this.renderStatsTab()}
{activeTab === InspectTab.Query && <QueryInspector panel={panel} data={last.series} />}
</TabContent>
</CustomScrollbar>
</Drawer>
); );
} };
}
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 mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => { const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
const panelState = state.dashboard.panels[props.panel.id]; const panelState = state.dashboard.panels[props.panel.id];
......
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> = ({ ...@@ -35,7 +35,7 @@ export const OptionsPaneContent: React.FC<Props> = ({
const styles = getStyles(theme); const styles = getStyles(theme);
const [activeTab, setActiveTab] = useState('options'); const [activeTab, setActiveTab] = useState('options');
const [isSearching, setSearchMode] = useState(false); 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( const renderFieldOptions = useCallback(
(plugin: PanelPlugin) => { (plugin: PanelPlugin) => {
...@@ -51,11 +51,11 @@ export const OptionsPaneContent: React.FC<Props> = ({ ...@@ -51,11 +51,11 @@ export const OptionsPaneContent: React.FC<Props> = ({
plugin={plugin} plugin={plugin}
onChange={onFieldConfigsChange} onChange={onFieldConfigsChange}
/* hasSeries makes sure current data is there */ /* hasSeries makes sure current data is there */
data={currentData!.series} data={data!.series}
/> />
); );
}, },
[currentData, plugin, panel, onFieldConfigsChange] [data, plugin, panel, onFieldConfigsChange]
); );
const renderFieldOverrideOptions = useCallback( const renderFieldOverrideOptions = useCallback(
...@@ -72,11 +72,11 @@ export const OptionsPaneContent: React.FC<Props> = ({ ...@@ -72,11 +72,11 @@ export const OptionsPaneContent: React.FC<Props> = ({
plugin={plugin} plugin={plugin}
onChange={onFieldConfigsChange} onChange={onFieldConfigsChange}
/* hasSeries makes sure current data is there */ /* 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 // When the panel has no query only show the main tab
...@@ -106,7 +106,7 @@ export const OptionsPaneContent: React.FC<Props> = ({ ...@@ -106,7 +106,7 @@ export const OptionsPaneContent: React.FC<Props> = ({
panel={panel} panel={panel}
plugin={plugin} plugin={plugin}
dashboard={dashboard} dashboard={dashboard}
data={currentData} data={data}
onPanelConfigChange={onPanelConfigChange} onPanelConfigChange={onPanelConfigChange}
onPanelOptionsChanged={onPanelOptionsChanged} onPanelOptionsChanged={onPanelOptionsChanged}
/> />
......
import { PanelData } from '@grafana/data'; import { DataQueryError, LoadingState, PanelData } from '@grafana/data';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { PanelModel } from '../../state'; import { PanelModel } from '../../state';
import { Unsubscribable } from 'rxjs'; import { Unsubscribable } from 'rxjs';
import { GetDataOptions } from '../../state/PanelQueryRunner'; 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 querySubscription = useRef<Unsubscribable>(null);
const [latestData, setLatestData] = useState<PanelData>(null); const [latestData, setLatestData] = useState<PanelData>();
useEffect(() => { useEffect(() => {
querySubscription.current = panel querySubscription.current = panel
...@@ -18,15 +28,19 @@ export const usePanelLatestData = (panel: PanelModel, options: GetDataOptions): ...@@ -18,15 +28,19 @@ export const usePanelLatestData = (panel: PanelModel, options: GetDataOptions):
return () => { return () => {
if (querySubscription.current) { if (querySubscription.current) {
console.log('unsubscribing');
querySubscription.current.unsubscribe(); 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 [ return {
latestData, data: latestData,
// TODO: make this more clever, use PanelData.state error: latestData && latestData.error,
!!(latestData && latestData.series), isLoading: latestData ? latestData.state === LoadingState.Loading : true,
]; hasSeries: latestData ? !!latestData.series : false,
};
}; };
...@@ -27,7 +27,8 @@ import { ...@@ -27,7 +27,8 @@ import {
} from 'app/types'; } from 'app/types';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; 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 { SubMenu } from '../components/SubMenu/SubMenu';
import { cleanUpDashboardAndVariables } from '../state/actions'; import { cleanUpDashboardAndVariables } from '../state/actions';
import { cancelVariables } from '../../variables/state/actions'; import { cancelVariables } from '../../variables/state/actions';
......
...@@ -7,7 +7,7 @@ import { getLocationSrv } from '@grafana/runtime'; ...@@ -7,7 +7,7 @@ import { getLocationSrv } from '@grafana/runtime';
import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import templateSrv from 'app/features/templating/template_srv'; import templateSrv from 'app/features/templating/template_srv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { InspectTab } from '../../components/Inspector/PanelInspector'; import { InspectTab } from '../../components/Inspector/types';
enum InfoMode { enum InfoMode {
Error = 'Error', Error = 'Error',
......
...@@ -298,4 +298,48 @@ describe('PanelQueryRunner', () => { ...@@ -298,4 +298,48 @@ describe('PanelQueryRunner', () => {
getTransformations: () => [({} as unknown) as DataTransformerConfig], 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