mirror of https://github.com/grafana/grafana
Explore Metrics: Introduce augurs sorting options in breakdown view (#91189)
* refactor breakdown scene * refactor BreakdownScene along with LayoutSwitcher * rename * don't pass default layout * better type handling * betterer * add @bsull/augurs * implement LabelBreakdownScene * integrate SortByScene in LabelBreakdownScene * move to new directory * introduce BreakdownSearchScene * integrate searchScene * cleaning * initialize @bsull/augurs * add interaction * use new breakdown scene * resolve merge conflicts * ugrade @bsull/augurs * update import * update css * update tooltip text * refine sorting * fix unit test * fix * implement outlier detector * support wasm * jest testing fix * localization fix * use unknown instead of any * update i18n * update betterer * fix locales * update test * fix tests maybe * prettier * chore: update jest config * chore: create mock for @bsull/augurs (#92156) chore: create mock for bsull/augurs @bsull/augurs assumes it will be running as an ESM, not a CommonJS module, so can't be loaded by Jest (specifically because it contains a reference to import.meta.url). This PR provides a mock implementation which gets tests passing again. Ideally we'd be able to load the actual @bsull/augurs module in tests so this is just a stopgap really, until a better solution appears. * fix unit tests * remove unused BreakdownScene.tsx * set outliers as undefined if an error occurs * Add labels * betterer * reset event implemented * fix controls positioning * update augurs * betterer * i18n * conflict fixes * update texts --------- Co-authored-by: Matias Chomicki <matyax@gmail.com> Co-authored-by: Ben Sully <ben.sully@grafana.com>pull/95781/head
parent
297ccfc52c
commit
bcdcb1f74b
@ -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<SortBySceneState> { |
||||
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<SortBySceneState, 'target'>) { |
||||
const { sortBy } = getSortByPreference(state.target, 'outliers'); |
||||
super({ |
||||
target: state.target, |
||||
sortBy, |
||||
}); |
||||
} |
||||
|
||||
public onCriteriaChange = (criteria: SelectableValue<string>) => { |
||||
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<SortByScene>) => { |
||||
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 ( |
||||
<Field |
||||
htmlFor="sort-by-criteria" |
||||
label={ |
||||
<div className={styles.sortByTooltip}> |
||||
<Trans i18nKey="explore-metrics.breakdown.sortBy">Sort by</Trans> |
||||
<IconButton |
||||
name={'info-circle'} |
||||
size="sm" |
||||
variant={'secondary'} |
||||
tooltip="Sorts values using standard or smart time series calculations." |
||||
/> |
||||
</div> |
||||
} |
||||
> |
||||
<Select |
||||
value={value} |
||||
width={20} |
||||
isSearchable={true} |
||||
options={model.sortingOptions} |
||||
placeholder={'Choose criteria'} |
||||
onChange={model.onCriteriaChange} |
||||
inputId="sort-by-criteria" |
||||
/> |
||||
</Field> |
||||
); |
||||
}; |
||||
} |
||||
|
||||
function getStyles(theme: GrafanaTheme2) { |
||||
return { |
||||
sortByTooltip: css({ |
||||
display: 'flex', |
||||
gap: theme.spacing(1), |
||||
}), |
||||
}; |
||||
} |
@ -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); |
||||
}); |
||||
}); |
@ -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; |
||||
}; |
@ -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 {} |
||||
} |
Loading…
Reference in new issue