mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
94f39e69a3
commit
9b6e531549
@ -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
|
||||
|
||||
|
@ -81,4 +81,5 @@ export interface FeatureToggles {
|
||||
individualCookiePreferences?: boolean;
|
||||
drawerDataSourcePicker?: boolean;
|
||||
traceqlSearch?: boolean;
|
||||
prometheusMetricEncyclopedia?: boolean;
|
||||
}
|
||||
|
@ -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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -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"
|
||||
)
|
||||
|
@ -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|==|>=|!=|<=|>|<|=|~|,)/;
|
||||
|
||||
|
@ -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;
|
||||
}
|
@ -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',
|
||||
};
|
@ -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',
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user