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