DashboardScene: Inspect panel data tab (#74646)

* Refactor data tab to be usable from scenes

* DashboardScene: Inspect data tab

* Everything seem to work now

* don't change drawer size in this PR

* Remove uncommented code

* Fix layout issues for data actions

* Added comment explaining retry
This commit is contained in:
Torkel Ödegaard 2023-09-11 16:11:22 +02:00 committed by GitHub
parent c8a0ebe0e8
commit 73a675af02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 183 additions and 136 deletions

View File

@ -3,6 +3,7 @@ import React, { MouseEventHandler } from 'react';
import { DraggableProvided } from 'react-beautiful-dnd';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Icon, IconButton, useStyles2 } from '@grafana/ui';
export interface QueryOperationRowHeaderProps {
@ -58,7 +59,7 @@ export const QueryOperationRowHeader = ({
{headerElement}
</div>
<div className={styles.column}>
<Stack gap={1} alignItems="center" wrap={false}>
{actionsElement}
{draggable && (
<Icon
@ -70,7 +71,7 @@ export const QueryOperationRowHeader = ({
{...dragHandleProps}
/>
)}
</div>
</Stack>
</div>
);
};

View File

@ -1,20 +1,77 @@
import React from 'react';
import { SceneComponentProps, SceneObjectBase, VizPanel } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { LoadingState } from '@grafana/data';
import {
SceneComponentProps,
SceneDataProvider,
SceneDataTransformer,
sceneGraph,
SceneObjectBase,
} from '@grafana/scenes';
import { GetDataOptions } from 'app/features/query/state/PanelQueryRunner';
import { InspectTab } from '../../inspector/types';
import { InspectDataTab as InspectDataTabOld } from '../../inspector/InspectDataTab';
import { InspectTabState } from './types';
export class InspectDataTab extends SceneObjectBase<InspectTabState> {
constructor(public panel: VizPanel) {
super({ label: t('dashboard.inspect.data-tab', 'Data'), value: InspectTab.Data });
export interface InspectDataTabState extends InspectTabState {
options: GetDataOptions;
}
export class InspectDataTab extends SceneObjectBase<InspectDataTabState> {
public constructor(state: Omit<InspectDataTabState, 'options'>) {
super({
...state,
options: {
withTransforms: true,
withFieldConfig: true,
},
});
}
static Component = ({ model }: SceneComponentProps<InspectDataTab>) => {
//const data = sceneGraph.getData(model.panel).useState();
public onOptionsChange = (options: GetDataOptions) => {
this.setState({ options });
};
return <div>Data tab</div>;
static Component = ({ model }: SceneComponentProps<InspectDataTab>) => {
const { options } = model.useState();
const panel = model.state.panelRef.resolve();
const dataProvider = sceneGraph.getData(panel);
const { data } = getDataProviderToSubscribeTo(dataProvider, options.withTransforms).useState();
const timeRange = sceneGraph.getTimeRange(panel);
if (!data) {
<div>No data found</div>;
}
return (
<InspectDataTabOld
isLoading={data?.state === LoadingState.Loading}
data={data?.series}
options={options}
hasTransformations={hasTransformations(dataProvider)}
timeZone={timeRange.getTimeZone()}
panelPluginId={panel.state.pluginId}
dataName={panel.state.title}
fieldConfig={panel.state.fieldConfig}
onOptionsChange={model.onOptionsChange}
/>
);
};
}
function hasTransformations(dataProvider: SceneDataProvider) {
if (dataProvider instanceof SceneDataTransformer) {
return dataProvider.state.transformations.length > 0;
}
return false;
}
function getDataProviderToSubscribeTo(dataProvider: SceneDataProvider, withTransforms: boolean) {
if (withTransforms && dataProvider instanceof SceneDataTransformer) {
return dataProvider.state.$data!;
}
return dataProvider;
}

View File

@ -1,17 +1,10 @@
import React from 'react';
import { SceneComponentProps, SceneObjectBase, VizPanel } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { InspectTab } from '../../inspector/types';
import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
import { InspectTabState } from './types';
export class InspectJsonTab extends SceneObjectBase<InspectTabState> {
constructor(public panel: VizPanel) {
super({ label: t('dashboard.inspect.json-tab', 'JSON'), value: InspectTab.JSON });
}
static Component = ({ model }: SceneComponentProps<InspectJsonTab>) => {
return <div>JSON</div>;
};

View File

@ -1,21 +1,15 @@
import React from 'react';
import { SceneComponentProps, sceneGraph, SceneObjectBase, VizPanel } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { SceneComponentProps, sceneGraph, SceneObjectBase } from '@grafana/scenes';
import { InspectStatsTab as OldInspectStatsTab } from '../../inspector/InspectStatsTab';
import { InspectTab } from '../../inspector/types';
import { InspectTabState } from './types';
export class InspectStatsTab extends SceneObjectBase<InspectTabState> {
constructor(public panel: VizPanel) {
super({ label: t('dashboard.inspect.stats-tab', 'Stats'), value: InspectTab.Stats });
}
static Component = ({ model }: SceneComponentProps<InspectStatsTab>) => {
const data = sceneGraph.getData(model.panel).useState();
const timeRange = sceneGraph.getTimeRange(model.panel);
const data = sceneGraph.getData(model.state.panelRef.resolve()).useState();
const timeRange = sceneGraph.getTimeRange(model.state.panelRef.resolve());
if (!data.data) {
return null;

View File

@ -12,8 +12,10 @@ import {
VizPanel,
SceneObjectRef,
} from '@grafana/scenes';
import { Drawer, Tab, TabsBar } from '@grafana/ui';
import { Alert, Drawer, Tab, TabsBar } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { supportsDataQuery } from 'app/features/dashboard/components/PanelEditor/utils';
import { InspectTab } from 'app/features/inspector/types';
import { InspectDataTab } from './InspectDataTab';
import { InspectJsonTab } from './InspectJsonTab';
@ -23,6 +25,7 @@ import { InspectTabState } from './types';
interface PanelInspectDrawerState extends SceneObjectState {
tabs?: Array<SceneObject<InspectTabState>>;
panelRef: SceneObjectRef<VizPanel>;
pluginNotLoaded?: boolean;
}
export class PanelInspectDrawer extends SceneObjectBase<PanelInspectDrawerState> {
@ -31,22 +34,35 @@ export class PanelInspectDrawer extends SceneObjectBase<PanelInspectDrawerState>
constructor(state: PanelInspectDrawerState) {
super(state);
this.buildTabs();
this.buildTabs(0);
}
buildTabs() {
const panel = this.state.panelRef.resolve();
/**
* We currently have no async await to get the panel plugin from the VizPanel.
* That is why there is a retry argument here and a setTimeout, to try again a bit later.
*/
buildTabs(retry: number) {
const panelRef = this.state.panelRef;
const panel = panelRef.resolve();
const plugin = panel.getPlugin();
const tabs: Array<SceneObject<InspectTabState>> = [];
if (plugin) {
if (supportsDataQuery(plugin)) {
tabs.push(new InspectDataTab(panel));
tabs.push(new InspectStatsTab(panel));
tabs.push(
new InspectDataTab({ panelRef, label: t('dashboard.inspect.data-tab', 'Data'), value: InspectTab.Data })
);
tabs.push(
new InspectStatsTab({ panelRef, label: t('dashboard.inspect.stats-tab', 'Stats'), value: InspectTab.Stats })
);
}
} else if (retry < 2000) {
setTimeout(() => this.buildTabs(retry + 100), 100);
} else {
this.setState({ pluginNotLoaded: true });
}
tabs.push(new InspectJsonTab(panel));
tabs.push(new InspectJsonTab({ panelRef, label: t('dashboard.inspect.json-tab', 'JSON'), value: InspectTab.JSON }));
this.setState({ tabs });
}
@ -62,7 +78,7 @@ export class PanelInspectDrawer extends SceneObjectBase<PanelInspectDrawerState>
}
function PanelInspectRenderer({ model }: SceneComponentProps<PanelInspectDrawer>) {
const { tabs } = model.useState();
const { tabs, pluginNotLoaded } = model.useState();
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
@ -78,7 +94,7 @@ function PanelInspectRenderer({ model }: SceneComponentProps<PanelInspectDrawer>
title={model.getDrawerTitle()}
scrollableContent
onClose={model.onClose}
size="md"
size="lg"
tabs={
<TabsBar>
{tabs.map((tab) => {
@ -94,6 +110,11 @@ function PanelInspectRenderer({ model }: SceneComponentProps<PanelInspectDrawer>
</TabsBar>
}
>
{pluginNotLoaded && (
<Alert title="Panel plugin not loaded">
Make sure the panel you want to inspect is visible and has been displayed before opening inspect.
</Alert>
)}
{currentTab.Component && <currentTab.Component model={currentTab} />}
</Drawer>
);

View File

@ -1,7 +1,8 @@
import { SceneObjectState } from '@grafana/scenes';
import { SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes';
import { InspectTab } from 'app/features/inspector/types';
export interface InspectTabState extends SceneObjectState {
label: string;
value: InspectTab;
panelRef: SceneObjectRef<VizPanel>;
}

View File

@ -88,7 +88,10 @@ export const InspectContent = ({
>
{activeTab === InspectTab.Data && (
<InspectDataTab
panel={panel}
dataName={panel.getDisplayTitle()}
panelPluginId={panel.type}
fieldConfig={panel.fieldConfig}
hasTransformations={Boolean(panel.transformations?.length)}
data={data && data.series}
isLoading={isDataLoading}
options={dataOptions}

View File

@ -3,7 +3,7 @@ import { useToggle } from 'react-use';
import { DataFrame, DataTransformerConfig, TransformerRegistryItem, FrameMatcherID } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { ConfirmModal, HorizontalGroup } from '@grafana/ui';
import { ConfirmModal } from '@grafana/ui';
import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp';
import {
QueryOperationAction,
@ -103,7 +103,7 @@ export const TransformationOperationRow = ({
const renderActions = ({ isOpen }: QueryOperationRowRenderProps) => {
return (
<HorizontalGroup align="center" width="auto">
<>
{uiConfig.state && <PluginStateInfo state={uiConfig.state} />}
<QueryOperationToggleAction
title="Show transform help"
@ -152,7 +152,7 @@ export const TransformationOperationRow = ({
onDismiss={() => setShowDeleteModal(false)}
/>
)}
</HorizontalGroup>
</>
);
};

View File

@ -56,6 +56,7 @@ export function ExploreQueryInspector(props: Props) {
content: (
<InspectDataTab
data={dataFrames}
dataName={'Explore'}
isLoading={loading}
options={{ withTransforms: false, withFieldConfig: false }}
timeZone={timeZone}

View File

@ -4,7 +4,6 @@ import { DataFrame, DataTransformerID, getFrameDisplayName, SelectableValue } fr
import { Field, HorizontalGroup, Select, Switch, VerticalGroup, useStyles2 } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { t } from 'app/core/internationalization';
import { PanelModel } from 'app/features/dashboard/state';
import { DetailText } from 'app/features/inspector/DetailText';
import { GetDataOptions } from 'app/features/query/state/PanelQueryRunner';
@ -13,24 +12,24 @@ import { getPanelInspectorStyles2 } from './styles';
interface Props {
options: GetDataOptions;
dataFrames: DataFrame[];
transformId: DataTransformerID;
transformationOptions: Array<SelectableValue<DataTransformerID>>;
selectedDataFrame: number | DataTransformerID;
downloadForExcel: boolean;
onDataFrameChange: (item: SelectableValue<DataTransformerID | number>) => void;
toggleDownloadForExcel: () => void;
data?: DataFrame[];
panel?: PanelModel;
hasTransformations?: boolean;
onOptionsChange?: (options: GetDataOptions) => void;
actions?: React.ReactNode;
}
export const InspectDataOptions = ({
options,
actions,
onOptionsChange,
panel,
hasTransformations,
data,
dataFrames,
transformId,
transformationOptions,
selectedDataFrame,
onDataFrameChange,
@ -39,10 +38,6 @@ export const InspectDataOptions = ({
}: Props) => {
const styles = useStyles2(getPanelInspectorStyles2);
const panelTransformations = panel?.getTransformations();
const showPanelTransformationsOption = Boolean(panelTransformations?.length);
const showFieldConfigsOption = panel && !panel.plugin?.fieldConfigRegistry.isEmpty();
let dataSelect = dataFrames;
if (selectedDataFrame === DataTransformerID.joinByField) {
dataSelect = data!;
@ -100,6 +95,7 @@ export const InspectDataOptions = ({
title={t('dashboard.inspect-data.data-options', 'Data options')}
headerElement={<DetailText>{getActiveString()}</DetailText>}
isOpen={false}
actions={actions}
>
<div className={styles.options} data-testid="dataOptions">
<VerticalGroup spacing="none">
@ -116,7 +112,7 @@ export const InspectDataOptions = ({
)}
<HorizontalGroup>
{showPanelTransformationsOption && onOptionsChange && (
{hasTransformations && onOptionsChange && (
<Field
label={t('dashboard.inspect-data.transformations-label', 'Apply panel transformations')}
description={t(
@ -130,7 +126,7 @@ export const InspectDataOptions = ({
/>
</Field>
)}
{showFieldConfigsOption && onOptionsChange && (
{onOptionsChange && (
<Field
label={t('dashboard.inspect-data.formatted-data-label', 'Formatted data')}
description={t(

View File

@ -1,4 +1,3 @@
import { css } from '@emotion/css';
import { cloneDeep } from 'lodash';
import React, { PureComponent } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
@ -7,7 +6,6 @@ import {
applyFieldOverrides,
applyRawFieldOverrides,
CoreApp,
CSVConfig,
DataFrame,
DataTransformerID,
FieldConfigSource,
@ -20,7 +18,6 @@ import { reportInteraction } from '@grafana/runtime';
import { Button, Spinner, Table } from '@grafana/ui';
import { config } from 'app/core/config';
import { t, Trans } from 'app/core/internationalization';
import { PanelModel } from 'app/features/dashboard/state';
import { GetDataOptions } from 'app/features/query/state/PanelQueryRunner';
import { dataFrameToLogsModel } from '../logs/logsModel';
@ -35,7 +32,11 @@ interface Props {
timeZone: TimeZone;
app?: CoreApp;
data?: DataFrame[];
panel?: PanelModel;
/** The title of the panel or other context name */
dataName: string;
panelPluginId?: string;
fieldConfig?: FieldConfigSource;
hasTransformations?: boolean;
onOptionsChange?: (options: GetDataOptions) => void;
}
@ -91,15 +92,20 @@ export class InspectDataTab extends PureComponent<Props, State> {
}
}
exportCsv = (dataFrame: DataFrame, csvConfig: CSVConfig = {}) => {
const { panel } = this.props;
exportCsv(dataFrames: DataFrame[], hasLogs: boolean) {
const { dataName } = this.props;
const { transformId } = this.state;
const dataFrame = dataFrames[this.state.dataFrameIndex];
downloadDataFrameAsCsv(dataFrame, panel ? panel.getDisplayTitle() : 'Explore', csvConfig, transformId);
};
if (hasLogs) {
reportInteraction('grafana_logs_download_clicked', { app: this.props.app, format: 'csv' });
}
exportLogsAsTxt = () => {
const { data, panel, app } = this.props;
downloadDataFrameAsCsv(dataFrame, dataName, { useExcelHeader: this.state.downloadForExcel }, transformId);
}
onExportLogsAsTxt = () => {
const { data, dataName, app } = this.props;
reportInteraction('grafana_logs_download_logs_clicked', {
app,
@ -108,11 +114,11 @@ export class InspectDataTab extends PureComponent<Props, State> {
});
const logsModel = dataFrameToLogsModel(data || []);
downloadLogsModelAsTxt(logsModel, panel ? panel.getDisplayTitle() : 'Explore');
downloadLogsModelAsTxt(logsModel, dataName);
};
exportTracesAsJson = () => {
const { data, panel, app } = this.props;
onExportTracesAsJson = () => {
const { data, dataName, app } = this.props;
if (!data) {
return;
@ -123,7 +129,8 @@ export class InspectDataTab extends PureComponent<Props, State> {
if (df.meta?.preferredVisualisationType !== 'trace') {
continue;
}
const traceFormat = downloadTraceAsJson(df, (panel ? panel.getDisplayTitle() : 'Explore') + '-traces');
const traceFormat = downloadTraceAsJson(df, dataName + '-traces');
reportInteraction('grafana_traces_download_traces_clicked', {
app,
@ -134,8 +141,9 @@ export class InspectDataTab extends PureComponent<Props, State> {
}
};
exportServiceGraph = () => {
const { data, panel, app } = this.props;
onExportServiceGraph = () => {
const { data, dataName, app } = this.props;
reportInteraction('grafana_traces_download_service_graph_clicked', {
app,
grafana_version: config.buildInfo.version,
@ -146,7 +154,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
return;
}
downloadAsJson(data, panel ? panel.getDisplayTitle() : 'Explore');
downloadAsJson(data, dataName);
};
onDataFrameChange = (item: SelectableValue<DataTransformerID | number>) => {
@ -158,28 +166,28 @@ export class InspectDataTab extends PureComponent<Props, State> {
});
};
toggleDownloadForExcel = () => {
onToggleDownloadForExcel = () => {
this.setState((prevState) => ({
downloadForExcel: !prevState.downloadForExcel,
}));
};
getProcessedData(): DataFrame[] {
const { options, panel, timeZone } = this.props;
const { options, panelPluginId, fieldConfig, timeZone } = this.props;
const data = this.state.transformedData;
if (!options.withFieldConfig || !panel) {
if (!options.withFieldConfig || !panelPluginId || !fieldConfig) {
return applyRawFieldOverrides(data);
}
const fieldConfig = this.cleanTableConfigFromFieldConfig(panel.type, panel.fieldConfig);
const fieldConfigCleaned = this.cleanTableConfigFromFieldConfig(panelPluginId, fieldConfig);
// We need to apply field config as it's not done by PanelQueryRunner (even when withFieldConfig is true).
// It's because transformers create new fields and data frames, and we need to clean field config of any table settings.
return applyFieldOverrides({
data,
theme: config.theme2,
fieldConfig,
fieldConfig: fieldConfigCleaned,
timeZone,
replaceVariables: (value: string) => {
return value;
@ -210,9 +218,34 @@ export class InspectDataTab extends PureComponent<Props, State> {
return fieldConfig;
}
renderActions(dataFrames: DataFrame[], hasLogs: boolean, hasTraces: boolean, hasServiceGraph: boolean) {
return (
<>
<Button variant="primary" onClick={() => this.exportCsv(dataFrames, hasLogs)} size="sm">
<Trans i18nKey="dashboard.inspect-data.download-csv">Download CSV</Trans>
</Button>
{hasLogs && (
<Button variant="primary" onClick={this.onExportLogsAsTxt} size="sm">
<Trans i18nKey="dashboard.inspect-data.download-logs">Download logs</Trans>
</Button>
)}
{hasTraces && (
<Button variant="primary" onClick={this.onExportTracesAsJson} size="sm">
<Trans i18nKey="dashboard.inspect-data.download-traces">Download traces</Trans>
</Button>
)}
{hasServiceGraph && (
<Button variant="primary" onClick={this.onExportServiceGraph} size="sm">
<Trans i18nKey="dashboard.inspect-data.download-service">Download service graph</Trans>
</Button>
)}
</>
);
}
render() {
const { isLoading, options, data, panel, onOptionsChange, app } = this.props;
const { dataFrameIndex, transformId, transformationOptions, selectedDataFrame, downloadForExcel } = this.state;
const { isLoading, options, data, onOptionsChange, hasTransformations } = this.props;
const { dataFrameIndex, transformationOptions, selectedDataFrame, downloadForExcel } = this.state;
const styles = getPanelInspectorStyles();
if (isLoading) {
@ -241,70 +274,17 @@ export class InspectDataTab extends PureComponent<Props, State> {
<div className={styles.toolbar}>
<InspectDataOptions
data={data}
panel={panel}
hasTransformations={hasTransformations}
options={options}
dataFrames={dataFrames}
transformId={transformId}
transformationOptions={transformationOptions}
selectedDataFrame={selectedDataFrame}
downloadForExcel={downloadForExcel}
onOptionsChange={onOptionsChange}
onDataFrameChange={this.onDataFrameChange}
toggleDownloadForExcel={this.toggleDownloadForExcel}
toggleDownloadForExcel={this.onToggleDownloadForExcel}
actions={this.renderActions(dataFrames, hasLogs, hasTraces, hasServiceGraph)}
/>
<Button
variant="primary"
onClick={() => {
if (hasLogs) {
reportInteraction('grafana_logs_download_clicked', {
app,
format: 'csv',
});
}
this.exportCsv(dataFrames[dataFrameIndex], { useExcelHeader: this.state.downloadForExcel });
}}
className={css`
margin-bottom: 10px;
`}
>
<Trans i18nKey="dashboard.inspect-data.download-csv">Download CSV</Trans>
</Button>
{hasLogs && (
<Button
variant="primary"
onClick={this.exportLogsAsTxt}
className={css`
margin-bottom: 10px;
margin-left: 10px;
`}
>
<Trans i18nKey="dashboard.inspect-data.download-logs">Download logs</Trans>
</Button>
)}
{hasTraces && (
<Button
variant="primary"
onClick={this.exportTracesAsJson}
className={css`
margin-bottom: 10px;
margin-left: 10px;
`}
>
<Trans i18nKey="dashboard.inspect-data.download-traces">Download traces</Trans>
</Button>
)}
{hasServiceGraph && (
<Button
variant="primary"
onClick={this.exportServiceGraph}
className={css`
margin-bottom: 10px;
margin-left: 10px;
`}
>
<Trans i18nKey="dashboard.inspect-data.download-service">Download service graph</Trans>
</Button>
)}
</div>
<div className={styles.content}>
<AutoSizer>

View File

@ -23,7 +23,7 @@ import {
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { AngularComponent, getAngularLoader, getDataSourceSrv } from '@grafana/runtime';
import { Badge, ErrorBoundaryAlert, HorizontalGroup } from '@grafana/ui';
import { Badge, ErrorBoundaryAlert } from '@grafana/ui';
import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp';
import {
QueryOperationAction,
@ -439,7 +439,7 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop
const hasEditorHelp = datasource?.components?.QueryEditorHelp;
return (
<HorizontalGroup width="auto">
<>
{hasEditorHelp && (
<QueryOperationToggleAction
title="Show data source help"
@ -468,7 +468,7 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop
/>
) : null}
<QueryOperationAction title="Remove query" icon="trash-alt" onClick={this.onRemoveQuery} />
</HorizontalGroup>
</>
);
};