diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx index 6378b8d1f9d..15545025545 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx @@ -6,7 +6,7 @@ import Highlighter from 'react-highlight-words'; import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; import { EditorField, EditorFieldGroup } from '@grafana/experimental'; import { config } from '@grafana/runtime'; -import { AsyncSelect, Button, FormatOptionLabelMeta, useStyles2 } from '@grafana/ui'; +import { AsyncSelect, Button, FormatOptionLabelMeta, Icon, useStyles2 } from '@grafana/ui'; import { SelectMenuOptions } from '@grafana/ui/src/components/Select/SelectMenu'; import { PrometheusDatasource } from '../../datasource'; @@ -15,6 +15,7 @@ import { QueryBuilderLabelFilter } from '../shared/types'; import { PromVisualQuery } from '../types'; import { MetricsModal } from './metrics-modal/MetricsModal'; +import { tracking } from './metrics-modal/state/helpers'; // We are matching words split with space const splitSeparator = ' '; @@ -119,6 +120,7 @@ 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 @@ -131,7 +133,8 @@ export function MetricSelect({ return (
{ // if there is no metric and the m.e. is enabled, open the modal if (e.code === 'Enter') { @@ -146,14 +149,14 @@ export function MetricSelect({
{option.description}
} @@ -197,15 +200,16 @@ export function MetricSelect({ metrics.splice(0, metrics.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS); } - if (config.featureToggles.prometheusMetricEncyclopedia) { + if (prometheusMetricEncyclopedia) { // pass the initial metrics, possibly filtered by labels into the Metrics Modal const metricsModalOption: SelectableValue[] = [ { value: 'BrowseMetrics', - label: 'Browse metrics', + label: 'Metrics explorer', description: 'Browse and filter metrics and metadata with a fuzzy search', }, ]; + // pass the initial metrics into the Metrics Modal setState({ metrics: [...metricsModalOption, ...metrics], isLoading: undefined, @@ -222,13 +226,14 @@ export function MetricSelect({ if (value) { // if there is no metric and the m.e. is enabled, open the modal if (prometheusMetricEncyclopedia && value === 'BrowseMetrics') { + tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query); setState({ ...state, metricsModalOpen: true }); } else { onChange({ ...query, metric: value }); } } }} - components={{ Option: CustomOption }} + components={prometheusMetricEncyclopedia ? { Option: CustomOption } : {}} /> @@ -253,7 +258,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ justify-content: space-between; cursor: pointer; :hover { - background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)}; + background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.1)}; } `, customOptionlabel: css` @@ -265,7 +270,10 @@ const getStyles = (theme: GrafanaTheme2) => ({ opacity: 50%; `, focus: css` - background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)}; + background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.1)}; + `, + customOptionWidth: css` + min-width: 400px; `, }); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx index 2e587f2f020..d57b80dce4c 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx @@ -45,7 +45,7 @@ export function PromQueryBuilderContainer(props: Props) { useBackend: query.useBackend ?? false, disableTextWrap: query.disableTextWrap ?? false, fullMetaSearch: query.fullMetaSearch ?? false, - excludeNullMetadata: query.excludeNullMetadata ?? false, + includeNullMetadata: query.includeNullMetadata ?? true, }) ); } @@ -103,7 +103,7 @@ const stateSlice = createSlice({ state.visQuery.useBackend = action.payload.useBackend; state.visQuery.disableTextWrap = action.payload.disableTextWrap; state.visQuery.fullMetaSearch = action.payload.fullMetaSearch; - state.visQuery.excludeNullMetadata = action.payload.excludeNullMetadata; + state.visQuery.includeNullMetadata = action.payload.includeNullMetadata; } }, }, diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx new file mode 100644 index 00000000000..3991277ed7a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx @@ -0,0 +1,100 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, Switch, Tooltip, useTheme2 } from '@grafana/ui'; + +import { testIds } from './MetricsModal'; +import { placeholders } from './state/helpers'; +import { MetricsModalState } from './state/state'; + +type AdditionalSettingsProps = { + state: MetricsModalState; + onChangeFullMetaSearch: () => void; + onChangeIncludeNullMetadata: () => void; + onChangeDisableTextWrap: () => void; + onChangeUseBackend: () => void; + onChangeInferType: () => void; +}; + +export function AdditionalSettings(props: AdditionalSettingsProps) { + const { + state, + onChangeFullMetaSearch, + onChangeIncludeNullMetadata, + onChangeDisableTextWrap, + onChangeUseBackend, + onChangeInferType, + } = props; + + const theme = useTheme2(); + const styles = getStyles(theme); + + return ( + <> +
+ onChangeFullMetaSearch()} + /> +
{placeholders.metadataSearchSwitch}
+
+
+ onChangeIncludeNullMetadata()} + /> +
{placeholders.includeNullMetadata}
+
+
+ onChangeDisableTextWrap()} /> +
Disable text wrap
+
+
+ onChangeUseBackend()} /> +
{placeholders.setUseBackend} 
+ + + +
+
+ onChangeInferType()} /> +
{placeholders.inferType} 
+ + + +
+ + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + settingsIcon: css` + color: ${theme.colors.text.secondary}; + `, + selectItem: css` + display: flex; + flex-direction: row; + align-items: center; + padding: 4px 0; + `, + selectItemLabel: css` + margin: 0 0 0 ${theme.spacing(1)}; + align-self: center; + color: ${theme.colors.text.secondary}; + font-size: 12px; + `, + }; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/FeedbackLink.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/FeedbackLink.tsx index 012538895b2..d5eb2c66d65 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/FeedbackLink.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/FeedbackLink.tsx @@ -13,7 +13,7 @@ export function FeedbackLink({ feedbackUrl }: Props) { const styles = useStyles2(getStyles); return ( - + 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/metrics-modal/MetricsModal.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx index e5e6ddbc677..94ec3155abc 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx @@ -22,7 +22,7 @@ describe('MetricsModal', () => { it('renders the modal', async () => { setup(defaultQuery, listOfMetrics); await waitFor(() => { - expect(screen.getByText('Browse metrics')).toBeInTheDocument(); + expect(screen.getByText('Metrics explorer')).toBeInTheDocument(); }); }); @@ -88,20 +88,6 @@ describe('MetricsModal', () => { }); }); - it('filters by alphebetical letter choice', async () => { - setup(defaultQuery, listOfMetrics); - // pick the letter J - const letterJ = screen.getByTestId('letter-J'); - await userEvent.click(letterJ); - - // check metrics that start with J - const metricStartingWithJ = screen.getByText('j'); - expect(metricStartingWithJ).toBeInTheDocument(); - // check metrics that don't start with J - const metricStartingWithSomethingElse = screen.queryByText('a'); - expect(metricStartingWithSomethingElse).toBeNull(); - }); - // Pagination it('shows metrics within a range by pagination', async () => { // default resultsPerPage is 100 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 index 4b5b857ecec..1e5895f21f0 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -3,31 +3,39 @@ 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 { + Input, + Modal, + MultiSelect, + Spinner, + useTheme2, + Pagination, + Button, + Toggletip, + ButtonGroup, + Icon, +} from '@grafana/ui'; import { PrometheusDatasource } from '../../../datasource'; import { PromVisualQuery } from '../../types'; +import { AdditionalSettings } from './AdditionalSettings'; import { FeedbackLink } from './FeedbackLink'; -import { LetterSearch } from './LetterSearch'; import { ResultsTable } from './ResultsTable'; import { calculatePageList, calculateResultsPerPage, displayedMetrics, - filterMetrics, getBackendSearchMetrics, setMetrics, placeholders, promTypes, + tracking, } from './state/helpers'; import { DEFAULT_RESULTS_PER_PAGE, initialState, MAXIMUM_RESULTS_PER_PAGE, - // MetricsModalReducer, MetricsModalMetadata, stateSlice, } from './state/state'; @@ -44,7 +52,7 @@ export type MetricsModalProps = { initialMetrics: string[]; }; -// actions +// actions to update the state const { setIsLoading, buildMetrics, @@ -55,13 +63,13 @@ const { setNameHaystack, setMetaHaystack, setFullMetaSearch, - setExcludeNullMetadata, + setIncludeNullMetadata, setSelectedTypes, - setLetterSearch, setUseBackend, setSelectedIdx, setDisableTextWrap, showAdditionalSettings, + setInferType, } = stateSlice.actions; export const MetricsModal = (props: MetricsModalProps) => { @@ -75,27 +83,31 @@ export const MetricsModal = (props: MetricsModalProps) => { /** * loads metrics and metadata on opening modal and switching off useBackend */ - const updateMetricsMetadata = useCallback(async () => { - // *** Loading Gif - dispatch(setIsLoading(true)); + const updateMetricsMetadata = useCallback( + async (inferType: boolean) => { + // *** Loading Gif + dispatch(setIsLoading(true)); - const data: MetricsModalMetadata = await setMetrics(datasource, query, initialMetrics); + const data: MetricsModalMetadata = await setMetrics(datasource, query, inferType, 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]); + 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(state.inferType); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateMetricsMetadata]); const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => { @@ -111,10 +123,10 @@ export const MetricsModal = (props: MetricsModalProps) => { */ const debouncedBackendSearch = useMemo( () => - debounce(async (metricText: string) => { + debounce(async (metricText: string, inferType: boolean) => { dispatch(setIsLoading(true)); - const metrics = await getBackendSearchMetrics(metricText, query.labels, datasource); + const metrics = await getBackendSearchMetrics(metricText, query.labels, datasource, inferType); dispatch( filterMetricsBackend({ @@ -135,12 +147,12 @@ export const MetricsModal = (props: MetricsModalProps) => { dispatch(setMetaHaystack(haystackData)); } - function fuzzySearchCallback(query: string, fullMetaSearchVal: boolean) { + function searchCallback(query: string, fullMetaSearchVal: boolean) { if (state.useBackend && query === '') { // get all metrics data if a user erases everything in the input - updateMetricsMetadata(); + updateMetricsMetadata(state.inferType); } else if (state.useBackend) { - debouncedBackendSearch(query); + debouncedBackendSearch(query, state.inferType); } else { // search either the names or all metadata // fuzzy search go! @@ -161,179 +173,137 @@ export const MetricsModal = (props: MetricsModalProps) => { 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, - }); + + tracking('grafana_prom_metric_encycopedia_tracking', state, metric.value); + onClose(); } } + /* Settings switches */ + const additionalSettings = ( + { + const newVal = !state.fullMetaSearch; + dispatch(setFullMetaSearch(newVal)); + onChange({ ...query, fullMetaSearch: newVal }); + + searchCallback(state.fuzzySearchQuery, newVal); + }} + onChangeIncludeNullMetadata={() => { + dispatch(setIncludeNullMetadata(!state.includeNullMetadata)); + onChange({ ...query, includeNullMetadata: !state.includeNullMetadata }); + }} + onChangeDisableTextWrap={() => { + dispatch(setDisableTextWrap()); + onChange({ ...query, disableTextWrap: !state.disableTextWrap }); + tracking('grafana_prom_metric_encycopedia_disable_text_wrap_interaction', state, ''); + }} + onChangeInferType={() => { + const inferType = !state.inferType; + dispatch(setInferType(inferType)); + // update the type + if (state.useBackend) { + // if there is no query yet, it will infer the type on the api call + if (state.fuzzySearchQuery !== '') { + debouncedBackendSearch(state.fuzzySearchQuery, inferType); + } + } else { + // updates the metadata with the inferred type + updateMetricsMetadata(inferType); + } + }} + onChangeUseBackend={() => { + const newVal = !state.useBackend; + dispatch(setUseBackend(newVal)); + onChange({ ...query, useBackend: newVal }); + if (newVal === false) { + // rebuild the metrics metadata if we turn off useBackend + updateMetricsMetadata(state.inferType); + } else { + // check if there is text in the browse search and update + if (state.fuzzySearchQuery !== '') { + debouncedBackendSearch(state.fuzzySearchQuery, state.inferType); + } + // otherwise wait for user typing + } + }} + /> + ); + return ( +
- - { - const value = e.currentTarget.value ?? ''; - dispatch(setFuzzySearchQuery(value)); - - fuzzySearchCallback(value, state.fullMetaSearch); - }} - onKeyDown={(e) => { - keyFunction(e); - }} - /> - + { + const value = e.currentTarget.value ?? ''; + dispatch(setFuzzySearchQuery(value)); + searchCallback(value, state.fullMetaSearch); + }} + onKeyDown={(e) => { + keyFunction(e); + }} + />
-
- +
+ +
+ {state.hasMetadata && ( +
{ - // *** 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)); - }} + onChange={(v) => 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.metric && Currently selected: {query.metric}} {query.labels.length > 0 && ( -

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

+
+ +
+  These metrics have been pre-filtered by labels chosen in the label filters. +
+
)}
@@ -346,43 +316,42 @@ export const MetricsModal = (props: MetricsModalProps) => { state={state} selectedIdx={state.selectedIdx} disableTextWrap={state.disableTextWrap} + onFocusRow={(idx: number) => dispatch(setSelectedIdx(idx))} /> )}
+
+
+ Showing {state.filteredMetricCount} of {state.totalMetricCount} results +
+ { + const page = val ?? 1; + dispatch(setPageNum(page)); + }} + /> +
+

# Results per page 

+ { + const value = +e.currentTarget.value; -
-
- - { - const value = +e.currentTarget.value; + if (isNaN(value) || value >= MAXIMUM_RESULTS_PER_PAGE) { + return; + } - if (isNaN(value)) { - return; - } - - dispatch(setResultsPerPage(value)); - }} - /> - - { - const page = val ?? 1; - dispatch(setPageNum(page)); + dispatch(setResultsPerPage(value)); }} />
-
); @@ -399,4 +368,5 @@ export const testIds = { resultsPerPage: 'results-per-page', setUseBackend: 'set-use-backend', showAdditionalSettings: 'show-additional-settings', + inferType: 'set-infer-type', }; 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 index 488db534cfa..c2cdbfd2187 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx @@ -1,13 +1,13 @@ import { css } from '@emotion/css'; -import React, { useEffect, useRef } from 'react'; +import React, { ReactElement, 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 { Icon, Tooltip, useTheme2 } from '@grafana/ui'; import { PromVisualQuery } from '../../types'; +import { tracking } from './state/helpers'; import { MetricsModalState } from './state/state'; import { MetricData, MetricsData } from './types'; @@ -19,10 +19,11 @@ type ResultsTableProps = { state: MetricsModalState; selectedIdx: number; disableTextWrap: boolean; + onFocusRow: (idx: number) => void; }; export function ResultsTable(props: ResultsTableProps) { - const { metrics, onChange, onClose, query, state, selectedIdx, disableTextWrap } = props; + const { metrics, onChange, onClose, query, state, selectedIdx, disableTextWrap, onFocusRow } = props; const theme = useTheme2(); const styles = getStyles(theme, disableTextWrap); @@ -36,15 +37,7 @@ export function ResultsTable(props: ResultsTableProps) { 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, - }); + tracking('grafana_prom_metric_encycopedia_tracking', state, metric.value); onClose(); } } @@ -63,8 +56,9 @@ export function ResultsTable(props: ResultsTableProps) { textToHighlight={metric.type ?? ''} searchWords={state.metaHaystackMatches} autoEscape - highlightClassName={styles.matchHighLight} - /> + highlightClassName={`${styles.matchHighLight} ${metric.inferred ? styles.italicized : ''}`} + />{' '} + {inferredType(metric.inferred ?? false)} - {metric.type ?? ''} + + {metric.type ?? ''} {inferredType(metric.inferred ?? false)} + {metric.description ?? ''} ); } } + function inferredType(inferred: boolean): JSX.Element | undefined { + if (inferred) { + return ( + + + + ); + } else { + return undefined; + } + } + + function noMetricsMessages(): ReactElement { + let message; + + if (!state.fuzzySearchQuery) { + message = 'There are no metrics found in the data source.'; + } + + if (query.labels.length > 0) { + message = 'There are no metrics found. Try to expand your label filters.'; + } + + if (state.fuzzySearchQuery) { + message = 'There are no metrics found. Try to expand your search and filters.'; + } + + return ( + + {message} + + ); + } + return ( - - - + + + {state.hasMetadata && ( <> - - + + )} <> - {metrics && + {metrics.length > 0 && metrics.map((metric: MetricData, idx: number) => { return ( selectMetric(metric)} + tabIndex={0} + onFocus={() => onFocusRow(idx)} + onKeyDown={(e) => { + if (e.code === 'Enter' && e.currentTarget.classList.contains('selected-row')) { + selectMetric(metric); + } + }} > -
Name
NameTypeDescriptionTypeDescription
+ ); })} + {metrics.length === 0 && !state.isLoading && noMetricsMessages()}
@@ -132,6 +170,7 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { return { table: css` + ${disableTextWrap ? '' : 'table-layout: fixed;'} border-radius: ${theme.shape.borderRadius()}; width: 100%; white-space: ${disableTextWrap ? 'nowrap' : 'normal'}; @@ -142,11 +181,9 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { td, th { min-width: ${theme.spacing(3)}; + border-bottom: 1px solid ${theme.colors.border.weak}; } `, - header: css` - border-bottom: 1px solid ${theme.colors.border.weak}; - `, row: css` label: row; cursor: pointer; @@ -158,6 +195,9 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { background-color: ${rowHoverBg}; } `, + tableHeaderPadding: css` + padding: 8px; + `, selectedRow: css` background-color: ${rowHoverBg}; `, @@ -166,5 +206,26 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { color: ${theme.components.textHighlight.text}; background-color: ${theme.components.textHighlight.background}; `, + nameWidth: css` + ${disableTextWrap ? '' : 'width: 40%;'} + `, + nameOverflow: css` + ${disableTextWrap ? '' : 'overflow-wrap: anywhere;'} + `, + typeWidth: css` + ${disableTextWrap ? '' : 'width: 16%;'} + `, + stickyHeader: css` + position: sticky; + top: 0; + background-color: ${theme.colors.background.primary}; + `, + noResults: css` + text-align: center; + color: ${theme.colors.text.secondary}; + `, + italicized: css` + font-style: italic; + `, }; }; 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 index 91e1d620688..ef6b7108795 100644 --- 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 @@ -1,5 +1,6 @@ import { AnyAction } from '@reduxjs/toolkit'; +import { reportInteraction } from '@grafana/runtime'; import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasource'; import { getMetadataHelp, getMetadataType } from 'app/plugins/datasource/prometheus/language_provider'; @@ -15,6 +16,7 @@ const { setFilteredMetricCount } = stateSlice.actions; export async function setMetrics( datasource: PrometheusDatasource, query: PromVisualQuery, + inferType: boolean, initialMetrics?: string[] ): Promise { // metadata is set in the metric select now @@ -32,17 +34,9 @@ export async function setMetrics( let metricsData: MetricsData | undefined; metricsData = initialMetrics?.map((m: string) => { - const type = getMetadataType(m, datasource.languageProvider.metricsMetadata!); - const description = getMetadataHelp(m, datasource.languageProvider.metricsMetadata!); + const metricData = buildMetricData(m, inferType, datasource); - // possibly remove the type in favor of the type select - const metaDataString = `${m}¦${type}¦${description}`; - - const metricData: MetricData = { - value: m, - type: type, - description: description, - }; + const metaDataString = `${m}¦${metricData.type}¦${metricData.description}`; nameHaystackDictionaryData[m] = metricData; metaHaystackDictionaryData[metaDataString] = metricData; @@ -61,6 +55,40 @@ export async function setMetrics( }; } +/** + * Builds the metric data object with type, description and inferred flag + * + * @param metric The metric name + * @param inferType state attribute that the infer type setting is on or off + * @param datasource The Prometheus datasource for mapping metradata to the metric name + * @returns A MetricData object. + */ +function buildMetricData(metric: string, inferType: boolean, datasource: PrometheusDatasource): MetricData { + let type = getMetadataType(metric, datasource.languageProvider.metricsMetadata!); + let inferredType; + if (!type && inferType) { + type = metricTypeHints(metric); + + if (type) { + inferredType = true; + } + } + const description = getMetadataHelp(metric, datasource.languageProvider.metricsMetadata!); + + if (description?.toLowerCase().includes('histogram') && type !== 'histogram') { + type += ' (histogram)'; + } + + const metricData: MetricData = { + value: metric, + type: type, + description: description, + inferred: inferredType, + }; + + return metricData; +} + /** * The filtered and paginated metrics displayed in the modal * */ @@ -75,12 +103,9 @@ export function displayedMetrics(state: MetricsModalState, dispatch: React.Dispa } /** - * 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 + * Filter the metrics with all the options, fuzzy, type, null metadata */ -export function filterMetrics(state: MetricsModalState, skipLetterSearch?: boolean): MetricsData { +export function filterMetrics(state: MetricsModalState): MetricsData { let filteredMetrics: MetricsData = state.metrics; if (state.fuzzySearchQuery && !state.useBackend) { @@ -91,27 +116,29 @@ export function filterMetrics(state: MetricsModalState, skipLetterSearch?: boole } } - 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) { + if (state.selectedTypes.length > 0) { filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => { // Matches type - const matchesSelectedType = state.selectedTypes.some((t) => t.value === m.type); + const matchesSelectedType = state.selectedTypes.some((t) => { + if (m.type && t.value) { + return m.type.includes(t.value); + } + return false; + }); // missing type const hasNoType = !m.type; - return matchesSelectedType || (hasNoType && !state.excludeNullMetadata); + return matchesSelectedType || (hasNoType && state.includeNullMetadata); }); } - if (state.excludeNullMetadata) { + if (!state.includeNullMetadata) { filteredMetrics = filteredMetrics.filter((m: MetricData) => { + if (state.inferType && m.inferred) { + return true; + } + return m.type !== undefined && m.description !== undefined; }); } @@ -152,15 +179,17 @@ export const calculateResultsPerPage = (results: number, defaultResults: number, /** * The backend query that replaces the uFuzzy search when the option 'useBackend' has been selected + * this is a regex search either to the series or labels Prometheus endpoint + * depending on which the Prometheus type or version supports * @param metricText * @param labels * @param datasource - * @returns */ export async function getBackendSearchMetrics( metricText: string, labels: QueryBuilderLabelFilter[], - datasource: PrometheusDatasource + datasource: PrometheusDatasource, + inferType: boolean ): Promise> { const queryString = regexifyLabelValuesQueryString(metricText); @@ -173,14 +202,47 @@ export async function getBackendSearchMetrics( const results = datasource.metricFindQuery(params); return await results.then((results) => { - return results.map((result) => { - return { - value: result.text, - }; - }); + return results.map((result) => buildMetricData(result.text, inferType, datasource)); }); } +function metricTypeHints(metric: string): string { + const histogramMetric = metric.match(/^\w+_bucket$|^\w+_bucket{.*}$/); + if (histogramMetric) { + return 'counter (histogram)'; + } + + const counterMatch = metric.match(/\b(\w+_(total|sum|count))\b/); + if (counterMatch) { + return 'counter'; + } + + return ''; +} + +export function tracking(event: string, state?: MetricsModalState | null, metric?: string, query?: PromVisualQuery) { + switch (event) { + case 'grafana_prom_metric_encycopedia_tracking': + reportInteraction(event, { + metric: metric, + hasMetadata: state?.hasMetadata, + totalMetricCount: state?.totalMetricCount, + fuzzySearchQuery: state?.fuzzySearchQuery, + fullMetaSearch: state?.fullMetaSearch, + selectedTypes: state?.selectedTypes, + inferType: state?.inferType, + }); + case 'grafana_prom_metric_encycopedia_disable_text_wrap_interaction': + reportInteraction(event, { + disableTextWrap: state?.disableTextWrap, + }); + case 'grafana_prometheus_metric_encyclopedia_open': + reportInteraction(event, { + query: query, + }); + } +} + export const promTypes: PromFilterOption[] = [ { value: 'counter', @@ -205,9 +267,9 @@ export const promTypes: PromFilterOption[] = [ 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', + metadataSearchSwitch: 'Include search with type and description', + type: 'Filter by type', + includeNullMetadata: 'Include results with no metadata', + setUseBackend: 'Enable regex search', + inferType: 'Infer metric type', }; 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 index 5e78c63ee0e..7e6749a481a 100644 --- 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 @@ -48,7 +48,6 @@ export const stateSlice = createSlice({ setFuzzySearchQuery: (state, action: PayloadAction) => { state.fuzzySearchQuery = action.payload; state.pageNum = 1; - state.letterSearch = ''; state.selectedIdx = 0; }, setNameHaystack: (state, action: PayloadAction) => { @@ -63,22 +62,17 @@ export const stateSlice = createSlice({ state.fullMetaSearch = action.payload; state.pageNum = 1; }, - setExcludeNullMetadata: (state, action: PayloadAction) => { - state.excludeNullMetadata = action.payload; + setIncludeNullMetadata: (state, action: PayloadAction) => { + state.includeNullMetadata = 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) => { @@ -90,6 +84,9 @@ export const stateSlice = createSlice({ showAdditionalSettings: (state) => { state.showAdditionalSettings = !state.showAdditionalSettings; }, + setInferType: (state, action: PayloadAction) => { + state.inferType = action.payload; + }, }, }); @@ -114,13 +111,13 @@ export function initialState(query?: PromVisualQuery): MetricsModalState { pageNum: 1, fuzzySearchQuery: '', fullMetaSearch: query?.fullMetaSearch ?? false, - excludeNullMetadata: query?.excludeNullMetadata ?? false, + includeNullMetadata: query?.includeNullMetadata ?? true, selectedTypes: [], - letterSearch: '', useBackend: query?.useBackend ?? false, disableTextWrap: query?.disableTextWrap ?? false, selectedIdx: 0, showAdditionalSettings: false, + inferType: true, }; } @@ -162,12 +159,10 @@ export interface MetricsModalState { fuzzySearchQuery: string; /** Enables the fuzzy meatadata search */ fullMetaSearch: boolean; - /** Excludes results that are missing type and description */ - excludeNullMetadata: boolean; + /** Includes results that are missing type and description */ + includeNullMetadata: 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 */ @@ -176,6 +171,8 @@ export interface MetricsModalState { selectedIdx: number; /** Display toggle switches for settings */ showAdditionalSettings: boolean; + /** Check metric to match on substrings to infer prometheus type */ + inferType: boolean; } /** @@ -197,7 +194,7 @@ export function getSettings(visQuery: PromVisualQuery): MetricsModalSettings { useBackend: visQuery?.useBackend ?? false, disableTextWrap: visQuery?.disableTextWrap ?? false, fullMetaSearch: visQuery?.fullMetaSearch ?? false, - excludeNullMetadata: visQuery.excludeNullMetadata ?? false, + includeNullMetadata: visQuery.includeNullMetadata ?? false, }; } @@ -205,5 +202,5 @@ export type MetricsModalSettings = { useBackend?: boolean; disableTextWrap?: boolean; fullMetaSearch?: boolean; - excludeNullMetadata?: boolean; + includeNullMetadata?: 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 index 65978dae22f..bd5ad66f45e 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts @@ -18,7 +18,6 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { flex-direction: row; flex-wrap: wrap; gap: ${theme.spacing(2)}; - margin-bottom: ${theme.spacing(2)}; `, inputItemFirst: css` flex-basis: 40%; @@ -33,51 +32,33 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { 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; + resultsAmount: css` color: ${theme.colors.text.secondary}; - `, - resultsHeading: css` - margin: 0 0 0 0; + font-size: 0.85rem; + padding: 0 0 4px 0; `, resultsData: css` - margin: 0 0 ${theme.spacing(1)} 0; + margin: 4px 0 ${theme.spacing(2)} 0; `, resultsDataCount: css` margin: 0; `, resultsDataFiltered: css` - margin: 0; - color: ${theme.colors.warning.text}; + color: ${theme.colors.text.secondary}; + text-align: center; + border: solid 1px rgba(204, 204, 220, 0.25); + padding: 7px; `, - 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)}; + resultsDataFilteredText: css` + display: inline; + vertical-align: text-top; `, results: css` - height: calc(80vh - 280px); + height: calc(80vh - 310px); overflow-y: scroll; `, - pageSettingsWrapper: css` - padding-top: ${theme.spacing(1.5)}; + resultsFooter: css` + margin-top: 24px; display: flex; flex-direction: row; flex-wrap: wrap; @@ -85,39 +66,29 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { 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` + currentlySelected: css` color: grey; - opacity: 50%; + opacity: 75%; + font-size: 0.75rem; `, 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; `, + settingsBtn: css` + float: right; + `, + resultsPerPageLabel: css` + color: ${theme.colors.text.secondary}; + opacity: 75%; + padding-top: 5px; + font-size: 0.85rem; + margin-right: 8px; + `, + resultsPerPageWrapper: css` + display: flex; + `, }; }; 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 index ff47b531602..2f25a7d49f5 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/types.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/types.ts @@ -2,8 +2,9 @@ export type MetricsData = MetricData[]; export type MetricData = { value: string; - type?: string; + type?: string | null; description?: string; + inferred?: boolean; }; export type PromFilterOption = { diff --git a/public/app/plugins/datasource/prometheus/querybuilder/types.ts b/public/app/plugins/datasource/prometheus/querybuilder/types.ts index c4f8b6b8c2c..1f7b12a32c4 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/types.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/types.ts @@ -12,7 +12,7 @@ export interface PromVisualQuery { // metrics modal additional settings useBackend?: boolean; disableTextWrap?: boolean; - excludeNullMetadata?: boolean; + includeNullMetadata?: boolean; fullMetaSearch?: boolean; } diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts index 9fca875b939..60af3af315f 100644 --- a/public/app/plugins/datasource/prometheus/types.ts +++ b/public/app/plugins/datasource/prometheus/types.ts @@ -20,7 +20,7 @@ export interface PromQuery extends GenPromQuery, DataQuery { useBackend?: boolean; disableTextWrap?: boolean; fullMetaSearch?: boolean; - excludeNullMetadata?: boolean; + includeNullMetadata?: boolean; } export enum PrometheusCacheLevel {