mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Prometheus: Metric encyclopedia improvements (#67084)
* move to me directory and sort by relevance * refactor letter search, uFuzzy and styles out of ency * begin state refactor * refactor getMetaData useEffect call with useReducer * refactor pagination with useReducer * refactor fuzzy state for useReducer * refactor all filters for useReducer * remove haystacks arrays in favor of haystack dictionaries w object keys * refactor out functions into state helpers * switch label filter text color to work with light theme * make each row clickable to select metric * add pagination component * fix max results * make a better table with keystrokes, navigate to metric with up&down, select on enter, hide settings, make a nice button * save space, give more real esate to the table * add highlighting for fuzzy search matches * add custom button in metric select option to open metric encyclopedia * open the modal with enter keystroke * remove unnecessary actions and variables from m.e. * fix tests, clean code * remove setting of selected idx on results row when hovering * tests * rename to metrics modal and have select option same as header * reduce width for wider screens * pass in initial metrics list and remove call to labels and series in metrics modal * use createSlice from reduc toolkit to deduce actions * save the metrics modal additional settings * galen alphabet refactor suggestion * remove extra row in results table * fix storing settings, wrap in feature toggle * remove metadata check & load because metric select already handles this * Update public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/LetterSearch.tsx Co-authored-by: Leon Sorokin <leeoniya@gmail.com> * fix styles, show cursor as pointer for select option and clickable row * taller modal for larger screens * turn off metadata settings if usebackend is selected * additional settings button space * add pipe to ufuzzy metadata search --------- Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
parent
0d52d19e21
commit
d31d1576fb
@ -1,850 +0,0 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
import debounce from 'debounce-promise';
|
||||
import { debounce as debounceLodash } from 'lodash';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { EditorField } from '@grafana/experimental';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import {
|
||||
Button,
|
||||
CellProps,
|
||||
Column,
|
||||
InlineField,
|
||||
Switch,
|
||||
Input,
|
||||
InteractiveTable,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Spinner,
|
||||
useTheme2,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import { getMetadataHelp, getMetadataType } from '../../language_provider';
|
||||
import { promQueryModeller } from '../PromQueryModeller';
|
||||
import { regexifyLabelValuesQueryString } from '../shared/parsingUtils';
|
||||
import { PromVisualQuery } from '../types';
|
||||
|
||||
import { FeedbackLink } from './FeedbackLink';
|
||||
|
||||
type Props = {
|
||||
datasource: PrometheusDatasource;
|
||||
isOpen: boolean;
|
||||
query: PromVisualQuery;
|
||||
onClose: () => void;
|
||||
onChange: (query: PromVisualQuery) => void;
|
||||
};
|
||||
|
||||
type MetricsData = MetricData[];
|
||||
|
||||
type MetricData = {
|
||||
value: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type PromFilterOption = {
|
||||
value: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const promTypes: PromFilterOption[] = [
|
||||
{
|
||||
value: 'counter',
|
||||
description:
|
||||
'A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart.',
|
||||
},
|
||||
{
|
||||
value: 'gauge',
|
||||
description: 'A metric that represents a single numerical value that can arbitrarily go up and down.',
|
||||
},
|
||||
{
|
||||
value: 'histogram',
|
||||
description:
|
||||
'A histogram samples observations (usually things like request durations or response sizes) and counts them in configurable buckets.',
|
||||
},
|
||||
{
|
||||
value: 'summary',
|
||||
description:
|
||||
'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.',
|
||||
},
|
||||
];
|
||||
|
||||
export const placeholders = {
|
||||
browse: 'Search metrics by name',
|
||||
metadataSearchSwitch: 'Search by metadata type and description in addition to name',
|
||||
type: 'Select...',
|
||||
variables: 'Select...',
|
||||
excludeNoMetadata: 'Exclude results with no metadata',
|
||||
setUseBackend: 'Use the backend to browse metrics',
|
||||
};
|
||||
|
||||
export const DEFAULT_RESULTS_PER_PAGE = 10;
|
||||
|
||||
const uf = new uFuzzy({
|
||||
intraMode: 1,
|
||||
intraIns: 1,
|
||||
intraSub: 1,
|
||||
intraTrn: 1,
|
||||
intraDel: 1,
|
||||
});
|
||||
|
||||
function fuzzySearch(haystack: string[], query: string, setter: React.Dispatch<React.SetStateAction<number[]>>) {
|
||||
const idxs = uf.filter(haystack, query);
|
||||
idxs && setter(idxs);
|
||||
}
|
||||
|
||||
const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300);
|
||||
|
||||
export const MetricEncyclopediaModal = (props: Props) => {
|
||||
const { datasource, isOpen, onClose, onChange, query } = props;
|
||||
|
||||
const [variables, setVariables] = useState<Array<SelectableValue<string>>>([]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// metric list
|
||||
const [metrics, setMetrics] = useState<MetricsData>([]);
|
||||
const [hasMetadata, setHasMetadata] = useState<boolean>(true);
|
||||
const [metaHaystack, setMetaHaystack] = useState<string[]>([]);
|
||||
const [nameHaystack, setNameHaystack] = useState<string[]>([]);
|
||||
|
||||
// pagination
|
||||
const [resultsPerPage, setResultsPerPage] = useState<number>(DEFAULT_RESULTS_PER_PAGE);
|
||||
const [pageNum, setPageNum] = useState<number>(1);
|
||||
|
||||
// filters
|
||||
const [fuzzySearchQuery, setFuzzySearchQuery] = useState<string>('');
|
||||
const [fuzzyMetaSearchResults, setFuzzyMetaSearchResults] = useState<number[]>([]);
|
||||
const [fuzzyNameSearchResults, setFuzzyNameSearchResults] = useState<number[]>([]);
|
||||
const [fullMetaSearch, setFullMetaSearch] = useState<boolean>(false);
|
||||
const [excludeNullMetadata, setExcludeNullMetadata] = useState<boolean>(false);
|
||||
const [selectedTypes, setSelectedTypes] = useState<Array<SelectableValue<string>>>([]);
|
||||
const [letterSearch, setLetterSearch] = useState<string | null>(null);
|
||||
|
||||
const [totalMetricCount, setTotalMetricCount] = useState<number>(0);
|
||||
const [filteredMetricCount, setFilteredMetricCount] = useState<number>();
|
||||
// backend search metric names by text
|
||||
const [useBackend, setUseBackend] = useState<boolean>(false);
|
||||
const [disableTextWrap, setDisableTextWrap] = useState<boolean>(false);
|
||||
|
||||
const updateMetricsMetadata = useCallback(async () => {
|
||||
// *** Loading Gif
|
||||
setIsLoading(true);
|
||||
|
||||
// Makes sure we loaded the metadata for metrics. Usually this is done in the start() method of the provider but we
|
||||
// don't use it with the visual builder and there is no need to run all the start() setup anyway.
|
||||
if (!datasource.languageProvider.metricsMetadata) {
|
||||
await datasource.languageProvider.loadMetricsMetadata();
|
||||
}
|
||||
|
||||
// Error handling for when metrics metadata returns as undefined
|
||||
// *** Will have to handle metadata filtering if this happens
|
||||
// *** only display metrics fuzzy search, filter and pagination
|
||||
if (!datasource.languageProvider.metricsMetadata) {
|
||||
setHasMetadata(false);
|
||||
datasource.languageProvider.metricsMetadata = {};
|
||||
}
|
||||
|
||||
// filter by adding the query.labels to the search?
|
||||
// *** do this in the filter???
|
||||
let metrics;
|
||||
if (query.labels.length > 0) {
|
||||
const expr = promQueryModeller.renderLabels(query.labels);
|
||||
metrics = (await datasource.languageProvider.getSeries(expr, true))['__name__'] ?? [];
|
||||
} else {
|
||||
metrics = (await datasource.languageProvider.getLabelValues('__name__')) ?? [];
|
||||
}
|
||||
|
||||
let haystackMetaData: string[] = [];
|
||||
let haystackNameData: string[] = [];
|
||||
let metricsData: MetricsData = metrics.map((m) => {
|
||||
const type = getMetadataType(m, datasource.languageProvider.metricsMetadata!);
|
||||
const description = getMetadataHelp(m, datasource.languageProvider.metricsMetadata!);
|
||||
|
||||
// string[] = name + type + description
|
||||
haystackMetaData.push(`${m} ${type} ${description}`);
|
||||
haystackNameData.push(m);
|
||||
return {
|
||||
value: m,
|
||||
type: type,
|
||||
description: description,
|
||||
};
|
||||
});
|
||||
|
||||
// setting this by the backend if useBackend is true
|
||||
setMetrics(metricsData);
|
||||
setMetaHaystack(haystackMetaData);
|
||||
setNameHaystack(haystackNameData);
|
||||
|
||||
setVariables(
|
||||
datasource.getVariables().map((v) => {
|
||||
return {
|
||||
value: v,
|
||||
label: v,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setTotalMetricCount(metricsData.length);
|
||||
setFilteredMetricCount(metricsData.length);
|
||||
setIsLoading(false);
|
||||
}, [query, datasource]);
|
||||
|
||||
useEffect(() => {
|
||||
updateMetricsMetadata();
|
||||
}, [updateMetricsMetadata]);
|
||||
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, disableTextWrap);
|
||||
|
||||
const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => {
|
||||
return {
|
||||
value: t.value,
|
||||
label: t.value,
|
||||
description: t.description,
|
||||
};
|
||||
});
|
||||
|
||||
function calculatePageList(metrics: MetricsData, resultsPerPage: number) {
|
||||
if (!metrics.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const calcResultsPerPage: number = resultsPerPage === 0 ? 1 : resultsPerPage;
|
||||
|
||||
const pages = Math.floor(filterMetrics(metrics).length / calcResultsPerPage) + 1;
|
||||
|
||||
return [...Array(pages).keys()].map((i) => i + 1);
|
||||
}
|
||||
|
||||
function sliceMetrics(metrics: MetricsData, pageNum: number, resultsPerPage: number) {
|
||||
const calcResultsPerPage: number = resultsPerPage === 0 ? 1 : resultsPerPage;
|
||||
const start: number = pageNum === 1 ? 0 : (pageNum - 1) * calcResultsPerPage;
|
||||
const end: number = start + calcResultsPerPage;
|
||||
return metrics.slice(start, end);
|
||||
}
|
||||
|
||||
function hasMetaDataFilters() {
|
||||
return selectedTypes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter
|
||||
*
|
||||
* @param metrics
|
||||
* @param skipLetterSearch used to show the alphabet letters as clickable before filtering out letters (needs to be refactored)
|
||||
* @returns
|
||||
*/
|
||||
function filterMetrics(metrics: MetricsData, skipLetterSearch?: boolean): MetricsData {
|
||||
let filteredMetrics: MetricsData = metrics;
|
||||
|
||||
if (fuzzySearchQuery) {
|
||||
filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => {
|
||||
if (useBackend) {
|
||||
// skip for backend!
|
||||
return true;
|
||||
} else if (fullMetaSearch) {
|
||||
return fuzzyMetaSearchResults.includes(idx);
|
||||
} else {
|
||||
return fuzzyNameSearchResults.includes(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (letterSearch && !skipLetterSearch) {
|
||||
filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => {
|
||||
const letters: string[] = [letterSearch, letterSearch.toLowerCase()];
|
||||
return letters.includes(m.value[0]);
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedTypes.length > 0 && !useBackend) {
|
||||
filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => {
|
||||
// Matches type
|
||||
const matchesSelectedType = selectedTypes.some((t) => t.value === m.type);
|
||||
|
||||
// missing type
|
||||
const hasNoType = !m.type;
|
||||
|
||||
return matchesSelectedType || (hasNoType && !excludeNullMetadata);
|
||||
});
|
||||
}
|
||||
|
||||
if (excludeNullMetadata) {
|
||||
filteredMetrics = filteredMetrics.filter((m: MetricData) => {
|
||||
return m.type !== undefined && m.description !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return filteredMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* The filtered and paginated metrics displayed in the modal
|
||||
* */
|
||||
function displayedMetrics(metrics: MetricsData) {
|
||||
const filteredSorted: MetricsData = filterMetrics(metrics).sort(alphabetically(true, hasMetaDataFilters()));
|
||||
|
||||
if (filteredMetricCount !== filteredSorted.length && filteredSorted.length !== 0) {
|
||||
setFilteredMetricCount(filteredSorted.length);
|
||||
}
|
||||
|
||||
const displayedMetrics: MetricsData = sliceMetrics(filteredSorted, pageNum, resultsPerPage);
|
||||
|
||||
return displayedMetrics;
|
||||
}
|
||||
/**
|
||||
* The backend debounced search
|
||||
*/
|
||||
const debouncedBackendSearch = useMemo(
|
||||
() =>
|
||||
debounce(async (metricText: string) => {
|
||||
setIsLoading(true);
|
||||
const queryString = regexifyLabelValuesQueryString(metricText);
|
||||
|
||||
const labelsParams = query.labels.map((label) => {
|
||||
return `,${label.label}="${label.value}"`;
|
||||
});
|
||||
|
||||
const params = `label_values({__name__=~".*${queryString}"${
|
||||
query.labels ? labelsParams.join() : ''
|
||||
}},__name__)`;
|
||||
|
||||
const results = datasource.metricFindQuery(params);
|
||||
|
||||
const metrics = await results.then((results) => {
|
||||
return results.map((result) => {
|
||||
return {
|
||||
value: result.text,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
setMetrics(metrics);
|
||||
setFilteredMetricCount(metrics.length);
|
||||
setIsLoading(false);
|
||||
}, datasource.getDebounceTimeInMilliseconds()),
|
||||
[datasource, query.labels]
|
||||
);
|
||||
|
||||
function letterSearchComponent() {
|
||||
const alphabetCheck: { [char: string]: number } = {
|
||||
A: 0,
|
||||
B: 0,
|
||||
C: 0,
|
||||
D: 0,
|
||||
E: 0,
|
||||
F: 0,
|
||||
G: 0,
|
||||
H: 0,
|
||||
I: 0,
|
||||
J: 0,
|
||||
K: 0,
|
||||
L: 0,
|
||||
M: 0,
|
||||
N: 0,
|
||||
O: 0,
|
||||
P: 0,
|
||||
Q: 0,
|
||||
R: 0,
|
||||
S: 0,
|
||||
T: 0,
|
||||
U: 0,
|
||||
V: 0,
|
||||
W: 0,
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Z: 0,
|
||||
};
|
||||
|
||||
filterMetrics(metrics, true).forEach((m: MetricData, idx) => {
|
||||
const metricFirstLetter = m.value[0].toUpperCase();
|
||||
|
||||
if (alphabet.includes(metricFirstLetter) && !alphabetCheck[metricFirstLetter]) {
|
||||
alphabetCheck[metricFirstLetter] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
// return the alphabet components with the correct style and behavior
|
||||
return Object.keys(alphabetCheck).map((letter: string) => {
|
||||
// const active: boolean = .some((m: MetricData) => {
|
||||
// return m.value[0] === letter || m.value[0] === letter?.toLowerCase();
|
||||
// });
|
||||
const active: boolean = alphabetCheck[letter] > 0;
|
||||
// starts with letter search
|
||||
// filter by starts with letter
|
||||
// if same letter searched null out remove letter search
|
||||
function updateLetterSearch() {
|
||||
if (letterSearch === letter) {
|
||||
setLetterSearch(null);
|
||||
} else {
|
||||
setLetterSearch(letter);
|
||||
}
|
||||
setPageNum(1);
|
||||
}
|
||||
// selected letter to filter by
|
||||
const selectedClass: string = letterSearch === letter ? styles.selAlpha : '';
|
||||
// these letters are represented in the list of metrics
|
||||
const activeClass: string = active ? styles.active : styles.gray;
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={active ? updateLetterSearch : () => {}}
|
||||
className={`${selectedClass} ${activeClass}`}
|
||||
key={letter}
|
||||
data-testid={'letter-' + letter}
|
||||
>
|
||||
{letter + ' '}
|
||||
{/* {idx !== coll.length - 1 ? '|': ''} */}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const MAXIMUM_RESULTS_PER_PAGE = 1000;
|
||||
const calculateResultsPerPage = (results: number) => {
|
||||
if (results < 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (results > MAXIMUM_RESULTS_PER_PAGE) {
|
||||
return MAXIMUM_RESULTS_PER_PAGE;
|
||||
}
|
||||
|
||||
return results ?? 10;
|
||||
};
|
||||
|
||||
const ButtonCell = ({
|
||||
row: {
|
||||
original: { value },
|
||||
},
|
||||
}: CellProps<MetricData, void>) => {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={'secondary'}
|
||||
fill={'solid'}
|
||||
aria-label="use this metric button"
|
||||
data-testid={testIds.useMetric}
|
||||
onClick={() => {
|
||||
onChange({ ...query, metric: value });
|
||||
reportInteraction('grafana_prom_metric_encycopedia_tracking', {
|
||||
metric: value,
|
||||
hasVariables: variables.length > 0,
|
||||
hasMetadata: hasMetadata,
|
||||
totalMetricCount: metrics.length,
|
||||
fuzzySearchQuery: fuzzySearchQuery,
|
||||
fullMetaSearch: fullMetaSearch,
|
||||
selectedTypes: selectedTypes,
|
||||
letterSearch: letterSearch,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Use this metric
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
function tableResults(metrics: MetricsData) {
|
||||
const tableData: MetricsData = metrics;
|
||||
|
||||
const columns: Array<Column<MetricData>> = [
|
||||
{ id: '', header: 'Select', cell: ButtonCell },
|
||||
{ id: 'value', header: 'Name' },
|
||||
{ id: 'type', header: 'Type' },
|
||||
{ id: 'description', header: 'Description' },
|
||||
];
|
||||
|
||||
return <InteractiveTable className={styles.table} columns={columns} data={tableData} getRowId={(r) => r.value} />;
|
||||
}
|
||||
|
||||
function fuzzySearchCallback(query: string, fullMetaSearchVal: boolean) {
|
||||
if (useBackend && query === '') {
|
||||
// get all metrics data if a user erases everything in the input
|
||||
updateMetricsMetadata();
|
||||
} else if (useBackend) {
|
||||
debouncedBackendSearch(query);
|
||||
} else {
|
||||
// search either the names or all metadata
|
||||
// fuzzy search go!
|
||||
|
||||
if (fullMetaSearchVal) {
|
||||
debouncedFuzzySearch(metaHaystack, query, setFuzzyMetaSearchResults);
|
||||
} else {
|
||||
debouncedFuzzySearch(nameHaystack, query, setFuzzyNameSearchResults);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
data-testid={testIds.metricModal}
|
||||
isOpen={isOpen}
|
||||
title="Browse metrics"
|
||||
onDismiss={onClose}
|
||||
aria-label="Metric Encyclopedia"
|
||||
className={styles.modal}
|
||||
>
|
||||
<div className={styles.inputWrapper}>
|
||||
<div className={cx(styles.inputItem, styles.inputItemFirst)}>
|
||||
<EditorField label="Search metrics">
|
||||
<Input
|
||||
data-testid={testIds.searchMetric}
|
||||
placeholder={placeholders.browse}
|
||||
value={fuzzySearchQuery}
|
||||
onInput={(e) => {
|
||||
const value = e.currentTarget.value ?? '';
|
||||
setFuzzySearchQuery(value);
|
||||
|
||||
fuzzySearchCallback(value, fullMetaSearch);
|
||||
|
||||
setPageNum(1);
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
<div className={styles.inputItem}>
|
||||
<EditorField label="Filter by type">
|
||||
<MultiSelect
|
||||
data-testid={testIds.selectType}
|
||||
inputId="my-select"
|
||||
options={typeOptions}
|
||||
value={selectedTypes}
|
||||
disabled={!hasMetadata || useBackend}
|
||||
placeholder={placeholders.type}
|
||||
onChange={(v) => {
|
||||
// *** Filter by type
|
||||
// *** always include metrics without metadata but label it as unknown type
|
||||
// Consider tabs select instead of actual select or multi select
|
||||
setSelectedTypes(v);
|
||||
setPageNum(1);
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
<div className={styles.inputItem}>
|
||||
<EditorField label="Select template variables">
|
||||
<Select
|
||||
inputId="my-select"
|
||||
options={variables}
|
||||
value={''}
|
||||
placeholder={placeholders.variables}
|
||||
onChange={(v) => {
|
||||
const value: string = v.value ?? '';
|
||||
onChange({ ...query, metric: value });
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.selectWrapper}>
|
||||
<EditorField label="Search Settings">
|
||||
<>
|
||||
<div className={styles.selectItem}>
|
||||
<Switch
|
||||
data-testid={testIds.searchWithMetadata}
|
||||
value={fullMetaSearch}
|
||||
disabled={useBackend || !hasMetadata}
|
||||
onChange={() => {
|
||||
const newVal = !fullMetaSearch;
|
||||
setFullMetaSearch(newVal);
|
||||
|
||||
fuzzySearchCallback(fuzzySearchQuery, newVal);
|
||||
|
||||
setPageNum(1);
|
||||
}}
|
||||
/>
|
||||
<p className={styles.selectItemLabel}>{placeholders.metadataSearchSwitch}</p>
|
||||
</div>
|
||||
<div className={styles.selectItem}>
|
||||
<Switch
|
||||
data-testid={testIds.setUseBackend}
|
||||
value={useBackend}
|
||||
onChange={() => {
|
||||
const newVal = !useBackend;
|
||||
setUseBackend(newVal);
|
||||
if (newVal === false) {
|
||||
// rebuild the metrics metadata if we turn off useBackend
|
||||
updateMetricsMetadata();
|
||||
} else {
|
||||
// check if there is text in the browse search and update
|
||||
if (fuzzySearchQuery !== '') {
|
||||
debouncedBackendSearch(fuzzySearchQuery);
|
||||
}
|
||||
// otherwise wait for user typing
|
||||
}
|
||||
|
||||
setPageNum(1);
|
||||
}}
|
||||
/>
|
||||
<p className={styles.selectItemLabel}>{placeholders.setUseBackend}</p>
|
||||
</div>
|
||||
</>
|
||||
</EditorField>
|
||||
</div>
|
||||
<h4 className={styles.resultsHeading}>Results</h4>
|
||||
<div className={styles.resultsData}>
|
||||
<div className={styles.resultsDataCount}>
|
||||
Showing {filteredMetricCount} of {totalMetricCount} total metrics.{' '}
|
||||
{isLoading && <Spinner className={styles.loadingSpinner} />}
|
||||
</div>
|
||||
{query.labels.length > 0 && (
|
||||
<p className={styles.resultsDataFiltered}>
|
||||
These metrics have been pre-filtered by labels chosen in the label filters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.alphabetRow}>
|
||||
<div>{letterSearchComponent()}</div>
|
||||
<div className={styles.alphabetRowToggles}>
|
||||
<div className={styles.selectItem}>
|
||||
<Switch value={disableTextWrap} onChange={() => setDisableTextWrap((p) => !p)} />
|
||||
<p className={styles.selectItemLabel}>Disable text wrap</p>
|
||||
</div>
|
||||
<div className={styles.selectItem}>
|
||||
<Switch
|
||||
value={excludeNullMetadata}
|
||||
disabled={useBackend || !hasMetadata}
|
||||
onChange={() => {
|
||||
setExcludeNullMetadata(!excludeNullMetadata);
|
||||
setPageNum(1);
|
||||
}}
|
||||
/>
|
||||
<p className={styles.selectItemLabel}>{placeholders.excludeNoMetadata}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.results}>{metrics && tableResults(displayedMetrics(metrics))}</div>
|
||||
|
||||
<div className={styles.pageSettingsWrapper}>
|
||||
<div className={styles.pageSettings}>
|
||||
<InlineField label="Select page" labelWidth={20} className="query-keyword">
|
||||
<Select
|
||||
data-testid={testIds.searchPage}
|
||||
options={calculatePageList(metrics, resultsPerPage).map((p) => {
|
||||
return { value: p, label: '' + p };
|
||||
})}
|
||||
value={pageNum ?? 1}
|
||||
placeholder="select page"
|
||||
width={20}
|
||||
onChange={(e) => {
|
||||
const value = e.value ?? 1;
|
||||
setPageNum(value);
|
||||
}}
|
||||
/>
|
||||
</InlineField>
|
||||
|
||||
<InlineField
|
||||
label="# results per page"
|
||||
tooltip={'The maximum results per page is ' + MAXIMUM_RESULTS_PER_PAGE}
|
||||
labelWidth={20}
|
||||
>
|
||||
<Input
|
||||
data-testid={testIds.resultsPerPage}
|
||||
value={calculateResultsPerPage(resultsPerPage)}
|
||||
placeholder="results per page"
|
||||
width={20}
|
||||
onInput={(e) => {
|
||||
const value = +e.currentTarget.value;
|
||||
|
||||
if (isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResultsPerPage(value);
|
||||
}}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
|
||||
<FeedbackLink feedbackUrl="https://forms.gle/DEMAJHoAMpe3e54CA" />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
function alphabetically(ascending: boolean, metadataFilters: boolean) {
|
||||
return function (a: MetricData, b: MetricData) {
|
||||
// equal items sort equally
|
||||
if (a.value === b.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// *** NO METADATA? SORT LAST
|
||||
// undefined metadata sort after anything else
|
||||
// if filters are on
|
||||
if (metadataFilters) {
|
||||
if (a.type === undefined) {
|
||||
return 1;
|
||||
}
|
||||
if (b.type === undefined) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, if we're ascending, lowest sorts first
|
||||
if (ascending) {
|
||||
return a.value < b.value ? -1 : 1;
|
||||
}
|
||||
|
||||
// if descending, highest sorts first
|
||||
return a.value < b.value ? 1 : -1;
|
||||
};
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
||||
return {
|
||||
modal: css`
|
||||
width: 85vw;
|
||||
${theme.breakpoints.down('md')} {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
inputWrapper: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: ${theme.spacing(2)};
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
`,
|
||||
inputItemFirst: css`
|
||||
flex-basis: 40%;
|
||||
`,
|
||||
inputItem: css`
|
||||
flex-grow: 1;
|
||||
flex-basis: 20%;
|
||||
${theme.breakpoints.down('md')} {
|
||||
min-width: 100%;
|
||||
}
|
||||
`,
|
||||
selectWrapper: css`
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
`,
|
||||
selectItem: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`,
|
||||
selectItemLabel: css`
|
||||
margin: 0 0 0 ${theme.spacing(1)};
|
||||
align-self: center;
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
resultsHeading: css`
|
||||
margin: 0 0 0 0;
|
||||
`,
|
||||
resultsData: css`
|
||||
margin: 0 0 ${theme.spacing(1)} 0;
|
||||
`,
|
||||
resultsDataCount: css`
|
||||
margin: 0;
|
||||
`,
|
||||
resultsDataFiltered: css`
|
||||
margin: 0;
|
||||
color: ${theme.colors.warning.main};
|
||||
`,
|
||||
alphabetRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
column-gap: ${theme.spacing(1)};
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
alphabetRowToggles: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: ${theme.spacing(1)};
|
||||
`,
|
||||
results: css`
|
||||
height: 300px;
|
||||
overflow-y: scroll;
|
||||
`,
|
||||
pageSettingsWrapper: css`
|
||||
padding-top: ${theme.spacing(1.5)};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`,
|
||||
pageSettings: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
`,
|
||||
selAlpha: css`
|
||||
cursor: pointer;
|
||||
color: #6e9fff;
|
||||
`,
|
||||
active: css`
|
||||
cursor: pointer;
|
||||
`,
|
||||
gray: css`
|
||||
color: grey;
|
||||
`,
|
||||
loadingSpinner: css`
|
||||
display: inline-block;
|
||||
`,
|
||||
table: css`
|
||||
white-space: ${disableTextWrap ? 'nowrap' : 'normal'};
|
||||
td {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
export const testIds = {
|
||||
metricModal: 'metric-modal',
|
||||
searchMetric: 'search-metric',
|
||||
searchWithMetadata: 'search-with-metadata',
|
||||
selectType: 'select-type',
|
||||
metricCard: 'metric-card',
|
||||
useMetric: 'use-metric',
|
||||
searchPage: 'search-page',
|
||||
resultsPerPage: 'results-per-page',
|
||||
setUseBackend: 'set-use-backend',
|
||||
};
|
||||
|
||||
const alphabet = [
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
'G',
|
||||
'H',
|
||||
'I',
|
||||
'J',
|
||||
'K',
|
||||
'L',
|
||||
'M',
|
||||
'N',
|
||||
'O',
|
||||
'P',
|
||||
'Q',
|
||||
'R',
|
||||
'S',
|
||||
'T',
|
||||
'U',
|
||||
'V',
|
||||
'W',
|
||||
'X',
|
||||
'Y',
|
||||
'Z',
|
||||
];
|
@ -5,13 +5,17 @@ import Highlighter from 'react-highlight-words';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
|
||||
import { EditorField, EditorFieldGroup } from '@grafana/experimental';
|
||||
import { AsyncSelect, FormatOptionLabelMeta, useStyles2 } from '@grafana/ui';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { AsyncSelect, Button, FormatOptionLabelMeta, useStyles2 } from '@grafana/ui';
|
||||
import { SelectMenuOptions } from '@grafana/ui/src/components/Select/SelectMenu';
|
||||
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import { regexifyLabelValuesQueryString } from '../shared/parsingUtils';
|
||||
import { QueryBuilderLabelFilter } from '../shared/types';
|
||||
import { PromVisualQuery } from '../types';
|
||||
|
||||
import { MetricsModal } from './metrics-modal/MetricsModal';
|
||||
|
||||
// We are matching words split with space
|
||||
const splitSeparator = ' ';
|
||||
|
||||
@ -26,6 +30,8 @@ export interface Props {
|
||||
|
||||
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000;
|
||||
|
||||
const prometheusMetricEncyclopedia = config.featureToggles.prometheusMetricEncyclopedia;
|
||||
|
||||
export function MetricSelect({
|
||||
datasource,
|
||||
query,
|
||||
@ -38,6 +44,8 @@ export function MetricSelect({
|
||||
const [state, setState] = useState<{
|
||||
metrics?: Array<SelectableValue<any>>;
|
||||
isLoading?: boolean;
|
||||
metricsModalOpen?: boolean;
|
||||
initialMetrics?: string[];
|
||||
}>({});
|
||||
|
||||
const customFilterOption = useCallback((option: SelectableValue<any>, searchQuery: string) => {
|
||||
@ -129,40 +137,118 @@ export function MetricSelect({
|
||||
(query: string) => getMetricLabels(query),
|
||||
datasource.getDebounceTimeInMilliseconds()
|
||||
);
|
||||
// No type found for the common select props so typing as any
|
||||
// https://github.com/grafana/grafana/blob/main/packages/grafana-ui/src/components/Select/SelectBase.tsx/#L212-L263
|
||||
// eslint-disable-next-line
|
||||
const CustomOption = (props: any) => {
|
||||
const option = props.data;
|
||||
|
||||
if (option.value === 'BrowseMetrics') {
|
||||
const isFocused = props.isFocused ? styles.focus : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props.innerProps}
|
||||
onKeyDown={(e) => {
|
||||
// if there is no metric and the m.e. is enabled, open the modal
|
||||
if (e.code === 'Enter') {
|
||||
setState({ ...state, metricsModalOpen: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{
|
||||
<div className={`${styles.customOption} ${isFocused}`}>
|
||||
<div>
|
||||
<div>{option.label}</div>
|
||||
<div className={styles.customOptionDesc}>{option.description}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
fill="outline"
|
||||
size="sm"
|
||||
onClick={() => setState({ ...state, metricsModalOpen: true })}
|
||||
icon="book"
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return SelectMenuOptions(props);
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorFieldGroup>
|
||||
<EditorField label="Metric">
|
||||
<AsyncSelect
|
||||
inputId="prometheus-metric-select"
|
||||
className={styles.select}
|
||||
value={query.metric ? toOption(query.metric) : undefined}
|
||||
placeholder={'Select metric'}
|
||||
allowCustomValue
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
filterOption={customFilterOption}
|
||||
onOpenMenu={async () => {
|
||||
if (metricLookupDisabled) {
|
||||
return;
|
||||
}
|
||||
setState({ isLoading: true });
|
||||
const metrics = await onGetMetrics();
|
||||
if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) {
|
||||
metrics.splice(0, metrics.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
|
||||
}
|
||||
setState({ metrics, isLoading: undefined });
|
||||
}}
|
||||
loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch}
|
||||
isLoading={state.isLoading}
|
||||
defaultOptions={state.metrics}
|
||||
onChange={({ value }) => {
|
||||
if (value) {
|
||||
onChange({ ...query, metric: value });
|
||||
}
|
||||
}}
|
||||
<>
|
||||
{prometheusMetricEncyclopedia && !datasource.lookupsDisabled && state.metricsModalOpen && (
|
||||
<MetricsModal
|
||||
datasource={datasource}
|
||||
isOpen={state.metricsModalOpen}
|
||||
onClose={() => setState({ ...state, metricsModalOpen: false })}
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
initialMetrics={state.initialMetrics ?? []}
|
||||
/>
|
||||
</EditorField>
|
||||
</EditorFieldGroup>
|
||||
)}
|
||||
<EditorFieldGroup>
|
||||
<EditorField label="Metric">
|
||||
<AsyncSelect
|
||||
inputId="prometheus-metric-select"
|
||||
className={styles.select}
|
||||
value={query.metric ? toOption(query.metric) : undefined}
|
||||
placeholder={'Select metric'}
|
||||
allowCustomValue
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
filterOption={customFilterOption}
|
||||
onOpenMenu={async () => {
|
||||
if (metricLookupDisabled) {
|
||||
return;
|
||||
}
|
||||
setState({ isLoading: true });
|
||||
const metrics = await onGetMetrics();
|
||||
const initialMetrics: string[] = metrics.map((m) => m.value);
|
||||
if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) {
|
||||
metrics.splice(0, metrics.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
|
||||
}
|
||||
|
||||
if (config.featureToggles.prometheusMetricEncyclopedia) {
|
||||
// pass the initial metrics, possibly filtered by labels into the Metrics Modal
|
||||
const metricsModalOption: SelectableValue[] = [
|
||||
{
|
||||
value: 'BrowseMetrics',
|
||||
label: 'Browse metrics',
|
||||
description: 'Browse and filter metrics and metadata with a fuzzy search',
|
||||
},
|
||||
];
|
||||
setState({
|
||||
metrics: [...metricsModalOption, ...metrics],
|
||||
isLoading: undefined,
|
||||
initialMetrics: initialMetrics,
|
||||
});
|
||||
} else {
|
||||
setState({ metrics, isLoading: undefined });
|
||||
}
|
||||
}}
|
||||
loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch}
|
||||
isLoading={state.isLoading}
|
||||
defaultOptions={state.metrics}
|
||||
onChange={({ value }) => {
|
||||
if (value) {
|
||||
// if there is no metric and the m.e. is enabled, open the modal
|
||||
if (prometheusMetricEncyclopedia && value === 'BrowseMetrics') {
|
||||
setState({ ...state, metricsModalOpen: true });
|
||||
} else {
|
||||
onChange({ ...query, metric: value });
|
||||
}
|
||||
}
|
||||
}}
|
||||
components={{ Option: CustomOption }}
|
||||
/>
|
||||
</EditorField>
|
||||
</EditorFieldGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -177,4 +263,24 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
color: ${theme.colors.warning.contrastText};
|
||||
background-color: ${theme.colors.warning.main};
|
||||
`,
|
||||
customOption: css`
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
:hover {
|
||||
background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)};
|
||||
}
|
||||
`,
|
||||
customOptionlabel: css`
|
||||
color: ${theme.colors.text.primary};
|
||||
`,
|
||||
customOptionDesc: css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
font-size: ${theme.typography.size.xs};
|
||||
opacity: 50%;
|
||||
`,
|
||||
focus: css`
|
||||
background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)};
|
||||
`,
|
||||
});
|
||||
|
@ -1,10 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { DataSourceApi, GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data';
|
||||
import { DataSourceApi, PanelData, SelectableValue } from '@grafana/data';
|
||||
import { EditorRow } from '@grafana/experimental';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, Tag, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import { getMetadataString } from '../../language_provider';
|
||||
@ -22,7 +19,6 @@ import { QueryBuilderLabelFilter, QueryBuilderOperation } from '../shared/types'
|
||||
import { PromVisualQuery } from '../types';
|
||||
|
||||
import { LabelFilters } from './LabelFilters';
|
||||
import { MetricEncyclopediaModal } from './MetricEncyclopediaModal';
|
||||
import { MetricSelect, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './MetricSelect';
|
||||
import { NestedQueryList } from './NestedQueryList';
|
||||
import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained';
|
||||
@ -39,12 +35,10 @@ export interface Props {
|
||||
export const PromQueryBuilder = React.memo<Props>((props) => {
|
||||
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
|
||||
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
|
||||
const [metricEncyclopediaModalOpen, setMetricEncyclopediaModalOpen] = useState(false);
|
||||
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => {
|
||||
onChange({ ...query, labels });
|
||||
};
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
/**
|
||||
* Map metric metadata to SelectableValue for Select component and also adds defined template variables to the list.
|
||||
*/
|
||||
@ -208,55 +202,20 @@ export const PromQueryBuilder = React.memo<Props>((props) => {
|
||||
}, [datasource, query, withTemplateVariableOptions]);
|
||||
|
||||
const lang = { grammar: promqlGrammar, name: 'promql' };
|
||||
const isMetricEncyclopediaEnabled = config.featureToggles.prometheusMetricEncyclopedia;
|
||||
|
||||
const initHints = datasource.getInitHints();
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRow>
|
||||
{isMetricEncyclopediaEnabled && !datasource.lookupsDisabled ? (
|
||||
<>
|
||||
<Button
|
||||
className={styles.button}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setMetricEncyclopediaModalOpen((prevValue) => !prevValue)}
|
||||
>
|
||||
Metric encyclopedia
|
||||
</Button>
|
||||
{query.metric && (
|
||||
<Tag
|
||||
name={' ' + query.metric}
|
||||
color="#3D71D9"
|
||||
icon="times"
|
||||
onClick={() => {
|
||||
onChange({ ...query, metric: '' });
|
||||
}}
|
||||
title="Click to remove metric"
|
||||
className={styles.metricTag}
|
||||
/>
|
||||
)}
|
||||
{metricEncyclopediaModalOpen && (
|
||||
<MetricEncyclopediaModal
|
||||
datasource={datasource}
|
||||
isOpen={metricEncyclopediaModalOpen}
|
||||
onClose={() => setMetricEncyclopediaModalOpen(false)}
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<MetricSelect
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
onGetMetrics={onGetMetrics}
|
||||
datasource={datasource}
|
||||
labelsFilters={query.labels}
|
||||
metricLookupDisabled={datasource.lookupsDisabled}
|
||||
/>
|
||||
)}
|
||||
<MetricSelect
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
onGetMetrics={onGetMetrics}
|
||||
datasource={datasource}
|
||||
labelsFilters={query.labels}
|
||||
metricLookupDisabled={datasource.lookupsDisabled}
|
||||
/>
|
||||
<LabelFilters
|
||||
debounceDuration={datasource.getDebounceTimeInMilliseconds()}
|
||||
getLabelValuesAutofillSuggestions={getLabelValuesAutocompleteSuggestions}
|
||||
@ -365,15 +324,3 @@ async function getMetrics(
|
||||
}
|
||||
|
||||
PromQueryBuilder.displayName = 'PromQueryBuilder';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
button: css`
|
||||
height: auto;
|
||||
`,
|
||||
metricTag: css`
|
||||
margin: '10px 0 10px 0',
|
||||
backgroundColor: '#3D71D9',
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
|
||||
import { PanelData } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import { PromQuery } from '../../types';
|
||||
@ -11,6 +12,7 @@ import { PromVisualQuery } from '../types';
|
||||
|
||||
import { PromQueryBuilder } from './PromQueryBuilder';
|
||||
import { QueryPreview } from './QueryPreview';
|
||||
import { getSettings, MetricsModalSettings } from './metrics-modal/state/state';
|
||||
|
||||
export interface Props {
|
||||
query: PromQuery;
|
||||
@ -26,22 +28,39 @@ export interface State {
|
||||
expr: string;
|
||||
}
|
||||
|
||||
const prometheusMetricEncyclopedia = config.featureToggles.prometheusMetricEncyclopedia;
|
||||
/**
|
||||
* This component is here just to contain the translation logic between string query and the visual query builder model.
|
||||
*/
|
||||
export function PromQueryBuilderContainer(props: Props) {
|
||||
const { query, onChange, onRunQuery, datasource, data, showExplain } = props;
|
||||
const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr });
|
||||
|
||||
// Only rebuild visual query if expr changes from outside
|
||||
useEffect(() => {
|
||||
dispatch(exprChanged(query.expr));
|
||||
}, [query.expr]);
|
||||
|
||||
if (prometheusMetricEncyclopedia) {
|
||||
dispatch(
|
||||
setMetricsModalSettings({
|
||||
useBackend: query.useBackend ?? false,
|
||||
disableTextWrap: query.disableTextWrap ?? false,
|
||||
fullMetaSearch: query.fullMetaSearch ?? false,
|
||||
excludeNullMetadata: query.excludeNullMetadata ?? false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const onVisQueryChange = (visQuery: PromVisualQuery) => {
|
||||
const expr = promQueryModeller.renderQuery(visQuery);
|
||||
dispatch(visualQueryChange({ visQuery, expr }));
|
||||
onChange({ ...props.query, expr: expr });
|
||||
|
||||
if (prometheusMetricEncyclopedia) {
|
||||
const metricsModalSettings = getSettings(visQuery);
|
||||
onChange({ ...props.query, expr: expr, ...metricsModalSettings });
|
||||
} else {
|
||||
onChange({ ...props.query, expr: expr });
|
||||
}
|
||||
};
|
||||
|
||||
if (!state.visQuery) {
|
||||
@ -75,10 +94,19 @@ const stateSlice = createSlice({
|
||||
if (!state.visQuery || state.expr !== action.payload) {
|
||||
state.expr = action.payload;
|
||||
const parseResult = buildVisualQueryFromString(action.payload);
|
||||
|
||||
state.visQuery = parseResult.query;
|
||||
}
|
||||
},
|
||||
setMetricsModalSettings: (state, action: PayloadAction<MetricsModalSettings>) => {
|
||||
if (state.visQuery && prometheusMetricEncyclopedia) {
|
||||
state.visQuery.useBackend = action.payload.useBackend;
|
||||
state.visQuery.disableTextWrap = action.payload.disableTextWrap;
|
||||
state.visQuery.fullMetaSearch = action.payload.fullMetaSearch;
|
||||
state.visQuery.excludeNullMetadata = action.payload.excludeNullMetadata;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { visualQueryChange, exprChanged } = stateSlice.actions;
|
||||
const { visualQueryChange, exprChanged, setMetricsModalSettings } = stateSlice.actions;
|
||||
|
@ -17,7 +17,7 @@ export function FeedbackLink({ feedbackUrl }: Props) {
|
||||
<a
|
||||
href={feedbackUrl}
|
||||
className={styles.link}
|
||||
title="The Metric Encyclopedia is new, please let us know how we can improve it"
|
||||
title="The Metrics Modal is new, please let us know how we can improve it"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { getStyles } from './styles';
|
||||
import { MetricData, MetricsData } from './types';
|
||||
|
||||
export type LetterSearchProps = {
|
||||
filteredMetrics: MetricsData;
|
||||
disableTextWrap: boolean;
|
||||
updateLetterSearch: (letter: string) => void;
|
||||
letterSearch: string | null;
|
||||
};
|
||||
|
||||
export function LetterSearch(props: LetterSearchProps) {
|
||||
const { filteredMetrics, disableTextWrap, updateLetterSearch, letterSearch } = props;
|
||||
|
||||
const alphabetDictionary = alphabetCheck();
|
||||
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, disableTextWrap);
|
||||
|
||||
filteredMetrics.forEach((m: MetricData, idx: number) => {
|
||||
const metricFirstLetter = m.value[0].toUpperCase();
|
||||
|
||||
if (alphabet.includes(metricFirstLetter) && !alphabetDictionary[metricFirstLetter]) {
|
||||
alphabetDictionary[metricFirstLetter] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
// return the alphabet components with the correct style and behavior
|
||||
return (
|
||||
<div>
|
||||
{Object.keys(alphabetDictionary).map((letter: string) => {
|
||||
const active: boolean = alphabetDictionary[letter] > 0;
|
||||
// starts with letter search
|
||||
// filter by starts with letter
|
||||
// if same letter searched null out remove letter search
|
||||
function setLetterSearch() {
|
||||
updateLetterSearch(letter);
|
||||
}
|
||||
// selected letter to filter by
|
||||
const selectedClass: string = letterSearch === letter ? styles.selAlpha : '';
|
||||
// these letters are represented in the list of metrics
|
||||
const activeClass: string = active ? styles.active : styles.gray;
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={active ? setLetterSearch : () => {}}
|
||||
className={`${selectedClass} ${activeClass}`}
|
||||
key={letter}
|
||||
data-testid={'letter-' + letter}
|
||||
>
|
||||
{letter + ' '}
|
||||
{/* {idx !== coll.length - 1 ? '|': ''} */}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const alphabet = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
|
||||
|
||||
function alphabetCheck(): { [char: string]: number } {
|
||||
const check: { [char: string]: number } = {};
|
||||
|
||||
alphabet.forEach((char) => (check[char] = 0));
|
||||
|
||||
return check;
|
||||
}
|
@ -4,13 +4,13 @@ import React from 'react';
|
||||
|
||||
import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data';
|
||||
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import PromQlLanguageProvider from '../../language_provider';
|
||||
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
|
||||
import { PromOptions } from '../../types';
|
||||
import { PromVisualQuery } from '../types';
|
||||
import { PrometheusDatasource } from '../../../datasource';
|
||||
import PromQlLanguageProvider from '../../../language_provider';
|
||||
import { EmptyLanguageProviderMock } from '../../../language_provider.mock';
|
||||
import { PromOptions } from '../../../types';
|
||||
import { PromVisualQuery } from '../../types';
|
||||
|
||||
import { MetricEncyclopediaModal, testIds } from './MetricEncyclopediaModal';
|
||||
import { MetricsModal, testIds } from './MetricsModal';
|
||||
|
||||
// don't care about interaction tracking in our unit tests
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
@ -18,7 +18,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
reportInteraction: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('MetricEncyclopediaModal', () => {
|
||||
describe('MetricsModal', () => {
|
||||
it('renders the modal', async () => {
|
||||
setup(defaultQuery, listOfMetrics);
|
||||
await waitFor(() => {
|
||||
@ -46,7 +46,7 @@ describe('MetricEncyclopediaModal', () => {
|
||||
operations: [],
|
||||
};
|
||||
|
||||
setup(query, listOfMetrics);
|
||||
setup(query, ['with-labels'], true);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('with-labels')).toBeInTheDocument();
|
||||
});
|
||||
@ -102,18 +102,9 @@ describe('MetricEncyclopediaModal', () => {
|
||||
expect(metricStartingWithSomethingElse).toBeNull();
|
||||
});
|
||||
|
||||
it('allows a user to select a template variable', async () => {
|
||||
setup(defaultQuery, listOfMetrics);
|
||||
|
||||
await waitFor(() => {
|
||||
const selectType = screen.getByText('Select template variables');
|
||||
expect(selectType).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Pagination
|
||||
it('shows metrics within a range by pagination', async () => {
|
||||
// default resultsPerPage is 10
|
||||
// default resultsPerPage is 100
|
||||
setup(defaultQuery, listOfMetrics);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('all-metrics')).toBeInTheDocument();
|
||||
@ -146,9 +137,9 @@ describe('MetricEncyclopediaModal', () => {
|
||||
expect(metricInsideRange).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('paginates millions of metrics and does not run out of memory', async () => {
|
||||
const millionsOfMetrics: string[] = [...Array(1000000).keys()].map((i) => '' + i);
|
||||
setup(defaultQuery, millionsOfMetrics);
|
||||
it('paginates lots of metrics and does not run out of memory', async () => {
|
||||
const lotsOfMetrics: string[] = [...Array(100000).keys()].map((i) => '' + i);
|
||||
setup(defaultQuery, lotsOfMetrics);
|
||||
await waitFor(() => {
|
||||
// doesn't break on loading
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
@ -156,7 +147,7 @@ describe('MetricEncyclopediaModal', () => {
|
||||
const resultsPerPageInput = screen.getByTestId(testIds.resultsPerPage);
|
||||
// doesn't break on changing results per page
|
||||
await userEvent.type(resultsPerPageInput, '11');
|
||||
const metricInsideRange = screen.getByText('10');
|
||||
const metricInsideRange = screen.getByText('9');
|
||||
expect(metricInsideRange).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -174,11 +165,9 @@ describe('MetricEncyclopediaModal', () => {
|
||||
});
|
||||
const searchMetric = screen.getByTestId(testIds.searchMetric);
|
||||
expect(searchMetric).toBeInTheDocument();
|
||||
await userEvent.type(searchMetric, 'a_b');
|
||||
await userEvent.type(searchMetric, 'a_buck');
|
||||
|
||||
await waitFor(() => {
|
||||
metricABucket = screen.getByText('a_bucket');
|
||||
expect(metricABucket).toBeInTheDocument();
|
||||
metricAll = screen.queryByText('all-metrics');
|
||||
expect(metricAll).toBeNull();
|
||||
});
|
||||
@ -194,6 +183,10 @@ describe('MetricEncyclopediaModal', () => {
|
||||
expect(metricABucket).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const showSettingsButton = screen.getByTestId(testIds.showAdditionalSettings);
|
||||
expect(showSettingsButton).toBeInTheDocument();
|
||||
await userEvent.click(showSettingsButton);
|
||||
|
||||
const metadataSwitch = screen.getByTestId(testIds.searchWithMetadata);
|
||||
expect(metadataSwitch).toBeInTheDocument();
|
||||
await userEvent.click(metadataSwitch);
|
||||
@ -217,12 +210,11 @@ const defaultQuery: PromVisualQuery = {
|
||||
|
||||
const listOfMetrics: string[] = ['all-metrics', 'a_bucket', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
|
||||
|
||||
function createDatasource(metrics: string[], withLabels?: boolean) {
|
||||
function createDatasource(withLabels?: boolean) {
|
||||
const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider;
|
||||
|
||||
// display different results if their are labels selected in the PromVisualQuery
|
||||
if (withLabels) {
|
||||
languageProvider.getSeries = () => Promise.resolve({ __name__: ['with-labels'] });
|
||||
languageProvider.metricsMetadata = {
|
||||
'with-labels': {
|
||||
type: 'with-labels-type',
|
||||
@ -231,7 +223,6 @@ function createDatasource(metrics: string[], withLabels?: boolean) {
|
||||
};
|
||||
} else {
|
||||
// all metrics
|
||||
languageProvider.getLabelValues = () => Promise.resolve(metrics);
|
||||
languageProvider.metricsMetadata = {
|
||||
'all-metrics': {
|
||||
type: 'all-metrics-type',
|
||||
@ -262,23 +253,24 @@ function createDatasource(metrics: string[], withLabels?: boolean) {
|
||||
return datasource;
|
||||
}
|
||||
|
||||
function createProps(query: PromVisualQuery, datasource: PrometheusDatasource) {
|
||||
function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, metrics: string[]) {
|
||||
return {
|
||||
datasource,
|
||||
isOpen: true,
|
||||
onChange: jest.fn(),
|
||||
onClose: jest.fn(),
|
||||
query: query,
|
||||
initialMetrics: metrics,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) {
|
||||
const withLabels: boolean = query.labels.length > 0;
|
||||
const datasource = createDatasource(metrics, withLabels);
|
||||
const props = createProps(query, datasource);
|
||||
const datasource = createDatasource(withLabels);
|
||||
const props = createProps(query, datasource, metrics);
|
||||
|
||||
// render the modal only
|
||||
const { container } = render(<MetricEncyclopediaModal {...props} />);
|
||||
const { container } = render(<MetricsModal {...props} />);
|
||||
|
||||
return container;
|
||||
}
|
@ -0,0 +1,402 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import debounce from 'debounce-promise';
|
||||
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { EditorField } from '@grafana/experimental';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { InlineField, Switch, Input, Modal, MultiSelect, Spinner, useTheme2, Pagination, Button } from '@grafana/ui';
|
||||
|
||||
import { PrometheusDatasource } from '../../../datasource';
|
||||
import { PromVisualQuery } from '../../types';
|
||||
|
||||
import { FeedbackLink } from './FeedbackLink';
|
||||
import { LetterSearch } from './LetterSearch';
|
||||
import { ResultsTable } from './ResultsTable';
|
||||
import {
|
||||
calculatePageList,
|
||||
calculateResultsPerPage,
|
||||
displayedMetrics,
|
||||
filterMetrics,
|
||||
getBackendSearchMetrics,
|
||||
setMetrics,
|
||||
placeholders,
|
||||
promTypes,
|
||||
} from './state/helpers';
|
||||
import {
|
||||
DEFAULT_RESULTS_PER_PAGE,
|
||||
initialState,
|
||||
MAXIMUM_RESULTS_PER_PAGE,
|
||||
// MetricsModalReducer,
|
||||
MetricsModalMetadata,
|
||||
stateSlice,
|
||||
} from './state/state';
|
||||
import { getStyles } from './styles';
|
||||
import { PromFilterOption } from './types';
|
||||
import { debouncedFuzzySearch } from './uFuzzy';
|
||||
|
||||
export type MetricsModalProps = {
|
||||
datasource: PrometheusDatasource;
|
||||
isOpen: boolean;
|
||||
query: PromVisualQuery;
|
||||
onClose: () => void;
|
||||
onChange: (query: PromVisualQuery) => void;
|
||||
initialMetrics: string[];
|
||||
};
|
||||
|
||||
// actions
|
||||
const {
|
||||
setIsLoading,
|
||||
buildMetrics,
|
||||
filterMetricsBackend,
|
||||
setResultsPerPage,
|
||||
setPageNum,
|
||||
setFuzzySearchQuery,
|
||||
setNameHaystack,
|
||||
setMetaHaystack,
|
||||
setFullMetaSearch,
|
||||
setExcludeNullMetadata,
|
||||
setSelectedTypes,
|
||||
setLetterSearch,
|
||||
setUseBackend,
|
||||
setSelectedIdx,
|
||||
setDisableTextWrap,
|
||||
showAdditionalSettings,
|
||||
} = stateSlice.actions;
|
||||
|
||||
export const MetricsModal = (props: MetricsModalProps) => {
|
||||
const { datasource, isOpen, onClose, onChange, query, initialMetrics } = props;
|
||||
|
||||
const [state, dispatch] = useReducer(stateSlice.reducer, initialState(query));
|
||||
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, state.disableTextWrap);
|
||||
|
||||
/**
|
||||
* loads metrics and metadata on opening modal and switching off useBackend
|
||||
*/
|
||||
const updateMetricsMetadata = useCallback(async () => {
|
||||
// *** Loading Gif
|
||||
dispatch(setIsLoading(true));
|
||||
|
||||
const data: MetricsModalMetadata = await setMetrics(datasource, query, initialMetrics);
|
||||
|
||||
dispatch(
|
||||
buildMetrics({
|
||||
isLoading: false,
|
||||
hasMetadata: data.hasMetadata,
|
||||
metrics: data.metrics,
|
||||
metaHaystackDictionary: data.metaHaystackDictionary,
|
||||
nameHaystackDictionary: data.nameHaystackDictionary,
|
||||
totalMetricCount: data.metrics.length,
|
||||
filteredMetricCount: data.metrics.length,
|
||||
})
|
||||
);
|
||||
}, [query, datasource, initialMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
updateMetricsMetadata();
|
||||
}, [updateMetricsMetadata]);
|
||||
|
||||
const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => {
|
||||
return {
|
||||
value: t.value,
|
||||
label: t.value,
|
||||
description: t.description,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The backend debounced search
|
||||
*/
|
||||
const debouncedBackendSearch = useMemo(
|
||||
() =>
|
||||
debounce(async (metricText: string) => {
|
||||
dispatch(setIsLoading(true));
|
||||
|
||||
const metrics = await getBackendSearchMetrics(metricText, query.labels, datasource);
|
||||
|
||||
dispatch(
|
||||
filterMetricsBackend({
|
||||
metrics: metrics,
|
||||
filteredMetricCount: metrics.length,
|
||||
isLoading: false,
|
||||
})
|
||||
);
|
||||
}, datasource.getDebounceTimeInMilliseconds()),
|
||||
[datasource, query]
|
||||
);
|
||||
|
||||
function fuzzyNameDispatch(haystackData: string[][]) {
|
||||
dispatch(setNameHaystack(haystackData));
|
||||
}
|
||||
|
||||
function fuzzyMetaDispatch(haystackData: string[][]) {
|
||||
dispatch(setMetaHaystack(haystackData));
|
||||
}
|
||||
|
||||
function fuzzySearchCallback(query: string, fullMetaSearchVal: boolean) {
|
||||
if (state.useBackend && query === '') {
|
||||
// get all metrics data if a user erases everything in the input
|
||||
updateMetricsMetadata();
|
||||
} else if (state.useBackend) {
|
||||
debouncedBackendSearch(query);
|
||||
} else {
|
||||
// search either the names or all metadata
|
||||
// fuzzy search go!
|
||||
if (fullMetaSearchVal) {
|
||||
debouncedFuzzySearch(Object.keys(state.metaHaystackDictionary), query, fuzzyMetaDispatch);
|
||||
} else {
|
||||
debouncedFuzzySearch(Object.keys(state.nameHaystackDictionary), query, fuzzyNameDispatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function keyFunction(e: React.KeyboardEvent<HTMLElement>) {
|
||||
if (e.code === 'ArrowDown' && state.selectedIdx < state.resultsPerPage - 1) {
|
||||
dispatch(setSelectedIdx(state.selectedIdx + 1));
|
||||
} else if (e.code === 'ArrowUp' && state.selectedIdx > 0) {
|
||||
dispatch(setSelectedIdx(state.selectedIdx - 1));
|
||||
} else if (e.code === 'Enter') {
|
||||
const metric = displayedMetrics(state, dispatch)[state.selectedIdx];
|
||||
|
||||
onChange({ ...query, metric: metric.value });
|
||||
reportInteraction('grafana_prom_metric_encycopedia_tracking', {
|
||||
metric: metric.value,
|
||||
hasMetadata: state.hasMetadata,
|
||||
totalMetricCount: state.totalMetricCount,
|
||||
fuzzySearchQuery: state.fuzzySearchQuery,
|
||||
fullMetaSearch: state.fullMetaSearch,
|
||||
selectedTypes: state.selectedTypes,
|
||||
letterSearch: state.letterSearch,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
data-testid={testIds.metricModal}
|
||||
isOpen={isOpen}
|
||||
title="Browse metrics"
|
||||
onDismiss={onClose}
|
||||
aria-label="Browse metrics"
|
||||
className={styles.modal}
|
||||
>
|
||||
<div className={styles.inputWrapper}>
|
||||
<div className={cx(styles.inputItem, styles.inputItemFirst)}>
|
||||
<EditorField label="Search metrics">
|
||||
<Input
|
||||
autoFocus={true}
|
||||
data-testid={testIds.searchMetric}
|
||||
placeholder={placeholders.browse}
|
||||
value={state.fuzzySearchQuery}
|
||||
onInput={(e) => {
|
||||
const value = e.currentTarget.value ?? '';
|
||||
dispatch(setFuzzySearchQuery(value));
|
||||
|
||||
fuzzySearchCallback(value, state.fullMetaSearch);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
keyFunction(e);
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
<div className={styles.inputItem}>
|
||||
<EditorField label="Filter by type">
|
||||
<MultiSelect
|
||||
data-testid={testIds.selectType}
|
||||
inputId="my-select"
|
||||
options={typeOptions}
|
||||
value={state.selectedTypes}
|
||||
disabled={!state.hasMetadata || state.useBackend}
|
||||
placeholder={placeholders.type}
|
||||
onChange={(v) => {
|
||||
// *** Filter by type
|
||||
// *** always include metrics without metadata but label it as unknown type
|
||||
// Consider tabs select instead of actual select or multi select
|
||||
dispatch(setSelectedTypes(v));
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
</div>
|
||||
{/* <h4 className={styles.resultsHeading}>Results</h4> */}
|
||||
<div className={styles.resultsData}>
|
||||
<div className={styles.resultsDataCount}>
|
||||
Showing {state.filteredMetricCount} of {state.totalMetricCount} results.{' '}
|
||||
<Spinner className={`${styles.loadingSpinner} ${state.isLoading ? styles.visible : ''}`} />
|
||||
<div className={styles.selectWrapper}>
|
||||
<div className={styles.alphabetRow}>
|
||||
<LetterSearch
|
||||
filteredMetrics={filterMetrics(state, true)}
|
||||
disableTextWrap={state.disableTextWrap}
|
||||
updateLetterSearch={(letter: string) => {
|
||||
if (state.letterSearch === letter) {
|
||||
dispatch(setLetterSearch(''));
|
||||
} else {
|
||||
dispatch(setLetterSearch(letter));
|
||||
}
|
||||
}}
|
||||
letterSearch={state.letterSearch}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
size="sm"
|
||||
onClick={() => dispatch(showAdditionalSettings())}
|
||||
onKeyDown={(e) => {
|
||||
keyFunction(e);
|
||||
}}
|
||||
data-testid={testIds.showAdditionalSettings}
|
||||
>
|
||||
Additional Settings
|
||||
</Button>
|
||||
</div>
|
||||
{state.showAdditionalSettings && (
|
||||
<>
|
||||
<div className={styles.selectItem}>
|
||||
<Switch
|
||||
data-testid={testIds.searchWithMetadata}
|
||||
value={state.fullMetaSearch}
|
||||
disabled={state.useBackend || !state.hasMetadata}
|
||||
onChange={() => {
|
||||
const newVal = !state.fullMetaSearch;
|
||||
dispatch(setFullMetaSearch(newVal));
|
||||
onChange({ ...query, fullMetaSearch: newVal });
|
||||
|
||||
fuzzySearchCallback(state.fuzzySearchQuery, newVal);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
keyFunction(e);
|
||||
}}
|
||||
/>
|
||||
<p className={styles.selectItemLabel}>{placeholders.metadataSearchSwitch}</p>
|
||||
</div>
|
||||
<div className={styles.selectItem}>
|
||||
<Switch
|
||||
value={state.excludeNullMetadata}
|
||||
disabled={state.useBackend || !state.hasMetadata}
|
||||
onChange={() => {
|
||||
dispatch(setExcludeNullMetadata(!state.excludeNullMetadata));
|
||||
onChange({ ...query, excludeNullMetadata: !state.excludeNullMetadata });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
keyFunction(e);
|
||||
}}
|
||||
/>
|
||||
<p className={styles.selectItemLabel}>{placeholders.excludeNoMetadata}</p>
|
||||
</div>
|
||||
<div className={styles.selectItem}>
|
||||
<Switch
|
||||
value={state.disableTextWrap}
|
||||
onChange={() => {
|
||||
dispatch(setDisableTextWrap());
|
||||
onChange({ ...query, disableTextWrap: !state.disableTextWrap });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
keyFunction(e);
|
||||
}}
|
||||
/>
|
||||
<p className={styles.selectItemLabel}>Disable text wrap</p>
|
||||
</div>
|
||||
<div className={styles.selectItem}>
|
||||
<Switch
|
||||
data-testid={testIds.setUseBackend}
|
||||
value={state.useBackend}
|
||||
onChange={() => {
|
||||
const newVal = !state.useBackend;
|
||||
dispatch(setUseBackend(newVal));
|
||||
onChange({ ...query, useBackend: newVal });
|
||||
if (newVal === false) {
|
||||
// rebuild the metrics metadata if we turn off useBackend
|
||||
updateMetricsMetadata();
|
||||
} else {
|
||||
// check if there is text in the browse search and update
|
||||
if (state.fuzzySearchQuery !== '') {
|
||||
debouncedBackendSearch(state.fuzzySearchQuery);
|
||||
}
|
||||
// otherwise wait for user typing
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
keyFunction(e);
|
||||
}}
|
||||
/>
|
||||
<p className={styles.selectItemLabel}>{placeholders.setUseBackend}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{query.labels.length > 0 && (
|
||||
<p className={styles.resultsDataFiltered}>
|
||||
These metrics have been pre-filtered by labels chosen in the label filters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.results}>
|
||||
{state.metrics && (
|
||||
<ResultsTable
|
||||
metrics={displayedMetrics(state, dispatch)}
|
||||
onChange={onChange}
|
||||
onClose={onClose}
|
||||
query={query}
|
||||
state={state}
|
||||
selectedIdx={state.selectedIdx}
|
||||
disableTextWrap={state.disableTextWrap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.pageSettingsWrapper}>
|
||||
<div className={styles.pageSettings}>
|
||||
<InlineField
|
||||
label="# results per page"
|
||||
tooltip={'The maximum results per page is ' + MAXIMUM_RESULTS_PER_PAGE}
|
||||
labelWidth={20}
|
||||
>
|
||||
<Input
|
||||
data-testid={testIds.resultsPerPage}
|
||||
value={calculateResultsPerPage(state.resultsPerPage, DEFAULT_RESULTS_PER_PAGE, MAXIMUM_RESULTS_PER_PAGE)}
|
||||
placeholder="results per page"
|
||||
width={20}
|
||||
onInput={(e) => {
|
||||
const value = +e.currentTarget.value;
|
||||
|
||||
if (isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setResultsPerPage(value));
|
||||
}}
|
||||
/>
|
||||
</InlineField>
|
||||
<Pagination
|
||||
currentPage={state.pageNum ?? 1}
|
||||
numberOfPages={calculatePageList(state).length}
|
||||
onNavigate={(val: number) => {
|
||||
const page = val ?? 1;
|
||||
dispatch(setPageNum(page));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FeedbackLink feedbackUrl="https://forms.gle/DEMAJHoAMpe3e54CA" />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const testIds = {
|
||||
metricModal: 'metric-modal',
|
||||
searchMetric: 'search-metric',
|
||||
searchWithMetadata: 'search-with-metadata',
|
||||
selectType: 'select-type',
|
||||
metricCard: 'metric-card',
|
||||
useMetric: 'use-metric',
|
||||
searchPage: 'search-page',
|
||||
resultsPerPage: 'results-per-page',
|
||||
setUseBackend: 'set-use-backend',
|
||||
showAdditionalSettings: 'show-additional-settings',
|
||||
};
|
@ -0,0 +1,170 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { PromVisualQuery } from '../../types';
|
||||
|
||||
import { MetricsModalState } from './state/state';
|
||||
import { MetricData, MetricsData } from './types';
|
||||
|
||||
type ResultsTableProps = {
|
||||
metrics: MetricsData;
|
||||
onChange: (query: PromVisualQuery) => void;
|
||||
onClose: () => void;
|
||||
query: PromVisualQuery;
|
||||
state: MetricsModalState;
|
||||
selectedIdx: number;
|
||||
disableTextWrap: boolean;
|
||||
};
|
||||
|
||||
export function ResultsTable(props: ResultsTableProps) {
|
||||
const { metrics, onChange, onClose, query, state, selectedIdx, disableTextWrap } = props;
|
||||
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, disableTextWrap);
|
||||
|
||||
const tableRef = useRef<HTMLTableElement | null>(null);
|
||||
|
||||
function isSelectedRow(idx: number): boolean {
|
||||
return idx === selectedIdx;
|
||||
}
|
||||
|
||||
function selectMetric(metric: MetricData) {
|
||||
if (metric.value) {
|
||||
onChange({ ...query, metric: metric.value });
|
||||
reportInteraction('grafana_prom_metric_encycopedia_tracking', {
|
||||
metric: metric.value,
|
||||
hasMetadata: state.hasMetadata,
|
||||
totalMetricCount: state.totalMetricCount,
|
||||
fuzzySearchQuery: state.fuzzySearchQuery,
|
||||
fullMetaSearch: state.fullMetaSearch,
|
||||
selectedTypes: state.selectedTypes,
|
||||
letterSearch: state.letterSearch,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const tr = tableRef.current?.getElementsByClassName('selected-row')[0];
|
||||
tr?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}, [selectedIdx]);
|
||||
|
||||
function metaRows(metric: MetricData) {
|
||||
if (state.fullMetaSearch && metric) {
|
||||
return (
|
||||
<>
|
||||
<td>
|
||||
<Highlighter
|
||||
textToHighlight={metric.type ?? ''}
|
||||
searchWords={state.metaHaystackMatches}
|
||||
autoEscape
|
||||
highlightClassName={styles.matchHighLight}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Highlighter
|
||||
textToHighlight={metric.description ?? ''}
|
||||
searchWords={state.metaHaystackMatches}
|
||||
autoEscape
|
||||
highlightClassName={styles.matchHighLight}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<td>{metric.type ?? ''}</td>
|
||||
<td>{metric.description ?? ''}</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<table className={styles.table} ref={tableRef}>
|
||||
<thead>
|
||||
<tr className={styles.header}>
|
||||
<th>Name</th>
|
||||
{state.hasMetadata && (
|
||||
<>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<>
|
||||
{metrics &&
|
||||
metrics.map((metric: MetricData, idx: number) => {
|
||||
return (
|
||||
<tr
|
||||
key={metric?.value ?? idx}
|
||||
className={`${styles.row} ${isSelectedRow(idx) ? `${styles.selectedRow} selected-row` : ''}`}
|
||||
onClick={() => selectMetric(metric)}
|
||||
>
|
||||
<td>
|
||||
<Highlighter
|
||||
textToHighlight={metric?.value ?? ''}
|
||||
searchWords={state.fullMetaSearch ? state.metaHaystackMatches : state.nameHaystackMatches}
|
||||
autoEscape
|
||||
highlightClassName={styles.matchHighLight}
|
||||
/>
|
||||
</td>
|
||||
{state.hasMetadata && metaRows(metric)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
||||
const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03);
|
||||
|
||||
return {
|
||||
table: css`
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
width: 100%;
|
||||
white-space: ${disableTextWrap ? 'nowrap' : 'normal'};
|
||||
td {
|
||||
padding: ${theme.spacing(1)};
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
min-width: ${theme.spacing(3)};
|
||||
}
|
||||
`,
|
||||
header: css`
|
||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
||||
`,
|
||||
row: css`
|
||||
label: row;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid ${theme.colors.border.weak}
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
:hover {
|
||||
background-color: ${rowHoverBg};
|
||||
}
|
||||
`,
|
||||
selectedRow: css`
|
||||
background-color: ${rowHoverBg};
|
||||
`,
|
||||
matchHighLight: css`
|
||||
background: inherit;
|
||||
color: ${theme.components.textHighlight.text};
|
||||
background-color: ${theme.components.textHighlight.background};
|
||||
`,
|
||||
};
|
||||
};
|
@ -0,0 +1,213 @@
|
||||
import { AnyAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasource';
|
||||
import { getMetadataHelp, getMetadataType } from 'app/plugins/datasource/prometheus/language_provider';
|
||||
|
||||
import { regexifyLabelValuesQueryString } from '../../../shared/parsingUtils';
|
||||
import { QueryBuilderLabelFilter } from '../../../shared/types';
|
||||
import { PromVisualQuery } from '../../../types';
|
||||
import { HaystackDictionary, MetricData, MetricsData, PromFilterOption } from '../types';
|
||||
|
||||
import { MetricsModalMetadata, MetricsModalState, stateSlice } from './state';
|
||||
|
||||
const { setFilteredMetricCount } = stateSlice.actions;
|
||||
|
||||
export async function setMetrics(
|
||||
datasource: PrometheusDatasource,
|
||||
query: PromVisualQuery,
|
||||
initialMetrics?: string[]
|
||||
): Promise<MetricsModalMetadata> {
|
||||
// metadata is set in the metric select now
|
||||
// use this to disable metadata search and display
|
||||
let hasMetadata = true;
|
||||
const metadata = datasource.languageProvider.metricsMetadata;
|
||||
if (metadata && Object.keys(metadata).length === 0) {
|
||||
hasMetadata = false;
|
||||
}
|
||||
|
||||
let nameHaystackDictionaryData: HaystackDictionary = {};
|
||||
let metaHaystackDictionaryData: HaystackDictionary = {};
|
||||
|
||||
// pass in metrics from getMetrics in the query builder, reduced in the metric select
|
||||
let metricsData: MetricsData | undefined;
|
||||
|
||||
metricsData = initialMetrics?.map((m: string) => {
|
||||
const type = getMetadataType(m, datasource.languageProvider.metricsMetadata!);
|
||||
const description = getMetadataHelp(m, datasource.languageProvider.metricsMetadata!);
|
||||
|
||||
// possibly remove the type in favor of the type select
|
||||
const metaDataString = `${m}¦${type}¦${description}`;
|
||||
|
||||
const metricData: MetricData = {
|
||||
value: m,
|
||||
type: type,
|
||||
description: description,
|
||||
};
|
||||
|
||||
nameHaystackDictionaryData[m] = metricData;
|
||||
metaHaystackDictionaryData[metaDataString] = metricData;
|
||||
|
||||
return metricData;
|
||||
});
|
||||
|
||||
return {
|
||||
isLoading: false,
|
||||
hasMetadata: hasMetadata,
|
||||
metrics: metricsData ?? [],
|
||||
metaHaystackDictionary: metaHaystackDictionaryData,
|
||||
nameHaystackDictionary: nameHaystackDictionaryData,
|
||||
totalMetricCount: metricsData?.length ?? 0,
|
||||
filteredMetricCount: metricsData?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The filtered and paginated metrics displayed in the modal
|
||||
* */
|
||||
export function displayedMetrics(state: MetricsModalState, dispatch: React.Dispatch<AnyAction>) {
|
||||
const filteredSorted: MetricsData = filterMetrics(state);
|
||||
|
||||
if (!state.isLoading && state.filteredMetricCount !== filteredSorted.length) {
|
||||
dispatch(setFilteredMetricCount(filteredSorted.length));
|
||||
}
|
||||
|
||||
return sliceMetrics(filteredSorted, state.pageNum, state.resultsPerPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the metrics with all the options, fuzzy, type, letter
|
||||
* @param metrics
|
||||
* @param skipLetterSearch used to show the alphabet letters as clickable before filtering out letters (needs to be refactored)
|
||||
* @returns
|
||||
*/
|
||||
export function filterMetrics(state: MetricsModalState, skipLetterSearch?: boolean): MetricsData {
|
||||
let filteredMetrics: MetricsData = state.metrics;
|
||||
|
||||
if (state.fuzzySearchQuery && !state.useBackend) {
|
||||
if (state.fullMetaSearch) {
|
||||
filteredMetrics = state.metaHaystackOrder.map((needle: string) => state.metaHaystackDictionary[needle]);
|
||||
} else {
|
||||
filteredMetrics = state.nameHaystackOrder.map((needle: string) => state.nameHaystackDictionary[needle]);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.letterSearch && !skipLetterSearch) {
|
||||
filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => {
|
||||
const letters: string[] = [state.letterSearch, state.letterSearch.toLowerCase()];
|
||||
return letters.includes(m.value[0]);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.selectedTypes.length > 0 && !state.useBackend) {
|
||||
filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => {
|
||||
// Matches type
|
||||
const matchesSelectedType = state.selectedTypes.some((t) => t.value === m.type);
|
||||
|
||||
// missing type
|
||||
const hasNoType = !m.type;
|
||||
|
||||
return matchesSelectedType || (hasNoType && !state.excludeNullMetadata);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.excludeNullMetadata) {
|
||||
filteredMetrics = filteredMetrics.filter((m: MetricData) => {
|
||||
return m.type !== undefined && m.description !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return filteredMetrics;
|
||||
}
|
||||
|
||||
export function calculatePageList(state: MetricsModalState) {
|
||||
if (!state.metrics.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const calcResultsPerPage: number = state.resultsPerPage === 0 ? 1 : state.resultsPerPage;
|
||||
|
||||
const pages = Math.floor(filterMetrics(state).length / calcResultsPerPage) + 1;
|
||||
|
||||
return [...Array(pages).keys()].map((i) => i + 1);
|
||||
}
|
||||
|
||||
export function sliceMetrics(metrics: MetricsData, pageNum: number, resultsPerPage: number) {
|
||||
const calcResultsPerPage: number = resultsPerPage === 0 ? 1 : resultsPerPage;
|
||||
const start: number = pageNum === 1 ? 0 : (pageNum - 1) * calcResultsPerPage;
|
||||
const end: number = start + calcResultsPerPage;
|
||||
return metrics.slice(start, end);
|
||||
}
|
||||
|
||||
export const calculateResultsPerPage = (results: number, defaultResults: number, max: number) => {
|
||||
if (results < 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (results > max) {
|
||||
return max;
|
||||
}
|
||||
|
||||
return results ?? defaultResults;
|
||||
};
|
||||
|
||||
/**
|
||||
* The backend query that replaces the uFuzzy search when the option 'useBackend' has been selected
|
||||
* @param metricText
|
||||
* @param labels
|
||||
* @param datasource
|
||||
* @returns
|
||||
*/
|
||||
export async function getBackendSearchMetrics(
|
||||
metricText: string,
|
||||
labels: QueryBuilderLabelFilter[],
|
||||
datasource: PrometheusDatasource
|
||||
): Promise<Array<{ value: string }>> {
|
||||
const queryString = regexifyLabelValuesQueryString(metricText);
|
||||
|
||||
const labelsParams = labels.map((label) => {
|
||||
return `,${label.label}="${label.value}"`;
|
||||
});
|
||||
|
||||
const params = `label_values({__name__=~".*${queryString}"${labels ? labelsParams.join() : ''}},__name__)`;
|
||||
|
||||
const results = datasource.metricFindQuery(params);
|
||||
|
||||
return await results.then((results) => {
|
||||
return results.map((result) => {
|
||||
return {
|
||||
value: result.text,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const promTypes: PromFilterOption[] = [
|
||||
{
|
||||
value: 'counter',
|
||||
description:
|
||||
'A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart.',
|
||||
},
|
||||
{
|
||||
value: 'gauge',
|
||||
description: 'A metric that represents a single numerical value that can arbitrarily go up and down.',
|
||||
},
|
||||
{
|
||||
value: 'histogram',
|
||||
description:
|
||||
'A histogram samples observations (usually things like request durations or response sizes) and counts them in configurable buckets.',
|
||||
},
|
||||
{
|
||||
value: 'summary',
|
||||
description:
|
||||
'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.',
|
||||
},
|
||||
];
|
||||
|
||||
export const placeholders = {
|
||||
browse: 'Search metrics by name',
|
||||
metadataSearchSwitch: 'Search by metadata type and description in addition to name',
|
||||
type: 'Select...',
|
||||
variables: 'Select...',
|
||||
excludeNoMetadata: 'Exclude results with no metadata',
|
||||
setUseBackend: 'Use the backend to browse metrics',
|
||||
};
|
@ -0,0 +1,209 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
import { PromVisualQuery } from '../../../types';
|
||||
import { HaystackDictionary, MetricsData } from '../types';
|
||||
|
||||
export const DEFAULT_RESULTS_PER_PAGE = 100;
|
||||
export const MAXIMUM_RESULTS_PER_PAGE = 1000;
|
||||
|
||||
export const stateSlice = createSlice({
|
||||
name: 'metrics-modal-state',
|
||||
initialState: initialState(),
|
||||
reducers: {
|
||||
filterMetricsBackend: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
metrics: MetricsData;
|
||||
filteredMetricCount: number;
|
||||
isLoading: boolean;
|
||||
}>
|
||||
) => {
|
||||
state.metrics = action.payload.metrics;
|
||||
state.filteredMetricCount = action.payload.filteredMetricCount;
|
||||
state.isLoading = action.payload.isLoading;
|
||||
},
|
||||
buildMetrics: (state, action: PayloadAction<MetricsModalMetadata>) => {
|
||||
state.isLoading = action.payload.isLoading;
|
||||
state.metrics = action.payload.metrics;
|
||||
state.hasMetadata = action.payload.hasMetadata;
|
||||
state.metaHaystackDictionary = action.payload.metaHaystackDictionary;
|
||||
state.nameHaystackDictionary = action.payload.nameHaystackDictionary;
|
||||
state.totalMetricCount = action.payload.totalMetricCount;
|
||||
state.filteredMetricCount = action.payload.filteredMetricCount;
|
||||
},
|
||||
setIsLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.isLoading = action.payload;
|
||||
},
|
||||
setFilteredMetricCount: (state, action: PayloadAction<number>) => {
|
||||
state.filteredMetricCount = action.payload;
|
||||
},
|
||||
setResultsPerPage: (state, action: PayloadAction<number>) => {
|
||||
state.resultsPerPage = action.payload;
|
||||
},
|
||||
setPageNum: (state, action: PayloadAction<number>) => {
|
||||
state.pageNum = action.payload;
|
||||
},
|
||||
setFuzzySearchQuery: (state, action: PayloadAction<string>) => {
|
||||
state.fuzzySearchQuery = action.payload;
|
||||
state.pageNum = 1;
|
||||
state.letterSearch = '';
|
||||
state.selectedIdx = 0;
|
||||
},
|
||||
setNameHaystack: (state, action: PayloadAction<string[][]>) => {
|
||||
state.nameHaystackOrder = action.payload[0];
|
||||
state.nameHaystackMatches = action.payload[1];
|
||||
},
|
||||
setMetaHaystack: (state, action: PayloadAction<string[][]>) => {
|
||||
state.metaHaystackOrder = action.payload[0];
|
||||
state.metaHaystackMatches = action.payload[1];
|
||||
},
|
||||
setFullMetaSearch: (state, action: PayloadAction<boolean>) => {
|
||||
state.fullMetaSearch = action.payload;
|
||||
state.pageNum = 1;
|
||||
},
|
||||
setExcludeNullMetadata: (state, action: PayloadAction<boolean>) => {
|
||||
state.excludeNullMetadata = action.payload;
|
||||
state.pageNum = 1;
|
||||
},
|
||||
setSelectedTypes: (state, action: PayloadAction<Array<SelectableValue<string>>>) => {
|
||||
state.selectedTypes = action.payload;
|
||||
state.pageNum = 1;
|
||||
},
|
||||
setLetterSearch: (state, action: PayloadAction<string>) => {
|
||||
state.letterSearch = action.payload;
|
||||
state.pageNum = 1;
|
||||
},
|
||||
setUseBackend: (state, action: PayloadAction<boolean>) => {
|
||||
state.useBackend = action.payload;
|
||||
state.fullMetaSearch = false;
|
||||
state.excludeNullMetadata = false;
|
||||
state.pageNum = 1;
|
||||
},
|
||||
setSelectedIdx: (state, action: PayloadAction<number>) => {
|
||||
state.selectedIdx = action.payload;
|
||||
},
|
||||
setDisableTextWrap: (state) => {
|
||||
state.disableTextWrap = !state.disableTextWrap;
|
||||
},
|
||||
showAdditionalSettings: (state) => {
|
||||
state.showAdditionalSettings = !state.showAdditionalSettings;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Initial state for the Metrics Modal
|
||||
* @returns
|
||||
*/
|
||||
export function initialState(query?: PromVisualQuery): MetricsModalState {
|
||||
return {
|
||||
isLoading: true,
|
||||
metrics: [],
|
||||
hasMetadata: true,
|
||||
metaHaystackDictionary: {},
|
||||
metaHaystackMatches: [],
|
||||
metaHaystackOrder: [],
|
||||
nameHaystackDictionary: {},
|
||||
nameHaystackOrder: [],
|
||||
nameHaystackMatches: [],
|
||||
totalMetricCount: 0,
|
||||
filteredMetricCount: null,
|
||||
resultsPerPage: DEFAULT_RESULTS_PER_PAGE,
|
||||
pageNum: 1,
|
||||
fuzzySearchQuery: '',
|
||||
fullMetaSearch: query?.fullMetaSearch ?? false,
|
||||
excludeNullMetadata: query?.excludeNullMetadata ?? false,
|
||||
selectedTypes: [],
|
||||
letterSearch: '',
|
||||
useBackend: query?.useBackend ?? false,
|
||||
disableTextWrap: query?.disableTextWrap ?? false,
|
||||
selectedIdx: 0,
|
||||
showAdditionalSettings: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Metrics Modal state object
|
||||
*/
|
||||
export interface MetricsModalState {
|
||||
/** Used for the loading spinner */
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* Initial collection of metrics.
|
||||
* The frontend filters do not impact this, but
|
||||
* it is reduced by the backend search.
|
||||
*/
|
||||
metrics: MetricsData;
|
||||
/** Field for disabling type select and switches that rely on metadata */
|
||||
hasMetadata: boolean;
|
||||
/** Used to display metrics and help with fuzzy order */
|
||||
nameHaystackDictionary: HaystackDictionary;
|
||||
/** Used to sort name fuzzy search by relevance */
|
||||
nameHaystackOrder: string[];
|
||||
/** Used to highlight text in fuzzy matches */
|
||||
nameHaystackMatches: string[];
|
||||
/** Used to display metrics and help with fuzzy order for search across all metadata */
|
||||
metaHaystackDictionary: HaystackDictionary;
|
||||
/** Used to sort meta fuzzy search by relevance */
|
||||
metaHaystackOrder: string[];
|
||||
/** Used to highlight text in fuzzy matches */
|
||||
metaHaystackMatches: string[];
|
||||
/** Total results computed on initialization */
|
||||
totalMetricCount: number;
|
||||
/** Set after filtering metrics */
|
||||
filteredMetricCount: number | null;
|
||||
/** Pagination field for showing results in table */
|
||||
resultsPerPage: number;
|
||||
/** Pagination field */
|
||||
pageNum: number;
|
||||
/** The text query used to match metrics */
|
||||
fuzzySearchQuery: string;
|
||||
/** Enables the fuzzy meatadata search */
|
||||
fullMetaSearch: boolean;
|
||||
/** Excludes results that are missing type and description */
|
||||
excludeNullMetadata: boolean;
|
||||
/** Filter by prometheus type */
|
||||
selectedTypes: Array<SelectableValue<string>>;
|
||||
/** After results are filtered, select a letter to show metrics that start with that letter */
|
||||
letterSearch: string;
|
||||
/** Filter by the series match endpoint instead of the fuzzy search */
|
||||
useBackend: boolean;
|
||||
/** Disable text wrap for descriptions in the results table */
|
||||
disableTextWrap: boolean;
|
||||
/** The selected metric in the table represented by hover style highlighting */
|
||||
selectedIdx: number;
|
||||
/** Display toggle switches for settings */
|
||||
showAdditionalSettings: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for the useEffect get metadata function
|
||||
*/
|
||||
export type MetricsModalMetadata = {
|
||||
isLoading: boolean;
|
||||
metrics: MetricsData;
|
||||
hasMetadata: boolean;
|
||||
metaHaystackDictionary: HaystackDictionary;
|
||||
nameHaystackDictionary: HaystackDictionary;
|
||||
totalMetricCount: number;
|
||||
filteredMetricCount: number | null;
|
||||
};
|
||||
|
||||
// for updating the settings in the PromQuery model
|
||||
export function getSettings(visQuery: PromVisualQuery): MetricsModalSettings {
|
||||
return {
|
||||
useBackend: visQuery?.useBackend ?? false,
|
||||
disableTextWrap: visQuery?.disableTextWrap ?? false,
|
||||
fullMetaSearch: visQuery?.fullMetaSearch ?? false,
|
||||
excludeNullMetadata: visQuery.excludeNullMetadata ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export type MetricsModalSettings = {
|
||||
useBackend?: boolean;
|
||||
disableTextWrap?: boolean;
|
||||
fullMetaSearch?: boolean;
|
||||
excludeNullMetadata?: boolean;
|
||||
};
|
@ -0,0 +1,123 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
||||
return {
|
||||
modal: css`
|
||||
width: 85vw;
|
||||
${theme.breakpoints.down('md')} {
|
||||
width: 100%;
|
||||
}
|
||||
${theme.breakpoints.up('xl')} {
|
||||
width: 60%;
|
||||
}
|
||||
`,
|
||||
inputWrapper: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: ${theme.spacing(2)};
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
`,
|
||||
inputItemFirst: css`
|
||||
flex-basis: 40%;
|
||||
`,
|
||||
inputItem: css`
|
||||
flex-grow: 1;
|
||||
flex-basis: 20%;
|
||||
${theme.breakpoints.down('md')} {
|
||||
min-width: 100%;
|
||||
}
|
||||
`,
|
||||
selectWrapper: css`
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
selectItem: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`,
|
||||
selectItemLabel: css`
|
||||
margin: 0 0 0 ${theme.spacing(1)};
|
||||
align-self: center;
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
resultsHeading: css`
|
||||
margin: 0 0 0 0;
|
||||
`,
|
||||
resultsData: css`
|
||||
margin: 0 0 ${theme.spacing(1)} 0;
|
||||
`,
|
||||
resultsDataCount: css`
|
||||
margin: 0;
|
||||
`,
|
||||
resultsDataFiltered: css`
|
||||
margin: 0;
|
||||
color: ${theme.colors.warning.text};
|
||||
`,
|
||||
alphabetRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
column-gap: ${theme.spacing(1)};
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
alphabetRowToggles: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: ${theme.spacing(1)};
|
||||
`,
|
||||
results: css`
|
||||
height: calc(80vh - 280px);
|
||||
overflow-y: scroll;
|
||||
`,
|
||||
pageSettingsWrapper: css`
|
||||
padding-top: ${theme.spacing(1.5)};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
`,
|
||||
pageSettings: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
`,
|
||||
selAlpha: css`
|
||||
cursor: pointer;
|
||||
color: #6e9fff;
|
||||
`,
|
||||
active: css`
|
||||
cursor: pointer;
|
||||
`,
|
||||
gray: css`
|
||||
color: grey;
|
||||
opacity: 50%;
|
||||
`,
|
||||
loadingSpinner: css`
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
`,
|
||||
table: css`
|
||||
white-space: ${disableTextWrap ? 'nowrap' : 'normal'};
|
||||
td {
|
||||
vertical-align: baseline;
|
||||
padding: 0;
|
||||
}
|
||||
`,
|
||||
tableDiv: css`
|
||||
padding: 8px;
|
||||
`,
|
||||
visible: css`
|
||||
visibility: visible;
|
||||
`,
|
||||
};
|
||||
};
|
@ -0,0 +1,30 @@
|
||||
export type MetricsData = MetricData[];
|
||||
|
||||
export type MetricData = {
|
||||
value: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type PromFilterOption = {
|
||||
value: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export interface HaystackDictionary {
|
||||
[needle: string]: MetricData;
|
||||
}
|
||||
|
||||
export type UFuzzyInfo = {
|
||||
idx: number[];
|
||||
start: number[];
|
||||
chars: number[];
|
||||
terms: number[];
|
||||
interIns: number[];
|
||||
intraIns: number[];
|
||||
interLft2: number[];
|
||||
interRgt2: number[];
|
||||
interLft1: number[];
|
||||
interRgt1: number[];
|
||||
ranges: number[][];
|
||||
};
|
@ -0,0 +1,43 @@
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
import { debounce as debounceLodash } from 'lodash';
|
||||
|
||||
const uf = new uFuzzy({
|
||||
intraMode: 1,
|
||||
intraIns: 1,
|
||||
intraSub: 1,
|
||||
intraTrn: 1,
|
||||
intraDel: 1,
|
||||
});
|
||||
|
||||
export function fuzzySearch(haystack: string[], query: string, dispatcher: (data: string[][]) => void) {
|
||||
const [idxs, info, order] = uf.search(haystack, query, false, 1e5);
|
||||
|
||||
let haystackOrder: string[] = [];
|
||||
let matchesSet: Set<string> = new Set();
|
||||
if (idxs && order) {
|
||||
/**
|
||||
* get the fuzzy matches for hilighting
|
||||
* @param part
|
||||
* @param matched
|
||||
*/
|
||||
const mark = (part: string, matched: boolean) => {
|
||||
if (matched) {
|
||||
matchesSet.add(part);
|
||||
}
|
||||
};
|
||||
|
||||
// Iterate to create the order of needles(queries) and the matches
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
let infoIdx = order[i];
|
||||
|
||||
/** Evaluate the match, get the matches for highlighting */
|
||||
uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark);
|
||||
/** Get the order */
|
||||
haystackOrder.push(haystack[info.idx[infoIdx]]);
|
||||
}
|
||||
|
||||
dispatcher([haystackOrder, [...matchesSet]]);
|
||||
}
|
||||
}
|
||||
|
||||
export const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300);
|
@ -9,6 +9,11 @@ export interface PromVisualQuery {
|
||||
labels: QueryBuilderLabelFilter[];
|
||||
operations: QueryBuilderOperation[];
|
||||
binaryQueries?: PromVisualQueryBinary[];
|
||||
// metrics modal additional settings
|
||||
useBackend?: boolean;
|
||||
disableTextWrap?: boolean;
|
||||
excludeNullMetadata?: boolean;
|
||||
fullMetaSearch?: boolean;
|
||||
}
|
||||
|
||||
export type PromVisualQueryBinary = VisualQueryBinary<PromVisualQuery>;
|
||||
|
@ -18,6 +18,11 @@ export interface PromQuery extends GenPromQuery, DataQuery {
|
||||
hinting?: boolean;
|
||||
interval?: string;
|
||||
intervalFactor?: number;
|
||||
// store the metrics modal additional settings
|
||||
useBackend?: boolean;
|
||||
disableTextWrap?: boolean;
|
||||
fullMetaSearch?: boolean;
|
||||
excludeNullMetadata?: boolean;
|
||||
}
|
||||
|
||||
export enum PrometheusCacheLevel {
|
||||
|
Loading…
Reference in New Issue
Block a user