[Uptime monitor management] Handle duplicate monitor name (#124005)

This commit is contained in:
Shahzad 2022-02-01 14:43:56 +01:00 committed by GitHub
parent 322c2bdb07
commit 268c896315
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 292 additions and 13 deletions

View file

@ -10,6 +10,8 @@ import * as t from 'io-ts';
export const FetchMonitorManagementListQueryArgsType = t.partial({
page: t.number,
perPage: t.number,
search: t.string,
searchFields: t.array(t.string),
});
export type FetchMonitorManagementListQueryArgs = t.TypeOf<

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const syntheticsMonitorType = 'synthetics-monitor';

View file

@ -10,3 +10,4 @@ export * from './uptime.journey';
export * from './monitor_management.journey';
export * from './step_duration.journey';
export * from './alerts';
export * from './monitor_name.journey';

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { journey, step, expect, before, Page } from '@elastic/synthetics';
import { monitorManagementPageProvider } from '../page_objects/monitor_management';
import { byTestId } from './utils';
journey(`MonitorName`, async ({ page, params }: { page: Page; params: any }) => {
const uptime = monitorManagementPageProvider({ page, kibanaUrl: params.kibanaUrl });
before(async () => {
await uptime.waitForLoadingToFinish();
});
step('Go to monitor-management', async () => {
await uptime.navigateToMonitorManagement();
});
step('login to Kibana', async () => {
await uptime.loginToKibana();
const invalid = await page.locator(`text=Username or password is incorrect. Please try again.`);
expect(await invalid.isVisible()).toBeFalsy();
});
step(`shows error if name already exists`, async () => {
await uptime.clickAddMonitor();
await uptime.createBasicMonitorDetails({
name: 'Test monitor',
locations: ['US Central'],
apmServiceName: 'synthetics',
});
await uptime.fillByTestSubj('syntheticsUrlField', 'https://www.google.com');
await uptime.assertText({ text: 'Monitor name already exists.' });
expect(await page.isEnabled(byTestId('monitorTestNowRunBtn'))).toBeFalsy();
});
step(`form becomes valid after change`, async () => {
await uptime.createBasicMonitorDetails({
name: 'Test monitor 2',
locations: ['US Central'],
apmServiceName: 'synthetics',
});
expect(await page.isEnabled(byTestId('monitorTestNowRunBtn'))).toBeTruthy();
});
});

View file

@ -13,6 +13,7 @@ import { esArchiverLoad, esArchiverUnload } from './tasks/es_archiver';
import './journeys';
import { createApmAndObsUsersAndRoles } from '../../apm/scripts/create_apm_users_and_roles/create_apm_users_and_roles';
import { importMonitors } from './tasks/import_monitors';
const listOfJourneys = [
'uptime',
@ -56,6 +57,8 @@ async function playwrightStart(getService: any, headless = true, match?: string)
port: config.get('servers.kibana.port'),
});
await importMonitors({ kibanaUrl });
await createApmAndObsUsersAndRoles({
elasticsearch: { username: 'elastic', password: 'changeme' },
kibana: { roleSuffix: 'e2e', hostname: kibanaUrl },

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import axios from 'axios';
import path from 'path';
import FormData from 'form-data';
import * as fs from 'fs';
export const importMonitors = async ({
kibanaUrl,
username,
password,
}: {
kibanaUrl: string;
username?: string;
password?: string;
}) => {
// eslint-disable-next-line no-console
console.log('Loading sample monitors');
const form = new FormData();
const file = fs.readFileSync(path.join(__dirname, './uptime_monitor.ndjson'));
form.append('file', file, 'uptime_monitor.ndjson');
try {
axios
.request({
method: 'post',
url: kibanaUrl + '/api/saved_objects/_import?overwrite=true',
auth: { username: username ?? 'elastic', password: password ?? 'changeme' },
headers: { 'kbn-xsrf': 'true', ...form.getHeaders() },
data: form,
})
.then(({ data }) => {
if (data.successCount === 2) {
// eslint-disable-next-line no-console
console.log('Successfully imported 2 monitors');
}
});
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
}
};

View file

@ -0,0 +1,3 @@
{"attributes":{"__ui":{"is_tls_enabled":false,"is_zip_url_tls_enabled":false},"check.request.body":{"type":"text","value":""},"check.request.headers":{},"check.request.method":"GET","check.response.body.negative":[],"check.response.body.positive":[],"check.response.headers":{},"check.response.status":[],"enabled":true,"locations":[{"geo":{"lat":41.25,"lon":-95.86},"id":"us_central","label":"US Central","url":"https://us-central.synthetics.elastic.dev"}],"max_redirects":"0","name":"Test Monitor","password":"4SdnK70F8YLqril8Mh5qVveP","proxy_url":"","response.include_body":"on_error","response.include_headers":true,"schedule":{"number":"3","unit":"m"},"service.name":"","tags":[],"timeout":"16","type":"http","urls":"https://www.google.com","username":""},"coreMigrationVersion":"8.1.0","id":"832b9980-7fba-11ec-b360-25a79ce3f496","references":[],"sort":[1643319958480,20371],"type":"synthetics-monitor","updated_at":"2022-01-27T21:45:58.480Z","version":"WzExOTg3ODYsMl0="}
{"attributes":{"__ui":{"is_tls_enabled":false,"is_zip_url_tls_enabled":false},"check.request.body":{"type":"text","value":""},"check.request.headers":{},"check.request.method":"GET","check.response.body.negative":[],"check.response.body.positive":[],"check.response.headers":{},"check.response.status":[],"enabled":true,"locations":[{"geo":{"lat":41.25,"lon":-95.86},"id":"us_central","label":"US Central","url":"https://us-central.synthetics.elastic.dev"}],"max_redirects":"0","name":"Test Monito","password":"4SdnK70F8YLqril8Mh5qVveP","proxy_url":"","response.include_body":"on_error","response.include_headers":true,"schedule":{"number":"3","unit":"m"},"service.name":"","tags":[],"timeout":"16","type":"http","urls":"https://www.google.com","username":""},"coreMigrationVersion":"8.1.0","id":"b28380d0-7fba-11ec-b360-25a79ce3f496","references":[],"sort":[1643320037906,20374],"type":"synthetics-monitor","updated_at":"2022-01-27T21:47:17.906Z","version":"WzExOTg4MDAsMl0="}
{"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":2,"missingRefCount":0,"missingReferences":[]}

View file

@ -110,6 +110,7 @@ export const ActionBar = ({ monitor, isValid, onSave, onTestNow, testRun }: Acti
iconType="play"
onClick={() => onTestNow()}
disabled={!isValid}
data-test-subj={'monitorTestNowRunBtn'}
>
{testRun ? RE_RUN_TEST_LABEL : RUN_TEST_LABEL}
</EuiButton>

View file

@ -5,13 +5,15 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFormRow, EuiFieldText } from '@elastic/eui';
import { ConfigKey } from '../../../../common/runtime_types';
import { Validation } from '../../../../common/types';
import { usePolicyConfigContext } from '../../fleet_package/contexts';
import { ServiceLocations } from './locations';
import { useMonitorName } from './use_monitor_name';
interface Props {
validate: Validation;
@ -24,6 +26,14 @@ export const MonitorNameAndLocation = ({ validate }: Props) => {
[ConfigKey.LOCATIONS]: locations,
});
const [localName, setLocalName] = useState(name);
const { validName, nameAlreadyExists } = useMonitorName({ search: localName });
useEffect(() => {
setName(validName);
}, [setName, validName]);
return (
<>
<EuiFormRow
@ -34,22 +44,26 @@ export const MonitorNameAndLocation = ({ validate }: Props) => {
/>
}
fullWidth={true}
isInvalid={isNameInvalid}
isInvalid={isNameInvalid || nameAlreadyExists}
error={
<FormattedMessage
id="xpack.uptime.monitorManagement.monitorNameFieldError"
defaultMessage="Monitor name is required"
/>
nameAlreadyExists ? (
NAME_ALREADY_EXISTS
) : (
<FormattedMessage
id="xpack.uptime.monitorManagement.monitorNameFieldError"
defaultMessage="Monitor name is required"
/>
)
}
>
<EuiFieldText
autoFocus={true}
defaultValue={name}
required={true}
isInvalid={isNameInvalid}
isInvalid={isNameInvalid || nameAlreadyExists}
fullWidth={true}
name="name"
onChange={(event) => setName(event.target.value)}
onChange={(event) => setLocalName(event.target.value)}
data-test-subj="monitorManagementMonitorName"
/>
</EuiFormRow>
@ -61,3 +75,7 @@ export const MonitorNameAndLocation = ({ validate }: Props) => {
</>
);
};
const NAME_ALREADY_EXISTS = i18n.translate('xpack.uptime.monitorManagement.duplicateNameError', {
defaultMessage: 'Monitor name already exists.',
});

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { defaultCore, WrappedHelper } from '../../../lib/helper/rtl_helpers';
import { renderHook } from '@testing-library/react-hooks';
import { useMonitorName } from './use_monitor_name';
import * as hooks from '../../../hooks/use_monitor';
describe('useMonitorName', () => {
it('returns expected results', () => {
const { result } = renderHook(() => useMonitorName({}), { wrapper: WrappedHelper });
expect(result.current).toStrictEqual({ nameAlreadyExists: false, validName: '' });
expect(defaultCore.savedObjects.client.find).toHaveBeenCalledWith({
aggs: {
monitorNames: { terms: { field: 'synthetics-monitor.attributes.name', size: 10000 } },
},
perPage: 0,
type: 'synthetics-monitor',
});
});
it('returns expected results after data', async () => {
defaultCore.savedObjects.client.find = jest.fn().mockReturnValue({
aggregations: {
monitorNames: {
buckets: [{ key: 'Test' }, { key: 'Test 1' }],
},
},
});
const { result, waitForNextUpdate } = renderHook(() => useMonitorName({ search: 'Test' }), {
wrapper: WrappedHelper,
});
expect(result.current).toStrictEqual({ nameAlreadyExists: false, validName: 'Test' });
await waitForNextUpdate();
expect(result.current).toStrictEqual({ nameAlreadyExists: true, validName: '' });
});
it('returns expected results after data while editing monitor', async () => {
defaultCore.savedObjects.client.find = jest.fn().mockReturnValue({
aggregations: {
monitorNames: {
buckets: [{ key: 'Test' }, { key: 'Test 1' }],
},
},
});
jest.spyOn(hooks, 'useMonitorId').mockReturnValue('test-id');
const { result, waitForNextUpdate } = renderHook(() => useMonitorName({ search: 'Test' }), {
wrapper: WrappedHelper,
});
expect(result.current).toStrictEqual({ nameAlreadyExists: false, validName: 'Test' });
await waitForNextUpdate();
expect(result.current).toStrictEqual({ nameAlreadyExists: false, validName: 'Test' });
});
});

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useState } from 'react';
import { useFetcher } from '../../../../../observability/public';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { syntheticsMonitorType } from '../../../../common/types/saved_objects';
import { useMonitorId } from '../../../hooks';
interface AggsResponse {
monitorNames: {
buckets: Array<{
key: string;
}>;
};
}
export const useMonitorName = ({ search = '' }: { search?: string }) => {
const [values, setValues] = useState<string[]>([]);
const monitorId = useMonitorId();
const { savedObjects } = useKibana().services;
const { data } = useFetcher(() => {
const aggs = {
monitorNames: {
terms: {
field: `${syntheticsMonitorType}.attributes.name`,
size: 10000,
},
},
};
return savedObjects?.client.find<unknown, typeof aggs>({
type: syntheticsMonitorType,
perPage: 0,
aggs,
});
}, []);
useEffect(() => {
if (data?.aggregations) {
const newValues = (data.aggregations as AggsResponse)?.monitorNames.buckets.map(({ key }) =>
key.toLowerCase()
);
if (monitorId && newValues.includes(search.toLowerCase())) {
setValues(newValues.filter((val) => val !== search.toLowerCase()));
} else {
setValues(newValues);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, monitorId]);
const hasMonitor = Boolean(
search && values && values.length > 0 && values?.includes(search.trim().toLowerCase())
);
return { nameAlreadyExists: hasMonitor, validName: hasMonitor ? '' : search };
};

View file

@ -101,8 +101,8 @@ const mockAppUrls: Record<string, string> = {
};
/* default mock core */
const defaultCore = coreMock.createStart();
const mockCore: () => Partial<CoreStart> = () => {
export const defaultCore = coreMock.createStart();
export const mockCore: () => Partial<CoreStart> = () => {
const core: Partial<CoreStart & ClientPluginsStart & { storage: IStorageWrapper }> = {
...defaultCore,
application: {

View file

@ -17,7 +17,6 @@ import {
import { SyntheticsMonitorSavedObject } from '../../../common/types';
import { apiService } from './utils';
// TODO: Type the return type from runtime types
export const setMonitor = async ({
monitor,
id,
@ -32,7 +31,6 @@ export const setMonitor = async ({
}
};
// TODO, change to monitor runtime type
export const getMonitor = async ({ id }: { id: string }): Promise<SyntheticsMonitorSavedObject> => {
return await apiService.get(`${API_URLS.SYNTHETICS_MONITORS}/${id}`);
};

View file

@ -41,10 +41,12 @@ export const getAllSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({
query: schema.object({
page: schema.maybe(schema.number()),
perPage: schema.maybe(schema.number()),
search: schema.maybe(schema.string()),
}),
},
handler: async ({ request, savedObjectsClient }): Promise<any> => {
const { perPage = 50, page } = request.query;
const { perPage = 50, page, search } = request.query;
// TODO: add query/filtering params
const {
saved_objects: monitors,
@ -54,6 +56,7 @@ export const getAllSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({
type: syntheticsMonitorType,
perPage,
page,
filter: search ? `${syntheticsMonitorType}.attributes.name: ${search}` : '',
});
return {
...rest,