diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.tsx deleted file mode 100644 index 4c336acf866..00000000000 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.tsx +++ /dev/null @@ -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>) { - 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>>([]); - - const [isLoading, setIsLoading] = useState(true); - - // metric list - const [metrics, setMetrics] = useState([]); - const [hasMetadata, setHasMetadata] = useState(true); - const [metaHaystack, setMetaHaystack] = useState([]); - const [nameHaystack, setNameHaystack] = useState([]); - - // pagination - const [resultsPerPage, setResultsPerPage] = useState(DEFAULT_RESULTS_PER_PAGE); - const [pageNum, setPageNum] = useState(1); - - // filters - const [fuzzySearchQuery, setFuzzySearchQuery] = useState(''); - const [fuzzyMetaSearchResults, setFuzzyMetaSearchResults] = useState([]); - const [fuzzyNameSearchResults, setFuzzyNameSearchResults] = useState([]); - const [fullMetaSearch, setFullMetaSearch] = useState(false); - const [excludeNullMetadata, setExcludeNullMetadata] = useState(false); - const [selectedTypes, setSelectedTypes] = useState>>([]); - const [letterSearch, setLetterSearch] = useState(null); - - const [totalMetricCount, setTotalMetricCount] = useState(0); - const [filteredMetricCount, setFilteredMetricCount] = useState(); - // backend search metric names by text - const [useBackend, setUseBackend] = useState(false); - const [disableTextWrap, setDisableTextWrap] = useState(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 ( - {}} - className={`${selectedClass} ${activeClass}`} - key={letter} - data-testid={'letter-' + letter} - > - {letter + ' '} - {/* {idx !== coll.length - 1 ? '|': ''} */} - - ); - }); - } - - 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) => { - return ( - - ); - }; - - function tableResults(metrics: MetricsData) { - const tableData: MetricsData = metrics; - - const columns: Array> = [ - { id: '', header: 'Select', cell: ButtonCell }, - { id: 'value', header: 'Name' }, - { id: 'type', header: 'Type' }, - { id: 'description', header: 'Description' }, - ]; - - return 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 ( - -
-
- - { - const value = e.currentTarget.value ?? ''; - setFuzzySearchQuery(value); - - fuzzySearchCallback(value, fullMetaSearch); - - setPageNum(1); - }} - /> - -
-
- - { - // *** 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); - }} - /> - -
-
- - { - return { value: p, label: '' + p }; - })} - value={pageNum ?? 1} - placeholder="select page" - width={20} - onChange={(e) => { - const value = e.value ?? 1; - setPageNum(value); - }} - /> - - - - { - const value = +e.currentTarget.value; - - if (isNaN(value)) { - return; - } - - setResultsPerPage(value); - }} - /> - -
- - -
-
- ); -}; - -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', -]; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx index 74c48ef49cc..02611841f5d 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx @@ -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>; isLoading?: boolean; + metricsModalOpen?: boolean; + initialMetrics?: string[]; }>({}); const customFilterOption = useCallback((option: SelectableValue, 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 ( +
{ + // if there is no metric and the m.e. is enabled, open the modal + if (e.code === 'Enter') { + setState({ ...state, metricsModalOpen: true }); + } + }} + > + { +
+
+
{option.label}
+
{option.description}
+
+ +
+ } +
+ ); + } + + return SelectMenuOptions(props); + }; return ( - - - { - 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 && ( + setState({ ...state, metricsModalOpen: false })} + query={query} + onChange={onChange} + initialMetrics={state.initialMetrics ?? []} /> - - + )} + + + { + 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 }} + /> + + + ); } @@ -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)}; + `, }); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx index d56c50722da..84fd29920f4 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx @@ -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) => { const { datasource, query, onChange, onRunQuery, data, showExplain } = props; const [highlightedOp, setHighlightedOp] = useState(); - 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) => { }, [datasource, query, withTemplateVariableOptions]); const lang = { grammar: promqlGrammar, name: 'promql' }; - const isMetricEncyclopediaEnabled = config.featureToggles.prometheusMetricEncyclopedia; const initHints = datasource.getInitHints(); return ( <> - {isMetricEncyclopediaEnabled && !datasource.lookupsDisabled ? ( - <> - - {query.metric && ( - { - onChange({ ...query, metric: '' }); - }} - title="Click to remove metric" - className={styles.metricTag} - /> - )} - {metricEncyclopediaModalOpen && ( - setMetricEncyclopediaModalOpen(false)} - query={query} - onChange={onChange} - /> - )} - - ) : ( - - )} + { - return { - button: css` - height: auto; - `, - metricTag: css` - margin: '10px 0 10px 0', - backgroundColor: '#3D71D9', - `, - }; -}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx index 05d84bc6fc4..2e587f2f020 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx @@ -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) => { + 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; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/FeedbackLink.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/FeedbackLink.tsx similarity index 90% rename from public/app/plugins/datasource/prometheus/querybuilder/components/FeedbackLink.tsx rename to public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/FeedbackLink.tsx index ae3916a0f51..012538895b2 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/FeedbackLink.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/FeedbackLink.tsx @@ -17,7 +17,7 @@ export function FeedbackLink({ feedbackUrl }: Props) { diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/LetterSearch.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/LetterSearch.tsx new file mode 100644 index 00000000000..cda6c972055 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/LetterSearch.tsx @@ -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 ( +
+ {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 ( + {}} + className={`${selectedClass} ${activeClass}`} + key={letter} + data-testid={'letter-' + letter} + > + {letter + ' '} + {/* {idx !== coll.length - 1 ? '|': ''} */} + + ); + })} +
+ ); +} + +export const alphabet = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ']; + +function alphabetCheck(): { [char: string]: number } { + const check: { [char: string]: number } = {}; + + alphabet.forEach((char) => (check[char] = 0)); + + return check; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx similarity index 83% rename from public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.test.tsx rename to public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx index b01d2b8f38c..e5e6ddbc677 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx @@ -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(); + const { container } = render(); return container; } diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx new file mode 100644 index 00000000000..4b5b857ecec --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -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) { + 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 ( + +
+
+ + { + const value = e.currentTarget.value ?? ''; + dispatch(setFuzzySearchQuery(value)); + + fuzzySearchCallback(value, state.fullMetaSearch); + }} + onKeyDown={(e) => { + keyFunction(e); + }} + /> + +
+
+ + { + // *** 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)); + }} + /> + +
+
+ {/*

Results

*/} +
+
+ Showing {state.filteredMetricCount} of {state.totalMetricCount} results.{' '} + +
+
+ { + if (state.letterSearch === letter) { + dispatch(setLetterSearch('')); + } else { + dispatch(setLetterSearch(letter)); + } + }} + letterSearch={state.letterSearch} + /> + +
+ {state.showAdditionalSettings && ( + <> +
+ { + const newVal = !state.fullMetaSearch; + dispatch(setFullMetaSearch(newVal)); + onChange({ ...query, fullMetaSearch: newVal }); + + fuzzySearchCallback(state.fuzzySearchQuery, newVal); + }} + onKeyDown={(e) => { + keyFunction(e); + }} + /> +

{placeholders.metadataSearchSwitch}

+
+
+ { + dispatch(setExcludeNullMetadata(!state.excludeNullMetadata)); + onChange({ ...query, excludeNullMetadata: !state.excludeNullMetadata }); + }} + onKeyDown={(e) => { + keyFunction(e); + }} + /> +

{placeholders.excludeNoMetadata}

+
+
+ { + dispatch(setDisableTextWrap()); + onChange({ ...query, disableTextWrap: !state.disableTextWrap }); + }} + onKeyDown={(e) => { + keyFunction(e); + }} + /> +

Disable text wrap

+
+
+ { + 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); + }} + /> +

{placeholders.setUseBackend}

+
+ + )} +
+
+ {query.labels.length > 0 && ( +

+ These metrics have been pre-filtered by labels chosen in the label filters. +

+ )} +
+
+ {state.metrics && ( + + )} +
+ +
+
+ + { + const value = +e.currentTarget.value; + + if (isNaN(value)) { + return; + } + + dispatch(setResultsPerPage(value)); + }} + /> + + { + const page = val ?? 1; + dispatch(setPageNum(page)); + }} + /> +
+ +
+
+ ); +}; + +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', +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx new file mode 100644 index 00000000000..488db534cfa --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx @@ -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(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 ( + <> + + + + + + + + ); + } else { + return ( + <> + {metric.type ?? ''} + {metric.description ?? ''} + + ); + } + } + + return ( + + + + + {state.hasMetadata && ( + <> + + + + )} + + + + <> + {metrics && + metrics.map((metric: MetricData, idx: number) => { + return ( + selectMetric(metric)} + > + + {state.hasMetadata && metaRows(metric)} + + ); + })} + + +
NameTypeDescription
+ +
+ ); +} + +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}; + `, + }; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts new file mode 100644 index 00000000000..91e1d620688 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts @@ -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 { + // 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) { + 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> { + 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', +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts new file mode 100644 index 00000000000..5e78c63ee0e --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts @@ -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) => { + 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) => { + state.isLoading = action.payload; + }, + setFilteredMetricCount: (state, action: PayloadAction) => { + state.filteredMetricCount = action.payload; + }, + setResultsPerPage: (state, action: PayloadAction) => { + state.resultsPerPage = action.payload; + }, + setPageNum: (state, action: PayloadAction) => { + state.pageNum = action.payload; + }, + setFuzzySearchQuery: (state, action: PayloadAction) => { + state.fuzzySearchQuery = action.payload; + state.pageNum = 1; + state.letterSearch = ''; + state.selectedIdx = 0; + }, + setNameHaystack: (state, action: PayloadAction) => { + state.nameHaystackOrder = action.payload[0]; + state.nameHaystackMatches = action.payload[1]; + }, + setMetaHaystack: (state, action: PayloadAction) => { + state.metaHaystackOrder = action.payload[0]; + state.metaHaystackMatches = action.payload[1]; + }, + setFullMetaSearch: (state, action: PayloadAction) => { + state.fullMetaSearch = action.payload; + state.pageNum = 1; + }, + setExcludeNullMetadata: (state, action: PayloadAction) => { + state.excludeNullMetadata = action.payload; + state.pageNum = 1; + }, + setSelectedTypes: (state, action: PayloadAction>>) => { + state.selectedTypes = action.payload; + state.pageNum = 1; + }, + setLetterSearch: (state, action: PayloadAction) => { + state.letterSearch = action.payload; + state.pageNum = 1; + }, + setUseBackend: (state, action: PayloadAction) => { + state.useBackend = action.payload; + state.fullMetaSearch = false; + state.excludeNullMetadata = false; + state.pageNum = 1; + }, + setSelectedIdx: (state, action: PayloadAction) => { + 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>; + /** 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; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts new file mode 100644 index 00000000000..65978dae22f --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts @@ -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; + `, + }; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/types.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/types.ts new file mode 100644 index 00000000000..ff47b531602 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/types.ts @@ -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[][]; +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts new file mode 100644 index 00000000000..27741aed4d7 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts @@ -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 = 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); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/types.ts b/public/app/plugins/datasource/prometheus/querybuilder/types.ts index 5570e025a7e..c4f8b6b8c2c 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/types.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/types.ts @@ -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; diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts index 65386982c10..2798725fabe 100644 --- a/public/app/plugins/datasource/prometheus/types.ts +++ b/public/app/plugins/datasource/prometheus/types.ts @@ -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 {