diff --git a/.betterer.results b/.betterer.results index 57c89589246..da54e902293 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4362,6 +4362,9 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] ], + "public/app/features/trails/services/sorting.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], diff --git a/jest.config.js b/jest.config.js index 6d9f76b9317..5418ede2e38 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,6 +20,7 @@ const esModules = [ '@msagl', 'lodash-es', 'vscode-languageserver-types', + '@bsull/augurs', ].join('|'); module.exports = { @@ -54,6 +55,7 @@ module.exports = { '^@grafana/schema/dist/esm/(.*)$': '/packages/grafana-schema/src/$1', // prevent systemjs amd extra from breaking tests. 'systemjs/dist/extras/amd': '/public/test/mocks/systemjsAMDExtra.ts', + '@bsull/augurs': '/public/test/mocks/augurs.ts', }, // Log the test results with dynamic Loki tags. Drone CI only reporters: ['default', ['/public/test/log-reporter.js', { enable: process.env.DRONE === 'true' }]], diff --git a/package.json b/package.json index 1df7cce05b5..0883ab0c535 100644 --- a/package.json +++ b/package.json @@ -245,6 +245,7 @@ "yargs": "^17.5.1" }, "dependencies": { + "@bsull/augurs": "0.4.2", "@emotion/css": "11.13.4", "@emotion/react": "11.13.3", "@fingerprintjs/fingerprintjs": "^3.4.2", diff --git a/public/app/features/trails/Breakdown/ByFrameRepeater.tsx b/public/app/features/trails/Breakdown/ByFrameRepeater.tsx index 3959537d84f..6e0fd133760 100644 --- a/public/app/features/trails/Breakdown/ByFrameRepeater.tsx +++ b/public/app/features/trails/Breakdown/ByFrameRepeater.tsx @@ -18,6 +18,7 @@ import { Trans } from 'app/core/internationalization'; import { getLabelValueFromDataFrame } from '../services/levels'; import { fuzzySearch } from '../services/search'; +import { sortSeries } from '../services/sorting'; import { BreakdownSearchReset } from './BreakdownSearchScene'; import { findSceneObjectsByType } from './utils'; @@ -33,12 +34,18 @@ type FrameIterateCallback = (frames: DataFrame[], seriesIndex: number) => void; export class ByFrameRepeater extends SceneObjectBase { private unfilteredChildren: SceneFlexItem[] = []; - private series: DataFrame[] = []; + private sortBy: string; + private sortedSeries: DataFrame[] = []; private getFilter: () => string; - public constructor({ getFilter, ...state }: ByFrameRepeaterState & { getFilter: () => string }) { + public constructor({ + sortBy, + getFilter, + ...state + }: ByFrameRepeaterState & { sortBy: string; getFilter: () => string }) { super(state); + this.sortBy = sortBy; this.getFilter = getFilter; this.addActivationHandler(() => { @@ -69,15 +76,24 @@ export class ByFrameRepeater extends SceneObjectBase { }); } + public sort = (sortBy: string) => { + const data = sceneGraph.getData(this); + this.sortBy = sortBy; + if (data.state.data) { + this.performRepeat(data.state.data); + } + }; + private performRepeat(data: PanelData) { const newChildren: SceneFlexItem[] = []; - this.series = data.series; + const sortedSeries = sortSeries(data.series, this.sortBy); - for (let seriesIndex = 0; seriesIndex < this.series.length; seriesIndex++) { - const layoutChild = this.state.getLayoutChild(data, this.series[seriesIndex], seriesIndex); + for (let seriesIndex = 0; seriesIndex < sortedSeries.length; seriesIndex++) { + const layoutChild = this.state.getLayoutChild(data, sortedSeries[seriesIndex], seriesIndex); newChildren.push(layoutChild); } + this.sortedSeries = sortedSeries; this.unfilteredChildren = newChildren; if (this.getFilter()) { @@ -114,8 +130,8 @@ export class ByFrameRepeater extends SceneObjectBase { if (!data) { return; } - for (let seriesIndex = 0; seriesIndex < this.series.length; seriesIndex++) { - callback(this.series, seriesIndex); + for (let seriesIndex = 0; seriesIndex < this.sortedSeries.length; seriesIndex++) { + callback(this.sortedSeries, seriesIndex); } }; diff --git a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx index e8d383d9558..8c6c3012fde 100644 --- a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx +++ b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx @@ -1,3 +1,4 @@ +import init from '@bsull/augurs'; import { css } from '@emotion/css'; import { isNumber, max, min, throttle } from 'lodash'; import { useEffect } from 'react'; @@ -35,6 +36,7 @@ import { MetricScene } from '../MetricScene'; import { StatusWrapper } from '../StatusWrapper'; import { reportExploreMetrics } from '../interactions'; import { updateOtelJoinWithGroupLeft } from '../otel/util'; +import { getSortByPreference } from '../services/store'; import { ALL_VARIABLE_VALUE } from '../services/variables'; import { MDP_METRIC_PREVIEW, @@ -50,6 +52,7 @@ import { AddToFiltersGraphAction } from './AddToFiltersGraphAction'; import { BreakdownSearchReset, BreakdownSearchScene } from './BreakdownSearchScene'; import { ByFrameRepeater } from './ByFrameRepeater'; import { LayoutSwitcher } from './LayoutSwitcher'; +import { SortByScene, SortCriteriaChanged } from './SortByScene'; import { BreakdownLayoutChangeCallback, BreakdownLayoutType } from './types'; import { getLabelOptions } from './utils'; import { BreakdownAxisChangeEvent, yAxisSyncBehavior } from './yAxisSyncBehavior'; @@ -59,6 +62,7 @@ const MAX_PANELS_IN_ALL_LABELS_BREAKDOWN = 60; export interface LabelBreakdownSceneState extends SceneObjectState { body?: LayoutSwitcher; search: BreakdownSearchScene; + sortBy: SortByScene; labels: Array>; value?: string; loading?: boolean; @@ -76,6 +80,7 @@ export class LabelBreakdownScene extends SceneObjectBase console.debug('Grafana ML initialized')); + const variable = this.getVariable(); variable.subscribeToState((newState, oldState) => { @@ -102,6 +110,7 @@ export class LabelBreakdownScene extends SceneObjectBase { + if (event.target !== 'labels') { + return; + } + if (this.state.body instanceof LayoutSwitcher) { + this.state.body.state.breakdownLayouts.forEach((layout) => { + if (layout instanceof ByFrameRepeater) { + layout.sort(event.sortBy); + } + }); + } + reportExploreMetrics('sorting_changed', { sortBy: event.sortBy }); + }; + private onReferencedVariableValueChanged() { const variable = this.getVariable(); variable.changeValueTo(ALL_VARIABLE_VALUE); @@ -291,7 +314,7 @@ export class LabelBreakdownScene extends SceneObjectBase) => { - const { labels, body, search, loading, value, blockingMessage } = model.useState(); + const { labels, body, search, sortBy, loading, value, blockingMessage } = model.useState(); const styles = useStyles2(getStyles); const trail = getTrailFor(model); @@ -322,9 +345,12 @@ export class LabelBreakdownScene extends SceneObjectBase - - + <> + + + + + )} {body instanceof LayoutSwitcher && ( @@ -470,6 +496,7 @@ function buildNormalLayout( return item; } + const { sortBy } = getSortByPreference('labels', 'outliers'); const getFilter = () => searchScene.state.filter ?? ''; return new LayoutSwitcher({ @@ -511,6 +538,7 @@ function buildNormalLayout( ], }), getLayoutChild, + sortBy, getFilter, }), new ByFrameRepeater({ @@ -520,6 +548,7 @@ function buildNormalLayout( children: [], }), getLayoutChild, + sortBy, getFilter, }), ], diff --git a/public/app/features/trails/Breakdown/SortByScene.tsx b/public/app/features/trails/Breakdown/SortByScene.tsx new file mode 100644 index 00000000000..cd9498ff2ed --- /dev/null +++ b/public/app/features/trails/Breakdown/SortByScene.tsx @@ -0,0 +1,109 @@ +import { css } from '@emotion/css'; + +import { BusEventBase, GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { IconButton, Select } from '@grafana/ui'; +import { Field, useStyles2 } from '@grafana/ui/'; + +import { Trans } from '../../../core/internationalization'; +import { getSortByPreference, setSortByPreference } from '../services/store'; + +export interface SortBySceneState extends SceneObjectState { + target: 'fields' | 'labels'; + sortBy: string; +} + +export class SortCriteriaChanged extends BusEventBase { + constructor( + public target: 'fields' | 'labels', + public sortBy: string + ) { + super(); + } + + public static type = 'sort-criteria-changed'; +} + +export class SortByScene extends SceneObjectBase { + public sortingOptions = [ + { + label: '', + options: [ + { + value: 'outliers', + label: 'Outlying Values', + description: 'Prioritizes values that show distinct behavior from others within the same label', + }, + { + value: 'alphabetical', + label: 'Name [A-Z]', + description: 'Alphabetical order', + }, + { + value: 'alphabetical-reversed', + label: 'Name [Z-A]', + description: 'Reversed alphabetical order', + }, + ], + }, + ]; + + constructor(state: Pick) { + const { sortBy } = getSortByPreference(state.target, 'outliers'); + super({ + target: state.target, + sortBy, + }); + } + + public onCriteriaChange = (criteria: SelectableValue) => { + if (!criteria.value) { + return; + } + this.setState({ sortBy: criteria.value }); + setSortByPreference(this.state.target, criteria.value); + this.publishEvent(new SortCriteriaChanged(this.state.target, criteria.value), true); + }; + + public static Component = ({ model }: SceneComponentProps) => { + const styles = useStyles2(getStyles); + const { sortBy } = model.useState(); + const group = model.sortingOptions.find((group) => group.options.find((option) => option.value === sortBy)); + const value = group?.options.find((option) => option.value === sortBy); + return ( + + Sort by + + + } + > +