Prometheus: Refactor metrics modal to handle high cardinality metrics (#108437)
* remove redux from PromQueryBuilderContainer * remove export * housekeeping * introduce MetricsModalContext to replace redux * handle result pagination * add search related values in context * handle filtering * handle search * handle search and highlighting * cleaning up * better helpers * remove unused types * reorganize the code * update settings * improve search and fetch * remove unused styles * linting * fix tests * update * lint * i18n * import order * try to fix the test * remove start call from variable editor * fix highlighter * fix pagination alignment * make the entire row clickable and remove select button * review updates * remove settings * use ufuzzy search * fix tests * use case-insensitive search * review fixes * fix pagination * improve readability
This commit is contained in:
@@ -24,11 +24,11 @@ export function createPromDS(dataSourceID: string, name: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getResources() {
|
export function getResources() {
|
||||||
cy.intercept(/__name__/g, metricResponse);
|
cy.intercept(/__name__/g, metricResponse).as('getMetricNames');
|
||||||
|
|
||||||
cy.intercept(/metadata/g, metadataResponse);
|
cy.intercept(/metadata/g, metadataResponse).as('getMetadata');
|
||||||
|
|
||||||
cy.intercept(/labels/g, labelsResponse);
|
cy.intercept(/labels/g, labelsResponse).as('getLabels');
|
||||||
}
|
}
|
||||||
|
|
||||||
const metricResponse = {
|
const metricResponse = {
|
||||||
|
|||||||
@@ -138,12 +138,11 @@ describe('Prometheus query editor', () => {
|
|||||||
|
|
||||||
it('can select a metric and provide a hint', () => {
|
it('can select a metric and provide a hint', () => {
|
||||||
navigateToEditor('Builder', 'prometheusBuilder');
|
navigateToEditor('Builder', 'prometheusBuilder');
|
||||||
|
|
||||||
getResources();
|
getResources();
|
||||||
|
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click();
|
||||||
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click().type('metric1');
|
cy.wait('@getMetadata');
|
||||||
|
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().type('metric1');
|
||||||
selectOption('metric1');
|
selectOption('metric1');
|
||||||
|
|
||||||
e2e.components.DataSource.Prometheus.queryEditor.builder.hints().contains('hint: add rate');
|
e2e.components.DataSource.Prometheus.queryEditor.builder.hints().contains('hint: add rate');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { select } from 'react-select-event';
|
import { select } from 'react-select-event';
|
||||||
|
|
||||||
import { dateTime, TimeRange } from '@grafana/data';
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { PrometheusDatasource } from '../datasource';
|
import { PrometheusDatasource } from '../datasource';
|
||||||
@@ -376,24 +375,4 @@ describe('PromVariableQueryEditor', () => {
|
|||||||
qryType: 5,
|
qryType: 5,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Calls language provider with the time range received in props', async () => {
|
|
||||||
const now = dateTime('2023-09-16T21:26:00Z');
|
|
||||||
const range: TimeRange = {
|
|
||||||
from: dateTime(now).subtract(2, 'days'),
|
|
||||||
to: now,
|
|
||||||
raw: {
|
|
||||||
from: 'now-2d',
|
|
||||||
to: 'now',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
props.range = range;
|
|
||||||
|
|
||||||
const languageProviderStartMock = jest.fn();
|
|
||||||
props.datasource.languageProvider.start = languageProviderStartMock;
|
|
||||||
|
|
||||||
render(<PromVariableQueryEditor {...props} />);
|
|
||||||
|
|
||||||
expect(languageProviderStartMock).toHaveBeenCalledWith(range);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,11 +77,6 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
|
|||||||
// label filters have been added as a filter for metrics in label values query type
|
// label filters have been added as a filter for metrics in label values query type
|
||||||
const [labelFilters, setLabelFilters] = useState<QueryBuilderLabelFilter[]>([]);
|
const [labelFilters, setLabelFilters] = useState<QueryBuilderLabelFilter[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
datasource.languageProvider.start(range);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -311,10 +311,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"querybuilder": {
|
"querybuilder": {
|
||||||
"additional-settings": {
|
|
||||||
"content-filter-metric-names-regex-search-using": "Filter metric names by regex search, using an additional call on the Prometheus API.",
|
|
||||||
"disable-text-wrap": "Disable text wrap"
|
|
||||||
},
|
|
||||||
"feedback-link": {
|
"feedback-link": {
|
||||||
"give-feedback": "Give feedback",
|
"give-feedback": "Give feedback",
|
||||||
"title-give-feedback": "The metrics explorer is new, please let us know how we can improve it"
|
"title-give-feedback": "The metrics explorer is new, please let us know how we can improve it"
|
||||||
@@ -328,9 +324,6 @@
|
|||||||
},
|
},
|
||||||
"get-placeholders": {
|
"get-placeholders": {
|
||||||
"browse": "Search metrics by name",
|
"browse": "Search metrics by name",
|
||||||
"include-null-metadata": "Include results with no metadata",
|
|
||||||
"metadata-search-switch": "Include description in search",
|
|
||||||
"set-use-backend": "Enable regex search",
|
|
||||||
"type": "Filter by type"
|
"type": "Filter by type"
|
||||||
},
|
},
|
||||||
"get-prom-types": {
|
"get-prom-types": {
|
||||||
@@ -378,15 +371,9 @@
|
|||||||
"tooltip-metric": "Optional: returns a list of label values for the label name in the specified metric."
|
"tooltip-metric": "Optional: returns a list of label values for the label name in the specified metric."
|
||||||
},
|
},
|
||||||
"metrics-modal": {
|
"metrics-modal": {
|
||||||
"additional-settings": "Additional Settings",
|
|
||||||
"aria-label-additional-settings": "Additional settings",
|
|
||||||
"aria-label-browse-metrics": "Browse metrics",
|
"aria-label-browse-metrics": "Browse metrics",
|
||||||
"currently-selected": "Currently selected: {{selected}}",
|
"currently-selected": "Currently selected: {{selected}}",
|
||||||
"metrics-pre-filtered": "These metrics have been pre-filtered by labels chosen in the label filters.",
|
"metrics-pre-filtered": "These metrics have been pre-filtered by labels chosen in the label filters.",
|
||||||
"placeholder-results-per-page": "results per page",
|
|
||||||
"results-amount_one": "Showing {{num}} of {{count}} results",
|
|
||||||
"results-amount_other": "Showing {{num}} of {{count}} results",
|
|
||||||
"results-per-page": "Results per page",
|
|
||||||
"title-metrics-explorer": "Metrics explorer"
|
"title-metrics-explorer": "Metrics explorer"
|
||||||
},
|
},
|
||||||
"nested-query": {
|
"nested-query": {
|
||||||
@@ -500,7 +487,6 @@
|
|||||||
"message-expand-search": "There are no metrics found. Try to expand your search and filters.",
|
"message-expand-search": "There are no metrics found. Try to expand your search and filters.",
|
||||||
"message-no-metrics-found": "There are no metrics found in the data source.",
|
"message-no-metrics-found": "There are no metrics found in the data source.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"select": "Select",
|
|
||||||
"type": "Type"
|
"type": "Type"
|
||||||
},
|
},
|
||||||
"update-function-args": {
|
"update-function-args": {
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import { PrometheusDatasource } from '../../datasource';
|
|||||||
import { QueryBuilderLabelFilter } from '../shared/types';
|
import { QueryBuilderLabelFilter } from '../shared/types';
|
||||||
import { PromVisualQuery } from '../types';
|
import { PromVisualQuery } from '../types';
|
||||||
|
|
||||||
|
import { formatKeyValueStrings } from './formatter';
|
||||||
import { MetricsModal } from './metrics-modal/MetricsModal';
|
import { MetricsModal } from './metrics-modal/MetricsModal';
|
||||||
import { tracking } from './metrics-modal/state/helpers';
|
|
||||||
import { formatKeyValueStrings } from './shared/formatter';
|
|
||||||
|
|
||||||
export interface MetricComboboxProps {
|
export interface MetricComboboxProps {
|
||||||
metricLookupDisabled: boolean;
|
metricLookupDisabled: boolean;
|
||||||
@@ -76,18 +75,6 @@ export function MetricCombobox({
|
|||||||
[getMetricLabels, onGetMetrics]
|
[getMetricLabels, onGetMetrics]
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadMetricsExplorerMetrics = useCallback(async () => {
|
|
||||||
const allMetrics = await onGetMetrics();
|
|
||||||
const metrics: string[] = [];
|
|
||||||
for (const metric of allMetrics) {
|
|
||||||
if (metric.value) {
|
|
||||||
metrics.push(metric.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return metrics;
|
|
||||||
}, [onGetMetrics]);
|
|
||||||
|
|
||||||
const asyncSelect = () => {
|
const asyncSelect = () => {
|
||||||
return (
|
return (
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
@@ -115,10 +102,7 @@ export function MetricCombobox({
|
|||||||
)}
|
)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="book-open"
|
icon="book-open"
|
||||||
onClick={() => {
|
onClick={() => setMetricsModalOpen(true)}
|
||||||
tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query);
|
|
||||||
setMetricsModalOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
);
|
);
|
||||||
@@ -133,7 +117,6 @@ export function MetricCombobox({
|
|||||||
onClose={() => setMetricsModalOpen(false)}
|
onClose={() => setMetricsModalOpen(false)}
|
||||||
query={query}
|
query={query}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialMetrics={loadMetricsExplorerMetrics}
|
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { PrometheusDatasource } from '../../datasource';
|
|||||||
import { binaryScalarDefs } from '../binaryScalarOperations';
|
import { binaryScalarDefs } from '../binaryScalarOperations';
|
||||||
import { PromVisualQueryBinary } from '../types';
|
import { PromVisualQueryBinary } from '../types';
|
||||||
|
|
||||||
import { QueryBuilderContent } from './shared/QueryBuilderContent';
|
import { QueryBuilderContent } from './QueryBuilderContent';
|
||||||
|
|
||||||
interface NestedQueryProps {
|
interface NestedQueryProps {
|
||||||
nestedQuery: PromVisualQueryBinary;
|
nestedQuery: PromVisualQueryBinary;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { PanelData } from '@grafana/data';
|
|||||||
import { PrometheusDatasource } from '../../datasource';
|
import { PrometheusDatasource } from '../../datasource';
|
||||||
import { PromVisualQuery } from '../types';
|
import { PromVisualQuery } from '../types';
|
||||||
|
|
||||||
import { BaseQueryBuilder } from './shared/BaseQueryBuilder';
|
import { NestedQueryList } from './NestedQueryList';
|
||||||
|
import { QueryBuilderContent } from './QueryBuilderContent';
|
||||||
|
|
||||||
interface PromQueryBuilderProps {
|
interface PromQueryBuilderProps {
|
||||||
query: PromVisualQuery;
|
query: PromVisualQuery;
|
||||||
@@ -18,7 +19,22 @@ interface PromQueryBuilderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PromQueryBuilder = memo<PromQueryBuilderProps>((props) => {
|
export const PromQueryBuilder = memo<PromQueryBuilderProps>((props) => {
|
||||||
return <BaseQueryBuilder {...props} />;
|
const { query, datasource, onChange, onRunQuery, showExplain } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<QueryBuilderContent {...props} />
|
||||||
|
{query.binaryQueries && query.binaryQueries.length > 0 && (
|
||||||
|
<NestedQueryList
|
||||||
|
query={query}
|
||||||
|
datasource={datasource}
|
||||||
|
onChange={onChange}
|
||||||
|
onRunQuery={onRunQuery}
|
||||||
|
showExplain={showExplain}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
PromQueryBuilder.displayName = 'PromQueryBuilder';
|
PromQueryBuilder.displayName = 'PromQueryBuilder';
|
||||||
|
|||||||
-4
@@ -20,12 +20,8 @@ describe('PromQueryBuilderContainer', () => {
|
|||||||
await addOperationInQueryBuilder('Range functions', 'Rate');
|
await addOperationInQueryBuilder('Range functions', 'Rate');
|
||||||
// extra fields here are for storing metrics explorer settings. Future work: store these in local storage.
|
// extra fields here are for storing metrics explorer settings. Future work: store these in local storage.
|
||||||
expect(props.onChange).toHaveBeenCalledWith({
|
expect(props.onChange).toHaveBeenCalledWith({
|
||||||
disableTextWrap: false,
|
|
||||||
expr: 'rate(metric_test{job="testjob"}[$__rate_interval])',
|
expr: 'rate(metric_test{job="testjob"}[$__rate_interval])',
|
||||||
fullMetaSearch: false,
|
|
||||||
includeNullMetadata: true,
|
|
||||||
refId: 'A',
|
refId: 'A',
|
||||||
useBackend: false,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+18
-62
@@ -1,6 +1,5 @@
|
|||||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx
|
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { useEffect, useState } from 'react';
|
||||||
import { useEffect, useReducer } from 'react';
|
|
||||||
|
|
||||||
import { PanelData } from '@grafana/data';
|
import { PanelData } from '@grafana/data';
|
||||||
|
|
||||||
@@ -12,7 +11,6 @@ import { PromVisualQuery } from '../types';
|
|||||||
|
|
||||||
import { PromQueryBuilder } from './PromQueryBuilder';
|
import { PromQueryBuilder } from './PromQueryBuilder';
|
||||||
import { QueryPreview } from './QueryPreview';
|
import { QueryPreview } from './QueryPreview';
|
||||||
import { getSettings, MetricsModalSettings } from './metrics-modal/state/state';
|
|
||||||
|
|
||||||
interface PromQueryBuilderContainerProps {
|
interface PromQueryBuilderContainerProps {
|
||||||
query: PromQuery;
|
query: PromQuery;
|
||||||
@@ -23,7 +21,7 @@ interface PromQueryBuilderContainerProps {
|
|||||||
showExplain: boolean;
|
showExplain: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface RenderedQuery {
|
||||||
visQuery?: PromVisualQuery;
|
visQuery?: PromVisualQuery;
|
||||||
expr: string;
|
expr: string;
|
||||||
}
|
}
|
||||||
@@ -33,80 +31,38 @@ interface State {
|
|||||||
*/
|
*/
|
||||||
export function PromQueryBuilderContainer(props: PromQueryBuilderContainerProps) {
|
export function PromQueryBuilderContainer(props: PromQueryBuilderContainerProps) {
|
||||||
const { query, onChange, onRunQuery, datasource, data, showExplain } = props;
|
const { query, onChange, onRunQuery, datasource, data, showExplain } = props;
|
||||||
const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr });
|
const [rendered, setRendered] = useState<RenderedQuery>({ expr: query.expr });
|
||||||
// Only rebuild visual query if expr changes from outside
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(exprChanged(query.expr));
|
|
||||||
dispatch(
|
|
||||||
setMetricsModalSettings({
|
|
||||||
useBackend: query.useBackend ?? false,
|
|
||||||
disableTextWrap: query.disableTextWrap ?? false,
|
|
||||||
fullMetaSearch: query.fullMetaSearch ?? false,
|
|
||||||
includeNullMetadata: query.includeNullMetadata ?? true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
datasource.languageProvider.start(data?.timeRange);
|
// Only rebuild visual query if expr changes from outside
|
||||||
}, [data?.timeRange, datasource.languageProvider]);
|
if (!rendered.visQuery || rendered.expr !== query.expr) {
|
||||||
|
const parseResult = buildVisualQueryFromString(query.expr ?? '');
|
||||||
|
|
||||||
|
setRendered({ expr: query.expr, visQuery: parseResult.query });
|
||||||
|
}
|
||||||
|
}, [query, rendered]);
|
||||||
|
|
||||||
const onVisQueryChange = (visQuery: PromVisualQuery) => {
|
const onVisQueryChange = (visQuery: PromVisualQuery) => {
|
||||||
const expr = promQueryModeller.renderQuery(visQuery);
|
const expr = promQueryModeller.renderQuery(visQuery);
|
||||||
dispatch(visualQueryChange({ visQuery, expr }));
|
setRendered({ expr, visQuery });
|
||||||
|
onChange({ ...props.query, expr });
|
||||||
const metricsModalSettings = getSettings(visQuery);
|
|
||||||
onChange({ ...props.query, expr: expr, ...metricsModalSettings });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!state.visQuery) {
|
if (!rendered.visQuery) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PromQueryBuilder
|
<PromQueryBuilder
|
||||||
query={state.visQuery}
|
|
||||||
datasource={datasource}
|
|
||||||
onChange={onVisQueryChange}
|
|
||||||
onRunQuery={onRunQuery}
|
|
||||||
data={data}
|
data={data}
|
||||||
|
datasource={datasource}
|
||||||
showExplain={showExplain}
|
showExplain={showExplain}
|
||||||
|
query={rendered.visQuery}
|
||||||
|
onRunQuery={onRunQuery}
|
||||||
|
onChange={onVisQueryChange}
|
||||||
/>
|
/>
|
||||||
{<QueryPreview query={query.expr} />}
|
<QueryPreview query={query.expr} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: State = {
|
|
||||||
expr: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const stateSlice = createSlice({
|
|
||||||
name: 'prom-builder-container',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
visualQueryChange: (state, action: PayloadAction<{ visQuery: PromVisualQuery; expr: string }>) => {
|
|
||||||
state.expr = action.payload.expr;
|
|
||||||
state.visQuery = action.payload.visQuery;
|
|
||||||
},
|
|
||||||
exprChanged: (state, action: PayloadAction<string>) => {
|
|
||||||
if (!state.visQuery || state.expr !== action.payload) {
|
|
||||||
state.expr = action.payload;
|
|
||||||
const parseResult = buildVisualQueryFromString(action.payload ?? '');
|
|
||||||
|
|
||||||
state.visQuery = parseResult.query;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setMetricsModalSettings: (state, action: PayloadAction<MetricsModalSettings>) => {
|
|
||||||
if (state.visQuery) {
|
|
||||||
state.visQuery.useBackend = action.payload.useBackend;
|
|
||||||
state.visQuery.disableTextWrap = action.payload.disableTextWrap;
|
|
||||||
state.visQuery.fullMetaSearch = action.payload.fullMetaSearch;
|
|
||||||
state.visQuery.includeNullMetadata = action.payload.includeNullMetadata;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { visualQueryChange, exprChanged, setMetricsModalSettings } = stateSlice.actions;
|
|
||||||
|
|||||||
+26
-17
@@ -1,28 +1,37 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { memo, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
|
|
||||||
import { DataSourceApi, getDefaultTimeRange } from '@grafana/data';
|
import { DataSourceApi, getDefaultTimeRange, PanelData } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { EditorRow } from '@grafana/plugin-ui';
|
import { EditorRow } from '@grafana/plugin-ui';
|
||||||
|
|
||||||
import { promqlGrammar } from '../../../promql';
|
import { PrometheusDatasource } from '../../datasource';
|
||||||
import { getInitHints } from '../../../query_hints';
|
import { promqlGrammar } from '../../promql';
|
||||||
import { buildVisualQueryFromString } from '../../parsing';
|
import { getInitHints } from '../../query_hints';
|
||||||
import { OperationExplainedBox } from '../../shared/OperationExplainedBox';
|
import { buildVisualQueryFromString } from '../parsing';
|
||||||
import { OperationList } from '../../shared/OperationList';
|
import { OperationExplainedBox } from '../shared/OperationExplainedBox';
|
||||||
import { OperationListExplained } from '../../shared/OperationListExplained';
|
import { OperationList } from '../shared/OperationList';
|
||||||
import { OperationsEditorRow } from '../../shared/OperationsEditorRow';
|
import { OperationListExplained } from '../shared/OperationListExplained';
|
||||||
import { QueryBuilderHints } from '../../shared/QueryBuilderHints';
|
import { OperationsEditorRow } from '../shared/OperationsEditorRow';
|
||||||
import { RawQuery } from '../../shared/RawQuery';
|
import { QueryBuilderHints } from '../shared/QueryBuilderHints';
|
||||||
import { promQueryModeller } from '../../shared/modeller_instance';
|
import { RawQuery } from '../shared/RawQuery';
|
||||||
import { QueryBuilderOperation } from '../../shared/types';
|
import { promQueryModeller } from '../shared/modeller_instance';
|
||||||
import { PromVisualQuery } from '../../types';
|
import { QueryBuilderOperation } from '../shared/types';
|
||||||
import { MetricsLabelsSection } from '../MetricsLabelsSection';
|
import { PromVisualQuery } from '../types';
|
||||||
import { EXPLAIN_LABEL_FILTER_CONTENT } from '../PromQueryBuilderExplained';
|
|
||||||
|
|
||||||
import { BaseQueryBuilderProps } from './types';
|
import { MetricsLabelsSection } from './MetricsLabelsSection';
|
||||||
|
import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained';
|
||||||
|
|
||||||
export const QueryBuilderContent = memo<BaseQueryBuilderProps>((props) => {
|
interface QueryBuilderContentProps {
|
||||||
|
query: PromVisualQuery;
|
||||||
|
datasource: PrometheusDatasource;
|
||||||
|
onChange: (update: PromVisualQuery) => void;
|
||||||
|
onRunQuery: () => void;
|
||||||
|
data?: PanelData;
|
||||||
|
showExplain: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryBuilderContent = memo<QueryBuilderContentProps>((props) => {
|
||||||
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
|
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
|
||||||
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
|
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
|
||||||
|
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { regexifyLabelValuesQueryString } from '../../parsingUtils';
|
import { regexifyLabelValuesQueryString } from '../parsingUtils';
|
||||||
import { QueryBuilderLabelFilter } from '../../shared/types';
|
import { QueryBuilderLabelFilter } from '../shared/types';
|
||||||
|
|
||||||
export const formatPrometheusLabelFiltersToString = (
|
const formatPrometheusLabelFiltersToString = (
|
||||||
queryString: string,
|
queryString: string,
|
||||||
labelsFilters: QueryBuilderLabelFilter[] | undefined
|
labelsFilters: QueryBuilderLabelFilter[] | undefined
|
||||||
): string => {
|
): string => {
|
||||||
-87
@@ -1,87 +0,0 @@
|
|||||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx
|
|
||||||
import { css } from '@emotion/css';
|
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { Trans, t } from '@grafana/i18n';
|
|
||||||
import { Icon, Switch, Tooltip, useTheme2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { metricsModaltestIds } from './shared/testIds';
|
|
||||||
import { AdditionalSettingsProps } from './shared/types';
|
|
||||||
import { getPlaceholders } from './state/helpers';
|
|
||||||
|
|
||||||
export function AdditionalSettings(props: AdditionalSettingsProps) {
|
|
||||||
const { state, onChangeFullMetaSearch, onChangeIncludeNullMetadata, onChangeDisableTextWrap, onChangeUseBackend } =
|
|
||||||
props;
|
|
||||||
|
|
||||||
const theme = useTheme2();
|
|
||||||
const styles = getStyles(theme);
|
|
||||||
|
|
||||||
const placeholders = getPlaceholders();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.selectItem}>
|
|
||||||
<Switch
|
|
||||||
data-testid={metricsModaltestIds.searchWithMetadata}
|
|
||||||
value={state.fullMetaSearch}
|
|
||||||
disabled={state.useBackend || !state.hasMetadata}
|
|
||||||
onChange={() => onChangeFullMetaSearch()}
|
|
||||||
/>
|
|
||||||
<div className={styles.selectItemLabel}>{placeholders.metadataSearchSwitch}</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.selectItem}>
|
|
||||||
<Switch
|
|
||||||
value={state.includeNullMetadata}
|
|
||||||
disabled={!state.hasMetadata}
|
|
||||||
onChange={() => onChangeIncludeNullMetadata()}
|
|
||||||
/>
|
|
||||||
<div className={styles.selectItemLabel}>{placeholders.includeNullMetadata}</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.selectItem}>
|
|
||||||
<Switch value={state.disableTextWrap} onChange={() => onChangeDisableTextWrap()} />
|
|
||||||
<div className={styles.selectItemLabel}>
|
|
||||||
<Trans i18nKey="grafana-prometheus.querybuilder.additional-settings.disable-text-wrap">
|
|
||||||
Disable text wrap
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.selectItem}>
|
|
||||||
<Switch
|
|
||||||
data-testid={metricsModaltestIds.setUseBackend}
|
|
||||||
value={state.useBackend}
|
|
||||||
onChange={() => onChangeUseBackend()}
|
|
||||||
/>
|
|
||||||
<div className={styles.selectItemLabel}>{placeholders.setUseBackend} </div>
|
|
||||||
<Tooltip
|
|
||||||
content={t(
|
|
||||||
'grafana-prometheus.querybuilder.additional-settings.content-filter-metric-names-regex-search-using',
|
|
||||||
'Filter metric names by regex search, using an additional call on the Prometheus API.'
|
|
||||||
)}
|
|
||||||
placement="bottom-end"
|
|
||||||
>
|
|
||||||
<Icon name="info-circle" size="xs" className={styles.settingsIcon} />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStyles(theme: GrafanaTheme2) {
|
|
||||||
return {
|
|
||||||
settingsIcon: css({
|
|
||||||
color: theme.colors.text.secondary,
|
|
||||||
}),
|
|
||||||
selectItem: css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '4px 0',
|
|
||||||
}),
|
|
||||||
selectItemLabel: css({
|
|
||||||
margin: `0 0 0 ${theme.spacing(1)}`,
|
|
||||||
alignSelf: 'center',
|
|
||||||
color: theme.colors.text.secondary,
|
|
||||||
fontSize: '12px',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+45
-50
@@ -12,7 +12,7 @@ import { PromOptions } from '../../../types';
|
|||||||
import { PromVisualQuery } from '../../types';
|
import { PromVisualQuery } from '../../types';
|
||||||
|
|
||||||
import { MetricsModal } from './MetricsModal';
|
import { MetricsModal } from './MetricsModal';
|
||||||
import { metricsModaltestIds } from './shared/testIds';
|
import { metricsModaltestIds } from './testIds';
|
||||||
|
|
||||||
// don't care about interaction tracking in our unit tests
|
// don't care about interaction tracking in our unit tests
|
||||||
jest.mock('@grafana/runtime', () => ({
|
jest.mock('@grafana/runtime', () => ({
|
||||||
@@ -117,28 +117,6 @@ describe('MetricsModal', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows results metrics per page chosen by the user', async () => {
|
|
||||||
setup(defaultQuery, listOfMetrics);
|
|
||||||
const resultsPerPageInput = screen.getByTestId(metricsModaltestIds.resultsPerPage);
|
|
||||||
await userEvent.type(resultsPerPageInput, '12');
|
|
||||||
const metricInsideRange = screen.getByText('j');
|
|
||||||
expect(metricInsideRange).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('paginates lots of metrics and does not run out of memory', async () => {
|
|
||||||
const lotsOfMetrics: string[] = [...Array(100000).keys()].map((i) => '' + i);
|
|
||||||
setup(defaultQuery, lotsOfMetrics);
|
|
||||||
await waitFor(() => {
|
|
||||||
// doesn't break on loading
|
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
const resultsPerPageInput = screen.getByTestId(metricsModaltestIds.resultsPerPage);
|
|
||||||
// doesn't break on changing results per page
|
|
||||||
await userEvent.type(resultsPerPageInput, '11');
|
|
||||||
const metricInsideRange = screen.getByText('9');
|
|
||||||
expect(metricInsideRange).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fuzzy search
|
// Fuzzy search
|
||||||
it('searches and filter by metric name with a fuzzy search', async () => {
|
it('searches and filter by metric name with a fuzzy search', async () => {
|
||||||
// search for a_bucket by name
|
// search for a_bucket by name
|
||||||
@@ -171,14 +149,6 @@ describe('MetricsModal', () => {
|
|||||||
expect(metricABucket).toBeInTheDocument();
|
expect(metricABucket).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const showSettingsButton = screen.getByTestId(metricsModaltestIds.showAdditionalSettings);
|
|
||||||
expect(showSettingsButton).toBeInTheDocument();
|
|
||||||
await userEvent.click(showSettingsButton);
|
|
||||||
|
|
||||||
const metadataSwitch = screen.getByTestId(metricsModaltestIds.searchWithMetadata);
|
|
||||||
expect(metadataSwitch).toBeInTheDocument();
|
|
||||||
await userEvent.click(metadataSwitch);
|
|
||||||
|
|
||||||
const searchMetric = screen.getByTestId(metricsModaltestIds.searchMetric);
|
const searchMetric = screen.getByTestId(metricsModaltestIds.searchMetric);
|
||||||
expect(searchMetric).toBeInTheDocument();
|
expect(searchMetric).toBeInTheDocument();
|
||||||
await userEvent.type(searchMetric, 'functions');
|
await userEvent.type(searchMetric, 'functions');
|
||||||
@@ -248,6 +218,34 @@ function createDatasource(withLabels?: boolean) {
|
|||||||
const languageProvider = new EmptyLanguageProviderMock() as unknown as PrometheusLanguageProviderInterface;
|
const languageProvider = new EmptyLanguageProviderMock() as unknown as PrometheusLanguageProviderInterface;
|
||||||
|
|
||||||
// display different results if their labels are selected in the PromVisualQuery
|
// display different results if their labels are selected in the PromVisualQuery
|
||||||
|
if (withLabels) {
|
||||||
|
languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({
|
||||||
|
'with-labels': {
|
||||||
|
type: 'with-labels-type',
|
||||||
|
help: 'with-labels-help',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// all metrics - create metadata for all metrics in listOfMetrics
|
||||||
|
const mockMetadata: Record<string, { type: string; help: string }> = {};
|
||||||
|
listOfMetrics.forEach((metric) => {
|
||||||
|
if (metric === 'all-metrics') {
|
||||||
|
mockMetadata[metric] = { type: 'all-metrics-type', help: 'all-metrics-help' };
|
||||||
|
} else if (metric === 'a_bucket') {
|
||||||
|
mockMetadata[metric] = { type: 'histogram', help: 'for functions' };
|
||||||
|
} else if (metric === 'new_histogram') {
|
||||||
|
mockMetadata[metric] = { type: 'histogram', help: 'a native histogram' };
|
||||||
|
} else if (metric === 'a') {
|
||||||
|
mockMetadata[metric] = { type: 'counter', help: 'a-metric-help' };
|
||||||
|
} else {
|
||||||
|
mockMetadata[metric] = { type: 'counter', help: `${metric} metric help` };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue(mockMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also mock the retrieveMetricsMetadata method that might be used elsewhere
|
||||||
if (withLabels) {
|
if (withLabels) {
|
||||||
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({
|
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({
|
||||||
'with-labels': {
|
'with-labels': {
|
||||||
@@ -256,26 +254,23 @@ function createDatasource(withLabels?: boolean) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// all metrics
|
// Create the same metadata structure for retrieveMetricsMetadata
|
||||||
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({
|
const mockMetadata: Record<string, { type: string; help: string }> = {};
|
||||||
'all-metrics': {
|
listOfMetrics.forEach((metric) => {
|
||||||
type: 'all-metrics-type',
|
if (metric === 'all-metrics') {
|
||||||
help: 'all-metrics-help',
|
mockMetadata[metric] = { type: 'all-metrics-type', help: 'all-metrics-help' };
|
||||||
},
|
} else if (metric === 'a_bucket') {
|
||||||
a: {
|
mockMetadata[metric] = { type: 'histogram', help: 'for functions' };
|
||||||
type: 'counter',
|
} else if (metric === 'new_histogram') {
|
||||||
help: 'a-metric-help',
|
mockMetadata[metric] = { type: 'histogram', help: 'a native histogram' };
|
||||||
},
|
} else if (metric === 'a') {
|
||||||
a_bucket: {
|
mockMetadata[metric] = { type: 'counter', help: 'a-metric-help' };
|
||||||
type: 'histogram',
|
} else {
|
||||||
help: 'for functions',
|
mockMetadata[metric] = { type: 'counter', help: `${metric} metric help` };
|
||||||
},
|
}
|
||||||
new_histogram: {
|
|
||||||
type: 'histogram',
|
|
||||||
help: 'a native histogram',
|
|
||||||
},
|
|
||||||
// missing metadata for other metrics is tested for, see below
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue(mockMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
const datasource = new PrometheusDatasource(
|
const datasource = new PrometheusDatasource(
|
||||||
|
|||||||
+61
-274
@@ -1,107 +1,49 @@
|
|||||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx
|
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx
|
||||||
import { cx } from '@emotion/css';
|
import { cx } from '@emotion/css';
|
||||||
import debounce from 'debounce-promise';
|
|
||||||
import { useCallback, useEffect, useMemo, useReducer } from 'react';
|
|
||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue, TimeRange } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { t, Trans } from '@grafana/i18n';
|
import { t, Trans } from '@grafana/i18n';
|
||||||
import {
|
import { Icon, Input, Modal, MultiSelect, Pagination, Spinner, useStyles2 } from '@grafana/ui';
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
Icon,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
MultiSelect,
|
|
||||||
Pagination,
|
|
||||||
Spinner,
|
|
||||||
Toggletip,
|
|
||||||
useTheme2,
|
|
||||||
} from '@grafana/ui';
|
|
||||||
|
|
||||||
import { getDebounceTimeInMilliseconds } from '../../../caching';
|
import { PrometheusDatasource } from '../../../datasource';
|
||||||
import { METRIC_LABEL } from '../../../constants';
|
import { PromVisualQuery } from '../../types';
|
||||||
import { regexifyLabelValuesQueryString } from '../../parsingUtils';
|
|
||||||
import { formatPrometheusLabelFilters } from '../shared/formatter';
|
|
||||||
|
|
||||||
import { AdditionalSettings } from './AdditionalSettings';
|
|
||||||
import { FeedbackLink } from './FeedbackLink';
|
import { FeedbackLink } from './FeedbackLink';
|
||||||
|
import { MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext';
|
||||||
import { ResultsTable } from './ResultsTable';
|
import { ResultsTable } from './ResultsTable';
|
||||||
import { metricsModaltestIds } from './shared/testIds';
|
import { getPlaceholders, getPromTypes } from './helpers';
|
||||||
import { MetricsModalProps } from './shared/types';
|
import { getMetricsModalStyles } from './styles';
|
||||||
import {
|
import { metricsModaltestIds } from './testIds';
|
||||||
calculatePageList,
|
|
||||||
calculateResultsPerPage,
|
|
||||||
displayedMetrics,
|
|
||||||
getPlaceholders,
|
|
||||||
getPromTypes,
|
|
||||||
setMetrics,
|
|
||||||
tracking,
|
|
||||||
} from './state/helpers';
|
|
||||||
import {
|
|
||||||
buildMetrics,
|
|
||||||
DEFAULT_RESULTS_PER_PAGE,
|
|
||||||
filterMetricsBackend,
|
|
||||||
initialState,
|
|
||||||
MAXIMUM_RESULTS_PER_PAGE,
|
|
||||||
MetricsModalMetadata,
|
|
||||||
setDisableTextWrap,
|
|
||||||
setFullMetaSearch,
|
|
||||||
setFuzzySearchQuery,
|
|
||||||
setIncludeNullMetadata,
|
|
||||||
setIsLoading,
|
|
||||||
setMetaHaystack,
|
|
||||||
setNameHaystack,
|
|
||||||
setPageNum,
|
|
||||||
setResultsPerPage,
|
|
||||||
setSelectedTypes,
|
|
||||||
setUseBackend,
|
|
||||||
showAdditionalSettings,
|
|
||||||
stateSlice,
|
|
||||||
} from './state/state';
|
|
||||||
import { getStyles } from './styles';
|
|
||||||
import { PromFilterOption } from './types';
|
import { PromFilterOption } from './types';
|
||||||
import { debouncedFuzzySearch } from './uFuzzy';
|
|
||||||
|
|
||||||
export const MetricsModal = (props: MetricsModalProps) => {
|
interface MetricsModalProps {
|
||||||
const { datasource, isOpen, onClose, onChange, query, initialMetrics, timeRange } = props;
|
datasource: PrometheusDatasource;
|
||||||
|
timeRange: TimeRange;
|
||||||
|
isOpen: boolean;
|
||||||
|
query: PromVisualQuery;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (query: PromVisualQuery) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(stateSlice.reducer, initialState(query));
|
const MetricsModalContent = (props: MetricsModalProps) => {
|
||||||
|
const { isOpen, onClose, onChange, query, timeRange } = props;
|
||||||
|
|
||||||
const theme = useTheme2();
|
const {
|
||||||
const styles = getStyles(theme, state.disableTextWrap);
|
isLoading,
|
||||||
|
filteredMetricsData,
|
||||||
|
debouncedBackendSearch,
|
||||||
|
pagination,
|
||||||
|
setPagination,
|
||||||
|
selectedTypes,
|
||||||
|
setSelectedTypes,
|
||||||
|
searchedText,
|
||||||
|
setSearchedText,
|
||||||
|
} = useMetricsModal();
|
||||||
|
const styles = useStyles2(getMetricsModalStyles);
|
||||||
const placeholders = getPlaceholders();
|
const placeholders = getPlaceholders();
|
||||||
const promTypes = getPromTypes();
|
const promTypes = getPromTypes();
|
||||||
|
|
||||||
/**
|
|
||||||
* loads metrics and metadata on opening modal and switching off useBackend
|
|
||||||
*/
|
|
||||||
const updateMetricsMetadata = useCallback(async () => {
|
|
||||||
// *** Loading Gif
|
|
||||||
dispatch(setIsLoading(true));
|
|
||||||
|
|
||||||
// Because Combobox in MetricsCombobox doesn't use the same lifecycle as Select to open the Metrics Explorer
|
|
||||||
// it might not have loaded any metrics yet, so it instead passes in an async function to get the metrics
|
|
||||||
const metrics = typeof initialMetrics === 'function' ? await initialMetrics() : initialMetrics;
|
|
||||||
|
|
||||||
const data: MetricsModalMetadata = await setMetrics(datasource, query, metrics);
|
|
||||||
dispatch(
|
|
||||||
buildMetrics({
|
|
||||||
isLoading: false,
|
|
||||||
hasMetadata: data.hasMetadata,
|
|
||||||
metrics: data.metrics,
|
|
||||||
metaHaystackDictionary: data.metaHaystackDictionary,
|
|
||||||
nameHaystackDictionary: data.nameHaystackDictionary,
|
|
||||||
totalMetricCount: data.metrics.length,
|
|
||||||
filteredMetricCount: data.metrics.length,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [query, datasource, initialMetrics]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateMetricsMetadata();
|
|
||||||
}, [updateMetricsMetadata]);
|
|
||||||
|
|
||||||
const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => {
|
const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => {
|
||||||
return {
|
return {
|
||||||
value: t.value,
|
value: t.value,
|
||||||
@@ -110,96 +52,10 @@ export const MetricsModal = (props: MetricsModalProps) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
const searchCallback = (query: string, fullMetaSearchVal?: boolean) => {
|
||||||
* The backend debounced search
|
setSearchedText(query);
|
||||||
*/
|
debouncedBackendSearch(timeRange, query);
|
||||||
const debouncedBackendSearch = useMemo(
|
};
|
||||||
() =>
|
|
||||||
debounce(async (metricText: string) => {
|
|
||||||
dispatch(setIsLoading(true));
|
|
||||||
|
|
||||||
const queryString = regexifyLabelValuesQueryString(metricText);
|
|
||||||
const filterArray = query.labels ? formatPrometheusLabelFilters(query.labels) : [];
|
|
||||||
const match = `{__name__=~".*${queryString}"${filterArray ? filterArray.join('') : ''}}`;
|
|
||||||
|
|
||||||
const results = await datasource.languageProvider.queryLabelValues(timeRange, METRIC_LABEL, match);
|
|
||||||
|
|
||||||
const resultsOptions = results.map((result) => ({
|
|
||||||
value: result,
|
|
||||||
}));
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
filterMetricsBackend({
|
|
||||||
metrics: resultsOptions,
|
|
||||||
filteredMetricCount: resultsOptions.length,
|
|
||||||
isLoading: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, getDebounceTimeInMilliseconds(datasource.cacheLevel)),
|
|
||||||
[datasource.cacheLevel, datasource.languageProvider, query.labels, timeRange]
|
|
||||||
);
|
|
||||||
|
|
||||||
function fuzzyNameDispatch(haystackData: string[][]) {
|
|
||||||
dispatch(setNameHaystack(haystackData));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fuzzyMetaDispatch(haystackData: string[][]) {
|
|
||||||
dispatch(setMetaHaystack(haystackData));
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchCallback(query: string, fullMetaSearchVal: boolean) {
|
|
||||||
if (state.useBackend && query === '') {
|
|
||||||
// get all metrics data if a user erases everything in the input
|
|
||||||
updateMetricsMetadata();
|
|
||||||
} else if (state.useBackend) {
|
|
||||||
debouncedBackendSearch(query);
|
|
||||||
} else {
|
|
||||||
// search either the names or all metadata
|
|
||||||
// fuzzy search go!
|
|
||||||
if (fullMetaSearchVal) {
|
|
||||||
debouncedFuzzySearch(Object.keys(state.metaHaystackDictionary), query, fuzzyMetaDispatch);
|
|
||||||
} else {
|
|
||||||
debouncedFuzzySearch(Object.keys(state.nameHaystackDictionary), query, fuzzyNameDispatch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings switches */
|
|
||||||
const additionalSettings = (
|
|
||||||
<AdditionalSettings
|
|
||||||
state={state}
|
|
||||||
onChangeFullMetaSearch={() => {
|
|
||||||
const newVal = !state.fullMetaSearch;
|
|
||||||
dispatch(setFullMetaSearch(newVal));
|
|
||||||
onChange({ ...query, fullMetaSearch: newVal });
|
|
||||||
searchCallback(state.fuzzySearchQuery, newVal);
|
|
||||||
}}
|
|
||||||
onChangeIncludeNullMetadata={() => {
|
|
||||||
dispatch(setIncludeNullMetadata(!state.includeNullMetadata));
|
|
||||||
onChange({ ...query, includeNullMetadata: !state.includeNullMetadata });
|
|
||||||
}}
|
|
||||||
onChangeDisableTextWrap={() => {
|
|
||||||
dispatch(setDisableTextWrap());
|
|
||||||
onChange({ ...query, disableTextWrap: !state.disableTextWrap });
|
|
||||||
tracking('grafana_prom_metric_encycopedia_disable_text_wrap_interaction', state, '');
|
|
||||||
}}
|
|
||||||
onChangeUseBackend={() => {
|
|
||||||
const newVal = !state.useBackend;
|
|
||||||
dispatch(setUseBackend(newVal));
|
|
||||||
onChange({ ...query, useBackend: newVal });
|
|
||||||
if (newVal === false) {
|
|
||||||
// rebuild the metrics metadata if we turn off useBackend
|
|
||||||
updateMetricsMetadata();
|
|
||||||
} else {
|
|
||||||
// check if there is text in the browse search and update
|
|
||||||
if (state.fuzzySearchQuery !== '') {
|
|
||||||
debouncedBackendSearch(state.fuzzySearchQuery);
|
|
||||||
}
|
|
||||||
// otherwise wait for user typing
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -220,58 +76,27 @@ export const MetricsModal = (props: MetricsModalProps) => {
|
|||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
data-testid={metricsModaltestIds.searchMetric}
|
data-testid={metricsModaltestIds.searchMetric}
|
||||||
placeholder={placeholders.browse}
|
placeholder={placeholders.browse}
|
||||||
value={state.fuzzySearchQuery}
|
value={searchedText}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const value = e.currentTarget.value ?? '';
|
const value = e.currentTarget.value ?? '';
|
||||||
dispatch(setFuzzySearchQuery(value));
|
setSearchedText(value);
|
||||||
searchCallback(value, state.fullMetaSearch);
|
setPagination({ ...pagination, pageNum: 1 });
|
||||||
|
searchCallback(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{state.hasMetadata && (
|
|
||||||
<div className={styles.inputItem}>
|
|
||||||
<MultiSelect
|
|
||||||
data-testid={metricsModaltestIds.selectType}
|
|
||||||
inputId="my-select"
|
|
||||||
options={typeOptions}
|
|
||||||
value={state.selectedTypes}
|
|
||||||
placeholder={placeholders.type}
|
|
||||||
onChange={(v) => dispatch(setSelectedTypes(v))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<Spinner className={`${styles.loadingSpinner} ${state.isLoading ? styles.visible : ''}`} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.inputItem}>
|
<div className={styles.inputItem}>
|
||||||
<Toggletip
|
<MultiSelect
|
||||||
aria-label={t(
|
data-testid={metricsModaltestIds.selectType}
|
||||||
'grafana-prometheus.querybuilder.metrics-modal.aria-label-additional-settings',
|
inputId="my-select"
|
||||||
'Additional settings'
|
options={typeOptions}
|
||||||
)}
|
value={selectedTypes}
|
||||||
content={additionalSettings}
|
placeholder={placeholders.filterType}
|
||||||
placement="bottom-end"
|
onChange={setSelectedTypes}
|
||||||
closeButton={false}
|
/>
|
||||||
>
|
</div>
|
||||||
<ButtonGroup className={styles.settingsBtn}>
|
<div>
|
||||||
<Button
|
<Spinner className={`${styles.loadingSpinner} ${isLoading ? styles.visible : ''}`} />
|
||||||
variant="secondary"
|
|
||||||
size="md"
|
|
||||||
onClick={() => dispatch(showAdditionalSettings())}
|
|
||||||
data-testid={metricsModaltestIds.showAdditionalSettings}
|
|
||||||
className={styles.noBorder}
|
|
||||||
>
|
|
||||||
<Trans i18nKey="grafana-prometheus.querybuilder.metrics-modal.additional-settings">
|
|
||||||
Additional Settings
|
|
||||||
</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className={styles.noBorder}
|
|
||||||
variant="secondary"
|
|
||||||
icon={state.showAdditionalSettings ? 'angle-up' : 'angle-down'}
|
|
||||||
/>
|
|
||||||
</ButtonGroup>
|
|
||||||
</Toggletip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.resultsData}>
|
<div className={styles.resultsData}>
|
||||||
@@ -298,61 +123,23 @@ export const MetricsModal = (props: MetricsModalProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.results}>
|
<div className={styles.results}>
|
||||||
{state.metrics && (
|
{filteredMetricsData && <ResultsTable onChange={onChange} onClose={onClose} query={query} />}
|
||||||
<ResultsTable
|
|
||||||
metrics={displayedMetrics(state, dispatch)}
|
|
||||||
onChange={onChange}
|
|
||||||
onClose={onClose}
|
|
||||||
query={query}
|
|
||||||
state={state}
|
|
||||||
disableTextWrap={state.disableTextWrap}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.resultsFooter}>
|
<div className={styles.resultsFooter}>
|
||||||
<div className={styles.resultsAmount}>
|
|
||||||
<Trans
|
|
||||||
i18nKey="grafana-prometheus.querybuilder.metrics-modal.results-amount"
|
|
||||||
values={{ num: state.filteredMetricCount }}
|
|
||||||
count={state.totalMetricCount}
|
|
||||||
>
|
|
||||||
Showing {'{{num}}'} of {'{{count}}'} results
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={state.pageNum ?? 1}
|
currentPage={pagination.pageNum > pagination.totalPageNum ? 1 : pagination.pageNum}
|
||||||
numberOfPages={calculatePageList(state).length}
|
numberOfPages={pagination.totalPageNum}
|
||||||
onNavigate={(val: number) => {
|
onNavigate={(val: number) => setPagination({ ...pagination, pageNum: val ?? 1 })}
|
||||||
const page = val ?? 1;
|
|
||||||
dispatch(setPageNum(page));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className={styles.resultsPerPageWrapper}>
|
|
||||||
<p className={styles.resultsPerPageLabel}>
|
|
||||||
<Trans i18nKey="grafana-prometheus.querybuilder.metrics-modal.results-per-page">Results per page</Trans>
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
data-testid={metricsModaltestIds.resultsPerPage}
|
|
||||||
value={calculateResultsPerPage(state.resultsPerPage, DEFAULT_RESULTS_PER_PAGE, MAXIMUM_RESULTS_PER_PAGE)}
|
|
||||||
placeholder={t(
|
|
||||||
'grafana-prometheus.querybuilder.metrics-modal.placeholder-results-per-page',
|
|
||||||
'results per page'
|
|
||||||
)}
|
|
||||||
width={10}
|
|
||||||
title={'The maximum results per page is ' + MAXIMUM_RESULTS_PER_PAGE}
|
|
||||||
type="number"
|
|
||||||
onInput={(e) => {
|
|
||||||
const value = +e.currentTarget.value;
|
|
||||||
|
|
||||||
if (isNaN(value) || value >= MAXIMUM_RESULTS_PER_PAGE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(setResultsPerPage(value));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MetricsModal = (props: MetricsModalProps) => {
|
||||||
|
return (
|
||||||
|
<MetricsModalContextProvider languageProvider={props.datasource.languageProvider}>
|
||||||
|
<MetricsModalContent {...props} />
|
||||||
|
</MetricsModalContextProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+329
@@ -0,0 +1,329 @@
|
|||||||
|
import { act, render, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { TimeRange } from '@grafana/data';
|
||||||
|
|
||||||
|
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
|
||||||
|
|
||||||
|
import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext';
|
||||||
|
import { generateMetricData } from './helpers';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('./helpers', () => ({
|
||||||
|
generateMetricData: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGenerateMetricData = generateMetricData as jest.MockedFunction<typeof generateMetricData>;
|
||||||
|
|
||||||
|
// Mock language provider
|
||||||
|
const mockLanguageProvider: PrometheusLanguageProviderInterface = {
|
||||||
|
queryMetricsMetadata: jest.fn(),
|
||||||
|
queryLabelValues: jest.fn(),
|
||||||
|
retrieveMetricsMetadata: jest.fn(),
|
||||||
|
} as unknown as PrometheusLanguageProviderInterface;
|
||||||
|
|
||||||
|
// Helper to create wrapper component
|
||||||
|
const createWrapper = (languageProvider = mockLanguageProvider) => {
|
||||||
|
return ({ children }: { children: ReactNode }) => (
|
||||||
|
<MetricsModalContextProvider languageProvider={languageProvider}>{children}</MetricsModalContextProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sample time range for tests
|
||||||
|
const defaultTimeRange: TimeRange = {
|
||||||
|
from: 'now-1h' as unknown as TimeRange['from'],
|
||||||
|
to: 'now' as unknown as TimeRange['to'],
|
||||||
|
raw: {
|
||||||
|
from: 'now-1h',
|
||||||
|
to: 'now',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MetricsModalContext', () => {
|
||||||
|
let consoleSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
// Mock console.error to suppress React act() warnings
|
||||||
|
consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Default mock implementations
|
||||||
|
mockGenerateMetricData.mockImplementation((metric) => ({
|
||||||
|
value: metric,
|
||||||
|
type: 'counter',
|
||||||
|
description: 'Test metric',
|
||||||
|
}));
|
||||||
|
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
|
||||||
|
test_metric: { type: 'counter', help: 'Test metric' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useMetricsModal hook', () => {
|
||||||
|
it('should throw error when used outside provider', () => {
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useMetricsModal());
|
||||||
|
}).toThrow('useMetricsModal must be used within a MetricsModalContextProvider');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide context value when used within provider', () => {
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBeDefined();
|
||||||
|
expect(result.current.isLoading).toBe(true); // Initially loading
|
||||||
|
expect(result.current.filteredMetricsData).toEqual([]);
|
||||||
|
expect(result.current.pagination).toEqual({
|
||||||
|
pageNum: 1,
|
||||||
|
totalPageNum: 1,
|
||||||
|
resultsPerPage: DEFAULT_RESULTS_PER_PAGE,
|
||||||
|
});
|
||||||
|
expect(result.current.selectedTypes).toEqual([]);
|
||||||
|
expect(result.current.searchedText).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('State management', () => {
|
||||||
|
it('should update pagination', () => {
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedPagination = { pageNum: 1, resultsPerPage: 50, totalPageNum: 1 };
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setPagination({ pageNum: 2, resultsPerPage: 50, totalPageNum: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.pagination).toEqual(expectedPagination);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update selected types', () => {
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTypes = [{ value: 'counter', label: 'Counter' }];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setSelectedTypes(newTypes);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.selectedTypes).toEqual(newTypes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update searched text', () => {
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setSearchedText('test_metric');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.searchedText).toBe('test_metric');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update loading state', () => {
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Metadata fetching', () => {
|
||||||
|
it('should load initial metadata on mount', async () => {
|
||||||
|
const mockMetadata = {
|
||||||
|
cpu_usage: { type: 'gauge', help: 'CPU usage percentage' },
|
||||||
|
memory_usage: { type: 'gauge', help: 'Memory usage bytes' },
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue(mockMetadata);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for metadata to load
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLanguageProvider.queryMetricsMetadata).toHaveBeenCalledWith(1000);
|
||||||
|
expect(mockGenerateMetricData).toHaveBeenCalledWith('cpu_usage', mockLanguageProvider);
|
||||||
|
expect(mockGenerateMetricData).toHaveBeenCalledWith('memory_usage', mockLanguageProvider);
|
||||||
|
expect(result.current.filteredMetricsData).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty metadata response', async () => {
|
||||||
|
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.filteredMetricsData).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle metadata fetch error', async () => {
|
||||||
|
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.filteredMetricsData).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backend search', () => {
|
||||||
|
it('should perform backend search with results', async () => {
|
||||||
|
(mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['test_metric', 'other_metric']);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.debouncedBackendSearch(defaultTimeRange, 'test');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith(
|
||||||
|
defaultTimeRange,
|
||||||
|
'__name__',
|
||||||
|
'{__name__=~"(?i).*test.*"}'
|
||||||
|
);
|
||||||
|
expect(result.current.filteredMetricsData).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle backend search error', async () => {
|
||||||
|
(mockLanguageProvider.queryLabelValues as jest.Mock).mockRejectedValue(new Error('Search failed'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.debouncedBackendSearch(defaultTimeRange, 'test');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.filteredMetricsData).toEqual([]);
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Filtering logic', () => {
|
||||||
|
it('should return all metrics when no types are selected', async () => {
|
||||||
|
mockGenerateMetricData.mockImplementation((metric) => ({
|
||||||
|
value: metric,
|
||||||
|
type: 'counter',
|
||||||
|
description: 'Test metric',
|
||||||
|
}));
|
||||||
|
|
||||||
|
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
|
||||||
|
test_metric: { type: 'counter', help: 'Test metric' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.filteredMetricsData).toHaveLength(1);
|
||||||
|
expect(result.current.selectedTypes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter metrics by selected type', async () => {
|
||||||
|
mockGenerateMetricData.mockImplementation((metric) => ({
|
||||||
|
value: metric,
|
||||||
|
type: metric === 'counter_metric' ? 'counter' : 'gauge',
|
||||||
|
description: 'Test metric',
|
||||||
|
}));
|
||||||
|
|
||||||
|
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
|
||||||
|
counter_metric: { type: 'counter', help: 'Counter metric' },
|
||||||
|
gauge_metric: { type: 'gauge', help: 'Gauge metric' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setSelectedTypes([{ value: 'counter', label: 'Counter' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.filteredMetricsData).toHaveLength(1);
|
||||||
|
expect(result.current.filteredMetricsData[0].value).toBe('counter_metric');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle metrics without type when "no type" is selected', async () => {
|
||||||
|
mockGenerateMetricData.mockImplementation((metric) => ({
|
||||||
|
value: metric,
|
||||||
|
type: metric === 'no_type_metric' ? undefined : 'counter',
|
||||||
|
description: 'Test metric',
|
||||||
|
}));
|
||||||
|
|
||||||
|
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
|
||||||
|
counter_metric: { type: 'counter', help: 'Counter metric' },
|
||||||
|
no_type_metric: { help: 'Metric without type' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setSelectedTypes([{ value: 'no type', label: 'No Type' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.filteredMetricsData).toHaveLength(1);
|
||||||
|
expect(result.current.filteredMetricsData[0].value).toBe('no_type_metric');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component integration', () => {
|
||||||
|
it('should render provider without errors', () => {
|
||||||
|
const TestComponent = () => {
|
||||||
|
return <div data-testid="test">frontend</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MetricsModalContextProvider languageProvider={mockLanguageProvider}>
|
||||||
|
<TestComponent />
|
||||||
|
</MetricsModalContextProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('test')).toHaveTextContent('frontend');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
import debounce from 'debounce-promise';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
FC,
|
||||||
|
PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { SelectableValue, TimeRange } from '@grafana/data';
|
||||||
|
|
||||||
|
import { METRIC_LABEL, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from '../../../constants';
|
||||||
|
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
|
||||||
|
import { regexifyLabelValuesQueryString } from '../../parsingUtils';
|
||||||
|
import { QueryBuilderLabelFilter } from '../../shared/types';
|
||||||
|
import { formatPrometheusLabelFilters } from '../formatter';
|
||||||
|
|
||||||
|
import { generateMetricData } from './helpers';
|
||||||
|
import { MetricData, MetricsData } from './types';
|
||||||
|
import { fuzzySearch } from './uFuzzy';
|
||||||
|
|
||||||
|
export const DEFAULT_RESULTS_PER_PAGE = 25;
|
||||||
|
|
||||||
|
type Pagination = {
|
||||||
|
pageNum: number;
|
||||||
|
resultsPerPage: number;
|
||||||
|
totalPageNum: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MetricsModalContextValue = {
|
||||||
|
isLoading: boolean;
|
||||||
|
setIsLoading: (val: boolean) => void;
|
||||||
|
filteredMetricsData: MetricData[];
|
||||||
|
debouncedBackendSearch: (
|
||||||
|
timeRange: TimeRange,
|
||||||
|
metricText: string,
|
||||||
|
queryLabels?: QueryBuilderLabelFilter[]
|
||||||
|
) => Promise<void>;
|
||||||
|
pagination: Pagination;
|
||||||
|
setPagination: (val: Pagination) => void;
|
||||||
|
selectedTypes: Array<SelectableValue<string>>;
|
||||||
|
setSelectedTypes: (val: Array<SelectableValue<string>>) => void;
|
||||||
|
searchedText: string;
|
||||||
|
setSearchedText: (val: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricsModalContext = createContext<MetricsModalContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
type MetricsModalContextProviderProps = {
|
||||||
|
languageProvider: PrometheusLanguageProviderInterface;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalContextProviderProps>> = ({
|
||||||
|
children,
|
||||||
|
languageProvider,
|
||||||
|
}) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [metricsData, setMetricsData] = useState<MetricsData>([]);
|
||||||
|
const [pagination, setPagination] = useState<Pagination>({
|
||||||
|
pageNum: 1,
|
||||||
|
totalPageNum: 1,
|
||||||
|
resultsPerPage: DEFAULT_RESULTS_PER_PAGE,
|
||||||
|
});
|
||||||
|
const [selectedTypes, setSelectedTypes] = useState<Array<SelectableValue<string>>>([]);
|
||||||
|
const [searchedText, setSearchedText] = useState('');
|
||||||
|
|
||||||
|
const filteredMetricsData = useMemo(() => {
|
||||||
|
if (selectedTypes.length === 0) {
|
||||||
|
return metricsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter metrics based on selected types
|
||||||
|
return metricsData.filter((metric: MetricData) => {
|
||||||
|
return selectedTypes.some((selectedType) => {
|
||||||
|
// Handle metrics with defined types
|
||||||
|
if (metric.type && selectedType.value) {
|
||||||
|
return metric.type.includes(selectedType.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle metrics without type when "no type" is selected
|
||||||
|
if (!metric.type && selectedType.value === 'no type') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [metricsData, selectedTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPageNum =
|
||||||
|
filteredMetricsData.length === 0 ? 1 : Math.ceil(filteredMetricsData.length / pagination.resultsPerPage);
|
||||||
|
const pageNum = pagination.pageNum > totalPageNum ? 1 : pagination.pageNum;
|
||||||
|
|
||||||
|
setPagination((prevPagination) => ({
|
||||||
|
...prevPagination,
|
||||||
|
totalPageNum,
|
||||||
|
pageNum,
|
||||||
|
}));
|
||||||
|
}, [filteredMetricsData.length, pagination.resultsPerPage, pagination.pageNum]);
|
||||||
|
|
||||||
|
// Track the latest search ID to handle race conditions
|
||||||
|
const latestSearchIdRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const fetchMetadata = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const metadata = await languageProvider.queryMetricsMetadata(PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
|
||||||
|
|
||||||
|
if (Object.keys(metadata).length === 0) {
|
||||||
|
setMetricsData([]);
|
||||||
|
} else {
|
||||||
|
const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider));
|
||||||
|
setMetricsData(processedData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMetricsData([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [languageProvider]);
|
||||||
|
|
||||||
|
const debouncedBackendSearch = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(async (timeRange: TimeRange, metricText: string, queryLabels?: QueryBuilderLabelFilter[]) => {
|
||||||
|
// Generate unique search ID to handle race conditions
|
||||||
|
const searchId = ++latestSearchIdRef.current;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (metricText === '') {
|
||||||
|
await fetchMetadata();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const queryString = regexifyLabelValuesQueryString(metricText);
|
||||||
|
const filterArray = queryLabels ? formatPrometheusLabelFilters(queryLabels) : [];
|
||||||
|
const match = `{__name__=~"(?i).*${queryString}"${filterArray ? filterArray.join('') : ''}}`;
|
||||||
|
|
||||||
|
const results = await languageProvider.queryLabelValues(timeRange, METRIC_LABEL, match);
|
||||||
|
|
||||||
|
// Check if this is still the most recent search
|
||||||
|
if (searchId !== latestSearchIdRef.current) {
|
||||||
|
return; // Ignore outdated results
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fuzzyOrderedMetrics] = fuzzySearch(results, queryString);
|
||||||
|
const resultsOptions: MetricsData = fuzzyOrderedMetrics.map((m) => generateMetricData(m, languageProvider));
|
||||||
|
|
||||||
|
setMetricsData(resultsOptions);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
// Only update state if this is still the latest search
|
||||||
|
if (searchId === latestSearchIdRef.current) {
|
||||||
|
console.error('Backend search failed:', error);
|
||||||
|
setMetricsData([]); // Clear results on error
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300),
|
||||||
|
[fetchMetadata, languageProvider]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMetadata();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MetricsModalContext.Provider
|
||||||
|
value={{
|
||||||
|
isLoading,
|
||||||
|
setIsLoading,
|
||||||
|
filteredMetricsData,
|
||||||
|
debouncedBackendSearch,
|
||||||
|
pagination,
|
||||||
|
setPagination,
|
||||||
|
selectedTypes,
|
||||||
|
setSelectedTypes,
|
||||||
|
searchedText,
|
||||||
|
setSearchedText,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MetricsModalContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMetricsModal() {
|
||||||
|
const context = useContext(MetricsModalContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useMetricsModal must be used within a MetricsModalContextProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
+47
-145
@@ -1,65 +1,62 @@
|
|||||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx
|
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx
|
||||||
import { css } from '@emotion/css';
|
import { ReactElement, useMemo } from 'react';
|
||||||
import { ReactElement } from 'react';
|
|
||||||
import Highlighter from 'react-highlight-words';
|
import Highlighter from 'react-highlight-words';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { t, Trans } from '@grafana/i18n';
|
import { t, Trans } from '@grafana/i18n';
|
||||||
import { Button, Icon, Tooltip, useTheme2 } from '@grafana/ui';
|
import { Icon, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { docsTip } from '../../../configuration/shared/utils';
|
import { docsTip } from '../../../configuration/shared/utils';
|
||||||
import { PromVisualQuery } from '../../types';
|
import { PromVisualQuery } from '../../types';
|
||||||
|
|
||||||
import { tracking } from './state/helpers';
|
import { useMetricsModal } from './MetricsModalContext';
|
||||||
import { MetricsModalState } from './state/state';
|
import { getResultsTableStyles } from './styles';
|
||||||
import { MetricData, MetricsData } from './types';
|
import { MetricData } from './types';
|
||||||
|
|
||||||
type ResultsTableProps = {
|
type ResultsTableProps = {
|
||||||
metrics: MetricsData;
|
|
||||||
onChange: (query: PromVisualQuery) => void;
|
onChange: (query: PromVisualQuery) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
query: PromVisualQuery;
|
query: PromVisualQuery;
|
||||||
state: MetricsModalState;
|
|
||||||
disableTextWrap: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ResultsTable(props: ResultsTableProps) {
|
export function ResultsTable(props: ResultsTableProps) {
|
||||||
const { metrics, onChange, onClose, query, state, disableTextWrap } = props;
|
const { onChange, onClose, query } = props;
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
filteredMetricsData,
|
||||||
|
pagination: { pageNum, resultsPerPage },
|
||||||
|
selectedTypes,
|
||||||
|
searchedText,
|
||||||
|
} = useMetricsModal();
|
||||||
|
|
||||||
const theme = useTheme2();
|
const slicedMetrics = useMemo(() => {
|
||||||
const styles = getStyles(theme, disableTextWrap);
|
const startIndex = (pageNum - 1) * resultsPerPage;
|
||||||
|
const endIndex = startIndex + resultsPerPage;
|
||||||
|
return filteredMetricsData.slice(startIndex, endIndex);
|
||||||
|
}, [filteredMetricsData, pageNum, resultsPerPage]);
|
||||||
|
|
||||||
|
const styles = useStyles2(getResultsTableStyles);
|
||||||
|
|
||||||
function selectMetric(metric: MetricData) {
|
function selectMetric(metric: MetricData) {
|
||||||
if (metric.value) {
|
if (metric.value) {
|
||||||
onChange({ ...query, metric: metric.value });
|
onChange({ ...query, metric: metric.value });
|
||||||
tracking('grafana_prom_metric_encycopedia_tracking', state, metric.value);
|
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function metaRows(metric: MetricData) {
|
function metaRows(metric: MetricData) {
|
||||||
if (state.fullMetaSearch && metric) {
|
return (
|
||||||
return (
|
<>
|
||||||
<>
|
<td>{displayType(metric.type ?? '')}</td>
|
||||||
<td>{displayType(metric.type ?? '')}</td>
|
<td>
|
||||||
<td>
|
<Highlighter
|
||||||
<Highlighter
|
textToHighlight={metric.description ?? ''}
|
||||||
textToHighlight={metric.description ?? ''}
|
searchWords={[]}
|
||||||
searchWords={state.metaHaystackMatches}
|
autoEscape
|
||||||
autoEscape
|
highlightClassName={styles.matchHighLight}
|
||||||
highlightClassName={styles.matchHighLight}
|
/>
|
||||||
/>
|
</td>
|
||||||
</td>
|
</>
|
||||||
</>
|
);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<td>{displayType(metric.type ?? '')}</td>
|
|
||||||
<td>{metric.description ?? ''}</td>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addHelpIcon(fullType: string, descriptiveType: string, link: string) {
|
function addHelpIcon(fullType: string, descriptiveType: string, link: string) {
|
||||||
@@ -105,7 +102,7 @@ export function ResultsTable(props: ResultsTableProps) {
|
|||||||
function noMetricsMessages(): ReactElement {
|
function noMetricsMessages(): ReactElement {
|
||||||
let message;
|
let message;
|
||||||
|
|
||||||
if (!state.fuzzySearchQuery) {
|
if (!searchedText) {
|
||||||
message = t(
|
message = t(
|
||||||
'grafana-prometheus.querybuilder.results-table.message-no-metrics-found',
|
'grafana-prometheus.querybuilder.results-table.message-no-metrics-found',
|
||||||
'There are no metrics found in the data source.'
|
'There are no metrics found in the data source.'
|
||||||
@@ -119,7 +116,7 @@ export function ResultsTable(props: ResultsTableProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.fuzzySearchQuery || state.selectedTypes.length > 0) {
|
if (searchedText || selectedTypes.length > 0) {
|
||||||
message = t(
|
message = t(
|
||||||
'grafana-prometheus.querybuilder.results-table.message-expand-search',
|
'grafana-prometheus.querybuilder.results-table.message-expand-search',
|
||||||
'There are no metrics found. Try to expand your search and filters.'
|
'There are no metrics found. Try to expand your search and filters.'
|
||||||
@@ -133,21 +130,6 @@ export function ResultsTable(props: ResultsTableProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function textHighlight(state: MetricsModalState) {
|
|
||||||
if (state.useBackend) {
|
|
||||||
// highlight the input only for the backend search
|
|
||||||
// this highlight is equivalent to how the metric select highlights
|
|
||||||
// look into matching on regex input
|
|
||||||
return [state.fuzzySearchQuery];
|
|
||||||
} else if (state.fullMetaSearch) {
|
|
||||||
// highlight the matches in the ufuzzy metaHaystack
|
|
||||||
return state.metaHaystackMatches;
|
|
||||||
} else {
|
|
||||||
// highlight the ufuzzy name matches
|
|
||||||
return state.nameHaystackMatches;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead className={styles.stickyHeader}>
|
<thead className={styles.stickyHeader}>
|
||||||
@@ -155,115 +137,35 @@ export function ResultsTable(props: ResultsTableProps) {
|
|||||||
<th className={`${styles.nameWidth} ${styles.tableHeaderPadding}`}>
|
<th className={`${styles.nameWidth} ${styles.tableHeaderPadding}`}>
|
||||||
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.name">Name</Trans>
|
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.name">Name</Trans>
|
||||||
</th>
|
</th>
|
||||||
{state.hasMetadata && (
|
<th className={`${styles.typeWidth} ${styles.tableHeaderPadding}`}>
|
||||||
<>
|
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.type">Type</Trans>
|
||||||
<th className={`${styles.typeWidth} ${styles.tableHeaderPadding}`}>
|
</th>
|
||||||
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.type">Type</Trans>
|
<th className={`${styles.descriptionWidth} ${styles.tableHeaderPadding}`}>
|
||||||
</th>
|
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.description">Description</Trans>
|
||||||
<th className={`${styles.descriptionWidth} ${styles.tableHeaderPadding}`}>
|
</th>
|
||||||
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.description">Description</Trans>
|
|
||||||
</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<th className={styles.selectButtonWidth}> </th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<>
|
<>
|
||||||
{metrics.length > 0 &&
|
{slicedMetrics.length > 0 &&
|
||||||
metrics.map((metric: MetricData, idx: number) => {
|
slicedMetrics.map((metric: MetricData, idx: number) => {
|
||||||
return (
|
return (
|
||||||
<tr key={metric?.value ?? idx} className={styles.row}>
|
<tr key={metric?.value ?? idx} className={styles.row} onClick={() => selectMetric(metric)}>
|
||||||
<td className={styles.nameOverflow}>
|
<td className={styles.nameOverflow}>
|
||||||
<Highlighter
|
<Highlighter
|
||||||
textToHighlight={metric?.value ?? ''}
|
textToHighlight={metric?.value ?? ''}
|
||||||
searchWords={textHighlight(state)}
|
searchWords={searchedText.split(' ')}
|
||||||
autoEscape
|
autoEscape
|
||||||
highlightClassName={styles.matchHighLight}
|
highlightClassName={styles.matchHighLight}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{state.hasMetadata && metaRows(metric)}
|
{metaRows(metric)}
|
||||||
<td>
|
|
||||||
<Button
|
|
||||||
size="md"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => selectMetric(metric)}
|
|
||||||
className={styles.centerButton}
|
|
||||||
>
|
|
||||||
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.select">Select</Trans>
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{metrics.length === 0 && !state.isLoading && noMetricsMessages()}
|
{slicedMetrics.length === 0 && !isLoading && noMetricsMessages()}
|
||||||
</>
|
</>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
|
||||||
return {
|
|
||||||
table: css({
|
|
||||||
tableLayout: disableTextWrap ? undefined : 'fixed',
|
|
||||||
borderRadius: theme.shape.radius.default,
|
|
||||||
width: '100%',
|
|
||||||
whiteSpace: disableTextWrap ? 'nowrap' : 'normal',
|
|
||||||
td: {
|
|
||||||
padding: theme.spacing(1),
|
|
||||||
},
|
|
||||||
'td,th': {
|
|
||||||
minWidth: theme.spacing(3),
|
|
||||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
row: css({
|
|
||||||
label: 'row',
|
|
||||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
|
||||||
'&:last-child': {
|
|
||||||
borderBottom: 0,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
tableHeaderPadding: css({
|
|
||||||
padding: '8px',
|
|
||||||
}),
|
|
||||||
matchHighLight: css({
|
|
||||||
background: 'inherit',
|
|
||||||
color: theme.components.textHighlight.text,
|
|
||||||
backgroundColor: theme.components.textHighlight.background,
|
|
||||||
}),
|
|
||||||
nameWidth: css({
|
|
||||||
width: disableTextWrap ? undefined : '37.5%',
|
|
||||||
}),
|
|
||||||
nameOverflow: css({
|
|
||||||
overflowWrap: disableTextWrap ? undefined : 'anywhere',
|
|
||||||
}),
|
|
||||||
typeWidth: css({
|
|
||||||
width: disableTextWrap ? undefined : '15%',
|
|
||||||
}),
|
|
||||||
descriptionWidth: css({
|
|
||||||
width: disableTextWrap ? undefined : '35%',
|
|
||||||
}),
|
|
||||||
selectButtonWidth: css({
|
|
||||||
width: disableTextWrap ? undefined : '12.5%',
|
|
||||||
}),
|
|
||||||
stickyHeader: css({
|
|
||||||
position: 'sticky',
|
|
||||||
top: 0,
|
|
||||||
backgroundColor: theme.colors.background.primary,
|
|
||||||
}),
|
|
||||||
noResults: css({
|
|
||||||
textAlign: 'center',
|
|
||||||
color: theme.colors.text.secondary,
|
|
||||||
}),
|
|
||||||
tooltipSpace: css({
|
|
||||||
marginLeft: '4px',
|
|
||||||
}),
|
|
||||||
centerButton: css({
|
|
||||||
display: 'block',
|
|
||||||
margin: 'auto',
|
|
||||||
border: 'none',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
|
||||||
|
import { PromMetricsMetadata } from '../../../types';
|
||||||
|
|
||||||
|
import { calculatePageList, generateMetricData, getPlaceholders, getPromTypes } from './helpers';
|
||||||
|
import { MetricsData } from './types';
|
||||||
|
|
||||||
|
// Mock the language provider
|
||||||
|
const createMockLanguageProvider = (metadata: PromMetricsMetadata = {}): PrometheusLanguageProviderInterface =>
|
||||||
|
({
|
||||||
|
retrieveMetricsMetadata: jest.fn().mockReturnValue(metadata),
|
||||||
|
}) as unknown as PrometheusLanguageProviderInterface;
|
||||||
|
|
||||||
|
describe('helpers.ts', () => {
|
||||||
|
describe('generateMetricData', () => {
|
||||||
|
it('should generate basic metric data', () => {
|
||||||
|
const mockProvider = createMockLanguageProvider({
|
||||||
|
test_metric: {
|
||||||
|
type: 'counter',
|
||||||
|
help: 'Test counter metric',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = generateMetricData('test_metric', mockProvider);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
value: 'test_metric',
|
||||||
|
type: 'counter',
|
||||||
|
description: 'Test counter metric',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle metric with no metadata', () => {
|
||||||
|
const mockProvider = createMockLanguageProvider({});
|
||||||
|
|
||||||
|
const result = generateMetricData('unknown_metric', mockProvider);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
value: 'unknown_metric',
|
||||||
|
type: undefined,
|
||||||
|
description: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enhance type based on description for histogram', () => {
|
||||||
|
const mockProvider = createMockLanguageProvider({
|
||||||
|
test_metric: {
|
||||||
|
type: 'gauge',
|
||||||
|
help: 'This is a histogram metric for testing',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = generateMetricData('test_metric', mockProvider);
|
||||||
|
|
||||||
|
expect(result.type).toBe('gauge (histogram)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enhance type based on description for summary', () => {
|
||||||
|
const mockProvider = createMockLanguageProvider({
|
||||||
|
test_metric: {
|
||||||
|
type: 'counter',
|
||||||
|
help: 'This is a summary metric for testing',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = generateMetricData('test_metric', mockProvider);
|
||||||
|
|
||||||
|
expect(result.type).toBe('counter (summary)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not enhance type if already matches description', () => {
|
||||||
|
const mockProvider = createMockLanguageProvider({
|
||||||
|
test_metric: {
|
||||||
|
type: 'histogram',
|
||||||
|
help: 'This is a histogram metric for testing',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = generateMetricData('test_metric', mockProvider);
|
||||||
|
|
||||||
|
expect(result.type).toBe('native histogram'); // Should be native histogram, not enhanced
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect native histogram vs classic histogram', () => {
|
||||||
|
const mockProvider = createMockLanguageProvider({
|
||||||
|
native_histogram: {
|
||||||
|
type: 'histogram',
|
||||||
|
help: 'Native histogram',
|
||||||
|
},
|
||||||
|
classic_bucket: {
|
||||||
|
type: 'histogram',
|
||||||
|
help: 'Classic histogram',
|
||||||
|
},
|
||||||
|
classic_bucket_with_labels: {
|
||||||
|
type: 'histogram',
|
||||||
|
help: 'Classic histogram with labels',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Native histogram (no _bucket suffix)
|
||||||
|
const nativeResult = generateMetricData('native_histogram', mockProvider);
|
||||||
|
expect(nativeResult.type).toBe('native histogram');
|
||||||
|
|
||||||
|
// Classic histogram (with _bucket suffix)
|
||||||
|
const classicResult = generateMetricData('classic_bucket', mockProvider);
|
||||||
|
expect(classicResult.type).toBe('histogram');
|
||||||
|
|
||||||
|
// Classic histogram with labels
|
||||||
|
const classicWithLabelsResult = generateMetricData('classic_bucket_with_labels', mockProvider);
|
||||||
|
expect(classicWithLabelsResult.type).toBe('native histogram'); // No _bucket pattern match
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle old histogram pattern matching', () => {
|
||||||
|
const mockProvider = createMockLanguageProvider({
|
||||||
|
test_bucket: { type: 'histogram', help: 'Test bucket metric' },
|
||||||
|
'test_bucket{le="0.1"}': { type: 'histogram', help: 'Test bucket with labels' },
|
||||||
|
test_histogram: { type: 'histogram', help: 'Test histogram metric' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be classic histogram (matches pattern)
|
||||||
|
expect(generateMetricData('test_bucket', mockProvider).type).toBe('histogram');
|
||||||
|
expect(generateMetricData('test_bucket{le="0.1"}', mockProvider).type).toBe('histogram');
|
||||||
|
|
||||||
|
// Should be native histogram (doesn't match pattern)
|
||||||
|
expect(generateMetricData('test_histogram', mockProvider).type).toBe('native histogram');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case-insensitive description matching', () => {
|
||||||
|
const mockProvider = createMockLanguageProvider({
|
||||||
|
test_metric: {
|
||||||
|
type: 'gauge',
|
||||||
|
help: 'This is a HISTOGRAM metric for testing',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = generateMetricData('test_metric', mockProvider);
|
||||||
|
expect(result.type).toBe('gauge (histogram)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty type with description enhancement', () => {
|
||||||
|
const mockProvider = createMockLanguageProvider({
|
||||||
|
test_metric: {
|
||||||
|
type: '',
|
||||||
|
help: 'This is a histogram metric for testing',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = generateMetricData('test_metric', mockProvider);
|
||||||
|
expect(result.type).toBe('native histogram');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty description', () => {
|
||||||
|
const mockProvider = createMockLanguageProvider({
|
||||||
|
test_metric: {
|
||||||
|
type: 'gauge',
|
||||||
|
help: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = generateMetricData('test_metric', mockProvider);
|
||||||
|
expect(result.type).toBe('gauge');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculatePageList', () => {
|
||||||
|
const createMetricsData = (length: number): MetricsData =>
|
||||||
|
Array.from({ length }, (_, i) => ({ value: `metric_${i}` }));
|
||||||
|
|
||||||
|
it('should return empty array for empty metrics data', () => {
|
||||||
|
expect(calculatePageList([], 10)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return [1] for zero or negative results per page', () => {
|
||||||
|
const metricsData = createMetricsData(5);
|
||||||
|
expect(calculatePageList(metricsData, 0)).toEqual([1]);
|
||||||
|
expect(calculatePageList(metricsData, -5)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct page list for exact division', () => {
|
||||||
|
const metricsData = createMetricsData(20);
|
||||||
|
const result = calculatePageList(metricsData, 10);
|
||||||
|
expect(result).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct page list for non-exact division', () => {
|
||||||
|
const metricsData = createMetricsData(23);
|
||||||
|
const result = calculatePageList(metricsData, 10);
|
||||||
|
expect(result).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single page scenario', () => {
|
||||||
|
const metricsData = createMetricsData(5);
|
||||||
|
const result = calculatePageList(metricsData, 10);
|
||||||
|
expect(result).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large datasets', () => {
|
||||||
|
const metricsData = createMetricsData(1000);
|
||||||
|
const result = calculatePageList(metricsData, 25);
|
||||||
|
expect(result).toHaveLength(40);
|
||||||
|
expect(result[0]).toBe(1);
|
||||||
|
expect(result[result.length - 1]).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single item per page', () => {
|
||||||
|
const metricsData = createMetricsData(3);
|
||||||
|
const result = calculatePageList(metricsData, 1);
|
||||||
|
expect(result).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle fractional results per page', () => {
|
||||||
|
const metricsData = createMetricsData(10);
|
||||||
|
expect(calculatePageList(metricsData, 3.5)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPromTypes', () => {
|
||||||
|
it('should return array of Prometheus types', () => {
|
||||||
|
const types = getPromTypes();
|
||||||
|
|
||||||
|
expect(Array.isArray(types)).toBe(true);
|
||||||
|
expect(types.length).toBe(7);
|
||||||
|
|
||||||
|
const expectedValues = ['counter', 'gauge', 'histogram', 'native histogram', 'summary', 'unknown', 'no type'];
|
||||||
|
const actualValues = types.map((type) => type.value);
|
||||||
|
|
||||||
|
expect(actualValues).toEqual(expectedValues);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct structure for each type', () => {
|
||||||
|
const types = getPromTypes();
|
||||||
|
|
||||||
|
types.forEach((type) => {
|
||||||
|
expect(type).toHaveProperty('value');
|
||||||
|
expect(type).toHaveProperty('label');
|
||||||
|
expect(type).toHaveProperty('description');
|
||||||
|
|
||||||
|
expect(typeof type.value).toBe('string');
|
||||||
|
expect(typeof type.label).toBe('string');
|
||||||
|
expect(typeof type.description).toBe('string');
|
||||||
|
|
||||||
|
expect(type.value.length).toBeGreaterThan(0);
|
||||||
|
expect(type.label.length).toBeGreaterThan(0);
|
||||||
|
expect(type.description.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return consistent results on multiple calls', () => {
|
||||||
|
const types1 = getPromTypes();
|
||||||
|
const types2 = getPromTypes();
|
||||||
|
|
||||||
|
expect(types1).toEqual(types2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have unique values', () => {
|
||||||
|
const types = getPromTypes();
|
||||||
|
const values = types.map((type) => type.value);
|
||||||
|
const uniqueValues = [...new Set(values)];
|
||||||
|
|
||||||
|
expect(values.length).toBe(uniqueValues.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have descriptive labels', () => {
|
||||||
|
const types = getPromTypes();
|
||||||
|
|
||||||
|
types.forEach((type) => {
|
||||||
|
expect(type.label).not.toBe(type.value);
|
||||||
|
expect(type.label.length).toBeGreaterThanOrEqual(type.value.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPlaceholders', () => {
|
||||||
|
it('should return object with all required placeholders', () => {
|
||||||
|
const placeholders = getPlaceholders();
|
||||||
|
|
||||||
|
const expectedKeys = ['browse', 'filterType'];
|
||||||
|
|
||||||
|
expect(Object.keys(placeholders)).toEqual(expectedKeys);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have string values for all placeholders', () => {
|
||||||
|
const placeholders = getPlaceholders();
|
||||||
|
|
||||||
|
Object.values(placeholders).forEach((placeholder) => {
|
||||||
|
expect(typeof placeholder).toBe('string');
|
||||||
|
expect(placeholder.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return consistent results on multiple calls', () => {
|
||||||
|
const placeholders1 = getPlaceholders();
|
||||||
|
const placeholders2 = getPlaceholders();
|
||||||
|
|
||||||
|
expect(placeholders1).toEqual(placeholders2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain expected placeholder content', () => {
|
||||||
|
const placeholders = getPlaceholders();
|
||||||
|
|
||||||
|
expect(placeholders.browse).toMatch(/search/i);
|
||||||
|
expect(placeholders.filterType).toMatch(/type/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have descriptive placeholder text', () => {
|
||||||
|
const placeholders = getPlaceholders();
|
||||||
|
|
||||||
|
Object.values(placeholders).forEach((placeholder) => {
|
||||||
|
expect(placeholder.length).toBeGreaterThan(5);
|
||||||
|
expect(placeholder).toMatch(/[a-zA-Z]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
|
||||||
|
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
|
||||||
|
|
||||||
|
import { MetricData, MetricsData, PromFilterOption } from './types';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const HISTOGRAM_TYPES = ['histogram', 'summary'] as const;
|
||||||
|
const OLD_HISTOGRAM_PATTERN = /^\w+_bucket$|^\w+_bucket{.*}$/;
|
||||||
|
const HISTOGRAM_TYPE = 'histogram';
|
||||||
|
const NATIVE_HISTOGRAM_TYPE = 'native histogram';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the metric data object with type and description
|
||||||
|
* @param metric - The metric name
|
||||||
|
* @param languageProvider - The Prometheus language provider interface
|
||||||
|
* @returns MetricData object with value, type, and description
|
||||||
|
*/
|
||||||
|
export const generateMetricData = (
|
||||||
|
metric: string,
|
||||||
|
languageProvider: PrometheusLanguageProviderInterface
|
||||||
|
): MetricData => {
|
||||||
|
const metadata = languageProvider.retrieveMetricsMetadata();
|
||||||
|
|
||||||
|
let type = metadata[metric]?.type;
|
||||||
|
const description = metadata[metric]?.help;
|
||||||
|
|
||||||
|
HISTOGRAM_TYPES.forEach((t) => {
|
||||||
|
if (description?.toLowerCase().includes(t) && type !== t) {
|
||||||
|
type = type ? `${type} (${t})` : t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldHistogramMatch = metric.match(OLD_HISTOGRAM_PATTERN);
|
||||||
|
|
||||||
|
if (type === HISTOGRAM_TYPE && !oldHistogramMatch) {
|
||||||
|
type = NATIVE_HISTOGRAM_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: metric,
|
||||||
|
type: type,
|
||||||
|
description: description,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function calculatePageList(metricsData: MetricsData, resultsPerPage: number): number[] {
|
||||||
|
if (!Array.isArray(metricsData) || metricsData.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultsPerPage <= 0) {
|
||||||
|
return [1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(metricsData.length / resultsPerPage);
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPromTypes: () => PromFilterOption[] = () => [
|
||||||
|
{
|
||||||
|
value: 'counter',
|
||||||
|
label: t('grafana-prometheus.querybuilder.get-prom-types.label-counter', 'Counter'),
|
||||||
|
description: t(
|
||||||
|
'grafana-prometheus.querybuilder.get-prom-types.description-counter',
|
||||||
|
'A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart.'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gauge',
|
||||||
|
label: t('grafana-prometheus.querybuilder.get-prom-types.label-gauge', 'Gauge'),
|
||||||
|
description: t(
|
||||||
|
'grafana-prometheus.querybuilder.get-prom-types.description-gauge',
|
||||||
|
'A metric that represents a single numerical value that can arbitrarily go up and down.'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'histogram',
|
||||||
|
label: t('grafana-prometheus.querybuilder.get-prom-types.label-histogram', 'Histogram'),
|
||||||
|
description: t(
|
||||||
|
'grafana-prometheus.querybuilder.get-prom-types.description-histogram',
|
||||||
|
'A histogram samples observations (usually things like request durations or response sizes) and counts them in configurable buckets.'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'native histogram',
|
||||||
|
label: t('grafana-prometheus.querybuilder.get-prom-types.label-native-histogram', 'Native histogram'),
|
||||||
|
description: t(
|
||||||
|
'grafana-prometheus.querybuilder.get-prom-types.description-native-histogram',
|
||||||
|
'Native histograms are different from classic Prometheus histograms in a number of ways: Native histogram bucket boundaries are calculated by a formula that depends on the scale (resolution) of the native histogram, and are not user defined.'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'summary',
|
||||||
|
label: t('grafana-prometheus.querybuilder.get-prom-types.label-summary', 'Summary'),
|
||||||
|
description: t(
|
||||||
|
'grafana-prometheus.querybuilder.get-prom-types.description-summary',
|
||||||
|
'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'unknown',
|
||||||
|
label: t('grafana-prometheus.querybuilder.get-prom-types.label-unknown', 'Unknown'),
|
||||||
|
description: t(
|
||||||
|
'grafana-prometheus.querybuilder.get-prom-types.description-unknown',
|
||||||
|
'These metrics have been given the type unknown in the metadata.'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'no type',
|
||||||
|
label: t('grafana-prometheus.querybuilder.get-prom-types.label-no-type', 'No type'),
|
||||||
|
description: t(
|
||||||
|
'grafana-prometheus.querybuilder.get-prom-types.description-no-type',
|
||||||
|
'These metrics have no defined type in the metadata.'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getPlaceholders = () => ({
|
||||||
|
browse: t('grafana-prometheus.querybuilder.get-placeholders.browse', 'Search metrics by name'),
|
||||||
|
filterType: t('grafana-prometheus.querybuilder.get-placeholders.type', 'Filter by type'),
|
||||||
|
});
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { TimeRange } from '@grafana/data';
|
|
||||||
|
|
||||||
import { PrometheusDatasource } from '../../../../datasource';
|
|
||||||
import { PromVisualQuery } from '../../../types';
|
|
||||||
|
|
||||||
interface MetricsModalState {
|
|
||||||
useBackend: boolean;
|
|
||||||
disableTextWrap: boolean;
|
|
||||||
includeNullMetadata: boolean;
|
|
||||||
fullMetaSearch: boolean;
|
|
||||||
hasMetadata: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MetricsModalProps {
|
|
||||||
datasource: PrometheusDatasource;
|
|
||||||
isOpen: boolean;
|
|
||||||
query: PromVisualQuery;
|
|
||||||
onClose: () => void;
|
|
||||||
onChange: (query: PromVisualQuery) => void;
|
|
||||||
initialMetrics: string[] | (() => Promise<string[]>);
|
|
||||||
timeRange: TimeRange;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdditionalSettingsProps {
|
|
||||||
state: MetricsModalState;
|
|
||||||
onChangeFullMetaSearch: () => void;
|
|
||||||
onChangeIncludeNullMetadata: () => void;
|
|
||||||
onChangeDisableTextWrap: () => void;
|
|
||||||
onChangeUseBackend: () => void;
|
|
||||||
}
|
|
||||||
-278
@@ -1,278 +0,0 @@
|
|||||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts
|
|
||||||
import { AnyAction } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
import { t } from '@grafana/i18n';
|
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
|
||||||
|
|
||||||
import { PrometheusDatasource } from '../../../../datasource';
|
|
||||||
import { PromMetricsMetadata } from '../../../../types';
|
|
||||||
import { PromVisualQuery } from '../../../types';
|
|
||||||
import { HaystackDictionary, MetricData, MetricsData, PromFilterOption } from '../types';
|
|
||||||
|
|
||||||
import { MetricsModalMetadata, MetricsModalState, setFilteredMetricCount } from './state';
|
|
||||||
|
|
||||||
export async function setMetrics(
|
|
||||||
datasource: PrometheusDatasource,
|
|
||||||
query: PromVisualQuery,
|
|
||||||
initialMetrics?: string[]
|
|
||||||
): Promise<MetricsModalMetadata> {
|
|
||||||
// metadata is set in the metric select now
|
|
||||||
// use this to disable metadata search and display
|
|
||||||
let hasMetadata = true;
|
|
||||||
const metadata = datasource.languageProvider.retrieveMetricsMetadata();
|
|
||||||
if (metadata && Object.keys(metadata).length === 0) {
|
|
||||||
hasMetadata = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nameHaystackDictionaryData: HaystackDictionary = {};
|
|
||||||
let metaHaystackDictionaryData: HaystackDictionary = {};
|
|
||||||
|
|
||||||
// pass in metrics from getMetrics in the query builder, reduced in the metric select
|
|
||||||
let metricsData: MetricsData | undefined;
|
|
||||||
|
|
||||||
metricsData = initialMetrics?.map((m: string) => {
|
|
||||||
const metricData = buildMetricData(m, datasource);
|
|
||||||
|
|
||||||
const metaDataString = `${m}¦${metricData.description}`;
|
|
||||||
|
|
||||||
nameHaystackDictionaryData[m] = metricData;
|
|
||||||
metaHaystackDictionaryData[metaDataString] = metricData;
|
|
||||||
|
|
||||||
return metricData;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
isLoading: false,
|
|
||||||
hasMetadata: hasMetadata,
|
|
||||||
metrics: metricsData ?? [],
|
|
||||||
metaHaystackDictionary: metaHaystackDictionaryData,
|
|
||||||
nameHaystackDictionary: nameHaystackDictionaryData,
|
|
||||||
totalMetricCount: metricsData?.length ?? 0,
|
|
||||||
filteredMetricCount: metricsData?.length ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the metric data object with type and description
|
|
||||||
*
|
|
||||||
* @param metric The metric name
|
|
||||||
* @param datasource The Prometheus datasource for mapping metradata to the metric name
|
|
||||||
* @returns A MetricData object.
|
|
||||||
*/
|
|
||||||
function buildMetricData(metric: string, datasource: PrometheusDatasource): MetricData {
|
|
||||||
let type = getMetadataType(metric, datasource.languageProvider.retrieveMetricsMetadata());
|
|
||||||
|
|
||||||
const description = getMetadataHelp(metric, datasource.languageProvider.retrieveMetricsMetadata());
|
|
||||||
|
|
||||||
['histogram', 'summary'].forEach((t) => {
|
|
||||||
if (description?.toLowerCase().includes(t) && type !== t) {
|
|
||||||
type += ` (${t})`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldHistogramMatch = metric.match(/^\w+_bucket$|^\w+_bucket{.*}$/);
|
|
||||||
|
|
||||||
if (type === 'histogram' && !oldHistogramMatch) {
|
|
||||||
type = 'native histogram';
|
|
||||||
}
|
|
||||||
|
|
||||||
const metricData: MetricData = {
|
|
||||||
value: metric,
|
|
||||||
type: type,
|
|
||||||
description: description,
|
|
||||||
};
|
|
||||||
|
|
||||||
return metricData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMetadataHelp(metric: string, metadata: PromMetricsMetadata): string | undefined {
|
|
||||||
return metadata[metric]?.help;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMetadataType(metric: string, metadata: PromMetricsMetadata): string | undefined {
|
|
||||||
return metadata[metric]?.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The filtered and paginated metrics displayed in the modal
|
|
||||||
* */
|
|
||||||
export function displayedMetrics(state: MetricsModalState, dispatch: React.Dispatch<AnyAction>) {
|
|
||||||
const filteredSorted: MetricsData = filterMetrics(state);
|
|
||||||
|
|
||||||
if (!state.isLoading && state.filteredMetricCount !== filteredSorted.length) {
|
|
||||||
dispatch(setFilteredMetricCount(filteredSorted.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
return sliceMetrics(filteredSorted, state.pageNum, state.resultsPerPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter the metrics with all the options, fuzzy, type, null metadata
|
|
||||||
*/
|
|
||||||
function filterMetrics(state: MetricsModalState): MetricsData {
|
|
||||||
let filteredMetrics: MetricsData = state.metrics;
|
|
||||||
|
|
||||||
if (state.fuzzySearchQuery && !state.useBackend) {
|
|
||||||
if (state.fullMetaSearch) {
|
|
||||||
filteredMetrics = state.metaHaystackOrder.map((needle: string) => state.metaHaystackDictionary[needle]);
|
|
||||||
} else {
|
|
||||||
filteredMetrics = state.nameHaystackOrder.map((needle: string) => state.nameHaystackDictionary[needle]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.selectedTypes.length > 0) {
|
|
||||||
filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => {
|
|
||||||
// Matches type
|
|
||||||
const matchesSelectedType = state.selectedTypes.some((t) => {
|
|
||||||
if (m.type && t.value) {
|
|
||||||
return m.type.includes(t.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m.type && t.value === 'no type') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// when a user filters for type, only return metrics with defined types
|
|
||||||
return matchesSelectedType;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.includeNullMetadata) {
|
|
||||||
filteredMetrics = filteredMetrics.filter((m: MetricData) => {
|
|
||||||
return m.type !== undefined && m.description !== undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredMetrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculatePageList(state: MetricsModalState) {
|
|
||||||
if (!state.metrics.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const calcResultsPerPage: number = state.resultsPerPage === 0 ? 1 : state.resultsPerPage;
|
|
||||||
|
|
||||||
const pages = Math.floor(filterMetrics(state).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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const calculateResultsPerPage = (results: number, defaultResults: number, max: number) => {
|
|
||||||
if (results < 1) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results > max) {
|
|
||||||
return max;
|
|
||||||
}
|
|
||||||
|
|
||||||
return results ?? defaultResults;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function tracking(event: string, state?: MetricsModalState | null, metric?: string, query?: PromVisualQuery) {
|
|
||||||
switch (event) {
|
|
||||||
case 'grafana_prom_metric_encycopedia_tracking':
|
|
||||||
reportInteraction(event, {
|
|
||||||
metric: metric,
|
|
||||||
hasMetadata: state?.hasMetadata,
|
|
||||||
totalMetricCount: state?.totalMetricCount,
|
|
||||||
fuzzySearchQuery: state?.fuzzySearchQuery,
|
|
||||||
fullMetaSearch: state?.fullMetaSearch,
|
|
||||||
selectedTypes: state?.selectedTypes,
|
|
||||||
useRegexSearch: state?.useBackend,
|
|
||||||
includeResultsWithoutMetadata: state?.includeNullMetadata,
|
|
||||||
});
|
|
||||||
case 'grafana_prom_metric_encycopedia_disable_text_wrap_interaction':
|
|
||||||
reportInteraction(event, {
|
|
||||||
disableTextWrap: state?.disableTextWrap,
|
|
||||||
});
|
|
||||||
case 'grafana_prometheus_metric_encyclopedia_open':
|
|
||||||
reportInteraction(event, {
|
|
||||||
query: query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPromTypes: () => PromFilterOption[] = () => [
|
|
||||||
{
|
|
||||||
value: 'counter',
|
|
||||||
label: t('grafana-prometheus.querybuilder.get-prom-types.label-counter', 'Counter'),
|
|
||||||
description: t(
|
|
||||||
'grafana-prometheus.querybuilder.get-prom-types.description-counter',
|
|
||||||
'A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart.'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'gauge',
|
|
||||||
label: t('grafana-prometheus.querybuilder.get-prom-types.label-gauge', 'Gauge'),
|
|
||||||
description: t(
|
|
||||||
'grafana-prometheus.querybuilder.get-prom-types.description-gauge',
|
|
||||||
'A metric that represents a single numerical value that can arbitrarily go up and down.'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'histogram',
|
|
||||||
label: t('grafana-prometheus.querybuilder.get-prom-types.label-histogram', 'Histogram'),
|
|
||||||
description: t(
|
|
||||||
'grafana-prometheus.querybuilder.get-prom-types.description-histogram',
|
|
||||||
'A histogram samples observations (usually things like request durations or response sizes) and counts them in configurable buckets.'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'native histogram',
|
|
||||||
label: t('grafana-prometheus.querybuilder.get-prom-types.label-native-histogram', 'Native histogram'),
|
|
||||||
description: t(
|
|
||||||
'grafana-prometheus.querybuilder.get-prom-types.description-native-histogram',
|
|
||||||
'Native histograms are different from classic Prometheus histograms in a number of ways: Native histogram bucket boundaries are calculated by a formula that depends on the scale (resolution) of the native histogram, and are not user defined.'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'summary',
|
|
||||||
label: t('grafana-prometheus.querybuilder.get-prom-types.label-summary', 'Summary'),
|
|
||||||
description: t(
|
|
||||||
'grafana-prometheus.querybuilder.get-prom-types.description-summary',
|
|
||||||
'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'unknown',
|
|
||||||
label: t('grafana-prometheus.querybuilder.get-prom-types.label-unknown', 'Unknown'),
|
|
||||||
description: t(
|
|
||||||
'grafana-prometheus.querybuilder.get-prom-types.description-unknown',
|
|
||||||
'These metrics have been given the type unknown in the metadata.'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'no type',
|
|
||||||
label: t('grafana-prometheus.querybuilder.get-prom-types.label-no-type', 'No type'),
|
|
||||||
description: t(
|
|
||||||
'grafana-prometheus.querybuilder.get-prom-types.description-no-type',
|
|
||||||
'These metrics have no defined type in the metadata.'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getPlaceholders = () => ({
|
|
||||||
browse: t('grafana-prometheus.querybuilder.get-placeholders.browse', 'Search metrics by name'),
|
|
||||||
metadataSearchSwitch: t(
|
|
||||||
'grafana-prometheus.querybuilder.get-placeholders.metadata-search-switch',
|
|
||||||
'Include description in search'
|
|
||||||
),
|
|
||||||
type: t('grafana-prometheus.querybuilder.get-placeholders.type', 'Filter by type'),
|
|
||||||
includeNullMetadata: t(
|
|
||||||
'grafana-prometheus.querybuilder.get-placeholders.include-null-metadata',
|
|
||||||
'Include results with no metadata'
|
|
||||||
),
|
|
||||||
setUseBackend: t('grafana-prometheus.querybuilder.get-placeholders.set-use-backend', 'Enable regex search'),
|
|
||||||
});
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts
|
|
||||||
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
|
||||||
|
|
||||||
import { PromVisualQuery } from '../../../types';
|
|
||||||
import { HaystackDictionary, MetricsData } from '../types';
|
|
||||||
|
|
||||||
export const DEFAULT_RESULTS_PER_PAGE = 100;
|
|
||||||
export const MAXIMUM_RESULTS_PER_PAGE = 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initial state for the metrics explorer
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function initialState(query?: PromVisualQuery): MetricsModalState {
|
|
||||||
return {
|
|
||||||
isLoading: true,
|
|
||||||
metrics: [],
|
|
||||||
hasMetadata: true,
|
|
||||||
metaHaystackDictionary: {},
|
|
||||||
metaHaystackMatches: [],
|
|
||||||
metaHaystackOrder: [],
|
|
||||||
nameHaystackDictionary: {},
|
|
||||||
nameHaystackOrder: [],
|
|
||||||
nameHaystackMatches: [],
|
|
||||||
totalMetricCount: 0,
|
|
||||||
filteredMetricCount: null,
|
|
||||||
resultsPerPage: DEFAULT_RESULTS_PER_PAGE,
|
|
||||||
pageNum: 1,
|
|
||||||
fuzzySearchQuery: '',
|
|
||||||
fullMetaSearch: query?.fullMetaSearch ?? false,
|
|
||||||
includeNullMetadata: query?.includeNullMetadata ?? true,
|
|
||||||
selectedTypes: [],
|
|
||||||
useBackend: query?.useBackend ?? false,
|
|
||||||
disableTextWrap: query?.disableTextWrap ?? false,
|
|
||||||
showAdditionalSettings: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The metrics explorer state object
|
|
||||||
*/
|
|
||||||
export interface MetricsModalState {
|
|
||||||
/** Used for the loading spinner */
|
|
||||||
isLoading: boolean;
|
|
||||||
/**
|
|
||||||
* Initial collection of metrics.
|
|
||||||
* The frontend filters do not impact this, but
|
|
||||||
* it is reduced by the backend search.
|
|
||||||
*/
|
|
||||||
metrics: MetricsData;
|
|
||||||
/** Field for disabling type select and switches that rely on metadata */
|
|
||||||
hasMetadata: boolean;
|
|
||||||
/** Used to display metrics and help with fuzzy order */
|
|
||||||
nameHaystackDictionary: HaystackDictionary;
|
|
||||||
/** Used to sort name fuzzy search by relevance */
|
|
||||||
nameHaystackOrder: string[];
|
|
||||||
/** Used to highlight text in fuzzy matches */
|
|
||||||
nameHaystackMatches: string[];
|
|
||||||
/** Used to display metrics and help with fuzzy order for search across all metadata */
|
|
||||||
metaHaystackDictionary: HaystackDictionary;
|
|
||||||
/** Used to sort meta fuzzy search by relevance */
|
|
||||||
metaHaystackOrder: string[];
|
|
||||||
/** Used to highlight text in fuzzy matches */
|
|
||||||
metaHaystackMatches: string[];
|
|
||||||
/** Total results computed on initialization */
|
|
||||||
totalMetricCount: number;
|
|
||||||
/** Set after filtering metrics */
|
|
||||||
filteredMetricCount: number | null;
|
|
||||||
/** Pagination field for showing results in table */
|
|
||||||
resultsPerPage: number;
|
|
||||||
/** Pagination field */
|
|
||||||
pageNum: number;
|
|
||||||
/** The text query used to match metrics */
|
|
||||||
fuzzySearchQuery: string;
|
|
||||||
/** Enables the fuzzy meatadata search */
|
|
||||||
fullMetaSearch: boolean;
|
|
||||||
/** Includes results that are missing type and description */
|
|
||||||
includeNullMetadata: boolean;
|
|
||||||
/** Filter by prometheus type */
|
|
||||||
selectedTypes: Array<SelectableValue<string>>;
|
|
||||||
/** Filter by the series match endpoint instead of the fuzzy search */
|
|
||||||
useBackend: boolean;
|
|
||||||
/** Disable text wrap for descriptions in the results table */
|
|
||||||
disableTextWrap: boolean;
|
|
||||||
/** Display toggle switches for settings */
|
|
||||||
showAdditionalSettings: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type for the useEffect get metadata function
|
|
||||||
*/
|
|
||||||
export type MetricsModalMetadata = {
|
|
||||||
isLoading: boolean;
|
|
||||||
metrics: MetricsData;
|
|
||||||
hasMetadata: boolean;
|
|
||||||
metaHaystackDictionary: HaystackDictionary;
|
|
||||||
nameHaystackDictionary: HaystackDictionary;
|
|
||||||
totalMetricCount: number;
|
|
||||||
filteredMetricCount: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// for updating the settings in the PromQuery model
|
|
||||||
export function getSettings(visQuery: PromVisualQuery): MetricsModalSettings {
|
|
||||||
return {
|
|
||||||
useBackend: visQuery?.useBackend ?? false,
|
|
||||||
disableTextWrap: visQuery?.disableTextWrap ?? false,
|
|
||||||
fullMetaSearch: visQuery?.fullMetaSearch ?? false,
|
|
||||||
includeNullMetadata: visQuery.includeNullMetadata ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MetricsModalSettings = {
|
|
||||||
useBackend?: boolean;
|
|
||||||
disableTextWrap?: boolean;
|
|
||||||
fullMetaSearch?: boolean;
|
|
||||||
includeNullMetadata?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stateSlice = createSlice({
|
|
||||||
name: 'metrics-modal-state',
|
|
||||||
initialState: initialState(),
|
|
||||||
reducers: {
|
|
||||||
filterMetricsBackend: (
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
metrics: MetricsData;
|
|
||||||
filteredMetricCount: number;
|
|
||||||
isLoading: boolean;
|
|
||||||
}>
|
|
||||||
) => {
|
|
||||||
state.metrics = action.payload.metrics;
|
|
||||||
state.filteredMetricCount = action.payload.filteredMetricCount;
|
|
||||||
state.isLoading = action.payload.isLoading;
|
|
||||||
},
|
|
||||||
buildMetrics: (state, action: PayloadAction<MetricsModalMetadata>) => {
|
|
||||||
state.isLoading = action.payload.isLoading;
|
|
||||||
state.metrics = action.payload.metrics;
|
|
||||||
state.hasMetadata = action.payload.hasMetadata;
|
|
||||||
state.metaHaystackDictionary = action.payload.metaHaystackDictionary;
|
|
||||||
state.nameHaystackDictionary = action.payload.nameHaystackDictionary;
|
|
||||||
state.totalMetricCount = action.payload.totalMetricCount;
|
|
||||||
state.filteredMetricCount = action.payload.filteredMetricCount;
|
|
||||||
},
|
|
||||||
setIsLoading: (state, action: PayloadAction<boolean>) => {
|
|
||||||
state.isLoading = action.payload;
|
|
||||||
},
|
|
||||||
setFilteredMetricCount: (state, action: PayloadAction<number>) => {
|
|
||||||
state.filteredMetricCount = action.payload;
|
|
||||||
},
|
|
||||||
setResultsPerPage: (state, action: PayloadAction<number>) => {
|
|
||||||
state.resultsPerPage = action.payload;
|
|
||||||
},
|
|
||||||
setPageNum: (state, action: PayloadAction<number>) => {
|
|
||||||
state.pageNum = action.payload;
|
|
||||||
},
|
|
||||||
setFuzzySearchQuery: (state, action: PayloadAction<string>) => {
|
|
||||||
state.fuzzySearchQuery = action.payload;
|
|
||||||
state.pageNum = 1;
|
|
||||||
},
|
|
||||||
setNameHaystack: (state, action: PayloadAction<string[][]>) => {
|
|
||||||
state.nameHaystackOrder = action.payload[0];
|
|
||||||
state.nameHaystackMatches = action.payload[1];
|
|
||||||
},
|
|
||||||
setMetaHaystack: (state, action: PayloadAction<string[][]>) => {
|
|
||||||
state.metaHaystackOrder = action.payload[0];
|
|
||||||
state.metaHaystackMatches = action.payload[1];
|
|
||||||
},
|
|
||||||
setFullMetaSearch: (state, action: PayloadAction<boolean>) => {
|
|
||||||
state.fullMetaSearch = action.payload;
|
|
||||||
state.pageNum = 1;
|
|
||||||
},
|
|
||||||
setIncludeNullMetadata: (state, action: PayloadAction<boolean>) => {
|
|
||||||
state.includeNullMetadata = action.payload;
|
|
||||||
state.pageNum = 1;
|
|
||||||
},
|
|
||||||
setSelectedTypes: (state, action: PayloadAction<Array<SelectableValue<string>>>) => {
|
|
||||||
state.selectedTypes = action.payload;
|
|
||||||
state.pageNum = 1;
|
|
||||||
},
|
|
||||||
setUseBackend: (state, action: PayloadAction<boolean>) => {
|
|
||||||
state.useBackend = action.payload;
|
|
||||||
state.fullMetaSearch = false;
|
|
||||||
state.pageNum = 1;
|
|
||||||
},
|
|
||||||
setDisableTextWrap: (state) => {
|
|
||||||
state.disableTextWrap = !state.disableTextWrap;
|
|
||||||
},
|
|
||||||
showAdditionalSettings: (state) => {
|
|
||||||
state.showAdditionalSettings = !state.showAdditionalSettings;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const {
|
|
||||||
setIsLoading,
|
|
||||||
buildMetrics,
|
|
||||||
filterMetricsBackend,
|
|
||||||
setResultsPerPage,
|
|
||||||
setPageNum,
|
|
||||||
setFuzzySearchQuery,
|
|
||||||
setNameHaystack,
|
|
||||||
setMetaHaystack,
|
|
||||||
setFullMetaSearch,
|
|
||||||
setIncludeNullMetadata,
|
|
||||||
setSelectedTypes,
|
|
||||||
setUseBackend,
|
|
||||||
setDisableTextWrap,
|
|
||||||
showAdditionalSettings,
|
|
||||||
setFilteredMetricCount,
|
|
||||||
} = stateSlice.actions;
|
|
||||||
@@ -3,7 +3,7 @@ import { css } from '@emotion/css';
|
|||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
export const getMetricsModalStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
modal: css({
|
modal: css({
|
||||||
width: '85vw',
|
width: '85vw',
|
||||||
@@ -34,20 +34,9 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
|||||||
minWidth: '100%',
|
minWidth: '100%',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
selectWrapper: css({
|
|
||||||
marginBottom: theme.spacing(1),
|
|
||||||
}),
|
|
||||||
resultsAmount: css({
|
|
||||||
color: theme.colors.text.secondary,
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
padding: '0 0 4px 0',
|
|
||||||
}),
|
|
||||||
resultsData: css({
|
resultsData: css({
|
||||||
margin: `4px 0 ${theme.spacing(2)} 0`,
|
margin: `4px 0 ${theme.spacing(2)} 0`,
|
||||||
}),
|
}),
|
||||||
resultsDataCount: css({
|
|
||||||
margin: 0,
|
|
||||||
}),
|
|
||||||
resultsDataFiltered: css({
|
resultsDataFiltered: css({
|
||||||
color: theme.colors.text.secondary,
|
color: theme.colors.text.secondary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
@@ -67,9 +56,9 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
|
justifyContent: 'center',
|
||||||
}),
|
}),
|
||||||
currentlySelected: css({
|
currentlySelected: css({
|
||||||
color: 'grey',
|
color: 'grey',
|
||||||
@@ -82,21 +71,69 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
|||||||
visible: css({
|
visible: css({
|
||||||
visibility: 'visible',
|
visibility: 'visible',
|
||||||
}),
|
}),
|
||||||
settingsBtn: css({
|
|
||||||
float: 'right',
|
|
||||||
}),
|
|
||||||
noBorder: css({
|
noBorder: css({
|
||||||
border: 'none',
|
border: 'none',
|
||||||
}),
|
}),
|
||||||
resultsPerPageLabel: css({
|
};
|
||||||
color: theme.colors.text.secondary,
|
};
|
||||||
opacity: '75%',
|
|
||||||
paddingTop: '5px',
|
export const getResultsTableStyles = (theme: GrafanaTheme2) => {
|
||||||
fontSize: '0.85rem',
|
return {
|
||||||
marginRight: '8px',
|
table: css({
|
||||||
|
tableLayout: 'fixed',
|
||||||
|
borderRadius: theme.shape.radius.default,
|
||||||
|
width: '100%',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
td: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
},
|
||||||
|
'td,th': {
|
||||||
|
minWidth: theme.spacing(3),
|
||||||
|
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
resultsPerPageWrapper: css({
|
row: css({
|
||||||
display: 'flex',
|
label: 'row',
|
||||||
|
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:last-child': {
|
||||||
|
borderBottom: 0,
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.colors.background.secondary,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tableHeaderPadding: css({
|
||||||
|
padding: '8px',
|
||||||
|
}),
|
||||||
|
matchHighLight: css({
|
||||||
|
background: 'inherit',
|
||||||
|
color: theme.components.textHighlight.text,
|
||||||
|
backgroundColor: theme.components.textHighlight.background,
|
||||||
|
}),
|
||||||
|
nameWidth: css({
|
||||||
|
width: '37.5%',
|
||||||
|
}),
|
||||||
|
nameOverflow: css({
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
}),
|
||||||
|
typeWidth: css({
|
||||||
|
width: '15%',
|
||||||
|
}),
|
||||||
|
descriptionWidth: css({
|
||||||
|
width: '35%',
|
||||||
|
}),
|
||||||
|
stickyHeader: css({
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
backgroundColor: theme.colors.background.primary,
|
||||||
|
}),
|
||||||
|
noResults: css({
|
||||||
|
textAlign: 'center',
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
}),
|
||||||
|
tooltipSpace: css({
|
||||||
|
marginLeft: '4px',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
-3
@@ -1,12 +1,9 @@
|
|||||||
export const metricsModaltestIds = {
|
export const metricsModaltestIds = {
|
||||||
metricModal: 'metric-modal',
|
metricModal: 'metric-modal',
|
||||||
searchMetric: 'search-metric',
|
searchMetric: 'search-metric',
|
||||||
searchWithMetadata: 'search-with-metadata',
|
|
||||||
selectType: 'select-type',
|
selectType: 'select-type',
|
||||||
metricCard: 'metric-card',
|
metricCard: 'metric-card',
|
||||||
useMetric: 'use-metric',
|
useMetric: 'use-metric',
|
||||||
searchPage: 'search-page',
|
searchPage: 'search-page',
|
||||||
resultsPerPage: 'results-per-page',
|
resultsPerPage: 'results-per-page',
|
||||||
setUseBackend: 'set-use-backend',
|
|
||||||
showAdditionalSettings: 'show-additional-settings',
|
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts
|
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts
|
||||||
import uFuzzy from '@leeoniya/ufuzzy';
|
import uFuzzy from '@leeoniya/ufuzzy';
|
||||||
import { debounce as debounceLodash } from 'lodash';
|
|
||||||
|
|
||||||
const uf = new uFuzzy({
|
const uf = new uFuzzy({
|
||||||
intraMode: 1,
|
intraMode: 1,
|
||||||
@@ -10,37 +9,34 @@ const uf = new uFuzzy({
|
|||||||
intraDel: 1,
|
intraDel: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
function fuzzySearch(haystack: string[], query: string, dispatcher: (data: string[][]) => void) {
|
export const fuzzySearch = (haystack: string[], query: string) => {
|
||||||
const [idxs, info, order] = uf.search(haystack, query, 0, 1e5);
|
const [idxs, info, order] = uf.search(haystack, query, 0, 1e5);
|
||||||
|
|
||||||
let haystackOrder: string[] = [];
|
let haystackOrder: string[] = [];
|
||||||
let matchesSet: Set<string> = new Set();
|
let matchesSet: Set<string> = new Set();
|
||||||
if (idxs && order) {
|
if (!(idxs && order)) {
|
||||||
/**
|
return [[], []];
|
||||||
* get the fuzzy matches for hilighting
|
|
||||||
* @param part
|
|
||||||
* @param matched
|
|
||||||
*/
|
|
||||||
const mark = (part: string, matched: boolean) => {
|
|
||||||
if (matched) {
|
|
||||||
matchesSet.add(part);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Iterate to create the order of needles(queries) and the matches
|
|
||||||
for (let i = 0; i < order.length; i++) {
|
|
||||||
let infoIdx = order[i];
|
|
||||||
|
|
||||||
/** Evaluate the match, get the matches for highlighting */
|
|
||||||
uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark);
|
|
||||||
/** Get the order */
|
|
||||||
haystackOrder.push(haystack[info.idx[infoIdx]]);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatcher([haystackOrder, [...matchesSet]]);
|
|
||||||
} else if (!query) {
|
|
||||||
dispatcher([[], []]);
|
|
||||||
}
|
}
|
||||||
}
|
/**
|
||||||
|
* get the fuzzy matches for highlighting
|
||||||
|
* @param part
|
||||||
|
* @param matched
|
||||||
|
*/
|
||||||
|
const mark = (part: string, matched: boolean) => {
|
||||||
|
if (matched) {
|
||||||
|
matchesSet.add(part);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300);
|
// Iterate to create the order of needles(queries) and the matches
|
||||||
|
for (let i = 0; i < order.length; i++) {
|
||||||
|
let infoIdx = order[i];
|
||||||
|
|
||||||
|
/** Evaluate the match, get the matches for highlighting */
|
||||||
|
uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark);
|
||||||
|
/** Get the order */
|
||||||
|
haystackOrder.push(haystack[info.idx[infoIdx]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [haystackOrder, [...matchesSet]];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
import { NestedQueryList } from '../NestedQueryList';
|
|
||||||
|
|
||||||
import { QueryBuilderContent } from './QueryBuilderContent';
|
|
||||||
import { BaseQueryBuilderProps } from './types';
|
|
||||||
|
|
||||||
export const BaseQueryBuilder = memo<BaseQueryBuilderProps>((props) => {
|
|
||||||
const { query, datasource, onChange, onRunQuery, showExplain } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<QueryBuilderContent {...props} />
|
|
||||||
{query.binaryQueries && query.binaryQueries.length > 0 && (
|
|
||||||
<NestedQueryList
|
|
||||||
query={query}
|
|
||||||
datasource={datasource}
|
|
||||||
onChange={onChange}
|
|
||||||
onRunQuery={onRunQuery}
|
|
||||||
showExplain={showExplain}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
BaseQueryBuilder.displayName = 'BaseQueryBuilder';
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { PanelData } from '@grafana/data';
|
|
||||||
|
|
||||||
import { PrometheusDatasource } from '../../../datasource';
|
|
||||||
import { PromVisualQuery } from '../../types';
|
|
||||||
|
|
||||||
export interface BaseQueryBuilderProps {
|
|
||||||
query: PromVisualQuery;
|
|
||||||
datasource: PrometheusDatasource;
|
|
||||||
onChange: (update: PromVisualQuery) => void;
|
|
||||||
onRunQuery: () => void;
|
|
||||||
data?: PanelData;
|
|
||||||
showExplain: boolean;
|
|
||||||
}
|
|
||||||
@@ -14,11 +14,6 @@ export interface PromVisualQuery {
|
|||||||
labels: QueryBuilderLabelFilter[];
|
labels: QueryBuilderLabelFilter[];
|
||||||
operations: QueryBuilderOperation[];
|
operations: QueryBuilderOperation[];
|
||||||
binaryQueries?: PromVisualQueryBinary[];
|
binaryQueries?: PromVisualQueryBinary[];
|
||||||
// metrics explorer additional settings
|
|
||||||
useBackend?: boolean;
|
|
||||||
disableTextWrap?: boolean;
|
|
||||||
includeNullMetadata?: boolean;
|
|
||||||
fullMetaSearch?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromQueryModellerInterface {
|
export interface PromQueryModellerInterface {
|
||||||
|
|||||||
@@ -15,11 +15,6 @@ export interface PromQuery extends GenPromQuery, DataQuery {
|
|||||||
showingTable?: boolean;
|
showingTable?: boolean;
|
||||||
hinting?: boolean;
|
hinting?: boolean;
|
||||||
interval?: string;
|
interval?: string;
|
||||||
// store the metrics explorer additional settings
|
|
||||||
useBackend?: boolean;
|
|
||||||
disableTextWrap?: boolean;
|
|
||||||
fullMetaSearch?: boolean;
|
|
||||||
includeNullMetadata?: boolean;
|
|
||||||
fromExploreMetrics?: boolean;
|
fromExploreMetrics?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user