Chore: Split PrometheusMetricsBrowser into small functional components (#101703)

* convert it to functional component

* useReducer

* usePromQueryFieldEffects

* clean up the code

* remove localStorage provider

* introduce usePromQueryFieldEffects.test.ts

* simpler state management

* remove mocks

* linting + betterer

* Split PrometheusMetricsBrowser into small functional components

* prettier
This commit is contained in:
ismail simsek
2025-04-04 18:58:46 +02:00
committed by GitHub
parent 23e0f63790
commit 8fadade4b8
17 changed files with 1366 additions and 881 deletions
@@ -11,8 +11,8 @@ import { PrometheusDatasource } from '../datasource';
import { getInitHints } from '../query_hints';
import { PromOptions, PromQuery } from '../types';
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise';
import { PrometheusMetricsBrowser } from './metrics-browser/PrometheusMetricsBrowser';
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper';
import { useMetricsState } from './useMetricsState';
import { usePromQueryFieldEffects } from './usePromQueryFieldEffects';
@@ -136,7 +136,7 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
};
// Use our custom effects hook
usePromQueryFieldEffects(range, data?.series, refreshMetrics, refreshHint);
usePromQueryFieldEffects(languageProvider, range, data?.series, refreshMetrics, refreshHint);
const { chooserText, buttonDisabled } = useMetricsState(datasource, languageProvider, syntaxLoaded);
@@ -1,734 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx
import { css, cx } from '@emotion/css';
import { ChangeEvent } from 'react';
import * as React from 'react';
import { FixedSizeList } from 'react-window';
import { getDefaultTimeRange, GrafanaTheme2, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import {
BrowserLabel as PromLabel,
Button,
Stack,
Input,
Label,
LoadingPlaceholder,
stylesFactory,
withTheme2,
} from '@grafana/ui';
import PromQlLanguageProvider from '../language_provider';
import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../language_utils';
import { isValidLegacyName, utf8Support } from '../utf8_support';
// Hard limit on labels to render
const EMPTY_SELECTOR = '{}';
const METRIC_LABEL = '__name__';
const LIST_ITEM_SIZE = 25;
const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels';
export interface BrowserProps {
languageProvider: PromQlLanguageProvider;
onChange: (selector: string) => void;
theme: GrafanaTheme2;
autoSelect?: number;
hide?: () => void;
timeRange?: TimeRange;
}
interface BrowserState {
labels: SelectableLabel[];
labelSearchTerm: string;
metricSearchTerm: string;
status: string;
error: string;
validationStatus: string;
valueSearchTerm: string;
seriesLimit?: string;
}
export const DEFAULT_SERIES_LIMIT = '40000';
export const REMOVE_SERIES_LIMIT = 'none';
interface FacettableValue {
name: string;
selected?: boolean;
details?: string;
}
export interface SelectableLabel {
name: string;
selected?: boolean;
loading?: boolean;
values?: FacettableValue[];
hidden?: boolean;
facets?: number;
}
export function buildSelector(labels: SelectableLabel[]): string {
let singleMetric = '';
const selectedLabels: string[] = [];
for (const label of labels) {
if ((label.name === METRIC_LABEL || label.selected) && label.values && label.values.length > 0) {
const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name);
if (selectedValues.length > 1) {
selectedLabels.push(
`${utf8Support(label.name)}=~"${selectedValues.map(escapeLabelValueInRegexSelector).join('|')}"`
);
} else if (selectedValues.length === 1) {
if (label.name === METRIC_LABEL) {
singleMetric = selectedValues[0];
} else {
selectedLabels.push(`${utf8Support(label.name)}="${escapeLabelValueInExactSelector(selectedValues[0])}"`);
}
}
}
}
const selectorParts: string[] = [];
const isLegacyName = singleMetric === '' || isValidLegacyName(singleMetric);
if (isLegacyName) {
selectorParts.push(singleMetric, '{');
} else {
selectorParts.push('{', `"${singleMetric}"`);
if (selectedLabels.length > 0) {
selectorParts.push(',');
}
}
selectorParts.push(selectedLabels.join(','), '}');
return selectorParts.join('');
}
export function facetLabels(
labels: SelectableLabel[],
possibleLabels: Record<string, string[]>,
lastFacetted?: string
): SelectableLabel[] {
return labels.map((label) => {
const possibleValues = possibleLabels[label.name];
if (possibleValues) {
let existingValues: FacettableValue[];
if (label.name === lastFacetted && label.values) {
// Facetting this label, show all values
existingValues = label.values;
} else {
// Keep selection in other facets
const selectedValues: Set<string> = new Set(
label.values?.filter((value) => value.selected).map((value) => value.name) || []
);
// Values for this label have not been requested yet, let's use the facetted ones as the initial values
existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) }));
}
return {
...label,
loading: false,
values: existingValues,
hidden: !possibleValues,
facets: existingValues.length,
};
}
// Label is facetted out, hide all values
return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 };
});
}
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
wrapper: css({
backgroundColor: theme.colors.background.secondary,
padding: theme.spacing(1),
width: '100%',
}),
list: css({
marginTop: theme.spacing(1),
display: 'flex',
flexWrap: 'wrap',
maxHeight: '200px',
overflow: 'auto',
alignContent: 'flex-start',
}),
section: css({
'& + &': {
margin: `${theme.spacing(2)} 0`,
},
position: 'relative',
}),
selector: css({
fontFamily: theme.typography.fontFamilyMonospace,
marginBottom: theme.spacing(1),
}),
status: css({
padding: theme.spacing(0.5),
color: theme.colors.text.secondary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
/* using absolute positioning because flex interferes with ellipsis */
position: 'absolute',
width: '50%',
right: 0,
textAlign: 'right',
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 100ms linear',
},
}),
statusShowing: css({
opacity: 1,
}),
error: css({
color: theme.colors.error.main,
}),
valueList: css({
marginRight: theme.spacing(1),
resize: 'horizontal',
}),
valueListWrapper: css({
borderLeft: `1px solid ${theme.colors.border.medium}`,
margin: `${theme.spacing(1)} 0`,
padding: `${theme.spacing(1)} 0 ${theme.spacing(1)} ${theme.spacing(1)}`,
}),
valueListArea: css({
display: 'flex',
flexWrap: 'wrap',
marginTop: theme.spacing(1),
}),
valueTitle: css({
marginLeft: `-${theme.spacing(0.5)}`,
marginBottom: theme.spacing(1),
}),
validationStatus: css({
padding: theme.spacing(0.5),
marginBottom: theme.spacing(1),
color: theme.colors.text.maxContrast,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
}));
/**
* TODO #33976: Remove duplicated code. The component is very similar to LokiLabelBrowser.tsx. Check if it's possible
* to create a single, generic component.
*/
export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserProps, BrowserState> {
valueListsRef = React.createRef<HTMLDivElement>();
state: BrowserState = {
labels: [],
labelSearchTerm: '',
metricSearchTerm: '',
status: 'Ready',
error: '',
validationStatus: '',
valueSearchTerm: '',
};
onChangeLabelSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ labelSearchTerm: event.target.value });
};
onChangeMetricSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ metricSearchTerm: event.target.value });
};
onChangeSeriesLimit = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ seriesLimit: event.target.value.trim() });
};
onChangeValueSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ valueSearchTerm: event.target.value });
};
onClickRunQuery = () => {
const selector = buildSelector(this.state.labels);
this.props.onChange(selector);
};
onClickRunRateQuery = () => {
const selector = buildSelector(this.state.labels);
const query = `rate(${selector}[$__rate_interval])`;
this.props.onChange(query);
};
onClickClear = () => {
this.setState((state) => {
const labels: SelectableLabel[] = state.labels.map((label) => ({
...label,
values: undefined,
selected: false,
loading: false,
hidden: false,
facets: undefined,
}));
return {
labels,
labelSearchTerm: '',
metricSearchTerm: '',
status: '',
error: '',
validationStatus: '',
valueSearchTerm: '',
};
});
localStorage.removeItem(LAST_USED_LABELS_KEY);
// Get metrics
this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
};
onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
const label = this.state.labels.find((l) => l.name === name);
if (!label) {
return;
}
// Toggle selected state
const selected = !label.selected;
let nextValue: Partial<SelectableLabel> = { selected };
if (label.values && !selected) {
// Deselect all values if label was deselected
const values = label.values.map((value) => ({ ...value, selected: false }));
nextValue = { ...nextValue, facets: 0, values };
}
// Resetting search to prevent empty results
this.setState({ labelSearchTerm: '' });
this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name));
};
onClickValue = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
const label = this.state.labels.find((l) => l.name === name);
if (!label || !label.values) {
return;
}
// Resetting search to prevent empty results
this.setState({ labelSearchTerm: '' });
// Toggling value for selected label, leaving other values intact
const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected }));
this.updateLabelState(name, { values }, '', () => this.doFacetting(name));
};
onClickMetric = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
// Finding special metric label
const label = this.state.labels.find((l) => l.name === name);
if (!label || !label.values) {
return;
}
// Resetting search to prevent empty results
this.setState({ metricSearchTerm: '' });
// Toggling value for selected label, leaving other values intact
const values = label.values.map((v) => ({
...v,
selected: v.name === value || v.selected ? !v.selected : v.selected,
}));
// Toggle selected state of special metrics label
const selected = values.some((v) => v.selected);
this.updateLabelState(name, { selected, values }, '', () => this.doFacetting(name));
};
onClickValidate = () => {
const selector = buildSelector(this.state.labels);
this.validateSelector(selector);
};
updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) {
this.setState((state) => {
const labels: SelectableLabel[] = state.labels.map((label) => {
if (label.name === name) {
return { ...label, ...updatedFields };
}
return label;
});
// New status overrides errors
const error = status ? '' : state.error;
return { labels, status, error, validationStatus: '' };
}, cb);
}
componentDidMount() {
const { languageProvider } = this.props;
if (languageProvider) {
const selectedLabels: string[] = JSON.parse(localStorage.getItem(LAST_USED_LABELS_KEY) ?? `[]`) ?? [];
languageProvider.start(this.props.timeRange).then(() => {
let rawLabels: string[] = languageProvider.getLabelKeys();
// Get metrics
this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
// Auto-select previously selected labels
const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({
name: label,
selected: selectedLabels.includes(label),
loading: false,
}));
// Pre-fetch values for selected labels
this.setState({ labels }, () => {
this.state.labels.forEach((label) => {
if (label.selected) {
this.fetchValues(label.name, EMPTY_SELECTOR);
}
});
});
});
}
}
doFacettingForLabel(name: string) {
const label = this.state.labels.find((l) => l.name === name);
if (!label) {
return;
}
const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name);
localStorage.setItem(LAST_USED_LABELS_KEY, JSON.stringify(selectedLabels));
if (label.selected) {
// Refetch values for newly selected label...
if (!label.values) {
this.fetchValues(name, buildSelector(this.state.labels));
}
} else {
// Only need to facet when deselecting labels
this.doFacetting();
}
}
doFacetting = (lastFacetted?: string) => {
const selector = buildSelector(this.state.labels);
if (selector === EMPTY_SELECTOR) {
// Clear up facetting
const labels: SelectableLabel[] = this.state.labels.map((label) => {
return { ...label, facets: 0, values: undefined, hidden: false };
});
this.setState({ labels }, () => {
// Get fresh set of values
this.state.labels.forEach(
(label) => (label.selected || label.name === METRIC_LABEL) && this.fetchValues(label.name, selector)
);
});
} else {
// Do facetting
this.fetchSeries(selector, lastFacetted);
}
};
getTimeRange = (): TimeRange => {
return this.props.timeRange ?? getDefaultTimeRange();
};
async fetchValues(name: string, selector: string) {
const { languageProvider } = this.props;
this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`);
try {
let rawValues = await languageProvider.getLabelValues(this.getTimeRange(), name);
// If selector changed, clear loading state and discard result by returning early
if (selector !== buildSelector(this.state.labels)) {
this.updateLabelState(name, { loading: false });
return;
}
const values: FacettableValue[] = [];
const { metricsMetadata } = languageProvider;
for (const labelValue of rawValues) {
const value: FacettableValue = { name: labelValue };
// Adding type/help text to metrics
if (name === METRIC_LABEL && metricsMetadata) {
const meta = metricsMetadata[labelValue];
if (meta) {
value.details = `(${meta.type}) ${meta.help}`;
}
}
values.push(value);
}
this.updateLabelState(name, { values, loading: false });
} catch (error) {
console.error(error);
}
}
async fetchSeries(selector: string, lastFacetted?: string) {
const { languageProvider } = this.props;
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`);
}
try {
const possibleLabels = await languageProvider.fetchSeriesLabels(
this.getTimeRange(),
selector,
true,
this.state.seriesLimit
);
// If selector changed, clear loading state and discard result by returning early
if (selector !== buildSelector(this.state.labels)) {
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: false });
}
return;
}
if (Object.keys(possibleLabels).length === 0) {
this.setState({ error: `Empty results, no matching label for ${selector}` });
return;
}
const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted);
this.setState({ labels, error: '' });
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: false });
}
} catch (error) {
console.error(error);
}
}
async validateSelector(selector: string) {
const { languageProvider } = this.props;
this.setState({ validationStatus: `Validating selector ${selector}`, error: '' });
const streams = await languageProvider.fetchSeries(this.getTimeRange(), selector);
this.setState({ validationStatus: `Selector is valid (${streams.length} series found)` });
}
render() {
const { theme } = this.props;
const { labels, labelSearchTerm, metricSearchTerm, status, error, validationStatus, valueSearchTerm } = this.state;
const styles = getStyles(theme);
if (labels.length === 0) {
return (
<div className={styles.wrapper}>
<LoadingPlaceholder text="Loading labels..." />
</div>
);
}
// Filter metrics
let metrics = labels.find((label) => label.name === METRIC_LABEL);
if (metrics && metricSearchTerm) {
metrics = {
...metrics,
values: metrics.values?.filter((value) => value.selected || value.name.includes(metricSearchTerm)),
};
}
// Filter labels
let nonMetricLabels = labels.filter((label) => !label.hidden && label.name !== METRIC_LABEL);
if (labelSearchTerm) {
nonMetricLabels = nonMetricLabels.filter((label) => label.selected || label.name.includes(labelSearchTerm));
}
// Filter non-metric label values
let selectedLabels = nonMetricLabels.filter((label) => label.selected && label.values);
if (valueSearchTerm) {
selectedLabels = selectedLabels.map((label) => ({
...label,
values: label.values?.filter((value) => value.selected || value.name.includes(valueSearchTerm)),
}));
}
const selector = buildSelector(this.state.labels);
const empty = selector === EMPTY_SELECTOR;
const metricCount = metrics?.values?.length || 0;
return (
<div className={styles.wrapper}>
<Stack gap={3}>
<div>
<div className={styles.section}>
<Label description="Once a metric is selected only possible labels are shown. Labels are limited by the series limit below.">
1. Select a metric
</Label>
<div>
<Input
onChange={this.onChangeMetricSearch}
aria-label="Filter expression for metric"
value={metricSearchTerm}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric}
/>
</div>
<Label description="Set to 'none' to remove limit and show all labels for a selected metric. Removing the limit may cause performance issues.">
Series limit
</Label>
<div>
<Input
onChange={this.onChangeSeriesLimit}
aria-label="Limit results from series endpoint"
value={this.state.seriesLimit ?? DEFAULT_SERIES_LIMIT}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.seriesLimit}
/>
</div>
<div
role="list"
className={styles.valueListWrapper}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.metricList}
>
<FixedSizeList
height={Math.min(450, metricCount * LIST_ITEM_SIZE)}
itemCount={metricCount}
itemSize={LIST_ITEM_SIZE}
itemKey={(i) => metrics!.values![i].name}
width={300}
className={styles.valueList}
>
{({ index, style }) => {
const value = metrics?.values?.[index];
if (!value) {
return null;
}
return (
<div style={style}>
<PromLabel
name={metrics!.name}
value={value?.name}
title={value.details}
active={value?.selected}
onClick={this.onClickMetric}
searchTerm={metricSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
</div>
</div>
<div>
<div className={styles.section}>
<Label description="Once label values are selected, only possible label combinations are shown.">
2. Select labels to search in
</Label>
<div>
<Input
onChange={this.onChangeLabelSearch}
aria-label="Filter expression for label"
value={labelSearchTerm}
data-testid={
selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelNamesFilter
}
/>
</div>
{/* Using fixed height here to prevent jumpy layout */}
<div className={styles.list} style={{ height: 120 }}>
{nonMetricLabels.map((label) => (
<PromLabel
key={label.name}
name={label.name}
loading={label.loading}
active={label.selected}
hidden={label.hidden}
facets={label.facets}
onClick={this.onClickLabel}
searchTerm={labelSearchTerm}
/>
))}
</div>
</div>
<div className={styles.section}>
<Label description="Use the search field to find values across selected labels.">
3. Select (multiple) values for your labels
</Label>
<div>
<Input
onChange={this.onChangeValueSearch}
aria-label="Filter expression for label values"
value={valueSearchTerm}
data-testid={
selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelValuesFilter
}
/>
</div>
<div className={styles.valueListArea} ref={this.valueListsRef}>
{selectedLabels.map((label) => (
<div
role="list"
key={label.name}
aria-label={`Values for ${label.name}`}
className={styles.valueListWrapper}
>
<div className={styles.valueTitle}>
<PromLabel
name={label.name}
loading={label.loading}
active={label.selected}
hidden={label.hidden}
//If no facets, we want to show number of all label values
facets={label.facets || label.values?.length}
onClick={this.onClickLabel}
/>
</div>
<FixedSizeList
height={Math.min(200, LIST_ITEM_SIZE * (label.values?.length || 0))}
itemCount={label.values?.length || 0}
itemSize={28}
itemKey={(i) => label.values![i].name}
width={200}
className={styles.valueList}
>
{({ index, style }) => {
const value = label.values?.[index];
if (!value) {
return null;
}
return (
<div style={style}>
<PromLabel
name={label.name}
value={value?.name}
active={value?.selected}
onClick={this.onClickValue}
searchTerm={valueSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
))}
</div>
</div>
</div>
</Stack>
<div className={styles.section}>
<Label>4. Resulting selector</Label>
<div aria-label="selector" className={styles.selector}>
{selector}
</div>
{validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>}
<Stack>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery}
aria-label="Use selector for query button"
disabled={empty}
onClick={this.onClickRunQuery}
>
Use query
</Button>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useAsRateQuery}
aria-label="Use selector as metrics button"
variant="secondary"
disabled={empty}
onClick={this.onClickRunRateQuery}
>
Use as rate query
</Button>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.validateSelector}
aria-label="Validate submit button"
variant="secondary"
disabled={empty}
onClick={this.onClickValidate}
>
Validate selector
</Button>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.clear}
aria-label="Selector clear button"
variant="secondary"
onClick={this.onClickClear}
>
Clear
</Button>
<div className={cx(styles.status, (status || error) && styles.statusShowing)}>
<span className={error ? styles.error : ''}>{error || status}</span>
</div>
</Stack>
</div>
</div>
);
}
}
export const PrometheusMetricsBrowser = withTheme2(UnthemedPrometheusMetricsBrowser);
@@ -0,0 +1,53 @@
import { ChangeEvent, MouseEvent } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Input, Label, BrowserLabel as PromLabel } from '@grafana/ui';
import { SelectableLabel } from './types';
interface LabelSelectorProps {
nonMetricLabels: SelectableLabel[];
labelSearchTerm: string;
onChangeLabelSearch: (event: ChangeEvent<HTMLInputElement>) => void;
onClickLabel: (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => void;
styles: Record<string, string>;
}
export function LabelSelector({
nonMetricLabels,
labelSearchTerm,
onChangeLabelSearch,
onClickLabel,
styles,
}: LabelSelectorProps) {
return (
<div className={styles.section}>
<Label description="Once label values are selected, only possible label combinations are shown.">
2. Select labels to search in
</Label>
<div>
<Input
onChange={onChangeLabelSearch}
aria-label="Filter expression for label"
value={labelSearchTerm}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelNamesFilter}
/>
</div>
{/* Using fixed height here to prevent jumpy layout */}
<div className={styles.list} style={{ height: 120 }}>
{nonMetricLabels.map((label) => (
<PromLabel
key={label.name}
name={label.name}
loading={label.loading}
active={label.selected}
hidden={label.hidden}
facets={label.facets}
onClick={onClickLabel}
searchTerm={labelSearchTerm}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import { ChangeEvent, MouseEvent } from 'react';
import { FixedSizeList } from 'react-window';
import { selectors } from '@grafana/e2e-selectors';
import { BrowserLabel as PromLabel, Input, Label } from '@grafana/ui';
import { LIST_ITEM_SIZE, SelectableLabel } from './types';
interface MetricSelectorProps {
metrics: SelectableLabel | undefined;
metricSearchTerm: string;
seriesLimit: string;
onChangeMetricSearch: (event: ChangeEvent<HTMLInputElement>) => void;
onChangeSeriesLimit: (event: ChangeEvent<HTMLInputElement>) => void;
onClickMetric: (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => void;
styles: Record<string, string>;
}
export function MetricSelector({
metrics,
metricSearchTerm,
seriesLimit,
onChangeMetricSearch,
onChangeSeriesLimit,
onClickMetric,
styles,
}: MetricSelectorProps) {
const metricCount = metrics?.values?.length || 0;
return (
<div>
<div className={styles.section}>
<Label description="Once a metric is selected only possible labels are shown. Labels are limited by the series limit below.">
1. Select a metric
</Label>
<div>
<Input
onChange={onChangeMetricSearch}
aria-label="Filter expression for metric"
value={metricSearchTerm}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric}
/>
</div>
<Label description="Set to 'none' to remove limit and show all labels for a selected metric. Removing the limit may cause performance issues.">
Series limit
</Label>
<div>
<Input
onChange={onChangeSeriesLimit}
aria-label="Limit results from series endpoint"
value={seriesLimit}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.seriesLimit}
/>
</div>
<div
role="list"
className={styles.valueListWrapper}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.metricList}
>
<FixedSizeList
height={Math.min(450, metricCount * LIST_ITEM_SIZE)}
itemCount={metricCount}
itemSize={LIST_ITEM_SIZE}
itemKey={(i) => metrics!.values![i].name}
width={300}
className={styles.valueList}
>
{({ index, style }) => {
const value = metrics?.values?.[index];
if (!value) {
return null;
}
return (
<div style={style}>
<PromLabel
name={metrics!.name}
value={value?.name}
title={value.details}
active={value?.selected}
onClick={onClickMetric}
searchTerm={metricSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
</div>
</div>
);
}
@@ -4,143 +4,10 @@ import userEvent from '@testing-library/user-event';
import { createTheme, getDefaultTimeRange, TimeRange } from '@grafana/data';
import PromQlLanguageProvider from '../language_provider';
import PromQlLanguageProvider from '../../language_provider';
import {
BrowserProps,
buildSelector,
facetLabels,
SelectableLabel,
UnthemedPrometheusMetricsBrowser,
} from './PrometheusMetricsBrowser';
describe('buildSelector()', () => {
it('returns an empty selector for no labels', () => {
expect(buildSelector([])).toEqual('{}');
});
it('returns an empty selector for selected labels with no values', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true }];
expect(buildSelector(labels)).toEqual('{}');
});
it('returns an empty selector for one selected label with no selected values', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar' }] }];
expect(buildSelector(labels)).toEqual('{}');
});
it('returns a simple selector from a selected label with a selected value', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar', selected: true }] }];
expect(buildSelector(labels)).toEqual('{foo="bar"}');
});
it('metric selector without labels', () => {
const labels: SelectableLabel[] = [{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }];
expect(buildSelector(labels)).toEqual('foo{}');
});
it('selector with multiple metrics', () => {
const labels: SelectableLabel[] = [
{
name: '__name__',
selected: true,
values: [
{ name: 'foo', selected: true },
{ name: 'bar', selected: true },
],
},
];
expect(buildSelector(labels)).toEqual('{__name__=~"foo|bar"}');
});
it('metric selector with labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] },
{ name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('foo{bar="baz"}');
});
describe('utf8 support', () => {
it('metric selector with utf8 metric', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] },
];
expect(buildSelector(labels)).toEqual('{"utf8.metric"}');
});
it('metric selector with utf8 labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] },
{ name: 'utf8.label', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('foo{"utf8.label"="baz"}');
});
it('metric selector with utf8 labels and metrics', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] },
{ name: 'utf8.label', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('{"utf8.metric","utf8.label"="baz"}');
});
it('metric selector with utf8 metric and with utf8/non-utf8 labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] },
{ name: 'utf8.label', selected: true, values: [{ name: 'uuu', selected: true }] },
{ name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('{"utf8.metric","utf8.label"="uuu",bar="baz"}');
});
it('metric selector with non-utf8 metric with utf8/non-utf8 labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] },
{ name: 'utf8.label', selected: true, values: [{ name: 'uuu', selected: true }] },
{ name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('foo{"utf8.label"="uuu",bar="baz"}');
});
});
});
describe('facetLabels()', () => {
const possibleLabels = {
cluster: ['dev'],
namespace: ['alertmanager'],
};
const labels: SelectableLabel[] = [
{ name: 'foo', selected: true, values: [{ name: 'bar' }] },
{ name: 'cluster', values: [{ name: 'dev' }, { name: 'ops' }, { name: 'prod' }] },
{ name: 'namespace', values: [{ name: 'alertmanager' }] },
];
it('returns no labels given an empty label set', () => {
expect(facetLabels([], {})).toEqual([]);
});
it('marks all labels as hidden when no labels are possible', () => {
const result = facetLabels(labels, {});
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[0].values).toBeUndefined();
});
it('keeps values as facetted when they are possible', () => {
const result = facetLabels(labels, possibleLabels);
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[0].values).toBeUndefined();
expect(result[1].hidden).toBeFalsy();
expect(result[1].values!.length).toBe(1);
expect(result[1].values![0].name).toBe('dev');
});
it('does not facet out label values that are currently being facetted', () => {
const result = facetLabels(labels, possibleLabels, 'cluster');
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[1].hidden).toBeFalsy();
// 'cluster' is being facetted, should show all 3 options even though only 1 is possible
expect(result[1].values!.length).toBe(3);
expect(result[2].values!.length).toBe(1);
});
});
import { UnthemedPrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
import { BrowserProps } from './types';
describe('PrometheusMetricsBrowser', () => {
const setupProps = (): BrowserProps => {
@@ -0,0 +1,373 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx
import { ChangeEvent, Component, MouseEvent } from 'react';
import { getDefaultTimeRange } from '@grafana/data';
import { LoadingPlaceholder, Stack, withTheme2 } from '@grafana/ui';
import { LabelSelector } from './LabelSelector';
import { MetricSelector } from './MetricSelector';
import { SelectorActions } from './SelectorActions';
import { ValueSelector } from './ValueSelector';
import { buildSelector, facetLabels } from './selectorBuilder';
import { getStyles } from './styles';
import {
BrowserProps,
BrowserState,
DEFAULT_SERIES_LIMIT,
EMPTY_SELECTOR,
FacettableValue,
LAST_USED_LABELS_KEY,
METRIC_LABEL,
SelectableLabel,
} from './types';
export class UnthemedPrometheusMetricsBrowser extends Component<BrowserProps, BrowserState> {
state: BrowserState = {
labels: [],
labelSearchTerm: '',
metricSearchTerm: '',
status: 'Ready',
error: '',
validationStatus: '',
valueSearchTerm: '',
};
onChangeLabelSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ labelSearchTerm: event.target.value });
};
onChangeMetricSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ metricSearchTerm: event.target.value });
};
onChangeSeriesLimit = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ seriesLimit: event.target.value.trim() });
};
onChangeValueSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ valueSearchTerm: event.target.value });
};
onClickRunQuery = () => {
const selector = buildSelector(this.state.labels);
this.props.onChange(selector);
};
onClickRunRateQuery = () => {
const selector = buildSelector(this.state.labels);
const query = `rate(${selector}[$__rate_interval])`;
this.props.onChange(query);
};
onClickClear = () => {
this.setState((state) => {
const labels: SelectableLabel[] = state.labels.map((label) => ({
...label,
values: undefined,
selected: false,
loading: false,
hidden: false,
facets: undefined,
}));
return {
labels,
labelSearchTerm: '',
metricSearchTerm: '',
status: '',
error: '',
validationStatus: '',
valueSearchTerm: '',
};
});
localStorage.removeItem(LAST_USED_LABELS_KEY);
// Get metrics
this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
};
onClickLabel = (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => {
const label = this.state.labels.find((l) => l.name === name);
if (!label) {
return;
}
// Toggle selected state
const selected = !label.selected;
let nextValue: Partial<SelectableLabel> = { selected };
if (label.values && !selected) {
// Deselect all values if label was deselected
const values = label.values.map((value) => ({ ...value, selected: false }));
nextValue = { ...nextValue, facets: 0, values };
}
// Resetting search to prevent empty results
this.setState({ labelSearchTerm: '' });
this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name));
};
onClickValue = (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => {
const label = this.state.labels.find((l) => l.name === name);
if (!label || !label.values) {
return;
}
// Resetting search to prevent empty results
this.setState({ labelSearchTerm: '' });
// Toggling value for selected label, leaving other values intact
const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected }));
this.updateLabelState(name, { values }, '', () => this.doFacetting(name));
};
onClickMetric = (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => {
// Finding special metric label
const label = this.state.labels.find((l) => l.name === name);
if (!label || !label.values) {
return;
}
// Resetting search to prevent empty results
this.setState({ metricSearchTerm: '' });
// Toggling value for selected label, leaving other values intact
const values = label.values.map((v) => ({
...v,
selected: v.name === value || v.selected ? !v.selected : v.selected,
}));
// Toggle selected state of special metrics label
const selected = values.some((v) => v.selected);
this.updateLabelState(name, { selected, values }, '', () => this.doFacetting(name));
};
onClickValidate = () => {
const selector = buildSelector(this.state.labels);
this.validateSelector(selector);
};
updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) {
this.setState((state) => {
const labels: SelectableLabel[] = state.labels.map((label) => {
if (label.name === name) {
return { ...label, ...updatedFields };
}
return label;
});
// New status overrides errors
const error = status ? '' : state.error;
return { labels, status, error, validationStatus: '' };
}, cb);
}
componentDidMount() {
const { languageProvider } = this.props;
if (languageProvider) {
const selectedLabels: string[] = JSON.parse(localStorage.getItem(LAST_USED_LABELS_KEY) ?? `[]`) ?? [];
languageProvider.start(this.props.timeRange).then(() => {
let rawLabels: string[] = languageProvider.getLabelKeys();
// Get metrics
this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
// Auto-select previously selected labels
const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({
name: label,
selected: selectedLabels.includes(label),
loading: false,
}));
// Pre-fetch values for selected labels
this.setState({ labels }, () => {
this.state.labels.forEach((label) => {
if (label.selected) {
this.fetchValues(label.name, EMPTY_SELECTOR);
}
});
});
});
}
}
doFacettingForLabel(name: string) {
const label = this.state.labels.find((l) => l.name === name);
if (!label) {
return;
}
const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name);
localStorage.setItem(LAST_USED_LABELS_KEY, JSON.stringify(selectedLabels));
if (label.selected) {
// Refetch values for newly selected label...
if (!label.values) {
this.fetchValues(name, buildSelector(this.state.labels));
}
} else {
// Only need to facet when deselecting labels
this.doFacetting();
}
}
doFacetting = (lastFacetted?: string) => {
const selector = buildSelector(this.state.labels);
if (selector === EMPTY_SELECTOR) {
// Clear up facetting
const labels: SelectableLabel[] = this.state.labels.map((label) => {
return { ...label, facets: 0, values: undefined, hidden: false };
});
this.setState({ labels }, () => {
// Get fresh set of values
this.state.labels.forEach(
(label) => (label.selected || label.name === METRIC_LABEL) && this.fetchValues(label.name, selector)
);
});
} else {
// Do facetting
this.fetchSeries(selector, lastFacetted);
}
};
async fetchValues(name: string, selector: string) {
const { languageProvider } = this.props;
this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`);
try {
let rawValues = await languageProvider.getLabelValues(this.props.timeRange ?? getDefaultTimeRange(), name);
// If selector changed, clear loading state and discard result by returning early
if (selector !== buildSelector(this.state.labels)) {
this.updateLabelState(name, { loading: false });
return;
}
const values: FacettableValue[] = [];
const { metricsMetadata } = languageProvider;
for (const labelValue of rawValues) {
const value: FacettableValue = { name: labelValue };
// Adding type/help text to metrics
if (name === METRIC_LABEL && metricsMetadata) {
const meta = metricsMetadata[labelValue];
if (meta) {
value.details = `(${meta.type}) ${meta.help}`;
}
}
values.push(value);
}
this.updateLabelState(name, { values, loading: false });
} catch (error) {
console.error(error);
}
}
async fetchSeries(selector: string, lastFacetted?: string) {
const { languageProvider } = this.props;
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`);
}
try {
const possibleLabels = await languageProvider.fetchSeriesLabels(
this.props.timeRange ?? getDefaultTimeRange(),
selector,
true,
this.state.seriesLimit
);
// If selector changed, clear loading state and discard result by returning early
if (selector !== buildSelector(this.state.labels)) {
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: false });
}
return;
}
if (Object.keys(possibleLabels).length === 0) {
this.setState({ error: `Empty results, no matching label for ${selector}` });
return;
}
const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted);
this.setState({ labels, error: '' });
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: false });
}
} catch (error) {
console.error(error);
}
}
async validateSelector(selector: string) {
const { languageProvider } = this.props;
this.setState({ validationStatus: `Validating selector ${selector}`, error: '' });
const streams = await languageProvider.fetchSeries(this.props.timeRange ?? getDefaultTimeRange(), selector);
this.setState({ validationStatus: `Selector is valid (${streams.length} series found)` });
}
render() {
const { theme } = this.props;
const { labels, labelSearchTerm, metricSearchTerm, status, error, validationStatus, valueSearchTerm } = this.state;
const styles = getStyles(theme);
if (labels.length === 0) {
return (
<div className={styles.wrapper}>
<LoadingPlaceholder text="Loading labels..." />
</div>
);
}
// Filter metrics
let metrics = labels.find((label) => label.name === METRIC_LABEL);
if (metrics && metricSearchTerm) {
metrics = {
...metrics,
values: metrics.values?.filter((value) => value.selected || value.name.includes(metricSearchTerm)),
};
}
// Filter labels
let nonMetricLabels = labels.filter((label) => !label.hidden && label.name !== METRIC_LABEL);
if (labelSearchTerm) {
nonMetricLabels = nonMetricLabels.filter((label) => label.selected || label.name.includes(labelSearchTerm));
}
// Filter non-metric label values
let selectedLabels = nonMetricLabels.filter((label) => label.selected && label.values);
if (valueSearchTerm) {
selectedLabels = selectedLabels.map((label) => ({
...label,
values: label.values?.filter((value) => value.selected || value.name.includes(valueSearchTerm)),
}));
}
const selector = buildSelector(this.state.labels);
const empty = selector === EMPTY_SELECTOR;
return (
<div className={styles.wrapper}>
<Stack gap={3}>
<MetricSelector
metrics={metrics}
metricSearchTerm={metricSearchTerm}
seriesLimit={this.state.seriesLimit ?? DEFAULT_SERIES_LIMIT}
onChangeMetricSearch={this.onChangeMetricSearch}
onChangeSeriesLimit={this.onChangeSeriesLimit}
onClickMetric={this.onClickMetric}
styles={styles}
/>
<div>
<LabelSelector
nonMetricLabels={nonMetricLabels}
labelSearchTerm={labelSearchTerm}
onChangeLabelSearch={this.onChangeLabelSearch}
onClickLabel={this.onClickLabel}
styles={styles}
/>
<ValueSelector
selectedLabels={selectedLabels}
valueSearchTerm={valueSearchTerm}
onChangeValueSearch={this.onChangeValueSearch}
onClickValue={this.onClickValue}
onClickLabel={this.onClickLabel}
styles={styles}
/>
</div>
</Stack>
<SelectorActions
selector={selector}
validationStatus={validationStatus}
status={status}
error={error}
empty={empty}
onClickRunQuery={this.onClickRunQuery}
onClickRunRateQuery={this.onClickRunRateQuery}
onClickValidate={this.onClickValidate}
onClickClear={this.onClickClear}
styles={styles}
/>
</div>
);
}
}
export const PrometheusMetricsBrowser = withTheme2(UnthemedPrometheusMetricsBrowser);
@@ -0,0 +1,79 @@
import { cx } from '@emotion/css';
import { selectors } from '@grafana/e2e-selectors';
import { Button, Label, Stack } from '@grafana/ui';
interface SelectorActionsProps {
selector: string;
validationStatus: string;
status: string;
error: string;
empty: boolean;
onClickRunQuery: () => void;
onClickRunRateQuery: () => void;
onClickValidate: () => void;
onClickClear: () => void;
styles: Record<string, string>;
}
export function SelectorActions({
selector,
validationStatus,
status,
error,
empty,
onClickRunQuery,
onClickRunRateQuery,
onClickValidate,
onClickClear,
styles,
}: SelectorActionsProps) {
return (
<div className={styles.section}>
<Label>4. Resulting selector</Label>
<div aria-label="selector" className={styles.selector}>
{selector}
</div>
{validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>}
<Stack>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery}
aria-label="Use selector for query button"
disabled={empty}
onClick={onClickRunQuery}
>
Use query
</Button>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useAsRateQuery}
aria-label="Use selector as metrics button"
variant="secondary"
disabled={empty}
onClick={onClickRunRateQuery}
>
Use as rate query
</Button>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.validateSelector}
aria-label="Validate submit button"
variant="secondary"
disabled={empty}
onClick={onClickValidate}
>
Validate selector
</Button>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.clear}
aria-label="Selector clear button"
variant="secondary"
onClick={onClickClear}
>
Clear
</Button>
<div className={cx(styles.status, (status || error) && styles.statusShowing)}>
<span className={error ? styles.error : ''}>{error || status}</span>
</div>
</Stack>
</div>
);
}
@@ -0,0 +1,84 @@
import { ChangeEvent, MouseEvent } from 'react';
import { FixedSizeList } from 'react-window';
import { selectors } from '@grafana/e2e-selectors';
import { Input, Label, BrowserLabel as PromLabel } from '@grafana/ui';
import { SelectableLabel, LIST_ITEM_SIZE } from './types';
interface ValueSelectorProps {
selectedLabels: SelectableLabel[];
valueSearchTerm: string;
onChangeValueSearch: (event: ChangeEvent<HTMLInputElement>) => void;
onClickValue: (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => void;
onClickLabel: (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => void;
styles: Record<string, string>;
}
export function ValueSelector({
selectedLabels,
valueSearchTerm,
onChangeValueSearch,
onClickValue,
onClickLabel,
styles,
}: ValueSelectorProps) {
return (
<div className={styles.section}>
<Label description="Use the search field to find values across selected labels.">
3. Select (multiple) values for your labels
</Label>
<div>
<Input
onChange={onChangeValueSearch}
aria-label="Filter expression for label values"
value={valueSearchTerm}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelValuesFilter}
/>
</div>
<div className={styles.valueListArea}>
{selectedLabels.map((label) => (
<div role="list" key={label.name} aria-label={`Values for ${label.name}`} className={styles.valueListWrapper}>
<div className={styles.valueTitle}>
<PromLabel
name={label.name}
loading={label.loading}
active={label.selected}
hidden={label.hidden}
// If no facets, we want to show number of all label values
facets={label.facets || label.values?.length}
onClick={onClickLabel}
/>
</div>
<FixedSizeList
height={Math.min(200, LIST_ITEM_SIZE * (label.values?.length || 0))}
itemCount={label.values?.length || 0}
itemSize={28}
itemKey={(i) => label.values![i].name}
width={200}
className={styles.valueList}
>
{({ index, style }) => {
const value = label.values?.[index];
if (!value) {
return null;
}
return (
<div style={style}>
<PromLabel
name={label.name}
value={value?.name}
active={value?.selected}
onClick={onClickValue}
searchTerm={valueSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,441 @@
import { buildSelector, facetLabels } from './selectorBuilder';
import { METRIC_LABEL, SelectableLabel } from './types';
describe('selectorBuilder', () => {
describe('buildSelector', () => {
it('returns an empty selector for no labels', () => {
expect(buildSelector([])).toEqual('{}');
});
it('returns an empty selector for selected labels with no values', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true }];
expect(buildSelector(labels)).toEqual('{}');
});
it('returns an empty selector for one selected label with no selected values', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar' }] }];
expect(buildSelector(labels)).toEqual('{}');
});
it('returns a simple selector from a selected label with a selected value', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar', selected: true }] }];
expect(buildSelector(labels)).toEqual('{foo="bar"}');
});
it('metric selector without labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] },
];
expect(buildSelector(labels)).toEqual('foo{}');
});
it('selector with multiple metrics', () => {
const labels: SelectableLabel[] = [
{
name: '__name__',
selected: true,
values: [
{ name: 'foo', selected: true },
{ name: 'bar', selected: true },
],
},
];
expect(buildSelector(labels)).toEqual('{__name__=~"foo|bar"}');
});
it('metric selector with labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] },
{ name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('foo{bar="baz"}');
});
describe('utf8 support', () => {
it('metric selector with utf8 metric', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] },
];
expect(buildSelector(labels)).toEqual('{"utf8.metric"}');
});
it('metric selector with utf8 labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] },
{ name: 'utf8.label', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('foo{"utf8.label"="baz"}');
});
it('metric selector with utf8 labels and metrics', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] },
{ name: 'utf8.label', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('{"utf8.metric","utf8.label"="baz"}');
});
it('metric selector with utf8 metric and with utf8/non-utf8 labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] },
{ name: 'utf8.label', selected: true, values: [{ name: 'uuu', selected: true }] },
{ name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('{"utf8.metric","utf8.label"="uuu",bar="baz"}');
});
it('metric selector with non-utf8 metric with utf8/non-utf8 labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] },
{ name: 'utf8.label', selected: true, values: [{ name: 'uuu', selected: true }] },
{ name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('foo{"utf8.label"="uuu",bar="baz"}');
});
});
it('should return empty selector when no labels are selected', () => {
const labels: SelectableLabel[] = [];
expect(buildSelector(labels)).toBe('{}');
});
it('should return empty selector when no values are selected', () => {
const labels: SelectableLabel[] = [
{
name: 'job',
selected: true,
values: [
{ name: 'prometheus', selected: false },
{ name: 'node_exporter', selected: false },
],
},
];
expect(buildSelector(labels)).toBe('{}');
});
it('should build selector with a single metric', () => {
const labels: SelectableLabel[] = [
{
name: METRIC_LABEL,
values: [{ name: 'http_requests_total', selected: true }],
},
];
expect(buildSelector(labels)).toBe('http_requests_total{}');
});
it('should build selector with a single label', () => {
const labels: SelectableLabel[] = [
{
name: 'job',
selected: true,
values: [{ name: 'prometheus', selected: true }],
},
];
expect(buildSelector(labels)).toBe('{job="prometheus"}');
});
it('should build selector with a metric and a label', () => {
const labels: SelectableLabel[] = [
{
name: METRIC_LABEL,
values: [{ name: 'http_requests_total', selected: true }],
},
{
name: 'job',
selected: true,
values: [{ name: 'prometheus', selected: true }],
},
];
expect(buildSelector(labels)).toBe('http_requests_total{job="prometheus"}');
});
it('should build selector with multiple label values using regex', () => {
const labels: SelectableLabel[] = [
{
name: 'job',
selected: true,
values: [
{ name: 'prometheus', selected: true },
{ name: 'node_exporter', selected: true },
],
},
];
expect(buildSelector(labels)).toBe('{job=~"prometheus|node_exporter"}');
});
it('should build selector with multiple labels', () => {
const labels: SelectableLabel[] = [
{
name: METRIC_LABEL,
values: [{ name: 'http_requests_total', selected: true }],
},
{
name: 'job',
selected: true,
values: [{ name: 'prometheus', selected: true }],
},
{
name: 'instance',
selected: true,
values: [{ name: 'localhost:9090', selected: true }],
},
];
expect(buildSelector(labels)).toBe('http_requests_total{job="prometheus",instance="localhost:9090"}');
});
it('should build selector with a mix of single and multiple values', () => {
const labels: SelectableLabel[] = [
{
name: METRIC_LABEL,
values: [{ name: 'http_requests_total', selected: true }],
},
{
name: 'job',
selected: true,
values: [
{ name: 'prometheus', selected: true },
{ name: 'node_exporter', selected: true },
],
},
{
name: 'instance',
selected: true,
values: [{ name: 'localhost:9090', selected: true }],
},
];
expect(buildSelector(labels)).toBe(
'http_requests_total{job=~"prometheus|node_exporter",instance="localhost:9090"}'
);
});
it('should handle non-legacy metric names with quotes', () => {
const labels: SelectableLabel[] = [
{
name: METRIC_LABEL,
values: [{ name: 'metric-with-dashes', selected: true }],
},
];
expect(buildSelector(labels)).toBe('{"metric-with-dashes"}');
});
it('should handle non-legacy metric names with labels', () => {
const labels: SelectableLabel[] = [
{
name: METRIC_LABEL,
values: [{ name: 'metric-with-dashes', selected: true }],
},
{
name: 'job',
selected: true,
values: [{ name: 'prometheus', selected: true }],
},
];
expect(buildSelector(labels)).toBe('{"metric-with-dashes",job="prometheus"}');
});
it('should ignore unselected labels', () => {
const labels: SelectableLabel[] = [
{
name: METRIC_LABEL,
values: [{ name: 'http_requests_total', selected: true }],
},
{
name: 'job',
selected: true,
values: [{ name: 'prometheus', selected: true }],
},
{
name: 'instance',
selected: false,
values: [{ name: 'localhost:9090', selected: true }],
},
];
expect(buildSelector(labels)).toBe('http_requests_total{job="prometheus"}');
});
it('should ignore labels with no selected values', () => {
const labels: SelectableLabel[] = [
{
name: METRIC_LABEL,
values: [{ name: 'http_requests_total', selected: true }],
},
{
name: 'job',
selected: true,
values: [{ name: 'prometheus', selected: false }],
},
];
expect(buildSelector(labels)).toBe('http_requests_total{}');
});
it('should handle special characters in label values', () => {
const labels: SelectableLabel[] = [
{
name: 'job',
selected: true,
values: [{ name: 'value with spaces', selected: true }],
},
{
name: 'instance',
selected: true,
values: [{ name: 'host:with:colons', selected: true }],
},
];
expect(buildSelector(labels)).toBe('{job="value with spaces",instance="host:with:colons"}');
});
it('should handle empty string values', () => {
const labels: SelectableLabel[] = [
{
name: 'job',
selected: true,
values: [{ name: '', selected: true }],
},
];
expect(buildSelector(labels)).toBe('{job=""}');
});
it('should handle multiple metrics with some unselected', () => {
const labels: SelectableLabel[] = [
{
name: METRIC_LABEL,
values: [
{ name: 'metric1', selected: true },
{ name: 'metric2', selected: true },
{ name: 'metric3', selected: false },
],
},
];
expect(buildSelector(labels)).toBe('{__name__=~"metric1|metric2"}');
});
it('should handle Unicode characters in label names and values', () => {
const labels: SelectableLabel[] = [
{
name: 'région',
selected: true,
values: [{ name: 'París', selected: true }],
},
{
name: '环境',
selected: true,
values: [{ name: '测试', selected: true }],
},
];
expect(buildSelector(labels)).toBe('{"région"="París","环境"="测试"}');
});
});
describe('facetLabels()', () => {
const possibleLabels = {
cluster: ['dev'],
namespace: ['alertmanager'],
};
const labels: SelectableLabel[] = [
{ name: 'foo', selected: true, values: [{ name: 'bar' }] },
{ name: 'cluster', values: [{ name: 'dev' }, { name: 'ops' }, { name: 'prod' }] },
{ name: 'namespace', values: [{ name: 'alertmanager' }] },
];
it('returns no labels given an empty label set', () => {
expect(facetLabels([], {})).toEqual([]);
});
it('marks all labels as hidden when no labels are possible', () => {
const result = facetLabels(labels, {});
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[0].values).toBeUndefined();
});
it('keeps values as facetted when they are possible', () => {
const result = facetLabels(labels, possibleLabels);
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[0].values).toBeUndefined();
expect(result[1].hidden).toBeFalsy();
expect(result[1].values!.length).toBe(1);
expect(result[1].values![0].name).toBe('dev');
});
it('does not facet out label values that are currently being facetted', () => {
const result = facetLabels(labels, possibleLabels, 'cluster');
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[1].hidden).toBeFalsy();
// 'cluster' is being facetted, should show all 3 options even though only 1 is possible
expect(result[1].values!.length).toBe(3);
expect(result[2].values!.length).toBe(1);
});
it('should preserve loading state during facetting', () => {
const labels: SelectableLabel[] = [
{ name: 'job', selected: true, loading: true },
{ name: 'instance', selected: false },
];
const possibleLabels = {
job: ['prometheus'],
};
const result = facetLabels(labels, possibleLabels);
expect(result[0].loading).toBe(false); // Loading should be reset after facetting
});
it('should handle multiple selected values preservation', () => {
const labels: SelectableLabel[] = [
{
name: 'job',
selected: true,
values: [
{ name: 'prometheus', selected: true },
{ name: 'grafana', selected: true },
],
},
];
const possibleLabels = {
job: ['prometheus', 'grafana', 'loki'],
};
const result = facetLabels(labels, possibleLabels);
const selectedValues = result[0].values?.filter((v) => v.selected).map((v) => v.name);
expect(selectedValues).toEqual(['prometheus', 'grafana']);
});
it('should handle mixed selected/unselected values during facetting', () => {
const labels: SelectableLabel[] = [
{
name: 'job',
selected: true,
values: [
{ name: 'prometheus', selected: true },
{ name: 'grafana', selected: false },
{ name: 'loki', selected: true },
],
},
];
const possibleLabels = {
job: ['prometheus', 'grafana', 'loki', 'tempo'],
};
const result = facetLabels(labels, possibleLabels);
expect(result[0].values?.filter((v) => v.selected).map((v) => v.name)).toEqual(['prometheus', 'loki']);
expect(result[0].values?.length).toBe(4); // Should include all possible values
});
it('should handle zero facets case', () => {
const labels: SelectableLabel[] = [
{
name: 'job',
selected: true,
values: [{ name: 'prometheus', selected: true }],
},
];
const possibleLabels = {
job: [],
};
const result = facetLabels(labels, possibleLabels);
expect(result[0].facets).toBe(0);
expect(result[0].hidden).toBe(false); // Should still be visible as it's in possibleLabels
expect(result[0].values).toEqual([]); // Should have empty values array
});
});
});
@@ -0,0 +1,74 @@
import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../../language_utils';
import { isValidLegacyName, utf8Support } from '../../utf8_support';
import { FacettableValue, METRIC_LABEL, SelectableLabel } from './types';
export function buildSelector(labels: SelectableLabel[]): string {
let singleMetric = '';
const selectedLabels: string[] = [];
for (const label of labels) {
if ((label.name === METRIC_LABEL || label.selected) && label.values && label.values.length > 0) {
const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name);
if (selectedValues.length > 1) {
selectedLabels.push(
`${utf8Support(label.name)}=~"${selectedValues.map(escapeLabelValueInRegexSelector).join('|')}"`
);
} else if (selectedValues.length === 1) {
if (label.name === METRIC_LABEL) {
singleMetric = selectedValues[0];
} else {
selectedLabels.push(`${utf8Support(label.name)}="${escapeLabelValueInExactSelector(selectedValues[0])}"`);
}
}
}
}
const selectorParts: string[] = [];
const isLegacyName = singleMetric === '' || isValidLegacyName(singleMetric);
if (isLegacyName) {
selectorParts.push(singleMetric, '{');
} else {
selectorParts.push('{', `"${singleMetric}"`);
if (selectedLabels.length > 0) {
selectorParts.push(',');
}
}
selectorParts.push(selectedLabels.join(','), '}');
return selectorParts.join('');
}
export function facetLabels(
labels: SelectableLabel[],
possibleLabels: Record<string, string[]>,
lastFacetted?: string
): SelectableLabel[] {
return labels.map((label) => {
const possibleValues = possibleLabels[label.name];
if (possibleValues) {
let existingValues: FacettableValue[];
if (label.name === lastFacetted && label.values) {
// Facetting this label, show all values
existingValues = label.values;
} else {
// Keep selection in other facets
const selectedValues: Set<string> = new Set(
label.values?.filter((value) => value.selected).map((value) => value.name) || []
);
// Values for this label have not been requested yet, let's use the facetted ones as the initial values
existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) }));
}
return {
...label,
loading: false,
values: existingValues,
hidden: !possibleValues,
facets: existingValues.length,
};
}
// Label is facetted out, hide all values
return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 };
});
}
@@ -0,0 +1,78 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { stylesFactory } from '@grafana/ui';
export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
wrapper: css({
backgroundColor: theme.colors.background.secondary,
padding: theme.spacing(1),
width: '100%',
}),
list: css({
marginTop: theme.spacing(1),
display: 'flex',
flexWrap: 'wrap',
maxHeight: '200px',
overflow: 'auto',
alignContent: 'flex-start',
}),
section: css({
'& + &': {
margin: `${theme.spacing(2)} 0`,
},
position: 'relative',
}),
selector: css({
fontFamily: theme.typography.fontFamilyMonospace,
marginBottom: theme.spacing(1),
}),
status: css({
padding: theme.spacing(0.5),
color: theme.colors.text.secondary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
/* using absolute positioning because flex interferes with ellipsis */
position: 'absolute',
width: '50%',
right: 0,
textAlign: 'right',
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 100ms linear',
},
}),
statusShowing: css({
opacity: 1,
}),
error: css({
color: theme.colors.error.main,
}),
valueList: css({
marginRight: theme.spacing(1),
resize: 'horizontal',
}),
valueListWrapper: css({
borderLeft: `1px solid ${theme.colors.border.medium}`,
margin: `${theme.spacing(1)} 0`,
padding: `${theme.spacing(1)} 0 ${theme.spacing(1)} ${theme.spacing(1)}`,
}),
valueListArea: css({
display: 'flex',
flexWrap: 'wrap',
marginTop: theme.spacing(1),
}),
valueTitle: css({
marginLeft: `-${theme.spacing(0.5)}`,
marginBottom: theme.spacing(1),
}),
validationStatus: css({
padding: theme.spacing(0.5),
marginBottom: theme.spacing(1),
color: theme.colors.text.maxContrast,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
}));
@@ -0,0 +1,45 @@
import { GrafanaTheme2, TimeRange } from '@grafana/data';
import PromQlLanguageProvider from '../../language_provider';
export interface FacettableValue {
name: string;
selected?: boolean;
details?: string;
}
export interface SelectableLabel {
name: string;
selected?: boolean;
loading?: boolean;
values?: FacettableValue[];
hidden?: boolean;
facets?: number;
}
export interface BrowserProps {
languageProvider: PromQlLanguageProvider;
onChange: (selector: string) => void;
theme: GrafanaTheme2;
autoSelect?: number;
hide?: () => void;
timeRange?: TimeRange;
}
export interface BrowserState {
labels: SelectableLabel[];
labelSearchTerm: string;
metricSearchTerm: string;
status: string;
error: string;
validationStatus: string;
valueSearchTerm: string;
seriesLimit?: string;
}
export const DEFAULT_SERIES_LIMIT = '40000';
export const REMOVE_SERIES_LIMIT = 'none';
export const EMPTY_SELECTOR = '{}';
export const METRIC_LABEL = '__name__';
export const LIST_ITEM_SIZE = 25;
export const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels';
@@ -69,7 +69,9 @@ describe('usePromQueryFieldEffects', () => {
});
it('should call refreshMetrics and refreshHint on initial render', async () => {
renderHook(() => usePromQueryFieldEffects(mockRange, [], refreshMetricsMock, refreshHintMock));
renderHook(() =>
usePromQueryFieldEffects(mockLanguageProvider, mockRange, [], refreshMetricsMock, refreshHintMock)
);
expect(refreshMetricsMock).toHaveBeenCalledTimes(1);
expect(refreshHintMock).toHaveBeenCalledTimes(2);
@@ -77,7 +79,14 @@ describe('usePromQueryFieldEffects', () => {
it('should call refreshMetrics when the time range changes', async () => {
const { rerender } = renderHook(
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
(props: TestProps) =>
usePromQueryFieldEffects(
props.languageProvider,
props.range,
props.series,
refreshMetricsMock,
refreshHintMock
),
{
initialProps: {
languageProvider: mockLanguageProvider,
@@ -102,7 +111,14 @@ describe('usePromQueryFieldEffects', () => {
it('should not call refreshMetrics when the time range is the same', () => {
const { rerender } = renderHook(
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
(props: TestProps) =>
usePromQueryFieldEffects(
props.languageProvider,
props.range,
props.series,
refreshMetricsMock,
refreshHintMock
),
{
initialProps: {
languageProvider: mockLanguageProvider,
@@ -129,7 +145,14 @@ describe('usePromQueryFieldEffects', () => {
it('should call refreshHint when series changes', () => {
const mockSeries = [{ name: 'new series', fields: [], length: 0 }] as DataFrame[];
const { rerender } = renderHook(
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
(props: TestProps) =>
usePromQueryFieldEffects(
props.languageProvider,
props.range,
props.series,
refreshMetricsMock,
refreshHintMock
),
{
initialProps: {
languageProvider: mockLanguageProvider,
@@ -157,7 +180,14 @@ describe('usePromQueryFieldEffects', () => {
it('should not call refreshHint when series is the same', () => {
const series = [] as DataFrame[];
const { rerender } = renderHook(
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
(props: TestProps) =>
usePromQueryFieldEffects(
props.languageProvider,
props.range,
props.series,
refreshMetricsMock,
refreshHintMock
),
{
initialProps: {
languageProvider: mockLanguageProvider,
@@ -2,11 +2,13 @@ import { MutableRefObject, useEffect, useRef } from 'react';
import { DataFrame, DateTime, TimeRange } from '@grafana/data';
import PromQlLanguageProvider from '../language_provider';
import { roundMsToMin } from '../language_utils';
import { CancelablePromise } from './cancelable-promise';
export function usePromQueryFieldEffects(
languageProvider: PromQlLanguageProvider,
range: TimeRange | undefined,
series: DataFrame[] | undefined,
refreshMetrics: (languageProviderInitRef: MutableRefObject<CancelablePromise<unknown> | null>) => Promise<void>,
@@ -17,7 +19,9 @@ export function usePromQueryFieldEffects(
// Effect for initial load
useEffect(() => {
refreshMetrics(languageProviderInitRef);
if (languageProvider) {
refreshMetrics(languageProviderInitRef);
}
refreshHint();
return () => {
+1 -1
View File
@@ -9,7 +9,7 @@ export { PromQueryEditorByApp } from './components/PromQueryEditorByApp';
export { MonacoQueryFieldLazy } from './components/monaco-query-field/MonacoQueryFieldLazy';
export { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
export { PromCheatSheet } from './components/PromCheatSheet';
export { PrometheusMetricsBrowser } from './components/PrometheusMetricsBrowser';
export { PrometheusMetricsBrowser } from './components/metrics-browser/PrometheusMetricsBrowser';
export { PromExemplarField } from './components/PromExemplarField';
export { PromExploreExtraField } from './components/PromExploreExtraField';
export { PromQueryEditorForAlerting } from './components/PromQueryEditorForAlerting';
@@ -1,7 +1,7 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/language_provider.test.ts
import { AbstractLabelOperator, dateTime, TimeRange } from '@grafana/data';
import { DEFAULT_SERIES_LIMIT } from './components/PrometheusMetricsBrowser';
import { DEFAULT_SERIES_LIMIT } from './components/metrics-browser/types';
import { Label } from './components/monaco-query-field/monaco-completion-provider/situation';
import { PrometheusDatasource } from './datasource';
import LanguageProvider, { removeQuotesIfExist } from './language_provider';
@@ -16,7 +16,7 @@ import {
} from '@grafana/data';
import { BackendSrvRequest } from '@grafana/runtime';
import { DEFAULT_SERIES_LIMIT, REMOVE_SERIES_LIMIT } from './components/PrometheusMetricsBrowser';
import { DEFAULT_SERIES_LIMIT, REMOVE_SERIES_LIMIT } from './components/metrics-browser/types';
import { Label } from './components/monaco-query-field/monaco-completion-provider/situation';
import { PrometheusDatasource } from './datasource';
import {