mirror of
https://github.com/grafana/grafana.git
synced 2026-08-18 00:54:58 -05: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:
co-authored by
Leon Sorokin
parent
0d52d19e21
commit
d31d1576fb
-850
@@ -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 { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
|
||||||
import { EditorField, EditorFieldGroup } from '@grafana/experimental';
|
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 { PrometheusDatasource } from '../../datasource';
|
||||||
import { regexifyLabelValuesQueryString } from '../shared/parsingUtils';
|
import { regexifyLabelValuesQueryString } from '../shared/parsingUtils';
|
||||||
import { QueryBuilderLabelFilter } from '../shared/types';
|
import { QueryBuilderLabelFilter } from '../shared/types';
|
||||||
import { PromVisualQuery } from '../types';
|
import { PromVisualQuery } from '../types';
|
||||||
|
|
||||||
|
import { MetricsModal } from './metrics-modal/MetricsModal';
|
||||||
|
|
||||||
// We are matching words split with space
|
// We are matching words split with space
|
||||||
const splitSeparator = ' ';
|
const splitSeparator = ' ';
|
||||||
|
|
||||||
@@ -26,6 +30,8 @@ export interface Props {
|
|||||||
|
|
||||||
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000;
|
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000;
|
||||||
|
|
||||||
|
const prometheusMetricEncyclopedia = config.featureToggles.prometheusMetricEncyclopedia;
|
||||||
|
|
||||||
export function MetricSelect({
|
export function MetricSelect({
|
||||||
datasource,
|
datasource,
|
||||||
query,
|
query,
|
||||||
@@ -38,6 +44,8 @@ export function MetricSelect({
|
|||||||
const [state, setState] = useState<{
|
const [state, setState] = useState<{
|
||||||
metrics?: Array<SelectableValue<any>>;
|
metrics?: Array<SelectableValue<any>>;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
metricsModalOpen?: boolean;
|
||||||
|
initialMetrics?: string[];
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
const customFilterOption = useCallback((option: SelectableValue<any>, searchQuery: string) => {
|
const customFilterOption = useCallback((option: SelectableValue<any>, searchQuery: string) => {
|
||||||
@@ -129,8 +137,61 @@ export function MetricSelect({
|
|||||||
(query: string) => getMetricLabels(query),
|
(query: string) => getMetricLabels(query),
|
||||||
datasource.getDebounceTimeInMilliseconds()
|
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 (
|
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 (
|
||||||
|
<>
|
||||||
|
{prometheusMetricEncyclopedia && !datasource.lookupsDisabled && state.metricsModalOpen && (
|
||||||
|
<MetricsModal
|
||||||
|
datasource={datasource}
|
||||||
|
isOpen={state.metricsModalOpen}
|
||||||
|
onClose={() => setState({ ...state, metricsModalOpen: false })}
|
||||||
|
query={query}
|
||||||
|
onChange={onChange}
|
||||||
|
initialMetrics={state.initialMetrics ?? []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<EditorFieldGroup>
|
<EditorFieldGroup>
|
||||||
<EditorField label="Metric">
|
<EditorField label="Metric">
|
||||||
<AsyncSelect
|
<AsyncSelect
|
||||||
@@ -147,22 +208,47 @@ export function MetricSelect({
|
|||||||
}
|
}
|
||||||
setState({ isLoading: true });
|
setState({ isLoading: true });
|
||||||
const metrics = await onGetMetrics();
|
const metrics = await onGetMetrics();
|
||||||
|
const initialMetrics: string[] = metrics.map((m) => m.value);
|
||||||
if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) {
|
if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) {
|
||||||
metrics.splice(0, 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 });
|
setState({ metrics, isLoading: undefined });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch}
|
loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch}
|
||||||
isLoading={state.isLoading}
|
isLoading={state.isLoading}
|
||||||
defaultOptions={state.metrics}
|
defaultOptions={state.metrics}
|
||||||
onChange={({ value }) => {
|
onChange={({ value }) => {
|
||||||
if (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 });
|
onChange({ ...query, metric: value });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
components={{ Option: CustomOption }}
|
||||||
/>
|
/>
|
||||||
</EditorField>
|
</EditorField>
|
||||||
</EditorFieldGroup>
|
</EditorFieldGroup>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,4 +263,24 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
color: ${theme.colors.warning.contrastText};
|
color: ${theme.colors.warning.contrastText};
|
||||||
background-color: ${theme.colors.warning.main};
|
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
-54
@@ -1,10 +1,7 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import React, { useCallback, useState } from 'react';
|
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 { EditorRow } from '@grafana/experimental';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { Button, Tag, useStyles2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { PrometheusDatasource } from '../../datasource';
|
import { PrometheusDatasource } from '../../datasource';
|
||||||
import { getMetadataString } from '../../language_provider';
|
import { getMetadataString } from '../../language_provider';
|
||||||
@@ -22,7 +19,6 @@ import { QueryBuilderLabelFilter, QueryBuilderOperation } from '../shared/types'
|
|||||||
import { PromVisualQuery } from '../types';
|
import { PromVisualQuery } from '../types';
|
||||||
|
|
||||||
import { LabelFilters } from './LabelFilters';
|
import { LabelFilters } from './LabelFilters';
|
||||||
import { MetricEncyclopediaModal } from './MetricEncyclopediaModal';
|
|
||||||
import { MetricSelect, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './MetricSelect';
|
import { MetricSelect, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './MetricSelect';
|
||||||
import { NestedQueryList } from './NestedQueryList';
|
import { NestedQueryList } from './NestedQueryList';
|
||||||
import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained';
|
import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained';
|
||||||
@@ -39,12 +35,10 @@ export interface Props {
|
|||||||
export const PromQueryBuilder = React.memo<Props>((props) => {
|
export const PromQueryBuilder = React.memo<Props>((props) => {
|
||||||
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
|
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
|
||||||
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
|
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
|
||||||
const [metricEncyclopediaModalOpen, setMetricEncyclopediaModalOpen] = useState(false);
|
|
||||||
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => {
|
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => {
|
||||||
onChange({ ...query, labels });
|
onChange({ ...query, labels });
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
/**
|
/**
|
||||||
* Map metric metadata to SelectableValue for Select component and also adds defined template variables to the list.
|
* Map metric metadata to SelectableValue for Select component and also adds defined template variables to the list.
|
||||||
*/
|
*/
|
||||||
@@ -208,46 +202,12 @@ export const PromQueryBuilder = React.memo<Props>((props) => {
|
|||||||
}, [datasource, query, withTemplateVariableOptions]);
|
}, [datasource, query, withTemplateVariableOptions]);
|
||||||
|
|
||||||
const lang = { grammar: promqlGrammar, name: 'promql' };
|
const lang = { grammar: promqlGrammar, name: 'promql' };
|
||||||
const isMetricEncyclopediaEnabled = config.featureToggles.prometheusMetricEncyclopedia;
|
|
||||||
|
|
||||||
const initHints = datasource.getInitHints();
|
const initHints = datasource.getInitHints();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EditorRow>
|
<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
|
<MetricSelect
|
||||||
query={query}
|
query={query}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -256,7 +216,6 @@ export const PromQueryBuilder = React.memo<Props>((props) => {
|
|||||||
labelsFilters={query.labels}
|
labelsFilters={query.labels}
|
||||||
metricLookupDisabled={datasource.lookupsDisabled}
|
metricLookupDisabled={datasource.lookupsDisabled}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<LabelFilters
|
<LabelFilters
|
||||||
debounceDuration={datasource.getDebounceTimeInMilliseconds()}
|
debounceDuration={datasource.getDebounceTimeInMilliseconds()}
|
||||||
getLabelValuesAutofillSuggestions={getLabelValuesAutocompleteSuggestions}
|
getLabelValuesAutofillSuggestions={getLabelValuesAutocompleteSuggestions}
|
||||||
@@ -365,15 +324,3 @@ async function getMetrics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
PromQueryBuilder.displayName = 'PromQueryBuilder';
|
PromQueryBuilder.displayName = 'PromQueryBuilder';
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
|
||||||
return {
|
|
||||||
button: css`
|
|
||||||
height: auto;
|
|
||||||
`,
|
|
||||||
metricTag: css`
|
|
||||||
margin: '10px 0 10px 0',
|
|
||||||
backgroundColor: '#3D71D9',
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
+31
-3
@@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
|||||||
import React, { useEffect, useReducer } from 'react';
|
import React, { useEffect, useReducer } from 'react';
|
||||||
|
|
||||||
import { PanelData } from '@grafana/data';
|
import { PanelData } from '@grafana/data';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
import { PrometheusDatasource } from '../../datasource';
|
import { PrometheusDatasource } from '../../datasource';
|
||||||
import { PromQuery } from '../../types';
|
import { PromQuery } from '../../types';
|
||||||
@@ -11,6 +12,7 @@ import { PromVisualQuery } from '../types';
|
|||||||
|
|
||||||
import { PromQueryBuilder } from './PromQueryBuilder';
|
import { PromQueryBuilder } from './PromQueryBuilder';
|
||||||
import { QueryPreview } from './QueryPreview';
|
import { QueryPreview } from './QueryPreview';
|
||||||
|
import { getSettings, MetricsModalSettings } from './metrics-modal/state/state';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
query: PromQuery;
|
query: PromQuery;
|
||||||
@@ -26,22 +28,39 @@ export interface State {
|
|||||||
expr: string;
|
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.
|
* This component is here just to contain the translation logic between string query and the visual query builder model.
|
||||||
*/
|
*/
|
||||||
export function PromQueryBuilderContainer(props: Props) {
|
export function PromQueryBuilderContainer(props: Props) {
|
||||||
const { query, onChange, onRunQuery, datasource, data, showExplain } = props;
|
const { query, onChange, onRunQuery, datasource, data, showExplain } = props;
|
||||||
const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr });
|
const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr });
|
||||||
|
|
||||||
// Only rebuild visual query if expr changes from outside
|
// Only rebuild visual query if expr changes from outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(exprChanged(query.expr));
|
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 onVisQueryChange = (visQuery: PromVisualQuery) => {
|
||||||
const expr = promQueryModeller.renderQuery(visQuery);
|
const expr = promQueryModeller.renderQuery(visQuery);
|
||||||
dispatch(visualQueryChange({ visQuery, expr }));
|
dispatch(visualQueryChange({ visQuery, expr }));
|
||||||
|
|
||||||
|
if (prometheusMetricEncyclopedia) {
|
||||||
|
const metricsModalSettings = getSettings(visQuery);
|
||||||
|
onChange({ ...props.query, expr: expr, ...metricsModalSettings });
|
||||||
|
} else {
|
||||||
onChange({ ...props.query, expr: expr });
|
onChange({ ...props.query, expr: expr });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!state.visQuery) {
|
if (!state.visQuery) {
|
||||||
@@ -75,10 +94,19 @@ const stateSlice = createSlice({
|
|||||||
if (!state.visQuery || state.expr !== action.payload) {
|
if (!state.visQuery || state.expr !== action.payload) {
|
||||||
state.expr = action.payload;
|
state.expr = action.payload;
|
||||||
const parseResult = buildVisualQueryFromString(action.payload);
|
const parseResult = buildVisualQueryFromString(action.payload);
|
||||||
|
|
||||||
state.visQuery = parseResult.query;
|
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;
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ export function FeedbackLink({ feedbackUrl }: Props) {
|
|||||||
<a
|
<a
|
||||||
href={feedbackUrl}
|
href={feedbackUrl}
|
||||||
className={styles.link}
|
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"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
+71
@@ -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;
|
||||||
|
}
|
||||||
+24
-32
@@ -4,13 +4,13 @@ import React from 'react';
|
|||||||
|
|
||||||
import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data';
|
import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data';
|
||||||
|
|
||||||
import { PrometheusDatasource } from '../../datasource';
|
import { PrometheusDatasource } from '../../../datasource';
|
||||||
import PromQlLanguageProvider from '../../language_provider';
|
import PromQlLanguageProvider from '../../../language_provider';
|
||||||
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
|
import { EmptyLanguageProviderMock } from '../../../language_provider.mock';
|
||||||
import { PromOptions } from '../../types';
|
import { PromOptions } from '../../../types';
|
||||||
import { PromVisualQuery } 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
|
// don't care about interaction tracking in our unit tests
|
||||||
jest.mock('@grafana/runtime', () => ({
|
jest.mock('@grafana/runtime', () => ({
|
||||||
@@ -18,7 +18,7 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
reportInteraction: jest.fn(),
|
reportInteraction: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('MetricEncyclopediaModal', () => {
|
describe('MetricsModal', () => {
|
||||||
it('renders the modal', async () => {
|
it('renders the modal', async () => {
|
||||||
setup(defaultQuery, listOfMetrics);
|
setup(defaultQuery, listOfMetrics);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -46,7 +46,7 @@ describe('MetricEncyclopediaModal', () => {
|
|||||||
operations: [],
|
operations: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
setup(query, listOfMetrics);
|
setup(query, ['with-labels'], true);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('with-labels')).toBeInTheDocument();
|
expect(screen.getByText('with-labels')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -102,18 +102,9 @@ describe('MetricEncyclopediaModal', () => {
|
|||||||
expect(metricStartingWithSomethingElse).toBeNull();
|
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
|
// Pagination
|
||||||
it('shows metrics within a range by pagination', async () => {
|
it('shows metrics within a range by pagination', async () => {
|
||||||
// default resultsPerPage is 10
|
// default resultsPerPage is 100
|
||||||
setup(defaultQuery, listOfMetrics);
|
setup(defaultQuery, listOfMetrics);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('all-metrics')).toBeInTheDocument();
|
expect(screen.getByText('all-metrics')).toBeInTheDocument();
|
||||||
@@ -146,9 +137,9 @@ describe('MetricEncyclopediaModal', () => {
|
|||||||
expect(metricInsideRange).toBeInTheDocument();
|
expect(metricInsideRange).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('paginates millions of metrics and does not run out of memory', async () => {
|
it('paginates lots of metrics and does not run out of memory', async () => {
|
||||||
const millionsOfMetrics: string[] = [...Array(1000000).keys()].map((i) => '' + i);
|
const lotsOfMetrics: string[] = [...Array(100000).keys()].map((i) => '' + i);
|
||||||
setup(defaultQuery, millionsOfMetrics);
|
setup(defaultQuery, lotsOfMetrics);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// doesn't break on loading
|
// doesn't break on loading
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
expect(screen.getByText('0')).toBeInTheDocument();
|
||||||
@@ -156,7 +147,7 @@ describe('MetricEncyclopediaModal', () => {
|
|||||||
const resultsPerPageInput = screen.getByTestId(testIds.resultsPerPage);
|
const resultsPerPageInput = screen.getByTestId(testIds.resultsPerPage);
|
||||||
// doesn't break on changing results per page
|
// doesn't break on changing results per page
|
||||||
await userEvent.type(resultsPerPageInput, '11');
|
await userEvent.type(resultsPerPageInput, '11');
|
||||||
const metricInsideRange = screen.getByText('10');
|
const metricInsideRange = screen.getByText('9');
|
||||||
expect(metricInsideRange).toBeInTheDocument();
|
expect(metricInsideRange).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,11 +165,9 @@ describe('MetricEncyclopediaModal', () => {
|
|||||||
});
|
});
|
||||||
const searchMetric = screen.getByTestId(testIds.searchMetric);
|
const searchMetric = screen.getByTestId(testIds.searchMetric);
|
||||||
expect(searchMetric).toBeInTheDocument();
|
expect(searchMetric).toBeInTheDocument();
|
||||||
await userEvent.type(searchMetric, 'a_b');
|
await userEvent.type(searchMetric, 'a_buck');
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
metricABucket = screen.getByText('a_bucket');
|
|
||||||
expect(metricABucket).toBeInTheDocument();
|
|
||||||
metricAll = screen.queryByText('all-metrics');
|
metricAll = screen.queryByText('all-metrics');
|
||||||
expect(metricAll).toBeNull();
|
expect(metricAll).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -194,6 +183,10 @@ describe('MetricEncyclopediaModal', () => {
|
|||||||
expect(metricABucket).toBeInTheDocument();
|
expect(metricABucket).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showSettingsButton = screen.getByTestId(testIds.showAdditionalSettings);
|
||||||
|
expect(showSettingsButton).toBeInTheDocument();
|
||||||
|
await userEvent.click(showSettingsButton);
|
||||||
|
|
||||||
const metadataSwitch = screen.getByTestId(testIds.searchWithMetadata);
|
const metadataSwitch = screen.getByTestId(testIds.searchWithMetadata);
|
||||||
expect(metadataSwitch).toBeInTheDocument();
|
expect(metadataSwitch).toBeInTheDocument();
|
||||||
await userEvent.click(metadataSwitch);
|
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'];
|
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;
|
const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider;
|
||||||
|
|
||||||
// display different results if their are labels selected in the PromVisualQuery
|
// display different results if their are labels selected in the PromVisualQuery
|
||||||
if (withLabels) {
|
if (withLabels) {
|
||||||
languageProvider.getSeries = () => Promise.resolve({ __name__: ['with-labels'] });
|
|
||||||
languageProvider.metricsMetadata = {
|
languageProvider.metricsMetadata = {
|
||||||
'with-labels': {
|
'with-labels': {
|
||||||
type: 'with-labels-type',
|
type: 'with-labels-type',
|
||||||
@@ -231,7 +223,6 @@ function createDatasource(metrics: string[], withLabels?: boolean) {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// all metrics
|
// all metrics
|
||||||
languageProvider.getLabelValues = () => Promise.resolve(metrics);
|
|
||||||
languageProvider.metricsMetadata = {
|
languageProvider.metricsMetadata = {
|
||||||
'all-metrics': {
|
'all-metrics': {
|
||||||
type: 'all-metrics-type',
|
type: 'all-metrics-type',
|
||||||
@@ -262,23 +253,24 @@ function createDatasource(metrics: string[], withLabels?: boolean) {
|
|||||||
return datasource;
|
return datasource;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createProps(query: PromVisualQuery, datasource: PrometheusDatasource) {
|
function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, metrics: string[]) {
|
||||||
return {
|
return {
|
||||||
datasource,
|
datasource,
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
onChange: jest.fn(),
|
onChange: jest.fn(),
|
||||||
onClose: jest.fn(),
|
onClose: jest.fn(),
|
||||||
query: query,
|
query: query,
|
||||||
|
initialMetrics: metrics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) {
|
function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) {
|
||||||
const withLabels: boolean = query.labels.length > 0;
|
const withLabels: boolean = query.labels.length > 0;
|
||||||
const datasource = createDatasource(metrics, withLabels);
|
const datasource = createDatasource(withLabels);
|
||||||
const props = createProps(query, datasource);
|
const props = createProps(query, datasource, metrics);
|
||||||
|
|
||||||
// render the modal only
|
// render the modal only
|
||||||
const { container } = render(<MetricEncyclopediaModal {...props} />);
|
const { container } = render(<MetricsModal {...props} />);
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
+402
@@ -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',
|
||||||
|
};
|
||||||
+170
@@ -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};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
||||||
+213
@@ -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',
|
||||||
|
};
|
||||||
+209
@@ -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;
|
||||||
|
};
|
||||||
+123
@@ -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;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
||||||
+30
@@ -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[][];
|
||||||
|
};
|
||||||
+43
@@ -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[];
|
labels: QueryBuilderLabelFilter[];
|
||||||
operations: QueryBuilderOperation[];
|
operations: QueryBuilderOperation[];
|
||||||
binaryQueries?: PromVisualQueryBinary[];
|
binaryQueries?: PromVisualQueryBinary[];
|
||||||
|
// metrics modal additional settings
|
||||||
|
useBackend?: boolean;
|
||||||
|
disableTextWrap?: boolean;
|
||||||
|
excludeNullMetadata?: boolean;
|
||||||
|
fullMetaSearch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromVisualQueryBinary = VisualQueryBinary<PromVisualQuery>;
|
export type PromVisualQueryBinary = VisualQueryBinary<PromVisualQuery>;
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export interface PromQuery extends GenPromQuery, DataQuery {
|
|||||||
hinting?: boolean;
|
hinting?: boolean;
|
||||||
interval?: string;
|
interval?: string;
|
||||||
intervalFactor?: number;
|
intervalFactor?: number;
|
||||||
|
// store the metrics modal additional settings
|
||||||
|
useBackend?: boolean;
|
||||||
|
disableTextWrap?: boolean;
|
||||||
|
fullMetaSearch?: boolean;
|
||||||
|
excludeNullMetadata?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PrometheusCacheLevel {
|
export enum PrometheusCacheLevel {
|
||||||
|
|||||||
Reference in New Issue
Block a user