From 87b2494872358b67ffe962067cac90bd025f9efd Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Tue, 23 Jul 2024 18:23:28 +0200 Subject: [PATCH] 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 <38694490+darrenjaneczek@users.noreply.github.com> * Update public/app/features/trails/groop/parser.ts Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> * Update public/app/features/trails/MetricSelect/MetricSelectScene.tsx Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> * Update public/app/features/trails/MetricSelect/MetricSelectScene.tsx Co-authored-by: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> * 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 <38694490+darrenjaneczek@users.noreply.github.com> Co-authored-by: Darren Janeczek --- .../trails/MetricSelect/MetricSelectScene.tsx | 211 +++++++++++--- .../app/features/trails/groop/lookup.test.ts | 88 ++++++ public/app/features/trails/groop/lookup.ts | 75 +++++ .../app/features/trails/groop/parser.test.ts | 90 ++++++ public/app/features/trails/groop/parser.ts | 138 +++++++++ .../trails/groop/testdata/metrics.txt | 263 ++++++++++++++++++ public/app/features/trails/interactions.ts | 15 + public/locales/en-US/grafana.json | 3 + public/locales/pseudo-LOCALE/grafana.json | 3 + 9 files changed, 844 insertions(+), 42 deletions(-) create mode 100644 public/app/features/trails/groop/lookup.test.ts create mode 100644 public/app/features/trails/groop/lookup.ts create mode 100644 public/app/features/trails/groop/parser.test.ts create mode 100644 public/app/features/trails/groop/parser.ts create mode 100644 public/app/features/trails/groop/testdata/metrics.txt diff --git a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx index bf7807612cd..d2bc0e63e54 100644 --- a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx +++ b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx @@ -1,9 +1,8 @@ import { css } from '@emotion/css'; import { debounce, isEqual } from 'lodash'; -import { useReducer } from 'react'; -import * as React from 'react'; +import { SyntheticEvent, useReducer } from 'react'; -import { GrafanaTheme2, RawTimeRange } from '@grafana/data'; +import { GrafanaTheme2, RawTimeRange, SelectableValue } from '@grafana/data'; import { isFetchError } from '@grafana/runtime'; import { AdHocFiltersVariable, @@ -12,21 +11,28 @@ import { SceneCSSGridItem, SceneCSSGridLayout, SceneFlexItem, + SceneFlexLayout, sceneGraph, SceneObject, SceneObjectBase, SceneObjectRef, SceneObjectState, SceneObjectStateChangedEvent, + SceneObjectUrlSyncConfig, + SceneObjectUrlValues, + SceneObjectWithUrlSync, SceneTimeRange, SceneVariable, SceneVariableSet, VariableDependencyConfig, } from '@grafana/scenes'; -import { InlineSwitch, Field, Alert, Icon, useStyles2, Tooltip, Input } from '@grafana/ui'; +import { Alert, Field, Icon, IconButton, InlineSwitch, Input, Select, Tooltip, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; +import { DataTrail } from '../DataTrail'; import { MetricScene } from '../MetricScene'; import { StatusWrapper } from '../StatusWrapper'; +import { Node, Parser } from '../groop/parser'; import { getMetricDescription } from '../helpers/MetricDatasourceHelper'; import { reportExploreMetrics } from '../interactions'; import { @@ -54,7 +60,9 @@ interface MetricPanel { } export interface MetricSelectSceneState extends SceneObjectState { - body: SceneCSSGridLayout; + body: SceneFlexLayout | SceneCSSGridLayout; + rootGroup?: Node; + metricPrefix?: string; showPreviews?: boolean; metricNames?: string[]; metricNamesLoading?: boolean; @@ -64,16 +72,23 @@ export interface MetricSelectSceneState extends SceneObjectState { const ROW_PREVIEW_HEIGHT = '175px'; const ROW_CARD_HEIGHT = '64px'; +const METRIC_PREFIX_ALL = 'all'; const MAX_METRIC_NAMES = 20000; -export class MetricSelectScene extends SceneObjectBase { +const viewByTooltip = + '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.'; + +export class MetricSelectScene extends SceneObjectBase implements SceneObjectWithUrlSync { private previewCache: Record = {}; private ignoreNextUpdate = false; + private _debounceRefreshMetricNames = debounce(() => this._refreshMetricNames(), 1000); constructor(state: Partial) { super({ + showPreviews: true, $variables: state.$variables, + metricPrefix: state.metricPrefix ?? METRIC_PREFIX_ALL, body: state.body ?? new SceneCSSGridLayout({ @@ -82,13 +97,13 @@ export class MetricSelectScene extends SceneObjectBase { autoRows: ROW_PREVIEW_HEIGHT, isLazy: true, }), - showPreviews: true, ...state, }); this.addActivationHandler(this._onActivate.bind(this)); } + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['metricPrefix'] }); protected _variableDependency = new VariableDependencyConfig(this, { variableNames: [VAR_DATASOURCE, VAR_FILTERS], onReferencedVariableValueChanged: (variable: SceneVariable) => { @@ -97,6 +112,18 @@ export class MetricSelectScene extends SceneObjectBase { }, }); + getUrlState() { + return { metricPrefix: this.state.metricPrefix }; + } + + updateFromUrl(values: SceneObjectUrlValues) { + if (typeof values.metricPrefix === 'string') { + if (this.state.metricPrefix !== values.metricPrefix) { + this.setState({ metricPrefix: values.metricPrefix }); + } + } + } + private _onActivate() { if (this.state.body.state.children.length === 0) { this.buildLayout(); @@ -159,8 +186,6 @@ export class MetricSelectScene extends SceneObjectBase { this._debounceRefreshMetricNames(); } - private _debounceRefreshMetricNames = debounce(() => this._refreshMetricNames(), 1000); - private async _refreshMetricNames() { const trail = getTrailFor(this); const timeRange: RawTimeRange | undefined = trail.state.$timeRange?.state; @@ -199,7 +224,17 @@ export class MetricSelectScene extends SceneObjectBase { `Add search terms or label filters to narrow down the number of metric names returned.` : undefined; - this.setState({ metricNames, metricNamesLoading: false, metricNamesWarning, metricNamesError: response.error }); + let bodyLayout = this.state.body; + const rootGroupNode = await this.generateGroups(metricNames); + + this.setState({ + metricNames, + rootGroup: rootGroupNode, + body: bodyLayout, + metricNamesLoading: false, + metricNamesWarning, + metricNamesError: response.error, + }); } catch (err: unknown) { let error = 'Unknown error'; if (isFetchError(err)) { @@ -214,19 +249,16 @@ export class MetricSelectScene extends SceneObjectBase { } } - private sortedPreviewMetrics() { - return Object.values(this.previewCache).sort((a, b) => { - if (a.isEmpty && b.isEmpty) { - return a.index - b.index; - } - if (a.isEmpty) { - return 1; - } - if (b.isEmpty) { - return -1; - } - return a.index - b.index; - }); + private async generateGroups(metricNames: string[] = []) { + const groopParser = new Parser(); + groopParser.config = { + ...groopParser.config, + maxDepth: 2, + minGroupSize: 2, + miscGroupKey: 'misc', + }; + const { root: rootGroupNode } = groopParser.parse(metricNames); + return rootGroupNode; } private onMetricNamesChanged() { @@ -286,32 +318,67 @@ export class MetricSelectScene extends SceneObjectBase { return; } - const children: SceneFlexItem[] = []; + if (!this.state.rootGroup) { + const rootGroupNode = await this.generateGroups(this.state.metricNames); + this.setState({ rootGroup: rootGroupNode }); + } + const children = await this.populateFilterableViewLayout(); + const rowTemplate = this.state.showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT; + this.state.body.setState({ children, autoRows: rowTemplate }); + } + + private async populateFilterableViewLayout() { const trail = getTrailFor(this); - - const metricsList = this.sortedPreviewMetrics(); - // Get the current filters to determine the count of them // Which is required for `getPreviewPanelFor` const filters = getFilters(this); + + let rootGroupNode = this.state.rootGroup; + if (!rootGroupNode) { + rootGroupNode = await this.generateGroups(this.state.metricNames); + this.setState({ rootGroup: rootGroupNode }); + } + + const children: SceneFlexItem[] = []; + + for (const [groupKey, groupNode] of rootGroupNode.groups) { + if (this.state.metricPrefix !== METRIC_PREFIX_ALL && this.state.metricPrefix !== groupKey) { + continue; + } + + for (const [_, value] of groupNode.groups) { + const panels = await this.populatePanels(trail, filters, value.values); + children.push(...panels); + } + + const morePanelsMaybe = await this.populatePanels(trail, filters, groupNode.values); + children.push(...morePanelsMaybe); + } + + return children; + } + + private async populatePanels(trail: DataTrail, filters: ReturnType, values: string[]) { const currentFilterCount = filters?.length || 0; - for (let index = 0; index < metricsList.length; index++) { - const metric = metricsList[index]; - const metadata = await trail.getMetricMetadata(metric.name); + const previewPanelLayoutItems: SceneFlexItem[] = []; + for (let index = 0; index < values.length; index++) { + const metricName = values[index]; + const metric: MetricPanel = this.previewCache[metricName] ?? { name: metricName, index, loaded: false }; + const metadata = await trail.getMetricMetadata(metricName); const description = getMetricDescription(metadata); if (this.state.showPreviews) { if (metric.itemRef && metric.isPanel) { - children.push(metric.itemRef.resolve()); + previewPanelLayoutItems.push(metric.itemRef.resolve()); continue; } const panel = getPreviewPanelFor(metric.name, index, currentFilterCount, description); metric.itemRef = panel.getRef(); metric.isPanel = true; - children.push(panel); + previewPanelLayoutItems.push(panel); } else { const panel = new SceneCSSGridItem({ $variables: new SceneVariableSet({ @@ -321,13 +388,11 @@ export class MetricSelectScene extends SceneObjectBase { }); metric.itemRef = panel.getRef(); metric.isPanel = false; - children.push(panel); + previewPanelLayoutItems.push(panel); } } - const rowTemplate = this.state.showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT; - - this.state.body.setState({ children, autoRows: rowTemplate }); + return previewPanelLayoutItems; } public updateMetricPanel = (metric: string, isLoaded?: boolean, isEmpty?: boolean) => { @@ -336,25 +401,52 @@ export class MetricSelectScene extends SceneObjectBase { metricPanel.isEmpty = isEmpty; metricPanel.loaded = isLoaded; this.previewCache[metric] = metricPanel; - this.buildLayout(); + if (this.state.metricPrefix === 'All') { + this.buildLayout(); + } } }; - public onSearchQueryChange = (evt: React.SyntheticEvent) => { + public onSearchQueryChange = (evt: SyntheticEvent) => { const metricSearch = evt.currentTarget.value; const trail = getTrailFor(this); // Update the variable trail.setState({ metricSearch }); }; + public onPrefixFilterChange = (val: SelectableValue) => { + this.setState({ metricPrefix: val.value }); + this.buildLayout(); + }; + + public reportPrefixFilterInteraction = (isMenuOpen: boolean) => { + const trail = getTrailFor(this); + const { steps, currentStep } = trail.state.history.state; + const previousMetric = steps[currentStep]?.trailState.metric; + const isRelatedMetricSelector = previousMetric !== undefined; + + reportExploreMetrics('prefix_filter_clicked', { + from: isRelatedMetricSelector ? 'related_metrics' : 'metric_list', + action: isMenuOpen ? 'open' : 'close', + }); + }; + public onTogglePreviews = () => { this.setState({ showPreviews: !this.state.showPreviews }); this.buildLayout(); }; public static Component = ({ model }: SceneComponentProps) => { - const { showPreviews, body, metricNames, metricNamesError, metricNamesLoading, metricNamesWarning } = - model.useState(); + const { + showPreviews, + body, + metricNames, + metricNamesError, + metricNamesLoading, + metricNamesWarning, + rootGroup, + metricPrefix, + } = model.useState(); const { children } = body.useState(); const trail = getTrailFor(model); const styles = useStyles2(getStyles); @@ -399,6 +491,29 @@ export class MetricSelectScene extends SceneObjectBase { suffix={metricNamesWarningIcon} /> + + View by + + + } + className={styles.displayOption} + > +