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
+
+
+ }
+ >
+
+
+ );
+ };
+}
+
+function getStyles(theme: GrafanaTheme2) {
+ return {
+ sortByTooltip: css({
+ display: 'flex',
+ gap: theme.spacing(1),
+ }),
+ };
+}
diff --git a/public/app/features/trails/Breakdown/panelConfigs.ts b/public/app/features/trails/Breakdown/panelConfigs.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/public/app/features/trails/interactions.ts b/public/app/features/trails/interactions.ts
index 937bc9302e2..602faaa0ee7 100644
--- a/public/app/features/trails/interactions.ts
+++ b/public/app/features/trails/interactions.ts
@@ -111,6 +111,10 @@ type Interactions = {
| 'close'
)
};
+ sorting_changed: {
+ // type of sorting
+ sortBy: string
+ };
wasm_not_supported: {},
};
diff --git a/public/app/features/trails/services/sorting.test.ts b/public/app/features/trails/services/sorting.test.ts
new file mode 100644
index 00000000000..eda6423e90c
--- /dev/null
+++ b/public/app/features/trails/services/sorting.test.ts
@@ -0,0 +1,74 @@
+import { toDataFrame, FieldType, ReducerID } from '@grafana/data';
+
+import { sortSeries } from './sorting';
+
+const frameA = toDataFrame({
+ fields: [
+ { name: 'Time', type: FieldType.time, values: [0] },
+ {
+ name: 'Value',
+ type: FieldType.number,
+ values: [0, 1, 0],
+ labels: {
+ test: 'C',
+ },
+ },
+ ],
+});
+const frameB = toDataFrame({
+ fields: [
+ { name: 'Time', type: FieldType.time, values: [0] },
+ {
+ name: 'Value',
+ type: FieldType.number,
+ values: [1, 1, 1],
+ labels: {
+ test: 'A',
+ },
+ },
+ ],
+});
+const frameC = toDataFrame({
+ fields: [
+ { name: 'Time', type: FieldType.time, values: [0] },
+ {
+ name: 'Value',
+ type: FieldType.number,
+ values: [100, 9999, 100],
+ labels: {
+ test: 'B',
+ },
+ },
+ ],
+});
+
+describe('sortSeries', () => {
+ test('Sorts series by standard deviation, descending', () => {
+ const series = [frameA, frameB, frameC];
+ const sortedSeries = [frameC, frameA, frameB];
+
+ const result = sortSeries(series, ReducerID.stdDev, 'desc');
+ expect(result).toEqual(sortedSeries);
+ });
+ test('Sorts series by standard deviation, ascending', () => {
+ const series = [frameA, frameB, frameC];
+ const sortedSeries = [frameB, frameA, frameC];
+
+ const result = sortSeries(series, ReducerID.stdDev, 'asc');
+ expect(result).toEqual(sortedSeries);
+ });
+ test('Sorts series alphabetically, ascending', () => {
+ const series = [frameA, frameB, frameC];
+ const sortedSeries = [frameB, frameC, frameA];
+
+ const result = sortSeries(series, 'alphabetical', 'asc');
+ expect(result).toEqual(sortedSeries);
+ });
+ test('Sorts series alphabetically, descending', () => {
+ const series = [frameA, frameB, frameC];
+ const sortedSeries = [frameB, frameC, frameA];
+
+ const result = sortSeries(series, 'alphabetical', 'desc');
+ expect(result).toEqual(sortedSeries);
+ });
+});
diff --git a/public/app/features/trails/services/sorting.ts b/public/app/features/trails/services/sorting.ts
new file mode 100644
index 00000000000..c78d04d0778
--- /dev/null
+++ b/public/app/features/trails/services/sorting.ts
@@ -0,0 +1,138 @@
+import { OutlierDetector, OutlierOutput } from '@bsull/augurs';
+import { memoize } from 'lodash';
+
+import { DataFrame, doStandardCalcs, fieldReducers, FieldType, outerJoinDataFrames, ReducerID } from '@grafana/data';
+
+import { reportExploreMetrics } from '../interactions';
+
+import { getLabelValueFromDataFrame } from './levels';
+
+export const sortSeries = memoize(
+ (series: DataFrame[], sortBy: string, direction = 'asc') => {
+ if (sortBy === 'alphabetical') {
+ return sortSeriesByName(series, 'asc');
+ }
+
+ if (sortBy === 'alphabetical-reversed') {
+ return sortSeriesByName(series, 'desc');
+ }
+
+ if (sortBy === 'outliers') {
+ initOutlierDetector(series);
+ }
+
+ const reducer = (dataFrame: DataFrame) => {
+ try {
+ if (sortBy === 'outliers') {
+ return calculateOutlierValue(series, dataFrame);
+ }
+ } catch (e) {
+ console.error(e);
+ // ML sorting panicked, fallback to stdDev
+ sortBy = ReducerID.stdDev;
+ }
+ const fieldReducer = fieldReducers.get(sortBy);
+ const value =
+ fieldReducer.reduce?.(dataFrame.fields[1], true, true) ?? doStandardCalcs(dataFrame.fields[1], true, true);
+ return value[sortBy] ?? 0;
+ };
+
+ const seriesCalcs = series.map((dataFrame) => ({
+ value: reducer(dataFrame),
+ dataFrame: dataFrame,
+ }));
+
+ seriesCalcs.sort((a, b) => {
+ if (a.value !== undefined && b.value !== undefined) {
+ return b.value - a.value;
+ }
+ return 0;
+ });
+
+ if (direction === 'asc') {
+ seriesCalcs.reverse();
+ }
+
+ return seriesCalcs.map(({ dataFrame }) => dataFrame);
+ },
+ (series: DataFrame[], sortBy: string, direction = 'asc') => {
+ const firstTimestamp = series.length > 0 ? series[0].fields[0].values[0] : 0;
+ const lastTimestamp =
+ series.length > 0
+ ? series[series.length - 1].fields[0].values[series[series.length - 1].fields[0].values.length - 1]
+ : 0;
+ const firstValue = series.length > 0 ? getLabelValueFromDataFrame(series[0]) : '';
+ const lastValue = series.length > 0 ? getLabelValueFromDataFrame(series[series.length - 1]) : '';
+ const key = `${firstValue}_${lastValue}_${firstTimestamp}_${lastTimestamp}_${series.length}_${sortBy}_${direction}`;
+ return key;
+ }
+);
+
+const initOutlierDetector = (series: DataFrame[]) => {
+ if (!wasmSupported()) {
+ return;
+ }
+
+ // Combine all frames into one by joining on time.
+ const joined = outerJoinDataFrames({ frames: series });
+ if (!joined) {
+ return;
+ }
+
+ // Get number fields: these are our series.
+ const joinedSeries = joined.fields.filter((f) => f.type === FieldType.number);
+ const nTimestamps = joinedSeries[0].values.length;
+ const points = new Float64Array(joinedSeries.flatMap((series) => series.values as number[]));
+
+ try {
+ const detector = OutlierDetector.dbscan({ sensitivity: 0.4 }).preprocess(points, nTimestamps);
+ outliers = detector.detect();
+ } catch (e) {
+ console.error(e);
+ outliers = undefined;
+ }
+};
+
+let outliers: OutlierOutput | undefined = undefined;
+
+export const calculateOutlierValue = (series: DataFrame[], data: DataFrame): number => {
+ if (!wasmSupported()) {
+ throw new Error('WASM not supported, fall back to stdDev');
+ }
+ if (!outliers) {
+ throw new Error('Initialize outlier detector first');
+ }
+
+ const index = series.indexOf(data);
+ if (outliers.seriesResults[index].isOutlier) {
+ return outliers.seriesResults[index].outlierIntervals.length;
+ }
+
+ return 0;
+};
+
+export const sortSeriesByName = (series: DataFrame[], direction: string) => {
+ const sortedSeries = [...series];
+ sortedSeries.sort((a, b) => {
+ const valueA = getLabelValueFromDataFrame(a);
+ const valueB = getLabelValueFromDataFrame(b);
+ if (!valueA || !valueB) {
+ return 0;
+ }
+ return valueA?.localeCompare(valueB) ?? 0;
+ });
+ if (direction === 'desc') {
+ sortedSeries.reverse();
+ }
+ return sortedSeries;
+};
+
+export const wasmSupported = () => {
+ const support = typeof WebAssembly === 'object';
+
+ if (!support) {
+ reportExploreMetrics('wasm_not_supported', {});
+ }
+
+ return support;
+};
diff --git a/public/app/features/trails/services/store.ts b/public/app/features/trails/services/store.ts
index 94f9fb7fd5d..c745d22f2e6 100644
--- a/public/app/features/trails/services/store.ts
+++ b/public/app/features/trails/services/store.ts
@@ -1,4 +1,4 @@
-import { TRAIL_BREAKDOWN_VIEW_KEY } from '../shared';
+import { TRAIL_BREAKDOWN_VIEW_KEY, TRAIL_BREAKDOWN_SORT_KEY } from '../shared';
export function getVewByPreference() {
return localStorage.getItem(TRAIL_BREAKDOWN_VIEW_KEY) ?? 'grid';
@@ -7,3 +7,19 @@ export function getVewByPreference() {
export function setVewByPreference(value?: string) {
return localStorage.setItem(TRAIL_BREAKDOWN_VIEW_KEY, value ?? 'grid');
}
+
+export function getSortByPreference(target: string, defaultSortBy: string) {
+ const preference = localStorage.getItem(`${TRAIL_BREAKDOWN_SORT_KEY}.${target}.by`) ?? '';
+ const parts = preference.split('.');
+ if (!parts[0] || !parts[1]) {
+ return { sortBy: defaultSortBy };
+ }
+ return { sortBy: parts[0], direction: parts[1] };
+}
+
+export function setSortByPreference(target: string, sortBy: string) {
+ // Prevent storing empty values
+ if (sortBy) {
+ localStorage.setItem(`${TRAIL_BREAKDOWN_SORT_KEY}.${target}.by`, `${sortBy}`);
+ }
+}
diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts
index 013c72817c3..16e97986788 100644
--- a/public/app/features/trails/shared.ts
+++ b/public/app/features/trails/shared.ts
@@ -43,6 +43,7 @@ export const trailDS = { uid: VAR_DATASOURCE_EXPR };
export const RECENT_TRAILS_KEY = 'grafana.trails.recent';
export const TRAIL_BOOKMARKS_KEY = 'grafana.trails.bookmarks';
export const TRAIL_BREAKDOWN_VIEW_KEY = 'grafana.trails.breakdown.view';
+export const TRAIL_BREAKDOWN_SORT_KEY = 'grafana.trails.breakdown.sort';
export const MDP_METRIC_PREVIEW = 250;
export const MDP_METRIC_OVERVIEW = 500;
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 526c5f9aace..d308b78e78a 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -1049,7 +1049,8 @@
"breakdown": {
"clearFilter": "Clear filter",
"labelSelect": "Select",
- "noMatchingValue": "No values found matching; {{filter}}"
+ "noMatchingValue": "No values found matching; {{filter}}",
+ "sortBy": "Sort by"
},
"viewBy": "View by"
},
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 859c47639f7..b9d8c6e1489 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -1049,7 +1049,8 @@
"breakdown": {
"clearFilter": "Cľęäř ƒįľŧęř",
"labelSelect": "Ŝęľęčŧ",
- "noMatchingValue": "Ńő väľūęş ƒőūʼnđ mäŧčĥįʼnģ; {{filter}}"
+ "noMatchingValue": "Ńő väľūęş ƒőūʼnđ mäŧčĥįʼnģ; {{filter}}",
+ "sortBy": "Ŝőřŧ þy"
},
"viewBy": "Vįęŵ þy"
},
diff --git a/public/test/mocks/augurs.ts b/public/test/mocks/augurs.ts
new file mode 100644
index 00000000000..6b4a146c674
--- /dev/null
+++ b/public/test/mocks/augurs.ts
@@ -0,0 +1,32 @@
+import type {
+ LoadedOutlierDetector as AugursLoadedOutlierDetector,
+ OutlierDetector as AugursOutlierDetector,
+ OutlierDetectorOptions,
+ OutlierOutput,
+} from '@bsull/augurs';
+
+export default function init() {}
+
+const dummyOutliers: OutlierOutput = {
+ outlyingSeries: [],
+ clusterBand: { min: [], max: [] },
+ seriesResults: [],
+};
+
+export class OutlierDetector implements AugursOutlierDetector {
+ free(): void {}
+ detect(): OutlierOutput {
+ return dummyOutliers;
+ }
+ preprocess(y: Float64Array, nTimestamps: number): AugursLoadedOutlierDetector {
+ return new LoadedOutlierDetector();
+ }
+}
+
+export class LoadedOutlierDetector implements AugursLoadedOutlierDetector {
+ detect(): OutlierOutput {
+ return dummyOutliers;
+ }
+ free(): void {}
+ updateDetector(options: OutlierDetectorOptions): void {}
+}
diff --git a/scripts/webpack/webpack.common.js b/scripts/webpack/webpack.common.js
index 5ee21c1a952..5c2aacf59c2 100644
--- a/scripts/webpack/webpack.common.js
+++ b/scripts/webpack/webpack.common.js
@@ -9,6 +9,10 @@ module.exports = {
app: './public/app/index.ts',
swagger: './public/swagger/index.tsx',
},
+ experiments: {
+ // Required to load WASM modules.
+ asyncWebAssembly: true,
+ },
output: {
clean: true,
path: path.resolve(__dirname, '../../public/build'),
diff --git a/yarn.lock b/yarn.lock
index 66051882eb4..b3061377095 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1703,6 +1703,13 @@ __metadata:
languageName: node
linkType: hard
+"@bsull/augurs@npm:0.4.2":
+ version: 0.4.2
+ resolution: "@bsull/augurs@npm:0.4.2"
+ checksum: 10/b4806fed1daf76380577b4ca86c2bf94c00ce1033df0b8365229a55ff16bd2f864d3f9ca3f92faf284e62013b68e953be65ee78968931ba342be0e1c054c2e63
+ languageName: node
+ linkType: hard
+
"@bundled-es-modules/cookie@npm:^2.0.0":
version: 2.0.0
resolution: "@bundled-es-modules/cookie@npm:2.0.0"
@@ -19049,6 +19056,7 @@ __metadata:
"@betterer/betterer": "npm:5.4.0"
"@betterer/cli": "npm:5.4.0"
"@betterer/eslint": "npm:5.4.0"
+ "@bsull/augurs": "npm:0.4.2"
"@cypress/webpack-preprocessor": "npm:6.0.2"
"@emotion/css": "npm:11.13.4"
"@emotion/eslint-plugin": "npm:11.12.0"