mirror of https://github.com/grafana/grafana.git
Explore Metrics: Add button to add to exploration (#95637)
* Explore Metrics: Add button to add to exploration * feat: enable explorations for breakdown panels * refactor: types * refactor: code should reflect single expected link * test: exploration button basics * refactor: leverage link limit * feat: add exploration button to more panels * chore: update betterer results * test: refactor for clarity * test: prefer consistent approach --------- Co-authored-by: Nick Richmond <nick.richmond@grafana.com>
This commit is contained in:
parent
e6a771cf4a
commit
8d4db7ac85
|
@ -1,13 +1,15 @@
|
|||
import { SceneObjectState, SceneObjectBase, SceneComponentProps, VizPanel, SceneQueryRunner } from '@grafana/scenes';
|
||||
import { RadioButtonGroup } from '@grafana/ui';
|
||||
|
||||
import { AddToExplorationButton } from '../MetricSelect/AddToExplorationsButton';
|
||||
import { MDP_METRIC_OVERVIEW, trailDS } from '../shared';
|
||||
import { getMetricSceneFor } from '../utils';
|
||||
|
||||
import { AutoVizPanelQuerySelector } from './AutoVizPanelQuerySelector';
|
||||
import { AutoQueryDef } from './types';
|
||||
|
||||
export interface AutoVizPanelState extends SceneObjectState {
|
||||
panel?: VizPanel;
|
||||
metric?: string;
|
||||
}
|
||||
|
||||
export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> {
|
||||
|
@ -19,24 +21,12 @@ export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> {
|
|||
|
||||
public onActivate() {
|
||||
if (!this.state.panel) {
|
||||
const { autoQuery } = getMetricSceneFor(this).state;
|
||||
const { autoQuery, metric } = getMetricSceneFor(this).state;
|
||||
|
||||
this.setState({ panel: this.getVizPanelFor(autoQuery.main) });
|
||||
this.setState({ panel: this.getVizPanelFor(autoQuery.main, metric), metric });
|
||||
}
|
||||
}
|
||||
|
||||
private getQuerySelector(def: AutoQueryDef) {
|
||||
const { autoQuery } = getMetricSceneFor(this).state;
|
||||
|
||||
if (autoQuery.variants.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = autoQuery.variants.map((q) => ({ label: q.variant, value: q.variant }));
|
||||
|
||||
return <RadioButtonGroup size="sm" options={options} value={def.variant} onChange={this.onChangeQuery} />;
|
||||
}
|
||||
|
||||
public onChangeQuery = (variant: string) => {
|
||||
const metricScene = getMetricSceneFor(this);
|
||||
|
||||
|
@ -46,7 +36,7 @@ export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> {
|
|||
metricScene.setState({ queryDef: def });
|
||||
};
|
||||
|
||||
private getVizPanelFor(def: AutoQueryDef) {
|
||||
private getVizPanelFor(def: AutoQueryDef, metric?: string) {
|
||||
return def
|
||||
.vizBuilder()
|
||||
.setData(
|
||||
|
@ -56,7 +46,10 @@ export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> {
|
|||
queries: def.queries,
|
||||
})
|
||||
)
|
||||
.setHeaderActions(this.getQuerySelector(def))
|
||||
.setHeaderActions([
|
||||
new AutoVizPanelQuerySelector({ queryDef: def, onChangeQuery: this.onChangeQuery }),
|
||||
new AddToExplorationButton({ labelName: metric ?? this.state.metric }),
|
||||
])
|
||||
.build();
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import { SceneObjectState, SceneObjectBase, SceneComponentProps } from '@grafana/scenes';
|
||||
import { RadioButtonGroup } from '@grafana/ui';
|
||||
|
||||
import { getMetricSceneFor } from '../utils';
|
||||
|
||||
import { AutoQueryDef } from './types';
|
||||
|
||||
interface QuerySelectorState extends SceneObjectState {
|
||||
queryDef: AutoQueryDef;
|
||||
onChangeQuery: (variant: string) => void;
|
||||
options?: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class AutoVizPanelQuerySelector extends SceneObjectBase<QuerySelectorState> {
|
||||
constructor(state: QuerySelectorState) {
|
||||
super(state);
|
||||
this.addActivationHandler(this._onActivate.bind(this));
|
||||
}
|
||||
|
||||
private _onActivate() {
|
||||
const { autoQuery } = getMetricSceneFor(this).state;
|
||||
|
||||
if (autoQuery.variants.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ options: autoQuery.variants.map((q) => ({ label: q.variant, value: q.variant })) });
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<AutoVizPanelQuerySelector>) => {
|
||||
const { options, onChangeQuery, queryDef } = model.useState();
|
||||
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RadioButtonGroup size="sm" options={options} value={queryDef.variant} onChange={onChangeQuery} />;
|
||||
};
|
||||
}
|
|
@ -34,6 +34,7 @@ import { AutoQueryDef } from '../AutomaticMetricQueries/types';
|
|||
import { BreakdownLabelSelector } from '../BreakdownLabelSelector';
|
||||
import { DataTrail } from '../DataTrail';
|
||||
import { MetricScene } from '../MetricScene';
|
||||
import { AddToExplorationButton } from '../MetricSelect/AddToExplorationsButton';
|
||||
import { StatusWrapper } from '../StatusWrapper';
|
||||
import { reportExploreMetrics } from '../interactions';
|
||||
import { updateOtelJoinWithGroupLeft } from '../otel/util';
|
||||
|
@ -439,7 +440,10 @@ export function buildAllLayout(
|
|||
],
|
||||
})
|
||||
)
|
||||
.setHeaderActions(new SelectLabelAction({ labelName: String(option.value) }))
|
||||
.setHeaderActions([
|
||||
new SelectLabelAction({ labelName: String(option.value) }),
|
||||
new AddToExplorationButton({ labelName: String(option.value) }),
|
||||
])
|
||||
.setUnit(unit)
|
||||
.setBehaviors([fixLegendForUnspecifiedLabelValueBehavior])
|
||||
.build();
|
||||
|
@ -490,7 +494,10 @@ function buildNormalLayout(
|
|||
.setTitle(getLabelValue(frame))
|
||||
.setData(new SceneDataNode({ data: { ...data, series: [frame] } }))
|
||||
.setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) })
|
||||
.setHeaderActions(new AddToFiltersGraphAction({ frame }))
|
||||
.setHeaderActions([
|
||||
new AddToFiltersGraphAction({ frame }),
|
||||
new AddToExplorationButton({ labelName: getLabelValue(frame) }),
|
||||
])
|
||||
.setUnit(unit)
|
||||
.build();
|
||||
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { PluginExtensionTypes } from '@grafana/data';
|
||||
import { setPluginLinksHook } from '@grafana/runtime';
|
||||
|
||||
import { mockPluginLinkExtension } from '../../alerting/unified/mocks';
|
||||
|
||||
import { AddToExplorationButton, addToExplorationsButtonLabel, explorationsPluginId } from './AddToExplorationsButton';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
setPluginExtensionGetter: jest.fn(),
|
||||
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
||||
useChromeHeaderHeight: jest.fn().mockReturnValue(80),
|
||||
getBackendSrv: () => {
|
||||
return {
|
||||
get: jest.fn(),
|
||||
};
|
||||
},
|
||||
getDataSourceSrv: () => {
|
||||
return {
|
||||
get: jest.fn().mockResolvedValue({}),
|
||||
getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }),
|
||||
};
|
||||
},
|
||||
getAppEvents: () => ({
|
||||
publish: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AddToExplorationButton', () => {
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("shouldn't render when a plugin extension link isn't provided by the Explorations app ", async () => {
|
||||
setPluginLinksHook(() => ({
|
||||
links: [],
|
||||
isLoading: false,
|
||||
}));
|
||||
const scene = new AddToExplorationButton({});
|
||||
render(<scene.Component model={scene} />);
|
||||
expect(() => screen.getByLabelText(addToExplorationsButtonLabel)).toThrow();
|
||||
});
|
||||
|
||||
it('should render when the Explorations app provides a plugin extension link', async () => {
|
||||
setPluginLinksHook(() => ({
|
||||
links: [
|
||||
mockPluginLinkExtension({
|
||||
description: addToExplorationsButtonLabel, // this overrides the aria-label
|
||||
onClick: () => {},
|
||||
path: '/a/grafana-explorations-app',
|
||||
pluginId: explorationsPluginId,
|
||||
title: 'Explorations',
|
||||
type: PluginExtensionTypes.link,
|
||||
}),
|
||||
],
|
||||
isLoading: false,
|
||||
}));
|
||||
const scene = new AddToExplorationButton({});
|
||||
render(<scene.Component model={scene} />);
|
||||
const button = screen.getByLabelText(addToExplorationsButtonLabel);
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,139 @@
|
|||
import { DataFrame, TimeRange } from '@grafana/data';
|
||||
import { usePluginLinks } from '@grafana/runtime';
|
||||
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState, SceneQueryRunner } from '@grafana/scenes';
|
||||
import { DataQuery, DataSourceRef } from '@grafana/schema';
|
||||
import { IconButton } from '@grafana/ui';
|
||||
|
||||
import MimirLogo from '../../../plugins/datasource/prometheus/img/mimir_logo.svg';
|
||||
import { VAR_DATASOURCE_EXPR } from '../shared';
|
||||
|
||||
export const explorationsPluginId = 'grafana-explorations-app';
|
||||
export const extensionPointId = 'grafana-explore-metrics/exploration/v1';
|
||||
export const addToExplorationsButtonLabel = 'add panel to exploration';
|
||||
|
||||
export interface AddToExplorationButtonState extends SceneObjectState {
|
||||
frame?: DataFrame;
|
||||
dsUid?: string;
|
||||
labelName?: string;
|
||||
fieldName?: string;
|
||||
context?: ExtensionContext;
|
||||
|
||||
disabledLinks: string[];
|
||||
queries: DataQuery[];
|
||||
}
|
||||
|
||||
interface ExtensionContext {
|
||||
timeRange: TimeRange;
|
||||
queries: DataQuery[];
|
||||
datasource: DataSourceRef;
|
||||
origin: string;
|
||||
url: string;
|
||||
type: string;
|
||||
title: string;
|
||||
id: string;
|
||||
logoPath: string;
|
||||
note?: string;
|
||||
drillDownLabel?: string;
|
||||
}
|
||||
|
||||
export class AddToExplorationButton extends SceneObjectBase<AddToExplorationButtonState> {
|
||||
constructor(state: Omit<AddToExplorationButtonState, 'disabledLinks' | 'queries'>) {
|
||||
super({ ...state, disabledLinks: [], queries: [] });
|
||||
|
||||
this.addActivationHandler(this._onActivate.bind(this));
|
||||
}
|
||||
|
||||
private _onActivate = () => {
|
||||
this._subs.add(
|
||||
this.subscribeToState(() => {
|
||||
this.getQueries();
|
||||
this.getContext();
|
||||
})
|
||||
);
|
||||
|
||||
const datasourceUid = sceneGraph.interpolate(this, VAR_DATASOURCE_EXPR);
|
||||
this.setState({ dsUid: datasourceUid });
|
||||
};
|
||||
|
||||
private readonly getQueries = () => {
|
||||
const data = sceneGraph.getData(this);
|
||||
const queryRunner = sceneGraph.findObject(data, isQueryRunner);
|
||||
|
||||
if (isQueryRunner(queryRunner)) {
|
||||
const filter = this.state.frame ? getFilter(this.state.frame) : null;
|
||||
const queries = queryRunner.state.queries.map((q) => ({
|
||||
...q,
|
||||
expr: sceneGraph.interpolate(queryRunner, q.expr),
|
||||
legendFormat: filter?.name ? `{{ ${filter.name} }}` : sceneGraph.interpolate(queryRunner, q.legendFormat),
|
||||
}));
|
||||
|
||||
if (JSON.stringify(queries) !== JSON.stringify(this.state.queries)) {
|
||||
this.setState({ queries });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly getContext = () => {
|
||||
const { queries, dsUid, labelName, fieldName } = this.state;
|
||||
const timeRange = sceneGraph.getTimeRange(this);
|
||||
|
||||
if (!timeRange || !queries || !dsUid) {
|
||||
return;
|
||||
}
|
||||
const ctx = {
|
||||
origin: 'Explore Metrics',
|
||||
type: 'timeseries',
|
||||
queries,
|
||||
timeRange: { ...timeRange.state.value },
|
||||
datasource: { uid: dsUid },
|
||||
url: window.location.href,
|
||||
id: `${JSON.stringify(queries)}${labelName}${fieldName}`,
|
||||
title: `${labelName}${fieldName ? ` > ${fieldName}` : ''}`,
|
||||
logoPath: MimirLogo,
|
||||
drillDownLabel: fieldName,
|
||||
};
|
||||
if (JSON.stringify(ctx) !== JSON.stringify(this.state.context)) {
|
||||
this.setState({ context: ctx });
|
||||
}
|
||||
};
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<AddToExplorationButton>) => {
|
||||
const { context, disabledLinks } = model.useState();
|
||||
const { links } = usePluginLinks({ extensionPointId, context, limitPerPlugin: 1 });
|
||||
const link = links.find((link) => link.pluginId === explorationsPluginId);
|
||||
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
tooltip={link.description}
|
||||
disabled={link.category === 'disabled' || disabledLinks.includes(link.id)}
|
||||
aria-label={addToExplorationsButtonLabel} // this is overriden by the `tooltip`
|
||||
key={link.id}
|
||||
name={link.icon ?? 'panel-add'}
|
||||
onClick={(e) => {
|
||||
if (link.onClick) {
|
||||
link.onClick(e);
|
||||
}
|
||||
model.setState({ disabledLinks: [...disabledLinks, link.id] });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const getFilter = (frame: DataFrame) => {
|
||||
const filterNameAndValueObj = frame.fields[1]?.labels ?? {};
|
||||
const keys = Object.keys(filterNameAndValueObj);
|
||||
if (keys.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const name = keys[0];
|
||||
return { name, value: filterNameAndValueObj[name] };
|
||||
};
|
||||
|
||||
function isQueryRunner(o: unknown): o is SceneQueryRunner {
|
||||
return o instanceof SceneQueryRunner;
|
||||
}
|
|
@ -45,6 +45,7 @@ import {
|
|||
} from '../shared';
|
||||
import { getFilters, getTrailFor, isSceneTimeRangeState } from '../utils';
|
||||
|
||||
import { AddToExplorationButton } from './AddToExplorationsButton';
|
||||
import { SelectMetricAction } from './SelectMetricAction';
|
||||
import { getMetricNames } from './api';
|
||||
import { getPreviewPanelFor } from './previewPanel';
|
||||
|
@ -624,7 +625,10 @@ function getCardPanelFor(metric: string, description?: string) {
|
|||
return PanelBuilders.text()
|
||||
.setTitle(metric)
|
||||
.setDescription(description)
|
||||
.setHeaderActions(new SelectMetricAction({ metric, title: 'Select' }))
|
||||
.setHeaderActions([
|
||||
new SelectMetricAction({ metric, title: 'Select' }),
|
||||
new AddToExplorationButton({ labelName: metric }),
|
||||
])
|
||||
.setOption('content', '')
|
||||
.build();
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { getAutoQueriesForMetric } from '../AutomaticMetricQueries/AutoQueryEngi
|
|||
import { getVariablesWithMetricConstant, MDP_METRIC_PREVIEW, trailDS } from '../shared';
|
||||
import { getColorByIndex } from '../utils';
|
||||
|
||||
import { AddToExplorationButton } from './AddToExplorationsButton';
|
||||
import { SelectMetricAction } from './SelectMetricAction';
|
||||
import { hideEmptyPreviews } from './hideEmptyPreviews';
|
||||
|
||||
|
@ -15,7 +16,10 @@ export function getPreviewPanelFor(metric: string, index: number, currentFilterC
|
|||
.vizBuilder()
|
||||
.setColor({ mode: 'fixed', fixedColor: getColorByIndex(index) })
|
||||
.setDescription(description)
|
||||
.setHeaderActions(new SelectMetricAction({ metric, title: 'Select' }))
|
||||
.setHeaderActions([
|
||||
new SelectMetricAction({ metric, title: 'Select' }),
|
||||
new AddToExplorationButton({ labelName: metric }),
|
||||
])
|
||||
.build();
|
||||
|
||||
const queries = autoQuery.preview.queries.map((query) =>
|
||||
|
|
Loading…
Reference in New Issue