diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx index 9a6b164ca7f..4912922b8e4 100644 --- a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx +++ b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx @@ -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 { @@ -19,24 +21,12 @@ export class AutoVizPanel extends SceneObjectBase { 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 ; - } - public onChangeQuery = (variant: string) => { const metricScene = getMetricSceneFor(this); @@ -46,7 +36,7 @@ export class AutoVizPanel extends SceneObjectBase { 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 { queries: def.queries, }) ) - .setHeaderActions(this.getQuerySelector(def)) + .setHeaderActions([ + new AutoVizPanelQuerySelector({ queryDef: def, onChangeQuery: this.onChangeQuery }), + new AddToExplorationButton({ labelName: metric ?? this.state.metric }), + ]) .build(); } diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanelQuerySelector.tsx b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanelQuerySelector.tsx new file mode 100644 index 00000000000..4b31d552681 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanelQuerySelector.tsx @@ -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 { + 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) => { + const { options, onChangeQuery, queryDef } = model.useState(); + + if (!options) { + return null; + } + + return ; + }; +} diff --git a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx index 9e5b95bdbd4..0072bbafb85 100644 --- a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx +++ b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx @@ -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(); diff --git a/public/app/features/trails/MetricSelect/AddToExplorationsButton.test.tsx b/public/app/features/trails/MetricSelect/AddToExplorationsButton.test.tsx new file mode 100644 index 00000000000..071b97981ec --- /dev/null +++ b/public/app/features/trails/MetricSelect/AddToExplorationsButton.test.tsx @@ -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(); + 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(); + const button = screen.getByLabelText(addToExplorationsButtonLabel); + expect(button).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/trails/MetricSelect/AddToExplorationsButton.tsx b/public/app/features/trails/MetricSelect/AddToExplorationsButton.tsx new file mode 100644 index 00000000000..86fa38c6d6b --- /dev/null +++ b/public/app/features/trails/MetricSelect/AddToExplorationsButton.tsx @@ -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 { + constructor(state: Omit) { + 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) => { + 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 ( + { + 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; +} diff --git a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx index 69ad378f842..edaf14c9533 100644 --- a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx +++ b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx @@ -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(); } diff --git a/public/app/features/trails/MetricSelect/previewPanel.ts b/public/app/features/trails/MetricSelect/previewPanel.ts index eda861dfa2b..791c57ce949 100644 --- a/public/app/features/trails/MetricSelect/previewPanel.ts +++ b/public/app/features/trails/MetricSelect/previewPanel.ts @@ -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) =>