[Synthetics] Add private locations in synthetics settings (#146986)

Fixes https://github.com/elastic/kibana/issues/137486
This commit is contained in:
Shahzad 2022-12-07 13:45:18 +01:00 committed by GitHub
parent 405eb89f35
commit 802d8eaeb2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 2031 additions and 151 deletions

View file

@ -17,6 +17,7 @@ export const PrivateLocationCodec = t.intersection([
t.partial({
isServiceManaged: t.boolean,
isInvalid: t.boolean,
tags: t.array(t.string),
/* Empty Lat lon was accidentally saved as an empty string instead of undefined or null
* Need a migration to fix */
geo: t.interface({ lat: t.union([t.string, t.number]), lon: t.union([t.string, t.number]) }),

View file

@ -6,11 +6,11 @@
*/
export * from './data_view_permissions';
export * from './read_only_user';
export * from './synthetics';
export * from './alerts';
export * from './uptime.journey';
export * from './step_duration.journey';
export * from './read_only_user';
export * from './monitor_details.journey';
export * from './monitor_name.journey';
export * from './monitor_management.journey';

View file

@ -12,29 +12,19 @@
* 2.0.
*/
import uuid from 'uuid';
import { journey, step, expect, after, before, Page } from '@elastic/synthetics';
import { journey, step, expect, after, Page } from '@elastic/synthetics';
import { monitorManagementPageProvider } from '../page_objects/monitor_management';
journey('MonitorDetails', async ({ page, params }: { page: Page; params: any }) => {
const uptime = monitorManagementPageProvider({ page, kibanaUrl: params.kibanaUrl });
const name = `Test monitor ${uuid.v4()}`;
before(async () => {
await uptime.waitForLoadingToFinish();
});
after(async () => {
await uptime.enableMonitorManagement(false);
});
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();
await uptime.navigateToMonitorManagement(true);
});
step('create basic monitor', async () => {

View file

@ -6,7 +6,7 @@
*/
import uuid from 'uuid';
import { journey, step, expect, before, after, Page } from '@elastic/synthetics';
import { journey, step, expect, after, Page } from '@elastic/synthetics';
import { byTestId } from '@kbn/observability-plugin/e2e/utils';
import { monitorManagementPageProvider } from '../page_objects/monitor_management';
import { DataStream } from '../../common/runtime_types/monitor_management';
@ -99,25 +99,13 @@ const createMonitorJourney = ({
const uptime = monitorManagementPageProvider({ page, kibanaUrl: params.kibanaUrl });
const isRemote = process.env.SYNTHETICS_REMOTE_ENABLED;
before(async () => {
await uptime.waitForLoadingToFinish();
});
after(async () => {
await uptime.navigateToMonitorManagement();
await uptime.enableMonitorManagement(false);
});
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();
await uptime.navigateToMonitorManagement(true);
});
step(`create ${monitorType} monitor`, async () => {
@ -168,20 +156,12 @@ journey('Monitor Management breadcrumbs', async ({ page, params }: { page: Page;
apmServiceName: 'service',
};
before(async () => {
await uptime.waitForLoadingToFinish();
});
after(async () => {
await uptime.enableMonitorManagement(false);
});
step('Go to monitor-management', async () => {
await uptime.navigateToMonitorManagement();
});
step('login to Kibana', async () => {
await uptime.loginToKibana();
await uptime.navigateToMonitorManagement(true);
});
step('Check breadcrumb', async () => {
@ -243,10 +223,6 @@ journey(
}),
];
before(async () => {
await uptime.waitForLoadingToFinish();
});
after(async () => {
await uptime.navigateToMonitorManagement();
await uptime.deleteMonitors();
@ -254,15 +230,7 @@ journey(
});
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();
await uptime.navigateToMonitorManagement(true);
});
for (const monitorConfig of sortedMonitors) {

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { journey, step, expect, before, after, Page } from '@elastic/synthetics';
import { journey, step, expect, after, Page } from '@elastic/synthetics';
import { monitorManagementPageProvider } from '../page_objects/monitor_management';
journey(
@ -12,24 +12,12 @@ journey(
async ({ page, params }: { page: Page; params: any }) => {
const uptime = monitorManagementPageProvider({ page, kibanaUrl: params.kibanaUrl });
before(async () => {
await uptime.waitForLoadingToFinish();
});
after(async () => {
await uptime.enableMonitorManagement(false);
});
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();
await uptime.navigateToMonitorManagement(true);
});
step('check add monitor button', async () => {
@ -48,20 +36,8 @@ journey(
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('editor', 'changeme');
const invalid = await page.locator(
`text=Username or password is incorrect. Please try again.`
);
expect(await invalid.isVisible()).toBeFalsy();
await uptime.navigateToMonitorManagement(true);
});
step('check add monitor button', async () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import uuid from 'uuid';
import { journey, step, expect, before, Page } from '@elastic/synthetics';
import { journey, step, expect, Page } from '@elastic/synthetics';
import { byTestId } from '@kbn/observability-plugin/e2e/utils';
import { monitorManagementPageProvider } from '../page_objects/monitor_management';
@ -22,18 +22,8 @@ journey(`MonitorName`, async ({ page, params }: { page: Page; params: any }) =>
});
};
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();
await uptime.navigateToMonitorManagement(true);
});
step('create basic monitor', async () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { before, expect, journey, Page, step } from '@elastic/synthetics';
import { expect, journey, Page, step } from '@elastic/synthetics';
import { byTestId } from '@kbn/observability-plugin/e2e/utils';
import { monitorManagementPageProvider } from '../../page_objects/monitor_management';
@ -14,12 +14,8 @@ journey(
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();
await uptime.navigateToMonitorManagement(false);
});
step('login to Kibana', async () => {

View file

@ -5,15 +5,11 @@
* 2.0.
*/
import { journey, step, expect, before } from '@elastic/synthetics';
import { waitForLoadingToFinish } from '@kbn/observability-plugin/e2e/utils';
import { journey, step, expect } from '@elastic/synthetics';
import { loginPageProvider } from '../page_objects/login';
journey('StepsDuration', async ({ page, params }) => {
const login = loginPageProvider({ page });
before(async () => {
await waitForLoadingToFinish({ page });
});
const queryParams = new URLSearchParams({
dateRangeStart: '2021-11-21T22:06:06.502Z',

View file

@ -144,24 +144,15 @@ const createMonitorJourney = ({
monitorEditDetails: Array<[string, string]>;
}) => {
journey(
`Synthetics - add monitor - ${monitorName}`,
`SyntheticsAddMonitor - ${monitorName}`,
async ({ page, params }: { page: Page; params: any }) => {
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl });
step('Go to monitor management', async () => {
await syntheticsApp.navigateToMonitorManagement();
await syntheticsApp.navigateToMonitorManagement(true);
});
step('login to Kibana', async () => {
await syntheticsApp.loginToKibana();
const invalid = await page.locator(
`text=Username or password is incorrect. Please try again.`
);
expect(await invalid.isVisible()).toBeFalsy();
});
step('Ensure all montiors are deleted', async () => {
await syntheticsApp.navigateToMonitorManagement();
step('Ensure all monitors are deleted', async () => {
await syntheticsApp.waitForLoadingToFinish();
const isSuccessful = await syntheticsApp.deleteMonitors();
expect(isSuccessful).toBeTruthy();

View file

@ -12,3 +12,4 @@ export * from './management_list.journey';
export * from './overview_sorting.journey';
export * from './overview_scrolling.journey';
export * from './overview_search.journey';
export * from './private_locations.journey';

View file

@ -20,9 +20,11 @@ journey('Overview Scrolling', async ({ page, params }) => {
await enableMonitorManagedViaApi(params.kibanaUrl);
await cleanTestMonitors(params);
const allPromises = [];
for (let i = 0; i < 100; i++) {
await addTestMonitor(params.kibanaUrl, `test monitor ${i}`);
allPromises.push(addTestMonitor(params.kibanaUrl, `test monitor ${i}`));
}
await Promise.all(allPromises);
await syntheticsApp.waitForLoadingToFinish();
});

View file

@ -26,22 +26,13 @@ journey('OverviewSorting', async ({ page, params }) => {
await addTestMonitor(params.kibanaUrl, testMonitor1);
await addTestMonitor(params.kibanaUrl, testMonitor2);
await addTestMonitor(params.kibanaUrl, testMonitor3);
await syntheticsApp.waitForLoadingToFinish();
});
step('Go to monitor-management', async () => {
await syntheticsApp.navigateToOverview();
});
step('login to Kibana', async () => {
await syntheticsApp.loginToKibana();
const invalid = await page.locator(`text=Username or password is incorrect. Please try again.`);
expect(await invalid.isVisible()).toBeFalsy();
await syntheticsApp.navigateToOverview(true);
});
step('sort alphabetical asc', async () => {
await syntheticsApp.navigateToOverview();
await page.waitForSelector(`[data-test-subj="syntheticsOverviewGridItem"]`);
await page.click('[data-test-subj="syntheticsOverviewSortButton"]');
await page.click('button:has-text("Alphabetical")');

View file

@ -0,0 +1,124 @@
/*
* 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, before, after, expect } from '@elastic/synthetics';
import { byTestId } from '@kbn/observability-plugin/e2e/utils';
import { waitForLoadingToFinish } from '@kbn/ux-plugin/e2e/journeys/utils';
import {
addTestMonitor,
cleanPrivateLocations,
cleanTestMonitors,
getPrivateLocations,
} from './services/add_monitor';
import { syntheticsAppPageProvider } from '../../page_objects/synthetics_app';
journey(`PrivateLocationsSettings`, async ({ page, params }) => {
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl });
page.setDefaultTimeout(2 * 30000);
before(async () => {
await cleanPrivateLocations(params);
await cleanTestMonitors(params);
});
after(async () => {
await cleanPrivateLocations(params);
await cleanTestMonitors(params);
});
step('Go to Settings page', async () => {
await syntheticsApp.navigateToSettings(true);
});
step('go to private locations tab', async () => {
await page.click('text=Private Locations');
});
step('Click text=Private Locations', async () => {
await page.click('text=Private Locations');
expect(page.url()).toBe('http://localhost:5620/app/synthetics/settings/private-locations');
await page.click('text=No agent policies found');
await page.click('text=Create agent policy');
expect(page.url()).toBe('http://localhost:5620/app/fleet/policies?create');
await page.click('[placeholder="Choose a name"]');
await page.fill('[placeholder="Choose a name"]', 'Test fleet policy');
await page.click('text=Collect system logs and metrics');
await page.click('div[role="dialog"] button:has-text("Create agent policy")');
await page.waitForTimeout(5 * 1000);
await waitForLoadingToFinish({ page });
});
step('Go to http://localhost:5620/app/fleet/policies', async () => {
await syntheticsApp.navigateToSettings(false);
await page.click('text=Private Locations');
});
step('Click button:has-text("Create location")', async () => {
await page.click('button:has-text("Create location")');
await page.click('[aria-label="Location name"]');
await page.fill('[aria-label="Location name"]', 'Test private');
await page.press('[aria-label="Location name"]', 'Tab');
await page.click('[aria-label="Select agent policy"]');
await page.click('button[role="option"]:has-text("Test fleet policyAgents: 0")');
await page.click('.euiComboBox__inputWrap');
await page.fill('[aria-label="Tags"]', 'Basement');
await page.press('[aria-label="Tags"]', 'Enter');
await page.fill('[aria-label="Tags"]', 'Area51');
await page.press('[aria-label="Tags"]', 'Enter');
await page.click('button:has-text("Save")');
});
let locationId: string;
step('Click text=AlertingPrivate LocationsData Retention', async () => {
await page.click('text=AlertingPrivate LocationsData Retention');
await page.click('h1:has-text("Settings")');
const privateLocations = await getPrivateLocations(params);
const locations = privateLocations.attributes.locations;
expect(locations.length).toBe(1);
locationId = locations[0].id;
await addTestMonitor(params.kibanaUrl, 'test-monitor', {
locations: [locations[0]],
type: 'browser',
});
});
step('Click text=1', async () => {
await page.click('text=1');
await page.click('text=Test private');
await page.click('.euiTableCellContent__hoverItem .euiToolTipAnchor');
await page.click('button:has-text("Tags")');
await page.click('[aria-label="Tags"] >> text=Area51');
await page.click(
'main div:has-text("Private locations allow you to run monitors from your own premises. They require")'
);
await page.click('text=Test private');
await page.click('.euiTableCellContent__hoverItem .euiToolTipAnchor');
await page.locator(byTestId(`deleteLocation-${locationId}`)).isDisabled();
const text =
'This location cannot be deleted, because it has 1 monitors running. Please remove this location from your monitors before deleting this location.';
await page.locator(`text=${text}`).isVisible();
});
step('Delete location', async () => {
await cleanTestMonitors(params);
await page.click('text=Data Retention');
expect(page.url()).toBe('http://localhost:5620/app/synthetics/settings/data-retention');
await page.click('text=Private Locations');
expect(page.url()).toBe('http://localhost:5620/app/synthetics/settings/private-locations');
await page.click('[aria-label="Delete location"]');
await page.click('button:has-text("Delete location")');
await page.click('text=Create your first private location');
});
});

View file

@ -6,6 +6,10 @@
*/
import axios from 'axios';
import {
privateLocationsSavedObjectId,
privateLocationsSavedObjectName,
} from '../../../../common/saved_objects/private_locations';
export const enableMonitorManagedViaApi = async (kibanaUrl: string) => {
try {
@ -25,10 +29,10 @@ export const addTestMonitor = async (
params: Record<string, any> = { type: 'browser' }
) => {
const testData = {
locations: [{ id: 'us_central', isServiceManaged: true }],
...(params?.type !== 'browser' ? {} : data),
...(params || {}),
name,
locations: [{ id: 'us_central', isServiceManaged: true }],
};
try {
await axios.post(kibanaUrl + '/internal/uptime/service/monitors', testData, {
@ -41,6 +45,21 @@ export const addTestMonitor = async (
}
};
export const getPrivateLocations = async (params: Record<string, any>) => {
const getService = params.getService;
const server = getService('kibanaServer');
try {
return await server.savedObjects.get({
id: privateLocationsSavedObjectId,
type: privateLocationsSavedObjectName,
});
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
}
};
export const cleanTestMonitors = async (params: Record<string, any>) => {
const getService = params.getService;
const server = getService('kibanaServer');
@ -53,6 +72,21 @@ export const cleanTestMonitors = async (params: Record<string, any>) => {
}
};
export const cleanPrivateLocations = async (params: Record<string, any>) => {
const getService = params.getService;
const server = getService('kibanaServer');
try {
await server.savedObjects.clean({ types: [privateLocationsSavedObjectName] });
await server.savedObjects.clean({
types: ['ingest-agent-policies', 'ingest-package-policies'],
});
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
}
};
const data = {
type: 'browser',
form_monitor_type: 'single',

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { Page } from '@elastic/synthetics';
import { waitForLoadingToFinish } from '@kbn/ux-plugin/e2e/journeys/utils';
export function loginPageProvider({
page,
@ -19,12 +20,18 @@ export function loginPageProvider({
}) {
return {
async waitForLoadingToFinish() {
while (true) {
if ((await page.$('[data-test-subj=kbnLoadingMessage]')) === null) break;
await page.waitForTimeout(5 * 1000);
}
await waitForLoadingToFinish({ page });
},
async loginToKibana(usernameT?: 'editor' | 'viewer', passwordT?: string) {
try {
// Close Monitor Management tour added in 8.2.0
await page.addInitScript(() => {
window.localStorage.setItem('xpack.synthetics.monitorManagement.openTour', 'false');
});
} catch (e) {
// ignore
}
if (isRemote) {
await page.click('text="Log in with Elasticsearch"');
}
@ -35,13 +42,15 @@ export function loginPageProvider({
await page.click('[data-test-subj=loginSubmit]');
await this.waitForLoadingToFinish();
// Close Monitor Management tour added in 8.2.0
try {
await page.click('[data-test-subj=syntheticsManagementTourDismiss]');
while (await page.isVisible('[data-test-subj=loginSubmit]')) {
await page.waitForTimeout(1000);
}
} catch (e) {
return;
// ignore
}
await waitForLoadingToFinish({ page });
},
};
}

View file

@ -34,10 +34,13 @@ export function monitorManagementPageProvider({
}),
...utilsPageProvider({ page }),
async navigateToMonitorManagement() {
async navigateToMonitorManagement(doLogin = false) {
await page.goto(monitorManagement, {
waitUntil: 'networkidle',
});
if (doLogin) {
await this.loginToKibana();
}
await this.waitForMonitorManagementLoadingToFinish();
},

View file

@ -22,6 +22,8 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
const monitorManagement = `${basePath}/app/synthetics/monitors`;
const addMonitor = `${basePath}/app/synthetics/add-monitor`;
const overview = `${basePath}/app/synthetics`;
const settingsPage = `${basePath}/app/synthetics/settings`;
return {
...loginPageProvider({
page,
@ -31,15 +33,21 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
}),
...utilsPageProvider({ page }),
async navigateToMonitorManagement() {
async navigateToMonitorManagement(doLogin = false) {
await page.goto(monitorManagement, {
waitUntil: 'networkidle',
});
if (doLogin) {
await this.loginToKibana();
}
await this.waitForMonitorManagementLoadingToFinish();
},
async navigateToOverview() {
async navigateToOverview(doLogin = false) {
await page.goto(overview, { waitUntil: 'networkidle' });
if (doLogin) {
await this.loginToKibana();
}
},
async waitForMonitorManagementLoadingToFinish() {
@ -53,8 +61,19 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
return await this.findByText('Create monitor');
},
async navigateToSettings(doLogin = true) {
await page.goto(settingsPage, {
waitUntil: 'networkidle',
});
if (doLogin) {
await this.loginToKibana();
}
},
async navigateToAddMonitor() {
await page.goto(addMonitor);
await page.goto(addMonitor, {
waitUntil: 'networkidle',
});
},
async ensureIsOnMonitorConfigPage() {

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { expect, Page } from '@elastic/synthetics';
import { waitForLoadingToFinish } from '@kbn/ux-plugin/e2e/journeys/utils';
export function utilsPageProvider({ page }: { page: Page }) {
return {
@ -13,10 +14,7 @@ export function utilsPageProvider({ page }: { page: Page }) {
},
async waitForLoadingToFinish() {
while (true) {
if ((await page.$(this.byTestId('kbnLoadingMessage'))) === null) break;
await page.waitForTimeout(5 * 1000);
}
await waitForLoadingToFinish({ page });
},
async assertText({ text }: { text: string }) {

View file

@ -0,0 +1,46 @@
/*
* 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 React from 'react';
import { EuiText, EuiSpacer, EuiHorizontalRule } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
export const TableTitle = ({
pageIndex,
pageSize,
total,
label,
}: {
pageIndex: number;
pageSize: number;
total: number;
label: string;
}) => {
const start = pageIndex * pageSize + 1;
const end = Math.min(start + pageSize - 1, total);
return (
<>
<EuiText size="xs">
<FormattedMessage
id="xpack.synthetics.tableTitle.showing"
defaultMessage="Showing {count} of {total} {label}"
values={{
count: (
<strong>
{start}-{end}
</strong>
),
total,
label: <strong>{label}</strong>,
}}
/>
</EuiText>
<EuiSpacer size="s" />
<EuiHorizontalRule margin="none" style={{ height: 2 }} />
</>
);
};

View file

@ -245,7 +245,7 @@ function LocationSelect({
);
}
function LoadingState() {
export function LoadingState() {
return (
<EuiFlexGroup alignItems="center" justifyContent="center" style={{ height: '100%' }}>
<EuiFlexItem grow={false}>

View file

@ -0,0 +1,51 @@
/*
* 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 React from 'react';
import { EuiComboBox, EuiFormRow } from '@elastic/eui';
import { Control, Controller, FieldErrors } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import { PrivateLocation } from '../../../../../../common/runtime_types';
export function TagsField({
tagsList,
control,
errors,
}: {
tagsList: string[];
errors: FieldErrors;
control: Control<PrivateLocation, any>;
}) {
return (
<EuiFormRow fullWidth label={TAGS_LABEL}>
<Controller
name="tags"
control={control}
render={({ field }) => (
<EuiComboBox
fullWidth
aria-label={TAGS_LABEL}
placeholder={TAGS_LABEL}
isInvalid={!!errors?.tags}
selectedOptions={field.value?.map((tag) => ({ label: tag, value: tag })) ?? []}
options={tagsList.map((tag) => ({ label: tag, value: tag }))}
onCreateOption={(newTag) => {
field.onChange([...(field.value ?? []), newTag]);
}}
{...field}
onChange={(selectedTags) => {
field.onChange(selectedTags.map((tag) => tag.value));
}}
/>
)}
/>
</EuiFormRow>
);
}
export const TAGS_LABEL = i18n.translate('xpack.synthetics.monitorManagement.paramForm.tagsLabel', {
defaultMessage: 'Tags',
});

View file

@ -10,12 +10,14 @@ import { EuiPageHeaderProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SYNTHETICS_SETTINGS_ROUTE } from '../../../../../common/constants';
export type SettingsTabId = 'data-retention' | 'params' | 'alerting' | 'private-locations';
export const getSettingsPageHeader = (
history: ReturnType<typeof useHistory>,
syntheticsPath: string
): EuiPageHeaderProps => {
// Not a component, but it doesn't matter. Hooks are just functions
const match = useRouteMatch<{ tabId: string }>(SYNTHETICS_SETTINGS_ROUTE); // eslint-disable-line react-hooks/rules-of-hooks
const match = useRouteMatch<{ tabId: SettingsTabId }>(SYNTHETICS_SETTINGS_ROUTE); // eslint-disable-line react-hooks/rules-of-hooks
if (!match) {
return {};
@ -23,6 +25,10 @@ export const getSettingsPageHeader = (
const { tabId } = match.params;
const replaceTab = (newTabId: SettingsTabId) => {
return `${syntheticsPath}${SYNTHETICS_SETTINGS_ROUTE.replace(':tabId', newTabId)}`;
};
return {
pageTitle: i18n.translate('xpack.synthetics.settingsRoute.pageHeaderTitle', {
defaultMessage: 'Settings',
@ -33,15 +39,22 @@ export const getSettingsPageHeader = (
label: i18n.translate('xpack.synthetics.settingsTabs.alerting', {
defaultMessage: 'Alerting',
}),
isSelected: tabId === 'alerting' || !tabId,
href: `${syntheticsPath}${SYNTHETICS_SETTINGS_ROUTE.replace(':tabId', 'alerting')}`,
isSelected: tabId === 'alerting',
href: replaceTab('alerting'),
},
{
label: i18n.translate('xpack.synthetics.settingsTabs.privateLocations', {
defaultMessage: 'Private Locations',
}),
isSelected: tabId === 'private-locations',
href: replaceTab('private-locations'),
},
{
label: i18n.translate('xpack.synthetics.settingsTabs.dataRetention', {
defaultMessage: 'Data retention',
defaultMessage: 'Data Retention',
}),
isSelected: tabId === 'data-retention',
href: `${syntheticsPath}${SYNTHETICS_SETTINGS_ROUTE.replace(':tabId', 'data-retention')}`,
href: replaceTab('data-retention'),
},
],
};

View file

@ -0,0 +1,117 @@
/*
* 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 React from 'react';
import { FormProvider } from 'react-hook-form';
import {
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiTitle,
EuiFlyout,
EuiButton,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { usePrivateLocationPermissions } from './hooks/use_private_location_permission';
import { useFormWrapped } from '../../../../../hooks/use_form_wrapped';
import { PrivateLocation } from '../../../../../../common/runtime_types';
import { LocationForm } from './location_form';
import { NEED_FLEET_READ_AGENT_POLICIES_PERMISSION, NEED_PERMISSIONS } from './translations';
export const AddLocationFlyout = ({
onSubmit,
setIsOpen,
privateLocations,
isLoading,
}: {
isLoading: boolean;
onSubmit: (val: PrivateLocation) => void;
setIsOpen: (val: boolean) => void;
privateLocations: PrivateLocation[];
}) => {
const form = useFormWrapped({
mode: 'onSubmit',
reValidateMode: 'onChange',
shouldFocusError: true,
defaultValues: {
label: '',
agentPolicyId: '',
id: '',
geo: {
lat: 0,
lon: 0,
},
concurrentMonitors: 1,
},
});
const { handleSubmit } = form;
const { canReadAgentPolicies } = usePrivateLocationPermissions();
const closeFlyout = () => {
setIsOpen(false);
};
return (
<FormProvider {...form}>
<EuiFlyout onClose={closeFlyout} style={{ width: 540 }}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{ADD_PRIVATE_LOCATION}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{!canReadAgentPolicies && (
<EuiCallOut title={NEED_PERMISSIONS} color="warning" iconType="help">
<p>{NEED_FLEET_READ_AGENT_POLICIES_PERMISSION}</p>
</EuiCallOut>
)}
<LocationForm privateLocations={privateLocations} />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={closeFlyout}
flush="left"
isLoading={isLoading}
>
{CANCEL_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill onClick={handleSubmit(onSubmit)} isLoading={isLoading}>
{SAVE_LABEL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</FormProvider>
);
};
const ADD_PRIVATE_LOCATION = i18n.translate(
'xpack.synthetics.monitorManagement.createPrivateLocations',
{
defaultMessage: 'Create private location',
}
);
const CANCEL_LABEL = i18n.translate('xpack.synthetics.monitorManagement.cancelLabel', {
defaultMessage: 'Cancel',
});
const SAVE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.saveLabel', {
defaultMessage: 'Save',
});

View file

@ -0,0 +1,57 @@
/*
* 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 React from 'react';
import { EuiLink, EuiButton, EuiEmptyPrompt, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useSyntheticsSettingsContext } from '../../../contexts';
import { LEARN_MORE, READ_DOCS } from './empty_locations';
export const AgentPolicyNeeded = () => {
const { basePath } = useSyntheticsSettingsContext();
return (
<EuiEmptyPrompt
hasBorder
title={<h2>{AGENT_POLICY_NEEDED}</h2>}
body={<p>{ADD_AGENT_POLICY_DESCRIPTION}</p>}
actions={
<EuiButton fill href={`${basePath}/app/fleet/policies?create`} color="primary">
{CREATE_AGENT_POLICY}
</EuiButton>
}
footer={
<>
<EuiTitle size="xxs">
<h3>{LEARN_MORE}</h3>
</EuiTitle>
<EuiLink
target="_blank"
href="https://www.elastic.co/guide/en/observability/current/uptime-set-up-choose-agent.html#private-locations"
>
{READ_DOCS}
</EuiLink>
</>
}
/>
);
};
const CREATE_AGENT_POLICY = i18n.translate('xpack.synthetics.monitorManagement.createAgentPolicy', {
defaultMessage: 'Create agent policy',
});
const AGENT_POLICY_NEEDED = i18n.translate('xpack.synthetics.monitorManagement.agentPolicyNeeded', {
defaultMessage: 'No agent policies found',
});
const ADD_AGENT_POLICY_DESCRIPTION = i18n.translate(
'xpack.synthetics.monitorManagement.addAgentPolicyDesc',
{
defaultMessage:
'Private locations require an Agent policy. In order to add a private location, first you must create an Agent policy in Fleet.',
}
);

View file

@ -0,0 +1,95 @@
/*
* 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 React, { useState } from 'react';
import { EuiButtonIcon, EuiConfirmModal, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useSyntheticsSettingsContext } from '../../../contexts';
export const DeleteLocation = ({
loading,
id,
label,
locationMonitors,
onDelete,
}: {
id: string;
label: string;
loading?: boolean;
onDelete: (id: string) => void;
locationMonitors: Array<{ id: string; count: number }>;
}) => {
const monCount = locationMonitors?.find((l) => l.id === id)?.count ?? 0;
const canDelete = monCount === 0;
const { canSave } = useSyntheticsSettingsContext();
const [isModalOpen, setIsModalOpen] = useState(false);
const deleteModal = (
<EuiConfirmModal
title={i18n.translate('xpack.synthetics.monitorManagement.deleteLocationName', {
defaultMessage: 'Delete "{location}"',
values: { location: label },
})}
onCancel={() => setIsModalOpen(false)}
onConfirm={() => onDelete(id)}
cancelButtonText={CANCEL_LABEL}
confirmButtonText={CONFIRM_LABEL}
buttonColor="danger"
defaultFocusedButton="confirm"
isLoading={loading}
>
<p>{ARE_YOU_SURE_LABEL}</p>
</EuiConfirmModal>
);
return (
<>
{isModalOpen && deleteModal}
<EuiToolTip
content={
canDelete
? DELETE_LABEL
: i18n.translate('xpack.synthetics.monitorManagement.cannotDelete.description', {
defaultMessage: `This location cannot be deleted, because it has {monCount, number} {monCount, plural,one {monitor} other {monitors}} running.
Please remove this location from your monitors before deleting this location.`,
values: { monCount },
})
}
>
<EuiButtonIcon
data-test-subj={`deleteLocation-${id}`}
isLoading={loading}
iconType="trash"
color="danger"
aria-label={DELETE_LABEL}
onClick={() => {
setIsModalOpen(true);
}}
isDisabled={!canDelete || !canSave}
/>
</EuiToolTip>
</>
);
};
const DELETE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.deleteLocation', {
defaultMessage: 'Delete location',
});
const CONFIRM_LABEL = i18n.translate('xpack.synthetics.monitorManagement.deleteLocationLabel', {
defaultMessage: 'Delete location',
});
const CANCEL_LABEL = i18n.translate('xpack.synthetics.monitorManagement.cancelLabel', {
defaultMessage: 'Cancel',
});
const ARE_YOU_SURE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.areYouSure', {
defaultMessage: 'Are you sure you want to delete this location?',
});

View file

@ -0,0 +1,97 @@
/*
* 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 React from 'react';
import { EuiEmptyPrompt, EuiButton, EuiLink, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDispatch } from 'react-redux';
import { setAddingNewPrivateLocation, setManageFlyoutOpen } from '../../../state/private_locations';
export const EmptyLocations = ({
inFlyout = true,
setIsAddingNew,
disabled,
}: {
inFlyout?: boolean;
disabled?: boolean;
setIsAddingNew?: (val: boolean) => void;
}) => {
const dispatch = useDispatch();
return (
<EuiEmptyPrompt
hasBorder
title={<h2>{ADD_FIRST_LOCATION}</h2>}
titleSize="s"
body={
<EuiText size="s">
{!inFlyout ? FIRST_MONITOR : ''} {START_ADDING_LOCATIONS_DESCRIPTION}
</EuiText>
}
actions={
<EuiButton
iconType="plusInCircle"
disabled={disabled}
color="primary"
fill
onClick={() => {
setIsAddingNew?.(true);
dispatch(setManageFlyoutOpen(true));
dispatch(setAddingNewPrivateLocation(true));
}}
>
{ADD_LOCATION}
</EuiButton>
}
footer={
<EuiText size="s">
{LEARN_MORE} <PrivateLocationDocsLink />
</EuiText>
}
/>
);
};
export const PrivateLocationDocsLink = ({ label }: { label?: string }) => (
<EuiLink
href="https://www.elastic.co/guide/en/observability/current/synthetics-private-location.html"
target="_blank"
>
{label ?? READ_DOCS}
</EuiLink>
);
const FIRST_MONITOR = i18n.translate('xpack.synthetics.monitorManagement.firstLocationMonitor', {
defaultMessage: 'In order to create a monitor, you will need to add a location first.',
});
const ADD_FIRST_LOCATION = i18n.translate(
'xpack.synthetics.monitorManagement.createFirstLocation',
{
defaultMessage: 'Create your first private location',
}
);
export const START_ADDING_LOCATIONS_DESCRIPTION = i18n.translate(
'xpack.synthetics.monitorManagement.startAddingLocationsDescription',
{
defaultMessage:
'Private locations allow you to run monitors from your own premises. They require an Elastic agent and Agent policy which you can control and maintain via Fleet.',
}
);
const ADD_LOCATION = i18n.translate('xpack.synthetics.monitorManagement.createLocation', {
defaultMessage: 'Create location',
});
export const READ_DOCS = i18n.translate('xpack.synthetics.monitorManagement.readDocs', {
defaultMessage: 'read the docs',
});
export const LEARN_MORE = i18n.translate('xpack.synthetics.monitorManagement.learnMore', {
defaultMessage: 'For more information,',
});

View file

@ -0,0 +1,63 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { defaultCore, WrappedHelper } from '../../../../utils/testing';
import { useLocationMonitors } from './use_location_monitors';
describe('useLocationMonitors', () => {
it('returns expected results', () => {
const { result } = renderHook(() => useLocationMonitors(), { wrapper: WrappedHelper });
expect(result.current).toStrictEqual({ locationMonitors: [], loading: true });
expect(defaultCore.savedObjects.client.find).toHaveBeenCalledWith({
aggs: {
locations: {
terms: { field: 'synthetics-monitor.attributes.locations.id', size: 10000 },
},
},
perPage: 0,
type: 'synthetics-monitor',
});
});
it('returns expected results after data', async () => {
defaultCore.savedObjects.client.find = jest.fn().mockReturnValue({
aggregations: {
locations: {
buckets: [
{ key: 'Test', doc_count: 5 },
{ key: 'Test 1', doc_count: 0 },
],
},
},
});
const { result, waitForNextUpdate } = renderHook(() => useLocationMonitors(), {
wrapper: WrappedHelper,
});
expect(result.current).toStrictEqual({ locationMonitors: [], loading: true });
await waitForNextUpdate();
expect(result.current).toStrictEqual({
loading: false,
locationMonitors: [
{
id: 'Test',
count: 5,
},
{
id: 'Test 1',
count: 0,
},
],
});
});
});

View file

@ -0,0 +1,54 @@
/*
* 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 { useMemo } from 'react';
import { useFetcher } from '@kbn/observability-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
monitorAttributes,
syntheticsMonitorType,
} from '../../../../../../../common/types/saved_objects';
interface AggsResponse {
locations: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
}
export const useLocationMonitors = () => {
const { savedObjects } = useKibana().services;
const { data, loading } = useFetcher(() => {
const aggs = {
locations: {
terms: {
field: `${monitorAttributes}.locations.id`,
size: 10000,
},
},
};
return savedObjects?.client.find<unknown, typeof aggs>({
type: syntheticsMonitorType,
perPage: 0,
aggs,
});
}, []);
return useMemo(() => {
if (data?.aggregations) {
const newValues = (data.aggregations as AggsResponse)?.locations.buckets.map(
({ key, doc_count: count }) => ({ id: key, count })
);
return { locationMonitors: newValues, loading };
}
return { locationMonitors: [], loading };
}, [data, loading]);
};

View file

@ -0,0 +1,147 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { defaultCore, WrappedHelper } from '../../../../utils/testing';
import { useLocationsAPI } from './use_locations_api';
describe('useLocationsAPI', () => {
it('returns expected results', () => {
const { result } = renderHook(() => useLocationsAPI(), {
wrapper: WrappedHelper,
});
expect(result.current).toEqual(
expect.objectContaining({
loading: true,
privateLocations: [],
})
);
expect(defaultCore.savedObjects.client.get).toHaveBeenCalledWith(
'synthetics-privates-locations',
'synthetics-privates-locations-singleton'
);
});
defaultCore.savedObjects.client.get = jest.fn().mockReturnValue({
attributes: {
locations: [
{
id: 'Test',
agentPolicyId: 'testPolicy',
},
],
},
});
it('returns expected results after data', async () => {
const { result, waitForNextUpdate } = renderHook(() => useLocationsAPI(), {
wrapper: WrappedHelper,
});
expect(result.current).toEqual(
expect.objectContaining({
loading: true,
privateLocations: [],
})
);
await waitForNextUpdate();
expect(result.current).toEqual(
expect.objectContaining({
loading: false,
privateLocations: [
{
id: 'Test',
agentPolicyId: 'testPolicy',
},
],
})
);
});
it('adds location on submit', async () => {
const { result, waitForNextUpdate } = renderHook(() => useLocationsAPI(), {
wrapper: WrappedHelper,
});
await waitForNextUpdate();
result.current.onSubmit({
id: 'new',
agentPolicyId: 'newPolicy',
label: 'new',
concurrentMonitors: 1,
geo: {
lat: 0,
lon: 0,
},
});
await waitForNextUpdate();
expect(defaultCore.savedObjects.client.create).toHaveBeenCalledWith(
'synthetics-privates-locations',
{
locations: [
{ id: 'Test', agentPolicyId: 'testPolicy' },
{
concurrentMonitors: 1,
id: 'newPolicy',
geo: {
lat: 0,
lon: 0,
},
label: 'new',
agentPolicyId: 'newPolicy',
},
],
},
{ id: 'synthetics-privates-locations-singleton', overwrite: true }
);
});
it('deletes location on delete', async () => {
defaultCore.savedObjects.client.get = jest.fn().mockReturnValue({
attributes: {
locations: [
{
id: 'Test',
agentPolicyId: 'testPolicy',
},
{
id: 'Test1',
agentPolicyId: 'testPolicy1',
},
],
},
});
const { result, waitForNextUpdate } = renderHook(() => useLocationsAPI(), {
wrapper: WrappedHelper,
});
await waitForNextUpdate();
result.current.onDelete('Test');
await waitForNextUpdate();
expect(defaultCore.savedObjects.client.create).toHaveBeenLastCalledWith(
'synthetics-privates-locations',
{
locations: [
{
id: 'Test1',
agentPolicyId: 'testPolicy1',
},
],
},
{ id: 'synthetics-privates-locations-singleton', overwrite: true }
);
});
});

View file

@ -0,0 +1,77 @@
/*
* 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 { useFetcher } from '@kbn/observability-plugin/public';
import { useState } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useDispatch } from 'react-redux';
import { setAddingNewPrivateLocation } from '../../../../state/private_locations';
import {
getSyntheticsPrivateLocations,
setSyntheticsPrivateLocations,
} from '../../../../state/private_locations/api';
import { PrivateLocation } from '../../../../../../../common/runtime_types';
export const useLocationsAPI = () => {
const [formData, setFormData] = useState<PrivateLocation>();
const [deleteId, setDeleteId] = useState<string>();
const [privateLocations, setPrivateLocations] = useState<PrivateLocation[]>([]);
const { savedObjects } = useKibana().services;
const dispatch = useDispatch();
const setIsAddingNew = (val: boolean) => dispatch(setAddingNewPrivateLocation(val));
const { loading: fetchLoading } = useFetcher(async () => {
const result = await getSyntheticsPrivateLocations(savedObjects?.client!);
setPrivateLocations(result);
return result;
}, []);
const { loading: saveLoading } = useFetcher(async () => {
if (privateLocations && formData) {
const existingLocations = privateLocations.filter((loc) => loc.id !== formData.agentPolicyId);
const result = await setSyntheticsPrivateLocations(savedObjects?.client!, {
locations: [...(existingLocations ?? []), { ...formData, id: formData.agentPolicyId }],
});
setPrivateLocations(result.locations);
setFormData(undefined);
setIsAddingNew(false);
return result;
}
}, [formData, privateLocations]);
const onSubmit = (data: PrivateLocation) => {
setFormData(data);
};
const onDelete = (id: string) => {
setDeleteId(id);
};
const { loading: deleteLoading } = useFetcher(async () => {
if (deleteId) {
const result = await setSyntheticsPrivateLocations(savedObjects?.client!, {
locations: (privateLocations ?? []).filter((loc) => loc.id !== deleteId),
});
setPrivateLocations(result.locations);
setDeleteId(undefined);
return result;
}
}, [deleteId, privateLocations]);
return {
formData,
onSubmit,
onDelete,
deleteLoading: Boolean(deleteLoading),
loading: Boolean(fetchLoading || saveLoading),
privateLocations,
};
};

View file

@ -0,0 +1,25 @@
/*
* 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 { useKibana } from '@kbn/kibana-react-plugin/public';
import { BrowserFields, ConfigKey } from '../../../../../../../common/runtime_types';
import { ClientPluginsStart } from '../../../../../../plugin';
export function usePrivateLocationPermissions(monitor?: BrowserFields) {
const { fleet } = useKibana<ClientPluginsStart>().services;
const canSaveIntegrations: boolean = Boolean(fleet?.authz.integrations.writeIntegrationPolicies);
const canReadAgentPolicies = Boolean(fleet?.authz.fleet.readAgentPolicies);
const locations = (monitor as BrowserFields)?.[ConfigKey.LOCATIONS];
const hasPrivateLocation = locations?.some((location) => !location.isServiceManaged);
const canUpdatePrivateMonitor = !(hasPrivateLocation && !canSaveIntegrations);
return { canUpdatePrivateMonitor, canReadAgentPolicies };
}

View file

@ -0,0 +1,123 @@
/*
* 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 React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFieldText,
EuiForm,
EuiFormRow,
EuiSpacer,
EuiCallOut,
EuiCode,
EuiLink,
} from '@elastic/eui';
import { useSelector } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { useFormContext, useFormState } from 'react-hook-form';
import { TagsField } from '../components/tags_field';
import { PrivateLocation } from '../../../../../../common/runtime_types';
import { AgentPolicyNeeded } from './agent_policy_needed';
import { PolicyHostsField } from './policy_hosts';
import { selectAgentPolicies } from '../../../state/private_locations';
export const LocationForm = ({
privateLocations,
}: {
onDiscard?: () => void;
privateLocations: PrivateLocation[];
}) => {
const { data } = useSelector(selectAgentPolicies);
const { control, register } = useFormContext<PrivateLocation>();
const { errors } = useFormState();
const tagsList = privateLocations.reduce((acc, item) => {
const tags = item.tags || [];
return [...acc, ...tags];
}, [] as string[]);
return (
<>
{data?.items.length === 0 && <AgentPolicyNeeded />}
<EuiForm component="form" noValidate>
<EuiFormRow
fullWidth
label={LOCATION_NAME_LABEL}
isInvalid={Boolean(errors?.label)}
error={errors?.label?.message}
>
<EuiFieldText
fullWidth
aria-label={LOCATION_NAME_LABEL}
{...register('label', {
required: {
value: true,
message: NAME_REQUIRED,
},
validate: (val: string) => {
return privateLocations.some((loc) => loc.label === val)
? NAME_ALREADY_EXISTS
: undefined;
},
})}
/>
</EuiFormRow>
<EuiSpacer />
<PolicyHostsField errors={errors} control={control} privateLocations={privateLocations} />
<EuiSpacer />
<TagsField tagsList={tagsList} control={control} errors={errors} />
<EuiSpacer />
<EuiCallOut title={AGENT_CALLOUT_TITLE} size="s" style={{ textAlign: 'left' }}>
<p>
{
<FormattedMessage
id="xpack.synthetics.monitorManagement.agentCallout.content"
defaultMessage='If you intend to run "Browser" monitors on this private location, please ensure you are using the {code} Docker container, which contains the dependencies to run these monitors. For more information, {link}.'
values={{
code: <EuiCode>elastic-agent-complete</EuiCode>,
link: (
<EuiLink
target="_blank"
href="https://www.elastic.co/guide/en/observability/current/uptime-set-up-choose-agent.html#private-locations"
external
>
<FormattedMessage
id="xpack.synthetics.monitorManagement.agentCallout.link"
defaultMessage="read the docs"
/>
</EuiLink>
),
}}
/>
}
</p>
</EuiCallOut>
</EuiForm>
</>
);
};
export const AGENT_CALLOUT_TITLE = i18n.translate(
'xpack.synthetics.monitorManagement.agentCallout.title',
{
defaultMessage: 'Requirement',
}
);
export const LOCATION_NAME_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.locationName',
{
defaultMessage: 'Location name',
}
);
const NAME_ALREADY_EXISTS = i18n.translate('xpack.synthetics.monitorManagement.alreadyExists', {
defaultMessage: 'Location name already exists.',
});
const NAME_REQUIRED = i18n.translate('xpack.synthetics.monitorManagement.nameRequired', {
defaultMessage: 'Location name is required',
});

View file

@ -0,0 +1,228 @@
/*
* 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 React, { useState } from 'react';
import {
EuiBadge,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDispatch } from 'react-redux';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { Criteria } from '@elastic/eui/src/components/basic_table/basic_table';
import { ViewLocationMonitors } from './view_location_monitors';
import { TableTitle } from '../../common/components/table_title';
import { TAGS_LABEL } from '../components/tags_field';
import { useSyntheticsSettingsContext } from '../../../contexts';
import { setAddingNewPrivateLocation } from '../../../state/private_locations';
import { PrivateLocationDocsLink, START_ADDING_LOCATIONS_DESCRIPTION } from './empty_locations';
import { PrivateLocation } from '../../../../../../common/runtime_types';
import { DeleteLocation } from './delete_location';
import { useLocationMonitors } from './hooks/use_location_monitors';
import { PolicyName } from './policy_name';
import { LOCATION_NAME_LABEL } from './location_form';
import { ClientPluginsStart } from '../../../../../plugin';
interface ListItem extends PrivateLocation {
monitors: number;
}
export const PrivateLocationsTable = ({
deleteLoading,
onDelete,
privateLocations,
}: {
deleteLoading: boolean;
onDelete: (id: string) => void;
privateLocations: PrivateLocation[];
}) => {
const dispatch = useDispatch();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const { locationMonitors, loading } = useLocationMonitors();
const { canSave } = useSyntheticsSettingsContext();
const tagsList = privateLocations.reduce((acc, item) => {
const tags = item.tags || [];
return new Set([...acc, ...tags]);
}, new Set<string>());
const columns = [
{
field: 'label',
name: LOCATION_NAME_LABEL,
},
{
field: 'monitors',
name: MONITORS,
render: (monitors: number, item: ListItem) => (
<ViewLocationMonitors count={monitors} locationName={item.label} />
),
},
{
field: 'agentPolicyId',
name: AGENT_POLICY_LABEL,
render: (agentPolicyId: string) => <PolicyName agentPolicyId={agentPolicyId} />,
},
{
name: TAGS_LABEL,
field: 'tags',
sortable: true,
render: (val: string[]) => {
const tags = val ?? [];
if (tags.length === 0) {
return <EuiText>--</EuiText>;
}
return (
<EuiFlexGroup gutterSize="xs" wrap>
{tags.map((tag) => (
<EuiFlexItem grow={false} key={tag}>
<EuiBadge>{tag}</EuiBadge>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
},
},
{
name: ACTIONS_LABEL,
actions: [
{
name: DELETE_LOCATION,
description: DELETE_LOCATION,
render: (item: ListItem) => (
<DeleteLocation
id={item.id}
label={item.label}
locationMonitors={locationMonitors}
onDelete={onDelete}
loading={deleteLoading}
/>
),
isPrimary: true,
'data-test-subj': 'action-delete',
},
],
},
];
const items = privateLocations.map((location) => ({
...location,
monitors: locationMonitors?.find((l) => l.id === location.id)?.count ?? 0,
}));
const setIsAddingNew = (val: boolean) => dispatch(setAddingNewPrivateLocation(val));
const { fleet } = useKibana<ClientPluginsStart>().services;
const hasFleetPermissions = Boolean(fleet?.authz.fleet.readAgentPolicies);
const renderToolRight = () => {
return [
<EuiButton
fill
data-test-subj={'addPrivateLocationButton'}
isLoading={loading}
disabled={!hasFleetPermissions || !canSave}
onClick={() => setIsAddingNew(true)}
iconType="plusInCircle"
>
{ADD_LABEL}
</EuiButton>,
];
};
return (
<div>
<EuiText>
{START_ADDING_LOCATIONS_DESCRIPTION} <PrivateLocationDocsLink label={LEARN_MORE} />
</EuiText>
<EuiSpacer size="m" />
<EuiInMemoryTable<ListItem>
itemId={'id'}
tableLayout="auto"
tableCaption={PRIVATE_LOCATIONS}
items={items}
columns={columns}
childrenBetween={
<TableTitle
total={items.length}
label={PRIVATE_LOCATIONS}
pageIndex={pageIndex}
pageSize={pageSize}
/>
}
pagination={{
pageSize,
pageIndex,
}}
onTableChange={({ page }: Criteria<any>) => {
setPageIndex(page?.index ?? 0);
setPageSize(page?.size ?? 10);
}}
search={{
toolsRight: renderToolRight(),
box: {
incremental: true,
},
filters: [
{
type: 'field_value_selection',
field: 'tags',
name: TAGS_LABEL,
multiSelect: true,
options: [...tagsList].map((tag) => ({
value: tag,
name: tag,
view: tag,
})),
},
],
}}
/>
</div>
);
};
const PRIVATE_LOCATIONS = i18n.translate('xpack.synthetics.monitorManagement.privateLocations', {
defaultMessage: 'Private locations',
});
const ACTIONS_LABEL = i18n.translate('xpack.synthetics.monitorManagement.actions', {
defaultMessage: 'Actions',
});
export const MONITORS = i18n.translate('xpack.synthetics.monitorManagement.monitors', {
defaultMessage: 'Monitors',
});
export const AGENT_POLICY_LABEL = i18n.translate('xpack.synthetics.monitorManagement.agentPolicy', {
defaultMessage: 'Agent Policy',
});
const DELETE_LOCATION = i18n.translate(
'xpack.synthetics.settingsRoute.privateLocations.deleteLabel',
{
defaultMessage: 'Delete private location',
}
);
const ADD_LABEL = i18n.translate('xpack.synthetics.monitorManagement.createLocation', {
defaultMessage: 'Create location',
});
export const LEARN_MORE = i18n.translate('xpack.synthetics.privateLocations.learnMore.label', {
defaultMessage: 'Learn more.',
});

View file

@ -0,0 +1,31 @@
/*
* 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 React, { FC } from 'react';
import { useSelector } from 'react-redux';
import { PrivateLocation } from '../../../../../../common/runtime_types';
import { AgentPolicyNeeded } from './agent_policy_needed';
import { EmptyLocations } from './empty_locations';
import { selectAgentPolicies } from '../../../state/private_locations';
export const ManageEmptyState: FC<{
privateLocations: PrivateLocation[];
hasFleetPermissions: boolean;
setIsAddingNew: (val: boolean) => void;
}> = ({ children, privateLocations, setIsAddingNew, hasFleetPermissions }) => {
const { data: agentPolicies } = useSelector(selectAgentPolicies);
if (agentPolicies?.total === 0) {
return <AgentPolicyNeeded />;
}
if (privateLocations.length === 0) {
return <EmptyLocations setIsAddingNew={setIsAddingNew} disabled={!hasFleetPermissions} />;
}
return <>{children}</>;
};

View file

@ -0,0 +1,80 @@
/*
* 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 React, { useEffect } from 'react';
import { EuiCallOut } from '@elastic/eui';
import { useDispatch, useSelector } from 'react-redux';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { LoadingState } from '../../monitors_page/overview/overview/monitor_detail_flyout';
import { PrivateLocationsTable } from './locations_table';
import { ClientPluginsStart } from '../../../../../plugin';
import { ManageEmptyState } from './manage_empty_state';
import { AddLocationFlyout } from './add_location_flyout';
import { useLocationsAPI } from './hooks/use_locations_api';
import {
getAgentPoliciesAction,
selectAddingNewPrivateLocation,
setAddingNewPrivateLocation,
} from '../../../state/private_locations';
import { PrivateLocation } from '../../../../../../common/runtime_types';
import { getServiceLocations } from '../../../state';
import { NEED_FLEET_READ_AGENT_POLICIES_PERMISSION, NEED_PERMISSIONS } from './translations';
export const ManagePrivateLocations = () => {
const dispatch = useDispatch();
const isAddingNew = useSelector(selectAddingNewPrivateLocation);
const setIsAddingNew = (val: boolean) => dispatch(setAddingNewPrivateLocation(val));
const { onSubmit, loading, privateLocations, onDelete, deleteLoading } = useLocationsAPI();
const { fleet } = useKibana<ClientPluginsStart>().services;
const hasFleetPermissions = Boolean(fleet?.authz.fleet.readAgentPolicies);
useEffect(() => {
dispatch(getAgentPoliciesAction.get());
dispatch(getServiceLocations());
}, [dispatch]);
const handleSubmit = (formData: PrivateLocation) => {
onSubmit(formData);
};
return (
<>
{loading ? (
<LoadingState />
) : (
<ManageEmptyState
privateLocations={privateLocations}
setIsAddingNew={setIsAddingNew}
hasFleetPermissions={hasFleetPermissions}
>
<PrivateLocationsTable
privateLocations={privateLocations}
onDelete={onDelete}
deleteLoading={deleteLoading}
/>
</ManageEmptyState>
)}
{!hasFleetPermissions && (
<EuiCallOut title={NEED_PERMISSIONS} color="warning" iconType="help">
<p>{NEED_FLEET_READ_AGENT_POLICIES_PERMISSION}</p>
</EuiCallOut>
)}
{isAddingNew ? (
<AddLocationFlyout
setIsOpen={setIsAddingNew}
onSubmit={handleSubmit}
privateLocations={privateLocations}
isLoading={loading}
/>
) : null}
</>
);
};

View file

@ -0,0 +1,138 @@
/*
* 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 React from 'react';
import { Controller, FieldErrors, Control } from 'react-hook-form';
import { useSelector } from 'react-redux';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiHealth,
EuiSuperSelect,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { PrivateLocation } from '../../../../../../common/runtime_types';
import { selectAgentPolicies } from '../../../state/private_locations';
export const PolicyHostsField = ({
errors,
control,
privateLocations,
}: {
errors: FieldErrors;
control: Control<PrivateLocation, any>;
privateLocations: PrivateLocation[];
}) => {
const { data } = useSelector(selectAgentPolicies);
const policyHostsOptions = data?.items.map((item) => {
const hasLocation = privateLocations.find((location) => location.agentPolicyId === item.id);
return {
disabled: Boolean(hasLocation),
value: item.id,
inputDisplay: (
<EuiHealth
color={item.status === 'active' ? 'success' : 'warning'}
style={{ lineHeight: 'inherit' }}
>
{item.name}
</EuiHealth>
),
'data-test-subj': item.name,
dropdownDisplay: (
<EuiToolTip
content={
hasLocation?.label
? i18n.translate('xpack.synthetics.monitorManagement.anotherPrivateLocation', {
defaultMessage:
'This agent policy is already attached to location: {locationName}.',
values: { locationName: hasLocation?.label },
})
: undefined
}
>
<>
<EuiHealth
color={item.status === 'active' ? 'success' : 'warning'}
style={{ lineHeight: 'inherit' }}
>
<strong>{item.name}</strong>
</EuiHealth>
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s" color="subdued" className="eui-textNoWrap">
<p>
{AGENTS_LABEL} {item.agents}
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s" color="subdued">
<p>{item.description}</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</>
</EuiToolTip>
),
};
});
return (
<EuiFormRow
fullWidth
label={POLICY_HOST_LABEL}
helpText={!errors?.agentPolicyId ? SELECT_POLICY_HOSTS_HELP_TEXT : undefined}
isInvalid={!!errors?.agentPolicyId}
error={SELECT_POLICY_HOSTS}
>
<Controller
name="agentPolicyId"
control={control}
rules={{ required: true }}
render={({ field }) => (
<EuiSuperSelect
fullWidth
aria-label={SELECT_POLICY_HOSTS}
placeholder={SELECT_POLICY_HOSTS}
valueOfSelected={field.value}
itemLayoutAlign="top"
popoverProps={{ repositionOnScroll: true }}
hasDividers
isInvalid={!!errors?.agentPolicyId}
options={policyHostsOptions ?? []}
{...field}
/>
)}
/>
</EuiFormRow>
);
};
const AGENTS_LABEL = i18n.translate('xpack.synthetics.monitorManagement.agentsLabel', {
defaultMessage: 'Agents: ',
});
const SELECT_POLICY_HOSTS = i18n.translate('xpack.synthetics.monitorManagement.selectPolicyHost', {
defaultMessage: 'Select agent policy',
});
const SELECT_POLICY_HOSTS_HELP_TEXT = i18n.translate(
'xpack.synthetics.monitorManagement.selectPolicyHost.helpText',
{
defaultMessage: 'We recommend using a single Elastic agent per agent policy.',
}
);
const POLICY_HOST_LABEL = i18n.translate('xpack.synthetics.monitorManagement.policyHost', {
defaultMessage: 'Agent policy',
});

View file

@ -0,0 +1,52 @@
/*
* 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 React from 'react';
import { EuiLink, EuiLoadingSpinner, EuiText, EuiTextColor } from '@elastic/eui';
import { useSelector } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { useSyntheticsSettingsContext } from '../../../contexts';
import { usePrivateLocationPermissions } from './hooks/use_private_location_permission';
import { selectAgentPolicies } from '../../../state/private_locations';
export const PolicyName = ({ agentPolicyId }: { agentPolicyId: string }) => {
const { canReadAgentPolicies } = usePrivateLocationPermissions();
const { basePath } = useSyntheticsSettingsContext();
const { data: policies, loading } = useSelector(selectAgentPolicies);
const policy = policies?.items.find((policyT) => policyT.id === agentPolicyId);
if (loading) {
return <EuiLoadingSpinner size="s" />;
}
return (
<EuiText size="s">
<p>
{canReadAgentPolicies && (
<EuiTextColor color="subdued">
{policy ? (
<EuiLink href={`${basePath}/app/fleet/policies/${agentPolicyId}`}>
{policy?.name}
</EuiLink>
) : (
<EuiText color="danger" size="s" className="eui-displayInline">
{POLICY_IS_DELETED}
</EuiText>
)}
</EuiTextColor>
)}
</p>
</EuiText>
);
};
const POLICY_IS_DELETED = i18n.translate('xpack.synthetics.monitorManagement.deletedPolicy', {
defaultMessage: 'Policy is deleted',
});

View file

@ -0,0 +1,23 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const NEED_PERMISSIONS = i18n.translate(
'xpack.synthetics.monitorManagement.needPermissions',
{
defaultMessage: 'Need permissions',
}
);
export const NEED_FLEET_READ_AGENT_POLICIES_PERMISSION = i18n.translate(
'xpack.synthetics.monitorManagement.needFleetReadAgentPoliciesPermission',
{
defaultMessage:
'You are not authorized to access Fleet. Fleet permissions are required to create new private locations.',
}
);

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 React, { useState } from 'react';
import { EuiPopover, EuiButtonEmpty, EuiButton, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useHistory } from 'react-router-dom';
export const ViewLocationMonitors = ({
count,
locationName,
}: {
count: number;
locationName: string;
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onButtonClick = () => setIsPopoverOpen((prevState) => !prevState);
const closePopover = () => setIsPopoverOpen(false);
const button = <EuiButtonEmpty onClick={onButtonClick}>{count}</EuiButtonEmpty>;
const history = useHistory();
return (
<EuiPopover button={button} isOpen={isPopoverOpen} closePopover={closePopover}>
<FormattedMessage
id="xpack.synthetics.monitorManagement.viewMonitors"
defaultMessage="Location {name} has {count, number} {count, plural,one {monitor} other {monitors}} running."
values={{ count, name: <strong>{locationName}</strong> }}
/>
<EuiSpacer size="s" />
{count > 0 ? (
<EuiButton
href={history.createHref({
pathname: '/monitors',
search: `?locations=${JSON.stringify([locationName])}`,
})}
>
{VIEW_LOCATION_MONITORS}
</EuiButton>
) : (
<EuiButton
href={history.createHref({
pathname: '/add-monitor',
})}
>
{CREATE_MONITOR}
</EuiButton>
)}
</EuiPopover>
);
};
const VIEW_LOCATION_MONITORS = i18n.translate(
'xpack.synthetics.monitorManagement.viewLocationMonitors',
{
defaultMessage: 'View location monitors',
}
);
const CREATE_MONITOR = i18n.translate('xpack.synthetics.monitorManagement.createLocationMonitors', {
defaultMessage: 'Create monitor',
});

View file

@ -7,17 +7,28 @@
import React from 'react';
import { Redirect, useParams } from 'react-router-dom';
import { SettingsTabId } from './page_header';
import { DataRetentionTab } from './data_retention';
import { useSettingsBreadcrumbs } from './use_settings_breadcrumbs';
import { ManagePrivateLocations } from './private_locations/manage_private_locations';
export const SettingsPage = () => {
useSettingsBreadcrumbs();
const { tabId } = useParams<{ tabId: string }>();
const { tabId } = useParams<{ tabId: SettingsTabId }>();
if (!tabId) {
return <Redirect to="/settings/alerting" />;
}
const renderTab = () => {
switch (tabId) {
case 'private-locations':
return <ManagePrivateLocations />;
case 'data-retention':
return <DataRetentionTab />;
case 'alerting':
return <div>TODO: Alerting</div>;
default:
return <Redirect to="/settings/alerting" />;
}
};
return <div>{tabId === 'alerting' ? <div>TODO: Alerting</div> : <DataRetentionTab />}</div>;
return <div>{renderTab()}</div>;
};

View file

@ -13,6 +13,7 @@ import {
I18nStart,
} from '@kbn/core/public';
import React, { createContext, useContext, useMemo } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ClientPluginsSetup, ClientPluginsStart } from '../../../plugin';
import { CLIENT_DEFAULTS, CONTEXT_DEFAULTS } from '../../../../common/constants';
import { useGetUrlParams } from '../hooks';
@ -43,6 +44,7 @@ export interface SyntheticsAppProps {
}
export interface SyntheticsSettingsContextValues {
canSave: boolean;
basePath: string;
dateRangeStart: string;
dateRangeEnd: string;
@ -69,6 +71,7 @@ const defaultContext: SyntheticsSettingsContextValues = {
isInfraAvailable: true,
isLogsAvailable: true,
isDev: false,
canSave: false,
};
export const SyntheticsSettingsContext = createContext(defaultContext);
@ -81,8 +84,13 @@ export const SyntheticsSettingsContextProvider: React.FC<SyntheticsAppProps> = (
const { dateRangeStart, dateRangeEnd } = useGetUrlParams();
const { application } = useKibana().services;
const canSave = (application?.capabilities.uptime.save ?? false) as boolean;
const value = useMemo(() => {
return {
canSave,
isDev,
basePath,
isApmAvailable,
@ -93,6 +101,7 @@ export const SyntheticsSettingsContextProvider: React.FC<SyntheticsAppProps> = (
dateRangeEnd: dateRangeEnd ?? DATE_RANGE_END,
};
}, [
canSave,
isDev,
basePath,
isApmAvailable,

View file

@ -0,0 +1,18 @@
/*
* 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 { createAction } from '@reduxjs/toolkit';
import { createAsyncAction } from '../utils/actions';
import { AgentPoliciesList } from '.';
export const getAgentPoliciesAction = createAsyncAction<void, AgentPoliciesList>(
'[AGENT POLICIES] GET'
);
export const setManageFlyoutOpen = createAction<boolean>('SET MANAGE FLYOUT OPEN');
export const setAddingNewPrivateLocation = createAction<boolean>('SET MANAGE FLYOUT ADDING NEW');

View file

@ -0,0 +1,58 @@
/*
* 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 { SavedObjectsClientContract } from '@kbn/core/public';
import { SyntheticsPrivateLocations } from '../../../../../common/runtime_types';
import { apiService } from '../../../../utils/api_service/api_service';
import { AgentPoliciesList } from '.';
import {
privateLocationsSavedObjectId,
privateLocationsSavedObjectName,
} from '../../../../../common/saved_objects/private_locations';
const FLEET_URLS = {
AGENT_POLICIES: '/api/fleet/agent_policies',
};
export const fetchAgentPolicies = async (): Promise<AgentPoliciesList> => {
return await apiService.get(
FLEET_URLS.AGENT_POLICIES,
{
page: 1,
perPage: 10000,
sortField: 'name',
sortOrder: 'asc',
full: true,
kuery: 'ingest-agent-policies.is_managed : false',
},
null
);
};
export const setSyntheticsPrivateLocations = async (
client: SavedObjectsClientContract,
privateLocations: SyntheticsPrivateLocations
) => {
const result = await client.create(privateLocationsSavedObjectName, privateLocations, {
id: privateLocationsSavedObjectId,
overwrite: true,
});
return result.attributes;
};
export const getSyntheticsPrivateLocations = async (client: SavedObjectsClientContract) => {
try {
const obj = await client.get<SyntheticsPrivateLocations>(
privateLocationsSavedObjectName,
privateLocationsSavedObjectId
);
return obj?.attributes.locations ?? [];
} catch (getErr) {
return [];
}
};

View file

@ -0,0 +1,22 @@
/*
* 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 { takeLeading } from 'redux-saga/effects';
import { fetchEffectFactory } from '../utils/fetch_effect';
import { fetchAgentPolicies } from './api';
import { getAgentPoliciesAction } from './actions';
export function* fetchAgentPoliciesEffect() {
yield takeLeading(
getAgentPoliciesAction.get,
fetchEffectFactory(
fetchAgentPolicies,
getAgentPoliciesAction.success,
getAgentPoliciesAction.fail
)
);
}

View file

@ -0,0 +1,56 @@
/*
* 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 { createReducer } from '@reduxjs/toolkit';
import { AgentPolicy } from '@kbn/fleet-plugin/common';
import { IHttpSerializedFetchError } from '..';
import { getAgentPoliciesAction, setAddingNewPrivateLocation } from './actions';
export interface AgentPoliciesList {
items: AgentPolicy[];
total: number;
page: number;
perPage: number;
}
export interface AgentPoliciesState {
data: AgentPoliciesList | null;
loading: boolean;
error: IHttpSerializedFetchError | null;
isManageFlyoutOpen?: boolean;
isAddingNewPrivateLocation?: boolean;
}
const initialState: AgentPoliciesState = {
data: null,
loading: false,
error: null,
isManageFlyoutOpen: false,
isAddingNewPrivateLocation: false,
};
export const agentPoliciesReducer = createReducer(initialState, (builder) => {
builder
.addCase(getAgentPoliciesAction.get, (state) => {
state.loading = true;
})
.addCase(getAgentPoliciesAction.success, (state, action) => {
state.data = action.payload;
state.loading = false;
})
.addCase(getAgentPoliciesAction.fail, (state, action) => {
state.error = action.payload;
state.loading = false;
})
.addCase(setAddingNewPrivateLocation, (state, action) => {
state.isAddingNewPrivateLocation = action.payload;
});
});
export * from './actions';
export * from './effects';
export * from './selectors';

View file

@ -0,0 +1,15 @@
/*
* 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 { createSelector } from 'reselect';
import { AppState } from '..';
const getState = (appState: AppState) => appState.agentPolicies;
export const selectAgentPolicies = createSelector(getState, (state) => state);
export const selectAddingNewPrivateLocation = (state: AppState) =>
state.agentPolicies.isAddingNewPrivateLocation ?? false;

View file

@ -6,6 +6,7 @@
*/
import { all, fork } from 'redux-saga/effects';
import { fetchAgentPoliciesEffect } from './private_locations';
import { fetchNetworkEventsEffect } from './network_events/effects';
import { fetchSyntheticsMonitorEffect } from './monitor_details';
import { fetchIndexStatusEffect } from './index_status';
@ -29,5 +30,6 @@ export const rootEffect = function* root(): Generator {
fork(fetchOverviewStatusEffect),
fork(fetchNetworkEventsEffect),
fork(fetchPingStatusesEffect),
fork(fetchAgentPoliciesEffect),
]);
};

View file

@ -7,6 +7,7 @@
import { combineReducers } from '@reduxjs/toolkit';
import { agentPoliciesReducer, AgentPoliciesState } from './private_locations';
import { networkEventsReducer, NetworkEventsState } from './network_events';
import { monitorDetailsReducer, MonitorDetailsState } from './monitor_details';
import { uiReducer, UiState } from './ui';
@ -30,6 +31,7 @@ export interface SyntheticsAppState {
browserJourney: BrowserJourneyState;
networkEvents: NetworkEventsState;
pingStatus: PingStatusState;
agentPolicies: AgentPoliciesState;
}
export const rootReducer = combineReducers<SyntheticsAppState>({
@ -43,4 +45,5 @@ export const rootReducer = combineReducers<SyntheticsAppState>({
browserJourney: browserJourneyReducer,
networkEvents: networkEventsReducer,
pingStatus: pingStatusReducer,
agentPolicies: agentPoliciesReducer,
});

View file

@ -92,6 +92,7 @@ const Application = (props: SyntheticsAppProps) => {
observability: startPlugins.observability,
cases: startPlugins.cases,
spaces: startPlugins.spaces,
fleet: startPlugins.fleet,
}}
>
<Router history={appMountParameters.history}>

View file

@ -111,6 +111,11 @@ export const mockState: SyntheticsAppState = {
browserJourney: getBrowserJourneyMockSlice(),
networkEvents: {},
pingStatus: getPingStatusesMockSlice(),
agentPolicies: {
loading: false,
error: null,
data: null,
},
};
function getBrowserJourneyMockSlice() {

View file

@ -89,6 +89,7 @@ export interface ClientPluginsStart {
docLinks: DocLinksStart;
uiSettings: CoreStart['uiSettings'];
usageCollection: UsageCollectionStart;
savedObjects: CoreStart['savedObjects'];
}
export interface UptimePluginServices extends Partial<CoreStart> {

View file

@ -10,7 +10,11 @@ import { expect, Page } from '@elastic/synthetics';
export async function waitForLoadingToFinish({ page }: { page: Page }) {
while (true) {
if ((await page.$(byTestId('kbnLoadingMessage'))) === null) break;
await page.waitForTimeout(5 * 1000);
await page.waitForTimeout(2 * 1000);
}
while (true) {
if ((await page.$(byTestId('globalLoadingIndicator'))) === null) break;
await page.waitForTimeout(2 * 1000);
}
}