Prometheus: Improve indication of labels loading when using metrics browser. (#111127)

* add loading spinner for labels and values selectors for prom

* add small margin bottom to hints to avoid component overlap when zoomed in
This commit is contained in:
jcolladokuri
2025-09-16 07:52:18 -07:00
committed by GitHub
parent be61b37682
commit fad8891b1a
6 changed files with 124 additions and 82 deletions
@@ -2,17 +2,18 @@ import { useMemo, useState } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { BrowserLabel as PromLabel, Input, Label, useStyles2 } from '@grafana/ui';
import { BrowserLabel as PromLabel, Input, Label, useStyles2, Spinner } from '@grafana/ui';
import { METRIC_LABEL } from '../../constants';
import { useMetricsBrowser } from './MetricsBrowserContext';
import { getStylesLabelSelector } from './styles';
import { getStylesLabelSelector, getStylesMetricsBrowser } from './styles';
export function LabelSelector() {
const styles = useStyles2(getStylesLabelSelector);
const sharedStyles = useStyles2(getStylesMetricsBrowser);
const [labelSearchTerm, setLabelSearchTerm] = useState('');
const { labelKeys, selectedLabelKeys, onLabelKeyClick } = useMetricsBrowser();
const { labelKeys, isLoadingLabelKeys, selectedLabelKeys, onLabelKeyClick } = useMetricsBrowser();
const filteredLabelKeys = useMemo(() => {
return labelKeys.filter(
@@ -44,24 +45,29 @@ export function LabelSelector() {
/>
</div>
{/* Using fixed height here to prevent jumpy layout */}
<div className={styles.list} style={{ height: 120 }}>
{filteredLabelKeys.map((label) => (
<PromLabel
key={label}
name={label}
loading={false}
active={selectedLabelKeys.includes(label)}
hidden={false}
facets={undefined}
onClick={(name: string) => {
// Resetting search to prevent empty results
setLabelSearchTerm('');
onLabelKeyClick(name);
}}
searchTerm={labelSearchTerm}
/>
))}
</div>
{isLoadingLabelKeys ? (
<div className={sharedStyles.spinner}>
<Spinner size="xl" />
</div>
) : (
<div className={styles.list} style={{ height: 120 }}>
{filteredLabelKeys.map((label) => (
<PromLabel
key={label}
name={label}
active={selectedLabelKeys.includes(label)}
hidden={false}
facets={undefined}
onClick={(name: string) => {
// Resetting search to prevent empty results
setLabelSearchTerm('');
onLabelKeyClick(name);
}}
searchTerm={labelSearchTerm}
/>
))}
</div>
)}
</div>
);
}
@@ -33,6 +33,8 @@ interface MetricsBrowserContextType {
// Data and selection state
metrics: Metric[];
labelKeys: string[];
isLoadingLabelKeys: boolean;
isLoadingLabelValues: boolean;
labelValues: Record<string, string[]>;
selectedMetric: string;
selectedLabelKeys: string[];
@@ -78,6 +80,8 @@ export function MetricsBrowserProvider({
validationStatus,
metrics,
labelKeys,
isLoadingLabelKeys,
isLoadingLabelValues,
labelValues,
selectedMetric,
selectedLabelKeys,
@@ -109,6 +113,8 @@ export function MetricsBrowserProvider({
getSelector,
metrics,
labelKeys,
isLoadingLabelKeys,
isLoadingLabelValues,
labelValues,
selectedMetric,
selectedLabelKeys,
@@ -128,16 +134,18 @@ export function MetricsBrowserProvider({
setSeriesLimit,
validationStatus,
onChange,
metrics,
getSelector,
metrics,
labelKeys,
isLoadingLabelKeys,
isLoadingLabelValues,
labelValues,
selectedMetric,
selectedLabelKeys,
selectedLabelValues,
handleSelectedMetricChange,
handleSelectedLabelKeyChange,
handleSelectedLabelValueChange,
handleSelectedMetricChange,
handleValidation,
handleClear,
]
@@ -3,17 +3,20 @@ import { FixedSizeList } from 'react-window';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { BrowserLabel as PromLabel, Input, Label, useStyles2 } from '@grafana/ui';
import { BrowserLabel as PromLabel, Input, Label, useStyles2, Spinner } from '@grafana/ui';
import { LIST_ITEM_SIZE } from '../../constants';
import { useMetricsBrowser } from './MetricsBrowserContext';
import { getStylesValueSelector } from './styles';
import { getStylesMetricsBrowser, getStylesValueSelector } from './styles';
export function ValueSelector() {
const styles = useStyles2(getStylesValueSelector);
const sharedStyles = useStyles2(getStylesMetricsBrowser);
const [valueSearchTerm, setValueSearchTerm] = useState('');
const { labelValues, selectedLabelValues, onLabelValueClick, onLabelKeyClick } = useMetricsBrowser();
const { labelValues, selectedLabelValues, isLoadingLabelValues, onLabelValueClick, onLabelKeyClick } =
useMetricsBrowser();
return (
<div className={styles.section}>
@@ -38,63 +41,62 @@ export function ValueSelector() {
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelValuesFilter}
/>
</div>
<div className={styles.valueListArea}>
{Object.entries(labelValues).map(([lk, lv]) => {
if (!lk || !lv) {
console.error('label values are empty:', { lk, lv });
return null;
}
return (
<div
role="list"
key={lk}
aria-label={t(
'grafana-prometheus.components.value-selector.aria-label-values-for',
'Values for {{labelKey}}',
{
labelKey: lk,
}
)}
className={styles.valueListWrapper}
>
<div className={styles.valueTitle}>
<PromLabel
name={lk}
loading={false}
active={true}
hidden={false}
facets={lv.length}
onClick={onLabelKeyClick}
/>
</div>
<FixedSizeList
height={Math.min(200, LIST_ITEM_SIZE * (lv.length || 0))}
itemCount={lv.length || 0}
itemSize={28}
itemKey={(i) => lv[i]}
width={200}
className={styles.valueList}
{isLoadingLabelValues ? (
<div className={sharedStyles.spinner}>
<Spinner size="xl" />
</div>
) : (
<div className={styles.valueListArea}>
{Object.entries(labelValues).map(([lk, lv]) => {
if (!lk || !lv) {
console.error('label values are empty:', { lk, lv });
return null;
}
return (
<div
role="list"
key={lk}
aria-label={t(
'grafana-prometheus.components.value-selector.aria-label-values-for',
'Values for {{labelKey}}',
{
labelKey: lk,
}
)}
className={styles.valueListWrapper}
>
{({ index, style }) => {
const value = lv[index];
const isSelected = selectedLabelValues[lk]?.includes(value);
return (
<div style={style}>
<PromLabel
name={value}
value={value}
active={isSelected}
onClick={(name) => onLabelValueClick(lk, name, !isSelected)}
searchTerm={valueSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
);
})}
</div>
<div className={styles.valueTitle}>
<PromLabel name={lk} active={true} hidden={false} facets={lv.length} onClick={onLabelKeyClick} />
</div>
<FixedSizeList
height={Math.min(200, LIST_ITEM_SIZE * (lv.length || 0))}
itemCount={lv.length || 0}
itemSize={28}
itemKey={(i) => lv[i]}
width={200}
className={styles.valueList}
>
{({ index, style }) => {
const value = lv[index];
const isSelected = selectedLabelValues[lk]?.includes(value);
return (
<div style={style}>
<PromLabel
name={value}
value={value}
active={isSelected}
onClick={(name) => onLabelValueClick(lk, name, !isSelected)}
searchTerm={valueSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -8,6 +8,12 @@ export const getStylesMetricsBrowser = (theme: GrafanaTheme2) => ({
padding: theme.spacing(1),
width: '100%',
}),
spinner: css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 120,
}),
});
export const getStylesMetricSelector = (theme: GrafanaTheme2) => ({
@@ -26,6 +26,8 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
const [lastSelectedLabelKey, setLastSelectedLabelKey] = useState('');
const [labelValues, setLabelValues] = useState<Record<string, string[]>>({});
const [selectedLabelValues, setSelectedLabelValues] = useState<Record<string, string[]>>({});
const [isLoadingLabelKeys, setIsLoadingLabelKeys] = useState(false);
const [isLoadingLabelValues, setIsLoadingLabelValues] = useState(false);
// Memoize the effective series limit to use the default when seriesLimit is empty
const effectiveLimit = useMemo(() => seriesLimit, [seriesLimit]);
@@ -163,6 +165,8 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
const transformedMetrics: Metric[] = await fetchMetrics(safeSelector);
// Labels
setIsLoadingLabelKeys(true);
setIsLoadingLabelValues(true);
const transformedLabelKeys: string[] = await fetchLabelKeys(safeSelector);
// Selected Labels
@@ -173,8 +177,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
setMetrics(transformedMetrics);
setLabelKeys(transformedLabelKeys);
setIsLoadingLabelKeys(false);
setSelectedLabelKeys(labelKeysInLocalStorage);
setLabelValues(transformedLabelValues);
setIsLoadingLabelValues(false);
},
[fetchLabelKeys, fetchLabelValues, fetchMetrics, loadSelectedLabelsFromStorage]
);
@@ -209,8 +215,11 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
const selector = buildSafeSelector(newSelectedMetric, selectedLabelValues);
try {
const fetchedMetrics = await fetchMetrics(selector);
setIsLoadingLabelKeys(true);
const fetchedLabelKeys = await fetchLabelKeys(selector);
const newSelectedLabelKeys = selectedLabelKeys.filter((slk) => fetchedLabelKeys.includes(slk));
setIsLoadingLabelValues(true);
const [transformedLabelValues, newSelectedLabelValues] = await fetchLabelValues(
newSelectedLabelKeys,
newSelectedMetric === '' ? undefined : selector
@@ -219,8 +228,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
setMetrics(fetchedMetrics);
setSelectedMetric(newSelectedMetric);
setLabelKeys(fetchedLabelKeys);
setIsLoadingLabelKeys(false);
setSelectedLabelKeys(newSelectedLabelKeys);
setLabelValues(transformedLabelValues);
setIsLoadingLabelValues(false);
setSelectedLabelValues(newSelectedLabelValues);
} catch (e: unknown) {
handleError(e, 'Error fetching labels');
@@ -240,6 +251,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
// Label key is not in the selectedLabelKeys. Let's add it.
newSelectedLabelKeys.push(labelKey);
const safeSelector = buildSafeSelector(selectedMetric, selectedLabelValues);
setIsLoadingLabelValues(true);
const [values] = await fetchLabelValues([labelKey], safeSelector);
newLabelValues[labelKey] = values[labelKey];
} else {
@@ -252,6 +264,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
localStorage.setItem(LAST_USED_LABELS_KEY, JSON.stringify(newSelectedLabelKeys));
setSelectedLabelKeys(newSelectedLabelKeys);
setLabelValues(newLabelValues);
setIsLoadingLabelValues(false);
setSelectedLabelValues(newSelectedLabelValues);
};
@@ -289,6 +302,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
// Fetch new values
let newLabelValues: Record<string, string[]> = {};
if (selectedLabelKeys.length !== 0) {
setIsLoadingLabelValues(true);
for (const lk of selectedLabelKeys) {
try {
const fetchedLabelValues = await languageProvider.queryLabelValues(
@@ -326,6 +340,7 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
// Fetch label keys
// If there is no metric or label value selected fetch all the keys instead of creating a selector
setIsLoadingLabelKeys(true);
let newLabelKeys: string[] = [];
if (!safeSelector) {
newLabelKeys = await fetchLabelKeys(undefined);
@@ -337,9 +352,11 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
setMetrics(newMetrics);
setLabelKeys(newLabelKeys);
setIsLoadingLabelKeys(false);
setSelectedLabelKeys(newSelectedLabelKeys);
setLastSelectedLabelKey(newLastSelectedLabelKey);
setLabelValues(newLabelValues);
setIsLoadingLabelValues(false);
setSelectedLabelValues(newSelectedLabelValues);
};
@@ -384,6 +401,8 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
metrics,
labelKeys,
labelValues,
isLoadingLabelKeys,
isLoadingLabelValues,
selectedMetric,
selectedLabelKeys,
selectedLabelValues,
@@ -92,6 +92,7 @@ const getStyles = (theme: GrafanaTheme2) => {
}),
hint: css({
marginRight: theme.spacing(1),
marginBottom: theme.spacing(1),
}),
};
};