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() {
|
||||
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 = {
|
||||
|
||||
@@ -138,12 +138,11 @@ describe('Prometheus query editor', () => {
|
||||
|
||||
it('can select a metric and provide a hint', () => {
|
||||
navigateToEditor('Builder', 'prometheusBuilder');
|
||||
|
||||
getResources();
|
||||
|
||||
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click().type('metric1');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click();
|
||||
cy.wait('@getMetadata');
|
||||
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().type('metric1');
|
||||
selectOption('metric1');
|
||||
|
||||
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 { select } from 'react-select-event';
|
||||
|
||||
import { dateTime, TimeRange } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { PrometheusDatasource } from '../datasource';
|
||||
@@ -376,24 +375,4 @@ describe('PromVariableQueryEditor', () => {
|
||||
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
|
||||
const [labelFilters, setLabelFilters] = useState<QueryBuilderLabelFilter[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
datasource.languageProvider.start(range);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
return;
|
||||
|
||||
@@ -311,10 +311,6 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"give-feedback": "Give feedback",
|
||||
"title-give-feedback": "The metrics explorer is new, please let us know how we can improve it"
|
||||
@@ -328,9 +324,6 @@
|
||||
},
|
||||
"get-placeholders": {
|
||||
"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"
|
||||
},
|
||||
"get-prom-types": {
|
||||
@@ -378,15 +371,9 @@
|
||||
"tooltip-metric": "Optional: returns a list of label values for the label name in the specified metric."
|
||||
},
|
||||
"metrics-modal": {
|
||||
"additional-settings": "Additional Settings",
|
||||
"aria-label-additional-settings": "Additional settings",
|
||||
"aria-label-browse-metrics": "Browse metrics",
|
||||
"currently-selected": "Currently selected: {{selected}}",
|
||||
"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"
|
||||
},
|
||||
"nested-query": {
|
||||
@@ -500,7 +487,6 @@
|
||||
"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.",
|
||||
"name": "Name",
|
||||
"select": "Select",
|
||||
"type": "Type"
|
||||
},
|
||||
"update-function-args": {
|
||||
|
||||
@@ -11,9 +11,8 @@ import { PrometheusDatasource } from '../../datasource';
|
||||
import { QueryBuilderLabelFilter } from '../shared/types';
|
||||
import { PromVisualQuery } from '../types';
|
||||
|
||||
import { formatKeyValueStrings } from './formatter';
|
||||
import { MetricsModal } from './metrics-modal/MetricsModal';
|
||||
import { tracking } from './metrics-modal/state/helpers';
|
||||
import { formatKeyValueStrings } from './shared/formatter';
|
||||
|
||||
export interface MetricComboboxProps {
|
||||
metricLookupDisabled: boolean;
|
||||
@@ -76,18 +75,6 @@ export function MetricCombobox({
|
||||
[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 = () => {
|
||||
return (
|
||||
<InputGroup>
|
||||
@@ -115,10 +102,7 @@ export function MetricCombobox({
|
||||
)}
|
||||
variant="secondary"
|
||||
icon="book-open"
|
||||
onClick={() => {
|
||||
tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query);
|
||||
setMetricsModalOpen(true);
|
||||
}}
|
||||
onClick={() => setMetricsModalOpen(true)}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
@@ -133,7 +117,6 @@ export function MetricCombobox({
|
||||
onClose={() => setMetricsModalOpen(false)}
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
initialMetrics={loadMetricsExplorerMetrics}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { PrometheusDatasource } from '../../datasource';
|
||||
import { binaryScalarDefs } from '../binaryScalarOperations';
|
||||
import { PromVisualQueryBinary } from '../types';
|
||||
|
||||
import { QueryBuilderContent } from './shared/QueryBuilderContent';
|
||||
import { QueryBuilderContent } from './QueryBuilderContent';
|
||||
|
||||
interface NestedQueryProps {
|
||||
nestedQuery: PromVisualQueryBinary;
|
||||
|
||||
@@ -6,7 +6,8 @@ import { PanelData } from '@grafana/data';
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import { PromVisualQuery } from '../types';
|
||||
|
||||
import { BaseQueryBuilder } from './shared/BaseQueryBuilder';
|
||||
import { NestedQueryList } from './NestedQueryList';
|
||||
import { QueryBuilderContent } from './QueryBuilderContent';
|
||||
|
||||
interface PromQueryBuilderProps {
|
||||
query: PromVisualQuery;
|
||||
@@ -18,7 +19,22 @@ interface PromQueryBuilderProps {
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
-4
@@ -20,12 +20,8 @@ describe('PromQueryBuilderContainer', () => {
|
||||
await addOperationInQueryBuilder('Range functions', 'Rate');
|
||||
// extra fields here are for storing metrics explorer settings. Future work: store these in local storage.
|
||||
expect(props.onChange).toHaveBeenCalledWith({
|
||||
disableTextWrap: false,
|
||||
expr: 'rate(metric_test{job="testjob"}[$__rate_interval])',
|
||||
fullMetaSearch: false,
|
||||
includeNullMetadata: true,
|
||||
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
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { useEffect, useReducer } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { PanelData } from '@grafana/data';
|
||||
|
||||
@@ -12,7 +11,6 @@ import { PromVisualQuery } from '../types';
|
||||
|
||||
import { PromQueryBuilder } from './PromQueryBuilder';
|
||||
import { QueryPreview } from './QueryPreview';
|
||||
import { getSettings, MetricsModalSettings } from './metrics-modal/state/state';
|
||||
|
||||
interface PromQueryBuilderContainerProps {
|
||||
query: PromQuery;
|
||||
@@ -23,7 +21,7 @@ interface PromQueryBuilderContainerProps {
|
||||
showExplain: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
interface RenderedQuery {
|
||||
visQuery?: PromVisualQuery;
|
||||
expr: string;
|
||||
}
|
||||
@@ -33,80 +31,38 @@ interface State {
|
||||
*/
|
||||
export function PromQueryBuilderContainer(props: PromQueryBuilderContainerProps) {
|
||||
const { query, onChange, onRunQuery, datasource, data, showExplain } = props;
|
||||
const [state, dispatch] = useReducer(stateSlice.reducer, { 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]);
|
||||
const [rendered, setRendered] = useState<RenderedQuery>({ expr: query.expr });
|
||||
|
||||
useEffect(() => {
|
||||
datasource.languageProvider.start(data?.timeRange);
|
||||
}, [data?.timeRange, datasource.languageProvider]);
|
||||
// Only rebuild visual query if expr changes from outside
|
||||
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 expr = promQueryModeller.renderQuery(visQuery);
|
||||
dispatch(visualQueryChange({ visQuery, expr }));
|
||||
|
||||
const metricsModalSettings = getSettings(visQuery);
|
||||
onChange({ ...props.query, expr: expr, ...metricsModalSettings });
|
||||
setRendered({ expr, visQuery });
|
||||
onChange({ ...props.query, expr });
|
||||
};
|
||||
|
||||
if (!state.visQuery) {
|
||||
if (!rendered.visQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PromQueryBuilder
|
||||
query={state.visQuery}
|
||||
datasource={datasource}
|
||||
onChange={onVisQueryChange}
|
||||
onRunQuery={onRunQuery}
|
||||
data={data}
|
||||
datasource={datasource}
|
||||
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 { memo, useState } from 'react';
|
||||
|
||||
import { DataSourceApi, getDefaultTimeRange } from '@grafana/data';
|
||||
import { DataSourceApi, getDefaultTimeRange, PanelData } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { EditorRow } from '@grafana/plugin-ui';
|
||||
|
||||
import { promqlGrammar } from '../../../promql';
|
||||
import { getInitHints } from '../../../query_hints';
|
||||
import { buildVisualQueryFromString } from '../../parsing';
|
||||
import { OperationExplainedBox } from '../../shared/OperationExplainedBox';
|
||||
import { OperationList } from '../../shared/OperationList';
|
||||
import { OperationListExplained } from '../../shared/OperationListExplained';
|
||||
import { OperationsEditorRow } from '../../shared/OperationsEditorRow';
|
||||
import { QueryBuilderHints } from '../../shared/QueryBuilderHints';
|
||||
import { RawQuery } from '../../shared/RawQuery';
|
||||
import { promQueryModeller } from '../../shared/modeller_instance';
|
||||
import { QueryBuilderOperation } from '../../shared/types';
|
||||
import { PromVisualQuery } from '../../types';
|
||||
import { MetricsLabelsSection } from '../MetricsLabelsSection';
|
||||
import { EXPLAIN_LABEL_FILTER_CONTENT } from '../PromQueryBuilderExplained';
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import { promqlGrammar } from '../../promql';
|
||||
import { getInitHints } from '../../query_hints';
|
||||
import { buildVisualQueryFromString } from '../parsing';
|
||||
import { OperationExplainedBox } from '../shared/OperationExplainedBox';
|
||||
import { OperationList } from '../shared/OperationList';
|
||||
import { OperationListExplained } from '../shared/OperationListExplained';
|
||||
import { OperationsEditorRow } from '../shared/OperationsEditorRow';
|
||||
import { QueryBuilderHints } from '../shared/QueryBuilderHints';
|
||||
import { RawQuery } from '../shared/RawQuery';
|
||||
import { promQueryModeller } from '../shared/modeller_instance';
|
||||
import { QueryBuilderOperation } from '../shared/types';
|
||||
import { PromVisualQuery } from '../types';
|
||||
|
||||
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 [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { regexifyLabelValuesQueryString } from '../../parsingUtils';
|
||||
import { QueryBuilderLabelFilter } from '../../shared/types';
|
||||
import { regexifyLabelValuesQueryString } from '../parsingUtils';
|
||||
import { QueryBuilderLabelFilter } from '../shared/types';
|
||||
|
||||
export const formatPrometheusLabelFiltersToString = (
|
||||
const formatPrometheusLabelFiltersToString = (
|
||||
queryString: string,
|
||||
labelsFilters: QueryBuilderLabelFilter[] | undefined
|
||||
): 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 { MetricsModal } from './MetricsModal';
|
||||
import { metricsModaltestIds } from './shared/testIds';
|
||||
import { metricsModaltestIds } from './testIds';
|
||||
|
||||
// don't care about interaction tracking in our unit tests
|
||||
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
|
||||
it('searches and filter by metric name with a fuzzy search', async () => {
|
||||
// search for a_bucket by name
|
||||
@@ -171,14 +149,6 @@ describe('MetricsModal', () => {
|
||||
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);
|
||||
expect(searchMetric).toBeInTheDocument();
|
||||
await userEvent.type(searchMetric, 'functions');
|
||||
@@ -248,6 +218,34 @@ function createDatasource(withLabels?: boolean) {
|
||||
const languageProvider = new EmptyLanguageProviderMock() as unknown as PrometheusLanguageProviderInterface;
|
||||
|
||||
// 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) {
|
||||
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({
|
||||
'with-labels': {
|
||||
@@ -256,26 +254,23 @@ function createDatasource(withLabels?: boolean) {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// all metrics
|
||||
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({
|
||||
'all-metrics': {
|
||||
type: 'all-metrics-type',
|
||||
help: 'all-metrics-help',
|
||||
},
|
||||
a: {
|
||||
type: 'counter',
|
||||
help: 'a-metric-help',
|
||||
},
|
||||
a_bucket: {
|
||||
type: 'histogram',
|
||||
help: 'for functions',
|
||||
},
|
||||
new_histogram: {
|
||||
type: 'histogram',
|
||||
help: 'a native histogram',
|
||||
},
|
||||
// missing metadata for other metrics is tested for, see below
|
||||
// Create the same metadata structure for retrieveMetricsMetadata
|
||||
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.retrieveMetricsMetadata = jest.fn().mockReturnValue(mockMetadata);
|
||||
}
|
||||
|
||||
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
|
||||
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 { t, Trans } from '@grafana/i18n';
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Icon,
|
||||
Input,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Pagination,
|
||||
Spinner,
|
||||
Toggletip,
|
||||
useTheme2,
|
||||
} from '@grafana/ui';
|
||||
import { Icon, Input, Modal, MultiSelect, Pagination, Spinner, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { getDebounceTimeInMilliseconds } from '../../../caching';
|
||||
import { METRIC_LABEL } from '../../../constants';
|
||||
import { regexifyLabelValuesQueryString } from '../../parsingUtils';
|
||||
import { formatPrometheusLabelFilters } from '../shared/formatter';
|
||||
import { PrometheusDatasource } from '../../../datasource';
|
||||
import { PromVisualQuery } from '../../types';
|
||||
|
||||
import { AdditionalSettings } from './AdditionalSettings';
|
||||
import { FeedbackLink } from './FeedbackLink';
|
||||
import { MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext';
|
||||
import { ResultsTable } from './ResultsTable';
|
||||
import { metricsModaltestIds } from './shared/testIds';
|
||||
import { MetricsModalProps } from './shared/types';
|
||||
import {
|
||||
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 { getPlaceholders, getPromTypes } from './helpers';
|
||||
import { getMetricsModalStyles } from './styles';
|
||||
import { metricsModaltestIds } from './testIds';
|
||||
import { PromFilterOption } from './types';
|
||||
import { debouncedFuzzySearch } from './uFuzzy';
|
||||
|
||||
export const MetricsModal = (props: MetricsModalProps) => {
|
||||
const { datasource, isOpen, onClose, onChange, query, initialMetrics, timeRange } = props;
|
||||
interface MetricsModalProps {
|
||||
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 styles = getStyles(theme, state.disableTextWrap);
|
||||
const {
|
||||
isLoading,
|
||||
filteredMetricsData,
|
||||
debouncedBackendSearch,
|
||||
pagination,
|
||||
setPagination,
|
||||
selectedTypes,
|
||||
setSelectedTypes,
|
||||
searchedText,
|
||||
setSearchedText,
|
||||
} = useMetricsModal();
|
||||
const styles = useStyles2(getMetricsModalStyles);
|
||||
const placeholders = getPlaceholders();
|
||||
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) => {
|
||||
return {
|
||||
value: t.value,
|
||||
@@ -110,96 +52,10 @@ export const MetricsModal = (props: MetricsModalProps) => {
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The backend debounced search
|
||||
*/
|
||||
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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const searchCallback = (query: string, fullMetaSearchVal?: boolean) => {
|
||||
setSearchedText(query);
|
||||
debouncedBackendSearch(timeRange, query);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -220,58 +76,27 @@ export const MetricsModal = (props: MetricsModalProps) => {
|
||||
autoFocus={true}
|
||||
data-testid={metricsModaltestIds.searchMetric}
|
||||
placeholder={placeholders.browse}
|
||||
value={state.fuzzySearchQuery}
|
||||
value={searchedText}
|
||||
onInput={(e) => {
|
||||
const value = e.currentTarget.value ?? '';
|
||||
dispatch(setFuzzySearchQuery(value));
|
||||
searchCallback(value, state.fullMetaSearch);
|
||||
setSearchedText(value);
|
||||
setPagination({ ...pagination, pageNum: 1 });
|
||||
searchCallback(value);
|
||||
}}
|
||||
/>
|
||||
</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}>
|
||||
<Toggletip
|
||||
aria-label={t(
|
||||
'grafana-prometheus.querybuilder.metrics-modal.aria-label-additional-settings',
|
||||
'Additional settings'
|
||||
)}
|
||||
content={additionalSettings}
|
||||
placement="bottom-end"
|
||||
closeButton={false}
|
||||
>
|
||||
<ButtonGroup className={styles.settingsBtn}>
|
||||
<Button
|
||||
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>
|
||||
<MultiSelect
|
||||
data-testid={metricsModaltestIds.selectType}
|
||||
inputId="my-select"
|
||||
options={typeOptions}
|
||||
value={selectedTypes}
|
||||
placeholder={placeholders.filterType}
|
||||
onChange={setSelectedTypes}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Spinner className={`${styles.loadingSpinner} ${isLoading ? styles.visible : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.resultsData}>
|
||||
@@ -298,61 +123,23 @@ export const MetricsModal = (props: MetricsModalProps) => {
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.results}>
|
||||
{state.metrics && (
|
||||
<ResultsTable
|
||||
metrics={displayedMetrics(state, dispatch)}
|
||||
onChange={onChange}
|
||||
onClose={onClose}
|
||||
query={query}
|
||||
state={state}
|
||||
disableTextWrap={state.disableTextWrap}
|
||||
/>
|
||||
)}
|
||||
{filteredMetricsData && <ResultsTable onChange={onChange} onClose={onClose} query={query} />}
|
||||
</div>
|
||||
<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
|
||||
currentPage={state.pageNum ?? 1}
|
||||
numberOfPages={calculatePageList(state).length}
|
||||
onNavigate={(val: number) => {
|
||||
const page = val ?? 1;
|
||||
dispatch(setPageNum(page));
|
||||
}}
|
||||
currentPage={pagination.pageNum > pagination.totalPageNum ? 1 : pagination.pageNum}
|
||||
numberOfPages={pagination.totalPageNum}
|
||||
onNavigate={(val: number) => setPagination({ ...pagination, pageNum: val ?? 1 })}
|
||||
/>
|
||||
<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>
|
||||
</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
|
||||
import { css } from '@emotion/css';
|
||||
import { ReactElement } from 'react';
|
||||
import { ReactElement, useMemo } from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
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 { PromVisualQuery } from '../../types';
|
||||
|
||||
import { tracking } from './state/helpers';
|
||||
import { MetricsModalState } from './state/state';
|
||||
import { MetricData, MetricsData } from './types';
|
||||
import { useMetricsModal } from './MetricsModalContext';
|
||||
import { getResultsTableStyles } from './styles';
|
||||
import { MetricData } from './types';
|
||||
|
||||
type ResultsTableProps = {
|
||||
metrics: MetricsData;
|
||||
onChange: (query: PromVisualQuery) => void;
|
||||
onClose: () => void;
|
||||
query: PromVisualQuery;
|
||||
state: MetricsModalState;
|
||||
disableTextWrap: boolean;
|
||||
};
|
||||
|
||||
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 styles = getStyles(theme, disableTextWrap);
|
||||
const slicedMetrics = useMemo(() => {
|
||||
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) {
|
||||
if (metric.value) {
|
||||
onChange({ ...query, metric: metric.value });
|
||||
tracking('grafana_prom_metric_encycopedia_tracking', state, metric.value);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function metaRows(metric: MetricData) {
|
||||
if (state.fullMetaSearch && metric) {
|
||||
return (
|
||||
<>
|
||||
<td>{displayType(metric.type ?? '')}</td>
|
||||
<td>
|
||||
<Highlighter
|
||||
textToHighlight={metric.description ?? ''}
|
||||
searchWords={state.metaHaystackMatches}
|
||||
autoEscape
|
||||
highlightClassName={styles.matchHighLight}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<td>{displayType(metric.type ?? '')}</td>
|
||||
<td>{metric.description ?? ''}</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<td>{displayType(metric.type ?? '')}</td>
|
||||
<td>
|
||||
<Highlighter
|
||||
textToHighlight={metric.description ?? ''}
|
||||
searchWords={[]}
|
||||
autoEscape
|
||||
highlightClassName={styles.matchHighLight}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function addHelpIcon(fullType: string, descriptiveType: string, link: string) {
|
||||
@@ -105,7 +102,7 @@ export function ResultsTable(props: ResultsTableProps) {
|
||||
function noMetricsMessages(): ReactElement {
|
||||
let message;
|
||||
|
||||
if (!state.fuzzySearchQuery) {
|
||||
if (!searchedText) {
|
||||
message = t(
|
||||
'grafana-prometheus.querybuilder.results-table.message-no-metrics-found',
|
||||
'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(
|
||||
'grafana-prometheus.querybuilder.results-table.message-expand-search',
|
||||
'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 (
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.stickyHeader}>
|
||||
@@ -155,115 +137,35 @@ export function ResultsTable(props: ResultsTableProps) {
|
||||
<th className={`${styles.nameWidth} ${styles.tableHeaderPadding}`}>
|
||||
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.name">Name</Trans>
|
||||
</th>
|
||||
{state.hasMetadata && (
|
||||
<>
|
||||
<th className={`${styles.typeWidth} ${styles.tableHeaderPadding}`}>
|
||||
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.type">Type</Trans>
|
||||
</th>
|
||||
<th className={`${styles.descriptionWidth} ${styles.tableHeaderPadding}`}>
|
||||
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.description">Description</Trans>
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
<th className={styles.selectButtonWidth}> </th>
|
||||
<th className={`${styles.typeWidth} ${styles.tableHeaderPadding}`}>
|
||||
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.type">Type</Trans>
|
||||
</th>
|
||||
<th className={`${styles.descriptionWidth} ${styles.tableHeaderPadding}`}>
|
||||
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.description">Description</Trans>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<>
|
||||
{metrics.length > 0 &&
|
||||
metrics.map((metric: MetricData, idx: number) => {
|
||||
{slicedMetrics.length > 0 &&
|
||||
slicedMetrics.map((metric: MetricData, idx: number) => {
|
||||
return (
|
||||
<tr key={metric?.value ?? idx} className={styles.row}>
|
||||
<tr key={metric?.value ?? idx} className={styles.row} onClick={() => selectMetric(metric)}>
|
||||
<td className={styles.nameOverflow}>
|
||||
<Highlighter
|
||||
textToHighlight={metric?.value ?? ''}
|
||||
searchWords={textHighlight(state)}
|
||||
searchWords={searchedText.split(' ')}
|
||||
autoEscape
|
||||
highlightClassName={styles.matchHighLight}
|
||||
/>
|
||||
</td>
|
||||
{state.hasMetadata && 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>
|
||||
{metaRows(metric)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{metrics.length === 0 && !state.isLoading && noMetricsMessages()}
|
||||
{slicedMetrics.length === 0 && !isLoading && noMetricsMessages()}
|
||||
</>
|
||||
</tbody>
|
||||
</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';
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
||||
export const getMetricsModalStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
modal: css({
|
||||
width: '85vw',
|
||||
@@ -34,20 +34,9 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
||||
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({
|
||||
margin: `4px 0 ${theme.spacing(2)} 0`,
|
||||
}),
|
||||
resultsDataCount: css({
|
||||
margin: 0,
|
||||
}),
|
||||
resultsDataFiltered: css({
|
||||
color: theme.colors.text.secondary,
|
||||
textAlign: 'center',
|
||||
@@ -67,9 +56,9 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
position: 'sticky',
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
currentlySelected: css({
|
||||
color: 'grey',
|
||||
@@ -82,21 +71,69 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
|
||||
visible: css({
|
||||
visibility: 'visible',
|
||||
}),
|
||||
settingsBtn: css({
|
||||
float: 'right',
|
||||
}),
|
||||
noBorder: css({
|
||||
border: 'none',
|
||||
}),
|
||||
resultsPerPageLabel: css({
|
||||
color: theme.colors.text.secondary,
|
||||
opacity: '75%',
|
||||
paddingTop: '5px',
|
||||
fontSize: '0.85rem',
|
||||
marginRight: '8px',
|
||||
};
|
||||
};
|
||||
|
||||
export const getResultsTableStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
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({
|
||||
display: 'flex',
|
||||
row: css({
|
||||
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 = {
|
||||
metricModal: 'metric-modal',
|
||||
searchMetric: 'search-metric',
|
||||
searchWithMetadata: 'search-with-metadata',
|
||||
selectType: 'select-type',
|
||||
metricCard: 'metric-card',
|
||||
useMetric: 'use-metric',
|
||||
searchPage: 'search-page',
|
||||
resultsPerPage: 'results-per-page',
|
||||
setUseBackend: 'set-use-backend',
|
||||
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
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
import { debounce as debounceLodash } from 'lodash';
|
||||
|
||||
const uf = new uFuzzy({
|
||||
intraMode: 1,
|
||||
@@ -10,37 +9,34 @@ const uf = new uFuzzy({
|
||||
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);
|
||||
|
||||
let haystackOrder: string[] = [];
|
||||
let matchesSet: Set<string> = new Set();
|
||||
if (idxs && order) {
|
||||
/**
|
||||
* 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([[], []]);
|
||||
if (!(idxs && order)) {
|
||||
return [[], []];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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[];
|
||||
operations: QueryBuilderOperation[];
|
||||
binaryQueries?: PromVisualQueryBinary[];
|
||||
// metrics explorer additional settings
|
||||
useBackend?: boolean;
|
||||
disableTextWrap?: boolean;
|
||||
includeNullMetadata?: boolean;
|
||||
fullMetaSearch?: boolean;
|
||||
}
|
||||
|
||||
export interface PromQueryModellerInterface {
|
||||
|
||||
@@ -15,11 +15,6 @@ export interface PromQuery extends GenPromQuery, DataQuery {
|
||||
showingTable?: boolean;
|
||||
hinting?: boolean;
|
||||
interval?: string;
|
||||
// store the metrics explorer additional settings
|
||||
useBackend?: boolean;
|
||||
disableTextWrap?: boolean;
|
||||
fullMetaSearch?: boolean;
|
||||
includeNullMetadata?: boolean;
|
||||
fromExploreMetrics?: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user