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:
ismail simsek
2025-04-04 17:56:31 +02:00
committed by GitHub
parent a93e618102
commit 23e0f63790
14 changed files with 571 additions and 337 deletions

View File

@ -401,9 +401,6 @@ exports[`better eslint`] = {
"packages/grafana-o11y-ds-frontend/src/createNodeGraphFrames.ts:5381": [ "packages/grafana-o11y-ds-frontend/src/createNodeGraphFrames.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
"packages/grafana-prometheus/src/components/PromQueryField.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/components/PromQueryField.tsx:5381": [ "packages/grafana-prometheus/src/components/PromQueryField.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],

View File

@ -24,7 +24,6 @@ jest.mock('./monaco-query-field/MonacoQueryFieldLazy', () => {
function setup(app: CoreApp): { onRunQuery: jest.Mock } { function setup(app: CoreApp): { onRunQuery: jest.Mock } {
const dataSource = { const dataSource = {
getInitHints: () => [],
getPrometheusTime: jest.fn((date, roundup) => 123), getPrometheusTime: jest.fn((date, roundup) => 123),
getQueryHints: jest.fn(() => []), getQueryHints: jest.fn(() => []),
getDebounceTimeInMilliseconds: jest.fn(() => 300), getDebounceTimeInMilliseconds: jest.fn(() => 300),

View File

@ -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 // 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 // @ts-ignore
import userEvent from '@testing-library/user-event'; 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 { PrometheusDatasource } from '../datasource';
import PromQlLanguageProvider from '../language_provider'; import * as queryHints from '../query_hints';
import { PromQueryField } from './PromQueryField'; import { PromQueryField } from './PromQueryField';
import { Props } from './monaco-query-field/MonacoQueryFieldProps'; import { Props } from './monaco-query-field/MonacoQueryFieldProps';
@ -31,7 +31,6 @@ const defaultProps = {
getLabelKeys: () => [], getLabelKeys: () => [],
metrics: [], metrics: [],
}, },
getInitHints: () => [],
} as unknown as PrometheusDatasource, } as unknown as PrometheusDatasource,
query: { query: {
expr: '', expr: '',
@ -40,6 +39,14 @@ const defaultProps = {
onRunQuery: () => {}, onRunQuery: () => {},
onChange: () => {}, onChange: () => {},
history: [], history: [],
range: {
from: dateTime('2022-01-01T00:00:00Z'),
to: dateTime('2022-01-02T00:00:00Z'),
raw: {
from: 'now-1d',
to: 'now',
},
},
}; };
describe('PromQueryField', () => { describe('PromQueryField', () => {
@ -48,6 +55,10 @@ describe('PromQueryField', () => {
window.getSelection = () => {}; window.getSelection = () => {};
}); });
beforeEach(() => {
jest.clearAllMocks();
});
it('renders metrics chooser regularly if lookups are not disabled in the datasource settings', async () => { it('renders metrics chooser regularly if lookups are not disabled in the datasource settings', async () => {
const queryField = render(<PromQueryField {...defaultProps} />); const queryField = render(<PromQueryField {...defaultProps} />);
@ -72,7 +83,9 @@ describe('PromQueryField', () => {
it('renders an initial hint if no data and initial hint provided', async () => { it('renders an initial hint if no data and initial hint provided', async () => {
const props = defaultProps; const props = defaultProps;
props.datasource.lookupsDisabled = true; 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} />); render(<PromQueryField {...props} />);
// wait for component to render // wait for component to render
@ -84,7 +97,6 @@ describe('PromQueryField', () => {
it('renders query hint if data, query hint and initial hint provided', async () => { it('renders query hint if data, query hint and initial hint provided', async () => {
const props = defaultProps; const props = defaultProps;
props.datasource.lookupsDisabled = true; props.datasource.lookupsDisabled = true;
props.datasource.getInitHints = () => [{ label: 'Initial hint', type: 'INFO' }];
props.datasource.getQueryHints = () => [{ label: 'Query hint', type: 'INFO' }]; props.datasource.getQueryHints = () => [{ label: 'Query hint', type: 'INFO' }];
render( render(
<PromQueryField <PromQueryField
@ -105,51 +117,6 @@ describe('PromQueryField', () => {
expect(screen.queryByText('Initial hint')).not.toBeInTheDocument(); 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 () => { it('should not run query onBlur', async () => {
const onRunQuery = jest.fn(); const onRunQuery = jest.fn();
const { container } = render(<PromQueryField {...defaultProps} app={CoreApp.Explore} onRunQuery={onRunQuery} />); const { container } = render(<PromQueryField {...defaultProps} app={CoreApp.Explore} onRunQuery={onRunQuery} />);
@ -166,18 +133,3 @@ describe('PromQueryField', () => {
expect(onRunQuery).not.toHaveBeenCalled(); 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;
}

View File

@ -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 // 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 { css, cx } from '@emotion/css';
import { PureComponent, ReactNode } from 'react'; import { MutableRefObject, ReactNode, useCallback, useState } from 'react';
import { import { getDefaultTimeRange, isDataFrame, QueryEditorProps, QueryHint, toLegacyResponseData } from '@grafana/data';
getDefaultTimeRange,
isDataFrame,
LocalStorageValueProvider,
QueryEditorProps,
QueryHint,
TimeRange,
toLegacyResponseData,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime'; 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 { PrometheusDatasource } from '../datasource';
import { roundMsToMin } from '../language_utils'; import { getInitHints } from '../query_hints';
import { PromOptions, PromQuery } from '../types'; import { PromOptions, PromQuery } from '../types';
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser'; import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise'; import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise';
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper'; 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'; interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions> {
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 {
ExtraFieldElement?: ReactNode; ExtraFieldElement?: ReactNode;
'data-testid'?: string; 'data-testid'?: string;
} }
interface PromQueryFieldState { export const PromQueryField = (props: PromQueryFieldProps) => {
labelBrowserVisible: boolean; const {
syntaxLoaded: boolean; app,
hint: QueryHint | null; datasource,
} datasource: { languageProvider },
query,
ExtraFieldElement,
history = [],
data,
range,
onChange,
onRunQuery,
} = props;
class PromQueryFieldClass extends PureComponent<PromQueryFieldProps, PromQueryFieldState> { const theme = useTheme2();
declare languageProviderInitializationPromise: CancelablePromise<any>;
constructor(props: PromQueryFieldProps) { const [syntaxLoaded, setSyntaxLoaded] = useState(false);
super(props); const [hint, setHint] = useState<QueryHint | null>(null);
const [labelBrowserVisible, setLabelBrowserVisible] = useState(false);
this.state = { const updateLanguage = useCallback(() => {
labelBrowserVisible: false, if (languageProvider.metrics) {
syntaxLoaded: false, setSyntaxLoaded(true);
hint: null,
};
}
componentDidMount() {
if (this.props.datasource.languageProvider) {
this.refreshMetrics();
} }
this.refreshHint(); }, [languageProvider]);
}
componentWillUnmount() { const refreshMetrics = useCallback(
if (this.languageProviderInitializationPromise) { async (languageProviderInitRef: MutableRefObject<CancelablePromise<any> | null>) => {
this.languageProviderInitializationPromise.cancel(); // Cancel any existing initialization using the ref
} if (languageProviderInitRef.current) {
} languageProviderInitRef.current.cancel();
}
componentDidUpdate(prevProps: PromQueryFieldProps) { if (!languageProvider || !range) {
const { return;
data, }
datasource: { languageProvider },
range,
} = this.props;
if (languageProvider !== prevProps.datasource.languageProvider) { try {
// We reset this only on DS change so we do not flesh loading state on every rangeChange which happens on every const initialization = makePromiseCancelable(languageProvider.start(range));
// query run if using relative range. languageProviderInitRef.current = initialization;
this.setState({
syntaxLoaded: false,
});
}
const changedRangeToRefresh = this.rangeChangedToRefresh(range, prevProps.range); const remainingTasks = await initialization.promise;
// 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();
}
if (data && prevProps.data && prevProps.data.series !== data.series) { // If there are remaining tasks, wait for them
this.refreshHint(); if (Array.isArray(remainingTasks) && remainingTasks.length > 0) {
} await Promise.all(remainingTasks);
} }
refreshHint = () => { updateLanguage();
const { datasource, query, data } = this.props; } catch (err) {
const initHints = datasource.getInitHints(); if (isCancelablePromiseRejection(err) && err.isCanceled) {
const initHint = initHints.length > 0 ? initHints[0] : null; // do nothing, promise was canceled
} else {
throw err;
}
} finally {
languageProviderInitRef.current = null;
}
},
[languageProvider, range, updateLanguage]
);
if (!data || data.series.length === 0) { const refreshHint = useCallback(() => {
this.setState({ const initHints = getInitHints(datasource);
hint: initHint, const initHint = initHints[0] ?? null;
});
// If no data or empty series, use default hint
if (!data?.series?.length) {
setHint(initHint);
return; return;
} }
@ -120,180 +98,108 @@ class PromQueryFieldClass extends PureComponent<PromQueryFieldProps, PromQueryFi
const queryHints = datasource.getQueryHints(query, result); const queryHints = datasource.getQueryHints(query, result);
let queryHint = queryHints.length > 0 ? queryHints[0] : null; let queryHint = queryHints.length > 0 ? queryHints[0] : null;
this.setState({ hint: queryHint ?? initHint }); setHint(queryHint ?? initHint);
}; }, [data, datasource, query]);
refreshMetrics = async () => { const onChangeQuery = (value: string, override?: boolean) => {
const { if (!onChange) {
range, return;
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;
}
} }
};
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 // Send text change to parent
const { query, onChange, onRunQuery } = this.props; const nextQuery: PromQuery = { ...query, expr: value };
if (onChange) { onChange(nextQuery);
const nextQuery: PromQuery = { ...query, expr: value };
onChange(nextQuery);
if (override && onRunQuery) { if (override && onRunQuery) {
onRunQuery(); onRunQuery();
}
} }
}; };
onClickChooserButton = () => { const onChangeLabelBrowser = (selector: string) => {
this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible })); onChangeQuery(selector, true);
setLabelBrowserVisible(false);
};
const onClickChooserButton = () => {
setLabelBrowserVisible(!labelBrowserVisible);
reportInteraction('user_grafana_prometheus_metrics_browser_clicked', { reportInteraction('user_grafana_prometheus_metrics_browser_clicked', {
editorMode: this.state.labelBrowserVisible ? 'metricViewClosed' : 'metricViewOpen', editorMode: labelBrowserVisible ? 'metricViewClosed' : 'metricViewOpen',
app: this.props?.app ?? '', app: app ?? '',
}); });
}; };
onClickHintFix = () => { const onClickHintFix = () => {
const { datasource, query, onChange, onRunQuery } = this.props;
const { hint } = this.state;
if (hint?.fix?.action) { if (hint?.fix?.action) {
onChange(datasource.modifyQuery(query, hint.fix.action)); onChange(datasource.modifyQuery(query, hint.fix.action));
} }
onRunQuery(); onRunQuery();
}; };
onUpdateLanguage = () => { // Use our custom effects hook
const { usePromQueryFieldEffects(range, data?.series, refreshMetrics, refreshHint);
datasource: { languageProvider },
} = this.props;
const { metrics } = languageProvider;
if (!metrics) { const { chooserText, buttonDisabled } = useMetricsState(datasource, languageProvider, syntaxLoaded);
return;
}
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() { <div className="flex-grow-1 min-width-15">
const { <MonacoQueryFieldWrapper
datasource, languageProvider={languageProvider}
datasource: { languageProvider }, history={history}
query, onChange={onChangeQuery}
ExtraFieldElement, onRunQuery={onRunQuery}
history = [], initialValue={query.expr ?? ''}
theme, placeholder="Enter a PromQL query…"
} = this.props; datasource={datasource}
timeRange={range ?? getDefaultTimeRange()}
const { labelBrowserVisible, syntaxLoaded, hint } = this.state; />
const hasMetrics = languageProvider.metrics.length > 0; </div>
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics); </div>
const buttonDisabled = !(syntaxLoaded && hasMetrics); {labelBrowserVisible && (
<div className="gf-form">
return ( <PrometheusMetricsBrowser
<LocalStorageValueProvider<string[]> storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}> languageProvider={languageProvider}
{(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => { onChange={onChangeLabelBrowser}
return ( timeRange={range}
<> />
<div </div>
className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1" )}
data-testid={this.props['data-testid']} {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 {hint.fix.label}
className="gf-form-label query-keyword pointer" </button>
onClick={this.onClickChooserButton} ) : null}
disabled={buttonDisabled} </div>
type="button" </div>
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton} ) : null}
> </>
{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);

View File

@ -184,9 +184,6 @@ describe('PrometheusMetricsBrowser', () => {
onChange: () => {}, onChange: () => {},
autoSelect: 0, autoSelect: 0,
languageProvider: mockLanguageProvider as unknown as PromQlLanguageProvider, languageProvider: mockLanguageProvider as unknown as PromQlLanguageProvider,
lastUsedLabels: [],
storeLastUsedLabels: () => {},
deleteLastUsedLabels: () => {},
timeRange: getDefaultTimeRange(), timeRange: getDefaultTimeRange(),
}; };

View File

@ -25,6 +25,7 @@ import { isValidLegacyName, utf8Support } from '../utf8_support';
const EMPTY_SELECTOR = '{}'; const EMPTY_SELECTOR = '{}';
const METRIC_LABEL = '__name__'; const METRIC_LABEL = '__name__';
const LIST_ITEM_SIZE = 25; const LIST_ITEM_SIZE = 25;
const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels';
export interface BrowserProps { export interface BrowserProps {
languageProvider: PromQlLanguageProvider; languageProvider: PromQlLanguageProvider;
@ -32,9 +33,6 @@ export interface BrowserProps {
theme: GrafanaTheme2; theme: GrafanaTheme2;
autoSelect?: number; autoSelect?: number;
hide?: () => void; hide?: () => void;
lastUsedLabels: string[];
storeLastUsedLabels: (labels: string[]) => void;
deleteLastUsedLabels: () => void;
timeRange?: TimeRange; timeRange?: TimeRange;
} }
@ -274,7 +272,7 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro
valueSearchTerm: '', valueSearchTerm: '',
}; };
}); });
this.props.deleteLastUsedLabels(); localStorage.removeItem(LAST_USED_LABELS_KEY);
// Get metrics // Get metrics
this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR); this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
}; };
@ -347,9 +345,9 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro
} }
componentDidMount() { componentDidMount() {
const { languageProvider, lastUsedLabels } = this.props; const { languageProvider } = this.props;
if (languageProvider) { if (languageProvider) {
const selectedLabels: string[] = lastUsedLabels; const selectedLabels: string[] = JSON.parse(localStorage.getItem(LAST_USED_LABELS_KEY) ?? `[]`) ?? [];
languageProvider.start(this.props.timeRange).then(() => { languageProvider.start(this.props.timeRange).then(() => {
let rawLabels: string[] = languageProvider.getLabelKeys(); let rawLabels: string[] = languageProvider.getLabelKeys();
// Get metrics // Get metrics
@ -378,7 +376,8 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro
return; return;
} }
const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name); 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) { if (label.selected) {
// Refetch values for newly selected label... // Refetch values for newly selected label...
if (!label.values) { if (!label.values) {

View File

@ -144,7 +144,6 @@ describe('PromVariableQueryEditor', () => {
getLabelValues: jest.fn().mockImplementation(() => ['that']), getLabelValues: jest.fn().mockImplementation(() => ['that']),
fetchLabelsWithMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })), fetchLabelsWithMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })),
} as Partial<PrometheusLanguageProvider> as PrometheusLanguageProvider, } as Partial<PrometheusLanguageProvider> as PrometheusLanguageProvider,
getInitHints: () => [],
getDebounceTimeInMilliseconds: jest.fn(), getDebounceTimeInMilliseconds: jest.fn(),
getTagKeys: jest.fn().mockImplementation(() => Promise.resolve(['this'])), getTagKeys: jest.fn().mockImplementation(() => Promise.resolve(['this'])),
getVariables: jest.fn().mockImplementation(() => []), getVariables: jest.fn().mockImplementation(() => []),

View File

@ -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);
});
});
});

View File

@ -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]);
}

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -56,7 +56,7 @@ import {
getRangeSnapInterval, getRangeSnapInterval,
} from './language_utils'; } from './language_utils';
import { PrometheusMetricFindQuery } from './metric_find_query'; import { PrometheusMetricFindQuery } from './metric_find_query';
import { getInitHints, getQueryHints } from './query_hints'; import { getQueryHints } from './query_hints';
import { promQueryModeller } from './querybuilder/PromQueryModeller'; import { promQueryModeller } from './querybuilder/PromQueryModeller';
import { QueryBuilderLabelFilter, QueryEditorMode } from './querybuilder/shared/types'; import { QueryBuilderLabelFilter, QueryEditorMode } from './querybuilder/shared/types';
import { CacheRequestInfo, defaultPrometheusQueryOverlapWindow, QueryCache } from './querycache/QueryCache'; import { CacheRequestInfo, defaultPrometheusQueryOverlapWindow, QueryCache } from './querycache/QueryCache';
@ -777,10 +777,6 @@ export class PrometheusDatasource
return getQueryHints(query.expr ?? '', result, this); return getQueryHints(query.expr ?? '', result, this);
} }
getInitHints() {
return getInitHints(this);
}
async loadRules() { async loadRules() {
try { try {
const res = await this.metadataRequest('/api/v1/rules', {}, { showErrorAlert: false }); const res = await this.metadataRequest('/api/v1/rules', {}, { showErrorAlert: false });

View File

@ -8,7 +8,6 @@ import {
LoadingState, LoadingState,
MutableDataFrame, MutableDataFrame,
PanelData, PanelData,
QueryHint,
TimeRange, TimeRange,
} from '@grafana/data'; } from '@grafana/data';
import { TemplateSrv } from '@grafana/runtime'; import { TemplateSrv } from '@grafana/runtime';
@ -16,6 +15,7 @@ import { TemplateSrv } from '@grafana/runtime';
import { PrometheusDatasource } from '../../datasource'; import { PrometheusDatasource } from '../../datasource';
import PromQlLanguageProvider from '../../language_provider'; import PromQlLanguageProvider from '../../language_provider';
import { EmptyLanguageProviderMock } from '../../language_provider.mock'; import { EmptyLanguageProviderMock } from '../../language_provider.mock';
import * as queryHints from '../../query_hints';
import { PromApplication, PromOptions } from '../../types'; import { PromApplication, PromOptions } from '../../types';
import { getLabelSelects } from '../testUtils'; import { getLabelSelects } from '../testUtils';
import { PromVisualQuery } from '../types'; import { PromVisualQuery } from '../types';
@ -239,12 +239,7 @@ describe('PromQueryBuilder', () => {
it('renders hint if initial hint provided', async () => { it('renders hint if initial hint provided', async () => {
const { datasource } = createDatasource(); const { datasource } = createDatasource();
datasource.getInitHints = (): QueryHint[] => [ jest.spyOn(queryHints, 'getInitHints').mockReturnValue([{ label: 'Initial hint', type: 'warning' }]);
{
label: 'Initial hint',
type: 'warning',
},
];
const props = createProps(datasource); const props = createProps(datasource);
render( render(
<PromQueryBuilder <PromQueryBuilder
@ -261,7 +256,7 @@ describe('PromQueryBuilder', () => {
it('renders no hint if no initial hint provided', async () => { it('renders no hint if no initial hint provided', async () => {
const { datasource } = createDatasource(); const { datasource } = createDatasource();
datasource.getInitHints = (): QueryHint[] => []; jest.spyOn(queryHints, 'getInitHints').mockReturnValue([]);
const props = createProps(datasource); const props = createProps(datasource);
render( render(
<PromQueryBuilder <PromQueryBuilder

View File

@ -8,6 +8,7 @@ import { EditorRow } from '@grafana/plugin-ui';
import { PrometheusDatasource } from '../../datasource'; import { PrometheusDatasource } from '../../datasource';
import promqlGrammar from '../../promql'; import promqlGrammar from '../../promql';
import { getInitHints } from '../../query_hints';
import { promQueryModeller } from '../PromQueryModeller'; import { promQueryModeller } from '../PromQueryModeller';
import { buildVisualQueryFromString } from '../parsing'; import { buildVisualQueryFromString } from '../parsing';
import { OperationExplainedBox } from '../shared/OperationExplainedBox'; import { OperationExplainedBox } from '../shared/OperationExplainedBox';
@ -38,7 +39,7 @@ export const PromQueryBuilder = memo<PromQueryBuilderProps>((props) => {
const lang = { grammar: promqlGrammar, name: 'promql' }; const lang = { grammar: promqlGrammar, name: 'promql' };
const initHints = datasource.getInitHints(); const initHints = getInitHints(datasource);
return ( return (
<> <>