import { css } from '@emotion/css'; import leven from 'leven'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneObjectState, SceneObjectBase, SceneComponentProps, PanelBuilders, SceneFlexItem, SceneVariableSet, QueryVariable, sceneGraph, VariableDependencyConfig, SceneCSSGridLayout, SceneCSSGridItem, SceneObjectRef, SceneQueryRunner, VariableValueOption, } from '@grafana/scenes'; import { VariableHide } from '@grafana/schema'; import { Input, Text, useStyles2, InlineSwitch } from '@grafana/ui'; import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; import { SelectMetricAction } from './SelectMetricAction'; import { hideEmptyPreviews } from './hideEmptyPreviews'; import { getVariablesWithMetricConstant, trailDS, VAR_FILTERS_EXPR, VAR_METRIC_NAMES } from './shared'; import { getColorByIndex, getTrailFor } from './utils'; interface MetricPanel { name: string; index: number; itemRef?: SceneObjectRef; isEmpty?: boolean; isPanel?: boolean; loaded?: boolean; } export interface MetricSelectSceneState extends SceneObjectState { body: SceneCSSGridLayout; showHeading?: boolean; searchQuery?: string; showPreviews?: boolean; } const ROW_PREVIEW_HEIGHT = '175px'; const ROW_CARD_HEIGHT = '64px'; export class MetricSelectScene extends SceneObjectBase { private previewCache: Record = {}; private ignoreNextUpdate = false; constructor(state: Partial) { super({ $variables: state.$variables ?? getMetricNamesVariableSet(), body: state.body ?? new SceneCSSGridLayout({ children: [], templateColumns: 'repeat(auto-fill, minmax(450px, 1fr))', autoRows: ROW_PREVIEW_HEIGHT, isLazy: true, }), showPreviews: true, ...state, }); this.addActivationHandler(this._onActivate.bind(this)); } protected _variableDependency = new VariableDependencyConfig(this, { variableNames: [VAR_METRIC_NAMES], onVariableUpdateCompleted: this.onVariableUpdateCompleted.bind(this), }); private onVariableUpdateCompleted(): void { this.updateMetrics(); this.buildLayout(); } 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; } } 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 updateMetrics() { const trail = getTrailFor(this); const variable = sceneGraph.lookupVariable(VAR_METRIC_NAMES, this); if (!(variable instanceof QueryVariable)) { return; } if (variable.state.loading) { return; } const searchRegex = createSearchRegExp(this.state.searchQuery); const metricNames = variable.state.options; 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; } if (Object.keys(metricsMap).length > metricsLimit) { break; } metricsMap[metricName] = { name: metricName, index, loaded: false }; } this.previewCache = metricsMap; } private buildLayout() { // Temp hack when going back to select metric scene and variable updates if (this.ignoreNextUpdate) { this.ignoreNextUpdate = false; return; } const variable = sceneGraph.lookupVariable(VAR_METRIC_NAMES, this); if (!(variable instanceof QueryVariable)) { return; } if (variable.state.loading) { return; } if (!Object.keys(this.previewCache).length) { this.updateMetrics(); } const children: SceneFlexItem[] = []; const metricsList = this.sortedPreviewMetrics(); for (let index = 0; index < metricsList.length; index++) { const metric = metricsList[index]; if (this.state.showPreviews) { if (metric.itemRef && metric.isPanel) { children.push(metric.itemRef.resolve()); continue; } const panel = getPreviewPanelFor(metric.name, index); 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), }); metric.itemRef = panel.getRef(); metric.isPanel = false; children.push(panel); } } const rowTemplate = this.state.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; this.buildLayout(); } }; public onSearchChange = (evt: React.SyntheticEvent) => { this.setState({ searchQuery: evt.currentTarget.value }); this.updateMetrics(); this.buildLayout(); }; public onTogglePreviews = () => { this.setState({ showPreviews: !this.state.showPreviews }); this.buildLayout(); }; public static Component = ({ model }: SceneComponentProps) => { const { showHeading, searchQuery, showPreviews } = model.useState(); const styles = useStyles2(getStyles); return (
{showHeading && (
Select a metric
)}
); }; } function getMetricNamesVariableSet() { return new SceneVariableSet({ variables: [ new QueryVariable({ name: VAR_METRIC_NAMES, datasource: trailDS, hide: VariableHide.hideVariable, includeAll: true, defaultToAll: true, skipUrlSync: true, query: { query: `label_values(${VAR_FILTERS_EXPR},__name__)`, refId: 'A' }, }), ], }); } function getPreviewPanelFor(metric: string, index: number) { const autoQuery = getAutoQueriesForMetric(metric); const vizPanel = autoQuery.preview .vizBuilder() .setColor({ mode: 'fixed', fixedColor: getColorByIndex(index) }) .setHeaderActions(new SelectMetricAction({ metric, title: 'Select' })) .build(); return new SceneCSSGridItem({ $variables: new SceneVariableSet({ variables: getVariablesWithMetricConstant(metric), }), $behaviors: [hideEmptyPreviews(metric)], $data: new SceneQueryRunner({ datasource: trailDS, maxDataPoints: 200, queries: autoQuery.preview.queries, }), body: vizPanel, }); } function getCardPanelFor(metric: string) { return PanelBuilders.text() .setTitle(metric) .setHeaderActions(new SelectMetricAction({ metric, title: 'Select' })) .setOption('content', '') .build(); } // 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); 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('_'); return ( (leven(aHalf, metric!) || 0 + (leven(aValue, metric!) || 0)) - (leven(bHalf, metric!) || 0 + (leven(bValue, metric!) || 0)) ); }); } function getStyles(theme: GrafanaTheme2) { return { container: css({ display: 'flex', flexDirection: 'column', flexGrow: 1, }), headingWrapper: css({ marginTop: theme.spacing(1), }), header: css({ flexGrow: 0, display: 'flex', gap: theme.spacing(2), marginBottom: theme.spacing(1), }), }; } // Consider any sequence of characters not permitted for metric names as a sepratator const splitSeparator = /[^a-z0-9_:]+/; function createSearchRegExp(spaceSeparatedMetricNames?: string) { if (!spaceSeparatedMetricNames) { return null; } const searchParts = spaceSeparatedMetricNames ?.toLowerCase() .split(splitSeparator) .filter((part) => part.length > 0) .map((part) => `(?=(.*${part}.*))`); if (searchParts.length === 0) { return null; } const regex = searchParts.join(''); // (?=(.*expr1.*))(?=().*expr2.*))... // The ?=(...) lookahead allows us to match these in any order. return new RegExp(regex, 'igy'); }