Prometheus: Metric encyclopedia (#63423)

* add metric encyclopedia feature toggle and component

* remove unused button

* move file, add test file

* add tests

* add pagination and tests

* test with 10,000,000 metrics

* remove unused import

* add filter by type

* search alphabetically and add switch to exclude metrics with no metadata

* add suggested functions and filter for functions

* allow user to select variables in encyclopedia

* fix style and tests

* add fuzzy search by either metric name or all metadata

* if missing metadata, remove metadata fuzzy search option, exclude metadata, and filter by type

* add encyclopedia feature tracking

* indicate that metrics are filtered by labels

* handle metric singular or plural

* add tooltips and fix language

* add filtering tests

* change 'search' to 'browse'

* remove functions filter and tests as not part of work flow

* add m.e. button and selected metric is a tag

* fix hanging search and update styles, padding, labels, and groupings

* small performance improvements

* fix tests

* add backend metrics query option

* add loading spinner for start load and backend search

* autofocus search input

* Update docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md

Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>

* run prettier

* run prettier

* fix text for feature toggle

* for license check since https://cla-assistant.io/check/grafana/grafana?pullRequest=<PR#> is not working

* fixing tests

* fix feature toggle docs

* fix feature toggle

* fix feature toggle

* add owner to feature toggle

---------

Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>
This commit is contained in:
Brendan O'Handley 2023-03-07 13:41:05 -05:00 committed by GitHub
parent 94f39e69a3
commit 9b6e531549
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1119 additions and 8 deletions

View File

@ -92,6 +92,7 @@ Alpha features might be changed or removed without prior notice.
| `individualCookiePreferences` | Support overriding cookie preferences per user |
| `drawerDataSourcePicker` | Changes the user experience for data source selection to a drawer. |
| `traceqlSearch` | Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries |
| `prometheusMetricEncyclopedia` | Replaces the Prometheus query builder metric select option with a paginated and filterable component |
## Development feature toggles

View File

@ -81,4 +81,5 @@ export interface FeatureToggles {
individualCookiePreferences?: boolean;
drawerDataSourcePicker?: boolean;
traceqlSearch?: boolean;
prometheusMetricEncyclopedia?: boolean;
}

View File

@ -393,5 +393,12 @@ var (
State: FeatureStateAlpha,
FrontendOnly: true,
},
{
Name: "prometheusMetricEncyclopedia",
Description: "Replaces the Prometheus query builder metric select option with a paginated and filterable component",
State: FeatureStateAlpha,
FrontendOnly: true,
Owner: "O11y-metrics",
},
}
)

View File

@ -266,4 +266,8 @@ const (
// FlagTraceqlSearch
// Enables the &#39;TraceQL Search&#39; tab for the Tempo datasource which provides a UI to generate TraceQL queries
FlagTraceqlSearch = "traceqlSearch"
// FlagPrometheusMetricEncyclopedia
// Replaces the Prometheus query builder metric select option with a paginated and filterable component
FlagPrometheusMetricEncyclopedia = "prometheusMetricEncyclopedia"
)

View File

@ -77,6 +77,20 @@ export function getMetadataString(metric: string, metadata: PromMetricsMetadata)
return `${type.toUpperCase()}: ${help}`;
}
export function getMetadataHelp(metric: string, metadata: PromMetricsMetadata): string | undefined {
if (!metadata[metric]) {
return undefined;
}
return metadata[metric].help;
}
export function getMetadataType(metric: string, metadata: PromMetricsMetadata): string | undefined {
if (!metadata[metric]) {
return undefined;
}
return metadata[metric].type;
}
const PREFIX_DELIMITER_REGEX =
/(="|!="|=~"|!~"|\{|\[|\(|\+|-|\/|\*|%|\^|\band\b|\bor\b|\bunless\b|==|>=|!=|<=|>|<|=|~|,)/;

View File

@ -0,0 +1,297 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import PromQlLanguageProvider from '../../language_provider';
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
import { PromOptions } from '../../types';
import { PromVisualQuery } from '../types';
import { MetricEncyclopediaModal, testIds, placeholders } from './MetricEncyclopediaModal';
// don't care about interaction tracking in our unit tests
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
describe('MetricEncyclopediaModal', () => {
it('renders the modal', async () => {
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
expect(screen.getByText('Browse Metrics')).toBeInTheDocument();
});
});
it('renders a list of metrics', async () => {
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
expect(screen.getByText('all-metrics')).toBeInTheDocument();
});
});
it('renders a list of metrics filtered by labels in the PromVisualQuery', async () => {
const query: PromVisualQuery = {
metric: 'random_metric',
labels: [
{
op: '=',
label: 'action',
value: 'add_presence',
},
],
operations: [],
};
setup(query, listOfMetrics);
await waitFor(() => {
expect(screen.getByText('with-labels')).toBeInTheDocument();
});
});
it('displays a type for a metric when the metric is clicked', async () => {
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
expect(screen.getByText('all-metrics')).toBeInTheDocument();
});
const interactiveMetric = screen.getByText('all-metrics');
await userEvent.click(interactiveMetric);
expect(screen.getByText('all-metrics-type')).toBeInTheDocument();
});
it('displays a description for a metric', async () => {
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
expect(screen.getByText('all-metrics')).toBeInTheDocument();
});
const interactiveMetric = screen.getByText('all-metrics');
await userEvent.click(interactiveMetric);
expect(screen.getByText('all-metrics-help')).toBeInTheDocument();
});
it('displays no metadata for a metric missing metadata when the metric is clicked', async () => {
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
expect(screen.getByText('b')).toBeInTheDocument();
});
const interactiveMetric = screen.getByText('b');
await userEvent.click(interactiveMetric);
expect(screen.getByText('No metadata available')).toBeInTheDocument();
});
// Filtering
it('has a filter for selected type', async () => {
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
const selectType = screen.getByText(placeholders.type);
expect(selectType).toBeInTheDocument();
});
});
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();
});
it('allows a user to select a template variable', async () => {
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
const selectType = screen.getByText(placeholders.variables);
expect(selectType).toBeInTheDocument();
});
});
// Pagination
it('shows metrics within a range by pagination', async () => {
// default resultsPerPage is 10
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
expect(screen.getByText('all-metrics')).toBeInTheDocument();
expect(screen.getByText('a_bucket')).toBeInTheDocument();
expect(screen.getByText('a')).toBeInTheDocument();
expect(screen.getByText('b')).toBeInTheDocument();
expect(screen.getByText('c')).toBeInTheDocument();
expect(screen.getByText('d')).toBeInTheDocument();
expect(screen.getByText('e')).toBeInTheDocument();
expect(screen.getByText('f')).toBeInTheDocument();
expect(screen.getByText('g')).toBeInTheDocument();
expect(screen.getByText('h')).toBeInTheDocument();
});
});
it('does not show metrics outside a range by pagination', async () => {
// default resultsPerPage is 10
setup(defaultQuery, listOfMetrics);
await waitFor(() => {
const metricOutsideRange = screen.queryByText('j');
expect(metricOutsideRange).toBeNull();
});
});
it('shows results metrics per page chosen by the user', async () => {
setup(defaultQuery, listOfMetrics);
const resultsPerPageInput = screen.getByTestId(testIds.resultsPerPage);
await userEvent.type(resultsPerPageInput, '12');
const metricInsideRange = screen.getByText('j');
expect(metricInsideRange).toBeInTheDocument();
});
it('paginates millions of metrics and does not run out of memory', async () => {
const millionsOfMetrics: string[] = [...Array(1000000).keys()].map((i) => '' + i);
setup(defaultQuery, millionsOfMetrics);
await waitFor(() => {
// doesn't break on loading
expect(screen.getByText('0')).toBeInTheDocument();
});
const resultsPerPageInput = screen.getByTestId(testIds.resultsPerPage);
// doesn't break on changing results per page
await userEvent.type(resultsPerPageInput, '11');
const metricInsideRange = screen.getByText('10');
expect(metricInsideRange).toBeInTheDocument();
});
// Fuzzy search
it('searches and filter by metric name with a fuzzy search', async () => {
// search for a_bucket by name
setup(defaultQuery, listOfMetrics);
let metricAll: HTMLElement | null;
let metricABucket: HTMLElement | null;
await waitFor(() => {
metricAll = screen.getByText('all-metrics');
metricABucket = screen.getByText('a_bucket');
expect(metricAll).toBeInTheDocument();
expect(metricABucket).toBeInTheDocument();
});
const searchMetric = screen.getByTestId(testIds.searchMetric);
expect(searchMetric).toBeInTheDocument();
await userEvent.type(searchMetric, 'a_b');
await waitFor(() => {
metricABucket = screen.getByText('a_bucket');
expect(metricABucket).toBeInTheDocument();
metricAll = screen.queryByText('all-metrics');
expect(metricAll).toBeNull();
});
});
it('searches by all metric metadata with a fuzzy search', async () => {
// search for a_bucket by metadata type counter but only type countt
setup(defaultQuery, listOfMetrics);
let metricABucket: HTMLElement | null;
await waitFor(() => {
metricABucket = screen.getByText('a_bucket');
expect(metricABucket).toBeInTheDocument();
});
const metadataSwitch = screen.getByTestId(testIds.searchWithMetadata);
expect(metadataSwitch).toBeInTheDocument();
await userEvent.click(metadataSwitch);
const searchMetric = screen.getByTestId(testIds.searchMetric);
expect(searchMetric).toBeInTheDocument();
await userEvent.type(searchMetric, 'countt');
await waitFor(() => {
metricABucket = screen.getByText('a_bucket');
expect(metricABucket).toBeInTheDocument();
});
});
});
const defaultQuery: PromVisualQuery = {
metric: 'random_metric',
labels: [],
operations: [],
};
const listOfMetrics: string[] = ['all-metrics', 'a_bucket', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
function createDatasource(metrics: string[], withLabels?: boolean) {
const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider;
// display different results if their are labels selected in the PromVisualQuery
if (withLabels) {
languageProvider.getSeries = () => Promise.resolve({ __name__: ['with-labels'] });
languageProvider.metricsMetadata = {
'with-labels': {
type: 'with-labels-type',
help: 'with-labels-help',
},
};
} else {
// all metrics
languageProvider.getLabelValues = () => Promise.resolve(metrics);
languageProvider.metricsMetadata = {
'all-metrics': {
type: 'all-metrics-type',
help: 'all-metrics-help',
},
a: {
type: 'counter',
help: 'a-metric-help',
},
a_bucket: {
type: 'counter',
help: 'for functions',
},
// missing metadata for other metrics is tested for, see below
};
}
const datasource = new PrometheusDatasource(
{
url: '',
jsonData: {},
meta: {} as DataSourcePluginMeta,
} as DataSourceInstanceSettings<PromOptions>,
undefined,
undefined,
languageProvider
);
return datasource;
}
function createProps(query: PromVisualQuery, datasource: PrometheusDatasource) {
return {
datasource,
isOpen: true,
onChange: jest.fn(),
onClose: jest.fn(),
query: query,
};
}
function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) {
const withLabels: boolean = query.labels.length > 0;
const datasource = createDatasource(metrics, withLabels);
const props = createProps(query, datasource);
// render the modal only
const { container } = render(<MetricEncyclopediaModal {...props} />);
return container;
}

View File

@ -0,0 +1,735 @@
import { css } from '@emotion/css';
import uFuzzy from '@leeoniya/ufuzzy';
import debounce from 'debounce-promise';
import { debounce as debounceLodash } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import {
Button,
Card,
Collapse,
InlineField,
InlineLabel,
InlineSwitch,
Input,
Modal,
MultiSelect,
Select,
Spinner,
useStyles2,
} from '@grafana/ui';
import { PrometheusDatasource } from '../../datasource';
import { getMetadataHelp, getMetadataType } from '../../language_provider';
import { promQueryModeller } from '../PromQueryModeller';
import { regexifyLabelValuesQueryString } from '../shared/parsingUtils';
import { PromVisualQuery } from '../types';
type Props = {
datasource: PrometheusDatasource;
isOpen: boolean;
query: PromVisualQuery;
onClose: () => void;
onChange: (query: PromVisualQuery) => void;
};
type MetricsData = MetricData[];
type MetricData = {
value: string;
type?: string;
description?: string;
};
type PromFilterOption = {
value: string;
description: string;
};
const promTypes: PromFilterOption[] = [
{
value: 'counter',
description:
'A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart.',
},
{
value: 'gauge',
description: 'A metric that represents a single numerical value that can arbitrarily go up and down.',
},
{
value: 'histogram',
description:
'A histogram samples observations (usually things like request durations or response sizes) and counts them in configurable buckets.',
},
{
value: 'summary',
description:
'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.',
},
];
export const placeholders = {
browse: 'Browse metric names by text',
metadataSearchSwicth: 'Browse by metadata type and description in addition to metric name',
type: 'Counter, gauge, histogram, or summary',
variables: 'Select a template variable for your metric',
excludeNoMetadata: 'Exclude results with no metadata when filtering',
setUseBackend: 'Use the backend to browse metrics and disable fuzzy search metadata browsing',
};
export const DEFAULT_RESULTS_PER_PAGE = 10;
export const MetricEncyclopediaModal = (props: Props) => {
const uf = UseUfuzzy();
const { datasource, isOpen, onClose, onChange, query } = props;
const [variables, setVariables] = useState<Array<SelectableValue<string>>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
// metric list
const [metrics, setMetrics] = useState<MetricsData>([]);
const [hasMetadata, setHasMetadata] = useState<boolean>(true);
const [haystack, setHaystack] = useState<string[]>([]);
const [nameHaystack, setNameHaystack] = useState<string[]>([]);
const [openTabs, setOpenTabs] = useState<string[]>([]);
// pagination
const [resultsPerPage, setResultsPerPage] = useState<number>(DEFAULT_RESULTS_PER_PAGE);
const [pageNum, setPageNum] = useState<number>(1);
// filters
const [fuzzySearchQuery, setFuzzySearchQuery] = useState<string>('');
const [fuzzyMetaSearchResults, setFuzzyMetaSearchResults] = useState<number[]>([]);
const [fuzzyNameSearchResults, setNameFuzzySearchResults] = useState<number[]>([]);
const [fullMetaSearch, setFullMetaSearch] = useState<boolean>(false);
const [excludeNullMetadata, setExcludeNullMetadata] = useState<boolean>(false);
const [selectedTypes, setSelectedTypes] = useState<Array<SelectableValue<string>>>([]);
const [letterSearch, setLetterSearch] = useState<string | null>(null);
// backend search metric names by text
const [useBackend, setUseBackend] = useState<boolean>(false);
const updateMetricsMetadata = useCallback(async () => {
// *** Loading Gif
setIsLoading(true);
// Makes sure we loaded the metadata for metrics. Usually this is done in the start() method of the provider but we
// don't use it with the visual builder and there is no need to run all the start() setup anyway.
if (!datasource.languageProvider.metricsMetadata) {
await datasource.languageProvider.loadMetricsMetadata();
}
// Error handling for when metrics metadata returns as undefined
// *** Will have to handle metadata filtering if this happens
// *** only display metrics fuzzy search, filter and pagination
if (!datasource.languageProvider.metricsMetadata) {
setHasMetadata(false);
datasource.languageProvider.metricsMetadata = {};
}
// filter by adding the query.labels to the search?
// *** do this in the filter???
let metrics;
if (query.labels.length > 0) {
const expr = promQueryModeller.renderLabels(query.labels);
metrics = (await datasource.languageProvider.getSeries(expr, true))['__name__'] ?? [];
} else {
metrics = (await datasource.languageProvider.getLabelValues('__name__')) ?? [];
}
let haystackData: string[] = [];
let haystackNameData: string[] = [];
let metricsData: MetricsData = metrics.map((m) => {
const type = getMetadataType(m, datasource.languageProvider.metricsMetadata!);
const description = getMetadataHelp(m, datasource.languageProvider.metricsMetadata!);
// string[] = name + type + description
haystackData.push(`${m} ${type} ${description}`);
haystackNameData.push(m);
return {
value: m,
type: type,
description: description,
};
});
// setting this by the backend if useBackend is true
setMetrics(metricsData);
setHaystack(haystackData);
setNameHaystack(haystackNameData);
setVariables(
datasource.getVariables().map((v) => {
return {
value: v,
label: v,
};
})
);
setIsLoading(false);
}, [query, datasource]);
useEffect(() => {
updateMetricsMetadata();
}, [updateMetricsMetadata]);
const styles = useStyles2(getStyles);
const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => {
return {
value: t.value,
label: t.value,
description: t.description,
};
});
function calculatePageList(metrics: MetricsData, resultsPerPage: number) {
if (!metrics.length) {
return [];
}
const calcResultsPerPage: number = resultsPerPage === 0 ? 1 : resultsPerPage;
const pages = Math.floor(filterMetrics(metrics).length / calcResultsPerPage) + 1;
return [...Array(pages).keys()].map((i) => i + 1);
}
function sliceMetrics(metrics: MetricsData, pageNum: number, resultsPerPage: number) {
const calcResultsPerPage: number = resultsPerPage === 0 ? 1 : resultsPerPage;
const start: number = pageNum === 1 ? 0 : (pageNum - 1) * calcResultsPerPage;
const end: number = start + calcResultsPerPage;
return metrics.slice(start, end);
}
function hasMetaDataFilters() {
return selectedTypes.length > 0;
}
function fuzzySearch(query: string) {
// search either the names or all metadata
// fuzzy search go!
if (fullMetaSearch) {
// considered simply filtering indexes with reduce and includes
// Performance comparison with 13,000 metrics searching metadata
// Fuzzy 6326ms
// Reduce & Includes 5541ms
const metaIdxs = uf.filter(haystack, query.toLowerCase());
setFuzzyMetaSearchResults(metaIdxs);
} else {
const nameIdxs = uf.filter(nameHaystack, query.toLowerCase());
setNameFuzzySearchResults(nameIdxs);
}
}
const debouncedFuzzySearch = debounceLodash((query: string) => {
fuzzySearch(query);
}, 300);
/**
* Filter
*
* @param metrics
* @param skipLetterSearch
* @returns
*/
function filterMetrics(metrics: MetricsData, skipLetterSearch?: boolean): MetricsData {
let filteredMetrics: MetricsData = metrics;
if (fuzzySearchQuery || excludeNullMetadata || (letterSearch && !skipLetterSearch) || selectedTypes.length > 0) {
filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => {
let keepMetric = false;
// search by text
if (fuzzySearchQuery) {
if (useBackend) {
// skip for backend!
keepMetric = true;
} else if (fullMetaSearch) {
keepMetric = fuzzyMetaSearchResults.includes(idx);
} else {
keepMetric = fuzzyNameSearchResults.includes(idx);
}
}
// user clicks the alphabet search
// backend and frontend
if (letterSearch && !skipLetterSearch) {
const letters: string[] = [letterSearch, letterSearch.toLowerCase()];
keepMetric = letters.includes(m.value[0]);
}
// select by type, counter, gauge, etc
// skip for backend because no metadata is returned
if (selectedTypes.length > 0 && !useBackend) {
// return the metric that matches the type
// return the metric if it has no type AND we are NOT excluding metrics without metadata
// Matches type
const matchesSelectedType = selectedTypes.some((t) => t.value === m.type);
// missing type
const hasNoType = !m.type;
return matchesSelectedType || (hasNoType && !excludeNullMetadata);
}
return keepMetric;
});
}
return filteredMetrics;
}
/**
* The filtered and paginated metrics displayed in the modal
* */
function displayedMetrics(metrics: MetricsData) {
const filteredSorted: MetricsData = filterMetrics(metrics).sort(alphabetically(true, hasMetaDataFilters()));
const displayedMetrics: MetricsData = sliceMetrics(filteredSorted, pageNum, resultsPerPage);
return displayedMetrics;
}
/**
* The backend debounced search
*/
const debouncedBackendSearch = useMemo(
() =>
debounce(async (metricText: string) => {
const queryString = regexifyLabelValuesQueryString(metricText);
const labelsParams = query.labels.map((label) => {
return `,${label.label}="${label.value}"`;
});
const params = `label_values({__name__=~".*${queryString}"${
query.labels ? labelsParams.join() : ''
}},__name__)`;
const results = datasource.metricFindQuery(params);
const metrics = await results.then((results) => {
return results.map((result) => {
return {
value: result.text,
};
});
});
setMetrics(metrics);
setIsLoading(false);
}, 300),
[datasource, query.labels]
);
return (
<Modal
data-testid={testIds.metricModal}
isOpen={isOpen}
title="Browse Metrics"
onDismiss={onClose}
aria-label="Metric Encyclopedia"
>
<div className={styles.spacing}>
Browse {metrics.length} metric{metrics.length > 1 ? 's' : ''} by text, by type, alphabetically or select a
variable.
{isLoading && (
<div className={styles.inlineSpinner}>
<Spinner></Spinner>
</div>
)}
</div>
{query.labels.length > 0 && (
<div className={styles.spacing}>
<i>These metrics have been pre-filtered by labels chosen in the label filters.</i>
</div>
)}
<div className="gf-form">
<Input
data-testid={testIds.searchMetric}
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) {
setIsLoading(true);
debouncedBackendSearch(value);
} else {
// do the search on the frontend
debouncedFuzzySearch(value);
}
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);
}}
/>
</InlineField>
)}
<InlineField label="" className={styles.labelColor} tooltip={<div>{placeholders.setUseBackend}</div>}>
<InlineSwitch
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
data-testid={testIds.selectType}
inputId="my-select"
options={typeOptions}
value={selectedTypes}
placeholder={placeholders.type}
onChange={(v) => {
// *** Filter by type
// *** always include metrics without metadata but label it as unknown type
// Consider tabs select instead of actual select or multi select
setSelectedTypes(v);
setPageNum(1);
}}
/>
{hasMetadata && (
<InlineField label="" className={styles.labelColor} tooltip={<div>{placeholders.excludeNoMetadata}</div>}>
<InlineSwitch
showLabel={true}
value={excludeNullMetadata}
onChange={() => {
setExcludeNullMetadata(!excludeNullMetadata);
setPageNum(1);
}}
/>
</InlineField>
)}
</div>
</>
)}
<div className="gf-form">
<h6>Variables</h6>
</div>
<div className="gf-form">
<Select
inputId="my-select"
options={variables}
value={''}
placeholder={placeholders.variables}
onChange={(v) => {
const value: string = v.value ?? '';
onChange({ ...query, metric: value });
onClose();
}}
/>
</div>
<h5 className={`${styles.center} ${styles.topPadding}`}>Results</h5>
<div className={`${styles.center} ${styles.bottomPadding}`}>
{[
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
].map((letter, idx, coll) => {
const active: boolean = filterMetrics(metrics, true).some((m: MetricData) => {
return m.value[0] === letter || m.value[0] === letter?.toLowerCase();
});
// starts with letter search
// filter by starts with letter
// if same letter searched null out remove letter search
function updateLetterSearch() {
if (letterSearch === letter) {
setLetterSearch(null);
} else {
setLetterSearch(letter);
}
setPageNum(1);
}
// selected letter to filter by
const selectedClass: string = letterSearch === letter ? styles.selAlpha : '';
// these letters are represented in the list of metrics
const activeClass: string = active ? styles.active : styles.gray;
return (
<span
onClick={active ? updateLetterSearch : () => {}}
className={`${selectedClass} ${activeClass}`}
key={letter}
data-testid={'letter-' + letter}
>
{letter + ' '}
{/* {idx !== coll.length - 1 ? '|': ''} */}
</span>
);
})}
</div>
{metrics &&
displayedMetrics(metrics).map((metric: MetricData, idx) => {
return (
<Collapse
aria-label={`open and close ${metric.value} query starter card`}
data-testid={testIds.metricCard}
key={metric.value}
label={metric.value}
isOpen={openTabs.includes(metric.value)}
collapsible={true}
onToggle={() =>
setOpenTabs((tabs) =>
// close tab if it's already open, otherwise open it
tabs.includes(metric.value) ? tabs.filter((t) => t !== metric.value) : [...tabs, metric.value]
)
}
>
<div className={styles.cardsContainer}>
<Card className={styles.card}>
<Card.Description>
{metric.description && metric.type ? (
<>
Type: <span className={styles.metadata}>{metric.type}</span>
<br />
Description: <span className={styles.metadata}>{metric.description}</span>
</>
) : (
<i>No metadata available</i>
)}
</Card.Description>
<Card.Actions>
{/* *** Make selecting a metric easier, consider click on text */}
<Button
size="sm"
aria-label="use this metric button"
data-testid={testIds.useMetric}
onClick={() => {
onChange({ ...query, metric: metric.value });
reportInteraction('grafana_prom_metric_encycopedia_tracking', {
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>
);
})}
<br />
<div className="gf-form">
<InlineLabel width={20} className="query-keyword">
Select Page
</InlineLabel>
<Select
data-testid={testIds.searchPage}
options={calculatePageList(metrics, resultsPerPage).map((p) => {
return { value: p, label: '' + p };
})}
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>
<br />
<Button aria-label="close metric encyclopedia modal" variant="secondary" onClick={onClose}>
Close
</Button>
</Modal>
);
};
function alphabetically(ascending: boolean, metadataFilters: boolean) {
return function (a: MetricData, b: MetricData) {
// equal items sort equally
if (a.value === b.value) {
return 0;
}
// *** NO METADATA? SORT LAST
// undefined metadata sort after anything else
// if filters are on
if (metadataFilters) {
if (a.type === undefined) {
return 1;
}
if (b.type === undefined) {
return -1;
}
}
// otherwise, if we're ascending, lowest sorts first
if (ascending) {
return a.value < b.value ? -1 : 1;
}
// if descending, highest sorts first
return a.value < b.value ? 1 : -1;
};
}
function UseUfuzzy(): uFuzzy {
const ref = useRef<uFuzzy>();
if (!ref.current) {
ref.current = new uFuzzy({
intraMode: 1,
intraIns: 1,
intraSub: 1,
intraTrn: 1,
intraDel: 1,
});
}
return ref.current;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
cardsContainer: css`
display: flex;
flex-direction: row;
flex-wrap: wrap;
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`
width: 100%;
display: flex;
flex-direction: column;
`,
selAlpha: css`
font-style: italic;
cursor: pointer;
color: #6e9fff;
`,
active: css`
cursor: pointer;
`,
gray: css`
color: grey;
`,
metadata: css`
color: rgb(204, 204, 220);
`,
labelColor: css`
color: #6e9fff;
`,
inlineSpinner: css`
display: inline-block;
`,
};
};
export const testIds = {
metricModal: 'metric-modal',
searchMetric: 'search-metric',
searchWithMetadata: 'search-with-metadata',
selectType: 'select-type',
metricCard: 'metric-card',
useMetric: 'use-metric',
searchPage: 'search-page',
resultsPerPage: 'results-per-page',
setUseBackend: 'set-use-backend',
};

View File

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