mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
4a30c65a5e
commit
e5d21461a0
@ -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.
|
||||
|
@ -0,0 +1,100 @@
|
||||
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>
|
||||
</QueryOperationRow>
|
||||
</Container>
|
||||
</VerticalGroup>
|
||||
</Container>
|
||||
|
||||
<div className={styles.options}>
|
||||
<Button variant="primary" onClick={() => this.exportCsv(dataFrames[dataFrameIndex])}>
|
||||
Download CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,22 @@
|
||||
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>;
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
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} />;
|
||||
};
|
@ -0,0 +1,45 @@
|
||||
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>
|
||||
);
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
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 '';
|
||||
}
|
||||
|
@ -1,34 +1,16 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
import { connect, MapStateToProps } from 'react-redux';
|
||||
import { InspectSubtitle } from './InspectSubtitle';
|
||||
import { InspectJSONTab } from './InspectJSONTab';
|
||||
import { QueryInspector } from './QueryInspector';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { connect, MapStateToProps, useDispatch } from 'react-redux';
|
||||
|
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||
import { CustomScrollbar, Drawer, JSONFormatter, TabContent } from '@grafana/ui';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
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 { PanelPlugin } from '@grafana/data';
|
||||
import { StoreState } from 'app/types';
|
||||
import { InspectDataTab } from './InspectDataTab';
|
||||
import { supportsDataQuery } from '../PanelEditor/utils';
|
||||
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 {
|
||||
dashboard: DashboardModel;
|
||||
@ -42,337 +24,44 @@ export interface ConnectedProps {
|
||||
|
||||
export type Props = OwnProps & ConnectedProps;
|
||||
|
||||
export enum InspectTab {
|
||||
Data = 'data',
|
||||
Meta = 'meta', // When result metadata exists
|
||||
Error = 'error',
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.querySubscription) {
|
||||
this.querySubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
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) {
|
||||
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 (
|
||||
<Drawer
|
||||
title={`Inspect: ${panel.title}` || 'Panel inspect'}
|
||||
subtitle={this.drawerSubtitle(tabs, activeTab)}
|
||||
width={drawerWidth}
|
||||
onClose={this.onClose}
|
||||
expandable
|
||||
>
|
||||
{activeTab === InspectTab.Data && this.renderDataTab()}
|
||||
<CustomScrollbar autoHeightMin="100%">
|
||||
<TabContent className={styles.tabContent}>
|
||||
{activeTab === InspectTab.Meta && this.renderMetadataInspector()}
|
||||
{activeTab === InspectTab.JSON && (
|
||||
<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,
|
||||
const PanelInspectorUnconnected: React.FC<Props> = ({ panel, dashboard, defaultTab, plugin }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [dataOptions, setDataOptions] = useState<GetDataOptions>({
|
||||
withTransforms: false,
|
||||
withFieldConfig: false,
|
||||
});
|
||||
return formattedValueToString(display(stat.value));
|
||||
}
|
||||
const { data, isLoading, error } = usePanelLatestData(panel, dataOptions);
|
||||
const metaDs = useDatasourceMetadata(data);
|
||||
const tabs = useInspectTabs(plugin, dashboard, error, metaDs);
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(
|
||||
updateLocation({
|
||||
query: { inspect: null, inspectTab: null },
|
||||
partial: true,
|
||||
})
|
||||
);
|
||||
}, [updateLocation]);
|
||||
|
||||
if (!plugin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InspectContent
|
||||
dashboard={dashboard}
|
||||
panel={panel}
|
||||
plugin={plugin}
|
||||
defaultTab={defaultTab}
|
||||
tabs={tabs}
|
||||
data={data}
|
||||
isDataLoading={isLoading}
|
||||
dataOptions={dataOptions}
|
||||
onDataOptionsChange={setDataOptions}
|
||||
metadataDatasource={metaDs}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
|
||||
const panelState = state.dashboard.panels[props.panel.id];
|
||||
|
64
public/app/features/dashboard/components/Inspector/hooks.ts
Normal file
64
public/app/features/dashboard/components/Inspector/hooks.ts
Normal file
@ -0,0 +1,64 @@
|
||||
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]);
|
||||
};
|
@ -0,0 +1,8 @@
|
||||
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}
|
||||
/>
|
||||
|
@ -1,12 +1,22 @@
|
||||
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: () => [{}],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user