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:
Brendan O'Handley 2023-05-01 17:29:35 -04:00 committed by GitHub
parent 0d52d19e21
commit d31d1576fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1475 additions and 981 deletions

View File

@ -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',
];

View File

@ -5,13 +5,17 @@ import Highlighter from 'react-highlight-words';
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { EditorField, EditorFieldGroup } from '@grafana/experimental';
import { AsyncSelect, FormatOptionLabelMeta, useStyles2 } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { AsyncSelect, Button, FormatOptionLabelMeta, useStyles2 } from '@grafana/ui';
import { SelectMenuOptions } from '@grafana/ui/src/components/Select/SelectMenu';
import { PrometheusDatasource } from '../../datasource';
import { regexifyLabelValuesQueryString } from '../shared/parsingUtils';
import { QueryBuilderLabelFilter } from '../shared/types';
import { PromVisualQuery } from '../types';
import { MetricsModal } from './metrics-modal/MetricsModal';
// We are matching words split with space
const splitSeparator = ' ';
@ -26,6 +30,8 @@ export interface Props {
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000;
const prometheusMetricEncyclopedia = config.featureToggles.prometheusMetricEncyclopedia;
export function MetricSelect({
datasource,
query,
@ -38,6 +44,8 @@ export function MetricSelect({
const [state, setState] = useState<{
metrics?: Array<SelectableValue<any>>;
isLoading?: boolean;
metricsModalOpen?: boolean;
initialMetrics?: string[];
}>({});
const customFilterOption = useCallback((option: SelectableValue<any>, searchQuery: string) => {
@ -129,40 +137,118 @@ export function MetricSelect({
(query: string) => getMetricLabels(query),
datasource.getDebounceTimeInMilliseconds()
);
// No type found for the common select props so typing as any
// https://github.com/grafana/grafana/blob/main/packages/grafana-ui/src/components/Select/SelectBase.tsx/#L212-L263
// eslint-disable-next-line
const CustomOption = (props: any) => {
const option = props.data;
if (option.value === 'BrowseMetrics') {
const isFocused = props.isFocused ? styles.focus : '';
return (
<div
{...props.innerProps}
onKeyDown={(e) => {
// if there is no metric and the m.e. is enabled, open the modal
if (e.code === 'Enter') {
setState({ ...state, metricsModalOpen: true });
}
}}
>
{
<div className={`${styles.customOption} ${isFocused}`}>
<div>
<div>{option.label}</div>
<div className={styles.customOptionDesc}>{option.description}</div>
</div>
<Button
variant="primary"
fill="outline"
size="sm"
onClick={() => setState({ ...state, metricsModalOpen: true })}
icon="book"
>
Open
</Button>
</div>
}
</div>
);
}
return SelectMenuOptions(props);
};
return (
<EditorFieldGroup>
<EditorField label="Metric">
<AsyncSelect
inputId="prometheus-metric-select"
className={styles.select}
value={query.metric ? toOption(query.metric) : undefined}
placeholder={'Select metric'}
allowCustomValue
formatOptionLabel={formatOptionLabel}
filterOption={customFilterOption}
onOpenMenu={async () => {
if (metricLookupDisabled) {
return;
}
setState({ isLoading: true });
const metrics = await onGetMetrics();
if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) {
metrics.splice(0, metrics.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
}
setState({ metrics, isLoading: undefined });
}}
loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch}
isLoading={state.isLoading}
defaultOptions={state.metrics}
onChange={({ value }) => {
if (value) {
onChange({ ...query, metric: value });
}
}}
<>
{prometheusMetricEncyclopedia && !datasource.lookupsDisabled && state.metricsModalOpen && (
<MetricsModal
datasource={datasource}
isOpen={state.metricsModalOpen}
onClose={() => setState({ ...state, metricsModalOpen: false })}
query={query}
onChange={onChange}
initialMetrics={state.initialMetrics ?? []}
/>
</EditorField>
</EditorFieldGroup>
)}
<EditorFieldGroup>
<EditorField label="Metric">
<AsyncSelect
inputId="prometheus-metric-select"
className={styles.select}
value={query.metric ? toOption(query.metric) : undefined}
placeholder={'Select metric'}
allowCustomValue
formatOptionLabel={formatOptionLabel}
filterOption={customFilterOption}
onOpenMenu={async () => {
if (metricLookupDisabled) {
return;
}
setState({ isLoading: true });
const metrics = await onGetMetrics();
const initialMetrics: string[] = metrics.map((m) => m.value);
if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) {
metrics.splice(0, metrics.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
}
if (config.featureToggles.prometheusMetricEncyclopedia) {
// pass the initial metrics, possibly filtered by labels into the Metrics Modal
const metricsModalOption: SelectableValue[] = [
{
value: 'BrowseMetrics',
label: 'Browse metrics',
description: 'Browse and filter metrics and metadata with a fuzzy search',
},
];
setState({
metrics: [...metricsModalOption, ...metrics],
isLoading: undefined,
initialMetrics: initialMetrics,
});
} else {
setState({ metrics, isLoading: undefined });
}
}}
loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch}
isLoading={state.isLoading}
defaultOptions={state.metrics}
onChange={({ value }) => {
if (value) {
// if there is no metric and the m.e. is enabled, open the modal
if (prometheusMetricEncyclopedia && value === 'BrowseMetrics') {
setState({ ...state, metricsModalOpen: true });
} else {
onChange({ ...query, metric: value });
}
}
}}
components={{ Option: CustomOption }}
/>
</EditorField>
</EditorFieldGroup>
</>
);
}
@ -177,4 +263,24 @@ const getStyles = (theme: GrafanaTheme2) => ({
color: ${theme.colors.warning.contrastText};
background-color: ${theme.colors.warning.main};
`,
customOption: css`
padding: 8px;
display: flex;
justify-content: space-between;
cursor: pointer;
:hover {
background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)};
}
`,
customOptionlabel: css`
color: ${theme.colors.text.primary};
`,
customOptionDesc: css`
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.size.xs};
opacity: 50%;
`,
focus: css`
background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)};
`,
});

View File

@ -1,10 +1,7 @@
import { css } from '@emotion/css';
import React, { useCallback, useState } from 'react';
import { DataSourceApi, GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data';
import { DataSourceApi, PanelData, SelectableValue } from '@grafana/data';
import { EditorRow } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Button, Tag, useStyles2 } from '@grafana/ui';
import { PrometheusDatasource } from '../../datasource';
import { getMetadataString } from '../../language_provider';
@ -22,7 +19,6 @@ import { QueryBuilderLabelFilter, QueryBuilderOperation } from '../shared/types'
import { PromVisualQuery } from '../types';
import { LabelFilters } from './LabelFilters';
import { MetricEncyclopediaModal } from './MetricEncyclopediaModal';
import { MetricSelect, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './MetricSelect';
import { NestedQueryList } from './NestedQueryList';
import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained';
@ -39,12 +35,10 @@ export interface Props {
export const PromQueryBuilder = React.memo<Props>((props) => {
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
const [metricEncyclopediaModalOpen, setMetricEncyclopediaModalOpen] = useState(false);
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => {
onChange({ ...query, labels });
};
const styles = useStyles2(getStyles);
/**
* Map metric metadata to SelectableValue for Select component and also adds defined template variables to the list.
*/
@ -208,55 +202,20 @@ export const PromQueryBuilder = React.memo<Props>((props) => {
}, [datasource, query, withTemplateVariableOptions]);
const lang = { grammar: promqlGrammar, name: 'promql' };
const isMetricEncyclopediaEnabled = config.featureToggles.prometheusMetricEncyclopedia;
const initHints = datasource.getInitHints();
return (
<>
<EditorRow>
{isMetricEncyclopediaEnabled && !datasource.lookupsDisabled ? (
<>
<Button
className={styles.button}
variant="secondary"
size="sm"
onClick={() => setMetricEncyclopediaModalOpen((prevValue) => !prevValue)}
>
Metric encyclopedia
</Button>
{query.metric && (
<Tag
name={' ' + query.metric}
color="#3D71D9"
icon="times"
onClick={() => {
onChange({ ...query, metric: '' });
}}
title="Click to remove metric"
className={styles.metricTag}
/>
)}
{metricEncyclopediaModalOpen && (
<MetricEncyclopediaModal
datasource={datasource}
isOpen={metricEncyclopediaModalOpen}
onClose={() => setMetricEncyclopediaModalOpen(false)}
query={query}
onChange={onChange}
/>
)}
</>
) : (
<MetricSelect
query={query}
onChange={onChange}
onGetMetrics={onGetMetrics}
datasource={datasource}
labelsFilters={query.labels}
metricLookupDisabled={datasource.lookupsDisabled}
/>
)}
<MetricSelect
query={query}
onChange={onChange}
onGetMetrics={onGetMetrics}
datasource={datasource}
labelsFilters={query.labels}
metricLookupDisabled={datasource.lookupsDisabled}
/>
<LabelFilters
debounceDuration={datasource.getDebounceTimeInMilliseconds()}
getLabelValuesAutofillSuggestions={getLabelValuesAutocompleteSuggestions}
@ -365,15 +324,3 @@ async function getMetrics(
}
PromQueryBuilder.displayName = 'PromQueryBuilder';
const getStyles = (theme: GrafanaTheme2) => {
return {
button: css`
height: auto;
`,
metricTag: css`
margin: '10px 0 10px 0',
backgroundColor: '#3D71D9',
`,
};
};

View File

@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import React, { useEffect, useReducer } from 'react';
import { PanelData } from '@grafana/data';
import { config } from '@grafana/runtime';
import { PrometheusDatasource } from '../../datasource';
import { PromQuery } from '../../types';
@ -11,6 +12,7 @@ import { PromVisualQuery } from '../types';
import { PromQueryBuilder } from './PromQueryBuilder';
import { QueryPreview } from './QueryPreview';
import { getSettings, MetricsModalSettings } from './metrics-modal/state/state';
export interface Props {
query: PromQuery;
@ -26,22 +28,39 @@ export interface State {
expr: string;
}
const prometheusMetricEncyclopedia = config.featureToggles.prometheusMetricEncyclopedia;
/**
* This component is here just to contain the translation logic between string query and the visual query builder model.
*/
export function PromQueryBuilderContainer(props: Props) {
const { query, onChange, onRunQuery, datasource, data, showExplain } = props;
const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr });
// Only rebuild visual query if expr changes from outside
useEffect(() => {
dispatch(exprChanged(query.expr));
}, [query.expr]);
if (prometheusMetricEncyclopedia) {
dispatch(
setMetricsModalSettings({
useBackend: query.useBackend ?? false,
disableTextWrap: query.disableTextWrap ?? false,
fullMetaSearch: query.fullMetaSearch ?? false,
excludeNullMetadata: query.excludeNullMetadata ?? false,
})
);
}
}, [query]);
const onVisQueryChange = (visQuery: PromVisualQuery) => {
const expr = promQueryModeller.renderQuery(visQuery);
dispatch(visualQueryChange({ visQuery, expr }));
onChange({ ...props.query, expr: expr });
if (prometheusMetricEncyclopedia) {
const metricsModalSettings = getSettings(visQuery);
onChange({ ...props.query, expr: expr, ...metricsModalSettings });
} else {
onChange({ ...props.query, expr: expr });
}
};
if (!state.visQuery) {
@ -75,10 +94,19 @@ const stateSlice = createSlice({
if (!state.visQuery || state.expr !== action.payload) {
state.expr = action.payload;
const parseResult = buildVisualQueryFromString(action.payload);
state.visQuery = parseResult.query;
}
},
setMetricsModalSettings: (state, action: PayloadAction<MetricsModalSettings>) => {
if (state.visQuery && prometheusMetricEncyclopedia) {
state.visQuery.useBackend = action.payload.useBackend;
state.visQuery.disableTextWrap = action.payload.disableTextWrap;
state.visQuery.fullMetaSearch = action.payload.fullMetaSearch;
state.visQuery.excludeNullMetadata = action.payload.excludeNullMetadata;
}
},
},
});
const { visualQueryChange, exprChanged } = stateSlice.actions;
const { visualQueryChange, exprChanged, setMetricsModalSettings } = stateSlice.actions;

View File

@ -17,7 +17,7 @@ export function FeedbackLink({ feedbackUrl }: Props) {
<a
href={feedbackUrl}
className={styles.link}
title="The Metric Encyclopedia is new, please let us know how we can improve it"
title="The Metrics Modal is new, please let us know how we can improve it"
target="_blank"
rel="noreferrer noopener"
>

View File

@ -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;
}

View File

@ -4,13 +4,13 @@ import React from 'react';
import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import PromQlLanguageProvider from '../../language_provider';
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
import { PromOptions } from '../../types';
import { PromVisualQuery } from '../types';
import { PrometheusDatasource } from '../../../datasource';
import PromQlLanguageProvider from '../../../language_provider';
import { EmptyLanguageProviderMock } from '../../../language_provider.mock';
import { PromOptions } from '../../../types';
import { PromVisualQuery } from '../../types';
import { MetricEncyclopediaModal, testIds } from './MetricEncyclopediaModal';
import { MetricsModal, testIds } from './MetricsModal';
// don't care about interaction tracking in our unit tests
jest.mock('@grafana/runtime', () => ({
@ -18,7 +18,7 @@ jest.mock('@grafana/runtime', () => ({
reportInteraction: jest.fn(),
}));
describe('MetricEncyclopediaModal', () => {
describe('MetricsModal', () => {
it('renders the modal', async () => {
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
@ -46,7 +46,7 @@ describe('MetricEncyclopediaModal', () => {
operations: [],
};
setup(query, listOfMetrics);
setup(query, ['with-labels'], true);
await waitFor(() => {
expect(screen.getByText('with-labels')).toBeInTheDocument();
});
@ -102,18 +102,9 @@ describe('MetricEncyclopediaModal', () => {
expect(metricStartingWithSomethingElse).toBeNull();
});
it('allows a user to select a template variable', async () => {
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
const selectType = screen.getByText('Select template variables');
expect(selectType).toBeInTheDocument();
});
});
// Pagination
it('shows metrics within a range by pagination', async () => {
// default resultsPerPage is 10
// default resultsPerPage is 100
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
expect(screen.getByText('all-metrics')).toBeInTheDocument();
@ -146,9 +137,9 @@ describe('MetricEncyclopediaModal', () => {
expect(metricInsideRange).toBeInTheDocument();
});
it('paginates millions of metrics and does not run out of memory', async () => {
const millionsOfMetrics: string[] = [...Array(1000000).keys()].map((i) => '' + i);
setup(defaultQuery, millionsOfMetrics);
it('paginates lots of metrics and does not run out of memory', async () => {
const lotsOfMetrics: string[] = [...Array(100000).keys()].map((i) => '' + i);
setup(defaultQuery, lotsOfMetrics);
await waitFor(() => {
// doesn't break on loading
expect(screen.getByText('0')).toBeInTheDocument();
@ -156,7 +147,7 @@ describe('MetricEncyclopediaModal', () => {
const resultsPerPageInput = screen.getByTestId(testIds.resultsPerPage);
// doesn't break on changing results per page
await userEvent.type(resultsPerPageInput, '11');
const metricInsideRange = screen.getByText('10');
const metricInsideRange = screen.getByText('9');
expect(metricInsideRange).toBeInTheDocument();
});
@ -174,11 +165,9 @@ describe('MetricEncyclopediaModal', () => {
});
const searchMetric = screen.getByTestId(testIds.searchMetric);
expect(searchMetric).toBeInTheDocument();
await userEvent.type(searchMetric, 'a_b');
await userEvent.type(searchMetric, 'a_buck');
await waitFor(() => {
metricABucket = screen.getByText('a_bucket');
expect(metricABucket).toBeInTheDocument();
metricAll = screen.queryByText('all-metrics');
expect(metricAll).toBeNull();
});
@ -194,6 +183,10 @@ describe('MetricEncyclopediaModal', () => {
expect(metricABucket).toBeInTheDocument();
});
const showSettingsButton = screen.getByTestId(testIds.showAdditionalSettings);
expect(showSettingsButton).toBeInTheDocument();
await userEvent.click(showSettingsButton);
const metadataSwitch = screen.getByTestId(testIds.searchWithMetadata);
expect(metadataSwitch).toBeInTheDocument();
await userEvent.click(metadataSwitch);
@ -217,12 +210,11 @@ const defaultQuery: PromVisualQuery = {
const listOfMetrics: string[] = ['all-metrics', 'a_bucket', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
function createDatasource(metrics: string[], withLabels?: boolean) {
function createDatasource(withLabels?: boolean) {
const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider;
// display different results if their are labels selected in the PromVisualQuery
if (withLabels) {
languageProvider.getSeries = () => Promise.resolve({ __name__: ['with-labels'] });
languageProvider.metricsMetadata = {
'with-labels': {
type: 'with-labels-type',
@ -231,7 +223,6 @@ function createDatasource(metrics: string[], withLabels?: boolean) {
};
} else {
// all metrics
languageProvider.getLabelValues = () => Promise.resolve(metrics);
languageProvider.metricsMetadata = {
'all-metrics': {
type: 'all-metrics-type',
@ -262,23 +253,24 @@ function createDatasource(metrics: string[], withLabels?: boolean) {
return datasource;
}
function createProps(query: PromVisualQuery, datasource: PrometheusDatasource) {
function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, metrics: string[]) {
return {
datasource,
isOpen: true,
onChange: jest.fn(),
onClose: jest.fn(),
query: query,
initialMetrics: metrics,
};
}
function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) {
const withLabels: boolean = query.labels.length > 0;
const datasource = createDatasource(metrics, withLabels);
const props = createProps(query, datasource);
const datasource = createDatasource(withLabels);
const props = createProps(query, datasource, metrics);
// render the modal only
const { container } = render(<MetricEncyclopediaModal {...props} />);
const { container } = render(<MetricsModal {...props} />);
return container;
}

View File

@ -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',
};

View File

@ -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};
`,
};
};

View File

@ -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',
};

View File

@ -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;
};

View File

@ -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;
`,
};
};

View File

@ -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[][];
};

View File

@ -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);

View File

@ -9,6 +9,11 @@ export interface PromVisualQuery {
labels: QueryBuilderLabelFilter[];
operations: QueryBuilderOperation[];
binaryQueries?: PromVisualQueryBinary[];
// metrics modal additional settings
useBackend?: boolean;
disableTextWrap?: boolean;
excludeNullMetadata?: boolean;
fullMetaSearch?: boolean;
}
export type PromVisualQueryBinary = VisualQueryBinary<PromVisualQuery>;

View File

@ -18,6 +18,11 @@ export interface PromQuery extends GenPromQuery, DataQuery {
hinting?: boolean;
interval?: string;
intervalFactor?: number;
// store the metrics modal additional settings
useBackend?: boolean;
disableTextWrap?: boolean;
fullMetaSearch?: boolean;
excludeNullMetadata?: boolean;
}
export enum PrometheusCacheLevel {