Prometheus: Metric encyclopedia modal redesign (#64816)

* feat: metric encyclopedia modal redesign

* test: update failing tests

* refactor: add suggestions from pr review

* test: fix failing test
This commit is contained in:
Gareth Dawson
2023-03-15 18:28:26 +00:00
committed by GitHub
parent 59a62353dd
commit 95048fc681
2 changed files with 314 additions and 238 deletions

View File

@@ -10,7 +10,7 @@ import { EmptyLanguageProviderMock } from '../../language_provider.mock';
import { PromOptions } from '../../types'; import { PromOptions } from '../../types';
import { PromVisualQuery } from '../types'; import { PromVisualQuery } from '../types';
import { MetricEncyclopediaModal, testIds, placeholders } from './MetricEncyclopediaModal'; import { MetricEncyclopediaModal, testIds } from './MetricEncyclopediaModal';
// don't care about interaction tracking in our unit tests // don't care about interaction tracking in our unit tests
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
@@ -96,7 +96,7 @@ describe('MetricEncyclopediaModal', () => {
setup(defaultQuery, listOfMetrics); setup(defaultQuery, listOfMetrics);
await waitFor(() => { await waitFor(() => {
const selectType = screen.getByText(placeholders.type); const selectType = screen.getByText('Filter by type');
expect(selectType).toBeInTheDocument(); expect(selectType).toBeInTheDocument();
}); });
}); });
@@ -119,7 +119,7 @@ describe('MetricEncyclopediaModal', () => {
setup(defaultQuery, listOfMetrics); setup(defaultQuery, listOfMetrics);
await waitFor(() => { await waitFor(() => {
const selectType = screen.getByText(placeholders.variables); const selectType = screen.getByText('Select template variables');
expect(selectType).toBeInTheDocument(); expect(selectType).toBeInTheDocument();
}); });
}); });

View File

@@ -1,17 +1,17 @@
import { css } from '@emotion/css'; import { css, cx } from '@emotion/css';
import uFuzzy from '@leeoniya/ufuzzy'; import uFuzzy from '@leeoniya/ufuzzy';
import debounce from 'debounce-promise'; import debounce from 'debounce-promise';
import { debounce as debounceLodash } from 'lodash'; import { debounce as debounceLodash } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { EditorField } from '@grafana/experimental';
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { import {
Button, Button,
Card, Card,
Collapse, Collapse,
InlineField, InlineField,
InlineLabel,
InlineSwitch, InlineSwitch,
Input, Input,
Modal, Modal,
@@ -73,12 +73,12 @@ const promTypes: PromFilterOption[] = [
]; ];
export const placeholders = { export const placeholders = {
browse: 'Browse metric names by text', browse: 'Search metrics by name',
metadataSearchSwicth: 'Browse by metadata type and description in addition to metric name', metadataSearchSwitch: 'Search by metadata type and description in addition to name',
type: 'Counter, gauge, histogram, or summary', type: 'Select...',
variables: 'Select a template variable for your metric', variables: 'Select...',
excludeNoMetadata: 'Exclude results with no metadata when filtering', excludeNoMetadata: 'Exclude results with no metadata',
setUseBackend: 'Use the backend to browse metrics and disable fuzzy search metadata browsing', setUseBackend: 'Use the backend to browse metrics',
}; };
export const DEFAULT_RESULTS_PER_PAGE = 10; export const DEFAULT_RESULTS_PER_PAGE = 10;
@@ -397,6 +397,19 @@ export const MetricEncyclopediaModal = (props: Props) => {
}); });
} }
const MAXIMUM_RESULTS_PER_PAGE = 1000;
const calculateResultsPerPage = (results: number) => {
if (results < 1) {
return 1;
}
if (results > MAXIMUM_RESULTS_PER_PAGE) {
return MAXIMUM_RESULTS_PER_PAGE;
}
return results ?? 10;
};
return ( return (
<Modal <Modal
data-testid={testIds.metricModal} data-testid={testIds.metricModal}
@@ -404,98 +417,47 @@ export const MetricEncyclopediaModal = (props: Props) => {
title="Browse Metrics" title="Browse Metrics"
onDismiss={onClose} onDismiss={onClose}
aria-label="Metric Encyclopedia" aria-label="Metric Encyclopedia"
className={styles.modal}
> >
<FeedbackLink feedbackUrl="https://forms.gle/DEMAJHoAMpe3e54CA" /> <div className={styles.inputWrapper}>
<div className={styles.spacing}> <div className={cx(styles.inputItem, styles.inputItemFirst)}>
Browse {totalMetricCount} metric{totalMetricCount > 1 ? 's' : ''} by text, by type, alphabetically or select a <EditorField label="Search metrics">
variable. <Input
{isLoading && ( data-testid={testIds.searchMetric}
<div className={styles.inlineSpinner}> placeholder={placeholders.browse}
<Spinner></Spinner> value={fuzzySearchQuery}
</div> onInput={(e) => {
)} const value = e.currentTarget.value ?? '';
</div> setFuzzySearchQuery(value);
{query.labels.length > 0 && ( if (useBackend && value === '') {
<div className={styles.spacing}> // get all metrics data if a user erases everything in the input
<i>These metrics have been pre-filtered by labels chosen in the label filters.</i> updateMetricsMetadata();
</div> } else if (useBackend) {
)} debouncedBackendSearch(value);
<div className="gf-form"> } else {
<Input // search either the names or all metadata
data-testid={testIds.searchMetric} // fuzzy search go!
placeholder={placeholders.browse}
value={fuzzySearchQuery}
autoFocus
onInput={(e) => {
const value = e.currentTarget.value ?? '';
setFuzzySearchQuery(value);
if (useBackend && value === '') {
// get all metrics data if a user erases everything in the input
updateMetricsMetadata();
} else if (useBackend) {
debouncedBackendSearch(value);
} else {
// search either the names or all metadata
// fuzzy search go!
if (fullMetaSearch) { if (fullMetaSearch) {
debouncedFuzzySearch(metaHaystack, value, setFuzzyMetaSearchResults); debouncedFuzzySearch(metaHaystack, value, setFuzzyMetaSearchResults);
} else { } else {
debouncedFuzzySearch(nameHaystack, value, setFuzzyNameSearchResults); debouncedFuzzySearch(nameHaystack, value, setFuzzyNameSearchResults);
} }
} }
setPageNum(1);
}}
/>
{hasMetadata && !useBackend && (
<InlineField label="" className={styles.labelColor} tooltip={<div>{placeholders.metadataSearchSwicth}</div>}>
<InlineSwitch
data-testid={testIds.searchWithMetadata}
showLabel={true}
value={fullMetaSearch}
onChange={() => {
setFullMetaSearch(!fullMetaSearch);
setPageNum(1); setPageNum(1);
}} }}
/> />
</InlineField> </EditorField>
)} </div>
<InlineField label="" className={styles.labelColor} tooltip={<div>{placeholders.setUseBackend}</div>}> <div className={styles.inputItem}>
<InlineSwitch <EditorField label="Filter by type">
data-testid={testIds.setUseBackend}
showLabel={true}
value={useBackend}
onChange={() => {
const newVal = !useBackend;
setUseBackend(newVal);
if (newVal === false) {
// rebuild the metrics metadata if we turn off useBackend
updateMetricsMetadata();
} else {
// check if there is text in the browse search and update
if (fuzzySearchQuery !== '') {
debouncedBackendSearch(fuzzySearchQuery);
}
// otherwise wait for user typing
}
setPageNum(1);
}}
/>
</InlineField>
</div>
{hasMetadata && !useBackend && (
<>
<div className="gf-form">
<h6>Filter by Type</h6>
</div>
<div className="gf-form">
<MultiSelect <MultiSelect
data-testid={testIds.selectType} data-testid={testIds.selectType}
inputId="my-select" inputId="my-select"
options={typeOptions} options={typeOptions}
value={selectedTypes} value={selectedTypes}
disabled={!hasMetadata || useBackend}
placeholder={placeholders.type} placeholder={placeholders.type}
onChange={(v) => { onChange={(v) => {
// *** Filter by type // *** Filter by type
@@ -505,137 +467,204 @@ export const MetricEncyclopediaModal = (props: Props) => {
setPageNum(1); setPageNum(1);
}} }}
/> />
{hasMetadata && ( </EditorField>
<InlineField label="" className={styles.labelColor} tooltip={<div>{placeholders.excludeNoMetadata}</div>}> </div>
<InlineSwitch <div className={styles.inputItem}>
showLabel={true} <EditorField label="Select template variables">
value={excludeNullMetadata} <Select
onChange={() => { inputId="my-select"
setExcludeNullMetadata(!excludeNullMetadata); options={variables}
setPageNum(1); value={''}
}} placeholder={placeholders.variables}
/> onChange={(v) => {
</InlineField> const value: string = v.value ?? '';
)} onChange({ ...query, metric: value });
</div> onClose();
</> }}
)} />
<div className="gf-form"> </EditorField>
<h6>Variables</h6> </div>
</div> </div>
<div className="gf-form">
<Select <div className={styles.selectWrapper}>
inputId="my-select" <EditorField label="Search Settings">
options={variables} <>
value={''} <div className={styles.selectItem}>
placeholder={placeholders.variables} <InlineSwitch
onChange={(v) => { data-testid={testIds.searchWithMetadata}
const value: string = v.value ?? ''; value={fullMetaSearch}
onChange({ ...query, metric: value }); disabled={useBackend || !hasMetadata}
onClose(); onChange={() => {
}} setFullMetaSearch(!fullMetaSearch);
/> setPageNum(1);
}}
/>
<p className={styles.selectItemLabel}>{placeholders.metadataSearchSwitch}</p>
</div>
{/* <div className={styles.selectItem}>
<InlineSwitch data-testid={'im not sure what this toggle does.'} value={false} onChange={() => {}} />
<p className={styles.selectItemLabel}>Disable fuzzy search metadata browsing (HELP!)</p>
</div> */}
<div className={styles.selectItem}>
<InlineSwitch
data-testid={testIds.setUseBackend}
value={useBackend}
onChange={() => {
const newVal = !useBackend;
setUseBackend(newVal);
if (newVal === false) {
// rebuild the metrics metadata if we turn off useBackend
updateMetricsMetadata();
} else {
// check if there is text in the browse search and update
if (fuzzySearchQuery !== '') {
debouncedBackendSearch(fuzzySearchQuery);
}
// otherwise wait for user typing
}
setPageNum(1);
}}
/>
<p className={styles.selectItemLabel}>{placeholders.setUseBackend}</p>
</div>
</>
</EditorField>
</div> </div>
<h5 className={`${styles.center} ${styles.topPadding}`}>{filteredMetricCount} Results</h5>
<div className={`${styles.center} ${styles.bottomPadding}`}>{letterSearchComponent()}</div> <h4 className={styles.resultsHeading}>Results</h4>
{metrics && <div className={styles.resultsData}>
displayedMetrics(metrics).map((metric: MetricData, idx) => { <div className={styles.resultsDataCount}>
return ( Showing {filteredMetricCount} of {totalMetricCount} total metrics.{' '}
<Collapse {isLoading && <Spinner className={styles.loadingSpinner} />}
aria-label={`open and close ${metric.value} query starter card`} </div>
data-testid={testIds.metricCard} {query.labels.length > 0 && (
key={metric.value} <p className={styles.resultsDataFiltered}>
label={metric.value} These metrics have been pre-filtered by labels chosen in the label filters.
isOpen={openTabs.includes(metric.value)} </p>
collapsible={true} )}
onToggle={() => </div>
setOpenTabs((tabs) =>
// close tab if it's already open, otherwise open it <div className={styles.alphabetRow}>
tabs.includes(metric.value) ? tabs.filter((t) => t !== metric.value) : [...tabs, metric.value] <div>{letterSearchComponent()}</div>
) <div className={styles.selectItem}>
} <InlineSwitch
> value={excludeNullMetadata}
<div className={styles.cardsContainer}> disabled={useBackend || !hasMetadata}
<Card className={styles.card}> onChange={() => {
<Card.Description> setExcludeNullMetadata(!excludeNullMetadata);
{metric.description && metric.type ? ( setPageNum(1);
<> }}
Type: <span className={styles.metadata}>{metric.type}</span> />
<br /> <p className={styles.selectItemLabel}>{placeholders.excludeNoMetadata}</p>
Description: <span className={styles.metadata}>{metric.description}</span> </div>
</> </div>
) : (
<i>No metadata available</i> <div className={styles.results}>
)} {metrics &&
</Card.Description> displayedMetrics(metrics).map((metric: MetricData, idx) => {
<Card.Actions> return (
{/* *** Make selecting a metric easier, consider click on text */} <Collapse
<Button aria-label={`open and close ${metric.value} query starter card`}
size="sm" data-testid={testIds.metricCard}
aria-label="use this metric button" key={metric.value}
data-testid={testIds.useMetric} label={metric.value}
onClick={() => { isOpen={openTabs.includes(metric.value)}
onChange({ ...query, metric: metric.value }); collapsible={true}
reportInteraction('grafana_prom_metric_encycopedia_tracking', { onToggle={() =>
metric: metric.value, setOpenTabs((tabs) =>
hasVariables: variables.length > 0, // close tab if it's already open, otherwise open it
hasMetadata: hasMetadata, tabs.includes(metric.value) ? tabs.filter((t) => t !== metric.value) : [...tabs, metric.value]
totalMetricCount: metrics.length, )
fuzzySearchQuery: fuzzySearchQuery, }
fullMetaSearch: fullMetaSearch, >
selectedTypes: selectedTypes, <div className={styles.cardsContainer}>
letterSearch: letterSearch, <Card className={styles.card}>
}); <Card.Description>
onClose(); {metric.description && metric.type ? (
}} <>
> Type: <span className={styles.metadata}>{metric.type}</span>
Use this metric <br />
</Button> Description: <span className={styles.metadata}>{metric.description}</span>
</Card.Actions> </>
</Card> ) : (
</div> <i>No metadata available</i>
</Collapse> )}
); </Card.Description>
})} <Card.Actions>
<br /> {/* *** Make selecting a metric easier, consider click on text */}
<div className="gf-form"> <Button
<InlineLabel width={20} className="query-keyword"> size="sm"
Select Page aria-label="use this metric button"
</InlineLabel> data-testid={testIds.useMetric}
<Select onClick={() => {
data-testid={testIds.searchPage} onChange({ ...query, metric: metric.value });
options={calculatePageList(metrics, resultsPerPage).map((p) => { reportInteraction('grafana_prom_metric_encycopedia_tracking', {
return { value: p, label: '' + p }; metric: metric.value,
hasVariables: variables.length > 0,
hasMetadata: hasMetadata,
totalMetricCount: metrics.length,
fuzzySearchQuery: fuzzySearchQuery,
fullMetaSearch: fullMetaSearch,
selectedTypes: selectedTypes,
letterSearch: letterSearch,
});
onClose();
}}
>
Use this metric
</Button>
</Card.Actions>
</Card>
</div>
</Collapse>
);
})} })}
value={pageNum ?? 1}
placeholder="select page"
onChange={(e) => {
const value = e.value ?? 1;
setPageNum(value);
}}
/>
<InlineLabel width={20} className="query-keyword">
# results per page
</InlineLabel>
<Input
data-testid={testIds.resultsPerPage}
value={resultsPerPage ?? 10}
placeholder="results per page"
onInput={(e) => {
const value = +e.currentTarget.value;
if (isNaN(value)) {
return;
}
setResultsPerPage(value);
}}
/>
</div> </div>
<br />
<Button aria-label="close metric encyclopedia modal" variant="secondary" onClick={onClose}> <div className={styles.pageSettingsWrapper}>
Close <div className={styles.pageSettings}>
</Button> <InlineField label="Select page" labelWidth={20} className="query-keyword">
<Select
data-testid={testIds.searchPage}
options={calculatePageList(metrics, resultsPerPage).map((p) => {
return { value: p, label: '' + p };
})}
value={pageNum ?? 1}
placeholder="select page"
width={20}
onChange={(e) => {
const value = e.value ?? 1;
setPageNum(value);
}}
/>
</InlineField>
<InlineField
label="# results per page"
tooltip={'The maximum results per page is ' + MAXIMUM_RESULTS_PER_PAGE}
labelWidth={20}
>
<Input
data-testid={testIds.resultsPerPage}
value={calculateResultsPerPage(resultsPerPage)}
placeholder="results per page"
width={20}
onInput={(e) => {
const value = +e.currentTarget.value;
if (isNaN(value)) {
return;
}
setResultsPerPage(value);
}}
/>
</InlineField>
</div>
<FeedbackLink feedbackUrl="https://forms.gle/DEMAJHoAMpe3e54CA" />
</div>
</Modal> </Modal>
); );
}; };
@@ -671,33 +700,83 @@ function alphabetically(ascending: boolean, metadataFilters: boolean) {
const getStyles = (theme: GrafanaTheme2) => { const getStyles = (theme: GrafanaTheme2) => {
return { return {
modal: css`
width: 85vw;
${theme.breakpoints.down('md')} {
width: 100%;
}
`,
inputWrapper: css`
display: flex;
flex-direction: row;
gap: ${theme.spacing(2)};
margin-bottom: ${theme.spacing(2)};
`,
inputItemFirst: css`
flex-basis: 40%;
`,
inputItem: css`
flex-grow: 1;
`,
selectWrapper: css`
margin-bottom: ${theme.spacing(2)};
`,
selectItem: css`
display: flex;
flex-direction: row;
`,
selectItemLabel: css`
margin: 0 0 0 ${theme.spacing(1)};
align-self: center;
color: ${theme.colors.text.secondary};
`,
resultsHeading: css`
margin: 0 0 0 0;
`,
resultsData: css`
margin: 0 0 ${theme.spacing(1)} 0;
`,
resultsDataCount: css`
margin: 0;
`,
resultsDataFiltered: css`
margin: 0;
color: ${theme.colors.warning.main};
`,
alphabetRow: css`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`,
results: css`
height: 300px;
overflow-y: scroll;
`,
pageSettingsWrapper: css`
padding-top: ${theme.spacing(1.5)};
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`,
pageSettings: css`
display: flex;
flex-direction: row;
align-items: center;
`,
cardsContainer: css` cardsContainer: css`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
`, `,
spacing: css`
margin-bottom: ${theme.spacing(1)};
`,
center: css`
text-align: center;
padding: 4px;
width: 100%;
`,
topPadding: css`
padding: 10px 0 0 0;
`,
bottomPadding: css`
padding: 0 0 4px 0;
`,
card: css` card: css`
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
`, `,
selAlpha: css` selAlpha: css`
font-style: italic;
cursor: pointer; cursor: pointer;
color: #6e9fff; color: #6e9fff;
`, `,
@@ -710,10 +789,7 @@ const getStyles = (theme: GrafanaTheme2) => {
metadata: css` metadata: css`
color: rgb(204, 204, 220); color: rgb(204, 204, 220);
`, `,
labelColor: css` loadingSpinner: css`
color: #6e9fff;
`,
inlineSpinner: css`
display: inline-block; display: inline-block;
`, `,
}; };