grafana/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts

405 lines
15 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { useDebounce } from 'react-use';
import { TimeRange } from '@grafana/data';
import { PrometheusLanguageProviderInterface } from '../../language_provider';
import { buildSelector } from './selectorBuilder';
import { DEFAULT_SERIES_LIMIT, EMPTY_SELECTOR, LAST_USED_LABELS_KEY, Metric, METRIC_LABEL } from './types';
export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: PrometheusLanguageProviderInterface) => {
const timeRangeRef = useRef<TimeRange>(timeRange);
const lastSeriesLimitRef = useRef(DEFAULT_SERIES_LIMIT);
const isInitializedRef = useRef(false);
const [seriesLimit, setSeriesLimit] = useState(DEFAULT_SERIES_LIMIT);
const [err, setErr] = useState('');
const [status, setStatus] = useState('Ready');
const [validationStatus, setValidationStatus] = useState('');
const [metrics, setMetrics] = useState<Metric[]>([]);
const [selectedMetric, setSelectedMetric] = useState('');
const [labelKeys, setLabelKeys] = useState<string[]>([]);
const [selectedLabelKeys, setSelectedLabelKeys] = useState<string[]>([]);
const [lastSelectedLabelKey, setLastSelectedLabelKey] = useState('');
const [labelValues, setLabelValues] = useState<Record<string, string[]>>({});
const [selectedLabelValues, setSelectedLabelValues] = useState<Record<string, string[]>>({});
// Memoize the effective series limit to use the default when seriesLimit is empty
const effectiveLimit = useMemo(() => seriesLimit || DEFAULT_SERIES_LIMIT, [seriesLimit]);
// We don't want to trigger fetching for small amount of time changes.
// When MetricsBrowser re-renders for any reason we might receive a new timerange.
// This particularly happens when we have relative time ranges: from: now, to: now-1h
useEffect(() => {
if (
timeRange.to.diff(timeRangeRef.current.to, 'second') >= 5 &&
timeRange.from.diff(timeRangeRef.current.from, 'second') >= 5
) {
timeRangeRef.current = timeRange;
}
}, [timeRange]);
//Handler for error processing - logs the error and updates UI state
const handleError = useCallback((e: unknown, msg: string) => {
if (e instanceof Error) {
setErr(`${msg}: ${e.message}`);
} else {
setErr(`${msg}: Unknown error`);
}
setStatus('');
}, []);
// Get metadata details for a metric if available
const getMetricDetails = useCallback(
(metricName: string) => {
const meta = languageProvider.metricsMetadata;
return meta && meta[metricName] ? `(${meta[metricName].type}) ${meta[metricName].help}` : undefined;
},
[languageProvider.metricsMetadata]
);
// Builds a safe selector string from metric name and label values
// Prometheus API doesn't allow empty matchers. This is bad => match[]={}
// Converts EMPTY_SELECTOR to undefined as some API calls need that
const buildSafeSelector = useCallback((metric: string, labelValues: Record<string, string[]>) => {
const selector = buildSelector(metric, labelValues);
return selector === EMPTY_SELECTOR ? undefined : selector;
}, []);
// Loads label keys from localStorage and filters them against available labels
// This ensures we only show label keys that are actually available in the current context
const loadSelectedLabelsFromStorage = useCallback(
(availableLabelKeys: string[]) => {
try {
const labelKeysInLocalStorageAsString = localStorage.getItem(LAST_USED_LABELS_KEY) || '[]';
const labelKeysInLocalStorage = JSON.parse(labelKeysInLocalStorageAsString);
return labelKeysInLocalStorage.filter((slk: string) => availableLabelKeys.includes(slk));
} catch (e) {
handleError(e, 'Failed to load saved label keys');
return [];
}
},
[handleError]
);
// Fetches metrics that match the given selector
// Transforms raw metric strings into Metric objects with metadata
const fetchMetrics = useCallback(
async (safeSelector?: string) => {
try {
const fetchedMetrics = await languageProvider.fetchSeriesValuesWithMatch(
timeRangeRef.current,
METRIC_LABEL,
safeSelector,
'MetricsBrowser_M',
effectiveLimit
);
return fetchedMetrics.map((m) => ({
name: m,
details: getMetricDetails(m),
}));
} catch (e) {
handleError(e, 'Error fetching metrics');
return [];
}
},
[getMetricDetails, handleError, languageProvider, effectiveLimit]
);
// Fetches label keys based on an optional selector
// Uses different APIs depending on whether a selector is provided
const fetchLabelKeys = useCallback(
async (safeSelector?: string) => {
try {
if (safeSelector) {
return Object.keys(
await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, safeSelector, effectiveLimit)
);
} else {
return (await languageProvider.fetchLabels(timeRangeRef.current, undefined, effectiveLimit)) || [];
}
} catch (e) {
handleError(e, 'Error fetching labels');
return [];
}
},
[handleError, languageProvider, effectiveLimit]
);
// Fetches values for multiple label keys and also prepares selected values
const fetchLabelValues = useCallback(
async (labelKeys: string[], safeSelector?: string) => {
const transformedLabelValues: Record<string, string[]> = {};
const newSelectedLabelValues: Record<string, string[]> = {};
for (const lk of labelKeys) {
try {
const values = await languageProvider.fetchSeriesValuesWithMatch(
timeRangeRef.current,
lk,
safeSelector,
`MetricsBrowser_LV_${lk}`,
effectiveLimit
);
transformedLabelValues[lk] = values;
if (selectedLabelValues[lk]) {
newSelectedLabelValues[lk] = [...selectedLabelValues[lk]];
}
} catch (e) {
handleError(e, 'Error fetching label values');
}
}
return [transformedLabelValues, newSelectedLabelValues];
},
[handleError, languageProvider, selectedLabelValues, effectiveLimit]
);
// Initial set up of the Metrics Browser
// This is called when "Clear" button clicked.
const initialize = useCallback(
async (metric: string, labelValues: Record<string, string[]>) => {
const selector = buildSelector(metric, labelValues);
const safeSelector = selector === EMPTY_SELECTOR ? undefined : selector;
// Metrics
const transformedMetrics: Metric[] = await fetchMetrics(safeSelector);
// Labels
const transformedLabelKeys: string[] = await fetchLabelKeys(safeSelector);
// Selected Labels
const labelKeysInLocalStorage: string[] = loadSelectedLabelsFromStorage(transformedLabelKeys);
// Selected Labels' Values
const [transformedLabelValues] = await fetchLabelValues(labelKeysInLocalStorage, safeSelector);
setMetrics(transformedMetrics);
setLabelKeys(transformedLabelKeys);
setSelectedLabelKeys(labelKeysInLocalStorage);
setLabelValues(transformedLabelValues);
},
[fetchLabelKeys, fetchLabelValues, fetchMetrics, loadSelectedLabelsFromStorage]
);
// Initialize the hook
useEffect(() => {
initialize(selectedMetric, selectedLabelValues);
isInitializedRef.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// We use debounce here to prevent fetching data on every keystroke
// We also track the seriesLimit change to prevent fetching twice right after the initialization
useDebounce(
() => {
if (isInitializedRef.current && lastSeriesLimitRef.current !== seriesLimit) {
initialize(selectedMetric, selectedLabelValues);
lastSeriesLimitRef.current = seriesLimit;
}
},
300,
[seriesLimit]
);
// Handles metric selection changes.
// If a metric selected it fetches the labels of that metric
// Otherwise it fetches all the labels.
// Based on the fetched labels, label value list is updated.
// If a label key is not present, its values are removed from the list.
const handleSelectedMetricChange = async (metricName: string) => {
const newSelectedMetric = selectedMetric !== metricName ? metricName : '';
const selector = buildSafeSelector(newSelectedMetric, selectedLabelValues);
try {
const fetchedMetrics = await fetchMetrics(selector);
const fetchedLabelKeys = await fetchLabelKeys(selector);
const newSelectedLabelKeys = selectedLabelKeys.filter((slk) => fetchedLabelKeys.includes(slk));
const [transformedLabelValues, newSelectedLabelValues] = await fetchLabelValues(
newSelectedLabelKeys,
newSelectedMetric === '' ? undefined : selector
);
setMetrics(fetchedMetrics);
setSelectedMetric(newSelectedMetric);
setLabelKeys(fetchedLabelKeys);
setSelectedLabelKeys(newSelectedLabelKeys);
setLabelValues(transformedLabelValues);
setSelectedLabelValues(newSelectedLabelValues);
} catch (e: unknown) {
handleError(e, 'Error fetching labels');
}
};
// Handles when a label key selection changed
// If it's a selection, it fetches the values based on the up-to-date selector
// If it's a de-selection, it clears the values from the list
const handleSelectedLabelKeyChange = async (labelKey: string) => {
const newSelectedLabelKeys = [...selectedLabelKeys];
const lkIdx = newSelectedLabelKeys.indexOf(labelKey);
const newLabelValues: Record<string, string[]> = { ...labelValues };
const newSelectedLabelValues: Record<string, string[]> = { ...selectedLabelValues };
if (lkIdx === -1) {
// Label key is not in the selectedLabelKeys. Let's add it.
newSelectedLabelKeys.push(labelKey);
const safeSelector = buildSafeSelector(selectedMetric, selectedLabelValues);
const [values] = await fetchLabelValues([labelKey], safeSelector);
newLabelValues[labelKey] = values[labelKey];
} else {
// Label key is in the selectedLabelKeys. Removing it and its values.
newSelectedLabelKeys.splice(lkIdx, 1);
delete newLabelValues[labelKey];
delete newSelectedLabelValues[labelKey];
}
localStorage.setItem(LAST_USED_LABELS_KEY, JSON.stringify(newSelectedLabelKeys));
setSelectedLabelKeys(newSelectedLabelKeys);
setLabelValues(newLabelValues);
setSelectedLabelValues(newSelectedLabelValues);
};
// Handle the labelValue click based on isSelected value.
// If it is false we need to remove it from selected values
// If it is true then we need to add it to selected values
// Then we first fetch the values of each selected label key using the up-to-date selector
// We merged the fetched and existing list for the list we interact.
// Because we might want to select more labels from the same list.
// For other value lists we use the intersection of fetched and selected values.
// Then we fetch the metrics based on new selector we have after value fetch
// Then we fetch the labels keys of the metrics we fetched.
const handleSelectedLabelValueChange = async (labelKey: string, labelValue: string, isSelected: boolean) => {
const newSelectedLabelValues = { ...selectedLabelValues };
let newLastSelectedLabelKey = lastSelectedLabelKey;
if (labelKey !== lastSelectedLabelKey) {
newLastSelectedLabelKey = labelKey;
}
// Label value selected
if (isSelected) {
if (!newSelectedLabelValues[labelKey]) {
newSelectedLabelValues[labelKey] = [];
}
newSelectedLabelValues[labelKey].push(labelValue);
} else {
newSelectedLabelValues[labelKey].splice(newSelectedLabelValues[labelKey].indexOf(labelValue), 1);
if (newSelectedLabelValues[labelKey].length === 0) {
delete newSelectedLabelValues[labelKey];
}
}
let safeSelector = buildSafeSelector(selectedMetric, newSelectedLabelValues);
// Fetch new values
let newLabelValues: Record<string, string[]> = {};
if (selectedLabelKeys.length !== 0) {
for (const lk of selectedLabelKeys) {
try {
const fetchedLabelValues = await languageProvider.fetchSeriesValuesWithMatch(
timeRange,
lk,
safeSelector,
`MetricsBrowser_LV_${lk}`,
effectiveLimit
);
// We don't want to discard values from last selected list.
// User might want to select more.
if (newLastSelectedLabelKey === lk) {
newLabelValues[lk] = Array.from(new Set([...labelValues[lk], ...fetchedLabelValues]));
} else {
// If there are already selected values merge them with the fetched values.
newLabelValues[lk] = fetchedLabelValues;
// Discard selected label values if they are not in response
newSelectedLabelValues[lk] = (newSelectedLabelValues[lk] ?? []).filter((item) =>
fetchedLabelValues.includes(item)
);
}
} catch (e: unknown) {
handleError(e, 'Error fetching label values');
}
}
}
// rebuild the selector based on the new selected label values
safeSelector = buildSafeSelector(selectedMetric, newSelectedLabelValues);
// Fetch metrics
const newMetrics: Metric[] = await fetchMetrics(safeSelector);
// Fetch label keys
// If there is no metric or label value selected fetch all the keys instead of creating a selector
let newLabelKeys: string[] = [];
if (!safeSelector) {
newLabelKeys = await fetchLabelKeys(undefined);
} else {
const labelKeysSelector = `{${METRIC_LABEL}=~"${newMetrics.map((m) => m.name).join('|')}"}`;
newLabelKeys = await fetchLabelKeys(labelKeysSelector);
}
const newSelectedLabelKeys: string[] = loadSelectedLabelsFromStorage(newLabelKeys);
setMetrics(newMetrics);
setLabelKeys(newLabelKeys);
setSelectedLabelKeys(newSelectedLabelKeys);
setLastSelectedLabelKey(newLastSelectedLabelKey);
setLabelValues(newLabelValues);
setSelectedLabelValues(newSelectedLabelValues);
};
// Validating if the selections we have can create a valid query
const handleValidation = async () => {
const selector = buildSelector(selectedMetric, selectedLabelValues);
setValidationStatus(`Validating selector ${selector}`);
setErr('');
try {
const results = await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, selector, effectiveLimit);
setValidationStatus(`Selector is valid (${Object.keys(results).length} labels found)`);
} catch (e) {
handleError(e, 'Validation failed');
setValidationStatus('');
}
};
// Clears all the selections even the ones in localStorage
const handleClear = () => {
localStorage.setItem(LAST_USED_LABELS_KEY, '[]');
setSelectedMetric('');
setSelectedLabelKeys([]);
setSelectedLabelValues({});
setErr('');
setStatus('Ready');
setValidationStatus('');
initialize('', {});
};
return {
err,
setErr,
status,
setStatus,
seriesLimit,
setSeriesLimit,
validationStatus,
metrics,
labelKeys,
labelValues,
selectedMetric,
selectedLabelKeys,
selectedLabelValues,
handleSelectedMetricChange,
handleSelectedLabelKeyChange,
handleSelectedLabelValueChange,
handleValidation,
handleClear,
// Helper functions - not part of the public API
buildSafeSelector,
loadSelectedLabelsFromStorage,
fetchMetrics,
fetchLabelKeys,
fetchLabelValues,
};
};