diff --git a/.betterer.results b/.betterer.results index 390e7b0d98e..448670006e4 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4088,6 +4088,9 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "11"], [0, 0, 0, "Do not use any type assertions.", "12"] ], + "public/app/features/trails/ActionTabs/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/trails/MetricScene.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/public/app/features/trails/ActionTabs/BreakdownScene.tsx b/public/app/features/trails/ActionTabs/BreakdownScene.tsx index d4484bec19c..b6652baaec5 100644 --- a/public/app/features/trails/ActionTabs/BreakdownScene.tsx +++ b/public/app/features/trails/ActionTabs/BreakdownScene.tsx @@ -1,8 +1,10 @@ import { css } from '@emotion/css'; +import { min, max, isNumber, debounce } from 'lodash'; import React from 'react'; -import { DataFrame, GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { DataFrame, FieldType, GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data'; import { + FieldConfigBuilders, PanelBuilders, QueryVariable, SceneComponentProps, @@ -18,6 +20,7 @@ import { SceneObjectState, SceneQueryRunner, VariableDependencyConfig, + VizPanel, } from '@grafana/scenes'; import { Button, Field, useStyles2 } from '@grafana/ui'; import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; @@ -28,12 +31,14 @@ import { BreakdownLabelSelector } from '../BreakdownLabelSelector'; import { MetricScene } from '../MetricScene'; import { StatusWrapper } from '../StatusWrapper'; import { trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP } from '../shared'; -import { getColorByIndex } from '../utils'; +import { getColorByIndex, getTrailFor } from '../utils'; import { AddToFiltersGraphAction } from './AddToFiltersGraphAction'; import { ByFrameRepeater } from './ByFrameRepeater'; import { LayoutSwitcher } from './LayoutSwitcher'; +import { breakdownPanelOptions } from './panelConfigs'; import { getLabelOptions } from './utils'; +import { BreakdownAxisChangeEvent, yAxisSyncBehavior } from './yAxisSyncBehavior'; const MAX_PANELS_IN_ALL_LABELS_BREAKDOWN = 60; @@ -76,12 +81,67 @@ export class BreakdownScene extends SceneObjectBase { } }); + const trail = getTrailFor(this); + trail.state.$timeRange?.subscribeToState(() => { + // The change in time range will cause a refresh of panel values, + // so we clear the axis range so it can be recalculated when the calls + // to `reportBreakdownPanelData` start being made from the panels' behavior. + this.clearBreakdownPanelAxisValues(); + }); + const metric = sceneGraph.getAncestor(this, MetricScene).state.metric; this._query = getAutoQueriesForMetric(metric).breakdown; this.updateBody(variable); } + private breakdownPanelMaxValue: number | undefined; + private breakdownPanelMinValue: number | undefined; + public reportBreakdownPanelData(data: PanelData | undefined) { + if (!data) { + return; + } + + let newMin = this.breakdownPanelMinValue; + let newMax = this.breakdownPanelMaxValue; + + data.series.forEach((dataFrame) => { + dataFrame.fields.forEach((breakdownData) => { + if (breakdownData.type !== FieldType.number) { + return; + } + const values = breakdownData.values.filter(isNumber); + + const maxValue = max(values); + const minValue = min(values); + + newMax = max([newMax, maxValue].filter(isNumber)); + newMin = min([newMin, minValue].filter(isNumber)); + }); + }); + + if (newMax === undefined || newMin === undefined || !Number.isFinite(newMax + newMin)) { + return; + } + + this.breakdownPanelMaxValue = newMax; + this.breakdownPanelMinValue = newMin; + + this._triggerAxisChangedEvent(); + } + + private _triggerAxisChangedEvent = debounce(() => { + const { breakdownPanelMinValue, breakdownPanelMaxValue } = this; + if (breakdownPanelMinValue !== undefined && breakdownPanelMaxValue !== undefined) { + this.publishEvent(new BreakdownAxisChangeEvent({ min: breakdownPanelMinValue, max: breakdownPanelMaxValue })); + } + }, 0); + + private clearBreakdownPanelAxisValues() { + this.breakdownPanelMaxValue = undefined; + this.breakdownPanelMinValue = undefined; + } + private getVariable(): QueryVariable { const variable = sceneGraph.lookupVariable(VAR_GROUP_BY, this)!; if (!(variable instanceof QueryVariable)) { @@ -117,6 +177,8 @@ export class BreakdownScene extends SceneObjectBase { stateUpdate.blockingMessage = 'Unable to retrieve label options for currently selected metric.'; } + this.clearBreakdownPanelAxisValues(); + // Setting the new panels will gradually end up calling reportBreakdownPanelData to update the new min & max this.setState(stateUpdate); } @@ -207,26 +269,33 @@ export function buildAllLayout(options: Array>, queryDef const expr = queryDef.queries[0].expr.replaceAll(VAR_GROUP_BY_EXP, String(option.value)); const unit = queryDef.unit; + const vizPanel = PanelBuilders.timeseries() + .setTitle(option.label!) + .setData( + new SceneQueryRunner({ + maxDataPoints: 250, + datasource: trailDS, + queries: [ + { + refId: 'A', + expr: expr, + legendFormat: `{{${option.label}}}`, + }, + ], + }) + ) + .setHeaderActions(new SelectLabelAction({ labelName: String(option.value) })) + .setUnit(unit) + .build(); + + vizPanel.addActivationHandler(() => { + vizPanel.onOptionsChange(breakdownPanelOptions); + }); + children.push( new SceneCSSGridItem({ - body: PanelBuilders.timeseries() - .setTitle(option.label!) - .setData( - new SceneQueryRunner({ - maxDataPoints: 250, - datasource: trailDS, - queries: [ - { - refId: 'A', - expr: expr, - legendFormat: `{{${option.label}}}`, - }, - ], - }) - ) - .setHeaderActions(new SelectLabelAction({ labelName: String(option.value) })) - .setUnit(unit) - .build(), + $behaviors: [yAxisSyncBehavior], + body: vizPanel, }) ); } @@ -257,6 +326,8 @@ export function buildAllLayout(options: Array>, queryDef const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))'; function buildNormalLayout(queryDef: AutoQueryDef) { + const unit = queryDef.unit; + return new LayoutSwitcher({ $data: new SceneQueryRunner({ datasource: trailDS, @@ -285,14 +356,26 @@ function buildNormalLayout(queryDef: AutoQueryDef) { children: [], }), getLayoutChild: (data, frame, frameIndex) => { + const vizPanel = queryDef + .vizBuilder() + .setTitle(getLabelValue(frame)) + .setData(new SceneDataNode({ data: { ...data, series: [frame] } })) + .setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) + .setHeaderActions(new AddToFiltersGraphAction({ frame })) + .setUnit(unit) + .build(); + + if (vizPanel.isActive) { + vizPanel.onOptionsChange(breakdownPanelOptions); + } else { + vizPanel.addActivationHandler(() => { + vizPanel.onOptionsChange(breakdownPanelOptions); + }); + } + return new SceneCSSGridItem({ - body: queryDef - .vizBuilder() - .setTitle(getLabelValue(frame)) - .setData(new SceneDataNode({ data: { ...data, series: [frame] } })) - .setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) - .setHeaderActions(new AddToFiltersGraphAction({ frame })) - .build(), + $behaviors: [yAxisSyncBehavior], + body: vizPanel, }); }, }), @@ -303,14 +386,28 @@ function buildNormalLayout(queryDef: AutoQueryDef) { children: [], }), getLayoutChild: (data, frame, frameIndex) => { + const vizPanel: VizPanel = queryDef + .vizBuilder() + .setTitle(getLabelValue(frame)) + .setData(new SceneDataNode({ data: { ...data, series: [frame] } })) + .setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) + .setHeaderActions(new AddToFiltersGraphAction({ frame })) + .setUnit(unit) + .build(); + + if (vizPanel.isActive) { + vizPanel.onOptionsChange(breakdownPanelOptions); + } else { + vizPanel.addActivationHandler(() => { + vizPanel.onOptionsChange(breakdownPanelOptions); + }); + } + + FieldConfigBuilders.timeseries().build(); + return new SceneCSSGridItem({ - body: queryDef - .vizBuilder() - .setTitle(getLabelValue(frame)) - .setData(new SceneDataNode({ data: { ...data, series: [frame] } })) - .setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) - .setHeaderActions(new AddToFiltersGraphAction({ frame })) - .build(), + $behaviors: [yAxisSyncBehavior], + body: vizPanel, }); }, }), diff --git a/public/app/features/trails/ActionTabs/panelConfigs.ts b/public/app/features/trails/ActionTabs/panelConfigs.ts new file mode 100644 index 00000000000..0afb22cb405 --- /dev/null +++ b/public/app/features/trails/ActionTabs/panelConfigs.ts @@ -0,0 +1,8 @@ +import { PanelOptionsBuilders } from '@grafana/scenes'; +import { SortOrder } from '@grafana/schema/dist/esm/index'; +import { TooltipDisplayMode } from '@grafana/ui'; + +export const breakdownPanelOptions = PanelOptionsBuilders.timeseries() + .setOption('tooltip', { mode: TooltipDisplayMode.Multi, sort: SortOrder.Descending, maxHeight: 250 }) + .setOption('legend', { showLegend: false }) + .build(); diff --git a/public/app/features/trails/ActionTabs/utils.ts b/public/app/features/trails/ActionTabs/utils.ts index b606f676370..9fadb16db3a 100644 --- a/public/app/features/trails/ActionTabs/utils.ts +++ b/public/app/features/trails/ActionTabs/utils.ts @@ -22,3 +22,25 @@ export function getLabelOptions(scenObject: SceneObject, variable: QueryVariable return labelOptions; } + +interface Type extends Function { + new (...args: any[]): T; +} +export function findSceneObjectByType(scene: SceneObject, sceneType: Type) { + const targetScene = sceneGraph.findObject(scene, (obj) => obj instanceof sceneType); + + if (targetScene instanceof sceneType) { + return targetScene; + } + + return null; +} + +export function findSceneObjectsByType(scene: SceneObject, sceneType: Type) { + function isSceneType(scene: SceneObject): scene is T { + return scene instanceof sceneType; + } + + const targetScenes = sceneGraph.findAllObjects(scene, isSceneType); + return targetScenes.filter(isSceneType); +} diff --git a/public/app/features/trails/ActionTabs/yAxisSyncBehavior.ts b/public/app/features/trails/ActionTabs/yAxisSyncBehavior.ts new file mode 100644 index 00000000000..f42baf2e68d --- /dev/null +++ b/public/app/features/trails/ActionTabs/yAxisSyncBehavior.ts @@ -0,0 +1,83 @@ +import { BusEventWithPayload } from '@grafana/data'; +import { + FieldConfigBuilders, + SceneCSSGridItem, + SceneDataProvider, + SceneStatelessBehavior, + VizPanel, + sceneGraph, +} from '@grafana/scenes'; + +import { BreakdownScene } from './BreakdownScene'; +import { findSceneObjectsByType } from './utils'; + +export class BreakdownAxisChangeEvent extends BusEventWithPayload<{ min: number; max: number }> { + public static type = 'selected-metric-query-results-event'; +} + +export const yAxisSyncBehavior: SceneStatelessBehavior = (sceneObject: SceneCSSGridItem) => { + const breakdownScene = sceneGraph.getAncestor(sceneObject, BreakdownScene); + + // Handle query runners from vizPanels that haven't been activated yet + findSceneObjectsByType(sceneObject, VizPanel).forEach((vizPanel) => { + if (vizPanel.isActive) { + registerDataProvider(vizPanel.state.$data); + } else { + vizPanel.addActivationHandler(() => { + registerDataProvider(vizPanel.state.$data); + }); + } + }); + + // Register the data providers of all present vizpanels + findSceneObjectsByType(sceneObject, VizPanel).forEach((vizPanel) => registerDataProvider(vizPanel.state.$data)); + + function registerDataProvider(dataProvider?: SceneDataProvider) { + if (!dataProvider) { + return; + } + + if (!dataProvider.isActive) { + dataProvider.addActivationHandler(() => { + // Call this function again when the dataprovider is activated + registerDataProvider(dataProvider); + }); + } + + // Report the panel data if it is already populated + if (dataProvider.state.data) { + breakdownScene.reportBreakdownPanelData(dataProvider.state.data); + } + + // Report the panel data whenever it is updated + dataProvider.subscribeToState(({ data }, _) => { + breakdownScene.reportBreakdownPanelData(data); + }); + } + + const axisChangeSubscription = breakdownScene.subscribeToEvent(BreakdownAxisChangeEvent, (event) => { + if (!sceneObject.isActive) { + axisChangeSubscription.unsubscribe(); + return; + } + + const fieldConfig = FieldConfigBuilders.timeseries() + .setCustomFieldConfig('axisSoftMin', event.payload.min) + .setCustomFieldConfig('axisSoftMax', event.payload.max) + .build(); + + findSceneObjectsByType(sceneObject, VizPanel).forEach((vizPanel) => { + function update() { + vizPanel.onFieldConfigChange(fieldConfig); + } + + if (vizPanel.isActive) { + // Update axis for panels that are already active + update(); + } else { + // Update inactive panels once they become active. + vizPanel.addActivationHandler(update); + } + }); + }); +}; diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts index ad51a4769b3..6f51f6270e2 100644 --- a/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts @@ -36,7 +36,7 @@ export function createHistogramQueryDefs(metricParts: string[]) { const percentiles: AutoQueryDef = { ...common, variant: 'percentiles', - queries: [99, 90, 50].map((p) => percentileQuery(p)).map(fixRefIds), + queries: [99, 90, 50].map((p) => percentileQuery(p)), vizBuilder: () => percentilesGraphBuilder(percentiles), }; @@ -50,15 +50,6 @@ export function createHistogramQueryDefs(metricParts: string[]) { return { preview: p50, main: percentiles, variants: [percentiles, heatmap], breakdown: breakdown }; } -function fixRefIds(queryDef: PromQuery, index: number): PromQuery { - // By default refIds are `"A"` - // This method will reassign based on `A + index` -- A, B, C, etc - return { - ...queryDef, - refId: String.fromCharCode('A'.charCodeAt(0) + index), - }; -} - const BASE_QUERY = `rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])`; function baseQuery(groupings: string[] = []) { @@ -68,7 +59,7 @@ function baseQuery(groupings: string[] = []) { function heatMapQuery(groupings: string[] = []): PromQuery { return { - refId: 'A', + refId: 'Heatmap', expr: baseQuery(groupings), format: 'heatmap', }; @@ -78,7 +69,7 @@ function percentileQuery(percentile: number, groupings: string[] = []) { const percent = percentile / 100; return { - refId: 'A', + refId: `Percentile${percentile}`, expr: `histogram_quantile(${percent}, ${baseQuery(groupings)})`, legendFormat: `${percentile}th Percentile`, };