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:
Sven Grossmann 2024-11-21 05:13:11 +01:00 committed by GitHub
parent e6a771cf4a
commit 8d4db7ac85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 275 additions and 21 deletions

View File

@ -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();
}

View File

@ -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} />;
};
}

View File

@ -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();

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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) =>