From 63e8753aa0ebfc1b3d700dbf560147887c16feca Mon Sep 17 00:00:00 2001 From: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Mon, 18 Mar 2024 12:16:38 -0400 Subject: [PATCH] datatrails: integrate dashboard panels with metrics explore (#84521) * feat: integrate dashboard panels with metrics explore - add dashboard panel menu items (in non-scenes dashboard) to open `metric{filters}` entries detected from queries to launch "metrics explorer" drawers for the selected `metric{filter}` * fix: remove OpenEmbeddedTrailEvent * fix: use modal manager dismiss capabilities instead --- .../scene/PanelMenuBehavior.tsx | 2 +- .../features/dashboard/utils/getPanelMenu.ts | 5 + .../trails/ActionTabs/MetricOverviewScene.tsx | 36 +++--- .../app/features/trails/DataTrailDrawer.tsx | 67 ---------- .../trails/Integrations/DataTrailEmbedded.tsx | 37 ++++++ .../trails/Integrations/SceneDrawer.tsx | 46 +++++++ .../Integrations/dashboardIntegration.ts | 115 ++++++++++++++++++ .../trails/Integrations/getQueryMetrics.ts | 31 +++++ .../app/features/trails/Integrations/utils.ts | 45 +++++++ public/app/features/trails/MetricScene.tsx | 13 +- .../features/trails/dashboardIntegration.ts | 38 ------ public/app/features/trails/shared.ts | 6 +- 12 files changed, 306 insertions(+), 135 deletions(-) delete mode 100644 public/app/features/trails/DataTrailDrawer.tsx create mode 100644 public/app/features/trails/Integrations/DataTrailEmbedded.tsx create mode 100644 public/app/features/trails/Integrations/SceneDrawer.tsx create mode 100644 public/app/features/trails/Integrations/dashboardIntegration.ts create mode 100644 public/app/features/trails/Integrations/getQueryMetrics.ts create mode 100644 public/app/features/trails/Integrations/utils.ts delete mode 100644 public/app/features/trails/dashboardIntegration.ts diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 6ebf570080c..55e23353af9 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -17,7 +17,7 @@ import { shareDashboardType } from 'app/features/dashboard/components/ShareModal import { InspectTab } from 'app/features/inspector/types'; import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils'; -import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegration'; +import { addDataTrailPanelAction } from 'app/features/trails/Integrations/dashboardIntegration'; import { ShowConfirmModalEvent } from 'app/types/events'; import { ShareModal } from '../sharing/ShareModal'; diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index ddd8a149eb9..adeb45331da 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -31,6 +31,7 @@ import { DashboardInteractions } from 'app/features/dashboard-scene/utils/intera import { InspectTab } from 'app/features/inspector/types'; import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard'; import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils'; +import { addDataTrailPanelAction } from 'app/features/trails/Integrations/dashboardIntegration'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { dispatch, store } from 'app/store/store'; @@ -168,6 +169,10 @@ export function getPanelMenu( }); } + if (config.featureToggles.datatrails) { + addDataTrailPanelAction(dashboard, panel, menu); + } + const inspectMenu: PanelMenuItem[] = []; // Only show these inspect actions for data plugins diff --git a/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx b/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx index 5f469eb9daa..3c5eef35f14 100644 --- a/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx +++ b/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx @@ -16,7 +16,7 @@ import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { ALL_VARIABLE_VALUE } from '../../variables/constants'; import { StatusWrapper } from '../StatusWrapper'; import { TRAILS_ROUTE, VAR_DATASOURCE_EXPR, VAR_GROUP_BY } from '../shared'; -import { getMetricSceneFor } from '../utils'; +import { getMetricSceneFor, getTrailFor } from '../utils'; import { getLabelOptions } from './utils'; @@ -106,20 +106,26 @@ export class MetricOverviewScene extends SceneObjectBase Labels {labelOptions.length === 0 && 'Unable to fetch labels.'} - {labelOptions.map((l) => ( - - {l.label!} - - ))} + {labelOptions.map((l) => + getTrailFor(model).state.embedded ? ( + // Do not render as TextLink when in embedded mode, as any direct URL + // manipulation will take the browser out out of the current page. +
{l.label}
+ ) : ( + + {l.label!} + + ) + )} diff --git a/public/app/features/trails/DataTrailDrawer.tsx b/public/app/features/trails/DataTrailDrawer.tsx deleted file mode 100644 index 94c7bc2f19c..00000000000 --- a/public/app/features/trails/DataTrailDrawer.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; - -import { getDataSourceSrv } from '@grafana/runtime'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneTimeRangeLike } from '@grafana/scenes'; -import { DataSourceRef } from '@grafana/schema'; -import { Drawer } from '@grafana/ui'; -import { PromVisualQuery } from 'app/plugins/datasource/prometheus/querybuilder/types'; - -import { getDashboardSceneFor } from '../dashboard-scene/utils/utils'; - -import { DataTrail } from './DataTrail'; -import { getDataTrailsApp } from './DataTrailsApp'; -import { OpenEmbeddedTrailEvent } from './shared'; - -interface DataTrailDrawerState extends SceneObjectState { - timeRange: SceneTimeRangeLike; - query: PromVisualQuery; - dsRef: DataSourceRef; -} - -export class DataTrailDrawer extends SceneObjectBase { - static Component = DataTrailDrawerRenderer; - - public trail: DataTrail; - - constructor(state: DataTrailDrawerState) { - super(state); - - this.trail = buildDataTrailFromQuery(state); - this.trail.addActivationHandler(() => { - this.trail.subscribeToEvent(OpenEmbeddedTrailEvent, this.onOpenTrail); - }); - } - - onOpenTrail = () => { - getDataTrailsApp().goToUrlForTrail(this.trail.clone({ embedded: false })); - }; - - onClose = () => { - const dashboard = getDashboardSceneFor(this); - dashboard.closeModal(); - }; -} - -function DataTrailDrawerRenderer({ model }: SceneComponentProps) { - return ( - -
- -
-
- ); -} - -export function buildDataTrailFromQuery({ query, dsRef, timeRange }: DataTrailDrawerState) { - const filters = query.labels.map((label) => ({ key: label.label, value: label.value, operator: label.op })); - - const ds = getDataSourceSrv().getInstanceSettings(dsRef); - - return new DataTrail({ - $timeRange: timeRange, - metric: query.metric, - initialDS: ds?.uid, - initialFilters: filters, - embedded: true, - }); -} diff --git a/public/app/features/trails/Integrations/DataTrailEmbedded.tsx b/public/app/features/trails/Integrations/DataTrailEmbedded.tsx new file mode 100644 index 00000000000..67b4ae6312c --- /dev/null +++ b/public/app/features/trails/Integrations/DataTrailEmbedded.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { AdHocVariableFilter } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneTimeRangeLike } from '@grafana/scenes'; + +import { DataTrail } from '../DataTrail'; + +export interface DataTrailEmbeddedState extends SceneObjectState { + timeRange: SceneTimeRangeLike; + metric?: string; + filters?: AdHocVariableFilter[]; + dataSourceUid?: string; +} +export class DataTrailEmbedded extends SceneObjectBase { + static Component = DataTrailEmbeddedRenderer; + + public trail: DataTrail; + + constructor(state: DataTrailEmbeddedState) { + super(state); + this.trail = buildDataTrailFromState(state); + } +} + +function DataTrailEmbeddedRenderer({ model }: SceneComponentProps) { + return ; +} + +export function buildDataTrailFromState({ metric, filters, dataSourceUid, timeRange }: DataTrailEmbeddedState) { + return new DataTrail({ + $timeRange: timeRange, + metric, + initialDS: dataSourceUid, + initialFilters: filters, + embedded: true, + }); +} diff --git a/public/app/features/trails/Integrations/SceneDrawer.tsx b/public/app/features/trails/Integrations/SceneDrawer.tsx new file mode 100644 index 00000000000..b48fe048ef0 --- /dev/null +++ b/public/app/features/trails/Integrations/SceneDrawer.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { SceneComponentProps, SceneObjectBase, SceneObject, SceneObjectState } from '@grafana/scenes'; +import { Drawer } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { ShowModalReactEvent } from 'app/types/events'; + +export type SceneDrawerProps = { + scene: SceneObject; + title: string; + onDismiss: () => void; +}; + +export function SceneDrawer(props: SceneDrawerProps) { + const { scene, title, onDismiss } = props; + return ( + +
+ +
+
+ ); +} + +interface SceneDrawerAsSceneState extends SceneObjectState, SceneDrawerProps {} + +export class SceneDrawerAsScene extends SceneObjectBase { + constructor(state: SceneDrawerProps) { + super(state); + } + + static Component({ model }: SceneComponentProps) { + const state = model.useState(); + + return ; + } +} + +export function launchSceneDrawerInGlobalModal(props: Omit) { + const payload = { + component: SceneDrawer, + props, + }; + + appEvents.publish(new ShowModalReactEvent(payload)); +} diff --git a/public/app/features/trails/Integrations/dashboardIntegration.ts b/public/app/features/trails/Integrations/dashboardIntegration.ts new file mode 100644 index 00000000000..09a8227d290 --- /dev/null +++ b/public/app/features/trails/Integrations/dashboardIntegration.ts @@ -0,0 +1,115 @@ +import { isString } from 'lodash'; + +import { PanelMenuItem, PanelModel } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; +import { DataSourceRef } from '@grafana/schema'; + +import { DashboardModel } from '../../dashboard/state'; +import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; +import { MetricScene } from '../MetricScene'; + +import { DataTrailEmbedded, DataTrailEmbeddedState } from './DataTrailEmbedded'; +import { SceneDrawerAsScene, launchSceneDrawerInGlobalModal } from './SceneDrawer'; +import { QueryMetric, getQueryMetrics } from './getQueryMetrics'; +import { createAdHocFilters, getQueryMetricLabel, getQueryRunner, getTimeRangeFromDashboard } from './utils'; + +export function addDataTrailPanelAction( + dashboard: DashboardScene | DashboardModel, + panel: VizPanel | PanelModel, + items: PanelMenuItem[] +) { + const queryRunner = getQueryRunner(panel); + if (!queryRunner) { + return; + } + + const ds = getDataSourceSrv().getInstanceSettings(queryRunner.state.datasource); + + if (ds?.meta.id !== 'prometheus') { + return; + } + + const queries = queryRunner.state.queries.map((q) => q.expr).filter(isString); + + const queryMetrics = getQueryMetrics(queries); + + const subMenu: PanelMenuItem[] = queryMetrics.map((item) => { + return { + text: getQueryMetricLabel(item), + onClick: createClickHandler(item, dashboard, ds), + }; + }); + + if (subMenu.length > 0) { + items.push({ + text: 'Explore metrics', + iconClassName: 'code-branch', + subMenu: getUnique(subMenu), + }); + } +} + +function getUnique(items: T[]) { + const uniqueMenuTexts = new Set(); + function isUnique({ text }: { text: string }) { + const before = uniqueMenuTexts.size; + uniqueMenuTexts.add(text); + const after = uniqueMenuTexts.size; + return after > before; + } + return items.filter(isUnique); +} + +function getEmbeddedTrailsState( + { metric, labelFilters, query }: QueryMetric, + timeRange: SceneTimeRangeLike, + dataSourceUid: string | undefined +) { + const state: DataTrailEmbeddedState = { + metric, + filters: createAdHocFilters(labelFilters), + dataSourceUid, + timeRange, + }; + + return state; +} + +function createCommonEmbeddedTrailStateProps( + item: QueryMetric, + dashboard: DashboardScene | DashboardModel, + ds: DataSourceRef +) { + const timeRange = getTimeRangeFromDashboard(dashboard); + const trailState = getEmbeddedTrailsState(item, timeRange, ds.uid); + const embeddedTrail: DataTrailEmbedded = new DataTrailEmbedded(trailState); + + embeddedTrail.trail.addActivationHandler(() => { + if (embeddedTrail.trail.state.topScene instanceof MetricScene) { + embeddedTrail.trail.state.topScene.setActionView('breakdown'); + } + }); + + const commonProps = { + scene: embeddedTrail, + title: 'Explore metrics', + }; + + return commonProps; +} + +function createClickHandler(item: QueryMetric, dashboard: DashboardScene | DashboardModel, ds: DataSourceRef) { + if (dashboard instanceof DashboardScene) { + return () => { + const commonProps = createCommonEmbeddedTrailStateProps(item, dashboard, ds); + const drawerScene = new SceneDrawerAsScene({ + ...commonProps, + onDismiss: () => dashboard.closeModal(), + }); + dashboard.showModal(drawerScene); + }; + } else { + return () => launchSceneDrawerInGlobalModal(createCommonEmbeddedTrailStateProps(item, dashboard, ds)); + } +} diff --git a/public/app/features/trails/Integrations/getQueryMetrics.ts b/public/app/features/trails/Integrations/getQueryMetrics.ts new file mode 100644 index 00000000000..5273756dc22 --- /dev/null +++ b/public/app/features/trails/Integrations/getQueryMetrics.ts @@ -0,0 +1,31 @@ +import { buildVisualQueryFromString } from '@grafana/prometheus/src/querybuilder/parsing'; +import { QueryBuilderLabelFilter } from '@grafana/prometheus/src/querybuilder/shared/types'; + +import { isEquals } from './utils'; + +/** An identified metric and its label for a query */ +export type QueryMetric = { + metric: string; + labelFilters: QueryBuilderLabelFilter[]; + query: string; +}; + +export function getQueryMetrics(queries: string[]) { + const queryMetrics: QueryMetric[] = []; + + queries.forEach((query) => { + const struct = buildVisualQueryFromString(query); + if (struct.errors.length > 0) { + return; + } + + const { metric, labels } = struct.query; + + queryMetrics.push({ metric, labelFilters: labels.filter(isEquals), query }); + struct.query.binaryQueries?.forEach(({ query: { metric, labels } }) => { + queryMetrics.push({ metric, labelFilters: labels.filter(isEquals), query }); + }); + }); + + return queryMetrics; +} diff --git a/public/app/features/trails/Integrations/utils.ts b/public/app/features/trails/Integrations/utils.ts new file mode 100644 index 00000000000..e6f518283d5 --- /dev/null +++ b/public/app/features/trails/Integrations/utils.ts @@ -0,0 +1,45 @@ +import { PanelModel } from '@grafana/data'; +import { QueryBuilderLabelFilter } from '@grafana/prometheus/src/querybuilder/shared/types'; +import { SceneQueryRunner, SceneTimeRange, VizPanel } from '@grafana/scenes'; +import { DashboardModel } from 'app/features/dashboard/state'; +import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; +import { getQueryRunnerFor } from 'app/features/dashboard-scene/utils/utils'; + +import { QueryMetric } from './getQueryMetrics'; + +// We only support label filters with the '=' operator +export function isEquals(labelFilter: QueryBuilderLabelFilter) { + return labelFilter.op === '='; +} + +export function getQueryRunner(panel: VizPanel | PanelModel) { + if (panel instanceof VizPanel) { + return getQueryRunnerFor(panel); + } + + return new SceneQueryRunner({ datasource: panel.datasource || undefined, queries: panel.targets || [] }); +} + +export function getTimeRangeFromDashboard(dashboard: DashboardScene | DashboardModel) { + if (dashboard instanceof DashboardScene) { + return dashboard.state.$timeRange!.clone(); + } + if (dashboard instanceof DashboardModel) { + return new SceneTimeRange({ ...dashboard.time }); + } + return new SceneTimeRange(); +} + +export function getQueryMetricLabel({ metric, labelFilters }: QueryMetric) { + // Don't show the filter unless there is more than one entry + if (labelFilters.length === 0) { + return metric; + } + + const filter = `{${labelFilters.map(({ label, op, value }) => `${label}${op}"${value}"`)}}`; + return `${metric}${filter}`; +} + +export function createAdHocFilters(labels: QueryBuilderLabelFilter[]) { + return labels?.map((label) => ({ key: label.label, value: label.value, operator: label.op })); +} diff --git a/public/app/features/trails/MetricScene.tsx b/public/app/features/trails/MetricScene.tsx index 1dcd2c01f27..e3b889fe513 100644 --- a/public/app/features/trails/MetricScene.tsx +++ b/public/app/features/trails/MetricScene.tsx @@ -12,7 +12,7 @@ import { SceneVariableSet, QueryVariable, } from '@grafana/scenes'; -import { ToolbarButton, Stack, Icon, TabsBar, Tab, useStyles2, Box } from '@grafana/ui'; +import { ToolbarButton, Box, Stack, Icon, TabsBar, Tab, useStyles2, LinkButton } from '@grafana/ui'; import { getExploreUrl } from '../../core/utils/explore'; @@ -29,12 +29,11 @@ import { ActionViewType, getVariablesWithMetricConstant, MakeOptional, - OpenEmbeddedTrailEvent, trailDS, VAR_GROUP_BY, VAR_METRIC_EXPR, } from './shared'; -import { getDataSource, getTrailFor } from './utils'; +import { getDataSource, getTrailFor, getUrlForTrail } from './utils'; export interface MetricSceneState extends SceneObjectState { body: MetricGraphScene; @@ -116,10 +115,6 @@ const actionViewsDefinitions: ActionViewDefinition[] = [ export interface MetricActionBarState extends SceneObjectState {} export class MetricActionBar extends SceneObjectBase { - public onOpenTrail = () => { - this.publishEvent(new OpenEmbeddedTrailEvent(), true); - }; - public getLinkToExplore = async () => { const metricScene = sceneGraph.getAncestor(this, MetricScene); const trail = getTrailFor(this); @@ -175,9 +170,9 @@ export class MetricActionBar extends SceneObjectBase { onClick={toggleBookmark} /> {trail.state.embedded && ( - + Open - + )} diff --git a/public/app/features/trails/dashboardIntegration.ts b/public/app/features/trails/dashboardIntegration.ts deleted file mode 100644 index 98b43feeaf4..00000000000 --- a/public/app/features/trails/dashboardIntegration.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { PanelMenuItem } from '@grafana/data'; -import { getDataSourceSrv } from '@grafana/runtime'; -import { VizPanel } from '@grafana/scenes'; -import { buildVisualQueryFromString } from 'app/plugins/datasource/prometheus/querybuilder/parsing'; - -import { DashboardScene } from '../dashboard-scene/scene/DashboardScene'; -import { getQueryRunnerFor } from '../dashboard-scene/utils/utils'; - -import { DataTrailDrawer } from './DataTrailDrawer'; - -export function addDataTrailPanelAction(dashboard: DashboardScene, vizPanel: VizPanel, items: PanelMenuItem[]) { - const queryRunner = getQueryRunnerFor(vizPanel); - if (!queryRunner) { - return; - } - - const ds = getDataSourceSrv().getInstanceSettings(queryRunner.state.datasource); - if (!ds || ds.meta.id !== 'prometheus' || queryRunner.state.queries.length > 1) { - return; - } - - const query = queryRunner.state.queries[0]; - const parsedResult = buildVisualQueryFromString(query.expr); - if (parsedResult.errors.length > 0) { - return; - } - - items.push({ - text: 'Data trail', - iconClassName: 'code-branch', - onClick: () => { - dashboard.showModal( - new DataTrailDrawer({ query: parsedResult.query, dsRef: ds, timeRange: dashboard.state.$timeRange!.clone() }) - ); - }, - shortcut: 'p s', - }); -} diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts index f37f30430dc..55960310c9c 100644 --- a/public/app/features/trails/shared.ts +++ b/public/app/features/trails/shared.ts @@ -1,4 +1,4 @@ -import { BusEventBase, BusEventWithPayload } from '@grafana/data'; +import { BusEventWithPayload } from '@grafana/data'; import { ConstantVariable, SceneObject } from '@grafana/scenes'; import { VariableHide } from '@grafana/schema'; @@ -47,7 +47,3 @@ export function getVariablesWithMetricConstant(metric: string) { export class MetricSelectedEvent extends BusEventWithPayload { public static type = 'metric-selected-event'; } - -export class OpenEmbeddedTrailEvent extends BusEventBase { - public static type = 'open-embedded-trail-event'; -}