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:
@@ -11,8 +11,8 @@ import { PrometheusDatasource } from '../datasource';
|
|||||||
import { getInitHints } from '../query_hints';
|
import { getInitHints } from '../query_hints';
|
||||||
import { PromOptions, PromQuery } from '../types';
|
import { PromOptions, PromQuery } from '../types';
|
||||||
|
|
||||||
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
|
|
||||||
import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise';
|
import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise';
|
||||||
|
import { PrometheusMetricsBrowser } from './metrics-browser/PrometheusMetricsBrowser';
|
||||||
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper';
|
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper';
|
||||||
import { useMetricsState } from './useMetricsState';
|
import { useMetricsState } from './useMetricsState';
|
||||||
import { usePromQueryFieldEffects } from './usePromQueryFieldEffects';
|
import { usePromQueryFieldEffects } from './usePromQueryFieldEffects';
|
||||||
@@ -136,7 +136,7 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Use our custom effects hook
|
// 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);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-136
@@ -4,143 +4,10 @@ import userEvent from '@testing-library/user-event';
|
|||||||
|
|
||||||
import { createTheme, getDefaultTimeRange, TimeRange } from '@grafana/data';
|
import { createTheme, getDefaultTimeRange, TimeRange } from '@grafana/data';
|
||||||
|
|
||||||
import PromQlLanguageProvider from '../language_provider';
|
import PromQlLanguageProvider from '../../language_provider';
|
||||||
|
|
||||||
import {
|
import { UnthemedPrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
|
||||||
BrowserProps,
|
import { BrowserProps } from './types';
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PrometheusMetricsBrowser', () => {
|
describe('PrometheusMetricsBrowser', () => {
|
||||||
const setupProps = (): BrowserProps => {
|
const setupProps = (): BrowserProps => {
|
||||||
+373
@@ -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 () => {
|
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(refreshMetricsMock).toHaveBeenCalledTimes(1);
|
||||||
expect(refreshHintMock).toHaveBeenCalledTimes(2);
|
expect(refreshHintMock).toHaveBeenCalledTimes(2);
|
||||||
@@ -77,7 +79,14 @@ describe('usePromQueryFieldEffects', () => {
|
|||||||
|
|
||||||
it('should call refreshMetrics when the time range changes', async () => {
|
it('should call refreshMetrics when the time range changes', async () => {
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
|
(props: TestProps) =>
|
||||||
|
usePromQueryFieldEffects(
|
||||||
|
props.languageProvider,
|
||||||
|
props.range,
|
||||||
|
props.series,
|
||||||
|
refreshMetricsMock,
|
||||||
|
refreshHintMock
|
||||||
|
),
|
||||||
{
|
{
|
||||||
initialProps: {
|
initialProps: {
|
||||||
languageProvider: mockLanguageProvider,
|
languageProvider: mockLanguageProvider,
|
||||||
@@ -102,7 +111,14 @@ describe('usePromQueryFieldEffects', () => {
|
|||||||
|
|
||||||
it('should not call refreshMetrics when the time range is the same', () => {
|
it('should not call refreshMetrics when the time range is the same', () => {
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
|
(props: TestProps) =>
|
||||||
|
usePromQueryFieldEffects(
|
||||||
|
props.languageProvider,
|
||||||
|
props.range,
|
||||||
|
props.series,
|
||||||
|
refreshMetricsMock,
|
||||||
|
refreshHintMock
|
||||||
|
),
|
||||||
{
|
{
|
||||||
initialProps: {
|
initialProps: {
|
||||||
languageProvider: mockLanguageProvider,
|
languageProvider: mockLanguageProvider,
|
||||||
@@ -129,7 +145,14 @@ describe('usePromQueryFieldEffects', () => {
|
|||||||
it('should call refreshHint when series changes', () => {
|
it('should call refreshHint when series changes', () => {
|
||||||
const mockSeries = [{ name: 'new series', fields: [], length: 0 }] as DataFrame[];
|
const mockSeries = [{ name: 'new series', fields: [], length: 0 }] as DataFrame[];
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
|
(props: TestProps) =>
|
||||||
|
usePromQueryFieldEffects(
|
||||||
|
props.languageProvider,
|
||||||
|
props.range,
|
||||||
|
props.series,
|
||||||
|
refreshMetricsMock,
|
||||||
|
refreshHintMock
|
||||||
|
),
|
||||||
{
|
{
|
||||||
initialProps: {
|
initialProps: {
|
||||||
languageProvider: mockLanguageProvider,
|
languageProvider: mockLanguageProvider,
|
||||||
@@ -157,7 +180,14 @@ describe('usePromQueryFieldEffects', () => {
|
|||||||
it('should not call refreshHint when series is the same', () => {
|
it('should not call refreshHint when series is the same', () => {
|
||||||
const series = [] as DataFrame[];
|
const series = [] as DataFrame[];
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
|
(props: TestProps) =>
|
||||||
|
usePromQueryFieldEffects(
|
||||||
|
props.languageProvider,
|
||||||
|
props.range,
|
||||||
|
props.series,
|
||||||
|
refreshMetricsMock,
|
||||||
|
refreshHintMock
|
||||||
|
),
|
||||||
{
|
{
|
||||||
initialProps: {
|
initialProps: {
|
||||||
languageProvider: mockLanguageProvider,
|
languageProvider: mockLanguageProvider,
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { MutableRefObject, useEffect, useRef } from 'react';
|
|||||||
|
|
||||||
import { DataFrame, DateTime, TimeRange } from '@grafana/data';
|
import { DataFrame, DateTime, TimeRange } from '@grafana/data';
|
||||||
|
|
||||||
|
import PromQlLanguageProvider from '../language_provider';
|
||||||
import { roundMsToMin } from '../language_utils';
|
import { roundMsToMin } from '../language_utils';
|
||||||
|
|
||||||
import { CancelablePromise } from './cancelable-promise';
|
import { CancelablePromise } from './cancelable-promise';
|
||||||
|
|
||||||
export function usePromQueryFieldEffects(
|
export function usePromQueryFieldEffects(
|
||||||
|
languageProvider: PromQlLanguageProvider,
|
||||||
range: TimeRange | undefined,
|
range: TimeRange | undefined,
|
||||||
series: DataFrame[] | undefined,
|
series: DataFrame[] | undefined,
|
||||||
refreshMetrics: (languageProviderInitRef: MutableRefObject<CancelablePromise<unknown> | null>) => Promise<void>,
|
refreshMetrics: (languageProviderInitRef: MutableRefObject<CancelablePromise<unknown> | null>) => Promise<void>,
|
||||||
@@ -17,7 +19,9 @@ export function usePromQueryFieldEffects(
|
|||||||
|
|
||||||
// Effect for initial load
|
// Effect for initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshMetrics(languageProviderInitRef);
|
if (languageProvider) {
|
||||||
|
refreshMetrics(languageProviderInitRef);
|
||||||
|
}
|
||||||
refreshHint();
|
refreshHint();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export { PromQueryEditorByApp } from './components/PromQueryEditorByApp';
|
|||||||
export { MonacoQueryFieldLazy } from './components/monaco-query-field/MonacoQueryFieldLazy';
|
export { MonacoQueryFieldLazy } from './components/monaco-query-field/MonacoQueryFieldLazy';
|
||||||
export { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
|
export { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
|
||||||
export { PromCheatSheet } from './components/PromCheatSheet';
|
export { PromCheatSheet } from './components/PromCheatSheet';
|
||||||
export { PrometheusMetricsBrowser } from './components/PrometheusMetricsBrowser';
|
export { PrometheusMetricsBrowser } from './components/metrics-browser/PrometheusMetricsBrowser';
|
||||||
export { PromExemplarField } from './components/PromExemplarField';
|
export { PromExemplarField } from './components/PromExemplarField';
|
||||||
export { PromExploreExtraField } from './components/PromExploreExtraField';
|
export { PromExploreExtraField } from './components/PromExploreExtraField';
|
||||||
export { PromQueryEditorForAlerting } from './components/PromQueryEditorForAlerting';
|
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
|
// 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 { 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 { Label } from './components/monaco-query-field/monaco-completion-provider/situation';
|
||||||
import { PrometheusDatasource } from './datasource';
|
import { PrometheusDatasource } from './datasource';
|
||||||
import LanguageProvider, { removeQuotesIfExist } from './language_provider';
|
import LanguageProvider, { removeQuotesIfExist } from './language_provider';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { BackendSrvRequest } from '@grafana/runtime';
|
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 { Label } from './components/monaco-query-field/monaco-completion-provider/situation';
|
||||||
import { PrometheusDatasource } from './datasource';
|
import { PrometheusDatasource } from './datasource';
|
||||||
import {
|
import {
|
||||||
|
|||||||
Reference in New Issue
Block a user