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
This commit is contained in:
Darren Janeczek 2024-03-18 12:16:38 -04:00 committed by GitHub
parent b1b65faf02
commit 63e8753aa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 306 additions and 135 deletions

View File

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

View File

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

View File

@ -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<MetricOverviewSceneStat
<Stack direction="column" gap={0.5}>
<Text weight={'medium'}>Labels</Text>
{labelOptions.length === 0 && 'Unable to fetch labels.'}
{labelOptions.map((l) => (
<TextLink
key={l.label}
href={sceneGraph.interpolate(
model,
`${TRAILS_ROUTE}$\{__url.params:exclude:actionView,var-groupby}&actionView=breakdown&var-groupby=${encodeURIComponent(
l.value!
)}`
)}
title="View breakdown"
>
{l.label!}
</TextLink>
))}
{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.
<div key={l.label}>{l.label}</div>
) : (
<TextLink
key={l.label}
href={sceneGraph.interpolate(
model,
`${TRAILS_ROUTE}$\{__url.params:exclude:actionView,var-groupby}&actionView=breakdown&var-groupby=${encodeURIComponent(
l.value!
)}`
)}
title="View breakdown"
>
{l.label!}
</TextLink>
)
)}
</Stack>
</>
</Stack>

View File

@ -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<DataTrailDrawerState> {
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<DataTrailDrawer>) {
return (
<Drawer title={'Data trail'} onClose={model.onClose} size="lg">
<div style={{ display: 'flex', height: '100%' }}>
<model.trail.Component model={model.trail} />
</div>
</Drawer>
);
}
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,
});
}

View File

@ -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<DataTrailEmbeddedState> {
static Component = DataTrailEmbeddedRenderer;
public trail: DataTrail;
constructor(state: DataTrailEmbeddedState) {
super(state);
this.trail = buildDataTrailFromState(state);
}
}
function DataTrailEmbeddedRenderer({ model }: SceneComponentProps<DataTrailEmbedded>) {
return <model.trail.Component model={model.trail} />;
}
export function buildDataTrailFromState({ metric, filters, dataSourceUid, timeRange }: DataTrailEmbeddedState) {
return new DataTrail({
$timeRange: timeRange,
metric,
initialDS: dataSourceUid,
initialFilters: filters,
embedded: true,
});
}

View File

@ -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 (
<Drawer title={title} onClose={onDismiss} size="lg">
<div style={{ display: 'flex', height: '100%' }}>
<scene.Component model={scene} />
</div>
</Drawer>
);
}
interface SceneDrawerAsSceneState extends SceneObjectState, SceneDrawerProps {}
export class SceneDrawerAsScene extends SceneObjectBase<SceneDrawerAsSceneState> {
constructor(state: SceneDrawerProps) {
super(state);
}
static Component({ model }: SceneComponentProps<SceneDrawerAsScene>) {
const state = model.useState();
return <SceneDrawer {...state} />;
}
}
export function launchSceneDrawerInGlobalModal(props: Omit<SceneDrawerProps, 'onDismiss'>) {
const payload = {
component: SceneDrawer,
props,
};
appEvents.publish(new ShowModalReactEvent(payload));
}

View File

@ -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<T extends { text: string }>(items: T[]) {
const uniqueMenuTexts = new Set<string>();
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));
}
}

View File

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

View File

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

View File

@ -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<MetricActionBarState> {
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<MetricActionBarState> {
onClick={toggleBookmark}
/>
{trail.state.embedded && (
<ToolbarButton variant={'canvas'} onClick={model.onOpenTrail}>
<LinkButton href={getUrlForTrail(trail)} variant={'secondary'}>
Open
</ToolbarButton>
</LinkButton>
)}
</Stack>
</div>

View File

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

View File

@ -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<string> {
public static type = 'metric-selected-event';
}
export class OpenEmbeddedTrailEvent extends BusEventBase {
public static type = 'open-embedded-trail-event';
}