The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/trails/MetricSelect/MetricSelectScene.tsx

723 lines
24 KiB

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<SceneCSSGridItem>;
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<MetricSelectSceneState> implements SceneObjectWithUrlSync {
private previewCache: Record<string, MetricPanel> = {};
private ignoreNextUpdate = false;
private _debounceRefreshMetricNames = debounce(() => this._refreshMetricNames(), 1000);
constructor(state: Partial<MetricSelectSceneState>) {
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<string, MetricPanel> = {};
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<HTMLInputElement>) => {
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<MetricSelectScene>) => {
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 ? (
<Tooltip
content={
<>
<h4>{unableToRetrieveMetricNames}</h4>
<p>{metricNamesWarning}</p>
</>
}
>
<Icon className={styles.warningIcon} name="exclamation-triangle" />
</Tooltip>
) : undefined;
return (
<div className={styles.container}>
<div className={styles.header}>
<Field
label={t('trails.metric-select-scene.label-search-metrics', 'Search metrics')}
className={styles.searchField}
>
<Input
placeholder={t('trails.metric-select-scene.placeholder-search-metrics', 'Search metrics')}
prefix={<Icon name={'search'} />}
value={metricSearch}
onChange={model.onSearchQueryChange}
suffix={metricNamesWarningIcon}
/>
</Field>
<Field
label={
<div className={styles.displayOptionTooltip}>
<Trans i18nKey="explore-metrics.viewBy">View by</Trans>
<IconButton name={'info-circle'} size="sm" variant={'secondary'} tooltip={viewByTooltip} />
</div>
}
className={styles.displayOption}
>
<Select
value={metricPrefix}
onChange={model.onPrefixFilterChange}
onOpenMenu={() => model.reportPrefixFilterInteraction(true)}
onCloseMenu={() => model.reportPrefixFilterInteraction(false)}
options={[
{
label: 'All metric names',
value: METRIC_PREFIX_ALL,
},
...Array.from(rootGroup?.groups.keys() ?? []).map((g) => ({ label: `${g}_`, value: g })),
]}
className="metrics-drilldown-metric-prefix-select"
/>
</Field>
{!metric && hasOtelResources && (
<Field
label={
<>
<div className={styles.displayOptionTooltip}>
<Trans i18nKey="trails.metric-select.filter-by">Filter by</Trans>
<IconButton
name={'info-circle'}
size="sm"
variant={'secondary'}
tooltip={
<Trans i18nKey="trails.metric-select.otel-switch">
This switch enables filtering by OTel resources for OTel native data sources.
</Trans>
}
/>
<div>
<Badge
text={<Trans i18nKey="trails.metric-select.new-badge">New</Trans>}
color={'blue'}
className={styles.badgeStyle}
></Badge>
</div>
</div>
</>
}
className={styles.displayOption}
>
<div
title={
!isStandardOtel ? 'This setting is disabled because this is not an OTel native data source.' : ''
}
>
<InlineSwitch
disabled={!isStandardOtel}
showLabel={true}
label={t('trails.metric-select-scene.label-otel-experience', 'OTel experience')}
value={useOtelExperience}
onChange={model.onToggleOtelExperience}
/>
</div>
</Field>
)}
</div>
{metricNamesError && (
<Alert title={unableToRetrieveMetricNames} severity="error">
<div>We are unable to connect to your data source. Double check your data source URL and credentials.</div>
<div>({metricNamesError})</div>
</Alert>
)}
{metricNamesWarning && !warningDismissed && (
<Alert
title={t(
'trails.metric-select-scene.title-unable-to-retrieve-all-metric-names',
'Unable to retrieve all metric names'
)}
severity="warning"
onSubmit={dismissWarning}
onRemove={dismissWarning}
>
<div>{metricNamesWarning}</div>
</Alert>
)}
<StatusWrapper {...{ isLoading, blockingMessage }}>
{body instanceof SceneFlexLayout && <body.Component model={body} />}
{body instanceof SceneCSSGridLayout && <body.Component model={body} />}
</StatusWrapper>
</div>
);
};
}
function getCardPanelFor(metric: string, description?: string) {
return PanelBuilders.text()
.setTitle(metric)
.setDescription(description)
.setHeaderActions([new SelectMetricAction({ metric, title: 'Select' })])
.setOption('content', '')
.build();
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
display: 'flex',
flexDirection: 'column',
}),
headingWrapper: css({
marginBottom: theme.spacing(0.5),
}),
header: css({
flexGrow: 0,
display: 'flex',
gap: theme.spacing(2),
marginBottom: theme.spacing(2),
alignItems: 'flex-end',
}),
searchField: css({
flexGrow: 1,
marginBottom: 0,
}),
metricTabGroup: css({
marginBottom: theme.spacing(2),
}),
displayOption: css({
flexGrow: 0,
marginBottom: 0,
minWidth: '184px',
}),
displayOptionTooltip: css({
display: 'flex',
gap: theme.spacing(1),
}),
warningIcon: css({
color: theme.colors.warning.main,
}),
badgeStyle: css({
display: 'flex',
height: '1rem',
padding: '0rem 0.25rem 0 0.30rem',
alignItems: 'center',
borderRadius: theme.shape.radius.pill,
border: `1px solid ${theme.colors.info.text}`,
background: theme.colors.info.transparent,
marginTop: '4px',
marginLeft: '-3px',
}),
};
}
function getMetricSearch(scene: SceneObject) {
const trail = getTrailFor(scene);
return trail.state.metricSearch || '';
}