diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 776b5ca774e..ce40c22e0d8 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -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 diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f5688619173..871849c6a98 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -81,4 +81,5 @@ export interface FeatureToggles { individualCookiePreferences?: boolean; drawerDataSourcePicker?: boolean; traceqlSearch?: boolean; + prometheusMetricEncyclopedia?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 198315ee53e..722d9218855 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index c26697b1a81..85f7c502304 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -266,4 +266,8 @@ const ( // FlagTraceqlSearch // Enables the 'TraceQL Search' 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" ) diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index 31970bb0db3..4b2251ac7e1 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -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|==|>=|!=|<=|>|<|=|~|,)/; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.test.tsx new file mode 100644 index 00000000000..fc16cf74394 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.test.tsx @@ -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, + 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(); + + return container; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.tsx new file mode 100644 index 00000000000..969a6135a4a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricEncyclopediaModal.tsx @@ -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>>([]); + + const [isLoading, setIsLoading] = useState(true); + + // metric list + const [metrics, setMetrics] = useState([]); + const [hasMetadata, setHasMetadata] = useState(true); + const [haystack, setHaystack] = useState([]); + const [nameHaystack, setNameHaystack] = useState([]); + const [openTabs, setOpenTabs] = useState([]); + + // pagination + const [resultsPerPage, setResultsPerPage] = useState(DEFAULT_RESULTS_PER_PAGE); + const [pageNum, setPageNum] = useState(1); + + // filters + const [fuzzySearchQuery, setFuzzySearchQuery] = useState(''); + const [fuzzyMetaSearchResults, setFuzzyMetaSearchResults] = useState([]); + const [fuzzyNameSearchResults, setNameFuzzySearchResults] = useState([]); + const [fullMetaSearch, setFullMetaSearch] = useState(false); + const [excludeNullMetadata, setExcludeNullMetadata] = useState(false); + const [selectedTypes, setSelectedTypes] = useState>>([]); + const [letterSearch, setLetterSearch] = useState(null); + + // backend search metric names by text + const [useBackend, setUseBackend] = useState(false); + + const updateMetricsMetadata = useCallback(async () => { + // *** Loading Gif + setIsLoading(true); + + // Makes sure we loaded the metadata for metrics. Usually this is done in the start() method of the provider but we + // don't use it with the visual builder and there is no need to run all the start() setup anyway. + if (!datasource.languageProvider.metricsMetadata) { + await datasource.languageProvider.loadMetricsMetadata(); + } + + // Error handling for when metrics metadata returns as undefined + // *** Will have to handle metadata filtering if this happens + // *** only display metrics fuzzy search, filter and pagination + if (!datasource.languageProvider.metricsMetadata) { + setHasMetadata(false); + datasource.languageProvider.metricsMetadata = {}; + } + + // filter by adding the query.labels to the search? + // *** do this in the filter??? + let metrics; + if (query.labels.length > 0) { + const expr = promQueryModeller.renderLabels(query.labels); + metrics = (await datasource.languageProvider.getSeries(expr, true))['__name__'] ?? []; + } else { + metrics = (await datasource.languageProvider.getLabelValues('__name__')) ?? []; + } + + let 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 ( + +
+ Browse {metrics.length} metric{metrics.length > 1 ? 's' : ''} by text, by type, alphabetically or select a + variable. + {isLoading && ( +
+ +
+ )} +
+ {query.labels.length > 0 && ( +
+ These metrics have been pre-filtered by labels chosen in the label filters. +
+ )} +
+ { + 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 && ( + {placeholders.metadataSearchSwicth}
}> + { + setFullMetaSearch(!fullMetaSearch); + setPageNum(1); + }} + /> + + )} + {placeholders.setUseBackend}}> + { + 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); + }} + /> + + + {hasMetadata && !useBackend && ( + <> +
+
Filter by Type
+
+
+ { + // *** 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 && ( + {placeholders.excludeNoMetadata}
}> + { + setExcludeNullMetadata(!excludeNullMetadata); + setPageNum(1); + }} + /> + + )} + + + )} +
+
Variables
+
+
+ { + return { value: p, label: '' + p }; + })} + value={pageNum ?? 1} + placeholder="select page" + onChange={(e) => { + const value = e.value ?? 1; + setPageNum(value); + }} + /> + + # results per page + + { + const value = +e.currentTarget.value; + + if (isNaN(value)) { + return; + } + + setResultsPerPage(value); + }} + /> +
+
+ +
+ ); +}; + +function alphabetically(ascending: boolean, metadataFilters: boolean) { + return function (a: MetricData, b: MetricData) { + // equal items sort equally + if (a.value === b.value) { + return 0; + } + + // *** NO METADATA? SORT LAST + // undefined metadata sort after anything else + // if filters are on + if (metadataFilters) { + if (a.type === undefined) { + return 1; + } + if (b.type === undefined) { + return -1; + } + } + + // otherwise, if we're ascending, lowest sorts first + if (ascending) { + return a.value < b.value ? -1 : 1; + } + + // if descending, highest sorts first + return a.value < b.value ? 1 : -1; + }; +} + +function UseUfuzzy(): uFuzzy { + const ref = useRef(); + + 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', +}; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx index 78e54fe840d..5fab120928d 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx @@ -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) => { const { datasource, query, onChange, onRunQuery, data, showExplain } = props; const [highlightedOp, setHighlightedOp] = useState(); + const [metricEncyclopediaModalOpen, setMetricEncyclopediaModalOpen] = useState(false); const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => { onChange({ ...query, labels }); }; + const styles = useStyles2(getStyles); /** * Map metric metadata to SelectableValue for Select component and also adds defined template variables to the list. */ @@ -202,17 +208,51 @@ export const PromQueryBuilder = React.memo((props) => { }, [datasource, query, withTemplateVariableOptions]); const lang = { grammar: promqlGrammar, name: 'promql' }; + const MetricEncyclopedia = config.featureToggles.prometheusMetricEncyclopedia; return ( <> - + {MetricEncyclopedia ? ( + <> + + {query.metric && ( + { + onChange({ ...query, metric: '' }); + }} + title="Click to remove metric" + className={styles.metricTag} + /> + )} + {metricEncyclopediaModalOpen && ( + setMetricEncyclopediaModalOpen(false)} + query={query} + onChange={onChange} + /> + )} + + ) : ( + + )} { + return { + button: css` + height: auto; + `, + metricTag: css` + margin: '10px 0 10px 0', + backgroundColor: '#3D71D9', + `, + }; +};