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