Google Cloud Monitor: Fix mem usage for dropdown (#67683) (#67950)

Previously the Metric name dropdown would attempt to load _all_ the
available metric names into the Select which would eventually crash the
browser if the dataset was large enough.

We can fix this by using AsyncSelect and making another query once a
Service is selected _and_ the user types a few characters.

* fix: update tests for AsyncSelect

* fix lint

* fix: add subset of metrics on initial load
This commit is contained in:
Adam Simpson
2023-05-11 19:26:51 +00:00
committed by GitHub
parent 9cb76776af
commit 4a07a2dc5d
4 changed files with 71 additions and 79 deletions
@@ -14,6 +14,7 @@ export const createMockDatasource = (overrides?: Partial<Datasource>) => {
getProjects: jest.fn().mockResolvedValue([]),
getDefaultProject: jest.fn().mockReturnValue('cloud-monitoring-default-project'),
templateSrv,
filterMetricsByType: jest.fn().mockResolvedValue([]),
getSLOServices: jest.fn().mockResolvedValue([]),
migrateQuery: jest.fn().mockImplementation((query) => query),
timeSrv: getTimeSrv(),
@@ -1,4 +1,5 @@
import { act, render, screen, waitFor, within } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { openMenu, select } from 'react-select-event';
@@ -60,6 +61,7 @@ describe('VisualMetricQueryEditor', () => {
const mockMetricDescriptor = createMockMetricDescriptor({ displayName: 'metricName_test', type: 'test_type' });
const datasource = createMockDatasource({
getMetricTypes: jest.fn().mockResolvedValue([createMockMetricDescriptor(), mockMetricDescriptor]),
filterMetricsByType: jest.fn().mockResolvedValue([createMockMetricDescriptor(), mockMetricDescriptor]),
getLabels: jest.fn().mockResolvedValue([]),
});
@@ -71,7 +73,8 @@ describe('VisualMetricQueryEditor', () => {
await select(service, 'Srv', { container: document.body });
});
const metricName = await screen.findByLabelText('Metric name');
await openMenu(metricName);
openMenu(metricName);
await userEvent.type(metricName, 'test');
await waitFor(() => expect(document.body).toHaveTextContent('metricName_test'));
await act(async () => {
await select(metricName, 'metricName_test', { container: document.body });
@@ -81,66 +84,6 @@ describe('VisualMetricQueryEditor', () => {
});
});
it('should render available metric options according to the selected service', async () => {
const onChange = jest.fn();
const query = createMockTimeSeriesList();
const datasource = createMockDatasource({
getMetricTypes: jest.fn().mockResolvedValue([
createMockMetricDescriptor({
service: 'service_a',
serviceShortName: 'srv_a',
type: 'metric1',
description: 'description_metric1',
displayName: 'displayName_metric1',
}),
createMockMetricDescriptor({
service: 'service_b',
serviceShortName: 'srv_b',
type: 'metric2',
description: 'description_metric2',
displayName: 'displayName_metric2',
}),
createMockMetricDescriptor({
service: 'service_b',
serviceShortName: 'srv_b',
type: 'metric3',
description: 'description_metric3',
displayName: 'displayName_metric3',
}),
]),
getLabels: jest.fn().mockResolvedValue([]),
});
render(<VisualMetricQueryEditor {...defaultProps} onChange={onChange} datasource={datasource} query={query} />);
const service = await screen.findByLabelText('Service');
await openMenu(service);
await act(async () => {
await select(service, 'Srv A', { container: document.body });
});
const metricName = await screen.findByLabelText('Metric name');
await openMenu(metricName);
const metricNameOptions = screen.getByLabelText('Select options menu');
expect(within(metricNameOptions).getByText('description_metric1')).toBeInTheDocument();
expect(within(metricNameOptions).getByText('displayName_metric1')).toBeInTheDocument();
expect(within(metricNameOptions).queryByText('displayName_metric2')).not.toBeInTheDocument();
expect(within(metricNameOptions).queryByText('description_metric2')).not.toBeInTheDocument();
expect(within(metricNameOptions).queryByText('displayName_metric3')).not.toBeInTheDocument();
expect(within(metricNameOptions).queryByText('description_metric3')).not.toBeInTheDocument();
await openMenu(service);
await act(async () => {
await select(service, 'Srv B', { container: document.body });
});
expect(within(metricNameOptions).queryByText('displayName_metric1')).not.toBeInTheDocument();
expect(within(metricNameOptions).queryByText('description_metric1')).not.toBeInTheDocument();
expect(within(metricNameOptions).getByText('displayName_metric2')).toBeInTheDocument();
expect(within(metricNameOptions).getByText('description_metric2')).toBeInTheDocument();
expect(within(metricNameOptions).getByText('displayName_metric3')).toBeInTheDocument();
expect(within(metricNameOptions).getByText('description_metric3')).toBeInTheDocument();
});
it('should have a distinct list of services', async () => {
const onChange = jest.fn();
const datasource = createMockDatasource({
@@ -210,6 +153,12 @@ describe('VisualMetricQueryEditor', () => {
});
const onChange = jest.fn();
const datasource = createMockDatasource({
filterMetricsByType: jest
.fn()
.mockResolvedValue([
createMockMetricDescriptor(),
createMockMetricDescriptor({ type: 'type2', displayName: 'metricName2', metricKind: MetricKind.GAUGE }),
]),
getMetricTypes: jest
.fn()
.mockResolvedValue([
@@ -227,7 +176,11 @@ describe('VisualMetricQueryEditor', () => {
expect(await screen.findByText('metric.test_groupby')).toBeInTheDocument();
const metric = await screen.findByLabelText('Metric name');
openMenu(metric);
await select(metric, 'metricName2', { container: document.body });
await userEvent.type(metric, 'type2');
await waitFor(() => expect(document.body).toHaveTextContent('metricName2'));
await act(async () => {
await select(metric, 'metricName2', { container: document.body });
});
expect(onChange).toBeCalledWith(
expect.objectContaining({ filters: ['metric.test_label', '=', 'test', 'AND', 'metric.type', '=', 'type2'] })
);
@@ -1,10 +1,11 @@
import { css } from '@emotion/css';
import debounce from 'debounce-promise';
import { startCase, uniqBy } from 'lodash';
import React, { useCallback, useEffect, useState } from 'react';
import { GrafanaTheme2, SelectableValue, TimeRange } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/experimental';
import { getSelectStyles, Select, useStyles2, useTheme2 } from '@grafana/ui';
import { getSelectStyles, Select, AsyncSelect, useStyles2, useTheme2 } from '@grafana/ui';
import CloudMonitoringDatasource from '../datasource';
import { getAlignmentPickerData, getMetricType, setMetricType } from '../functions';
@@ -159,6 +160,33 @@ export function Editor({
return services.length > 0 ? uniqBy(services, (s) => s.value) : [];
};
const filterMetrics = async (filter: string) => {
const metrics = await datasource.filterMetricsByType(projectName, service);
const filtered = metrics
.filter((m) => m.type.includes(filter.toLowerCase()))
.map((m) => ({
value: m.type,
label: m.displayName,
component: function optionComponent() {
return (
<div>
<div className={customStyle}>{m.type}</div>
<div className={selectStyles.optionDescription}>{m.description}</div>
</div>
);
},
}));
return [
{
label: 'Template Variables',
options: variableOptionGroup.options,
},
...filtered,
];
};
const debounceFilter = debounce(filterMetrics, 400);
const onMetricTypeChange = ({ value }: SelectableValue<string>) => {
const metricDescriptor = getSelectedMetricDescriptor(metricDescriptors, value!);
setMetricDescriptor(metricDescriptor);
@@ -205,6 +233,7 @@ export function Editor({
<Select
width="auto"
onChange={onServiceChange}
isLoading={services.length === 0}
value={[...services, ...variableOptionGroup.options].find((s) => s.value === service)}
options={[
{
@@ -217,21 +246,19 @@ export function Editor({
inputId={`${refId}-service`}
/>
</EditorField>
<EditorField label="Metric name" width="auto">
<Select
width="auto"
onChange={onMetricTypeChange}
value={[...metrics, ...variableOptionGroup.options].find((s) => s.value === metricType)}
options={[
{
label: 'Template Variables',
options: variableOptionGroup.options,
},
...metrics,
]}
placeholder="Select Metric"
inputId={`${refId}-select-metric`}
/>
<EditorField label="Metric name" width="auto" htmlFor={`${refId}-select-metric`}>
<span title={service === '' ? 'Select a service first' : 'Type to search metrics'}>
<AsyncSelect
width="auto"
onChange={onMetricTypeChange}
value={[...metrics, ...variableOptionGroup.options].find((s) => s.value === metricType)}
loadOptions={debounceFilter}
defaultOptions={metrics.slice(0, 100)}
placeholder="Select Metric"
inputId={`${refId}-select-metric`}
disabled={service === ''}
/>
</span>
</EditorField>
</EditorFieldGroup>
</EditorRow>
@@ -196,6 +196,17 @@ export default class CloudMonitoringDatasource extends DataSourceWithBackend<
) as Promise<MetricDescriptor[]>;
}
async filterMetricsByType(projectName: string, filter: string): Promise<MetricDescriptor[]> {
if (!projectName) {
return [];
}
return this.getResource(
`metricDescriptors/v3/projects/${this.templateSrv.replace(projectName)}/metricDescriptors`,
{ filter: `metric.type : "${filter}"` }
);
}
async getSLOServices(projectName: string): Promise<Array<SelectableValue<string>>> {
return this.getResource(`services/v3/projects/${this.templateSrv.replace(projectName)}/services?pageSize=1000`);
}