Skip to content

Commit 87b2494

Browse files
Explore Metrics: Implement grouping with metric prefixes (#89481)
* add groop as a local dependency * update layout * nested layout with panels * fix the height of the rows * copy groop library into grafana/grafana * Don't create a new scene everytime metrics refreshed * Add display option dropdown * handle different layout options in buildLayout * add select component props * unify scene body creation * handle other display cases in refreshMetricNames * set a new body when display format is different * handle nestedScene population * show nested groups * handle panel display * add tabs view * populate tabs view * show selected tab group * show display options before metric search * populate prefix filter layout * only switch layout for nested-rows display option * Update public/app/features/trails/groop/parser.ts Co-authored-by: Darren Janeczek <[email protected]> * Update public/app/features/trails/groop/parser.ts Co-authored-by: Darren Janeczek <[email protected]> * Update public/app/features/trails/MetricSelect/MetricSelectScene.tsx Co-authored-by: Darren Janeczek <[email protected]> * Update public/app/features/trails/MetricSelect/MetricSelectScene.tsx Co-authored-by: Darren Janeczek <[email protected]> * Remove tab view * generate groups async * Remove unnecessary parts * Refactor * implement urlSync * update keys * introduce interaction * ui updates * chore: revert some auto formatting to clarify comments * chore: revert some auto formatting to clarify comments * rename * add tooltip * add styles * update unit tests * make i18n-extract * update unit test --------- Co-authored-by: Darren Janeczek <[email protected]> Co-authored-by: Darren Janeczek <[email protected]>
1 parent d2b7893 commit 87b2494

File tree

9 files changed

+844
-42
lines changed

9 files changed

+844
-42
lines changed

public/app/features/trails/MetricSelect/MetricSelectScene.tsx

Lines changed: 169 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { css } from '@emotion/css';
22
import { debounce, isEqual } from 'lodash';
3-
import { useReducer } from 'react';
4-
import * as React from 'react';
3+
import { SyntheticEvent, useReducer } from 'react';
54

6-
import { GrafanaTheme2, RawTimeRange } from '@grafana/data';
5+
import { GrafanaTheme2, RawTimeRange, SelectableValue } from '@grafana/data';
76
import { isFetchError } from '@grafana/runtime';
87
import {
98
AdHocFiltersVariable,
@@ -12,21 +11,28 @@ import {
1211
SceneCSSGridItem,
1312
SceneCSSGridLayout,
1413
SceneFlexItem,
14+
SceneFlexLayout,
1515
sceneGraph,
1616
SceneObject,
1717
SceneObjectBase,
1818
SceneObjectRef,
1919
SceneObjectState,
2020
SceneObjectStateChangedEvent,
21+
SceneObjectUrlSyncConfig,
22+
SceneObjectUrlValues,
23+
SceneObjectWithUrlSync,
2124
SceneTimeRange,
2225
SceneVariable,
2326
SceneVariableSet,
2427
VariableDependencyConfig,
2528
} from '@grafana/scenes';
26-
import { InlineSwitch, Field, Alert, Icon, useStyles2, Tooltip, Input } from '@grafana/ui';
29+
import { Alert, Field, Icon, IconButton, InlineSwitch, Input, Select, Tooltip, useStyles2 } from '@grafana/ui';
30+
import { Trans } from 'app/core/internationalization';
2731

32+
import { DataTrail } from '../DataTrail';
2833
import { MetricScene } from '../MetricScene';
2934
import { StatusWrapper } from '../StatusWrapper';
35+
import { Node, Parser } from '../groop/parser';
3036
import { getMetricDescription } from '../helpers/MetricDatasourceHelper';
3137
import { reportExploreMetrics } from '../interactions';
3238
import {
@@ -54,7 +60,9 @@ interface MetricPanel {
5460
}
5561

5662
export interface MetricSelectSceneState extends SceneObjectState {
57-
body: SceneCSSGridLayout;
63+
body: SceneFlexLayout | SceneCSSGridLayout;
64+
rootGroup?: Node;
65+
metricPrefix?: string;
5866
showPreviews?: boolean;
5967
metricNames?: string[];
6068
metricNamesLoading?: boolean;
@@ -64,16 +72,23 @@ export interface MetricSelectSceneState extends SceneObjectState {
6472

6573
const ROW_PREVIEW_HEIGHT = '175px';
6674
const ROW_CARD_HEIGHT = '64px';
75+
const METRIC_PREFIX_ALL = 'all';
6776

6877
const MAX_METRIC_NAMES = 20000;
6978

70-
export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
79+
const viewByTooltip =
80+
'View by the metric prefix. A metric prefix is a single word at the beginning of the metric name, relevant to the domain the metric belongs to.';
81+
82+
export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> implements SceneObjectWithUrlSync {
7183
private previewCache: Record<string, MetricPanel> = {};
7284
private ignoreNextUpdate = false;
85+
private _debounceRefreshMetricNames = debounce(() => this._refreshMetricNames(), 1000);
7386

7487
constructor(state: Partial<MetricSelectSceneState>) {
7588
super({
89+
showPreviews: true,
7690
$variables: state.$variables,
91+
metricPrefix: state.metricPrefix ?? METRIC_PREFIX_ALL,
7792
body:
7893
state.body ??
7994
new SceneCSSGridLayout({
@@ -82,13 +97,13 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
8297
autoRows: ROW_PREVIEW_HEIGHT,
8398
isLazy: true,
8499
}),
85-
showPreviews: true,
86100
...state,
87101
});
88102

89103
this.addActivationHandler(this._onActivate.bind(this));
90104
}
91105

106+
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['metricPrefix'] });
92107
protected _variableDependency = new VariableDependencyConfig(this, {
93108
variableNames: [VAR_DATASOURCE, VAR_FILTERS],
94109
onReferencedVariableValueChanged: (variable: SceneVariable) => {
@@ -97,6 +112,18 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
97112
},
98113
});
99114

115+
getUrlState() {
116+
return { metricPrefix: this.state.metricPrefix };
117+
}
118+
119+
updateFromUrl(values: SceneObjectUrlValues) {
120+
if (typeof values.metricPrefix === 'string') {
121+
if (this.state.metricPrefix !== values.metricPrefix) {
122+
this.setState({ metricPrefix: values.metricPrefix });
123+
}
124+
}
125+
}
126+
100127
private _onActivate() {
101128
if (this.state.body.state.children.length === 0) {
102129
this.buildLayout();
@@ -159,8 +186,6 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
159186
this._debounceRefreshMetricNames();
160187
}
161188

162-
private _debounceRefreshMetricNames = debounce(() => this._refreshMetricNames(), 1000);
163-
164189
private async _refreshMetricNames() {
165190
const trail = getTrailFor(this);
166191
const timeRange: RawTimeRange | undefined = trail.state.$timeRange?.state;
@@ -199,7 +224,17 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
199224
`Add search terms or label filters to narrow down the number of metric names returned.`
200225
: undefined;
201226

202-
this.setState({ metricNames, metricNamesLoading: false, metricNamesWarning, metricNamesError: response.error });
227+
let bodyLayout = this.state.body;
228+
const rootGroupNode = await this.generateGroups(metricNames);
229+
230+
this.setState({
231+
metricNames,
232+
rootGroup: rootGroupNode,
233+
body: bodyLayout,
234+
metricNamesLoading: false,
235+
metricNamesWarning,
236+
metricNamesError: response.error,
237+
});
203238
} catch (err: unknown) {
204239
let error = 'Unknown error';
205240
if (isFetchError(err)) {
@@ -214,19 +249,16 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
214249
}
215250
}
216251

217-
private sortedPreviewMetrics() {
218-
return Object.values(this.previewCache).sort((a, b) => {
219-
if (a.isEmpty && b.isEmpty) {
220-
return a.index - b.index;
221-
}
222-
if (a.isEmpty) {
223-
return 1;
224-
}
225-
if (b.isEmpty) {
226-
return -1;
227-
}
228-
return a.index - b.index;
229-
});
252+
private async generateGroups(metricNames: string[] = []) {
253+
const groopParser = new Parser();
254+
groopParser.config = {
255+
...groopParser.config,
256+
maxDepth: 2,
257+
minGroupSize: 2,
258+
miscGroupKey: 'misc',
259+
};
260+
const { root: rootGroupNode } = groopParser.parse(metricNames);
261+
return rootGroupNode;
230262
}
231263

232264
private onMetricNamesChanged() {
@@ -286,32 +318,67 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
286318
return;
287319
}
288320

289-
const children: SceneFlexItem[] = [];
290-
291-
const trail = getTrailFor(this);
321+
if (!this.state.rootGroup) {
322+
const rootGroupNode = await this.generateGroups(this.state.metricNames);
323+
this.setState({ rootGroup: rootGroupNode });
324+
}
292325

293-
const metricsList = this.sortedPreviewMetrics();
326+
const children = await this.populateFilterableViewLayout();
327+
const rowTemplate = this.state.showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT;
328+
this.state.body.setState({ children, autoRows: rowTemplate });
329+
}
294330

331+
private async populateFilterableViewLayout() {
332+
const trail = getTrailFor(this);
295333
// Get the current filters to determine the count of them
296334
// Which is required for `getPreviewPanelFor`
297335
const filters = getFilters(this);
336+
337+
let rootGroupNode = this.state.rootGroup;
338+
if (!rootGroupNode) {
339+
rootGroupNode = await this.generateGroups(this.state.metricNames);
340+
this.setState({ rootGroup: rootGroupNode });
341+
}
342+
343+
const children: SceneFlexItem[] = [];
344+
345+
for (const [groupKey, groupNode] of rootGroupNode.groups) {
346+
if (this.state.metricPrefix !== METRIC_PREFIX_ALL && this.state.metricPrefix !== groupKey) {
347+
continue;
348+
}
349+
350+
for (const [_, value] of groupNode.groups) {
351+
const panels = await this.populatePanels(trail, filters, value.values);
352+
children.push(...panels);
353+
}
354+
355+
const morePanelsMaybe = await this.populatePanels(trail, filters, groupNode.values);
356+
children.push(...morePanelsMaybe);
357+
}
358+
359+
return children;
360+
}
361+
362+
private async populatePanels(trail: DataTrail, filters: ReturnType<typeof getFilters>, values: string[]) {
298363
const currentFilterCount = filters?.length || 0;
299364

300-
for (let index = 0; index < metricsList.length; index++) {
301-
const metric = metricsList[index];
302-
const metadata = await trail.getMetricMetadata(metric.name);
365+
const previewPanelLayoutItems: SceneFlexItem[] = [];
366+
for (let index = 0; index < values.length; index++) {
367+
const metricName = values[index];
368+
const metric: MetricPanel = this.previewCache[metricName] ?? { name: metricName, index, loaded: false };
369+
const metadata = await trail.getMetricMetadata(metricName);
303370
const description = getMetricDescription(metadata);
304371

305372
if (this.state.showPreviews) {
306373
if (metric.itemRef && metric.isPanel) {
307-
children.push(metric.itemRef.resolve());
374+
previewPanelLayoutItems.push(metric.itemRef.resolve());
308375
continue;
309376
}
310377
const panel = getPreviewPanelFor(metric.name, index, currentFilterCount, description);
311378

312379
metric.itemRef = panel.getRef();
313380
metric.isPanel = true;
314-
children.push(panel);
381+
previewPanelLayoutItems.push(panel);
315382
} else {
316383
const panel = new SceneCSSGridItem({
317384
$variables: new SceneVariableSet({
@@ -321,13 +388,11 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
321388
});
322389
metric.itemRef = panel.getRef();
323390
metric.isPanel = false;
324-
children.push(panel);
391+
previewPanelLayoutItems.push(panel);
325392
}
326393
}
327394

328-
const rowTemplate = this.state.showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT;
329-
330-
this.state.body.setState({ children, autoRows: rowTemplate });
395+
return previewPanelLayoutItems;
331396
}
332397

333398
public updateMetricPanel = (metric: string, isLoaded?: boolean, isEmpty?: boolean) => {
@@ -336,25 +401,52 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
336401
metricPanel.isEmpty = isEmpty;
337402
metricPanel.loaded = isLoaded;
338403
this.previewCache[metric] = metricPanel;
339-
this.buildLayout();
404+
if (this.state.metricPrefix === 'All') {
405+
this.buildLayout();
406+
}
340407
}
341408
};
342409

343-
public onSearchQueryChange = (evt: React.SyntheticEvent<HTMLInputElement>) => {
410+
public onSearchQueryChange = (evt: SyntheticEvent<HTMLInputElement>) => {
344411
const metricSearch = evt.currentTarget.value;
345412
const trail = getTrailFor(this);
346413
// Update the variable
347414
trail.setState({ metricSearch });
348415
};
349416

417+
public onPrefixFilterChange = (val: SelectableValue) => {
418+
this.setState({ metricPrefix: val.value });
419+
this.buildLayout();
420+
};
421+
422+
public reportPrefixFilterInteraction = (isMenuOpen: boolean) => {
423+
const trail = getTrailFor(this);
424+
const { steps, currentStep } = trail.state.history.state;
425+
const previousMetric = steps[currentStep]?.trailState.metric;
426+
const isRelatedMetricSelector = previousMetric !== undefined;
427+
428+
reportExploreMetrics('prefix_filter_clicked', {
429+
from: isRelatedMetricSelector ? 'related_metrics' : 'metric_list',
430+
action: isMenuOpen ? 'open' : 'close',
431+
});
432+
};
433+
350434
public onTogglePreviews = () => {
351435
this.setState({ showPreviews: !this.state.showPreviews });
352436
this.buildLayout();
353437
};
354438

355439
public static Component = ({ model }: SceneComponentProps<MetricSelectScene>) => {
356-
const { showPreviews, body, metricNames, metricNamesError, metricNamesLoading, metricNamesWarning } =
357-
model.useState();
440+
const {
441+
showPreviews,
442+
body,
443+
metricNames,
444+
metricNamesError,
445+
metricNamesLoading,
446+
metricNamesWarning,
447+
rootGroup,
448+
metricPrefix,
449+
} = model.useState();
358450
const { children } = body.useState();
359451
const trail = getTrailFor(model);
360452
const styles = useStyles2(getStyles);
@@ -399,6 +491,29 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
399491
suffix={metricNamesWarningIcon}
400492
/>
401493
</Field>
494+
<Field
495+
label={
496+
<div className={styles.displayOptionTooltip}>
497+
<Trans i18nKey="explore-metrics.viewBy">View by</Trans>
498+
<IconButton name={'info-circle'} size="sm" variant={'secondary'} tooltip={viewByTooltip} />
499+
</div>
500+
}
501+
className={styles.displayOption}
502+
>
503+
<Select
504+
value={metricPrefix}
505+
onChange={model.onPrefixFilterChange}
506+
onOpenMenu={() => model.reportPrefixFilterInteraction(true)}
507+
onCloseMenu={() => model.reportPrefixFilterInteraction(false)}
508+
options={[
509+
{
510+
label: 'All metric names',
511+
value: METRIC_PREFIX_ALL,
512+
},
513+
...Array.from(rootGroup?.groups.keys() ?? []).map((g) => ({ label: `${g}_`, value: g })),
514+
]}
515+
/>
516+
</Field>
402517
<InlineSwitch showLabel={true} label="Show previews" value={showPreviews} onChange={model.onTogglePreviews} />
403518
</div>
404519
{metricNamesError && (
@@ -418,7 +533,8 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
418533
</Alert>
419534
)}
420535
<StatusWrapper {...{ isLoading, blockingMessage }}>
421-
<body.Component model={body} />
536+
{body instanceof SceneFlexLayout && <body.Component model={body} />}
537+
{body instanceof SceneCSSGridLayout && <body.Component model={body} />}
422538
</StatusWrapper>
423539
</div>
424540
);
@@ -439,7 +555,6 @@ function getStyles(theme: GrafanaTheme2) {
439555
container: css({
440556
display: 'flex',
441557
flexDirection: 'column',
442-
flexGrow: 1,
443558
}),
444559
headingWrapper: css({
445560
marginBottom: theme.spacing(0.5),
@@ -455,6 +570,18 @@ function getStyles(theme: GrafanaTheme2) {
455570
flexGrow: 1,
456571
marginBottom: 0,
457572
}),
573+
metricTabGroup: css({
574+
marginBottom: theme.spacing(2),
575+
}),
576+
displayOption: css({
577+
flexGrow: 0,
578+
marginBottom: 0,
579+
minWidth: '184px',
580+
}),
581+
displayOptionTooltip: css({
582+
display: 'flex',
583+
gap: theme.spacing(1),
584+
}),
458585
warningIcon: css({
459586
color: theme.colors.warning.main,
460587
}),

0 commit comments

Comments
 (0)