From 7319a75110bff09e1d647535416f53abb4f78a80 Mon Sep 17 00:00:00 2001 From: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Wed, 31 Jan 2024 09:33:27 -0500 Subject: [PATCH] feat: datatrails: include metric prefix filter (#81316) * feat: datatrails: include metric prefix filter * fix: remove current metric from related metrics list * fix: Cascader issues - handle empty items list when generating searchable options - correct state management to ensure cascade shows cascade path of selection from search * fix: remove custom value creation --- .../src/components/Cascader/Cascader.tsx | 22 +-- .../MetricCategory/MetricCategoryCascader.tsx | 79 +++++++++++ .../MetricCategory/useMetricCategories.ts | 43 ++++++ .../app/features/trails/MetricSelectScene.tsx | 129 ++++++++++++++---- 4 files changed, 238 insertions(+), 35 deletions(-) create mode 100644 public/app/features/trails/MetricCategory/MetricCategoryCascader.tsx create mode 100644 public/app/features/trails/MetricCategory/useMetricCategories.ts diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.tsx index 538badcee58..d11fbb258eb 100644 --- a/packages/grafana-ui/src/components/Cascader/Cascader.tsx +++ b/packages/grafana-ui/src/components/Cascader/Cascader.tsx @@ -93,7 +93,7 @@ export class Cascader extends PureComponent { for (const option of options) { const cpy = [...optionPath]; cpy.push(option); - if (!option.items) { + if (!option.items || option.items.length === 0) { selectOptions.push({ singleLabel: cpy[cpy.length - 1].label, label: cpy.map((o) => o.label).join(this.props.separator || DEFAULT_SEPARATOR), @@ -135,23 +135,27 @@ export class Cascader extends PureComponent { : this.props.displayAllSelectedLevels ? selectedOptions.map((option) => option.label).join(this.props.separator || DEFAULT_SEPARATOR) : selectedOptions[selectedOptions.length - 1].label; - this.setState({ - rcValue: value, + const state: CascaderState = { + rcValue: { value, label: activeLabel }, focusCascade: true, activeLabel, - }); - + isSearching: false, + }; + this.setState(state); this.props.onSelect(selectedOptions[selectedOptions.length - 1].value); }; //For select onSelect = (obj: SelectableValue) => { const valueArray = obj.value || []; - this.setState({ - activeLabel: this.props.displayAllSelectedLevels ? obj.label : obj.singleLabel || '', - rcValue: valueArray, + const activeLabel = this.props.displayAllSelectedLevels ? obj.label : obj.singleLabel || ''; + const state: CascaderState = { + activeLabel: activeLabel, + rcValue: { value: valueArray, label: activeLabel }, isSearching: false, - }); + focusCascade: false, + }; + this.setState(state); this.props.onSelect(valueArray[valueArray.length - 1]); }; diff --git a/public/app/features/trails/MetricCategory/MetricCategoryCascader.tsx b/public/app/features/trails/MetricCategory/MetricCategoryCascader.tsx new file mode 100644 index 00000000000..75fe3c3a2e3 --- /dev/null +++ b/public/app/features/trails/MetricCategory/MetricCategoryCascader.tsx @@ -0,0 +1,79 @@ +import React, { useMemo, useReducer, useState } from 'react'; + +import { Cascader, CascaderOption, HorizontalGroup, Button } from '@grafana/ui'; + +import { useMetricCategories } from './useMetricCategories'; + +type Props = { + metricNames: string[]; + onSelect: (prefix: string | undefined) => void; + disabled?: boolean; + initialValue?: string; +}; + +export function MetricCategoryCascader({ metricNames, onSelect, disabled, initialValue }: Props) { + const categoryTree = useMetricCategories(metricNames); + const options = useMemo(() => createCasaderOptions(categoryTree), [categoryTree]); + + const [disableClear, setDisableClear] = useState(initialValue == null); + + // Increments whenever clear is pressed, to reset the Cascader component + const [cascaderKey, resetCascader] = useReducer((x) => x + 1, 0); + + const clear = () => { + resetCascader(); + setDisableClear(true); + onSelect(undefined); + }; + + return ( + + { + setDisableClear(!prefix); + onSelect(prefix); + }} + {...{ options, disabled, initialValue }} + /> + + + ); +} + +function createCasaderOptions(tree: ReturnType, currentPrefix = '') { + const categories = Object.entries(tree.children); + + const options = categories.map(([metricPart, node]) => { + let subcategoryEntries = Object.entries(node.children); + + while (subcategoryEntries.length === 1 && !node.isMetric) { + // There is only one subcategory, so we will join it with the current metricPart to reduce depth + const [subMetricPart, subNode] = subcategoryEntries[0]; + metricPart = `${metricPart}_${subMetricPart}`; + // Extend the metric part name, because there is only one subcategory + node = subNode; + subcategoryEntries = Object.entries(node.children); + } + + const value = currentPrefix + metricPart; + const subOptions = createCasaderOptions(node, value + '_'); + + const option: CascaderOption = { + value: value, + label: metricPart, + items: subOptions, + }; + + return option; + }); + + return options; +} diff --git a/public/app/features/trails/MetricCategory/useMetricCategories.ts b/public/app/features/trails/MetricCategory/useMetricCategories.ts new file mode 100644 index 00000000000..8e000a858c9 --- /dev/null +++ b/public/app/features/trails/MetricCategory/useMetricCategories.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; + +export function useMetricCategories(metrics: string[]) { + return useMemo(() => processMetrics(metrics), [metrics]); +} + +interface MetricPartNode { + isMetric?: boolean; + children: Record; +} + +function processMetrics(metrics: string[]) { + const categoryTree: MetricPartNode = { children: {} }; + + function insertMetric(metric: string) { + if (metric.indexOf(':') !== -1) { + // Ignore recording rules. + return; + } + + const metricParts = metric.split('_'); + + let cursor = categoryTree; + for (const metricPart of metricParts) { + let node = cursor.children[metricPart]; + if (!node) { + // Create new node + node = { + children: {}, + }; + // Insert it + cursor.children[metricPart] = node; + } + cursor = node; + } + // We know this node is a metric because it was for the last metricPart + cursor.isMetric = true; + } + + metrics.forEach((metric) => insertMetric(metric)); + + return categoryTree; +} diff --git a/public/app/features/trails/MetricSelectScene.tsx b/public/app/features/trails/MetricSelectScene.tsx index e93604cc227..13fd520cbfd 100644 --- a/public/app/features/trails/MetricSelectScene.tsx +++ b/public/app/features/trails/MetricSelectScene.tsx @@ -17,12 +17,13 @@ import { SceneCSSGridItem, SceneObjectRef, SceneQueryRunner, - VariableValueOption, } from '@grafana/scenes'; import { VariableHide } from '@grafana/schema'; -import { Input, Text, useStyles2, InlineSwitch } from '@grafana/ui'; +import { Input, Text, useStyles2, InlineSwitch, Field, LoadingPlaceholder } from '@grafana/ui'; import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; +import { MetricCategoryCascader } from './MetricCategory/MetricCategoryCascader'; +import { MetricScene } from './MetricScene'; import { SelectMetricAction } from './SelectMetricAction'; import { hideEmptyPreviews } from './hideEmptyPreviews'; import { getVariablesWithMetricConstant, trailDS, VAR_FILTERS_EXPR, VAR_METRIC_NAMES } from './shared'; @@ -42,6 +43,9 @@ export interface MetricSelectSceneState extends SceneObjectState { showHeading?: boolean; searchQuery?: string; showPreviews?: boolean; + prefixFilter?: string; + metricsAfterSearch?: string[]; + metricsAfterFilter?: string[]; } const ROW_PREVIEW_HEIGHT = '175px'; @@ -75,7 +79,7 @@ export class MetricSelectScene extends SceneObjectBase { }); private onVariableUpdateCompleted(): void { - this.updateMetrics(); + this.updateMetrics(); // Entire pipeline must be performed this.buildLayout(); } @@ -103,33 +107,71 @@ export class MetricSelectScene extends SceneObjectBase { }); } - private updateMetrics() { - const trail = getTrailFor(this); + private getAllMetricNames() { + // Get the datasource metrics list from the VAR_METRIC_NAMES variable const variable = sceneGraph.lookupVariable(VAR_METRIC_NAMES, this); if (!(variable instanceof QueryVariable)) { - return; + return null; } if (variable.state.loading) { + return null; + } + + const metricNames = variable.state.options.map((option) => option.value.toString()); + return metricNames; + } + + private applyMetricSearch() { + // This should only occur when the `searchQuery` changes, of if the `metricNames` change + const metricNames = this.getAllMetricNames(); + if (metricNames == null) { + return; + } + const searchRegex = createSearchRegExp(this.state.searchQuery); + + if (!searchRegex) { + this.setState({ metricsAfterSearch: metricNames }); + } else { + const metricsAfterSearch = metricNames.filter((metric) => !searchRegex || searchRegex.test(metric)); + this.setState({ metricsAfterSearch }); + } + } + + private applyMetricPrefixFilter() { + const { metricsAfterSearch, prefixFilter } = this.state; + + if (!prefixFilter || !metricsAfterSearch) { + this.setState({ metricsAfterFilter: metricsAfterSearch }); + } else { + const metricsAfterFilter = metricsAfterSearch.filter((metric) => metric.startsWith(prefixFilter)); + this.setState({ metricsAfterFilter }); + } + } + + private updateMetrics(applySearchAndFilter = true) { + if (applySearchAndFilter) { + // Set to false if these are not required (because they can be assumed to have been suitably called). + this.applyMetricSearch(); + this.applyMetricPrefixFilter(); + } + + const { metricsAfterFilter } = this.state; + + if (!metricsAfterFilter) { return; } - const searchRegex = createSearchRegExp(this.state.searchQuery); - const metricNames = variable.state.options; + const metricNames = metricsAfterFilter; + const trail = getTrailFor(this); const sortedMetricNames = trail.state.metric !== undefined ? sortRelatedMetrics(metricNames, trail.state.metric) : metricNames; const metricsMap: Record = {}; const metricsLimit = 120; for (let index = 0; index < sortedMetricNames.length; index++) { - const metric = sortedMetricNames[index]; - - const metricName = String(metric.value); - - if (searchRegex && !searchRegex.test(metricName)) { - continue; - } + const metricName = sortedMetricNames[index]; if (Object.keys(metricsMap).length > metricsLimit) { break; @@ -138,6 +180,14 @@ export class MetricSelectScene extends SceneObjectBase { metricsMap[metricName] = { name: metricName, index, loaded: false }; } + try { + // If there is a current metric, do not present it + const currentMetric = sceneGraph.getAncestor(this, MetricScene).state.metric; + delete metricsMap[currentMetric]; + } catch (err) { + // There is no current metric + } + this.previewCache = metricsMap; } @@ -207,7 +257,14 @@ export class MetricSelectScene extends SceneObjectBase { public onSearchChange = (evt: React.SyntheticEvent) => { this.setState({ searchQuery: evt.currentTarget.value }); - this.updateMetrics(); + this.updateMetrics(); // Need to repeat entire pipeline + this.buildLayout(); + }; + + public onPrefixFilterChange = (prefixFilter: string | undefined) => { + this.setState({ prefixFilter }); + this.applyMetricPrefixFilter(); + this.updateMetrics(false); // Only needed to applyMetricPrefixFilter this.buildLayout(); }; @@ -217,13 +274,25 @@ export class MetricSelectScene extends SceneObjectBase { }; public static Component = ({ model }: SceneComponentProps) => { - const { showHeading, searchQuery, showPreviews, body } = model.useState(); + const { showHeading, searchQuery, showPreviews, body, metricsAfterSearch, metricsAfterFilter, prefixFilter } = + model.useState(); const { children } = body.useState(); const styles = useStyles2(getStyles); - const searchTooStrictWarning = children.length === 0 && searchQuery && ( -
There are no results found. Try adjusting your search or filters.
- ); + const notLoaded = metricsAfterSearch === undefined && metricsAfterFilter === undefined && children.length === 0; + + const tooStrict = children.length === 0 && (searchQuery || prefixFilter); + + let status = + (notLoaded && ) || + (tooStrict && 'There are no results found. Try adjusting your search or filters.'); + + const showStatus = status &&
{status}
; + + const prefixError = + prefixFilter && metricsAfterSearch != null && !metricsAfterFilter?.length + ? 'The current prefix filter is not available with the current search terms.' + : undefined; return (
@@ -236,7 +305,17 @@ export class MetricSelectScene extends SceneObjectBase {
- {searchTooStrictWarning} +
+ + + +
+ {showStatus} ); @@ -291,13 +370,11 @@ function getCardPanelFor(metric: string) { } // Computes the Levenshtein distance between two strings, twice, once for the first half and once for the whole string. -function sortRelatedMetrics(metricList: VariableValueOption[], metric: string) { - return metricList.sort((a, b) => { - const aValue = String(a.value); +function sortRelatedMetrics(metricList: string[], metric: string) { + return metricList.sort((aValue, bValue) => { const aSplit = aValue.split('_'); const aHalf = aSplit.slice(0, aSplit.length / 2).join('_'); - const bValue = String(b.value); const bSplit = bValue.split('_'); const bHalf = bSplit.slice(0, bSplit.length / 2).join('_'); @@ -324,7 +401,7 @@ function getStyles(theme: GrafanaTheme2) { gap: theme.spacing(2), marginBottom: theme.spacing(1), }), - alternateMessage: css({ + statusMessage: css({ fontStyle: 'italic', marginTop: theme.spacing(7), textAlign: 'center',