Chore: Refactor explore metrics breakdown scene (#93368)

* 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

* remove sorting

* fix type guard

* more clean up

* remove wasm support from webpack

* betterer

---------

Co-authored-by: Matias Chomicki <matyax@gmail.com>
Co-authored-by: Ben Sully <ben.sully@grafana.com>
This commit is contained in:
ismail simsek 2024-09-20 10:22:34 +02:00 committed by GitHub
parent 74b2d38e37
commit 19c7e1f376
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 525 additions and 215 deletions

View File

@ -5265,13 +5265,13 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "10"],
[0, 0, 0, "Do not use any type assertions.", "11"]
],
"public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx:5381": [
"public/app/features/trails/Breakdown/AddToFiltersGraphAction.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/trails/ActionTabs/BreakdownScene.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
"public/app/features/trails/Breakdown/types.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/trails/ActionTabs/utils.ts:5381": [
"public/app/features/trails/Breakdown/utils.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/trails/DataTrailCard.tsx:5381": [

View File

@ -1,92 +0,0 @@
import { LoadingState, PanelData, DataFrame } from '@grafana/data';
import {
SceneObjectState,
SceneFlexItem,
SceneObjectBase,
sceneGraph,
SceneComponentProps,
SceneLayout,
SceneDataNode,
} from '@grafana/scenes';
import { StatusWrapper } from '../StatusWrapper';
import { findSceneObjectsByType } from './utils';
interface ByFrameRepeaterState extends SceneObjectState {
body: SceneLayout;
getLayoutChild(data: PanelData, frame: DataFrame, frameIndex: number): SceneFlexItem;
}
export class ByFrameRepeater extends SceneObjectBase<ByFrameRepeaterState> {
public constructor(state: ByFrameRepeaterState) {
super(state);
this.addActivationHandler(() => {
const data = sceneGraph.getData(this);
this._subs.add(
data.subscribeToState((newState, oldState) => {
if (newState.data === undefined) {
return;
}
const newData = newState.data;
if (newState.data !== undefined && newState.data?.state !== oldState.data?.state) {
findSceneObjectsByType(this, SceneDataNode).forEach((dataNode) => {
dataNode.setState({ data: { ...dataNode.state.data, state: newData.state } });
});
}
if (newData.state === LoadingState.Done) {
this.performRepeat(newData);
}
})
);
if (data.state.data) {
this.performRepeat(data.state.data);
}
});
}
private performRepeat(data: PanelData) {
const newChildren: SceneFlexItem[] = [];
for (let seriesIndex = 0; seriesIndex < data.series.length; seriesIndex++) {
const frame = data.series[seriesIndex];
if (frame.length <= 1) {
// If the data doesn't have at least two points, we skip it.
continue;
}
const layoutChild = this.state.getLayoutChild(data, frame, seriesIndex);
newChildren.push(layoutChild);
}
this.state.body.setState({ children: newChildren });
this.setState({ body: this.state.body });
}
public static Component = ({ model }: SceneComponentProps<ByFrameRepeater>) => {
const { body } = model.useState();
const { children } = body.useState();
const data = sceneGraph.getData(model);
const sceneDataState = data.useState();
const panelData = sceneDataState?.data;
const isLoading = panelData?.state === 'Loading' && children.length === 0;
const error = panelData?.state === LoadingState.Error ? 'Failed to load data.' : undefined;
const blockingMessage =
!isLoading && children.length === 0 && !error
? 'There is no data available. Try adjusting your filters, time range, or selecting a different label.'
: undefined;
return (
<StatusWrapper {...{ blockingMessage, error, isLoading }}>
<body.Component model={body} />
</StatusWrapper>
);
};
}

View File

@ -0,0 +1,68 @@
import { ChangeEvent } from 'react';
import { BusEventBase } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { ByFrameRepeater } from './ByFrameRepeater';
import { LabelBreakdownScene } from './LabelBreakdownScene';
import { SearchInput } from './SearchInput';
export class BreakdownSearchReset extends BusEventBase {
public static type = 'breakdown-search-reset';
}
export interface BreakdownSearchSceneState extends SceneObjectState {
filter?: string;
}
const recentFilters: Record<string, string> = {};
export class BreakdownSearchScene extends SceneObjectBase<BreakdownSearchSceneState> {
private cacheKey: string;
constructor(cacheKey: string) {
super({
filter: recentFilters[cacheKey] ?? '',
});
this.cacheKey = cacheKey;
}
public static Component = ({ model }: SceneComponentProps<BreakdownSearchScene>) => {
const { filter } = model.useState();
return (
<SearchInput
value={filter}
onChange={model.onValueFilterChange}
onClear={model.clearValueFilter}
placeholder="Search for value"
/>
);
};
public onValueFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ filter: event.target.value });
this.filterValues(event.target.value);
};
public clearValueFilter = () => {
this.setState({ filter: '' });
this.filterValues('');
};
public reset = () => {
this.setState({ filter: '' });
recentFilters[this.cacheKey] = '';
};
private filterValues(filter: string) {
if (this.parent instanceof LabelBreakdownScene) {
recentFilters[this.cacheKey] = filter;
const body = this.parent.state.body;
body?.forEachChild((child) => {
if (child instanceof ByFrameRepeater && child.state.body.isActive) {
child.filterByString(filter);
}
});
}
}
}

View File

@ -0,0 +1,189 @@
import { css } from '@emotion/css';
import { DataFrame, LoadingState, PanelData } from '@grafana/data';
import {
SceneByFrameRepeater,
SceneComponentProps,
SceneDataNode,
SceneFlexItem,
SceneFlexLayout,
sceneGraph,
SceneLayout,
SceneObjectBase,
SceneObjectState,
SceneReactObject,
} from '@grafana/scenes';
import { Alert, Button } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { getLabelValueFromDataFrame } from '../services/levels';
import { fuzzySearch } from '../services/search';
import { BreakdownSearchReset } from './BreakdownSearchScene';
import { findSceneObjectsByType } from './utils';
interface ByFrameRepeaterState extends SceneObjectState {
body: SceneLayout;
getLayoutChild(data: PanelData, frame: DataFrame, frameIndex: number): SceneFlexItem;
}
type FrameFilterCallback = (frame: DataFrame) => boolean;
type FrameIterateCallback = (frames: DataFrame[], seriesIndex: number) => void;
export class ByFrameRepeater extends SceneObjectBase<ByFrameRepeaterState> {
private unfilteredChildren: SceneFlexItem[] = [];
private series: DataFrame[] = [];
private getFilter: () => string;
public constructor({ getFilter, ...state }: ByFrameRepeaterState & { getFilter: () => string }) {
super(state);
this.getFilter = getFilter;
this.addActivationHandler(() => {
const data = sceneGraph.getData(this);
this._subs.add(
data.subscribeToState((newState, oldState) => {
if (newState.data === undefined) {
return;
}
const newData = newState.data;
if (newState.data?.state !== oldState.data?.state) {
findSceneObjectsByType(this, SceneDataNode).forEach((dataNode) => {
dataNode.setState({ data: { ...dataNode.state.data, state: newData.state } });
});
}
if (newData.state === LoadingState.Done) {
this.performRepeat(newData);
}
})
);
if (data.state.data) {
this.performRepeat(data.state.data);
}
});
}
private performRepeat(data: PanelData) {
const newChildren: SceneFlexItem[] = [];
this.series = data.series;
for (let seriesIndex = 0; seriesIndex < this.series.length; seriesIndex++) {
const layoutChild = this.state.getLayoutChild(data, this.series[seriesIndex], seriesIndex);
newChildren.push(layoutChild);
}
this.unfilteredChildren = newChildren;
if (this.getFilter()) {
this.state.body.setState({ children: [] });
this.filterByString(this.getFilter());
} else {
this.state.body.setState({ children: newChildren });
}
}
filterByString = (filter: string) => {
let haystack: string[] = [];
this.iterateFrames((frames, seriesIndex) => {
const labelValue = getLabelValue(frames[seriesIndex]);
haystack.push(labelValue);
});
fuzzySearch(haystack, filter, (data) => {
if (data && data[0]) {
// We got search results
this.filterFrames((frame: DataFrame) => {
const label = getLabelValue(frame);
return data[0].includes(label);
});
} else {
// reset search
this.filterFrames(() => true);
}
});
};
public iterateFrames = (callback: FrameIterateCallback) => {
const data = sceneGraph.getData(this).state.data;
if (!data) {
return;
}
for (let seriesIndex = 0; seriesIndex < this.series.length; seriesIndex++) {
callback(this.series, seriesIndex);
}
};
public filterFrames = (filterFn: FrameFilterCallback) => {
const newChildren: SceneFlexItem[] = [];
this.iterateFrames((frames, seriesIndex) => {
if (filterFn(frames[seriesIndex])) {
newChildren.push(this.unfilteredChildren[seriesIndex]);
}
});
if (newChildren.length === 0) {
this.state.body.setState({ children: [buildNoResultsScene(this.getFilter(), this.clearFilter)] });
} else {
this.state.body.setState({ children: newChildren });
}
};
public clearFilter = () => {
this.publishEvent(new BreakdownSearchReset(), true);
};
public static Component = ({ model }: SceneComponentProps<SceneByFrameRepeater>) => {
const { body } = model.useState();
return <body.Component model={body} />;
};
}
function buildNoResultsScene(filter: string, clearFilter: () => void) {
return new SceneFlexLayout({
direction: 'row',
children: [
new SceneFlexItem({
body: new SceneReactObject({
reactNode: (
<div className={styles.alertContainer}>
<Alert title="" severity="info" className={styles.noResultsAlert}>
<Trans i18nKey="explore-metrics.breakdown.noMatchingValue">
No values found matching; {{ filter }}
</Trans>
<Button className={styles.clearButton} onClick={clearFilter}>
<Trans i18nKey="explore-metrics.breakdown.clearFilter">Clear filter</Trans>
</Button>
</Alert>
</div>
),
}),
}),
],
});
}
const styles = {
alertContainer: css({
flexGrow: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}),
noResultsAlert: css({
minWidth: '30vw',
flexGrow: 0,
}),
clearButton: css({
marginLeft: '1.5rem',
}),
};
function getLabelValue(frame: DataFrame) {
return getLabelValueFromDataFrame(frame) ?? 'No labels';
}

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { min, max, isNumber, throttle } from 'lodash';
import { isNumber, max, min, throttle } from 'lodash';
import { DataFrame, FieldType, GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data';
import {
@ -17,12 +17,14 @@ import {
SceneObjectBase,
SceneObjectState,
SceneQueryRunner,
SceneReactObject,
VariableDependencyConfig,
VizPanel,
} from '@grafana/scenes';
import { DataQuery } from '@grafana/schema';
import { Button, Field, useStyles2 } from '@grafana/ui';
import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { Button, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { Field } from '@grafana/ui/';
import { Trans } from 'app/core/internationalization';
import { getAutoQueriesForMetric } from '../AutomaticMetricQueries/AutoQueryEngine';
import { AutoQueryDef } from '../AutomaticMetricQueries/types';
@ -30,10 +32,12 @@ import { BreakdownLabelSelector } from '../BreakdownLabelSelector';
import { MetricScene } from '../MetricScene';
import { StatusWrapper } from '../StatusWrapper';
import { reportExploreMetrics } from '../interactions';
import { ALL_VARIABLE_VALUE } from '../services/variables';
import { trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP } from '../shared';
import { getColorByIndex, getTrailFor } from '../utils';
import { AddToFiltersGraphAction } from './AddToFiltersGraphAction';
import { BreakdownSearchReset, BreakdownSearchScene } from './BreakdownSearchScene';
import { ByFrameRepeater } from './ByFrameRepeater';
import { LayoutSwitcher } from './LayoutSwitcher';
import { breakdownPanelOptions } from './panelConfigs';
@ -43,8 +47,9 @@ import { BreakdownAxisChangeEvent, yAxisSyncBehavior } from './yAxisSyncBehavior
const MAX_PANELS_IN_ALL_LABELS_BREAKDOWN = 60;
export interface BreakdownSceneState extends SceneObjectState {
body?: SceneObject;
export interface LabelBreakdownSceneState extends SceneObjectState {
body?: LayoutSwitcher;
search: BreakdownSearchScene;
labels: Array<SelectableValue<string>>;
value?: string;
loading?: boolean;
@ -52,16 +57,17 @@ export interface BreakdownSceneState extends SceneObjectState {
blockingMessage?: string;
}
export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
export class LabelBreakdownScene extends SceneObjectBase<LabelBreakdownSceneState> {
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [VAR_FILTERS],
onReferencedVariableValueChanged: this.onReferencedVariableValueChanged.bind(this),
});
constructor(state: Partial<BreakdownSceneState>) {
constructor(state: Partial<LabelBreakdownSceneState>) {
super({
labels: state.labels ?? [],
...state,
labels: state.labels ?? [],
search: new BreakdownSearchScene('labels'),
});
this.addActivationHandler(this._onActivate.bind(this));
@ -82,6 +88,12 @@ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
}
});
this._subs.add(
this.subscribeToEvent(BreakdownSearchReset, () => {
this.state.search.clearValueFilter();
})
);
const metricScene = sceneGraph.getAncestor(this, MetricScene);
const metric = metricScene.state.metric;
this._query = getAutoQueriesForMetric(metric).breakdown;
@ -103,6 +115,7 @@ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
private breakdownPanelMaxValue: number | undefined;
private breakdownPanelMinValue: number | undefined;
public reportBreakdownPanelData(data: PanelData | undefined) {
if (!data) {
return;
@ -170,7 +183,7 @@ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
private updateBody(variable: QueryVariable) {
const options = getLabelOptions(this, variable);
const stateUpdate: Partial<BreakdownSceneState> = {
const stateUpdate: Partial<LabelBreakdownSceneState> = {
loading: variable.state.loading,
value: String(variable.state.value),
labels: options,
@ -181,7 +194,7 @@ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
if (!variable.state.loading && variable.state.options.length) {
stateUpdate.body = variable.hasAllValue()
? buildAllLayout(options, this._query!, this.onBreakdownLayoutChange)
: buildNormalLayout(this._query!, this.onBreakdownLayoutChange);
: buildNormalLayout(this._query!, this.onBreakdownLayoutChange, this.state.search);
} else if (!variable.state.loading) {
stateUpdate.body = undefined;
stateUpdate.blockingMessage = 'Unable to retrieve label options for currently selected metric.';
@ -207,8 +220,8 @@ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
variable.changeValueTo(value);
};
public static Component = ({ model }: SceneComponentProps<BreakdownScene>) => {
const { labels, body, loading, value, blockingMessage } = model.useState();
public static Component = ({ model }: SceneComponentProps<LabelBreakdownScene>) => {
const { labels, body, search, loading, value, blockingMessage } = model.useState();
const styles = useStyles2(getStyles);
const { useOtelExperience } = getTrailFor(model).useState();
@ -218,16 +231,20 @@ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
<StatusWrapper {...{ isLoading: loading, blockingMessage }}>
<div className={styles.controls}>
{!loading && labels.length && (
<div className={styles.controlsLeft}>
<Field label={useOtelExperience ? 'By metric attribute' : 'By label'}>
<BreakdownLabelSelector options={labels} value={value} onChange={model.onChange} />
</Field>
</div>
<Field label={useOtelExperience ? 'By metric attribute' : 'By label'}>
<BreakdownLabelSelector options={labels} value={value} onChange={model.onChange} />
</Field>
)}
{value !== ALL_VARIABLE_VALUE && (
<Field label="Search" className={styles.searchField}>
<search.Component model={search} />
</Field>
)}
{body instanceof LayoutSwitcher && (
<div className={styles.controlsRight}>
<Field label="View">
<body.Selector model={body} />
</div>
</Field>
)}
</div>
<div className={styles.content}>{body && <body.Component model={body} />}</div>
@ -244,29 +261,22 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
minHeight: '100%',
flexDirection: 'column',
paddingTop: theme.spacing(1),
}),
content: css({
flexGrow: 1,
display: 'flex',
paddingTop: theme.spacing(0),
}),
searchField: css({
flexGrow: 1,
}),
controls: css({
flexGrow: 0,
display: 'flex',
alignItems: 'top',
alignItems: 'flex-end',
gap: theme.spacing(2),
}),
controlsRight: css({
flexGrow: 0,
display: 'flex',
justifyContent: 'flex-end',
}),
controlsLeft: css({
display: 'flex',
justifyContent: 'flex-left',
justifyItems: 'left',
width: '100%',
flexDirection: 'column',
justifyContent: 'space-between',
}),
};
}
@ -347,7 +357,11 @@ export function buildAllLayout(
const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))';
function buildNormalLayout(queryDef: AutoQueryDef, onBreakdownLayoutChange: BreakdownLayoutChangeCallback) {
function buildNormalLayout(
queryDef: AutoQueryDef,
onBreakdownLayoutChange: BreakdownLayoutChangeCallback,
searchScene: BreakdownSearchScene
) {
const unit = queryDef.unit;
function getLayoutChild(data: PanelData, frame: DataFrame, frameIndex: number): SceneFlexItem {
@ -376,6 +390,8 @@ function buildNormalLayout(queryDef: AutoQueryDef, onBreakdownLayoutChange: Brea
return item;
}
const getFilter = () => searchScene.state.filter ?? '';
return new LayoutSwitcher({
$data: new SceneQueryRunner({
datasource: trailDS,
@ -402,9 +418,16 @@ function buildNormalLayout(queryDef: AutoQueryDef, onBreakdownLayoutChange: Brea
body: new SceneCSSGridLayout({
templateColumns: GRID_TEMPLATE_COLUMNS,
autoRows: '200px',
children: [],
children: [
new SceneFlexItem({
body: new SceneReactObject({
reactNode: <LoadingPlaceholder text="Loading..." />,
}),
}),
],
}),
getLayoutChild,
getFilter,
}),
new ByFrameRepeater({
body: new SceneCSSGridLayout({
@ -413,6 +436,7 @@ function buildNormalLayout(queryDef: AutoQueryDef, onBreakdownLayoutChange: Brea
children: [],
}),
getLayoutChild,
getFilter,
}),
],
});
@ -429,13 +453,14 @@ function getLabelValue(frame: DataFrame) {
return labels[keys[0]];
}
export function buildBreakdownActionScene() {
return new BreakdownScene({});
export function buildLabelBreakdownActionScene() {
return new LabelBreakdownScene({});
}
interface SelectLabelActionState extends SceneObjectState {
labelName: string;
}
export class SelectLabelAction extends SceneObjectBase<SelectLabelActionState> {
public onClick = () => {
const label = this.state.labelName;
@ -446,14 +471,14 @@ export class SelectLabelAction extends SceneObjectBase<SelectLabelActionState> {
public static Component = ({ model }: SceneComponentProps<AddToFiltersGraphAction>) => {
return (
<Button variant="secondary" size="sm" fill="solid" onClick={model.onClick}>
Select
<Trans i18nKey="explore-metrics.breakdown.labelSelect">Select</Trans>
</Button>
);
};
}
function getBreakdownSceneFor(model: SceneObject): BreakdownScene {
if (model instanceof BreakdownScene) {
function getBreakdownSceneFor(model: SceneObject): LabelBreakdownScene {
if (model instanceof LabelBreakdownScene) {
return model;
}

View File

@ -8,12 +8,13 @@ import {
SceneObjectUrlValues,
SceneObjectWithUrlSync,
} from '@grafana/scenes';
import { Field, RadioButtonGroup } from '@grafana/ui';
import { RadioButtonGroup } from '@grafana/ui';
import { reportExploreMetrics } from '../interactions';
import { MakeOptional, TRAIL_BREAKDOWN_VIEW_KEY } from '../shared';
import { getVewByPreference, setVewByPreference } from '../services/store';
import { MakeOptional } from '../shared';
import { isBreakdownLayoutType, BreakdownLayoutChangeCallback, BreakdownLayoutType } from './types';
import { BreakdownLayoutChangeCallback, BreakdownLayoutType, isBreakdownLayoutType } from './types';
export interface LayoutSwitcherState extends SceneObjectState {
activeBreakdownLayout: BreakdownLayoutType;
@ -26,7 +27,7 @@ export class LayoutSwitcher extends SceneObjectBase<LayoutSwitcherState> impleme
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['breakdownLayout'] });
public constructor(state: MakeOptional<LayoutSwitcherState, 'activeBreakdownLayout'>) {
const storedBreakdownLayout = localStorage.getItem(TRAIL_BREAKDOWN_VIEW_KEY);
const storedBreakdownLayout = getVewByPreference();
super({
activeBreakdownLayout: isBreakdownLayoutType(storedBreakdownLayout) ? storedBreakdownLayout : 'grid',
...state,
@ -50,13 +51,11 @@ export class LayoutSwitcher extends SceneObjectBase<LayoutSwitcherState> impleme
const { activeBreakdownLayout, breakdownLayoutOptions } = model.useState();
return (
<Field label="View">
<RadioButtonGroup
options={breakdownLayoutOptions}
value={activeBreakdownLayout}
onChange={model.onLayoutChange}
/>
</Field>
<RadioButtonGroup
options={breakdownLayoutOptions}
value={activeBreakdownLayout}
onChange={model.onLayoutChange}
/>
);
}
@ -66,7 +65,7 @@ export class LayoutSwitcher extends SceneObjectBase<LayoutSwitcherState> impleme
}
reportExploreMetrics('breakdown_layout_changed', { layout: active });
localStorage.setItem(TRAIL_BREAKDOWN_VIEW_KEY, active);
setVewByPreference(active);
this.setState({ activeBreakdownLayout: active });
this.state.onBreakdownLayoutChange(active);
};

View File

@ -0,0 +1,29 @@
import { css } from '@emotion/css';
import { HTMLProps } from 'react';
import { Icon, Input } from '@grafana/ui';
interface Props extends Omit<HTMLProps<HTMLInputElement>, 'width'> {
onClear(): void;
}
export const SearchInput = ({ value, onChange, placeholder, onClear, ...rest }: Props) => {
return (
<Input
value={value}
onChange={onChange}
suffix={
value ? <Icon onClick={onClear} title={'Clear search'} name="times" className={styles.clearIcon} /> : undefined
}
prefix={<Icon name="search" />}
placeholder={placeholder}
{...rest}
/>
);
};
const styles = {
clearIcon: css({
cursor: 'pointer',
}),
};

View File

@ -0,0 +1,21 @@
import { isBreakdownLayoutType } from './types';
describe('types', () => {
it('isBreakdownLayoutType should return true for rows', () => {
const expected = true;
const result = isBreakdownLayoutType('rows');
expect(result).toBe(expected);
});
it('isBreakdownLayoutType should return false for undefined', () => {
const expected = false;
const result1 = isBreakdownLayoutType(undefined);
const result2 = isBreakdownLayoutType('undefined');
const result3 = isBreakdownLayoutType(null);
const result4 = isBreakdownLayoutType('null');
expect(result1).toBe(expected);
expect(result2).toBe(expected);
expect(result3).toBe(expected);
expect(result4).toBe(expected);
});
});

View File

@ -5,7 +5,7 @@ export type BreakdownLayoutType = (typeof BREAKDOWN_LAYOUT_TYPES)[number];
export function isBreakdownLayoutType(
breakdownLayoutType: string | null | undefined
): breakdownLayoutType is BreakdownLayoutType {
return !!breakdownLayoutType && breakdownLayoutType in BREAKDOWN_LAYOUT_TYPES;
return BREAKDOWN_LAYOUT_TYPES.includes(breakdownLayoutType as BreakdownLayoutType);
}
export type BreakdownLayoutChangeCallback = (newBreakdownLayout: BreakdownLayoutType) => void;

View File

@ -3,12 +3,12 @@ import {
FieldConfigBuilders,
SceneCSSGridItem,
SceneDataProvider,
sceneGraph,
SceneStatelessBehavior,
VizPanel,
sceneGraph,
} from '@grafana/scenes';
import { BreakdownScene } from './BreakdownScene';
import { LabelBreakdownScene } from './LabelBreakdownScene';
import { findSceneObjectsByType } from './utils';
export class BreakdownAxisChangeEvent extends BusEventWithPayload<{ min: number; max: number }> {
@ -16,7 +16,7 @@ export class BreakdownAxisChangeEvent extends BusEventWithPayload<{ min: number;
}
export const yAxisSyncBehavior: SceneStatelessBehavior = (sceneObject: SceneCSSGridItem) => {
const breakdownScene = sceneGraph.getAncestor(sceneObject, BreakdownScene);
const breakdownScene = sceneGraph.getAncestor(sceneObject, LabelBreakdownScene);
// Handle query runners from vizPanels that haven't been activated yet
findSceneObjectsByType(sceneObject, VizPanel).forEach((vizPanel) => {

View File

@ -1,9 +1,7 @@
import { css } from '@emotion/css';
import { useResizeObserver } from '@react-aria/utils';
import { useEffect, useRef, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Select, RadioButtonGroup, useStyles2, useTheme2, measureText } from '@grafana/ui';
import { Select, useStyles2 } from '@grafana/ui';
type Props = {
options: Array<SelectableValue<string>>;
@ -13,42 +11,8 @@ type Props = {
export function BreakdownLabelSelector({ options, value, onChange }: Props) {
const styles = useStyles2(getStyles);
const theme = useTheme2();
const [labelSelectorRequiredWidth, setLabelSelectorRequiredWidth] = useState<number>(0);
const [availableWidth, setAvailableWidth] = useState<number>(0);
const useHorizontalLabelSelector = availableWidth > labelSelectorRequiredWidth;
const controlsContainer = useRef<HTMLDivElement>(null);
useResizeObserver({
ref: controlsContainer,
onResize: () => {
const element = controlsContainer.current;
if (element) {
setAvailableWidth(element.clientWidth);
}
},
});
useEffect(() => {
const { fontSize } = theme.typography;
const text = options.map((option) => option.label || option.value || '').join(' ');
const textWidth = measureText(text, fontSize).width;
const additionalWidthPerItem = 32;
setLabelSelectorRequiredWidth(textWidth + additionalWidthPerItem * options.length);
}, [options, theme]);
return (
<div ref={controlsContainer}>
{useHorizontalLabelSelector ? (
<RadioButtonGroup {...{ options, value, onChange }} />
) : (
<Select {...{ options, value }} onChange={(selected) => onChange(selected.value)} className={styles.select} />
)}
</div>
);
return <Select {...{ options, value }} onChange={(selected) => onChange(selected.value)} className={styles.select} />;
}
function getStyles(theme: GrafanaTheme2) {

View File

@ -16,11 +16,11 @@ import { Box, Icon, LinkButton, Stack, Tab, TabsBar, ToolbarButton, Tooltip, use
import { getExploreUrl } from '../../core/utils/explore';
import { buildBreakdownActionScene } from './ActionTabs/BreakdownScene';
import { buildMetricOverviewScene } from './ActionTabs/MetricOverviewScene';
import { buildRelatedMetricsScene } from './ActionTabs/RelatedMetricsScene';
import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine';
import { AutoQueryDef, AutoQueryInfo } from './AutomaticMetricQueries/types';
import { buildLabelBreakdownActionScene } from './Breakdown/LabelBreakdownScene';
import { MAIN_PANEL_MAX_HEIGHT, MAIN_PANEL_MIN_HEIGHT, MetricGraphScene } from './MetricGraphScene';
import { ShareTrailButton } from './ShareTrailButton';
import { useBookmarkState } from './TrailStore/useBookmarkState';
@ -110,7 +110,7 @@ export class MetricScene extends SceneObjectBase<MetricSceneState> {
const actionViewsDefinitions: ActionViewDefinition[] = [
{ displayName: 'Overview', value: 'overview', getScene: buildMetricOverviewScene },
{ displayName: 'Breakdown', value: 'breakdown', getScene: buildBreakdownActionScene },
{ displayName: 'Breakdown', value: 'breakdown', getScene: buildLabelBreakdownActionScene },
{
displayName: 'Related metrics',
value: 'related',

View File

@ -1,7 +1,7 @@
import { AdHocVariableFilter } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { BreakdownLayoutType } from './ActionTabs/types';
import { BreakdownLayoutType } from './Breakdown/types';
import { TrailStepType } from './DataTrailsHistory';
import { ActionViewType } from './shared';
@ -110,6 +110,7 @@ type Interactions = {
| 'close'
)
};
wasm_not_supported: {},
};
const PREFIX = 'grafana_explore_metrics_';

View File

@ -0,0 +1,25 @@
import { FieldType, toDataFrame } from '@grafana/data';
import { getLabelValueFromDataFrame } from './levels';
describe('getLabelValueFromDataFrame', () => {
it('returns correct label value from data frame', () => {
expect(
getLabelValueFromDataFrame(
toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [0] },
{
name: 'Value',
type: FieldType.number,
values: [1],
labels: {
detected_level: 'warn',
},
},
],
})
)
).toEqual('warn');
});
});

View File

@ -0,0 +1,16 @@
import { DataFrame } from '@grafana/data';
export function getLabelValueFromDataFrame(frame: DataFrame) {
const labels = frame.fields[1]?.labels;
if (!labels) {
return null;
}
const keys = Object.keys(labels);
if (keys.length === 0) {
return null;
}
return labels[keys[0]];
}

View File

@ -0,0 +1,45 @@
import uFuzzy from '@leeoniya/ufuzzy';
import { debounce as debounceLodash } from 'lodash';
const uf = new uFuzzy({
intraMode: 1,
intraIns: 1,
intraSub: 1,
intraTrn: 1,
intraDel: 1,
});
export function fuzzySearch(haystack: string[], query: string, callback: (data: string[][]) => void) {
const [idxs, info, order] = uf.search(haystack, query, 0, 1e5);
let haystackOrder: string[] = [];
let matchesSet: Set<string> = new Set();
if (idxs && order) {
/**
* get the fuzzy matches for highlighting
* @param part
* @param matched
*/
const mark = (part: string, matched: boolean) => {
if (matched) {
matchesSet.add(part);
}
};
// Iterate to create the order of needles(queries) and the matches
for (let i = 0; i < order.length; i++) {
let infoIdx = order[i];
/** Evaluate the match, get the matches for highlighting */
uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark);
/** Get the order */
haystackOrder.push(haystack[info.idx[infoIdx]]);
}
callback([haystackOrder, [...matchesSet]]);
} else if (!query) {
callback([]);
}
}
export const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300);

View File

@ -0,0 +1,9 @@
import { TRAIL_BREAKDOWN_VIEW_KEY } from '../shared';
export function getVewByPreference() {
return localStorage.getItem(TRAIL_BREAKDOWN_VIEW_KEY) ?? 'grid';
}
export function setVewByPreference(value?: string) {
return localStorage.setItem(TRAIL_BREAKDOWN_VIEW_KEY, value ?? 'grid');
}

View File

@ -0,0 +1 @@
export const ALL_VARIABLE_VALUE = '$__all';

View File

@ -2,7 +2,7 @@ import { BusEventWithPayload } from '@grafana/data';
import { ConstantVariable, SceneObject } from '@grafana/scenes';
import { VariableHide } from '@grafana/schema';
export type ActionViewType = 'overview' | 'breakdown' | 'logs' | 'related';
export type ActionViewType = 'overview' | 'breakdown' | 'label-breakdown' | 'logs' | 'related';
export interface ActionViewDefinition {
displayName: string;
value: ActionViewType;

View File

@ -119,7 +119,7 @@
"parse-mode-warning-title": "Telegram messages are limited to 4096 UTF-8 characters."
},
"used-by_one": "Used by {{ count }} notification policy",
"used-by_other": "Used by {{ count }} notification policies"
"used-by_other": "Used by {{ count }} notification policy"
},
"contactPointFilter": {
"label": "Contact point"
@ -169,7 +169,7 @@
"inherited": "Inherited",
"mute-time": "Muted when",
"n-instances_one": "instance",
"n-instances_other": "instances",
"n-instances_other": "instance",
"timingOptions": {
"groupInterval": {
"description": "How long to wait before sending a notification about new alerts that are added to a group of alerts for which an initial notification has already been sent.",
@ -185,7 +185,7 @@
}
}
},
"n-more-policies_one": "{{count}} additional policy",
"n-more-policies_one": "{{count}} additional policies",
"n-more-policies_other": "{{count}} additional policies",
"new-child": "New child policy",
"new-policy": "Add new policy",
@ -291,15 +291,15 @@
},
"counts": {
"alertRule_one": "{{count}} alert rule",
"alertRule_other": "{{count}} alert rules",
"alertRule_other": "{{count}} alert rule",
"dashboard_one": "{{count}} dashboard",
"dashboard_other": "{{count}} dashboards",
"dashboard_other": "{{count}} dashboard",
"folder_one": "{{count}} folder",
"folder_other": "{{count}} folders",
"folder_other": "{{count}} folder",
"libraryPanel_one": "{{count}} library panel",
"libraryPanel_other": "{{count}} library panels",
"libraryPanel_other": "{{count}} library panel",
"total_one": "{{count}} item",
"total_other": "{{count}} items"
"total_other": "{{count}} item"
},
"dashboards-tree": {
"collapse-folder-button": "Collapse folder {{title}}",
@ -935,6 +935,11 @@
}
},
"explore-metrics": {
"breakdown": {
"clearFilter": "Clear filter",
"labelSelect": "Select",
"noMatchingValue": "No values found matching; {{filter}}"
},
"viewBy": "View by"
},
"export": {
@ -2166,16 +2171,16 @@
"confirm-text": "Delete",
"delete-button": "Delete",
"delete-loading": "Deleting...",
"text_one": "This action will delete {{numberOfDashboards}} dashboard.",
"text_one": "This action will delete {{numberOfDashboards}} dashboards.",
"text_other": "This action will delete {{numberOfDashboards}} dashboards.",
"title": "Permanently Delete Dashboards"
},
"restore-modal": {
"folder-picker-text_one": "Please choose a folder where your dashboard will be restored.",
"folder-picker-text_one": "Please choose a folder where your dashboards will be restored.",
"folder-picker-text_other": "Please choose a folder where your dashboards will be restored.",
"restore-button": "Restore",
"restore-loading": "Restoring...",
"text_one": "This action will restore {{numberOfDashboards}} dashboard.",
"text_one": "This action will restore {{numberOfDashboards}} dashboards.",
"text_other": "This action will restore {{numberOfDashboards}} dashboards.",
"title": "Restore Dashboards"
}

View File

@ -119,7 +119,7 @@
"parse-mode-warning-title": "Ŧęľęģřäm męşşäģęş äřę ľįmįŧęđ ŧő 4096 ŮŦF-8 čĥäřäčŧęřş."
},
"used-by_one": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy",
"used-by_other": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčįęş"
"used-by_other": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy"
},
"contactPointFilter": {
"label": "Cőʼnŧäčŧ pőįʼnŧ"
@ -169,7 +169,7 @@
"inherited": "Ĩʼnĥęřįŧęđ",
"mute-time": "Mūŧęđ ŵĥęʼn",
"n-instances_one": "įʼnşŧäʼnčę",
"n-instances_other": "įʼnşŧäʼnčęş",
"n-instances_other": "įʼnşŧäʼnčę",
"timingOptions": {
"groupInterval": {
"description": "Ħőŵ ľőʼnģ ŧő ŵäįŧ þęƒőřę şęʼnđįʼnģ ä ʼnőŧįƒįčäŧįőʼn äþőūŧ ʼnęŵ äľęřŧş ŧĥäŧ äřę äđđęđ ŧő ä ģřőūp őƒ äľęřŧş ƒőř ŵĥįčĥ äʼn įʼnįŧįäľ ʼnőŧįƒįčäŧįőʼn ĥäş äľřęäđy þęęʼn şęʼnŧ.",
@ -185,7 +185,7 @@
}
}
},
"n-more-policies_one": "{{count}} äđđįŧįőʼnäľ pőľįčy",
"n-more-policies_one": "{{count}} äđđįŧįőʼnäľ pőľįčįęş",
"n-more-policies_other": "{{count}} äđđįŧįőʼnäľ pőľįčįęş",
"new-child": "Ńęŵ čĥįľđ pőľįčy",
"new-policy": "Åđđ ʼnęŵ pőľįčy",
@ -291,15 +291,15 @@
},
"counts": {
"alertRule_one": "{{count}} äľęřŧ řūľę",
"alertRule_other": "{{count}} äľęřŧ řūľęş",
"alertRule_other": "{{count}} äľęřŧ řūľę",
"dashboard_one": "{{count}} đäşĥþőäřđ",
"dashboard_other": "{{count}} đäşĥþőäřđş",
"dashboard_other": "{{count}} đäşĥþőäřđ",
"folder_one": "{{count}} ƒőľđęř",
"folder_other": "{{count}} ƒőľđęřş",
"folder_other": "{{count}} ƒőľđęř",
"libraryPanel_one": "{{count}} ľįþřäřy päʼnęľ",
"libraryPanel_other": "{{count}} ľįþřäřy päʼnęľş",
"libraryPanel_other": "{{count}} ľįþřäřy päʼnęľ",
"total_one": "{{count}} įŧęm",
"total_other": "{{count}} įŧęmş"
"total_other": "{{count}} įŧęm"
},
"dashboards-tree": {
"collapse-folder-button": "Cőľľäpşę ƒőľđęř {{title}}",
@ -935,6 +935,11 @@
}
},
"explore-metrics": {
"breakdown": {
"clearFilter": "Cľęäř ƒįľŧęř",
"labelSelect": "Ŝęľęčŧ",
"noMatchingValue": "Ńő väľūęş ƒőūʼnđ mäŧčĥįʼnģ; {{filter}}"
},
"viewBy": "Vįęŵ þy"
},
"export": {
@ -2166,16 +2171,16 @@
"confirm-text": "Đęľęŧę",
"delete-button": "Đęľęŧę",
"delete-loading": "Đęľęŧįʼnģ...",
"text_one": "Ŧĥįş äčŧįőʼn ŵįľľ đęľęŧę {{numberOfDashboards}} đäşĥþőäřđ.",
"text_one": "Ŧĥįş äčŧįőʼn ŵįľľ đęľęŧę {{numberOfDashboards}} đäşĥþőäřđş.",
"text_other": "Ŧĥįş äčŧįőʼn ŵįľľ đęľęŧę {{numberOfDashboards}} đäşĥþőäřđş.",
"title": "Pęřmäʼnęʼnŧľy Đęľęŧę Đäşĥþőäřđş"
},
"restore-modal": {
"folder-picker-text_one": "Pľęäşę čĥőőşę ä ƒőľđęř ŵĥęřę yőūř đäşĥþőäřđ ŵįľľ þę řęşŧőřęđ.",
"folder-picker-text_one": "Pľęäşę čĥőőşę ä ƒőľđęř ŵĥęřę yőūř đäşĥþőäřđş ŵįľľ þę řęşŧőřęđ.",
"folder-picker-text_other": "Pľęäşę čĥőőşę ä ƒőľđęř ŵĥęřę yőūř đäşĥþőäřđş ŵįľľ þę řęşŧőřęđ.",
"restore-button": "Ŗęşŧőřę",
"restore-loading": "Ŗęşŧőřįʼnģ...",
"text_one": "Ŧĥįş äčŧįőʼn ŵįľľ řęşŧőřę {{numberOfDashboards}} đäşĥþőäřđ.",
"text_one": "Ŧĥįş äčŧįőʼn ŵįľľ řęşŧőřę {{numberOfDashboards}} đäşĥþőäřđş.",
"text_other": "Ŧĥįş äčŧįőʼn ŵįľľ řęşŧőřę {{numberOfDashboards}} đäşĥþőäřđş.",
"title": "Ŗęşŧőřę Đäşĥþőäřđş"
}