mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 05:12:36 +08:00
Chore: Convert prometheus query field to a functional component (#101515)
* convert it to functional component * useReducer * usePromQueryFieldEffects * clean up the code * remove localStorage provider * introduce usePromQueryFieldEffects.test.ts * simpler state management * remove mocks * linting + betterer * remove unnecessary check * use range * remove unused languageProvider * prettier
This commit is contained in:
@ -24,7 +24,6 @@ jest.mock('./monaco-query-field/MonacoQueryFieldLazy', () => {
|
||||
|
||||
function setup(app: CoreApp): { onRunQuery: jest.Mock } {
|
||||
const dataSource = {
|
||||
getInitHints: () => [],
|
||||
getPrometheusTime: jest.fn((date, roundup) => 123),
|
||||
getQueryHints: jest.fn(() => []),
|
||||
getDebounceTimeInMilliseconds: jest.fn(() => 300),
|
||||
|
@ -1,12 +1,12 @@
|
||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx
|
||||
import { getByTestId, render, screen, waitFor } from '@testing-library/react';
|
||||
import { getByTestId, render, screen } from '@testing-library/react';
|
||||
// @ts-ignore
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CoreApp, DataFrame, LoadingState, PanelData } from '@grafana/data';
|
||||
import { CoreApp, DataFrame, dateTime, LoadingState, PanelData } from '@grafana/data';
|
||||
|
||||
import { PrometheusDatasource } from '../datasource';
|
||||
import PromQlLanguageProvider from '../language_provider';
|
||||
import * as queryHints from '../query_hints';
|
||||
|
||||
import { PromQueryField } from './PromQueryField';
|
||||
import { Props } from './monaco-query-field/MonacoQueryFieldProps';
|
||||
@ -31,7 +31,6 @@ const defaultProps = {
|
||||
getLabelKeys: () => [],
|
||||
metrics: [],
|
||||
},
|
||||
getInitHints: () => [],
|
||||
} as unknown as PrometheusDatasource,
|
||||
query: {
|
||||
expr: '',
|
||||
@ -40,6 +39,14 @@ const defaultProps = {
|
||||
onRunQuery: () => {},
|
||||
onChange: () => {},
|
||||
history: [],
|
||||
range: {
|
||||
from: dateTime('2022-01-01T00:00:00Z'),
|
||||
to: dateTime('2022-01-02T00:00:00Z'),
|
||||
raw: {
|
||||
from: 'now-1d',
|
||||
to: 'now',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('PromQueryField', () => {
|
||||
@ -48,6 +55,10 @@ describe('PromQueryField', () => {
|
||||
window.getSelection = () => {};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders metrics chooser regularly if lookups are not disabled in the datasource settings', async () => {
|
||||
const queryField = render(<PromQueryField {...defaultProps} />);
|
||||
|
||||
@ -72,7 +83,9 @@ describe('PromQueryField', () => {
|
||||
it('renders an initial hint if no data and initial hint provided', async () => {
|
||||
const props = defaultProps;
|
||||
props.datasource.lookupsDisabled = true;
|
||||
props.datasource.getInitHints = () => [{ label: 'Initial hint', type: 'INFO' }];
|
||||
|
||||
jest.spyOn(queryHints, 'getInitHints').mockReturnValue([{ label: 'Initial hint', type: 'INFO' }]);
|
||||
|
||||
render(<PromQueryField {...props} />);
|
||||
|
||||
// wait for component to render
|
||||
@ -84,7 +97,6 @@ describe('PromQueryField', () => {
|
||||
it('renders query hint if data, query hint and initial hint provided', async () => {
|
||||
const props = defaultProps;
|
||||
props.datasource.lookupsDisabled = true;
|
||||
props.datasource.getInitHints = () => [{ label: 'Initial hint', type: 'INFO' }];
|
||||
props.datasource.getQueryHints = () => [{ label: 'Query hint', type: 'INFO' }];
|
||||
render(
|
||||
<PromQueryField
|
||||
@ -105,51 +117,6 @@ describe('PromQueryField', () => {
|
||||
expect(screen.queryByText('Initial hint')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes metrics when the data source changes', async () => {
|
||||
const defaultProps = {
|
||||
query: { expr: '', refId: '' },
|
||||
onRunQuery: () => {},
|
||||
onChange: () => {},
|
||||
history: [],
|
||||
};
|
||||
const metrics = ['foo', 'bar'];
|
||||
const queryField = render(
|
||||
<PromQueryField
|
||||
datasource={
|
||||
{
|
||||
languageProvider: makeLanguageProvider({ metrics: [metrics] }),
|
||||
getInitHints: () => [],
|
||||
} as unknown as PrometheusDatasource
|
||||
}
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
// wait for component to render
|
||||
await screen.findByRole('button');
|
||||
|
||||
const changedMetrics = ['baz', 'moo'];
|
||||
queryField.rerender(
|
||||
<PromQueryField
|
||||
// @ts-ignore
|
||||
datasource={{
|
||||
languageProvider: makeLanguageProvider({ metrics: [changedMetrics] }),
|
||||
}}
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
|
||||
// If we check the label browser right away it should be in loading state
|
||||
let labelBrowser = screen.getByRole('button');
|
||||
expect(labelBrowser).toHaveTextContent('Loading');
|
||||
|
||||
// wait for component to rerender
|
||||
labelBrowser = await screen.findByRole('button');
|
||||
await waitFor(() => {
|
||||
expect(labelBrowser).toHaveTextContent('Metrics browser');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not run query onBlur', async () => {
|
||||
const onRunQuery = jest.fn();
|
||||
const { container } = render(<PromQueryField {...defaultProps} app={CoreApp.Explore} onRunQuery={onRunQuery} />);
|
||||
@ -166,18 +133,3 @@ describe('PromQueryField', () => {
|
||||
expect(onRunQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function makeLanguageProvider(options: { metrics: string[][] }) {
|
||||
const metricsStack = [...options.metrics];
|
||||
return {
|
||||
histogramMetrics: [],
|
||||
metrics: [],
|
||||
metricsMetadata: {},
|
||||
lookupsDisabled: false,
|
||||
getLabelKeys: () => [],
|
||||
start() {
|
||||
this.metrics = metricsStack.shift();
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
} as any as PromQlLanguageProvider;
|
||||
}
|
||||
|
@ -1,118 +1,96 @@
|
||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { PureComponent, ReactNode } from 'react';
|
||||
import { MutableRefObject, ReactNode, useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
getDefaultTimeRange,
|
||||
isDataFrame,
|
||||
LocalStorageValueProvider,
|
||||
QueryEditorProps,
|
||||
QueryHint,
|
||||
TimeRange,
|
||||
toLegacyResponseData,
|
||||
} from '@grafana/data';
|
||||
import { getDefaultTimeRange, isDataFrame, QueryEditorProps, QueryHint, toLegacyResponseData } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { clearButtonStyles, Icon, Themeable2, withTheme2 } from '@grafana/ui';
|
||||
import { clearButtonStyles, Icon, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { PrometheusDatasource } from '../datasource';
|
||||
import { roundMsToMin } from '../language_utils';
|
||||
import { getInitHints } from '../query_hints';
|
||||
import { PromOptions, PromQuery } from '../types';
|
||||
|
||||
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
|
||||
import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise';
|
||||
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper';
|
||||
import { useMetricsState } from './useMetricsState';
|
||||
import { usePromQueryFieldEffects } from './usePromQueryFieldEffects';
|
||||
|
||||
const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels';
|
||||
|
||||
function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) {
|
||||
if (metricsLookupDisabled) {
|
||||
return '(Disabled)';
|
||||
}
|
||||
|
||||
if (!hasSyntax) {
|
||||
return 'Loading metrics...';
|
||||
}
|
||||
|
||||
if (!hasMetrics) {
|
||||
return '(No metrics found)';
|
||||
}
|
||||
|
||||
return 'Metrics browser';
|
||||
}
|
||||
|
||||
interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions>, Themeable2 {
|
||||
interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions> {
|
||||
ExtraFieldElement?: ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
interface PromQueryFieldState {
|
||||
labelBrowserVisible: boolean;
|
||||
syntaxLoaded: boolean;
|
||||
hint: QueryHint | null;
|
||||
}
|
||||
export const PromQueryField = (props: PromQueryFieldProps) => {
|
||||
const {
|
||||
app,
|
||||
datasource,
|
||||
datasource: { languageProvider },
|
||||
query,
|
||||
ExtraFieldElement,
|
||||
history = [],
|
||||
data,
|
||||
range,
|
||||
onChange,
|
||||
onRunQuery,
|
||||
} = props;
|
||||
|
||||
class PromQueryFieldClass extends PureComponent<PromQueryFieldProps, PromQueryFieldState> {
|
||||
declare languageProviderInitializationPromise: CancelablePromise<any>;
|
||||
const theme = useTheme2();
|
||||
|
||||
constructor(props: PromQueryFieldProps) {
|
||||
super(props);
|
||||
const [syntaxLoaded, setSyntaxLoaded] = useState(false);
|
||||
const [hint, setHint] = useState<QueryHint | null>(null);
|
||||
const [labelBrowserVisible, setLabelBrowserVisible] = useState(false);
|
||||
|
||||
this.state = {
|
||||
labelBrowserVisible: false,
|
||||
syntaxLoaded: false,
|
||||
hint: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.datasource.languageProvider) {
|
||||
this.refreshMetrics();
|
||||
const updateLanguage = useCallback(() => {
|
||||
if (languageProvider.metrics) {
|
||||
setSyntaxLoaded(true);
|
||||
}
|
||||
this.refreshHint();
|
||||
}
|
||||
}, [languageProvider]);
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.languageProviderInitializationPromise) {
|
||||
this.languageProviderInitializationPromise.cancel();
|
||||
}
|
||||
}
|
||||
const refreshMetrics = useCallback(
|
||||
async (languageProviderInitRef: MutableRefObject<CancelablePromise<any> | null>) => {
|
||||
// Cancel any existing initialization using the ref
|
||||
if (languageProviderInitRef.current) {
|
||||
languageProviderInitRef.current.cancel();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: PromQueryFieldProps) {
|
||||
const {
|
||||
data,
|
||||
datasource: { languageProvider },
|
||||
range,
|
||||
} = this.props;
|
||||
if (!languageProvider || !range) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (languageProvider !== prevProps.datasource.languageProvider) {
|
||||
// We reset this only on DS change so we do not flesh loading state on every rangeChange which happens on every
|
||||
// query run if using relative range.
|
||||
this.setState({
|
||||
syntaxLoaded: false,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const initialization = makePromiseCancelable(languageProvider.start(range));
|
||||
languageProviderInitRef.current = initialization;
|
||||
|
||||
const changedRangeToRefresh = this.rangeChangedToRefresh(range, prevProps.range);
|
||||
// We want to refresh metrics when language provider changes and/or when range changes (we round up intervals to a minute)
|
||||
if (languageProvider !== prevProps.datasource.languageProvider || changedRangeToRefresh) {
|
||||
this.refreshMetrics();
|
||||
}
|
||||
const remainingTasks = await initialization.promise;
|
||||
|
||||
if (data && prevProps.data && prevProps.data.series !== data.series) {
|
||||
this.refreshHint();
|
||||
}
|
||||
}
|
||||
// If there are remaining tasks, wait for them
|
||||
if (Array.isArray(remainingTasks) && remainingTasks.length > 0) {
|
||||
await Promise.all(remainingTasks);
|
||||
}
|
||||
|
||||
refreshHint = () => {
|
||||
const { datasource, query, data } = this.props;
|
||||
const initHints = datasource.getInitHints();
|
||||
const initHint = initHints.length > 0 ? initHints[0] : null;
|
||||
updateLanguage();
|
||||
} catch (err) {
|
||||
if (isCancelablePromiseRejection(err) && err.isCanceled) {
|
||||
// do nothing, promise was canceled
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
languageProviderInitRef.current = null;
|
||||
}
|
||||
},
|
||||
[languageProvider, range, updateLanguage]
|
||||
);
|
||||
|
||||
if (!data || data.series.length === 0) {
|
||||
this.setState({
|
||||
hint: initHint,
|
||||
});
|
||||
const refreshHint = useCallback(() => {
|
||||
const initHints = getInitHints(datasource);
|
||||
const initHint = initHints[0] ?? null;
|
||||
|
||||
// If no data or empty series, use default hint
|
||||
if (!data?.series?.length) {
|
||||
setHint(initHint);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -120,180 +98,108 @@ class PromQueryFieldClass extends PureComponent<PromQueryFieldProps, PromQueryFi
|
||||
const queryHints = datasource.getQueryHints(query, result);
|
||||
let queryHint = queryHints.length > 0 ? queryHints[0] : null;
|
||||
|
||||
this.setState({ hint: queryHint ?? initHint });
|
||||
};
|
||||
setHint(queryHint ?? initHint);
|
||||
}, [data, datasource, query]);
|
||||
|
||||
refreshMetrics = async () => {
|
||||
const {
|
||||
range,
|
||||
datasource: { languageProvider },
|
||||
} = this.props;
|
||||
|
||||
this.languageProviderInitializationPromise = makePromiseCancelable(languageProvider.start(range));
|
||||
|
||||
try {
|
||||
const remainingTasks = await this.languageProviderInitializationPromise.promise;
|
||||
await Promise.all(remainingTasks);
|
||||
this.onUpdateLanguage();
|
||||
} catch (err) {
|
||||
if (isCancelablePromiseRejection(err) && err.isCanceled) {
|
||||
// do nothing, promise was canceled
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
const onChangeQuery = (value: string, override?: boolean) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rangeChangedToRefresh(range?: TimeRange, prevRange?: TimeRange): boolean {
|
||||
if (range && prevRange) {
|
||||
const sameMinuteFrom = roundMsToMin(range.from.valueOf()) === roundMsToMin(prevRange.from.valueOf());
|
||||
const sameMinuteTo = roundMsToMin(range.to.valueOf()) === roundMsToMin(prevRange.to.valueOf());
|
||||
// If both are same, don't need to refresh.
|
||||
return !(sameMinuteFrom && sameMinuteTo);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO #33976: Remove this, add histogram group (query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;)
|
||||
*/
|
||||
onChangeLabelBrowser = (selector: string) => {
|
||||
this.onChangeQuery(selector, true);
|
||||
this.setState({ labelBrowserVisible: false });
|
||||
};
|
||||
|
||||
onChangeQuery = (value: string, override?: boolean) => {
|
||||
// Send text change to parent
|
||||
const { query, onChange, onRunQuery } = this.props;
|
||||
if (onChange) {
|
||||
const nextQuery: PromQuery = { ...query, expr: value };
|
||||
onChange(nextQuery);
|
||||
const nextQuery: PromQuery = { ...query, expr: value };
|
||||
onChange(nextQuery);
|
||||
|
||||
if (override && onRunQuery) {
|
||||
onRunQuery();
|
||||
}
|
||||
if (override && onRunQuery) {
|
||||
onRunQuery();
|
||||
}
|
||||
};
|
||||
|
||||
onClickChooserButton = () => {
|
||||
this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible }));
|
||||
const onChangeLabelBrowser = (selector: string) => {
|
||||
onChangeQuery(selector, true);
|
||||
setLabelBrowserVisible(false);
|
||||
};
|
||||
|
||||
const onClickChooserButton = () => {
|
||||
setLabelBrowserVisible(!labelBrowserVisible);
|
||||
|
||||
reportInteraction('user_grafana_prometheus_metrics_browser_clicked', {
|
||||
editorMode: this.state.labelBrowserVisible ? 'metricViewClosed' : 'metricViewOpen',
|
||||
app: this.props?.app ?? '',
|
||||
editorMode: labelBrowserVisible ? 'metricViewClosed' : 'metricViewOpen',
|
||||
app: app ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
onClickHintFix = () => {
|
||||
const { datasource, query, onChange, onRunQuery } = this.props;
|
||||
const { hint } = this.state;
|
||||
const onClickHintFix = () => {
|
||||
if (hint?.fix?.action) {
|
||||
onChange(datasource.modifyQuery(query, hint.fix.action));
|
||||
}
|
||||
onRunQuery();
|
||||
};
|
||||
|
||||
onUpdateLanguage = () => {
|
||||
const {
|
||||
datasource: { languageProvider },
|
||||
} = this.props;
|
||||
const { metrics } = languageProvider;
|
||||
// Use our custom effects hook
|
||||
usePromQueryFieldEffects(range, data?.series, refreshMetrics, refreshHint);
|
||||
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
const { chooserText, buttonDisabled } = useMetricsState(datasource, languageProvider, syntaxLoaded);
|
||||
|
||||
this.setState({ syntaxLoaded: true });
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1"
|
||||
data-testid={props['data-testid']}
|
||||
>
|
||||
<button
|
||||
className="gf-form-label query-keyword pointer"
|
||||
onClick={onClickChooserButton}
|
||||
disabled={buttonDisabled}
|
||||
type="button"
|
||||
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton}
|
||||
>
|
||||
{chooserText}
|
||||
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
|
||||
</button>
|
||||
|
||||
render() {
|
||||
const {
|
||||
datasource,
|
||||
datasource: { languageProvider },
|
||||
query,
|
||||
ExtraFieldElement,
|
||||
history = [],
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
const { labelBrowserVisible, syntaxLoaded, hint } = this.state;
|
||||
const hasMetrics = languageProvider.metrics.length > 0;
|
||||
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
|
||||
const buttonDisabled = !(syntaxLoaded && hasMetrics);
|
||||
|
||||
return (
|
||||
<LocalStorageValueProvider<string[]> storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}>
|
||||
{(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1"
|
||||
data-testid={this.props['data-testid']}
|
||||
<div className="flex-grow-1 min-width-15">
|
||||
<MonacoQueryFieldWrapper
|
||||
languageProvider={languageProvider}
|
||||
history={history}
|
||||
onChange={onChangeQuery}
|
||||
onRunQuery={onRunQuery}
|
||||
initialValue={query.expr ?? ''}
|
||||
placeholder="Enter a PromQL query…"
|
||||
datasource={datasource}
|
||||
timeRange={range ?? getDefaultTimeRange()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{labelBrowserVisible && (
|
||||
<div className="gf-form">
|
||||
<PrometheusMetricsBrowser
|
||||
languageProvider={languageProvider}
|
||||
onChange={onChangeLabelBrowser}
|
||||
timeRange={range}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{ExtraFieldElement}
|
||||
{hint ? (
|
||||
<div
|
||||
className={css({
|
||||
flexBasis: '100%',
|
||||
})}
|
||||
>
|
||||
<div className="text-warning">
|
||||
{hint.label}{' '}
|
||||
{hint.fix ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(clearButtonStyles(theme), 'text-link', 'muted')}
|
||||
onClick={onClickHintFix}
|
||||
>
|
||||
<button
|
||||
className="gf-form-label query-keyword pointer"
|
||||
onClick={this.onClickChooserButton}
|
||||
disabled={buttonDisabled}
|
||||
type="button"
|
||||
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton}
|
||||
>
|
||||
{chooserText}
|
||||
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
|
||||
</button>
|
||||
|
||||
<div className="flex-grow-1 min-width-15">
|
||||
<MonacoQueryFieldWrapper
|
||||
languageProvider={languageProvider}
|
||||
history={history}
|
||||
onChange={this.onChangeQuery}
|
||||
onRunQuery={this.props.onRunQuery}
|
||||
initialValue={query.expr ?? ''}
|
||||
placeholder="Enter a PromQL query…"
|
||||
datasource={datasource}
|
||||
timeRange={this.props.range ?? getDefaultTimeRange()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{labelBrowserVisible && (
|
||||
<div className="gf-form">
|
||||
<PrometheusMetricsBrowser
|
||||
languageProvider={languageProvider}
|
||||
onChange={this.onChangeLabelBrowser}
|
||||
lastUsedLabels={lastUsedLabels || []}
|
||||
storeLastUsedLabels={onLastUsedLabelsSave}
|
||||
deleteLastUsedLabels={onLastUsedLabelsDelete}
|
||||
timeRange={this.props.range}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ExtraFieldElement}
|
||||
{hint ? (
|
||||
<div
|
||||
className={css({
|
||||
flexBasis: '100%',
|
||||
})}
|
||||
>
|
||||
<div className="text-warning">
|
||||
{hint.label}{' '}
|
||||
{hint.fix ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(clearButtonStyles(theme), 'text-link', 'muted')}
|
||||
onClick={this.onClickHintFix}
|
||||
>
|
||||
{hint.fix.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</LocalStorageValueProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const PromQueryField = withTheme2(PromQueryFieldClass);
|
||||
{hint.fix.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -184,9 +184,6 @@ describe('PrometheusMetricsBrowser', () => {
|
||||
onChange: () => {},
|
||||
autoSelect: 0,
|
||||
languageProvider: mockLanguageProvider as unknown as PromQlLanguageProvider,
|
||||
lastUsedLabels: [],
|
||||
storeLastUsedLabels: () => {},
|
||||
deleteLastUsedLabels: () => {},
|
||||
timeRange: getDefaultTimeRange(),
|
||||
};
|
||||
|
||||
|
@ -25,6 +25,7 @@ import { isValidLegacyName, utf8Support } from '../utf8_support';
|
||||
const EMPTY_SELECTOR = '{}';
|
||||
const METRIC_LABEL = '__name__';
|
||||
const LIST_ITEM_SIZE = 25;
|
||||
const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels';
|
||||
|
||||
export interface BrowserProps {
|
||||
languageProvider: PromQlLanguageProvider;
|
||||
@ -32,9 +33,6 @@ export interface BrowserProps {
|
||||
theme: GrafanaTheme2;
|
||||
autoSelect?: number;
|
||||
hide?: () => void;
|
||||
lastUsedLabels: string[];
|
||||
storeLastUsedLabels: (labels: string[]) => void;
|
||||
deleteLastUsedLabels: () => void;
|
||||
timeRange?: TimeRange;
|
||||
}
|
||||
|
||||
@ -274,7 +272,7 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro
|
||||
valueSearchTerm: '',
|
||||
};
|
||||
});
|
||||
this.props.deleteLastUsedLabels();
|
||||
localStorage.removeItem(LAST_USED_LABELS_KEY);
|
||||
// Get metrics
|
||||
this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
|
||||
};
|
||||
@ -347,9 +345,9 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { languageProvider, lastUsedLabels } = this.props;
|
||||
const { languageProvider } = this.props;
|
||||
if (languageProvider) {
|
||||
const selectedLabels: string[] = lastUsedLabels;
|
||||
const selectedLabels: string[] = JSON.parse(localStorage.getItem(LAST_USED_LABELS_KEY) ?? `[]`) ?? [];
|
||||
languageProvider.start(this.props.timeRange).then(() => {
|
||||
let rawLabels: string[] = languageProvider.getLabelKeys();
|
||||
// Get metrics
|
||||
@ -378,7 +376,8 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro
|
||||
return;
|
||||
}
|
||||
const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name);
|
||||
this.props.storeLastUsedLabels(selectedLabels);
|
||||
localStorage.setItem(LAST_USED_LABELS_KEY, JSON.stringify(selectedLabels));
|
||||
|
||||
if (label.selected) {
|
||||
// Refetch values for newly selected label...
|
||||
if (!label.values) {
|
||||
|
@ -144,7 +144,6 @@ describe('PromVariableQueryEditor', () => {
|
||||
getLabelValues: jest.fn().mockImplementation(() => ['that']),
|
||||
fetchLabelsWithMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })),
|
||||
} as Partial<PrometheusLanguageProvider> as PrometheusLanguageProvider,
|
||||
getInitHints: () => [],
|
||||
getDebounceTimeInMilliseconds: jest.fn(),
|
||||
getTagKeys: jest.fn().mockImplementation(() => Promise.resolve(['this'])),
|
||||
getVariables: jest.fn().mockImplementation(() => []),
|
||||
|
@ -0,0 +1,113 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { PrometheusDatasource } from '../datasource';
|
||||
import PromQlLanguageProvider from '../language_provider';
|
||||
|
||||
import { useMetricsState } from './useMetricsState';
|
||||
|
||||
// Mock implementations
|
||||
const createMockLanguageProvider = (metrics: string[] = []): PromQlLanguageProvider =>
|
||||
({
|
||||
metrics,
|
||||
}) as unknown as PromQlLanguageProvider;
|
||||
|
||||
const createMockDatasource = (lookupsDisabled = false): PrometheusDatasource =>
|
||||
({
|
||||
lookupsDisabled,
|
||||
}) as unknown as PrometheusDatasource;
|
||||
|
||||
describe('useMetricsState', () => {
|
||||
describe('chooserText', () => {
|
||||
it('should return disabled message when lookups are disabled', () => {
|
||||
const datasource = createMockDatasource(true);
|
||||
const languageProvider = createMockLanguageProvider([]);
|
||||
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
|
||||
expect(result.current.chooserText).toBe('(Disabled)');
|
||||
});
|
||||
|
||||
it('should return loading message when syntax is not loaded', () => {
|
||||
const datasource = createMockDatasource();
|
||||
const languageProvider = createMockLanguageProvider(['metric1']);
|
||||
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, false));
|
||||
expect(result.current.chooserText).toBe('Loading metrics...');
|
||||
});
|
||||
|
||||
it('should return no metrics message when no metrics are found', () => {
|
||||
const datasource = createMockDatasource();
|
||||
const languageProvider = createMockLanguageProvider([]);
|
||||
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
|
||||
expect(result.current.chooserText).toBe('(No metrics found)');
|
||||
});
|
||||
|
||||
it('should return metrics browser text when metrics are available', () => {
|
||||
const datasource = createMockDatasource();
|
||||
const languageProvider = createMockLanguageProvider(['metric1']);
|
||||
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
|
||||
expect(result.current.chooserText).toBe('Metrics browser');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buttonDisabled', () => {
|
||||
it('should be disabled when syntax is not loaded', () => {
|
||||
const datasource = createMockDatasource();
|
||||
const languageProvider = createMockLanguageProvider(['metric1']);
|
||||
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, false));
|
||||
expect(result.current.buttonDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should be disabled when no metrics are available', () => {
|
||||
const datasource = createMockDatasource();
|
||||
const languageProvider = createMockLanguageProvider([]);
|
||||
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
|
||||
expect(result.current.buttonDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should be enabled when syntax is loaded and metrics are available', () => {
|
||||
const datasource = createMockDatasource();
|
||||
const languageProvider = createMockLanguageProvider(['metric1']);
|
||||
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
|
||||
expect(result.current.buttonDisabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasMetrics', () => {
|
||||
it('should be false when no metrics are available', () => {
|
||||
const datasource = createMockDatasource();
|
||||
const languageProvider = createMockLanguageProvider([]);
|
||||
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
|
||||
expect(result.current.hasMetrics).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when metrics are available', () => {
|
||||
const datasource = createMockDatasource();
|
||||
const languageProvider = createMockLanguageProvider(['metric1']);
|
||||
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
|
||||
expect(result.current.hasMetrics).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoization', () => {
|
||||
it('should return same values when dependencies have not changed', () => {
|
||||
const datasource = createMockDatasource();
|
||||
const languageProvider = createMockLanguageProvider(['metric1']);
|
||||
const { result, rerender } = renderHook(() => useMetricsState(datasource, languageProvider, true));
|
||||
const firstResult = result.current;
|
||||
|
||||
rerender();
|
||||
expect(result.current).toBe(firstResult);
|
||||
});
|
||||
|
||||
it('should update when datasource lookupsDisabled changes', () => {
|
||||
const initialDatasource = createMockDatasource(false);
|
||||
const languageProvider = createMockLanguageProvider(['metric1']);
|
||||
const { result, rerender } = renderHook(({ ds }) => useMetricsState(ds, languageProvider, true), {
|
||||
initialProps: { ds: initialDatasource },
|
||||
});
|
||||
const firstResult = result.current;
|
||||
|
||||
const updatedDatasource = createMockDatasource(true);
|
||||
rerender({ ds: updatedDatasource });
|
||||
expect(result.current).not.toBe(firstResult);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,38 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PrometheusDatasource } from '../datasource';
|
||||
import PromQlLanguageProvider from '../language_provider';
|
||||
|
||||
function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) {
|
||||
if (metricsLookupDisabled) {
|
||||
return '(Disabled)';
|
||||
}
|
||||
|
||||
if (!hasSyntax) {
|
||||
return 'Loading metrics...';
|
||||
}
|
||||
|
||||
if (!hasMetrics) {
|
||||
return '(No metrics found)';
|
||||
}
|
||||
|
||||
return 'Metrics browser';
|
||||
}
|
||||
|
||||
export function useMetricsState(
|
||||
datasource: PrometheusDatasource,
|
||||
languageProvider: PromQlLanguageProvider,
|
||||
syntaxLoaded: boolean
|
||||
) {
|
||||
return useMemo(() => {
|
||||
const hasMetrics = languageProvider.metrics.length > 0;
|
||||
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
|
||||
const buttonDisabled = !(syntaxLoaded && hasMetrics);
|
||||
|
||||
return {
|
||||
hasMetrics,
|
||||
chooserText,
|
||||
buttonDisabled,
|
||||
};
|
||||
}, [languageProvider.metrics, datasource.lookupsDisabled, syntaxLoaded]);
|
||||
}
|
@ -0,0 +1,182 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { DataFrame, dateTime, TimeRange } from '@grafana/data';
|
||||
|
||||
import PromQlLanguageProvider from '../language_provider';
|
||||
|
||||
import { usePromQueryFieldEffects } from './usePromQueryFieldEffects';
|
||||
|
||||
type TestProps = {
|
||||
languageProvider: PromQlLanguageProvider;
|
||||
range: TimeRange | undefined;
|
||||
series: DataFrame[];
|
||||
};
|
||||
|
||||
describe('usePromQueryFieldEffects', () => {
|
||||
const mockLanguageProvider = {
|
||||
start: jest.fn().mockResolvedValue([]),
|
||||
histogramMetrics: [],
|
||||
timeRange: {},
|
||||
metrics: ['metric1'],
|
||||
startTask: Promise.resolve(),
|
||||
datasource: {},
|
||||
lookupsDisabled: false,
|
||||
syntax: jest.fn(),
|
||||
getLabelKeys: jest.fn(),
|
||||
cleanText: jest.fn(),
|
||||
hasLookupsDisabled: jest.fn(),
|
||||
getBeginningCompletionItems: jest.fn(),
|
||||
getLabelCompletionItems: jest.fn(),
|
||||
getMetricCompletionItems: jest.fn(),
|
||||
getTermCompletionItems: jest.fn(),
|
||||
request: jest.fn(),
|
||||
importQueries: jest.fn(),
|
||||
labelKeys: [],
|
||||
labelFetchTs: 0,
|
||||
getDefaultCacheHeaders: jest.fn(),
|
||||
loadMetricsMetadata: jest.fn(),
|
||||
loadMetrics: jest.fn(),
|
||||
loadLabelKeys: jest.fn(),
|
||||
loadLabelValues: jest.fn(),
|
||||
modifyQuery: jest.fn(),
|
||||
} as unknown as PromQlLanguageProvider;
|
||||
|
||||
const mockRange: TimeRange = {
|
||||
from: dateTime('2022-01-01T00:00:00Z'),
|
||||
to: dateTime('2022-01-02T00:00:00Z'),
|
||||
raw: {
|
||||
from: 'now-1d',
|
||||
to: 'now',
|
||||
},
|
||||
};
|
||||
|
||||
const mockNewRange: TimeRange = {
|
||||
from: dateTime('2022-01-02T00:00:00Z'),
|
||||
to: dateTime('2022-01-03T00:00:00Z'),
|
||||
raw: {
|
||||
from: 'now-1d',
|
||||
to: 'now',
|
||||
},
|
||||
};
|
||||
|
||||
let refreshMetricsMock: jest.Mock;
|
||||
let refreshHintMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
refreshMetricsMock = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
refreshHintMock = jest.fn();
|
||||
});
|
||||
|
||||
it('should call refreshMetrics and refreshHint on initial render', async () => {
|
||||
renderHook(() => usePromQueryFieldEffects(mockRange, [], refreshMetricsMock, refreshHintMock));
|
||||
|
||||
expect(refreshMetricsMock).toHaveBeenCalledTimes(1);
|
||||
expect(refreshHintMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should call refreshMetrics when the time range changes', async () => {
|
||||
const { rerender } = renderHook(
|
||||
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
|
||||
{
|
||||
initialProps: {
|
||||
languageProvider: mockLanguageProvider,
|
||||
range: mockRange,
|
||||
series: [] as DataFrame[],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Initial render already called refreshMetrics once
|
||||
expect(refreshMetricsMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change the range
|
||||
rerender({
|
||||
languageProvider: mockLanguageProvider,
|
||||
range: mockNewRange,
|
||||
series: [] as DataFrame[],
|
||||
});
|
||||
|
||||
expect(refreshMetricsMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not call refreshMetrics when the time range is the same', () => {
|
||||
const { rerender } = renderHook(
|
||||
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
|
||||
{
|
||||
initialProps: {
|
||||
languageProvider: mockLanguageProvider,
|
||||
range: mockRange,
|
||||
series: [] as DataFrame[],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Initial render already called refreshMetrics once
|
||||
expect(refreshMetricsMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Rerender with the same range
|
||||
rerender({
|
||||
languageProvider: mockLanguageProvider,
|
||||
range: { ...mockRange }, // create a new object with the same values
|
||||
series: [] as DataFrame[],
|
||||
});
|
||||
|
||||
// Should still be called only once (from initial render)
|
||||
expect(refreshMetricsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call refreshHint when series changes', () => {
|
||||
const mockSeries = [{ name: 'new series', fields: [], length: 0 }] as DataFrame[];
|
||||
const { rerender } = renderHook(
|
||||
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
|
||||
{
|
||||
initialProps: {
|
||||
languageProvider: mockLanguageProvider,
|
||||
range: mockRange,
|
||||
series: [] as DataFrame[],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Initial render already called refreshHint once
|
||||
expect(refreshHintMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
refreshHintMock.mockClear();
|
||||
|
||||
// Change the series
|
||||
rerender({
|
||||
languageProvider: mockLanguageProvider,
|
||||
range: mockRange,
|
||||
series: mockSeries,
|
||||
});
|
||||
|
||||
expect(refreshHintMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call refreshHint when series is the same', () => {
|
||||
const series = [] as DataFrame[];
|
||||
const { rerender } = renderHook(
|
||||
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
|
||||
{
|
||||
initialProps: {
|
||||
languageProvider: mockLanguageProvider,
|
||||
range: mockRange,
|
||||
series,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Initial render already called refreshHint once
|
||||
refreshHintMock.mockClear();
|
||||
|
||||
// Rerender with the same series
|
||||
rerender({
|
||||
languageProvider: mockLanguageProvider,
|
||||
range: mockRange,
|
||||
series, // same empty array
|
||||
});
|
||||
|
||||
expect(refreshHintMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
@ -0,0 +1,60 @@
|
||||
import { MutableRefObject, useEffect, useRef } from 'react';
|
||||
|
||||
import { DataFrame, DateTime, TimeRange } from '@grafana/data';
|
||||
|
||||
import { roundMsToMin } from '../language_utils';
|
||||
|
||||
import { CancelablePromise } from './cancelable-promise';
|
||||
|
||||
export function usePromQueryFieldEffects(
|
||||
range: TimeRange | undefined,
|
||||
series: DataFrame[] | undefined,
|
||||
refreshMetrics: (languageProviderInitRef: MutableRefObject<CancelablePromise<unknown> | null>) => Promise<void>,
|
||||
refreshHint: () => void
|
||||
) {
|
||||
const lastRangeRef = useRef<{ from: DateTime; to: DateTime } | null>(null);
|
||||
const languageProviderInitRef = useRef<CancelablePromise<unknown> | null>(null);
|
||||
|
||||
// Effect for initial load
|
||||
useEffect(() => {
|
||||
refreshMetrics(languageProviderInitRef);
|
||||
refreshHint();
|
||||
|
||||
return () => {
|
||||
if (languageProviderInitRef.current) {
|
||||
languageProviderInitRef.current.cancel();
|
||||
languageProviderInitRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Effect for time range changes
|
||||
useEffect(() => {
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFrom = roundMsToMin(range.from.valueOf());
|
||||
const currentTo = roundMsToMin(range.to.valueOf());
|
||||
|
||||
if (!lastRangeRef.current) {
|
||||
lastRangeRef.current = { from: range.from, to: range.to };
|
||||
}
|
||||
|
||||
const lastFrom = roundMsToMin(lastRangeRef.current.from.valueOf());
|
||||
const lastTo = roundMsToMin(lastRangeRef.current.to.valueOf());
|
||||
|
||||
if (currentFrom !== lastFrom || currentTo !== lastTo) {
|
||||
lastRangeRef.current = { from: range.from, to: range.to };
|
||||
refreshMetrics(languageProviderInitRef);
|
||||
}
|
||||
}, [range, refreshMetrics]);
|
||||
|
||||
// Effect for data changes (refreshing hints)
|
||||
useEffect(() => {
|
||||
refreshHint();
|
||||
}, [series, refreshHint]);
|
||||
|
||||
return languageProviderInitRef;
|
||||
}
|
Reference in New Issue
Block a user