import { css } from '@emotion/css'; import { debounce, isEqual } from 'lodash'; import { SyntheticEvent, useReducer } from 'react'; import { AdHocVariableFilter, GrafanaTheme2, RawTimeRange, SelectableValue } from '@grafana/data'; import { config, isFetchError } from '@grafana/runtime'; import { AdHocFiltersVariable, PanelBuilders, SceneComponentProps, SceneCSSGridItem, SceneCSSGridLayout, SceneFlexItem, SceneFlexLayout, sceneGraph, SceneObject, SceneObjectBase, SceneObjectRef, SceneObjectState, SceneObjectStateChangedEvent, SceneObjectUrlSyncConfig, SceneObjectUrlValues, SceneObjectWithUrlSync, SceneTimeRange, SceneVariableSet, VariableDependencyConfig, } from '@grafana/scenes'; import { Alert, Badge, Field, Icon, IconButton, InlineSwitch, Input, Select, Tooltip, useStyles2 } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { MetricScene } from '../MetricScene'; import { StatusWrapper } from '../StatusWrapper'; import { Node, Parser } from '../groop/parser'; import { getMetricDescription } from '../helpers/MetricDatasourceHelper'; import { reportExploreMetrics } from '../interactions'; import { setOtelExperienceToggleState } from '../services/store'; import { getVariablesWithMetricConstant, MetricSelectedEvent, RefreshMetricsEvent, VAR_DATASOURCE, VAR_DATASOURCE_EXPR, VAR_FILTERS, } from '../shared'; import { getFilters, getTrailFor, isSceneTimeRangeState } from '../utils'; import { SelectMetricAction } from './SelectMetricAction'; import { getMetricNames } from './api'; import { getPreviewPanelFor } from './previewPanel'; import { sortRelatedMetrics } from './relatedMetrics'; import { createJSRegExpFromSearchTerms, createPromRegExp, deriveSearchTermsFromInput } from './util'; interface MetricPanel { name: string; index: number; itemRef?: SceneObjectRef; isEmpty?: boolean; isPanel?: boolean; loaded?: boolean; } export interface MetricSelectSceneState extends SceneObjectState { body: SceneFlexLayout | SceneCSSGridLayout; rootGroup?: Node; metricPrefix?: string; metricNames?: string[]; metricNamesLoading?: boolean; metricNamesError?: string; metricNamesWarning?: string; missingOtelTargets?: boolean; } const ROW_PREVIEW_HEIGHT = '175px'; const ROW_CARD_HEIGHT = '64px'; const METRIC_PREFIX_ALL = 'all'; const MAX_METRIC_NAMES = 20000; 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({ $variables: state.$variables, metricPrefix: state.metricPrefix ?? METRIC_PREFIX_ALL, body: state.body ?? new SceneCSSGridLayout({ children: [], templateColumns: 'repeat(auto-fill, minmax(450px, 1fr))', autoRows: ROW_PREVIEW_HEIGHT, isLazy: 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: () => { // In all cases, we want to reload the metric names this._debounceRefreshMetricNames(); }, }); 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(); } else { // Temp hack when going back to select metric scene and variable updates this.ignoreNextUpdate = true; } const trail = getTrailFor(this); this._subs.add( trail.subscribeToEvent(MetricSelectedEvent, (event) => { const { steps, currentStep } = trail.state.history.state; const prevStep = steps[currentStep].parentIndex; const previousMetric = steps[prevStep].trailState.metric; const isRelatedMetricSelector = previousMetric !== undefined; if (event.payload !== undefined) { const metricSearch = getMetricSearch(trail); const searchTermCount = deriveSearchTermsFromInput(metricSearch).length; reportExploreMetrics('metric_selected', { from: isRelatedMetricSelector ? 'related_metrics' : 'metric_list', searchTermCount, }); } }) ); this._subs.add( trail.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => { if (evt.payload.changedObject instanceof SceneTimeRange) { const { prevState, newState } = evt.payload; if (isSceneTimeRangeState(prevState) && isSceneTimeRangeState(newState)) { if (prevState.from === newState.from && prevState.to === newState.to) { return; } } } }) ); this._subs.add( trail.subscribeToState(({ metricSearch }, oldState) => { const oldSearchTerms = deriveSearchTermsFromInput(oldState.metricSearch); const newSearchTerms = deriveSearchTermsFromInput(metricSearch); if (!isEqual(oldSearchTerms, newSearchTerms)) { this._debounceRefreshMetricNames(); } }) ); this.subscribeToState((newState, prevState) => { if (newState.metricNames !== prevState.metricNames) { this.onMetricNamesChanged(); } }); this._subs.add( trail.subscribeToState(({ otelTargets }, oldState) => { // if the otel targets have changed, get the new list of metrics if ( otelTargets?.instances !== oldState.otelTargets?.instances && otelTargets?.jobs !== oldState.otelTargets?.jobs ) { this._debounceRefreshMetricNames(); } }) ); this._subs.add( trail.subscribeToState(() => { // users will most likely not switch this off but for now, // update metric names when changing useOtelExperience this._debounceRefreshMetricNames(); }) ); this._subs.add( trail.subscribeToState(() => { // move showPreviews into the settings // build layout when toggled this.buildLayout(); }) ); if (config.featureToggles.enableScopesInMetricsExplore) { this._subs.add( trail.subscribeToEvent(RefreshMetricsEvent, () => { this._debounceRefreshMetricNames(); }) ); } this._debounceRefreshMetricNames(); } private async _refreshMetricNames() { const trail = getTrailFor(this); const timeRange: RawTimeRange | undefined = trail.state.$timeRange?.state; if (!timeRange) { return; } const filters: AdHocVariableFilter[] = []; const filtersVar = sceneGraph.lookupVariable(VAR_FILTERS, this); const adhocFilters = filtersVar instanceof AdHocFiltersVariable ? (filtersVar?.state.filters ?? []) : []; if (adhocFilters.length > 0) { filters.push(...adhocFilters); } const metricSearchRegex = createPromRegExp(trail.state.metricSearch); if (metricSearchRegex) { filters.push({ key: '__name__', operator: '=~', value: metricSearchRegex, }); } const datasourceUid = sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR); this.setState({ metricNamesLoading: true, metricNamesError: undefined, metricNamesWarning: undefined }); try { const jobsList = trail.state.useOtelExperience ? (trail.state.otelTargets?.jobs ?? []) : []; const instancesList = trail.state.useOtelExperience ? (trail.state.otelTargets?.instances ?? []) : []; const response = await getMetricNames( datasourceUid, timeRange, sceneGraph.getScopesBridge(this)?.getValue() ?? [], filters, jobsList, instancesList, MAX_METRIC_NAMES ); const searchRegex = createJSRegExpFromSearchTerms(getMetricSearch(this)); let metricNames = searchRegex ? response.data.filter((metric) => !searchRegex || searchRegex.test(metric)) : response.data; // use this to generate groups for metric prefix const filteredMetricNames = metricNames; // filter the remaining metrics with the metric prefix const metricPrefix = this.state.metricPrefix; if (metricPrefix && metricPrefix !== 'all') { const prefixRegex = new RegExp(`(^${metricPrefix}.*)`, 'igy'); metricNames = metricNames.filter((metric) => !prefixRegex || prefixRegex.test(metric)); } let metricNamesWarning = response.limitReached ? `This feature will only return up to ${MAX_METRIC_NAMES} metric names for performance reasons. ` + `This limit is being exceeded for the current data source. ` + `Add search terms or label filters to narrow down the number of metric names returned.` : undefined; // if there are no otel targets for otel resources, there will be no labels if (trail.state.useOtelExperience && (jobsList.length === 0 || instancesList.length === 0)) { metricNames = []; metricNamesWarning = undefined; } let bodyLayout = this.state.body; // generate groups based on the search metrics input let rootGroupNode = await this.generateGroups(filteredMetricNames); this.setState({ metricNames, rootGroup: rootGroupNode, body: bodyLayout, metricNamesLoading: false, metricNamesWarning, metricNamesError: response.error, missingOtelTargets: response.missingOtelTargets, }); } catch (err: unknown) { let error = 'Unknown error'; if (isFetchError(err)) { if (err.cancelled) { error = 'Request cancelled'; } else if (err.statusText) { error = err.statusText; } } this.setState({ metricNames: undefined, metricNamesLoading: false, metricNamesError: error }); } } 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() { const metricNames = this.state.metricNames || []; const nameSet = new Set(metricNames); Object.values(this.previewCache).forEach((panel) => { if (!nameSet.has(panel.name)) { panel.isEmpty = true; } }); const trail = getTrailFor(this); const sortedMetricNames = trail.state.metric !== undefined ? sortRelatedMetrics(metricNames, trail.state.metric) : metricNames; const metricsMap: Record = {}; const metricsLimit = 120; // Clear absent metrics from cache Object.keys(this.previewCache).forEach((metric) => { if (!nameSet.has(metric)) { delete this.previewCache[metric]; } }); for (let index = 0; index < sortedMetricNames.length; index++) { const metricName = sortedMetricNames[index]; if (Object.keys(metricsMap).length > metricsLimit) { break; } const oldPanel = this.previewCache[metricName]; metricsMap[metricName] = oldPanel || { 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; this.buildLayout(); } 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 buildLayout() { const trail = getTrailFor(this); const showPreviews = trail.state.showPreviews; // Temp hack when going back to select metric scene and variable updates if (this.ignoreNextUpdate) { this.ignoreNextUpdate = false; return; } const children: SceneFlexItem[] = []; const metricsList = this.sortedPreviewMetrics(); // Get the current filters to determine the count of them // Which is required for `getPreviewPanelFor` const filters = getFilters(this); 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 description = getMetricDescription(metadata); if (showPreviews) { if (metric.itemRef && metric.isPanel) { children.push(metric.itemRef.resolve()); continue; } // refactor this into the query generator in future const isNative = trail.isNativeHistogram(metric.name); const panel = getPreviewPanelFor(metric.name, index, currentFilterCount, description, isNative, true); metric.itemRef = panel.getRef(); metric.isPanel = true; children.push(panel); } else { const panel = new SceneCSSGridItem({ $variables: new SceneVariableSet({ variables: getVariablesWithMetricConstant(metric.name), }), body: getCardPanelFor(metric.name, description), }); metric.itemRef = panel.getRef(); metric.isPanel = false; children.push(panel); } } const rowTemplate = showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT; this.state.body.setState({ children, autoRows: rowTemplate }); } public updateMetricPanel = (metric: string, isLoaded?: boolean, isEmpty?: boolean) => { const metricPanel = this.previewCache[metric]; if (metricPanel) { metricPanel.isEmpty = isEmpty; metricPanel.loaded = isLoaded; this.previewCache[metric] = metricPanel; if (this.state.metricPrefix === 'All') { this.buildLayout(); } } }; 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._refreshMetricNames(); }; 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 onToggleOtelExperience = () => { const trail = getTrailFor(this); const useOtelExperience = trail.state.useOtelExperience; // set the startButtonClicked to null as we have gone past the owrkflow this is needed for let startButtonClicked = false; let resettingOtel = true; if (useOtelExperience) { reportExploreMetrics('otel_experience_toggled', { value: 'off' }); // if turning off OTel resettingOtel = false; trail.resetOtelExperience(); } else { reportExploreMetrics('otel_experience_toggled', { value: 'on' }); } setOtelExperienceToggleState(!useOtelExperience); trail.setState({ useOtelExperience: !useOtelExperience, resettingOtel, startButtonClicked }); }; public static Component = ({ model }: SceneComponentProps) => { const { body, metricNames, metricNamesError, metricNamesLoading, metricNamesWarning, rootGroup, metricPrefix, missingOtelTargets, } = model.useState(); const { children } = body.useState(); const trail = getTrailFor(model); const styles = useStyles2(getStyles); const [warningDismissed, dismissWarning] = useReducer(() => true, false); const { metricSearch, useOtelExperience, hasOtelResources, isStandardOtel, metric } = trail.useState(); const tooStrict = children.length === 0 && metricSearch; const noMetrics = !metricNamesLoading && metricNames && metricNames.length === 0; const isLoading = metricNamesLoading && children.length === 0; const unableToRetrieveMetricNames = t( 'trails.metric-select-scene.unable-to-retrieve-metric-names', 'Unable to retrieve metric names' ); const blockingMessage = isLoading ? undefined : missingOtelTargets ? 'There are no metrics found. Please adjust your filters based on your OTel resource attributes.' : (noMetrics && 'There are no results found. Try a different time range or a different data source.') || (tooStrict && 'There are no results found. Try adjusting your search or filters.') || undefined; const metricNamesWarningIcon = metricNamesWarning ? (

{unableToRetrieveMetricNames}

{metricNamesWarning}

} >
) : undefined; return (
} value={metricSearch} onChange={model.onSearchQueryChange} suffix={metricNamesWarningIcon} /> View by
} className={styles.displayOption} >