datatrails: synchronize the range of the y axis for all breakdown panels (#84781)

feat: sync the yaxis of breakdown panels
This commit is contained in:
Darren Janeczek
2024-03-22 16:19:11 -04:00
committed by GitHub
parent b6cea0d7fe
commit 810c224039
6 changed files with 250 additions and 46 deletions

View File

@@ -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"]
],

View File

@@ -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<BreakdownSceneState> {
}
});
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<BreakdownSceneState> {
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<SelectableValue<string>>, 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<SelectableValue<string>>, 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,
});
},
}),

View File

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

View File

@@ -22,3 +22,25 @@ export function getLabelOptions(scenObject: SceneObject, variable: QueryVariable
return labelOptions;
}
interface Type<T> extends Function {
new (...args: any[]): T;
}
export function findSceneObjectByType<T extends SceneObject>(scene: SceneObject, sceneType: Type<T>) {
const targetScene = sceneGraph.findObject(scene, (obj) => obj instanceof sceneType);
if (targetScene instanceof sceneType) {
return targetScene;
}
return null;
}
export function findSceneObjectsByType<T extends SceneObject>(scene: SceneObject, sceneType: Type<T>) {
function isSceneType(scene: SceneObject): scene is T {
return scene instanceof sceneType;
}
const targetScenes = sceneGraph.findAllObjects(scene, isSceneType);
return targetScenes.filter(isSceneType);
}

View File

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

View File

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