Prometheus: Metric encyclopedia ux collab design (#68421)

* add class for full story click event on open modal

* move feedback link to modal top under header

* move results amount to bottom left

* move settings into modal, change language from exclude to include

* add metadata to backend search, use toggletip for settings, clean code

* style input row, remove labels and update settings button design

* remove alphabet search as requested by design

* display selected metric

* update style warning message for labels filtered metrics

* organize results footer

* update table design w fixed width and sticky header

* allow focus row on tab and use key Enter to select metric on keydown

* add rudderstack event for disable text wrap

* add messages for no metrics found, labels, search and none in data source.

* filter by type placeholder

* add min width to custom select option

* add text wrap for long metric names

* Have 4px margin b/w the search row and the 'currently selected' text. 16px between 'currently selected text' and the table

* Add some padding inside the first table header row (8 pixels on all sides)

* font-size of 12px for additional settings text

* 4px padding between additional settings text

* 24px margin between the last table cell and the pagination row

* # of Results per page font size 0.85rem

* 8px margin b/w the '# of results per page' and the dropdown

* fix test

* add infer type setting for testing

* use title on icon instead of wrapping in tooltip to fix test

* fix icon issue

* italicize inferred types, update setting text and add icon

* add space for label filters alert message

* make open button style consistent with advanced datasource picker

* keep copy for open modal button

* refactor rudderstack interactions and add inferType

* add event tracking for opening the modal

* galen's feedback, fix select horizontal scroll and results perpg bug

* ismail's feedback for metric types

* revert button in option for accessibility(galen) and style button with ghost mode

* change name to Metrics explorer

* fix hover/focus styles

* ismail's feedbcak, refactor hints, return empty string, remove @return

* Fix icon hovering: put tooltips back in over titles on icon

* make results not expand to fill table space and fix width for modal open option button
pull/68638/head
Brendan O'Handley 3 years ago committed by GitHub
parent effe21fb65
commit 2e6c71fd39
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 28
      public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx
  2. 4
      public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx
  3. 100
      public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx
  4. 3
      public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/FeedbackLink.tsx
  5. 71
      public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/LetterSearch.tsx
  6. 16
      public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx
  7. 374
      public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx
  8. 113
      public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx
  9. 138
      public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts
  10. 29
      public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts
  11. 89
      public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts
  12. 3
      public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/types.ts
  13. 2
      public/app/plugins/datasource/prometheus/querybuilder/types.ts
  14. 2
      public/app/plugins/datasource/prometheus/types.ts

@ -6,7 +6,7 @@ import Highlighter from 'react-highlight-words';
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { EditorField, EditorFieldGroup } from '@grafana/experimental'; import { EditorField, EditorFieldGroup } from '@grafana/experimental';
import { config } from '@grafana/runtime'; 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 { SelectMenuOptions } from '@grafana/ui/src/components/Select/SelectMenu';
import { PrometheusDatasource } from '../../datasource'; import { PrometheusDatasource } from '../../datasource';
@ -15,6 +15,7 @@ import { QueryBuilderLabelFilter } from '../shared/types';
import { PromVisualQuery } from '../types'; import { PromVisualQuery } from '../types';
import { MetricsModal } from './metrics-modal/MetricsModal'; import { MetricsModal } from './metrics-modal/MetricsModal';
import { tracking } from './metrics-modal/state/helpers';
// We are matching words split with space // We are matching words split with space
const splitSeparator = ' '; const splitSeparator = ' ';
@ -119,6 +120,7 @@ export function MetricSelect({
(query: string) => getMetricLabels(query), (query: string) => getMetricLabels(query),
datasource.getDebounceTimeInMilliseconds() datasource.getDebounceTimeInMilliseconds()
); );
// No type found for the common select props so typing as any // 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 // https://github.com/grafana/grafana/blob/main/packages/grafana-ui/src/components/Select/SelectBase.tsx/#L212-L263
// eslint-disable-next-line // eslint-disable-next-line
@ -131,7 +133,8 @@ export function MetricSelect({
return ( return (
<div <div
{...props.innerProps} {...props.innerProps}
className="metric-encyclopedia-open" ref={props.innerRef}
className={`${styles.customOptionWidth} metric-encyclopedia-open`}
onKeyDown={(e) => { onKeyDown={(e) => {
// if there is no metric and the m.e. is enabled, open the modal // if there is no metric and the m.e. is enabled, open the modal
if (e.code === 'Enter') { if (e.code === 'Enter') {
@ -146,14 +149,14 @@ export function MetricSelect({
<div className={`${styles.customOptionDesc} metric-encyclopedia-open`}>{option.description}</div> <div className={`${styles.customOptionDesc} metric-encyclopedia-open`}>{option.description}</div>
</div> </div>
<Button <Button
variant="primary" fill="text"
fill="outline"
size="sm" size="sm"
variant="secondary"
onClick={() => setState({ ...state, metricsModalOpen: true })} onClick={() => setState({ ...state, metricsModalOpen: true })}
icon="book"
className="metric-encyclopedia-open" className="metric-encyclopedia-open"
> >
Open Open
<Icon name="arrow-right" />
</Button> </Button>
</div> </div>
} }
@ -197,15 +200,16 @@ export function MetricSelect({
metrics.splice(0, metrics.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS); 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 // pass the initial metrics, possibly filtered by labels into the Metrics Modal
const metricsModalOption: SelectableValue[] = [ const metricsModalOption: SelectableValue[] = [
{ {
value: 'BrowseMetrics', value: 'BrowseMetrics',
label: 'Browse metrics', label: 'Metrics explorer',
description: 'Browse and filter metrics and metadata with a fuzzy search', description: 'Browse and filter metrics and metadata with a fuzzy search',
}, },
]; ];
// pass the initial metrics into the Metrics Modal
setState({ setState({
metrics: [...metricsModalOption, ...metrics], metrics: [...metricsModalOption, ...metrics],
isLoading: undefined, isLoading: undefined,
@ -222,13 +226,14 @@ export function MetricSelect({
if (value) { if (value) {
// if there is no metric and the m.e. is enabled, open the modal // if there is no metric and the m.e. is enabled, open the modal
if (prometheusMetricEncyclopedia && value === 'BrowseMetrics') { if (prometheusMetricEncyclopedia && value === 'BrowseMetrics') {
tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query);
setState({ ...state, metricsModalOpen: true }); setState({ ...state, metricsModalOpen: true });
} else { } else {
onChange({ ...query, metric: value }); onChange({ ...query, metric: value });
} }
} }
}} }}
components={{ Option: CustomOption }} components={prometheusMetricEncyclopedia ? { Option: CustomOption } : {}}
/> />
</EditorField> </EditorField>
</EditorFieldGroup> </EditorFieldGroup>
@ -253,7 +258,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
justify-content: space-between; justify-content: space-between;
cursor: pointer; cursor: pointer;
:hover { :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` customOptionlabel: css`
@ -265,7 +270,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
opacity: 50%; opacity: 50%;
`, `,
focus: css` 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;
`, `,
}); });

@ -45,7 +45,7 @@ export function PromQueryBuilderContainer(props: Props) {
useBackend: query.useBackend ?? false, useBackend: query.useBackend ?? false,
disableTextWrap: query.disableTextWrap ?? false, disableTextWrap: query.disableTextWrap ?? false,
fullMetaSearch: query.fullMetaSearch ?? 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.useBackend = action.payload.useBackend;
state.visQuery.disableTextWrap = action.payload.disableTextWrap; state.visQuery.disableTextWrap = action.payload.disableTextWrap;
state.visQuery.fullMetaSearch = action.payload.fullMetaSearch; state.visQuery.fullMetaSearch = action.payload.fullMetaSearch;
state.visQuery.excludeNullMetadata = action.payload.excludeNullMetadata; state.visQuery.includeNullMetadata = action.payload.includeNullMetadata;
} }
}, },
}, },

@ -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 (
<>
<div className={styles.selectItem}>
<Switch
data-testid={testIds.searchWithMetadata}
value={state.fullMetaSearch}
disabled={state.useBackend || !state.hasMetadata}
onChange={() => onChangeFullMetaSearch()}
/>
<div className={styles.selectItemLabel}>{placeholders.metadataSearchSwitch}</div>
</div>
<div className={styles.selectItem}>
<Switch
value={state.includeNullMetadata}
disabled={!state.hasMetadata}
onChange={() => onChangeIncludeNullMetadata()}
/>
<div className={styles.selectItemLabel}>{placeholders.includeNullMetadata}</div>
</div>
<div className={styles.selectItem}>
<Switch value={state.disableTextWrap} onChange={() => onChangeDisableTextWrap()} />
<div className={styles.selectItemLabel}>Disable text wrap</div>
</div>
<div className={styles.selectItem}>
<Switch data-testid={testIds.setUseBackend} value={state.useBackend} onChange={() => onChangeUseBackend()} />
<div className={styles.selectItemLabel}>{placeholders.setUseBackend}&nbsp;</div>
<Tooltip
content={'Filter metric names by regex search, using an additional call on the Prometheus API.'}
placement="bottom-end"
>
<Icon name="info-circle" size="xs" className={styles.settingsIcon} />
</Tooltip>
</div>
<div className={styles.selectItem}>
<Switch data-testid={testIds.inferType} value={state.inferType} onChange={() => onChangeInferType()} />
<div className={styles.selectItemLabel}>{placeholders.inferType}&nbsp;</div>
<Tooltip
content={
'For example, metrics ending in _sum, _count, will be given an inferred type of counter. Metrics ending in _bucket with be given a type of histogram.'
}
placement="bottom-end"
>
<Icon name="info-circle" size="xs" className={styles.settingsIcon} />
</Tooltip>
</div>
</>
);
}
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;
`,
};
}

@ -13,7 +13,7 @@ export function FeedbackLink({ feedbackUrl }: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
<Stack gap={1}> <Stack>
<a <a
href={feedbackUrl} href={feedbackUrl}
className={styles.link} className={styles.link}
@ -35,6 +35,7 @@ function getStyles(theme: GrafanaTheme2) {
':hover': { ':hover': {
color: theme.colors.text.link, color: theme.colors.text.link,
}, },
margin: `-25px 0 30px 0`,
}), }),
}; };
} }

@ -1,71 +0,0 @@
import React from 'react';
import { useTheme2 } from '@grafana/ui';
import { getStyles } from './styles';
import { MetricData, MetricsData } from './types';
export type LetterSearchProps = {
filteredMetrics: MetricsData;
disableTextWrap: boolean;
updateLetterSearch: (letter: string) => void;
letterSearch: string | null;
};
export function LetterSearch(props: LetterSearchProps) {
const { filteredMetrics, disableTextWrap, updateLetterSearch, letterSearch } = props;
const alphabetDictionary = alphabetCheck();
const theme = useTheme2();
const styles = getStyles(theme, disableTextWrap);
filteredMetrics.forEach((m: MetricData, idx: number) => {
const metricFirstLetter = m.value[0].toUpperCase();
if (alphabet.includes(metricFirstLetter) && !alphabetDictionary[metricFirstLetter]) {
alphabetDictionary[metricFirstLetter] += 1;
}
});
// return the alphabet components with the correct style and behavior
return (
<div>
{Object.keys(alphabetDictionary).map((letter: string) => {
const active: boolean = alphabetDictionary[letter] > 0;
// starts with letter search
// filter by starts with letter
// if same letter searched null out remove letter search
function setLetterSearch() {
updateLetterSearch(letter);
}
// selected letter to filter by
const selectedClass: string = letterSearch === letter ? styles.selAlpha : '';
// these letters are represented in the list of metrics
const activeClass: string = active ? styles.active : styles.gray;
return (
<span
onClick={active ? setLetterSearch : () => {}}
className={`${selectedClass} ${activeClass}`}
key={letter}
data-testid={'letter-' + letter}
>
{letter + ' '}
{/* {idx !== coll.length - 1 ? '|': ''} */}
</span>
);
})}
</div>
);
}
export const alphabet = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
function alphabetCheck(): { [char: string]: number } {
const check: { [char: string]: number } = {};
alphabet.forEach((char) => (check[char] = 0));
return check;
}

@ -22,7 +22,7 @@ describe('MetricsModal', () => {
it('renders the modal', async () => { it('renders the modal', async () => {
setup(defaultQuery, listOfMetrics); setup(defaultQuery, listOfMetrics);
await waitFor(() => { 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 // Pagination
it('shows metrics within a range by pagination', async () => { it('shows metrics within a range by pagination', async () => {
// default resultsPerPage is 100 // default resultsPerPage is 100

@ -3,31 +3,39 @@ import debounce from 'debounce-promise';
import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { EditorField } from '@grafana/experimental'; import {
import { reportInteraction } from '@grafana/runtime'; Input,
import { InlineField, Switch, Input, Modal, MultiSelect, Spinner, useTheme2, Pagination, Button } from '@grafana/ui'; Modal,
MultiSelect,
Spinner,
useTheme2,
Pagination,
Button,
Toggletip,
ButtonGroup,
Icon,
} from '@grafana/ui';
import { PrometheusDatasource } from '../../../datasource'; import { PrometheusDatasource } from '../../../datasource';
import { PromVisualQuery } from '../../types'; import { PromVisualQuery } from '../../types';
import { AdditionalSettings } from './AdditionalSettings';
import { FeedbackLink } from './FeedbackLink'; import { FeedbackLink } from './FeedbackLink';
import { LetterSearch } from './LetterSearch';
import { ResultsTable } from './ResultsTable'; import { ResultsTable } from './ResultsTable';
import { import {
calculatePageList, calculatePageList,
calculateResultsPerPage, calculateResultsPerPage,
displayedMetrics, displayedMetrics,
filterMetrics,
getBackendSearchMetrics, getBackendSearchMetrics,
setMetrics, setMetrics,
placeholders, placeholders,
promTypes, promTypes,
tracking,
} from './state/helpers'; } from './state/helpers';
import { import {
DEFAULT_RESULTS_PER_PAGE, DEFAULT_RESULTS_PER_PAGE,
initialState, initialState,
MAXIMUM_RESULTS_PER_PAGE, MAXIMUM_RESULTS_PER_PAGE,
// MetricsModalReducer,
MetricsModalMetadata, MetricsModalMetadata,
stateSlice, stateSlice,
} from './state/state'; } from './state/state';
@ -44,7 +52,7 @@ export type MetricsModalProps = {
initialMetrics: string[]; initialMetrics: string[];
}; };
// actions // actions to update the state
const { const {
setIsLoading, setIsLoading,
buildMetrics, buildMetrics,
@ -55,13 +63,13 @@ const {
setNameHaystack, setNameHaystack,
setMetaHaystack, setMetaHaystack,
setFullMetaSearch, setFullMetaSearch,
setExcludeNullMetadata, setIncludeNullMetadata,
setSelectedTypes, setSelectedTypes,
setLetterSearch,
setUseBackend, setUseBackend,
setSelectedIdx, setSelectedIdx,
setDisableTextWrap, setDisableTextWrap,
showAdditionalSettings, showAdditionalSettings,
setInferType,
} = stateSlice.actions; } = stateSlice.actions;
export const MetricsModal = (props: MetricsModalProps) => { 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 * loads metrics and metadata on opening modal and switching off useBackend
*/ */
const updateMetricsMetadata = useCallback(async () => { const updateMetricsMetadata = useCallback(
// *** Loading Gif async (inferType: boolean) => {
dispatch(setIsLoading(true)); // *** Loading Gif
dispatch(setIsLoading(true));
const data: MetricsModalMetadata = await setMetrics(datasource, query, initialMetrics); const data: MetricsModalMetadata = await setMetrics(datasource, query, inferType, initialMetrics);
dispatch( dispatch(
buildMetrics({ buildMetrics({
isLoading: false, isLoading: false,
hasMetadata: data.hasMetadata, hasMetadata: data.hasMetadata,
metrics: data.metrics, metrics: data.metrics,
metaHaystackDictionary: data.metaHaystackDictionary, metaHaystackDictionary: data.metaHaystackDictionary,
nameHaystackDictionary: data.nameHaystackDictionary, nameHaystackDictionary: data.nameHaystackDictionary,
totalMetricCount: data.metrics.length, totalMetricCount: data.metrics.length,
filteredMetricCount: data.metrics.length, filteredMetricCount: data.metrics.length,
}) })
); );
}, [query, datasource, initialMetrics]); },
[query, datasource, initialMetrics]
);
useEffect(() => { useEffect(() => {
updateMetricsMetadata(); updateMetricsMetadata(state.inferType);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateMetricsMetadata]); }, [updateMetricsMetadata]);
const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => { const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => {
@ -111,10 +123,10 @@ export const MetricsModal = (props: MetricsModalProps) => {
*/ */
const debouncedBackendSearch = useMemo( const debouncedBackendSearch = useMemo(
() => () =>
debounce(async (metricText: string) => { debounce(async (metricText: string, inferType: boolean) => {
dispatch(setIsLoading(true)); dispatch(setIsLoading(true));
const metrics = await getBackendSearchMetrics(metricText, query.labels, datasource); const metrics = await getBackendSearchMetrics(metricText, query.labels, datasource, inferType);
dispatch( dispatch(
filterMetricsBackend({ filterMetricsBackend({
@ -135,12 +147,12 @@ export const MetricsModal = (props: MetricsModalProps) => {
dispatch(setMetaHaystack(haystackData)); dispatch(setMetaHaystack(haystackData));
} }
function fuzzySearchCallback(query: string, fullMetaSearchVal: boolean) { function searchCallback(query: string, fullMetaSearchVal: boolean) {
if (state.useBackend && query === '') { if (state.useBackend && query === '') {
// get all metrics data if a user erases everything in the input // get all metrics data if a user erases everything in the input
updateMetricsMetadata(); updateMetricsMetadata(state.inferType);
} else if (state.useBackend) { } else if (state.useBackend) {
debouncedBackendSearch(query); debouncedBackendSearch(query, state.inferType);
} else { } else {
// search either the names or all metadata // search either the names or all metadata
// fuzzy search go! // fuzzy search go!
@ -161,179 +173,137 @@ export const MetricsModal = (props: MetricsModalProps) => {
const metric = displayedMetrics(state, dispatch)[state.selectedIdx]; const metric = displayedMetrics(state, dispatch)[state.selectedIdx];
onChange({ ...query, metric: metric.value }); onChange({ ...query, metric: metric.value });
reportInteraction('grafana_prom_metric_encycopedia_tracking', {
metric: metric.value, tracking('grafana_prom_metric_encycopedia_tracking', state, metric.value);
hasMetadata: state.hasMetadata,
totalMetricCount: state.totalMetricCount,
fuzzySearchQuery: state.fuzzySearchQuery,
fullMetaSearch: state.fullMetaSearch,
selectedTypes: state.selectedTypes,
letterSearch: state.letterSearch,
});
onClose(); onClose();
} }
} }
/* Settings switches */
const additionalSettings = (
<AdditionalSettings
state={state}
onChangeFullMetaSearch={() => {
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 ( return (
<Modal <Modal
data-testid={testIds.metricModal} data-testid={testIds.metricModal}
isOpen={isOpen} isOpen={isOpen}
title="Browse metrics" title="Metrics explorer"
onDismiss={onClose} onDismiss={onClose}
aria-label="Browse metrics" aria-label="Browse metrics"
className={styles.modal} className={styles.modal}
> >
<FeedbackLink feedbackUrl="https://forms.gle/DEMAJHoAMpe3e54CA" />
<div className={styles.inputWrapper}> <div className={styles.inputWrapper}>
<div className={cx(styles.inputItem, styles.inputItemFirst)}> <div className={cx(styles.inputItem, styles.inputItemFirst)}>
<EditorField label="Search metrics"> <Input
<Input autoFocus={true}
autoFocus={true} data-testid={testIds.searchMetric}
data-testid={testIds.searchMetric} placeholder={placeholders.browse}
placeholder={placeholders.browse} value={state.fuzzySearchQuery}
value={state.fuzzySearchQuery} onInput={(e) => {
onInput={(e) => { const value = e.currentTarget.value ?? '';
const value = e.currentTarget.value ?? ''; dispatch(setFuzzySearchQuery(value));
dispatch(setFuzzySearchQuery(value)); searchCallback(value, state.fullMetaSearch);
}}
fuzzySearchCallback(value, state.fullMetaSearch); onKeyDown={(e) => {
}} keyFunction(e);
onKeyDown={(e) => { }}
keyFunction(e); />
}}
/>
</EditorField>
</div> </div>
<div className={styles.inputItem}> <div>
<EditorField label="Filter by type"> <Spinner className={`${styles.loadingSpinner} ${state.isLoading ? styles.visible : ''}`} />
</div>
{state.hasMetadata && (
<div className={styles.inputItem}>
<MultiSelect <MultiSelect
data-testid={testIds.selectType} data-testid={testIds.selectType}
inputId="my-select" inputId="my-select"
options={typeOptions} options={typeOptions}
value={state.selectedTypes} value={state.selectedTypes}
disabled={!state.hasMetadata || state.useBackend}
placeholder={placeholders.type} placeholder={placeholders.type}
onChange={(v) => { onChange={(v) => dispatch(setSelectedTypes(v))}
// *** Filter by type
// *** always include metrics without metadata but label it as unknown type
// Consider tabs select instead of actual select or multi select
dispatch(setSelectedTypes(v));
}}
/> />
</EditorField> </div>
</div> )}
</div> <div className={styles.inputItem}>
{/* <h4 className={styles.resultsHeading}>Results</h4> */} <Toggletip
<div className={styles.resultsData}> aria-label="Additional settings"
<div className={styles.resultsDataCount}> content={additionalSettings}
Showing {state.filteredMetricCount} of {state.totalMetricCount} results.{' '} placement="bottom-end"
<Spinner className={`${styles.loadingSpinner} ${state.isLoading ? styles.visible : ''}`} /> closeButton={false}
<div className={styles.selectWrapper}> >
<div className={styles.alphabetRow}> <ButtonGroup className={styles.settingsBtn}>
<LetterSearch
filteredMetrics={filterMetrics(state, true)}
disableTextWrap={state.disableTextWrap}
updateLetterSearch={(letter: string) => {
if (state.letterSearch === letter) {
dispatch(setLetterSearch(''));
} else {
dispatch(setLetterSearch(letter));
}
}}
letterSearch={state.letterSearch}
/>
<Button <Button
variant="secondary" variant="secondary"
fill="text" size="md"
size="sm"
onClick={() => dispatch(showAdditionalSettings())} onClick={() => dispatch(showAdditionalSettings())}
onKeyDown={(e) => {
keyFunction(e);
}}
data-testid={testIds.showAdditionalSettings} data-testid={testIds.showAdditionalSettings}
> >
Additional Settings Additional Settings
</Button> </Button>
</div> <Button variant="secondary" icon={state.showAdditionalSettings ? 'angle-up' : 'angle-down'} />
{state.showAdditionalSettings && ( </ButtonGroup>
<> </Toggletip>
<div className={styles.selectItem}>
<Switch
data-testid={testIds.searchWithMetadata}
value={state.fullMetaSearch}
disabled={state.useBackend || !state.hasMetadata}
onChange={() => {
const newVal = !state.fullMetaSearch;
dispatch(setFullMetaSearch(newVal));
onChange({ ...query, fullMetaSearch: newVal });
fuzzySearchCallback(state.fuzzySearchQuery, newVal);
}}
onKeyDown={(e) => {
keyFunction(e);
}}
/>
<p className={styles.selectItemLabel}>{placeholders.metadataSearchSwitch}</p>
</div>
<div className={styles.selectItem}>
<Switch
value={state.excludeNullMetadata}
disabled={state.useBackend || !state.hasMetadata}
onChange={() => {
dispatch(setExcludeNullMetadata(!state.excludeNullMetadata));
onChange({ ...query, excludeNullMetadata: !state.excludeNullMetadata });
}}
onKeyDown={(e) => {
keyFunction(e);
}}
/>
<p className={styles.selectItemLabel}>{placeholders.excludeNoMetadata}</p>
</div>
<div className={styles.selectItem}>
<Switch
value={state.disableTextWrap}
onChange={() => {
dispatch(setDisableTextWrap());
onChange({ ...query, disableTextWrap: !state.disableTextWrap });
}}
onKeyDown={(e) => {
keyFunction(e);
}}
/>
<p className={styles.selectItemLabel}>Disable text wrap</p>
</div>
<div className={styles.selectItem}>
<Switch
data-testid={testIds.setUseBackend}
value={state.useBackend}
onChange={() => {
const newVal = !state.useBackend;
dispatch(setUseBackend(newVal));
onChange({ ...query, useBackend: newVal });
if (newVal === false) {
// rebuild the metrics metadata if we turn off useBackend
updateMetricsMetadata();
} else {
// check if there is text in the browse search and update
if (state.fuzzySearchQuery !== '') {
debouncedBackendSearch(state.fuzzySearchQuery);
}
// otherwise wait for user typing
}
}}
onKeyDown={(e) => {
keyFunction(e);
}}
/>
<p className={styles.selectItemLabel}>{placeholders.setUseBackend}</p>
</div>
</>
)}
</div>
</div> </div>
</div>
<div className={styles.resultsData}>
{query.metric && <i className={styles.currentlySelected}>Currently selected: {query.metric}</i>}
{query.labels.length > 0 && ( {query.labels.length > 0 && (
<p className={styles.resultsDataFiltered}> <div className={styles.resultsDataFiltered}>
These metrics have been pre-filtered by labels chosen in the label filters. <Icon name="info-circle" size="sm" />
</p> <div className={styles.resultsDataFilteredText}>
&nbsp;These metrics have been pre-filtered by labels chosen in the label filters.
</div>
</div>
)} )}
</div> </div>
<div className={styles.results}> <div className={styles.results}>
@ -346,43 +316,42 @@ export const MetricsModal = (props: MetricsModalProps) => {
state={state} state={state}
selectedIdx={state.selectedIdx} selectedIdx={state.selectedIdx}
disableTextWrap={state.disableTextWrap} disableTextWrap={state.disableTextWrap}
onFocusRow={(idx: number) => dispatch(setSelectedIdx(idx))}
/> />
)} )}
</div> </div>
<div className={styles.resultsFooter}>
<div className={styles.resultsAmount}>
Showing {state.filteredMetricCount} of {state.totalMetricCount} results
</div>
<Pagination
currentPage={state.pageNum ?? 1}
numberOfPages={calculatePageList(state).length}
onNavigate={(val: number) => {
const page = val ?? 1;
dispatch(setPageNum(page));
}}
/>
<div className={styles.resultsPerPageWrapper}>
<p className={styles.resultsPerPageLabel}># Results per page&nbsp;</p>
<Input
data-testid={testIds.resultsPerPage}
value={calculateResultsPerPage(state.resultsPerPage, DEFAULT_RESULTS_PER_PAGE, MAXIMUM_RESULTS_PER_PAGE)}
placeholder="results per page"
width={10}
title={'The maximum results per page is ' + MAXIMUM_RESULTS_PER_PAGE}
type="number"
onInput={(e) => {
const value = +e.currentTarget.value;
<div className={styles.pageSettingsWrapper}> if (isNaN(value) || value >= MAXIMUM_RESULTS_PER_PAGE) {
<div className={styles.pageSettings}> return;
<InlineField }
label="# results per page"
tooltip={'The maximum results per page is ' + MAXIMUM_RESULTS_PER_PAGE}
labelWidth={20}
>
<Input
data-testid={testIds.resultsPerPage}
value={calculateResultsPerPage(state.resultsPerPage, DEFAULT_RESULTS_PER_PAGE, MAXIMUM_RESULTS_PER_PAGE)}
placeholder="results per page"
width={20}
onInput={(e) => {
const value = +e.currentTarget.value;
if (isNaN(value)) {
return;
}
dispatch(setResultsPerPage(value)); dispatch(setResultsPerPage(value));
}}
/>
</InlineField>
<Pagination
currentPage={state.pageNum ?? 1}
numberOfPages={calculatePageList(state).length}
onNavigate={(val: number) => {
const page = val ?? 1;
dispatch(setPageNum(page));
}} }}
/> />
</div> </div>
<FeedbackLink feedbackUrl="https://forms.gle/DEMAJHoAMpe3e54CA" />
</div> </div>
</Modal> </Modal>
); );
@ -399,4 +368,5 @@ export const testIds = {
resultsPerPage: 'results-per-page', resultsPerPage: 'results-per-page',
setUseBackend: 'set-use-backend', setUseBackend: 'set-use-backend',
showAdditionalSettings: 'show-additional-settings', showAdditionalSettings: 'show-additional-settings',
inferType: 'set-infer-type',
}; };

@ -1,13 +1,13 @@
import { css } from '@emotion/css'; 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 Highlighter from 'react-highlight-words';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime'; import { Icon, Tooltip, useTheme2 } from '@grafana/ui';
import { useTheme2 } from '@grafana/ui';
import { PromVisualQuery } from '../../types'; import { PromVisualQuery } from '../../types';
import { tracking } from './state/helpers';
import { MetricsModalState } from './state/state'; import { MetricsModalState } from './state/state';
import { MetricData, MetricsData } from './types'; import { MetricData, MetricsData } from './types';
@ -19,10 +19,11 @@ type ResultsTableProps = {
state: MetricsModalState; state: MetricsModalState;
selectedIdx: number; selectedIdx: number;
disableTextWrap: boolean; disableTextWrap: boolean;
onFocusRow: (idx: number) => void;
}; };
export function ResultsTable(props: ResultsTableProps) { 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 theme = useTheme2();
const styles = getStyles(theme, disableTextWrap); const styles = getStyles(theme, disableTextWrap);
@ -36,15 +37,7 @@ export function ResultsTable(props: ResultsTableProps) {
function selectMetric(metric: MetricData) { function selectMetric(metric: MetricData) {
if (metric.value) { if (metric.value) {
onChange({ ...query, metric: metric.value }); onChange({ ...query, metric: metric.value });
reportInteraction('grafana_prom_metric_encycopedia_tracking', { tracking('grafana_prom_metric_encycopedia_tracking', state, metric.value);
metric: metric.value,
hasMetadata: state.hasMetadata,
totalMetricCount: state.totalMetricCount,
fuzzySearchQuery: state.fuzzySearchQuery,
fullMetaSearch: state.fullMetaSearch,
selectedTypes: state.selectedTypes,
letterSearch: state.letterSearch,
});
onClose(); onClose();
} }
} }
@ -63,8 +56,9 @@ export function ResultsTable(props: ResultsTableProps) {
textToHighlight={metric.type ?? ''} textToHighlight={metric.type ?? ''}
searchWords={state.metaHaystackMatches} searchWords={state.metaHaystackMatches}
autoEscape autoEscape
highlightClassName={styles.matchHighLight} highlightClassName={`${styles.matchHighLight} ${metric.inferred ? styles.italicized : ''}`}
/> />{' '}
{inferredType(metric.inferred ?? false)}
</td> </td>
<td> <td>
<Highlighter <Highlighter
@ -79,37 +73,80 @@ export function ResultsTable(props: ResultsTableProps) {
} else { } else {
return ( return (
<> <>
<td>{metric.type ?? ''}</td> <td className={metric.inferred ? styles.italicized : ''}>
{metric.type ?? ''} {inferredType(metric.inferred ?? false)}
</td>
<td>{metric.description ?? ''}</td> <td>{metric.description ?? ''}</td>
</> </>
); );
} }
} }
function inferredType(inferred: boolean): JSX.Element | undefined {
if (inferred) {
return (
<Tooltip content={'This metric type has been inferred'} placement="bottom-end">
<Icon name="info-circle" size="xs" />
</Tooltip>
);
} 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 (
<tr className={styles.noResults}>
<td colSpan={3}>{message}</td>
</tr>
);
}
return ( return (
<table className={styles.table} ref={tableRef}> <table className={styles.table} ref={tableRef}>
<thead> <thead className={styles.stickyHeader}>
<tr className={styles.header}> <tr>
<th>Name</th> <th className={`${styles.nameWidth} ${styles.tableHeaderPadding}`}>Name</th>
{state.hasMetadata && ( {state.hasMetadata && (
<> <>
<th>Type</th> <th className={`${styles.typeWidth} ${styles.tableHeaderPadding}`}>Type</th>
<th>Description</th> <th className={styles.tableHeaderPadding}>Description</th>
</> </>
)} )}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<> <>
{metrics && {metrics.length > 0 &&
metrics.map((metric: MetricData, idx: number) => { metrics.map((metric: MetricData, idx: number) => {
return ( return (
<tr <tr
key={metric?.value ?? idx} key={metric?.value ?? idx}
className={`${styles.row} ${isSelectedRow(idx) ? `${styles.selectedRow} selected-row` : ''}`} className={`${styles.row} ${isSelectedRow(idx) ? `${styles.selectedRow} selected-row` : ''}`}
onClick={() => selectMetric(metric)} onClick={() => selectMetric(metric)}
tabIndex={0}
onFocus={() => onFocusRow(idx)}
onKeyDown={(e) => {
if (e.code === 'Enter' && e.currentTarget.classList.contains('selected-row')) {
selectMetric(metric);
}
}}
> >
<td> <td className={styles.nameOverflow}>
<Highlighter <Highlighter
textToHighlight={metric?.value ?? ''} textToHighlight={metric?.value ?? ''}
searchWords={state.fullMetaSearch ? state.metaHaystackMatches : state.nameHaystackMatches} searchWords={state.fullMetaSearch ? state.metaHaystackMatches : state.nameHaystackMatches}
@ -121,6 +158,7 @@ export function ResultsTable(props: ResultsTableProps) {
</tr> </tr>
); );
})} })}
{metrics.length === 0 && !state.isLoading && noMetricsMessages()}
</> </>
</tbody> </tbody>
</table> </table>
@ -132,6 +170,7 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
return { return {
table: css` table: css`
${disableTextWrap ? '' : 'table-layout: fixed;'}
border-radius: ${theme.shape.borderRadius()}; border-radius: ${theme.shape.borderRadius()};
width: 100%; width: 100%;
white-space: ${disableTextWrap ? 'nowrap' : 'normal'}; white-space: ${disableTextWrap ? 'nowrap' : 'normal'};
@ -142,11 +181,9 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
td, td,
th { th {
min-width: ${theme.spacing(3)}; 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` row: css`
label: row; label: row;
cursor: pointer; cursor: pointer;
@ -158,6 +195,9 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
background-color: ${rowHoverBg}; background-color: ${rowHoverBg};
} }
`, `,
tableHeaderPadding: css`
padding: 8px;
`,
selectedRow: css` selectedRow: css`
background-color: ${rowHoverBg}; background-color: ${rowHoverBg};
`, `,
@ -166,5 +206,26 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
color: ${theme.components.textHighlight.text}; color: ${theme.components.textHighlight.text};
background-color: ${theme.components.textHighlight.background}; 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;
`,
}; };
}; };

@ -1,5 +1,6 @@
import { AnyAction } from '@reduxjs/toolkit'; import { AnyAction } from '@reduxjs/toolkit';
import { reportInteraction } from '@grafana/runtime';
import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasource'; import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasource';
import { getMetadataHelp, getMetadataType } from 'app/plugins/datasource/prometheus/language_provider'; import { getMetadataHelp, getMetadataType } from 'app/plugins/datasource/prometheus/language_provider';
@ -15,6 +16,7 @@ const { setFilteredMetricCount } = stateSlice.actions;
export async function setMetrics( export async function setMetrics(
datasource: PrometheusDatasource, datasource: PrometheusDatasource,
query: PromVisualQuery, query: PromVisualQuery,
inferType: boolean,
initialMetrics?: string[] initialMetrics?: string[]
): Promise<MetricsModalMetadata> { ): Promise<MetricsModalMetadata> {
// metadata is set in the metric select now // metadata is set in the metric select now
@ -32,17 +34,9 @@ export async function setMetrics(
let metricsData: MetricsData | undefined; let metricsData: MetricsData | undefined;
metricsData = initialMetrics?.map((m: string) => { metricsData = initialMetrics?.map((m: string) => {
const type = getMetadataType(m, datasource.languageProvider.metricsMetadata!); const metricData = buildMetricData(m, inferType, datasource);
const description = getMetadataHelp(m, datasource.languageProvider.metricsMetadata!);
// possibly remove the type in favor of the type select const metaDataString = `${m}¦${metricData.type}¦${metricData.description}`;
const metaDataString = `${m}¦${type}¦${description}`;
const metricData: MetricData = {
value: m,
type: type,
description: description,
};
nameHaystackDictionaryData[m] = metricData; nameHaystackDictionaryData[m] = metricData;
metaHaystackDictionaryData[metaDataString] = 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 * 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 * Filter the metrics with all the options, fuzzy, type, null metadata
* @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 { export function filterMetrics(state: MetricsModalState): MetricsData {
let filteredMetrics: MetricsData = state.metrics; let filteredMetrics: MetricsData = state.metrics;
if (state.fuzzySearchQuery && !state.useBackend) { if (state.fuzzySearchQuery && !state.useBackend) {
@ -91,27 +116,29 @@ export function filterMetrics(state: MetricsModalState, skipLetterSearch?: boole
} }
} }
if (state.letterSearch && !skipLetterSearch) { if (state.selectedTypes.length > 0) {
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) => { filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => {
// Matches type // 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 // missing type
const hasNoType = !m.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) => { filteredMetrics = filteredMetrics.filter((m: MetricData) => {
if (state.inferType && m.inferred) {
return true;
}
return m.type !== undefined && m.description !== undefined; 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 * 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 metricText
* @param labels * @param labels
* @param datasource * @param datasource
* @returns
*/ */
export async function getBackendSearchMetrics( export async function getBackendSearchMetrics(
metricText: string, metricText: string,
labels: QueryBuilderLabelFilter[], labels: QueryBuilderLabelFilter[],
datasource: PrometheusDatasource datasource: PrometheusDatasource,
inferType: boolean
): Promise<Array<{ value: string }>> { ): Promise<Array<{ value: string }>> {
const queryString = regexifyLabelValuesQueryString(metricText); const queryString = regexifyLabelValuesQueryString(metricText);
@ -173,14 +202,47 @@ export async function getBackendSearchMetrics(
const results = datasource.metricFindQuery(params); const results = datasource.metricFindQuery(params);
return await results.then((results) => { return await results.then((results) => {
return results.map((result) => { return results.map((result) => buildMetricData(result.text, inferType, datasource));
return {
value: result.text,
};
});
}); });
} }
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[] = [ export const promTypes: PromFilterOption[] = [
{ {
value: 'counter', value: 'counter',
@ -205,9 +267,9 @@ export const promTypes: PromFilterOption[] = [
export const placeholders = { export const placeholders = {
browse: 'Search metrics by name', browse: 'Search metrics by name',
metadataSearchSwitch: 'Search by metadata type and description in addition to name', metadataSearchSwitch: 'Include search with type and description',
type: 'Select...', type: 'Filter by type',
variables: 'Select...', includeNullMetadata: 'Include results with no metadata',
excludeNoMetadata: 'Exclude results with no metadata', setUseBackend: 'Enable regex search',
setUseBackend: 'Use the backend to browse metrics', inferType: 'Infer metric type',
}; };

@ -48,7 +48,6 @@ export const stateSlice = createSlice({
setFuzzySearchQuery: (state, action: PayloadAction<string>) => { setFuzzySearchQuery: (state, action: PayloadAction<string>) => {
state.fuzzySearchQuery = action.payload; state.fuzzySearchQuery = action.payload;
state.pageNum = 1; state.pageNum = 1;
state.letterSearch = '';
state.selectedIdx = 0; state.selectedIdx = 0;
}, },
setNameHaystack: (state, action: PayloadAction<string[][]>) => { setNameHaystack: (state, action: PayloadAction<string[][]>) => {
@ -63,22 +62,17 @@ export const stateSlice = createSlice({
state.fullMetaSearch = action.payload; state.fullMetaSearch = action.payload;
state.pageNum = 1; state.pageNum = 1;
}, },
setExcludeNullMetadata: (state, action: PayloadAction<boolean>) => { setIncludeNullMetadata: (state, action: PayloadAction<boolean>) => {
state.excludeNullMetadata = action.payload; state.includeNullMetadata = action.payload;
state.pageNum = 1; state.pageNum = 1;
}, },
setSelectedTypes: (state, action: PayloadAction<Array<SelectableValue<string>>>) => { setSelectedTypes: (state, action: PayloadAction<Array<SelectableValue<string>>>) => {
state.selectedTypes = action.payload; state.selectedTypes = action.payload;
state.pageNum = 1; state.pageNum = 1;
}, },
setLetterSearch: (state, action: PayloadAction<string>) => {
state.letterSearch = action.payload;
state.pageNum = 1;
},
setUseBackend: (state, action: PayloadAction<boolean>) => { setUseBackend: (state, action: PayloadAction<boolean>) => {
state.useBackend = action.payload; state.useBackend = action.payload;
state.fullMetaSearch = false; state.fullMetaSearch = false;
state.excludeNullMetadata = false;
state.pageNum = 1; state.pageNum = 1;
}, },
setSelectedIdx: (state, action: PayloadAction<number>) => { setSelectedIdx: (state, action: PayloadAction<number>) => {
@ -90,6 +84,9 @@ export const stateSlice = createSlice({
showAdditionalSettings: (state) => { showAdditionalSettings: (state) => {
state.showAdditionalSettings = !state.showAdditionalSettings; state.showAdditionalSettings = !state.showAdditionalSettings;
}, },
setInferType: (state, action: PayloadAction<boolean>) => {
state.inferType = action.payload;
},
}, },
}); });
@ -114,13 +111,13 @@ export function initialState(query?: PromVisualQuery): MetricsModalState {
pageNum: 1, pageNum: 1,
fuzzySearchQuery: '', fuzzySearchQuery: '',
fullMetaSearch: query?.fullMetaSearch ?? false, fullMetaSearch: query?.fullMetaSearch ?? false,
excludeNullMetadata: query?.excludeNullMetadata ?? false, includeNullMetadata: query?.includeNullMetadata ?? true,
selectedTypes: [], selectedTypes: [],
letterSearch: '',
useBackend: query?.useBackend ?? false, useBackend: query?.useBackend ?? false,
disableTextWrap: query?.disableTextWrap ?? false, disableTextWrap: query?.disableTextWrap ?? false,
selectedIdx: 0, selectedIdx: 0,
showAdditionalSettings: false, showAdditionalSettings: false,
inferType: true,
}; };
} }
@ -162,12 +159,10 @@ export interface MetricsModalState {
fuzzySearchQuery: string; fuzzySearchQuery: string;
/** Enables the fuzzy meatadata search */ /** Enables the fuzzy meatadata search */
fullMetaSearch: boolean; fullMetaSearch: boolean;
/** Excludes results that are missing type and description */ /** Includes results that are missing type and description */
excludeNullMetadata: boolean; includeNullMetadata: boolean;
/** Filter by prometheus type */ /** Filter by prometheus type */
selectedTypes: Array<SelectableValue<string>>; selectedTypes: Array<SelectableValue<string>>;
/** After results are filtered, select a letter to show metrics that start with that letter */
letterSearch: string;
/** Filter by the series match endpoint instead of the fuzzy search */ /** Filter by the series match endpoint instead of the fuzzy search */
useBackend: boolean; useBackend: boolean;
/** Disable text wrap for descriptions in the results table */ /** Disable text wrap for descriptions in the results table */
@ -176,6 +171,8 @@ export interface MetricsModalState {
selectedIdx: number; selectedIdx: number;
/** Display toggle switches for settings */ /** Display toggle switches for settings */
showAdditionalSettings: boolean; 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, useBackend: visQuery?.useBackend ?? false,
disableTextWrap: visQuery?.disableTextWrap ?? false, disableTextWrap: visQuery?.disableTextWrap ?? false,
fullMetaSearch: visQuery?.fullMetaSearch ?? false, fullMetaSearch: visQuery?.fullMetaSearch ?? false,
excludeNullMetadata: visQuery.excludeNullMetadata ?? false, includeNullMetadata: visQuery.includeNullMetadata ?? false,
}; };
} }
@ -205,5 +202,5 @@ export type MetricsModalSettings = {
useBackend?: boolean; useBackend?: boolean;
disableTextWrap?: boolean; disableTextWrap?: boolean;
fullMetaSearch?: boolean; fullMetaSearch?: boolean;
excludeNullMetadata?: boolean; includeNullMetadata?: boolean;
}; };

@ -18,7 +18,6 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: ${theme.spacing(2)}; gap: ${theme.spacing(2)};
margin-bottom: ${theme.spacing(2)};
`, `,
inputItemFirst: css` inputItemFirst: css`
flex-basis: 40%; flex-basis: 40%;
@ -33,51 +32,33 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
selectWrapper: css` selectWrapper: css`
margin-bottom: ${theme.spacing(1)}; margin-bottom: ${theme.spacing(1)};
`, `,
selectItem: css` resultsAmount: 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}; color: ${theme.colors.text.secondary};
`, font-size: 0.85rem;
resultsHeading: css` padding: 0 0 4px 0;
margin: 0 0 0 0;
`, `,
resultsData: css` resultsData: css`
margin: 0 0 ${theme.spacing(1)} 0; margin: 4px 0 ${theme.spacing(2)} 0;
`, `,
resultsDataCount: css` resultsDataCount: css`
margin: 0; margin: 0;
`, `,
resultsDataFiltered: css` resultsDataFiltered: css`
margin: 0; color: ${theme.colors.text.secondary};
color: ${theme.colors.warning.text}; text-align: center;
border: solid 1px rgba(204, 204, 220, 0.25);
padding: 7px;
`, `,
alphabetRow: css` resultsDataFilteredText: css`
display: flex; display: inline;
flex-direction: row; vertical-align: text-top;
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` results: css`
height: calc(80vh - 280px); height: calc(80vh - 310px);
overflow-y: scroll; overflow-y: scroll;
`, `,
pageSettingsWrapper: css` resultsFooter: css`
padding-top: ${theme.spacing(1.5)}; margin-top: 24px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
@ -85,39 +66,29 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
align-items: center; align-items: center;
position: sticky; position: sticky;
`, `,
pageSettings: css` currentlySelected: 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; color: grey;
opacity: 50%; opacity: 75%;
font-size: 0.75rem;
`, `,
loadingSpinner: css` loadingSpinner: css`
display: inline-block;
visibility: hidden; visibility: hidden;
`, `,
table: css`
white-space: ${disableTextWrap ? 'nowrap' : 'normal'};
td {
vertical-align: baseline;
padding: 0;
}
`,
tableDiv: css`
padding: 8px;
`,
visible: css` visible: css`
visibility: visible; 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;
`,
}; };
}; };

@ -2,8 +2,9 @@ export type MetricsData = MetricData[];
export type MetricData = { export type MetricData = {
value: string; value: string;
type?: string; type?: string | null;
description?: string; description?: string;
inferred?: boolean;
}; };
export type PromFilterOption = { export type PromFilterOption = {

@ -12,7 +12,7 @@ export interface PromVisualQuery {
// metrics modal additional settings // metrics modal additional settings
useBackend?: boolean; useBackend?: boolean;
disableTextWrap?: boolean; disableTextWrap?: boolean;
excludeNullMetadata?: boolean; includeNullMetadata?: boolean;
fullMetaSearch?: boolean; fullMetaSearch?: boolean;
} }

@ -20,7 +20,7 @@ export interface PromQuery extends GenPromQuery, DataQuery {
useBackend?: boolean; useBackend?: boolean;
disableTextWrap?: boolean; disableTextWrap?: boolean;
fullMetaSearch?: boolean; fullMetaSearch?: boolean;
excludeNullMetadata?: boolean; includeNullMetadata?: boolean;
} }
export enum PrometheusCacheLevel { export enum PrometheusCacheLevel {

Loading…
Cancel
Save