[7.x] EUIfy Watcher (#35301) (#39970)

This commit is contained in:
Alison Goryachev 2019-06-29 10:52:59 -04:00 committed by GitHub
parent e87bbe0307
commit 41ac633007
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
451 changed files with 10869 additions and 10886 deletions

View file

@ -24,6 +24,7 @@ export function createJestConfig({
'^ui/(.*)': `${kibanaDirectory}/src/legacy/ui/public/$1`,
'uiExports/(.*)': `${kibanaDirectory}/src/dev/jest/mocks/file_mocks.js`,
'^src/core/(.*)': `${kibanaDirectory}/src/core/$1`,
'^plugins/watcher/models/(.*)': `${xPackKibanaDirectory}/legacy/plugins/watcher/public/models/$1`,
'^plugins/([^\/.]*)(.*)': `${kibanaDirectory}/src/legacy/core_plugins/$1/public$2`,
'^plugins/xpack_main/(.*);': `${xPackKibanaDirectory}/legacy/plugins/xpack_main/public/$1`,
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':

View file

@ -4,32 +4,19 @@ This plugins adopts some conventions in addition to or in place of conventions i
## Folder structure
```
common/
constants/ // constants used across client and server
lib/ // helpers used across client and server
types/ // TS definitions
public/
directives/ (This is the same as *A, but is for directives that are used cross-view)
services/
watch/
index.js (no code here; only `export from watch.js`)
watch.js
notifications/
index.js (no code here; only `export from notifications.js`)
notifications.js
...
views/
edit/
...
list/
directives/ (*A)
my_directive_name/
directives/ (Subcomponents of my_directive_name are defined here, and this follows the same structure as *A)
index.js (no code here; only `export from my_directive_name.js`)
my_directive_name.js
my_directive_name.html
index.js (imports the directives in this folder, i.e.,my_directive_name)
routes/
index.js (no code here; only imports routes.js)
routes.js
index.js
components/ // common React components
constants/ // constants used on the client
lib/ // helpers used on the client
models/ // client models
sections/ // Sections of the app with corresponding React components
watch_edit
watch_list
watch_status
server/
lib/
screenshots/
@ -44,90 +31,6 @@ server/
say_hello.js
```
## Data Services
api calls:
- GET /watch/{id}
- PUT /watch/{id}
using the service
```js
import watch from './services/watch'
watch.get(...)
watch.put(...)
```
## Services / Lib
- Shared code that requires state should be made into a service. For example, see `pageService`.
- Shared code that doesn't require state (e.g. a simple helper function) should be made a lib.
For example, see `clamp`.
## Controller classes
- All functions in controller classes should be defined as arrow function constants. This is to ensure the `this` context is consistent, regardless of where it is being called.
GOOD
```
controller: class WatchListController {
onQueryChanged = (query) => {...};
}
```
BAD
```
controller: class WatchListController {
onQueryChanged(query) {...};
}
```
```
controller: class WatchListController {
constructor() {
this.onQueryChanged = (query) => {...};
}
}
```
- Constructors should be used to initialize state and define $scope.$watch(es)
GOOD
```
controllerAs: 'watchList',
bindToController: true,
scope: { foo: '=' },
controller: class WatchListController {
constructor() {
this.foo = this.foo || 'default';
$scope.$watch('watchList.foo', () => {
console.log('foo changed, fool');
});
}
}
```
## Event handlers
Event handler functions should be named with the following pattern:
> `on<Verb>`, in present tense
In case there is ambiguity about _what_ the verb is acting upon a noun should be included like so:
> `on<Noun><Verb>`, in present tense
GOOD
```
onDelete
onWatchDelete
```
BAD
```
onDeleted
onWatchDeleted
```
## Data Flow
We have a layered architecture in the Watcher UI codebase, with each layer performing a specific function to the data as it flows through it.

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getWatch } from '../../../test/fixtures';
export const WATCH_ID = 'my-test-watch';
export const WATCH = { watch: getWatch({ id: WATCH_ID }) };

View file

@ -0,0 +1,158 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import sinon, { SinonFakeServer } from 'sinon';
import { ROUTES } from '../../../common/constants';
const { API_ROOT } = ROUTES;
type HttpResponse = Record<string, any> | any[];
const mockResponse = (defaultResponse: HttpResponse, response: HttpResponse) => [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify({ ...defaultResponse, ...response }),
];
// Register helpers to mock HTTP Requests
const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
const setLoadWatchesResponse = (response: HttpResponse = {}) => {
const defaultResponse = { watches: [] };
server.respondWith('GET', `${API_ROOT}/watches`, mockResponse(defaultResponse, response));
};
const setLoadWatchResponse = (response: HttpResponse = {}) => {
const defaultResponse = { watch: {} };
server.respondWith('GET', `${API_ROOT}/watch/:id`, mockResponse(defaultResponse, response));
};
const setLoadWatchHistoryResponse = (response: HttpResponse = {}) => {
const defaultResponse = { watchHistoryItems: [] };
server.respondWith(
'GET',
`${API_ROOT}/watch/:id/history?startTime=*`,
mockResponse(defaultResponse, response)
);
};
const setLoadWatchHistoryItemResponse = (response: HttpResponse = {}) => {
const defaultResponse = { watchHistoryItem: {} };
server.respondWith('GET', `${API_ROOT}/history/:id`, mockResponse(defaultResponse, response));
};
const setDeleteWatchResponse = (response?: HttpResponse, error?: any) => {
const status = error ? error.status || 400 : 200;
const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
server.respondWith('POST', `${API_ROOT}/watches/delete`, [
status,
{ 'Content-Type': 'application/json' },
body,
]);
};
const setSaveWatchResponse = (id: string, response?: HttpResponse, error?: any) => {
const status = error ? error.status || 400 : 200;
const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
server.respondWith('PUT', `${API_ROOT}/watch/${id}`, [
status,
{ 'Content-Type': 'application/json' },
body,
]);
};
const setLoadExecutionResultResponse = (response: HttpResponse = {}) => {
const defaultResponse = { watchHistoryItem: {} };
server.respondWith('PUT', `${API_ROOT}/watch/execute`, mockResponse(defaultResponse, response));
};
const setLoadMatchingIndicesResponse = (response: HttpResponse = {}) => {
const defaultResponse = { indices: [] };
server.respondWith('POST', `${API_ROOT}/indices`, mockResponse(defaultResponse, response));
};
const setLoadEsFieldsResponse = (response: HttpResponse = {}) => {
const defaultResponse = { fields: [] };
server.respondWith('POST', `${API_ROOT}/fields`, mockResponse(defaultResponse, response));
};
const setLoadSettingsResponse = (response: HttpResponse = {}) => {
const defaultResponse = { action_types: {} };
server.respondWith('GET', `${API_ROOT}/settings`, mockResponse(defaultResponse, response));
};
const setLoadWatchVisualizeResponse = (response: HttpResponse = {}) => {
const defaultResponse = { visualizeData: {} };
server.respondWith(
'POST',
`${API_ROOT}/watch/visualize`,
mockResponse(defaultResponse, response)
);
};
const setDeactivateWatchResponse = (response: HttpResponse = {}) => {
const defaultResponse = { watchStatus: {} };
server.respondWith(
'PUT',
`${API_ROOT}/watch/:id/deactivate`,
mockResponse(defaultResponse, response)
);
};
const setActivateWatchResponse = (response: HttpResponse = {}) => {
const defaultResponse = { watchStatus: {} };
server.respondWith(
'PUT',
`${API_ROOT}/watch/:id/activate`,
mockResponse(defaultResponse, response)
);
};
const setAcknowledgeWatchResponse = (response: HttpResponse = {}) => {
const defaultResponse = { watchStatus: {} };
server.respondWith(
'PUT',
`${API_ROOT}/watch/:id/action/:actionId/acknowledge`,
mockResponse(defaultResponse, response)
);
};
return {
setLoadWatchesResponse,
setLoadWatchResponse,
setLoadWatchHistoryResponse,
setLoadWatchHistoryItemResponse,
setDeleteWatchResponse,
setSaveWatchResponse,
setLoadExecutionResultResponse,
setLoadMatchingIndicesResponse,
setLoadEsFieldsResponse,
setLoadSettingsResponse,
setLoadWatchVisualizeResponse,
setDeactivateWatchResponse,
setActivateWatchResponse,
setAcknowledgeWatchResponse,
};
};
export const init = () => {
const server = sinon.fakeServer.create();
server.respondImmediately = true;
// Define default response for unhandled requests.
// We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry,
// and we can mock them all with a 200 instead of mocking each one individually.
server.respondWith([200, {}, 'DefaultResponse']);
const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);
return {
server,
httpRequestsMockHelpers,
};
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { setup as watchListSetup } from './watch_list.helpers';
import { setup as watchStatusSetup } from './watch_status.helpers';
import { setup as watchCreateJsonSetup } from './watch_create_json.helpers';
import { setup as watchCreateThresholdSetup } from './watch_create_threshold.helpers';
import { setup as watchEditSetup } from './watch_edit.helpers';
export { nextTick, getRandomString, findTestSubject, TestBed } from '../../../../../../test_utils';
export { setupEnvironment } from './setup_environment';
export const pageHelpers = {
watchList: { setup: watchListSetup },
watchStatus: { setup: watchStatusSetup },
watchCreateJson: { setup: watchCreateJsonSetup },
watchCreateThreshold: { setup: watchCreateThresholdSetup },
watchEdit: { setup: watchEditSetup },
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import axios from 'axios';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { init as initHttpRequests } from './http_requests';
import { setHttpClient, setSavedObjectsClient } from '../../../public/lib/api';
const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
const mockSavedObjectsClient = () => {
return {
find: (_params?: any) => {},
};
};
export const setupEnvironment = () => {
const { server, httpRequestsMockHelpers } = initHttpRequests();
// @ts-ignore
setHttpClient(mockHttpClient);
setSavedObjectsClient(mockSavedObjectsClient());
return {
server,
httpRequestsMockHelpers,
};
};

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { registerTestBed, TestBed, TestBedConfig } from '../../../../../../test_utils';
import { WatchEdit } from '../../../public/sections/watch_edit/components/watch_edit';
import { ROUTES, WATCH_TYPES } from '../../../common/constants';
import { registerRouter } from '../../../public/lib/navigation';
const testBedConfig: TestBedConfig = {
memoryRouter: {
onRouter: router => registerRouter(router),
initialEntries: [`${ROUTES.API_ROOT}/watches/new-watch/${WATCH_TYPES.JSON}`],
componentRoutePath: `${ROUTES.API_ROOT}/watches/new-watch/:type`,
},
doMountAsync: true,
};
const initTestBed = registerTestBed(WatchEdit, testBedConfig);
export interface WatchCreateJsonTestBed extends TestBed<WatchCreateJsonTestSubjects> {
actions: {
selectTab: (tab: 'edit' | 'simulate') => void;
clickSubmitButton: () => void;
clickSimulateButton: () => void;
};
}
export const setup = async (): Promise<WatchCreateJsonTestBed> => {
const testBed = await initTestBed();
/**
* User Actions
*/
const selectTab = (tab: 'edit' | 'simulate') => {
const tabs = ['edit', 'simulate'];
testBed
.find('tab')
.at(tabs.indexOf(tab))
.simulate('click');
};
const clickSubmitButton = () => {
testBed.find('saveWatchButton').simulate('click');
};
const clickSimulateButton = () => {
testBed.find('simulateWatchButton').simulate('click');
};
return {
...testBed,
actions: {
selectTab,
clickSubmitButton,
clickSimulateButton,
},
};
};
type WatchCreateJsonTestSubjects = TestSubjects;
export type TestSubjects =
| 'actionModesSelect'
| 'idInput'
| 'ignoreConditionSwitch'
| 'jsonEditor'
| 'jsonWatchForm'
| 'jsonWatchSimulateForm'
| 'nameInput'
| 'pageTitle'
| 'saveWatchButton'
| 'scheduledTimeInput'
| 'sectionError'
| 'sectionLoading'
| 'simulateResultsFlyout'
| 'simulateResultsFlyoutTitle'
| 'simulateWatchButton'
| 'tab'
| 'triggeredTimeInput';

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { registerTestBed, TestBed, TestBedConfig } from '../../../../../../test_utils';
import { WatchEdit } from '../../../public/sections/watch_edit/components/watch_edit';
import { ROUTES, WATCH_TYPES } from '../../../common/constants';
import { registerRouter } from '../../../public/lib/navigation';
const testBedConfig: TestBedConfig = {
memoryRouter: {
onRouter: router => registerRouter(router),
initialEntries: [`${ROUTES.API_ROOT}/watches/new-watch/${WATCH_TYPES.THRESHOLD}`],
componentRoutePath: `${ROUTES.API_ROOT}/watches/new-watch/:type`,
},
doMountAsync: true,
};
const initTestBed = registerTestBed(WatchEdit, testBedConfig);
export interface WatchCreateThresholdTestBed extends TestBed<WatchCreateThresholdTestSubjects> {
actions: {
clickSubmitButton: () => void;
clickAddActionButton: () => void;
clickActionLink: (
actionType: 'logging' | 'email' | 'webhook' | 'index' | 'slack' | 'jira' | 'pagerduty'
) => void;
clickSimulateButton: () => void;
};
}
export const setup = async (): Promise<WatchCreateThresholdTestBed> => {
const testBed = await initTestBed();
/**
* User Actions
*/
const clickSubmitButton = () => {
testBed.find('saveWatchButton').simulate('click');
};
const clickAddActionButton = () => {
testBed.find('addWatchActionButton').simulate('click');
};
const clickSimulateButton = () => {
testBed.find('simulateActionButton').simulate('click');
};
const clickActionLink = (
actionType: 'logging' | 'email' | 'webhook' | 'index' | 'slack' | 'jira' | 'pagerduty'
) => {
testBed.find(`${actionType}ActionButton`).simulate('click');
};
return {
...testBed,
actions: {
clickSubmitButton,
clickAddActionButton,
clickActionLink,
clickSimulateButton,
},
};
};
type WatchCreateThresholdTestSubjects = TestSubjects;
export type TestSubjects =
| 'addWatchActionButton'
| 'emailBodyInput'
| 'emailSubjectInput'
| 'indexInput'
| 'indicesComboBox'
| 'jiraIssueTypeInput'
| 'jiraProjectKeyInput'
| 'jiraSummaryInput'
| 'loggingTextInput'
| 'mockComboBox'
| 'nameInput'
| 'pagerdutyDescriptionInput'
| 'pageTitle'
| 'saveWatchButton'
| 'sectionLoading'
| 'simulateActionButton'
| 'slackMessageTextarea'
| 'slackRecipientComboBox'
| 'toEmailAddressInput'
| 'triggerIntervalSizeInput'
| 'watchActionAccordion'
| 'watchActionAccordion.mockComboBox'
| 'watchActionsPanel'
| 'watchConditionTitle'
| 'watchTimeFieldSelect'
| 'watchVisualizationChart'
| 'webhookBodyEditor'
| 'webhookHostInput'
| 'webhookPasswordInput'
| 'webhookPathInput'
| 'webhookPortInput'
| 'webhookMethodSelect'
| 'webhookUsernameInput';

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { registerTestBed, TestBed, TestBedConfig } from '../../../../../../test_utils';
import { WatchEdit } from '../../../public/sections/watch_edit/components/watch_edit';
import { ROUTES } from '../../../common/constants';
import { registerRouter } from '../../../public/lib/navigation';
import { WATCH_ID } from './constants';
const testBedConfig: TestBedConfig = {
memoryRouter: {
onRouter: router => registerRouter(router),
initialEntries: [`${ROUTES.API_ROOT}/watches/watch/${WATCH_ID}/edit`],
componentRoutePath: `${ROUTES.API_ROOT}/watches/watch/:id/edit`,
},
doMountAsync: true,
};
const initTestBed = registerTestBed(WatchEdit, testBedConfig);
export interface WatchEditTestBed extends TestBed<WatchEditSubjects> {
actions: {
clickSubmitButton: () => void;
};
}
export const setup = async (): Promise<WatchEditTestBed> => {
const testBed = await initTestBed();
/**
* User Actions
*/
const clickSubmitButton = () => {
testBed.find('saveWatchButton').simulate('click');
};
return {
...testBed,
actions: {
clickSubmitButton,
},
};
};
type WatchEditSubjects = TestSubjects;
export type TestSubjects =
| 'idInput'
| 'jsonWatchForm'
| 'nameInput'
| 'pageTitle'
| 'thresholdWatchForm'
| 'watchTimeFieldSelect';

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import {
registerTestBed,
findTestSubject,
TestBed,
TestBedConfig,
nextTick,
} from '../../../../../../test_utils';
import { WatchList } from '../../../public/sections/watch_list/components/watch_list';
import { ROUTES } from '../../../common/constants';
const testBedConfig: TestBedConfig = {
memoryRouter: {
initialEntries: [`${ROUTES.API_ROOT}/watches`],
},
doMountAsync: true,
};
const initTestBed = registerTestBed(WatchList, testBedConfig);
export interface WatchListTestBed extends TestBed<WatchListTestSubjects> {
actions: {
selectWatchAt: (index: number) => void;
clickWatchAt: (index: number) => void;
clickWatchActionAt: (index: number, action: 'delete' | 'edit') => void;
};
}
export const setup = async (): Promise<WatchListTestBed> => {
const testBed = await initTestBed();
/**
* User Actions
*/
const selectWatchAt = (index: number) => {
const { rows } = testBed.table.getMetaData('watchesTable');
const row = rows[index];
const checkBox = row.reactWrapper.find('input').hostNodes();
checkBox.simulate('change', { target: { checked: true } });
};
const clickWatchAt = async (index: number) => {
const { rows } = testBed.table.getMetaData('watchesTable');
const watchesLink = findTestSubject(rows[index].reactWrapper, 'watchesLink');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
const { href } = watchesLink.props();
testBed.router.navigateTo(href!);
await nextTick();
testBed.component.update();
});
};
const clickWatchActionAt = async (index: number, action: 'delete' | 'edit') => {
const { component, table } = testBed;
const { rows } = table.getMetaData('watchesTable');
const currentRow = rows[index];
const lastColumn = currentRow.columns[currentRow.columns.length - 1].reactWrapper;
const button = findTestSubject(lastColumn, `${action}WatchButton`);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
button.simulate('click');
component.update();
});
};
return {
...testBed,
actions: {
selectWatchAt,
clickWatchAt,
clickWatchActionAt,
},
};
};
type WatchListTestSubjects = TestSubjects;
export type TestSubjects =
| 'appTitle'
| 'documentationLink'
| 'watchesTable'
| 'cell'
| 'row'
| 'deleteWatchButton'
| 'createWatchButton'
| 'emptyPrompt'
| 'emptyPrompt.createWatchButton'
| 'editWatchButton';

View file

@ -0,0 +1,141 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import {
registerTestBed,
findTestSubject,
TestBed,
TestBedConfig,
nextTick,
} from '../../../../../../test_utils';
import { WatchStatus } from '../../../public/sections/watch_status/components/watch_status';
import { ROUTES } from '../../../common/constants';
import { WATCH_ID } from './constants';
const testBedConfig: TestBedConfig = {
memoryRouter: {
initialEntries: [`${ROUTES.API_ROOT}/watches/watch/${WATCH_ID}/status`],
componentRoutePath: `${ROUTES.API_ROOT}/watches/watch/:id/status`,
},
doMountAsync: true,
};
const initTestBed = registerTestBed(WatchStatus, testBedConfig);
export interface WatchStatusTestBed extends TestBed<WatchStatusTestSubjects> {
actions: {
selectTab: (tab: 'execution history' | 'action statuses') => void;
clickToggleActivationButton: () => void;
clickAcknowledgeButton: (index: number) => void;
clickDeleteWatchButton: () => void;
clickWatchExecutionAt: (index: number, tableCellText: string) => void;
};
}
export const setup = async (): Promise<WatchStatusTestBed> => {
const testBed = await initTestBed();
/**
* User Actions
*/
const selectTab = (tab: 'execution history' | 'action statuses') => {
const tabs = ['execution history', 'action statuses'];
testBed
.find('tab')
.at(tabs.indexOf(tab))
.simulate('click');
};
const clickToggleActivationButton = async () => {
const { component } = testBed;
const button = testBed.find('toggleWatchActivationButton');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
button.simulate('click');
component.update();
});
};
const clickAcknowledgeButton = async (index: number) => {
const { component, table } = testBed;
const { rows } = table.getMetaData('watchActionStatusTable');
const currentRow = rows[index];
const lastColumn = currentRow.columns[currentRow.columns.length - 1].reactWrapper;
const button = findTestSubject(lastColumn, 'acknowledgeWatchButton');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
button.simulate('click');
component.update();
});
};
const clickDeleteWatchButton = async () => {
const { component } = testBed;
const button = testBed.find('deleteWatchButton');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
button.simulate('click');
component.update();
});
};
const clickWatchExecutionAt = async (index: number, tableCellText: string) => {
const { component, table } = testBed;
const { rows } = table.getMetaData('watchHistoryTable');
const currentRow = rows[index];
const firstColumn = currentRow.columns[0].reactWrapper;
const button = findTestSubject(firstColumn, `watchStartTimeColumn-${tableCellText}`);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
button.simulate('click');
await nextTick(100);
component.update();
});
};
return {
...testBed,
actions: {
selectTab,
clickToggleActivationButton,
clickAcknowledgeButton,
clickDeleteWatchButton,
clickWatchExecutionAt,
},
};
};
type WatchStatusTestSubjects = TestSubjects;
export type TestSubjects =
| 'acknowledgeWatchButton'
| 'actionErrorsButton'
| 'actionErrorsFlyout'
| 'actionErrorsFlyout.errorMessage'
| 'actionErrorsFlyout.title'
| 'deleteWatchButton'
| 'pageTitle'
| 'tab'
| 'toggleWatchActivationButton'
| 'watchActionStatusTable'
| 'watchActionsTable'
| 'watchDetailSection'
| 'watchHistoryDetailFlyout'
| 'watchHistoryDetailFlyout.title'
| 'watchHistorySection'
| 'watchHistoryErrorDetailFlyout'
| 'watchHistoryErrorDetailFlyout.errorMessage'
| 'watchHistoryErrorDetailFlyout.title'
| 'watchHistoryTable';

View file

@ -0,0 +1,284 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import { setupEnvironment, pageHelpers, nextTick } from './helpers';
import { WatchCreateJsonTestBed } from './helpers/watch_create_json.helpers';
import { WATCH } from './helpers/constants';
import defaultWatchJson from '../../public/models/watch/default_watch.json';
import { getExecuteDetails } from '../../test/fixtures';
jest.mock('ui/chrome', () => ({
breadcrumbs: { set: () => {} },
addBasePath: (path: string) => path || '/api/watcher',
}));
jest.mock('ui/time_buckets', () => {});
const { setup } = pageHelpers.watchCreateJson;
describe.skip('<JsonWatchEdit /> create route', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
let testBed: WatchCreateJsonTestBed;
afterAll(() => {
server.restore();
});
describe('on component mount', () => {
beforeEach(async () => {
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
const { component } = testBed;
await nextTick();
component.update();
});
});
test('should set the correct page title', () => {
const { find } = testBed;
expect(find('pageTitle').text()).toBe('Create advanced watch');
});
describe('tabs', () => {
test('should have 2 tabs', () => {
const { find } = testBed;
expect(find('tab').length).toBe(2);
expect(find('tab').map(t => t.text())).toEqual(['Edit', 'Simulate']);
});
test('should navigate to the "Simulate" tab', () => {
const { exists, actions } = testBed;
expect(exists('jsonWatchForm')).toBe(true);
expect(exists('jsonWatchSimulateForm')).toBe(false);
actions.selectTab('simulate');
expect(exists('jsonWatchForm')).toBe(false);
expect(exists('jsonWatchSimulateForm')).toBe(true);
});
});
describe('create', () => {
describe('form validation', () => {
test('should not allow empty ID field', () => {
const { form, actions } = testBed;
form.setInputValue('idInput', '');
actions.clickSubmitButton();
expect(form.getErrorsMessages()).toContain('ID is required');
});
test('should not allow invalid characters for ID field', () => {
const { form, actions } = testBed;
form.setInputValue('idInput', 'invalid$id*field/');
actions.clickSubmitButton();
expect(form.getErrorsMessages()).toContain(
'ID can only contain letters, underscores, dashes, and numbers.'
);
});
});
describe('form payload & API errors', () => {
test('should send the correct payload', async () => {
const { form, actions } = testBed;
const { watch } = WATCH;
form.setInputValue('nameInput', watch.name);
form.setInputValue('idInput', watch.id);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSubmitButton();
await nextTick();
});
const latestRequest = server.requests[server.requests.length - 1];
const DEFAULT_LOGGING_ACTION_ID = 'logging_1';
const DEFAULT_LOGGING_ACTION_TYPE = 'logging';
const DEFAULT_LOGGING_ACTION_TEXT =
'There are {{ctx.payload.hits.total}} documents in your index. Threshold is 10.';
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
id: watch.id,
name: watch.name,
type: watch.type,
isNew: true,
actions: [
{
id: DEFAULT_LOGGING_ACTION_ID,
type: DEFAULT_LOGGING_ACTION_TYPE,
text: DEFAULT_LOGGING_ACTION_TEXT,
[DEFAULT_LOGGING_ACTION_TYPE]: {
text: DEFAULT_LOGGING_ACTION_TEXT,
},
},
],
watch: defaultWatchJson,
})
);
});
test('should surface the API errors from the "save" HTTP request', async () => {
const { form, actions, component, exists, find } = testBed;
const { watch } = WATCH;
form.setInputValue('nameInput', watch.name);
form.setInputValue('idInput', watch.id);
const error = {
status: 400,
error: 'Bad request',
message: 'Watch payload is invalid',
};
httpRequestsMockHelpers.setSaveWatchResponse(watch.id, undefined, { body: error });
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSubmitButton();
await nextTick();
component.update();
});
expect(exists('sectionError')).toBe(true);
expect(find('sectionError').text()).toContain(error.message);
});
});
});
describe('simulate', () => {
beforeEach(() => {
const { actions, form } = testBed;
// Set watch id (required field) and switch to simulate tab
form.setInputValue('idInput', WATCH.watch.id);
actions.selectTab('simulate');
});
describe('form payload & API errors', () => {
test('should execute a watch with no input', async () => {
const { actions } = testBed;
const {
watch: { id, type },
} = WATCH;
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSimulateButton();
await nextTick();
});
const latestRequest = server.requests[server.requests.length - 1];
const actionModes = Object.keys(defaultWatchJson.actions).reduce(
(actionAccum: any, action) => {
actionAccum[action] = 'simulate';
return actionAccum;
},
{}
);
const executedWatch = {
id,
type,
isNew: true,
actions: [],
watch: defaultWatchJson,
};
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
executeDetails: getExecuteDetails({
actionModes,
}),
watch: executedWatch,
})
);
});
test('should execute a watch with a valid payload', async () => {
const { actions, form, find, exists, component } = testBed;
const {
watch: { id, type },
} = WATCH;
const SCHEDULED_TIME = '5';
const TRIGGERED_TIME = '5';
const IGNORE_CONDITION = true;
const ACTION_MODE = 'force_execute';
form.setInputValue('scheduledTimeInput', SCHEDULED_TIME);
form.setInputValue('triggeredTimeInput', TRIGGERED_TIME);
form.toggleEuiSwitch('ignoreConditionSwitch');
form.setInputValue('actionModesSelect', ACTION_MODE);
expect(exists('simulateResultsFlyout')).toBe(false);
httpRequestsMockHelpers.setLoadExecutionResultResponse({
watchHistoryItem: {
details: {},
watchStatus: {
actionStatuses: [],
},
},
});
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSimulateButton();
await nextTick();
component.update();
});
const latestRequest = server.requests[server.requests.length - 1];
const actionModes = Object.keys(defaultWatchJson.actions).reduce(
(actionAccum: any, action) => {
actionAccum[action] = ACTION_MODE;
return actionAccum;
},
{}
);
const executedWatch = {
id,
type,
isNew: true,
actions: [],
watch: defaultWatchJson,
};
const triggeredTime = `now+${TRIGGERED_TIME}s`;
const scheduledTime = `now+${SCHEDULED_TIME}s`;
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
executeDetails: getExecuteDetails({
triggerData: {
triggeredTime,
scheduledTime,
},
ignoreCondition: IGNORE_CONDITION,
actionModes,
}),
watch: executedWatch,
})
);
expect(exists('simulateResultsFlyout')).toBe(true);
expect(find('simulateResultsFlyoutTitle').text()).toEqual('Simulation results');
});
});
});
});
});

View file

@ -0,0 +1,786 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import axios from 'axios';
import { setupEnvironment, pageHelpers, nextTick } from './helpers';
import { WatchCreateThresholdTestBed } from './helpers/watch_create_threshold.helpers';
import { getExecuteDetails } from '../../test/fixtures';
import { WATCH_TYPES } from '../../common/constants';
const WATCH_NAME = 'my_test_watch';
const WATCH_TIME_FIELD = '@timestamp';
const MATCH_INDICES = ['index1'];
const ES_FIELDS = [{ name: '@timestamp', type: 'date' }];
const SETTINGS = {
action_types: {
email: { enabled: true },
index: { enabled: true },
jira: { enabled: true },
logging: { enabled: true },
pagerduty: { enabled: true },
slack: { enabled: true },
webhook: { enabled: true },
},
};
const WATCH_VISUALIZE_DATA = {
count: [[1559404800000, 14], [1559448000000, 196], [1559491200000, 44]],
};
const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
jest.mock('ui/chrome', () => ({
breadcrumbs: { set: () => {} },
addBasePath: (path: string) => path || '/api/watcher',
getUiSettingsClient: () => ({
get: () => {},
isDefault: () => true,
}),
}));
jest.mock('ui/time_buckets', () => {
class MockTimeBuckets {
setBounds(_domain: any) {
return {};
}
getInterval() {
return {
expression: {},
};
}
}
return { TimeBuckets: MockTimeBuckets };
});
jest.mock('../../public/lib/api', () => ({
...jest.requireActual('../../public/lib/api'),
loadIndexPatterns: async () => {
const INDEX_PATTERNS = [
{ attributes: { title: 'index1' } },
{ attributes: { title: 'index2' } },
{ attributes: { title: 'index3' } },
];
return await INDEX_PATTERNS;
},
getHttpClient: () => mockHttpClient,
}));
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
// Mocking EuiComboBox, as it utilizes "react-virtualized" for rendering search suggestions,
// which does not produce a valid component wrapper
EuiComboBox: (props: any) => (
<input
data-test-subj="mockComboBox"
onChange={async (syntheticEvent: any) => {
props.onChange([syntheticEvent['0']]);
}}
/>
),
}));
const { setup } = pageHelpers.watchCreateThreshold;
describe.skip('<ThresholdWatchEdit /> create route', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
let testBed: WatchCreateThresholdTestBed;
afterAll(() => {
server.restore();
});
describe('on component mount', () => {
beforeEach(async () => {
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
const { component } = testBed;
await nextTick();
component.update();
});
});
test('should set the correct page title', () => {
const { find } = testBed;
expect(find('pageTitle').text()).toBe('Create threshold alert');
});
describe('create', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadMatchingIndicesResponse({ indices: MATCH_INDICES });
httpRequestsMockHelpers.setLoadEsFieldsResponse({ fields: ES_FIELDS });
httpRequestsMockHelpers.setLoadSettingsResponse(SETTINGS);
httpRequestsMockHelpers.setLoadWatchVisualizeResponse(WATCH_VISUALIZE_DATA);
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
testBed.component.update();
});
});
describe('form validation', () => {
test('should not allow empty name field', () => {
const { form } = testBed;
form.setInputValue('nameInput', '');
expect(form.getErrorsMessages()).toContain('Name is required.');
});
test('should not allow empty time field', () => {
const { form } = testBed;
form.setInputValue('watchTimeFieldSelect', '');
expect(form.getErrorsMessages()).toContain('A time field is required.');
});
test('should not allow empty interval size field', () => {
const { form } = testBed;
form.setInputValue('triggerIntervalSizeInput', '');
expect(form.getErrorsMessages()).toContain('Interval size is required.');
});
test('should not allow negative interval size field', () => {
const { form } = testBed;
form.setInputValue('triggerIntervalSizeInput', '-1');
expect(form.getErrorsMessages()).toContain('Interval size cannot be a negative number.');
});
test('should disable the Create button with invalid fields', () => {
const { find } = testBed;
expect(find('saveWatchButton').props().disabled).toEqual(true);
});
test('it should enable the Create button and render additonal content with valid fields', async () => {
const { form, find, component, exists } = testBed;
form.setInputValue('nameInput', 'my_test_watch');
find('mockComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); // Using mocked EuiComboBox
form.setInputValue('watchTimeFieldSelect', '@timestamp');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
component.update();
});
expect(find('saveWatchButton').props().disabled).toEqual(false);
expect(find('watchConditionTitle').text()).toBe('Match the following condition');
expect(exists('watchVisualizationChart')).toBe(true);
expect(exists('watchActionsPanel')).toBe(true);
});
});
describe('actions', () => {
beforeEach(async () => {
const { form, find, component } = testBed;
// Set up valid fields needed for actions component to render
form.setInputValue('nameInput', WATCH_NAME);
find('mockComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); // Using mocked EuiComboBox
form.setInputValue('watchTimeFieldSelect', WATCH_TIME_FIELD);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
component.update();
});
});
test('should simulate a logging action', async () => {
const { form, find, actions, exists } = testBed;
const LOGGING_MESSAGE = 'test log message';
actions.clickAddActionButton();
actions.clickActionLink('logging');
expect(exists('watchActionAccordion')).toBe(true);
// First, provide invalid field and verify
form.setInputValue('loggingTextInput', '');
expect(form.getErrorsMessages()).toContain('Log text is required.');
expect(find('simulateActionButton').props().disabled).toEqual(true);
// Next, provide valid field and verify
form.setInputValue('loggingTextInput', LOGGING_MESSAGE);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSimulateButton();
await nextTick();
});
// Verify request
const latestRequest = server.requests[server.requests.length - 1];
const thresholdWatch = {
id: JSON.parse(latestRequest.requestBody).watch.id, // watch ID is created dynamically
name: WATCH_NAME,
type: WATCH_TYPES.THRESHOLD,
isNew: true,
actions: [
{
id: 'logging_1',
type: 'logging',
text: LOGGING_MESSAGE,
logging: {
text: LOGGING_MESSAGE,
},
},
],
index: MATCH_INDICES,
timeField: WATCH_TIME_FIELD,
triggerIntervalSize: 1,
triggerIntervalUnit: 'm',
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: 1000,
};
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
executeDetails: getExecuteDetails({
actionModes: {
logging_1: 'force_execute',
},
ignoreCondition: true,
recordExecution: false,
}),
watch: thresholdWatch,
})
);
});
test('should simulate an index action', async () => {
const { form, find, actions, exists } = testBed;
const INDEX = 'my_index';
actions.clickAddActionButton();
actions.clickActionLink('index');
expect(exists('watchActionAccordion')).toBe(true);
// First, provide invalid field and verify
form.setInputValue('indexInput', '');
expect(form.getErrorsMessages()).toContain('Index name is required.');
expect(find('simulateActionButton').props().disabled).toEqual(true);
// Next, provide valid field and verify
form.setInputValue('indexInput', INDEX);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSimulateButton();
await nextTick();
});
// Verify request
const latestRequest = server.requests[server.requests.length - 1];
const thresholdWatch = {
id: JSON.parse(latestRequest.requestBody).watch.id, // watch ID is created dynamically
name: WATCH_NAME,
type: WATCH_TYPES.THRESHOLD,
isNew: true,
actions: [
{
id: 'index_1',
type: 'index',
index: {
index: INDEX,
},
},
],
index: MATCH_INDICES,
timeField: WATCH_TIME_FIELD,
triggerIntervalSize: 1,
triggerIntervalUnit: 'm',
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: 1000,
};
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
executeDetails: getExecuteDetails({
actionModes: {
index_1: 'force_execute',
},
ignoreCondition: true,
recordExecution: false,
}),
watch: thresholdWatch,
})
);
});
test('should simulate a Slack action', async () => {
const { form, actions, exists } = testBed;
const SLACK_MESSAGE = 'test slack message';
actions.clickAddActionButton();
actions.clickActionLink('slack');
expect(exists('watchActionAccordion')).toBe(true);
form.setInputValue('slackMessageTextarea', SLACK_MESSAGE);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSimulateButton();
await nextTick();
});
// Verify request
const latestRequest = server.requests[server.requests.length - 1];
const thresholdWatch = {
id: JSON.parse(latestRequest.requestBody).watch.id, // watch ID is created dynamically
name: WATCH_NAME,
type: WATCH_TYPES.THRESHOLD,
isNew: true,
actions: [
{
id: 'slack_1',
type: 'slack',
text: SLACK_MESSAGE,
slack: {
message: {
text: SLACK_MESSAGE,
},
},
},
],
index: MATCH_INDICES,
timeField: WATCH_TIME_FIELD,
triggerIntervalSize: 1,
triggerIntervalUnit: 'm',
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: 1000,
};
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
executeDetails: getExecuteDetails({
actionModes: {
slack_1: 'force_execute',
},
ignoreCondition: true,
recordExecution: false,
}),
watch: thresholdWatch,
})
);
});
test('should simulate an email action', async () => {
const { form, find, actions, exists } = testBed;
const EMAIL_RECIPIENT = 'test@test.com';
const EMAIL_SUBJECT = 'test email subject';
const EMAIL_BODY = 'this is a test email body';
actions.clickAddActionButton();
actions.clickActionLink('email');
expect(exists('watchActionAccordion')).toBe(true);
// First, provide invalid fields and verify
form.setInputValue('emailBodyInput', '');
expect(form.getErrorsMessages()).toContain('Email body is required.');
expect(find('simulateActionButton').props().disabled).toEqual(true);
// Next, provide valid field and verify
find('watchActionAccordion.mockComboBox').simulate('change', [
{ label: EMAIL_RECIPIENT, value: EMAIL_RECIPIENT },
]); // Using mocked EuiComboBox
form.setInputValue('emailSubjectInput', EMAIL_SUBJECT);
form.setInputValue('emailBodyInput', EMAIL_BODY);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSimulateButton();
await nextTick();
});
// Verify request
const latestRequest = server.requests[server.requests.length - 1];
const thresholdWatch = {
id: JSON.parse(latestRequest.requestBody).watch.id, // watch ID is created dynamically
name: WATCH_NAME,
type: WATCH_TYPES.THRESHOLD,
isNew: true,
actions: [
{
id: 'email_1',
type: 'email',
to: [EMAIL_RECIPIENT],
subject: EMAIL_SUBJECT,
body: EMAIL_BODY,
email: {
to: [EMAIL_RECIPIENT],
subject: EMAIL_SUBJECT,
body: {
text: EMAIL_BODY,
},
},
},
],
index: MATCH_INDICES,
timeField: WATCH_TIME_FIELD,
triggerIntervalSize: 1,
triggerIntervalUnit: 'm',
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: 1000,
};
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
executeDetails: getExecuteDetails({
actionModes: {
email_1: 'force_execute',
},
ignoreCondition: true,
recordExecution: false,
}),
watch: thresholdWatch,
})
);
});
test('should simulate a webhook action', async () => {
const { form, find, actions, exists } = testBed;
const METHOD = 'put';
const HOST = 'localhost';
const PORT = '9200';
const PATH = '/test';
const USERNAME = 'test_user';
const PASSWORD = 'test_password';
actions.clickAddActionButton();
actions.clickActionLink('webhook');
expect(exists('watchActionAccordion')).toBe(true);
// First, provide invalid fields and verify
form.setInputValue('webhookHostInput', '');
form.setInputValue('webhookPortInput', '');
expect(form.getErrorsMessages()).toEqual([
'Webhook host is required.',
'Webhook port is required.',
]);
expect(find('simulateActionButton').props().disabled).toEqual(true);
// Next, provide valid fields and verify
form.setInputValue('webhookMethodSelect', METHOD);
form.setInputValue('webhookHostInput', HOST);
form.setInputValue('webhookPortInput', PORT);
form.setInputValue('webhookPathInput', PATH);
form.setInputValue('webhookUsernameInput', USERNAME);
form.setInputValue('webhookPasswordInput', PASSWORD);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSimulateButton();
await nextTick();
});
// Verify request
const latestRequest = server.requests[server.requests.length - 1];
const thresholdWatch = {
id: JSON.parse(latestRequest.requestBody).watch.id, // watch ID is created dynamically
name: WATCH_NAME,
type: WATCH_TYPES.THRESHOLD,
isNew: true,
actions: [
{
id: 'webhook_1',
type: 'webhook',
method: METHOD,
host: HOST,
port: Number(PORT),
path: PATH,
body:
'{\n "message": "Watch [{{ctx.metadata.name}}] has exceeded the threshold"\n}', // Default
username: USERNAME,
password: PASSWORD,
webhook: {
host: HOST,
port: Number(PORT),
},
},
],
index: MATCH_INDICES,
timeField: WATCH_TIME_FIELD,
triggerIntervalSize: 1,
triggerIntervalUnit: 'm',
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: 1000,
};
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
executeDetails: getExecuteDetails({
actionModes: {
webhook_1: 'force_execute',
},
ignoreCondition: true,
recordExecution: false,
}),
watch: thresholdWatch,
})
);
});
test('should simulate a Jira action', async () => {
const { form, find, actions, exists } = testBed;
const PROJECT_KEY = 'TEST_PROJECT_KEY';
const ISSUE_TYPE = 'Bug';
const SUMMARY = 'test Jira summary';
actions.clickAddActionButton();
actions.clickActionLink('jira');
expect(exists('watchActionAccordion')).toBe(true);
// First, provide invalid fields and verify
form.setInputValue('jiraProjectKeyInput', '');
form.setInputValue('jiraIssueTypeInput', '');
form.setInputValue('jiraSummaryInput', '');
expect(form.getErrorsMessages()).toEqual([
'Jira project key is required.',
'Jira issue type is required.',
'Jira summary is required.',
]);
expect(find('simulateActionButton').props().disabled).toEqual(true);
// Next, provide valid fields and verify
form.setInputValue('jiraProjectKeyInput', PROJECT_KEY);
form.setInputValue('jiraIssueTypeInput', ISSUE_TYPE);
form.setInputValue('jiraSummaryInput', SUMMARY);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSimulateButton();
await nextTick();
});
// Verify request
const latestRequest = server.requests[server.requests.length - 1];
const thresholdWatch = {
id: JSON.parse(latestRequest.requestBody).watch.id, // watch ID is created dynamically
name: WATCH_NAME,
type: WATCH_TYPES.THRESHOLD,
isNew: true,
actions: [
{
id: 'jira_1',
type: 'jira',
projectKey: PROJECT_KEY,
issueType: ISSUE_TYPE,
summary: SUMMARY,
jira: {
fields: {
project: {
key: PROJECT_KEY,
},
issuetype: {
name: ISSUE_TYPE,
},
summary: SUMMARY,
},
},
},
],
index: MATCH_INDICES,
timeField: WATCH_TIME_FIELD,
triggerIntervalSize: 1,
triggerIntervalUnit: 'm',
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: 1000,
};
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
executeDetails: getExecuteDetails({
actionModes: {
jira_1: 'force_execute',
},
ignoreCondition: true,
recordExecution: false,
}),
watch: thresholdWatch,
})
);
});
test('should simulate a PagerDuty action', async () => {
const { form, actions, exists, find } = testBed;
const DESCRIPTION = 'test pagerduty description';
actions.clickAddActionButton();
actions.clickActionLink('pagerduty');
expect(exists('watchActionAccordion')).toBe(true);
// First, provide invalid fields and verify
form.setInputValue('pagerdutyDescriptionInput', '');
expect(form.getErrorsMessages()).toContain('PagerDuty description is required.');
expect(find('simulateActionButton').props().disabled).toEqual(true);
// Next, provide valid fields and verify
form.setInputValue('pagerdutyDescriptionInput', DESCRIPTION);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSimulateButton();
await nextTick();
});
// Verify request
const latestRequest = server.requests[server.requests.length - 1];
const thresholdWatch = {
id: JSON.parse(latestRequest.requestBody).watch.id, // watch ID is created dynamically
name: WATCH_NAME,
type: WATCH_TYPES.THRESHOLD,
isNew: true,
actions: [
{
id: 'pagerduty_1',
type: 'pagerduty',
description: DESCRIPTION,
pagerduty: {
description: DESCRIPTION,
},
},
],
index: MATCH_INDICES,
timeField: WATCH_TIME_FIELD,
triggerIntervalSize: 1,
triggerIntervalUnit: 'm',
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: 1000,
};
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
executeDetails: getExecuteDetails({
actionModes: {
pagerduty_1: 'force_execute',
},
ignoreCondition: true,
recordExecution: false,
}),
watch: thresholdWatch,
})
);
});
});
describe('form payload', () => {
test('should send the correct payload', async () => {
const { form, find, component, actions } = testBed;
// Set up required fields
form.setInputValue('nameInput', WATCH_NAME);
find('mockComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); // Using mocked EuiComboBox
form.setInputValue('watchTimeFieldSelect', WATCH_TIME_FIELD);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
component.update();
actions.clickSubmitButton();
await nextTick();
});
// Verify request
const latestRequest = server.requests[server.requests.length - 1];
const thresholdWatch = {
id: JSON.parse(latestRequest.requestBody).id, // watch ID is created dynamically
name: WATCH_NAME,
type: WATCH_TYPES.THRESHOLD,
isNew: true,
actions: [],
index: MATCH_INDICES,
timeField: WATCH_TIME_FIELD,
triggerIntervalSize: 1,
triggerIntervalUnit: 'm',
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: 1000,
};
expect(latestRequest.requestBody).toEqual(JSON.stringify(thresholdWatch));
});
});
});
});
});

View file

@ -0,0 +1,229 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import axios from 'axios';
import { setupEnvironment, pageHelpers, nextTick } from './helpers';
import { WatchEditTestBed } from './helpers/watch_edit.helpers';
import { WATCH } from './helpers/constants';
import defaultWatchJson from '../../public/models/watch/default_watch.json';
import { getWatch } from '../../test/fixtures';
import { getRandomString } from '../../../../../test_utils';
const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
jest.mock('ui/chrome', () => ({
breadcrumbs: { set: () => {} },
addBasePath: (path: string) => path || '/api/watcher',
}));
jest.mock('ui/time_buckets', () => {
class MockTimeBuckets {
setBounds(_domain: any) {
return {};
}
getInterval() {
return {
expression: {},
};
}
}
return { TimeBuckets: MockTimeBuckets };
});
jest.mock('../../public/lib/api', () => ({
...jest.requireActual('../../public/lib/api'),
loadIndexPatterns: async () => {
const INDEX_PATTERNS = [
{ attributes: { title: 'index1' } },
{ attributes: { title: 'index2' } },
{ attributes: { title: 'index3' } },
];
return await INDEX_PATTERNS;
},
getHttpClient: () => mockHttpClient,
}));
const { setup } = pageHelpers.watchEdit;
describe.skip('<WatchEdit />', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
let testBed: WatchEditTestBed;
afterAll(() => {
server.restore();
});
describe('Advanced watch', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadWatchResponse(WATCH);
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
testBed.component.update();
});
});
describe('on component mount', () => {
test('should set the correct page title', () => {
const { find } = testBed;
expect(find('pageTitle').text()).toBe(`Edit ${WATCH.watch.name}`);
});
test('should populate the correct values', () => {
const { find, exists, component } = testBed;
const { watch } = WATCH;
const codeEditor = component.find('EuiCodeEditor');
expect(exists('jsonWatchForm')).toBe(true);
expect(find('nameInput').props().value).toBe(watch.name);
expect(find('idInput').props().value).toBe(watch.id);
expect(JSON.parse(codeEditor.props().value as string)).toEqual(defaultWatchJson);
// ID should not be editable
expect(find('idInput').props().readOnly).toEqual(true);
});
test('save a watch with new values', async () => {
const { form, actions } = testBed;
const { watch } = WATCH;
const EDITED_WATCH_NAME = 'new_watch_name';
form.setInputValue('nameInput', EDITED_WATCH_NAME);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSubmitButton();
await nextTick();
});
const latestRequest = server.requests[server.requests.length - 1];
const DEFAULT_LOGGING_ACTION_ID = 'logging_1';
const DEFAULT_LOGGING_ACTION_TYPE = 'logging';
const DEFAULT_LOGGING_ACTION_TEXT =
'There are {{ctx.payload.hits.total}} documents in your index. Threshold is 10.';
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
id: watch.id,
name: EDITED_WATCH_NAME,
type: watch.type,
isNew: false,
actions: [
{
id: DEFAULT_LOGGING_ACTION_ID,
type: DEFAULT_LOGGING_ACTION_TYPE,
text: DEFAULT_LOGGING_ACTION_TEXT,
[DEFAULT_LOGGING_ACTION_TYPE]: {
text: DEFAULT_LOGGING_ACTION_TEXT,
},
},
],
watch: defaultWatchJson,
})
);
});
});
});
describe('Threshold watch', () => {
const watch = getWatch({
id: getRandomString(),
type: 'threshold',
name: 'my_threshold_watch',
timeField: '@timestamp',
triggerIntervalSize: 10,
triggerIntervalUnit: 'm',
aggType: 'count',
termSize: 10,
thresholdComparator: '>',
timeWindowSize: 10,
timeWindowUnit: 'm',
threshold: [1000],
});
beforeEach(async () => {
httpRequestsMockHelpers.setLoadWatchResponse({ watch });
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
const { component } = testBed;
await nextTick();
component.update();
});
});
describe('on component mount', () => {
test('should set the correct page title', () => {
const { find } = testBed;
expect(find('pageTitle').text()).toBe(`Edit ${watch.name}`);
});
test('should populate the correct values', () => {
const { find, exists } = testBed;
expect(exists('thresholdWatchForm')).toBe(true);
expect(find('nameInput').props().value).toBe(watch.name);
expect(find('watchTimeFieldSelect').props().value).toBe(watch.timeField);
});
test('should save the watch with new values', async () => {
const { form, actions } = testBed;
const EDITED_WATCH_NAME = 'new_threshold_watch_name';
form.setInputValue('nameInput', EDITED_WATCH_NAME);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSubmitButton();
await nextTick();
});
const latestRequest = server.requests[server.requests.length - 1];
const {
id,
type,
timeField,
triggerIntervalSize,
triggerIntervalUnit,
aggType,
termSize,
thresholdComparator,
timeWindowSize,
timeWindowUnit,
threshold,
} = watch;
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
id,
name: EDITED_WATCH_NAME,
type,
isNew: false,
actions: [],
timeField,
triggerIntervalSize,
triggerIntervalUnit,
aggType,
termSize,
thresholdComparator,
timeWindowSize,
timeWindowUnit,
threshold: threshold && threshold[0],
})
);
});
});
});
});

View file

@ -0,0 +1,238 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import * as fixtures from '../../test/fixtures';
import {
setupEnvironment,
pageHelpers,
nextTick,
getRandomString,
findTestSubject,
} from './helpers';
import { WatchListTestBed } from './helpers/watch_list.helpers';
import { ROUTES } from '../../common/constants';
const { API_ROOT } = ROUTES;
jest.mock('ui/chrome', () => ({
breadcrumbs: { set: () => {} },
addBasePath: (path: string) => path || '/api/watcher',
}));
jest.mock('ui/time_buckets', () => {});
const { setup } = pageHelpers.watchList;
describe.skip('<WatchList />', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
let testBed: WatchListTestBed;
afterAll(() => {
server.restore();
});
describe('on component mount', () => {
beforeEach(async () => {
testBed = await setup();
});
describe('watches', () => {
describe('when there are no watches', () => {
beforeEach(() => {
httpRequestsMockHelpers.setLoadWatchesResponse({ watches: [] });
});
test('should display an empty prompt', async () => {
const { component, exists } = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
component.update();
});
expect(exists('emptyPrompt')).toBe(true);
expect(exists('emptyPrompt.createWatchButton')).toBe(true);
});
});
// create a threshold and advanced watch type and monitoring
describe('when there are watches', () => {
const watch1 = fixtures.getWatch({
name: `watchA-${getRandomString()}`,
id: `a-${getRandomString()}`,
type: 'threshold',
});
const watch2 = fixtures.getWatch({
name: `watchB-${getRandomString()}`,
id: `b-${getRandomString()}`,
type: 'json',
});
const watch3 = fixtures.getWatch({
name: `watchC-${getRandomString()}`,
id: `c-${getRandomString()}`,
type: 'monitoring',
isSystemWatch: true,
});
const watches = [watch1, watch2, watch3];
beforeEach(async () => {
httpRequestsMockHelpers.setLoadWatchesResponse({ watches });
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
testBed.component.update();
});
});
test('should set the correct app title', () => {
const { exists, find } = testBed;
expect(exists('appTitle')).toBe(true);
expect(find('appTitle').text()).toEqual('Watcher');
});
test('should have a link to the documentation', () => {
const { exists, find } = testBed;
expect(exists('documentationLink')).toBe(true);
expect(find('documentationLink').text()).toBe('Watcher docs');
});
test('should list them in the table', async () => {
const { table } = testBed;
const { tableCellsValues } = table.getMetaData('watchesTable');
const getExpectedValue = (value: any) => (typeof value === 'undefined' ? '' : value);
tableCellsValues.forEach((row, i) => {
const watch = watches[i];
const { name, id, watchStatus } = watch;
expect(row).toEqual([
'',
id, // required value
getExpectedValue(name),
watchStatus.state, // required value
getExpectedValue(watchStatus.comment),
getExpectedValue(watchStatus.lastMetCondition),
getExpectedValue(watchStatus.lastChecked),
'',
]);
});
});
test('should have a button to create a watch', () => {
const { exists } = testBed;
expect(exists('createWatchButton')).toBe(true);
});
test('should have a link to view watch details', () => {
const { table } = testBed;
const { rows } = table.getMetaData('watchesTable');
const idColumn = rows[0].columns[1].reactWrapper;
expect(findTestSubject(idColumn, `watchIdColumn-${watch1.id}`).length).toBe(1);
expect(findTestSubject(idColumn, `watchIdColumn-${watch1.id}`).props().href).toEqual(
`#/management/elasticsearch/watcher/watches/watch/${watch1.id}/status`
);
});
test('should have action buttons on each row to edit and delete a watch', () => {
const { table } = testBed;
const { rows } = table.getMetaData('watchesTable');
const lastColumn = rows[0].columns[rows[0].columns.length - 1].reactWrapper;
expect(findTestSubject(lastColumn, 'editWatchButton').length).toBe(1);
expect(findTestSubject(lastColumn, 'deleteWatchButton').length).toBe(1);
});
describe('system watch', () => {
test('should disable edit and delete actions', async () => {
const { table } = testBed;
const { rows } = table.getMetaData('watchesTable');
const systemWatch = rows[2];
const firstColumn = systemWatch.columns[0].reactWrapper;
const lastColumn = systemWatch.columns[rows[0].columns.length - 1].reactWrapper;
expect(
findTestSubject(firstColumn, `checkboxSelectRow-${watch3.id}`)
.getDOMNode()
.getAttribute('disabled')
).toEqual('');
expect(
findTestSubject(lastColumn, 'editWatchButton')
.getDOMNode()
.getAttribute('disabled')
).toEqual('');
expect(
findTestSubject(lastColumn, 'deleteWatchButton')
.getDOMNode()
.getAttribute('disabled')
).toEqual('');
});
});
describe('delete watch', () => {
test('should show a confirmation when clicking the delete watch button', async () => {
const { actions } = testBed;
await actions.clickWatchActionAt(0, 'delete');
// We need to read the document "body" as the modal is added there and not inside
// the component DOM tree.
expect(
document.body.querySelector('[data-test-subj="deleteWatchesConfirmation"]')
).not.toBe(null);
expect(
document.body.querySelector('[data-test-subj="deleteWatchesConfirmation"]')!
.textContent
).toContain('Delete watch');
});
test('should send the correct HTTP request to delete watch', async () => {
const { component, actions, table } = testBed;
const { rows } = table.getMetaData('watchesTable');
const watchId = rows[0].columns[2].value;
await actions.clickWatchActionAt(0, 'delete');
const modal = document.body.querySelector(
'[data-test-subj="deleteWatchesConfirmation"]'
);
const confirmButton: HTMLButtonElement | null = modal!.querySelector(
'[data-test-subj="confirmModalConfirmButton"]'
);
httpRequestsMockHelpers.setDeleteWatchResponse({
results: {
successes: [watchId],
errors: [],
},
});
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
confirmButton!.click();
await nextTick();
component.update();
});
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.method).toBe('POST');
expect(latestRequest.url).toBe(`${API_ROOT}/watches/delete`);
});
});
});
});
});
});

View file

@ -0,0 +1,285 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import { setupEnvironment, pageHelpers, nextTick } from './helpers';
import { WatchStatusTestBed } from './helpers/watch_status.helpers';
import { WATCH } from './helpers/constants';
import { getWatchHistory } from '../../test/fixtures';
import moment from 'moment';
import { ROUTES } from '../../common/constants';
import { WATCH_STATES, ACTION_STATES } from '../../common/constants';
const { API_ROOT } = ROUTES;
jest.mock('ui/chrome', () => ({
breadcrumbs: { set: () => {} },
addBasePath: (path: string) => path || '/api/watcher',
}));
jest.mock('ui/time_buckets', () => {});
const { setup } = pageHelpers.watchStatus;
const watchHistory1 = getWatchHistory({ startTime: '2019-06-04T01:11:11.294' });
const watchHistory2 = getWatchHistory({ startTime: '2019-06-04T01:10:10.987Z' });
const watchHistoryItems = { watchHistoryItems: [watchHistory1, watchHistory2] };
const ACTION_ID = 'my_logging_action_1';
const watch = {
...WATCH.watch,
watchStatus: {
state: WATCH_STATES.FIRING,
isActive: true,
actionStatuses: [
{
id: ACTION_ID,
state: ACTION_STATES.FIRING,
isAckable: true,
},
],
},
};
describe.skip('<WatchStatus />', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
let testBed: WatchStatusTestBed;
afterAll(() => {
server.restore();
});
describe('on component mount', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadWatchResponse({ watch });
httpRequestsMockHelpers.setLoadWatchHistoryResponse(watchHistoryItems);
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
testBed.component.update();
});
});
test('should set the correct page title', () => {
const { find } = testBed;
expect(find('pageTitle').text()).toBe(`Current status for '${watch.name}'`);
});
describe('tabs', () => {
test('should have 2 tabs', () => {
const { find } = testBed;
expect(find('tab').length).toBe(2);
expect(find('tab').map(t => t.text())).toEqual(['Execution history', 'Action statuses']);
});
test('should navigate to the "Action statuses" tab', () => {
const { exists, actions } = testBed;
expect(exists('watchHistorySection')).toBe(true);
expect(exists('watchDetailSection')).toBe(false);
actions.selectTab('action statuses');
expect(exists('watchHistorySection')).toBe(false);
expect(exists('watchDetailSection')).toBe(true);
});
});
describe('execution history', () => {
test('should list history items in the table', () => {
const { table } = testBed;
const { tableCellsValues } = table.getMetaData('watchHistoryTable');
const getExpectedValue = (value: any) => (typeof value === 'undefined' ? '' : value);
tableCellsValues.forEach((row, i) => {
const historyItem = watchHistoryItems.watchHistoryItems[i];
const { startTime, watchStatus } = historyItem;
expect(row).toEqual([
getExpectedValue(moment(startTime).format()),
getExpectedValue(watchStatus.state),
getExpectedValue(watchStatus.comment),
]);
});
});
test('should show execution history details on click', async () => {
const { actions, exists } = testBed;
const watchHistoryItem = {
...watchHistory1,
watchId: watch.id,
watchStatus: {
state: WATCH_STATES.FIRING,
actionStatuses: [
{
id: 'my_logging_action_1',
state: ACTION_STATES.FIRING,
isAckable: true,
},
],
},
};
const formattedStartTime = moment(watchHistoryItem.startTime).format();
httpRequestsMockHelpers.setLoadWatchHistoryItemResponse({ watchHistoryItem });
await actions.clickWatchExecutionAt(0, formattedStartTime);
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.method).toBe('GET');
expect(latestRequest.url).toBe(`${API_ROOT}/history/${watchHistoryItem.id}`);
expect(exists('watchHistoryDetailFlyout')).toBe(true);
});
});
describe('delete watch', () => {
test('should show a confirmation when clicking the delete button', async () => {
const { actions } = testBed;
await actions.clickDeleteWatchButton();
// We need to read the document "body" as the modal is added there and not inside
// the component DOM tree.
expect(
document.body.querySelector('[data-test-subj="deleteWatchesConfirmation"]')
).not.toBe(null);
expect(
document.body.querySelector('[data-test-subj="deleteWatchesConfirmation"]')!.textContent
).toContain('Delete watch');
});
test('should send the correct HTTP request to delete watch', async () => {
const { component, actions } = testBed;
await actions.clickDeleteWatchButton();
const modal = document.body.querySelector('[data-test-subj="deleteWatchesConfirmation"]');
const confirmButton: HTMLButtonElement | null = modal!.querySelector(
'[data-test-subj="confirmModalConfirmButton"]'
);
httpRequestsMockHelpers.setDeleteWatchResponse({
results: {
successes: [watch.id],
errors: [],
},
});
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
confirmButton!.click();
await nextTick();
component.update();
});
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.method).toBe('POST');
expect(latestRequest.url).toBe(`${API_ROOT}/watches/delete`);
});
});
describe('activate & deactive watch', () => {
test('should send the correct HTTP request to deactivate and activate a watch', async () => {
const { actions } = testBed;
httpRequestsMockHelpers.setDeactivateWatchResponse({
watchStatus: {
state: WATCH_STATES.DISABLED,
isActive: false,
},
});
await actions.clickToggleActivationButton();
const deactivateRequest = server.requests[server.requests.length - 1];
expect(deactivateRequest.method).toBe('PUT');
expect(deactivateRequest.url).toBe(`${API_ROOT}/watch/${watch.id}/deactivate`);
httpRequestsMockHelpers.setActivateWatchResponse({
watchStatus: {
state: WATCH_STATES.FIRING,
isActive: true,
},
});
await actions.clickToggleActivationButton();
const activateRequest = server.requests[server.requests.length - 1];
expect(activateRequest.method).toBe('PUT');
expect(activateRequest.url).toBe(`${API_ROOT}/watch/${watch.id}/activate`);
});
});
describe('action statuses', () => {
beforeEach(() => {
const { actions } = testBed;
actions.selectTab('action statuses');
});
test('should list the watch actions in a table', () => {
const { table } = testBed;
const { tableCellsValues } = table.getMetaData('watchActionStatusTable');
tableCellsValues.forEach((row, i) => {
const action = watch.watchStatus.actionStatuses[i];
const { id, state, isAckable } = action;
expect(row).toEqual([id, state, isAckable ? 'Acknowledge' : '']);
});
});
test('should allow an action to be acknowledged', async () => {
const { actions, table } = testBed;
httpRequestsMockHelpers.setAcknowledgeWatchResponse({
watchStatus: {
state: WATCH_STATES.FIRING,
isActive: true,
comment: 'Acked',
actionStatuses: [
{
id: ACTION_ID,
state: ACTION_STATES.ACKNOWLEDGED,
isAckable: false,
},
],
},
});
await actions.clickAcknowledgeButton(0);
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.method).toBe('PUT');
expect(latestRequest.url).toBe(
`${API_ROOT}/watch/${watch.id}/action/${ACTION_ID}/acknowledge`
);
const { tableCellsValues } = table.getMetaData('watchActionStatusTable');
tableCellsValues.forEach(row => {
expect(row).toEqual([ACTION_ID, ACTION_STATES.ACKNOWLEDGED, '']);
});
});
});
});
});

View file

@ -1,14 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { pluginDefinition } from '../plugin_definition';
describe ('pluginDefinition', () => {
it('defines the configPrefix correctly', () => {
expect(pluginDefinition.configPrefix).to.be('xpack.watcher');
});
});

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const ACTION_MODES = {
export const ACTION_MODES: { [key: string]: string } = {
// The action execution will be simulated. For example, The email action will create the email that would have been sent but will not actually send it. In this mode, the action may be throttled if the current state of the watch indicates it should be.
SIMULATE: 'simulate',
@ -19,6 +18,5 @@ export const ACTION_MODES = {
FORCE_EXECUTE: 'force_execute',
// The action will be skipped and wont be executed nor simulated. Effectively forcing the action to be throttled.
SKIP: 'skip'
SKIP: 'skip',
};

View file

@ -6,36 +6,34 @@
import { i18n } from '@kbn/i18n';
export const ACTION_STATES = {
export const ACTION_STATES: { [key: string]: string } = {
// Action is not being executed because conditions haven't been met
OK: i18n.translate('xpack.watcher.constants.actionStates.okStateText', {
defaultMessage: 'OK'
defaultMessage: 'OK',
}),
// Action has been acknowledged by user
ACKNOWLEDGED: i18n.translate('xpack.watcher.constants.actionStates.acknowledgedStateText', {
defaultMessage: 'Acked'
defaultMessage: 'Acked',
}),
// Action has been throttled (time-based) by the system
THROTTLED: i18n.translate('xpack.watcher.constants.actionStates.throttledStateText', {
defaultMessage: 'Throttled'
defaultMessage: 'Throttled',
}),
// Action has been completed
FIRING: i18n.translate('xpack.watcher.constants.actionStates.firingStateText', {
defaultMessage: 'Firing'
defaultMessage: 'Firing',
}),
// Action has failed
ERROR: i18n.translate('xpack.watcher.constants.actionStates.errorStateText', {
defaultMessage: 'Error'
defaultMessage: 'Error',
}),
// Action has a configuration error
CONFIG_ERROR: i18n.translate('xpack.watcher.constants.actionStates.configErrorStateText', {
defaultMessage: 'Config error'
defaultMessage: 'Config error',
}),
};

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const ACTION_TYPES = {
export const ACTION_TYPES: { [key: string]: string } = {
EMAIL: 'email',
WEBHOOK: 'webhook',
@ -14,14 +13,11 @@ export const ACTION_TYPES = {
LOGGING: 'logging',
HIPCHAT: 'hipchat',
SLACK: 'slack',
JIRA: 'jira',
PAGERDUTY: 'pagerduty',
UNKNOWN: 'unknown/invalid'
UNKNOWN: 'unknown/invalid',
};

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const AGG_TYPES = {
export const AGG_TYPES: { [key: string]: string } = {
COUNT: 'count',
AVERAGE: 'avg',
@ -14,6 +13,5 @@ export const AGG_TYPES = {
MIN: 'min',
MAX: 'max'
MAX: 'max',
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const COMPARATORS: { [key: string]: string } = {
GREATER_THAN: '>',
GREATER_THAN_OR_EQUALS: '>=',
BETWEEN: 'between',
LESS_THAN: '<',
LESS_THAN_OR_EQUALS: '<=',
};

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const ERROR_CODES = {
export const ERROR_CODES: { [key: string]: string } = {
// Property missing on object
ERR_PROP_MISSING: 'ERR_PROP_MISSING',
};

View file

@ -4,11 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const ES_SCROLL_SETTINGS = {
export const ES_SCROLL_SETTINGS: {
KEEPALIVE: string;
PAGE_SIZE: number;
} = {
// How long to keep a scroll alive
KEEPALIVE: '30s',
// How many results to return per scroll response
PAGE_SIZE: 100
PAGE_SIZE: 100,
};

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const INDEX_NAMES = {
export const INDEX_NAMES: { [key: string]: string } = {
WATCHES: '.watches',
WATCHER_HISTORY: '.watcher-history-*',
};

View file

@ -5,6 +5,6 @@
*/
// Durations are in ms
export const LISTS = {
NEW_ITEMS_HIGHLIGHT_DURATION: 1 * 1000
export const LISTS: { [key: string]: number } = {
NEW_ITEMS_HIGHLIGHT_DURATION: 1 * 1000,
};

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const PAGINATION: { initialPageSize: number; pageSizeOptions: number[] } = {
initialPageSize: 10,
pageSizeOptions: [10, 50, 100],
};

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { LICENSE_TYPE_GOLD, LicenseType } from '../../../../common/constants';
export const PLUGIN = {
ID: 'watcher',
MINIMUM_LICENSE_REQUIRED: LICENSE_TYPE_GOLD as LicenseType,
getI18nName: (i18n: any): string => {
return i18n.translate('xpack.watcher.appName', {
defaultMessage: 'Watcher',
});
},
};

View file

@ -7,8 +7,8 @@
// In milliseconds
const SIXTY_SECONDS = 60 * 1000;
export const REFRESH_INTERVALS = {
export const REFRESH_INTERVALS: { [key: string]: number } = {
WATCH_LIST: SIXTY_SECONDS,
WATCH_HISTORY: SIXTY_SECONDS,
WATCH_VISUALIZATION: SIXTY_SECONDS
WATCH_VISUALIZATION: SIXTY_SECONDS,
};

View file

@ -4,6 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const ROUTES = {
export const ROUTES: { [key: string]: string } = {
API_ROOT: '/api/watcher',
};

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const SORT_ORDERS = {
export const SORT_ORDERS: { [key: string]: string } = {
ASCENDING: 'asc',
DESCENDING: 'desc'
DESCENDING: 'desc',
};

View file

@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const TIME_UNITS = {
export const TIME_UNITS: { [key: string]: string } = {
SECOND: 's',
MINUTE: 'm',
HOUR: 'h',
DAY: 'd'
DAY: 'd',
};

View file

@ -4,10 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const COMPARATORS = {
GREATER_THAN: '>',
LESS_THAN: '<'
export const WATCH_HISTORY: { [key: string]: string } = {
INITIAL_RANGE: 'now-1h',
};

View file

@ -1,33 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const WATCH_STATE_COMMENTS = {
OK: '',
PARTIALLY_THROTTLED: i18n.translate('xpack.watcher.constants.watchStateComments.partiallyThrottledStateCommentText', {
defaultMessage: 'Partially Throttled'
}),
THROTTLED: i18n.translate('xpack.watcher.constants.watchStateComments.throttledStateCommentText', {
defaultMessage: 'Throttled'
}),
PARTIALLY_ACKNOWLEDGED: i18n.translate('xpack.watcher.constants.watchStateComments.partiallyAcknowledgedStateCommentText', {
defaultMessage: 'Partially Acked'
}),
ACKNOWLEDGED: i18n.translate('xpack.watcher.constants.watchStateComments.acknowledgedStateCommentText', {
defaultMessage: 'Acked'
}),
FAILING: i18n.translate('xpack.watcher.constants.watchStateComments.executionFailingStateCommentText', {
defaultMessage: 'Execution Failing'
}),
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const WATCH_STATE_COMMENTS: { [key: string]: string } = {
OK: '',
PARTIALLY_THROTTLED: i18n.translate(
'xpack.watcher.constants.watchStateComments.partiallyThrottledStateCommentText',
{
defaultMessage: 'Partially throttled',
}
),
THROTTLED: i18n.translate(
'xpack.watcher.constants.watchStateComments.throttledStateCommentText',
{
defaultMessage: 'Throttled',
}
),
PARTIALLY_ACKNOWLEDGED: i18n.translate(
'xpack.watcher.constants.watchStateComments.partiallyAcknowledgedStateCommentText',
{
defaultMessage: 'Partially acked',
}
),
ACKNOWLEDGED: i18n.translate(
'xpack.watcher.constants.watchStateComments.acknowledgedStateCommentText',
{
defaultMessage: 'Acked',
}
),
FAILING: i18n.translate(
'xpack.watcher.constants.watchStateComments.executionFailingStateCommentText',
{
defaultMessage: 'Execution failing',
}
),
};

View file

@ -6,26 +6,24 @@
import { i18n } from '@kbn/i18n';
export const WATCH_STATES = {
export const WATCH_STATES: { [key: string]: string } = {
DISABLED: i18n.translate('xpack.watcher.constants.watchStates.disabledStateText', {
defaultMessage: 'Disabled'
defaultMessage: 'Disabled',
}),
OK: i18n.translate('xpack.watcher.constants.watchStates.okStateText', {
defaultMessage: 'OK'
defaultMessage: 'OK',
}),
FIRING: i18n.translate('xpack.watcher.constants.watchStates.firingStateText', {
defaultMessage: 'Firing'
defaultMessage: 'Firing',
}),
ERROR: i18n.translate('xpack.watcher.constants.watchStates.errorStateText', {
defaultMessage: 'Error!'
defaultMessage: 'Error',
}),
CONFIG_ERROR: i18n.translate('xpack.watcher.constants.watchStates.configErrorStateText', {
defaultMessage: 'Config error'
defaultMessage: 'Config error',
}),
};

View file

@ -4,12 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const WATCH_TYPES = {
export const WATCH_TYPES: { [key: string]: string } = {
JSON: 'json',
THRESHOLD: 'threshold',
MONITORING: 'monitoring'
MONITORING: 'monitoring',
};

View file

@ -56,17 +56,6 @@ describe('get_action_type', () => {
expect(type).to.be(ACTION_TYPES.LOGGING);
});
it(`correctly calculates ACTION_TYPES.HIPCHAT`, () => {
const actionJson = {
hipchat: {
'foo': 'bar'
}
};
const type = getActionType(actionJson);
expect(type).to.be(ACTION_TYPES.HIPCHAT);
});
it(`correctly calculates ACTION_TYPES.SLACK`, () => {
const actionJson = {
slack: {

View file

@ -4,14 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { keys, values, intersection } from 'lodash';
import { intersection, keys, values } from 'lodash';
import { ACTION_TYPES } from '../../constants';
export function getActionType(action) {
const type = intersection(
keys(action),
values(ACTION_TYPES)
)[0] || ACTION_TYPES.UNKNOWN;
export function getActionType(action: { [key: string]: { [key: string]: any } }) {
const type = intersection(keys(action), values(ACTION_TYPES))[0] || ACTION_TYPES.UNKNOWN;
return type;
}

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
type EmailActionType = 'email';
type LoggingActionType = 'logging';
type WebhookActionType = 'webhook';
type IndexActionType = 'index';
type SlackActionType = 'slack';
type JiraActionType = 'jira';
type PagerDutyActionType = 'pagerduty';
export interface BaseAction {
id: string;
typeName: string;
isNew: boolean;
simulateMessage: string;
simulateFailMessage: string;
simulatePrompt: string;
selectMessage: string;
validate: () => { [key: string]: string[] };
isEnabled: boolean;
}
export interface EmailAction extends BaseAction {
type: EmailActionType;
iconClass: 'email';
to: string[];
subject?: string;
body: string;
}
export interface LoggingAction extends BaseAction {
type: LoggingActionType;
iconClass: 'loggingApp';
text: string;
}
export interface IndexAction extends BaseAction {
type: IndexActionType;
iconClass: 'indexOpen';
index: string;
}
export interface PagerDutyAction extends BaseAction {
type: PagerDutyActionType;
iconClass: 'apps';
description: string;
}
export interface WebhookAction extends BaseAction {
type: WebhookActionType;
iconClass: 'logoWebhook';
method?: 'head' | 'get' | 'post' | 'put' | 'delete';
host: string;
port: number;
path?: string;
body?: string;
username?: string;
password?: string;
}
export interface SlackAction extends BaseAction {
type: SlackActionType;
iconClass: 'logoSlack';
text?: string;
to?: string[];
}
export interface JiraAction extends BaseAction {
type: JiraActionType;
iconClass: 'apps';
projectKey: string;
issueType: string;
summary: string;
}
export type ActionType =
| EmailAction
| LoggingAction
| IndexAction
| SlackAction
| JiraAction
| PagerDutyAction;

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;
* you may not use this file except in compliance with the Elastic License.
*/
export interface ExecutedWatchResults {
id: string;
watchId: string;
details: any;
startTime: Date;
watchStatus: {
state: string;
actionStatuses: Array<{ state: string; lastExecutionReason: string; id: string }>;
};
}
export interface ExecutedWatchDetails {
scheduledTimeValue: string | undefined;
scheduledTimeUnit: string;
triggeredTimeValue: string | undefined;
triggeredTimeUnit: string;
ignoreCondition: boolean;
alternativeInput: any;
actionModes: {
[key: string]: string;
};
recordExecution: boolean;
upstreamJson: any;
}
export interface BaseWatch {
id: string;
type: string;
isNew: boolean;
name: string;
isSystemWatch: boolean;
watchStatus: any;
watchErrors: any;
typeName: string;
displayName: string;
upstreamJson: any;
resetActions: () => void;
createAction: (type: string, actionProps: {}) => void;
validate: () => { warning: { message: string; title?: string } };
actions: [
{
id: string;
type: string;
}
];
watch: {
actions: {
[key: string]: { [key: string]: any };
};
};
}

View file

@ -5,6 +5,7 @@
*/
import { resolve } from 'path';
import { i18n } from '@kbn/i18n';
import { registerFieldsRoutes } from './server/routes/api/fields';
import { registerSettingsRoutes } from './server/routes/api/settings';
import { registerHistoryRoutes } from './server/routes/api/history';
@ -12,7 +13,7 @@ import { registerIndicesRoutes } from './server/routes/api/indices';
import { registerLicenseRoutes } from './server/routes/api/license';
import { registerWatchesRoutes } from './server/routes/api/watches';
import { registerWatchRoutes } from './server/routes/api/watch';
import { registerLicenseChecker } from './server/lib/register_license_checker';
import { registerLicenseChecker } from '../../server/lib/register_license_checker';
import { PLUGIN } from './common/constants';
export const pluginDefinition = {
@ -21,17 +22,18 @@ export const pluginDefinition = {
publicDir: resolve(__dirname, 'public'),
require: ['kibana', 'elasticsearch', 'xpack_main'],
uiExports: {
managementSections: [
'plugins/watcher/sections/watch_detail',
'plugins/watcher/sections/watch_edit',
'plugins/watcher/sections/watch_list',
'plugins/watcher/sections/watch_history_item',
],
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
home: ['plugins/watcher/register_feature']
managementSections: ['plugins/watcher'],
home: ['plugins/watcher/register_feature'],
},
init: function (server) {
registerLicenseChecker(server);
// Register license checker
registerLicenseChecker(
server,
PLUGIN.ID,
PLUGIN.getI18nName(i18n),
PLUGIN.MINIMUM_LICENSE_REQUIRED
);
registerFieldsRoutes(server);
registerHistoryRoutes(server);
@ -40,5 +42,5 @@ export const pluginDefinition = {
registerSettingsRoutes(server);
registerWatchesRoutes(server);
registerWatchRoutes(server);
}
},
};

View file

@ -1,4 +0,0 @@
.mgtWatcher__list {
display: flex;
flex-grow: 1;
}

View file

@ -0,0 +1,3 @@
<kbn-management-app section="elasticsearch/watcher">
<div id="watchReactRoot"></div>
</kbn-management-app>

View file

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { HashRouter, Switch, Route, Redirect } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import { WatchStatus } from './sections/watch_status/components/watch_status';
import { WatchEdit } from './sections/watch_edit/components/watch_edit';
import { WatchList } from './sections/watch_list/components/watch_list';
import { registerRouter } from './lib/navigation';
import { BASE_PATH } from './constants';
import { LICENSE_STATUS_VALID } from '../../../common/constants';
import { EuiCallOut, EuiLink } from '@elastic/eui';
class ShareRouter extends Component {
static contextTypes = {
router: PropTypes.shape({
history: PropTypes.shape({
push: PropTypes.func.isRequired,
createHref: PropTypes.func.isRequired
}).isRequired
}).isRequired
}
constructor(...args) {
super(...args);
this.registerRouter();
}
registerRouter() {
// Share the router with the app without requiring React or context.
const { router } = this.context;
registerRouter(router);
}
render() {
return this.props.children;
}
}
export const App = ({ licenseStatus }) => {
const { status, message } = licenseStatus;
if (status !== LICENSE_STATUS_VALID) {
return (
<EuiCallOut
title={(
<FormattedMessage
id="xpack.watcher.app.licenseErrorTitle"
defaultMessage="License error"
/>
)}
color="warning"
iconType="help"
>
{message}{' '}
<EuiLink href="#/management/elasticsearch/license_management/home">
<FormattedMessage
id="xpack.watcher.app.licenseErrorLinkText"
defaultMessage="Manage your license."
/>
</EuiLink>
</EuiCallOut>
);
}
return (
<HashRouter>
<ShareRouter>
<AppWithoutRouter />
</ShareRouter>
</HashRouter>
);
};
// Export this so we can test it with a different router.
export const AppWithoutRouter = () => (
<Switch>
<Route exact path={`${BASE_PATH}watches`} component={WatchList} />
<Route exact path={`${BASE_PATH}watches/watch/:id/status`} component={WatchStatus} />
<Route exact path={`${BASE_PATH}watches/watch/:id/edit`} component={WatchEdit} />
<Route exact path={`${BASE_PATH}watches/new-watch/:type(json|threshold)`} component={WatchEdit} />
<Redirect from={BASE_PATH} to={`${BASE_PATH}watches`} />
</Switch>
);

View file

@ -1,22 +0,0 @@
<div class="kuiIcon">
<div
class="kuiIcon kuiIcon--success fa-check"
ng-if="actionStateIcon.actionStatus.state === actionStateIcon.ACTION_STATES.OK"
></div>
<div
class="kuiIcon kuiIcon--warning fa-thumbs-up"
ng-if="actionStateIcon.actionStatus.state === actionStateIcon.ACTION_STATES.ACKNOWLEDGED"
></div>
<div
class="kuiIcon kuiIcon--warning fa-clock-o"
ng-if="actionStateIcon.actionStatus.state === actionStateIcon.ACTION_STATES.THROTTLED"
></div>
<div
class="kuiIcon kuiIcon--warning fa-bullhorn"
ng-if="actionStateIcon.actionStatus.state === actionStateIcon.ACTION_STATES.FIRING"
></div>
<div
class="kuiIcon kuiIcon--error fa-exclamation-triangle"
ng-if="actionStateIcon.actionStatus.state === actionStateIcon.ACTION_STATES.ERROR || actionStateIcon.actionStatus.state === actionStateIcon.ACTION_STATES.CONFIG_ERROR"
></div>
</div>

View file

@ -1,29 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiModules } from 'ui/modules';
import template from './action_state_icon.html';
import { ACTION_STATES } from 'plugins/watcher/../common/constants';
const app = uiModules.get('xpack/watcher');
app.directive('actionStateIcon', function () {
return {
restrict: 'E',
replace: true,
template: template,
scope: {
actionStatus: '='
},
bindToController: true,
controllerAs: 'actionStateIcon',
controller: class ActionStateIconController {
constructor() {
this.ACTION_STATES = ACTION_STATES;
}
}
};
});

View file

@ -1,44 +0,0 @@
.actionTypeChoice {
display: flex;
}
.actionTypeChoice--disabled {
opacity: 0.5;
cursor: default;
}
.actionTypeIcon {
flex: 1 0 auto;
margin-right: $euiSizeM;
font-size: $euiFontSizeL;
}
.actionTypeDescription {
flex: 1 1 auto;
width: 100%;
}
.watchActionsActionType {
flex: 1 1 auto;
width: 450px;
.ui-select-bootstrap > .ui-select-choices {
min-width: 350px;
}
// Bootstrap ui select tweaks
.ui-select-bootstrap .ui-select-choices-row>span {
padding: $euiSizeM $euiSizeL;
}
.ui-select-match .btn {
border-color: $euiColorPrimary;
color: $euiColorPrimary;
}
.ui-select-placeholder {
color: $euiColorPrimary !important;
padding-right: $euiSizeM;
}
}

View file

@ -1 +0,0 @@
@import 'action_type_select';

View file

@ -1,34 +0,0 @@
<ui-select
ng-model="actionTypeSelect.selectedItem.value"
on-select="actionTypeSelect.onSelect($select.selected)"
>
<ui-select-match placeholder="{{ 'xpack.watcher.actionTypeSelect.addNewActionPlaceholder' | i18n: { defaultMessage: 'Add new action' } }}">
{{$select.selected.typeName}}
</ui-select-match>
<ui-select-choices repeat="actionType in actionTypeSelect.actionTypes | filter:$select.search">
<div
class="actionTypeChoice"
ng-class="{
'actionTypeChoice--disabled': !actionType.isEnabled
}"
>
<div class="actionTypeIcon">
<span ng-class="actionType.iconClass"></span>
</div>
<div class="actionTypeDescription">
<div
ng-bind-html="actionType.typeName | highlight: $select.search"
></div>
<div class="action-type-description">
<span ng-if="actionType.isEnabled" ng-bind-html="actionType.selectMessage | highlight: $select.search"></span>
<span
ng-if="!actionType.isEnabled"
i18n-id="xpack.watcher.actionTypeSelect.actionDisabledTextMessage"
i18n-default-message="Disabled. Configure {elasticsearchYmlText}."
i18n-values="{ elasticsearchYmlText: 'elasticsearch.yml' }"
></span>
</div>
</div>
</div>
</ui-select-choices>
</ui-select>

View file

@ -1,72 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { map } from 'lodash';
import { uiModules } from 'ui/modules';
import template from './action_type_select.html';
import 'ui/angular_ui_select';
import { Action } from 'plugins/watcher/models/action';
import 'plugins/watcher/services/settings';
const app = uiModules.get('xpack/watcher');
app.directive('actionTypeSelect', function ($injector) {
const watcherSettingsService = $injector.get('xpackWatcherSettingsService');
return {
restrict: 'E',
template: template,
scope: {
onChange: '='
},
controllerAs: 'actionTypeSelect',
bindToController: true,
controller: class ActionTypeSelectController {
constructor() {
this.selectedItem = { value: null };
this.loadActionTypes()
.then(actionTypes => {
this.actionTypes = actionTypes.filter((actionType) => {
// 'Action' is the default action type. If an action has the default then it's
// not fully implemented and shouldn't be presented to the user.
return actionType.typeName !== 'Action';
});
});
}
loadActionTypes() {
const allActionTypes = Action.getActionTypes();
// load the configuration settings to determine which actions are enabled
return watcherSettingsService.getSettings()
.then(settings => {
const result = map(allActionTypes, ({ typeName, iconClass, selectMessage }, type) => {
const isEnabled = settings.actionTypes[type].enabled;
return {
type,
typeName,
iconClass,
selectMessage,
isEnabled
};
});
return result;
});
}
onSelect(actionType) {
this.selectedItem = { value: null };
if (actionType.isEnabled) {
this.onChange(actionType.type);
}
}
}
};
});

View file

@ -1,21 +0,0 @@
.chartTooltip {
// The following rules adapted from ui/vislib/styles/_tooltip.less
line-height: 1.1;
font-size: $euiFontSize;
font-weight: normal;
background-color: $euiColorDarkestShade;
color: $euiColorEmptyShade;
border-radius: $euiBorderRadius;
position: fixed;
z-index: 120;
word-wrap: break-word;
max-width: 40%;
overflow: hidden;
pointer-events: none;
margin: $euiSizeS;
padding: $euiSizeXS;
}
.chartTooltip__label {
font-weight: bold;
}

View file

@ -1 +0,0 @@
@import 'chart_tooltip';

View file

@ -1,5 +0,0 @@
<div
class="chartTooltip"
ng-style="chartTooltip.style"
ng-transclude
></div>

View file

@ -1,70 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash';
import { uiModules } from 'ui/modules';
import template from './chart_tooltip.html';
function calculateTooltipPosition(pointPosition, plotPosition, tooltipWidth, tooltipHeight) {
const tooltipPosition = {
top: get(pointPosition, 'top'),
left: get(pointPosition, 'left')
};
const tooltipPositionBottom = pointPosition.top + tooltipHeight;
const isTooltipBeyondPlotBottom = tooltipPositionBottom > get(plotPosition, 'bottom');
if (isTooltipBeyondPlotBottom) {
tooltipPosition.top -= tooltipHeight;
}
const tooltipPositionRight = pointPosition.left + tooltipWidth;
const isTooltipBeyondPlotRight = tooltipPositionRight > get(plotPosition, 'right');
if (isTooltipBeyondPlotRight) {
tooltipPosition.left -= tooltipWidth;
}
return tooltipPosition;
}
const app = uiModules.get('xpack/watcher');
app.directive('chartTooltip', function () {
return {
restrict: 'E',
replace: true,
transclude: true,
template: template,
scope: {
pointPosition: '=',
plotPosition: '='
},
controllerAs: 'chartTooltip',
bindToController: true,
link: ($scope, element) => {
$scope.$watchGroup([
'chartTooltip.pointPosition',
'chartTooltip.plotPosition'
], () => {
// Calculate tooltip position. This is especially necessary to make sure the tooltip
// stays within the bounds of the chart.
const pointPosition = $scope.chartTooltip.pointPosition;
const plotPosition = $scope.chartTooltip.plotPosition;
const tooltipMargin = parseInt(element.css('margin')); // assumption: value is in px
const tooltipWidth = element[0].scrollWidth + (2 * tooltipMargin);
const tooltipHeight = element[0].scrollHeight + (2 * tooltipMargin);
const tooltipPosition = calculateTooltipPosition(pointPosition, plotPosition, tooltipWidth, tooltipHeight);
$scope.chartTooltip.style = tooltipPosition;
});
},
controller: class ChartTooltipController {
constructor() {
}
}
};
});

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
export const ConfirmWatchesModal = ({
modalOptions,
callback,
}: {
modalOptions: {
title: string;
message: string;
buttonLabel?: string;
buttonType?: 'primary' | 'danger';
} | null;
callback: (isConfirmed?: boolean) => void;
}) => {
if (!modalOptions) {
return null;
}
const { title, message, buttonType, buttonLabel } = modalOptions;
return (
<EuiOverlayMask>
<EuiConfirmModal
buttonColor={buttonType ? buttonType : 'primary'}
title={title}
onCancel={() => callback()}
onConfirm={() => {
callback(true);
}}
cancelButtonText={i18n.translate(
'xpack.watcher.sections.watchEdit.json.saveConfirmModal.cancelButtonLabel',
{ defaultMessage: 'Cancel' }
)}
confirmButtonText={
buttonLabel
? buttonLabel
: i18n.translate(
'xpack.watcher.sections.watchEdit.json.saveConfirmModal.saveButtonLabel',
{ defaultMessage: 'Save watch' }
)
}
>
{message}
</EuiConfirmModal>
</EuiOverlayMask>
);
};

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { toastNotifications } from 'ui/notify';
import { deleteWatches } from '../lib/api';
export const DeleteWatchesModal = ({
watchesToDelete,
callback,
}: {
watchesToDelete: string[];
callback: (deleted?: string[]) => void;
}) => {
const numWatchesToDelete = watchesToDelete.length;
if (!numWatchesToDelete) {
return null;
}
const confirmModalText = i18n.translate(
'xpack.watcher.deleteSelectedWatchesConfirmModal.descriptionText',
{
defaultMessage:
"You can't recover {numWatchesToDelete, plural, one {a deleted watch} other {deleted watches}}.",
values: { numWatchesToDelete },
}
);
const confirmButtonText = i18n.translate(
'xpack.watcher.deleteSelectedWatchesConfirmModal.deleteButtonLabel',
{
defaultMessage: 'Delete {numWatchesToDelete, plural, one {watch} other {# watches}} ',
values: { numWatchesToDelete },
}
);
const cancelButtonText = i18n.translate(
'xpack.watcher.deleteSelectedWatchesConfirmModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
);
return (
<EuiOverlayMask>
<EuiConfirmModal
buttonColor="danger"
data-test-subj="deleteWatchesConfirmation"
title={confirmButtonText}
onCancel={() => callback()}
onConfirm={async () => {
const { successes, errors } = await deleteWatches(watchesToDelete);
const numSuccesses = successes.length;
const numErrors = errors.length;
callback(successes);
if (numSuccesses > 0) {
toastNotifications.addSuccess(
i18n.translate(
'xpack.watcher.sections.watchList.deleteSelectedWatchesSuccessNotification.descriptionText',
{
defaultMessage:
'Deleted {numSuccesses, number} {numSuccesses, plural, one {watch} other {watches}}',
values: { numSuccesses },
}
)
);
}
if (numErrors > 0) {
toastNotifications.addDanger(
i18n.translate(
'xpack.watcher.sections.watchList.deleteSelectedWatchesErrorNotification.descriptionText',
{
defaultMessage:
'Failed to delete {numErrors, number} {numErrors, plural, one {watch} other {watches}}',
values: { numErrors },
}
)
);
}
}}
cancelButtonText={cancelButtonText}
confirmButtonText={confirmButtonText}
>
{confirmModalText}
</EuiConfirmModal>
</EuiOverlayMask>
);
};

View file

@ -1,11 +0,0 @@
.durationSelect {
white-space: nowrap;
}
.durationSelectSize {
display: inline-block;
}
.durationSelectUnit {
display: inline-block;
}

View file

@ -1 +0,0 @@
@import 'duration_select';

View file

@ -1,24 +0,0 @@
<div class="durationSelect">
<input
id="{{durationSelect.makeId('size')}}"
name="{{durationSelect.makeId('size')}}"
type="number"
class="durationSelectSize kuiTextInput watcherNumberInput"
min="0"
required
ng-model="durationSelect.size"
aria-label="{{ 'xpack.watcher.durationSelect.durationAmountAriaLabel' | i18n: { defaultMessage: 'Duration amount' } }}"
aria-describedby="{{durationSelect.describedBy}}"
>
<select
id="{{durationSelect.makeId('unit')}}"
name="{{durationSelect.makeId('unit')}}"
class="durationSelectUnit kuiSelect"
required
ng-model="durationSelect.unit"
ng-options="commonTimeUnit as timeUnit.labelPlural for (commonTimeUnit, timeUnit) in durationSelect.timeUnits"
aria-label="{{ 'xpack.watcher.durationSelect.durationTimeUnitAriaLabel' | i18n: { defaultMessage: 'Duration time unit' } }}"
aria-describedby="{{durationSelect.describedBy}}"
></select>
<div>

View file

@ -1,76 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import 'ui/fancy_forms';
import { uiModules } from 'ui/modules';
import { InitAfterBindingsWorkaround } from 'ui/compat';
import { TIME_UNITS } from 'plugins/watcher/constants';
import template from './duration_select.html';
import moment from 'moment';
import 'plugins/watcher/services/html_id_generator';
const app = uiModules.get('xpack/watcher');
app.directive('durationSelect', function ($injector) {
const htmlIdGeneratorFactory = $injector.get('xpackWatcherHtmlIdGeneratorFactory');
return {
require: '^form',
scope: {
durationId: '@',
minimumUnit: '=',
minimumSize: '=',
unit: '=',
size: '=',
describedBy: '@',
},
template,
replace: true,
controllerAs: 'durationSelect',
bindToController: true,
link: function ($scope, $element, $attrs, $ctrl) {
$scope.durationSelect.form = $ctrl;
},
controller: class DurationSelectController extends InitAfterBindingsWorkaround {
initAfterBindings($scope) {
this.timeUnits = TIME_UNITS;
this.makeId = htmlIdGeneratorFactory.create(['durationSelect', this.durationId]);
$scope.$watchMulti([
'durationSelect.minimumSize',
'durationSelect.minimumUnit'
], ([minimumSize, minimumUnit]) => {
this.minimumDuration = moment.duration(Number(minimumSize), minimumUnit).asMilliseconds();
this.checkValidity();
});
$scope.$watchMulti([
`durationSelect.size`,
`durationSelect.unit`
], ([size, unit]) => {
this.duration = moment.duration(Number(size), unit).asMilliseconds();
this.checkValidity();
});
}
checkValidity = () => {
const isValid = this.duration >= this.minimumDuration;
const sizeName = this.makeId('size');
const unitName = this.makeId('unit');
if (this.form[sizeName]) {
this.form[sizeName].$setTouched(true);
this.form[sizeName].$setValidity('minimumDuration', isValid);
}
if (this.form[unitName]) {
this.form[unitName].$setTouched(true);
this.form[unitName].$setValidity('minimumDuration', isValid);
}
}
}
};
});

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './duration_select';

View file

@ -1,18 +0,0 @@
<div class="kuiModal">
<div class="kuiModalHeader">
<h1 class="kuiModalHeader__title" id="watcher__error-display-modal-title">{{ vm.title }}</h1>
</div>
<div class="kuiModalBody">
<ul>
<li class="kuiVerticalRhythm" ng-repeat="error in vm.errors">{{ error.message }}</li>
</ul>
</div>
<div class="kuiModalFooter">
<button
ng-click="vm.close()"
class="kuiButton kuiButton--primary"
aria-label="close">
Close
</button>
</div>
</div>

View file

@ -1,18 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiModules } from 'ui/modules';
const app = uiModules.get('xpack/watcher');
app.controller('WatcherErrorsDisplayController', function WatcherErrorsDisplayController($modalInstance, params) {
this.title = params.title;
this.errors = params.errors;
this.close = function close() {
$modalInstance.close();
};
});

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './errors_display_modal';

View file

@ -1 +0,0 @@
@import 'components/expression_popover/expression_popover';

View file

@ -1,26 +0,0 @@
<div
class="kuiPopover kuiPopover--withTitle"
ng-class="{ 'kuiPopover-isOpen': expressionItem.isOpen }"
ng-show="expressionItem.isVisible"
>
<button
data-id="expressionItemButton"
class="kuiExpressionButton"
ng-class="{
'kuiExpressionButton-isActive': expressionItem.isOpen,
'kuiExpressionButton-isError': !expressionItem.isValid() && expressionItem.isDirty()
}">
<span class="kuiExpressionButton__description">
{{ expressionItem.description | uppercase }}
</span>
<span class="kuiExpressionButton__value">
{{ expressionItem.value | lowercase }}
</span>
</button>
<expression-popover
popover-title="expressionItem.description"
>
<div ng-transclude></div>
</expression-popover>
</div>

View file

@ -1,98 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import 'ui/fancy_forms';
import { uiModules } from 'ui/modules';
import { keyMap } from 'ui/utils/key_map';
import template from './expression_item.html';
const app = uiModules.get('xpack/watcher');
app.directive('expressionItem', function ($injector) {
const $document = $injector.get('$document');
const $timeout = $injector.get('$timeout');
return {
restrict: 'E',
replace: true,
require: '^expressionBuilder',
transclude: true,
template: template,
scope: {
itemId: '@',
description: '=',
value: '=',
isOpen: '=',
isVisible: '=',
onOpen: '=',
onClose: '=',
isValid: '=',
isDirty: '=',
form: '='
},
bindToController: true,
controllerAs: 'expressionItem',
link: function ($scope, $el) {
$scope.expressionItem.$firstInput = $el.find('[data-id="expressionItemPopoverContent"]').find(':input:first');
const $button = $el.find('[data-id="expressionItemButton"]');
const buttonFocusOrClick = () => {
$scope.$apply(() => {
$scope.expressionItem.onPopoverOpen();
});
};
const documentClick = (event) => {
if ($el.find(event.originalEvent.target).length === 0) {
$scope.$apply(() => {
$scope.expressionItem.onPopoverClose();
});
}
};
const documentKeydown = (event) => {
if ($scope.expressionItem.isOpen && keyMap[event.keyCode] === 'escape') {
$scope.$apply(() => {
$scope.expressionItem.onPopoverClose();
});
}
};
$button.on('focus', buttonFocusOrClick);
$button.on('click', buttonFocusOrClick);
$document.on('click', documentClick);
$document.on('keydown', documentKeydown);
$scope.$on('$destroy', () => {
$button.off('focus', buttonFocusOrClick);
$button.off('click', buttonFocusOrClick);
$document.off('click', documentClick);
$document.off('keydown', documentKeydown);
});
},
controller: class ExpressionItemController {
constructor($scope) {
$scope.$watch('expressionItem.isOpen', (isOpen, wasOpen) => {
if (isOpen) {
$timeout(() => {
$scope.expressionItem.$firstInput.focus();
});
} else if (wasOpen) {
this.form.$setTouched(true);
this.form.$setDirty(true);
}
});
}
onPopoverClose = () => {
this.onClose(this.itemId);
}
onPopoverOpen = () => {
this.onOpen(this.itemId);
}
}
};
});

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './expression_item';

View file

@ -1,3 +0,0 @@
.watcherExpressionPopover {
z-index: 1; // Addresses conflict between the expression popover and the visualization
}

View file

@ -1,13 +0,0 @@
<div
class="kuiPanelSimple kuiPopover__panel"
>
<div class="kuiPopoverTitle">
{{ expressionPopover.popoverTitle }}
</div>
<div
data-id="expressionItemPopoverContent"
class="kuiExpression"
ng-transclude
></div>
</div>

View file

@ -1,28 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiModules } from 'ui/modules';
import template from './expression_popover.html';
const app = uiModules.get('xpack/watcher');
app.directive('expressionPopover', function () {
return {
restrict: 'E',
replace: true,
require: '^expressionItem',
transclude: true,
template: template,
scope: {
popoverTitle: '='
},
bindToController: true,
controllerAs: 'expressionPopover',
controller: class ExpressionPopoverController {
constructor() { }
}
};
});

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './expression_popover';

View file

@ -1,28 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiModules } from 'ui/modules';
import template from './expression_builder.html';
import './components/expression_item';
import './components/expression_popover';
const app = uiModules.get('xpack/watcher');
app.directive('expressionBuilder', function () {
return {
restrict: 'E',
replace: true,
transclude: true,
template: template,
scope: {},
bindToController: true,
controllerAs: 'expressionBuilder',
controller: class BuilderController {
constructor() {
}
}
};
});

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './expression_builder';

View file

@ -1,3 +0,0 @@
.flotChart {
height: 100%;
}

View file

@ -1 +0,0 @@
@import 'flot_chart';

View file

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

View file

@ -1 +0,0 @@
<div class="flotChart"></div>

View file

@ -1,65 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isFunction, debounce } from 'lodash';
import { uiModules } from 'ui/modules';
import template from './flot_chart.html';
import $ from 'plugins/xpack_main/jquery_flot';
import { FLOT_EVENT_PLOT_HOVER_DEBOUNCE_MS } from './constants';
const app = uiModules.get('xpack/watcher');
app.directive('flotChart', function () {
return {
restrict: 'E',
replace: true,
template: template,
scope: {
// See https://github.com/flot/flot/blob/master/API.md#data-format
data: '=',
// See https://github.com/flot/flot/blob/master/API.md#plot-options
options: '=',
// Search for "plothover" in https://github.com/flot/flot/blob/master/API.md
onPlotHover: '=',
},
controllerAs: 'flotChart',
bindToController: true,
link: ($scope, element) => {
$scope.flotChart.container = element;
},
controller: class FlotChartController {
constructor($scope) {
$scope.$watchMulti([
'flotChart.data',
'flotChart.options'
], ([data, options]) => {
this.plot = $.plot(this.container, data, options);
});
$scope.$watch('flotChart.onPlotHover', (onPlotHover) => {
this.container.unbind('plothover');
if (isFunction(onPlotHover)) {
this.container.bind('plothover', debounce((...params) => {
// We use $scope.$apply to tell Angular to trigger a digest whenever
// the supplied event handler function is called
$scope.$apply(() => onPlotHover(...params, this.plot));
}, FLOT_EVENT_PLOT_HOVER_DEBOUNCE_MS));
}
});
$scope.$on('$destroy', () => {
this.container.unbind('plothover');
});
}
}
};
});

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './flot_chart';

View file

@ -1,17 +0,0 @@
<div class="kuiInfoPanel kuiInfoPanel--error">
<div class="kuiInfoPanelHeader">
<span class="kuiInfoPanelHeader__icon kuiIcon kuiIcon--error fa-warning"></span>
<span
class="kuiInfoPanelHeader__title"
ng-transclude
></span>
</div>
<div class="kuiInfoPanelBody">
<div
class="kuiInfoPanelBody__message"
i18n-id="xpack.watcher.forbiddenMessage.contactAdministratorTextMessage"
i18n-default-message="Please contact your administrator."
></div>
</div>
</div>

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiModules } from 'ui/modules';
import template from './forbidden_message.html';
const app = uiModules.get('xpack/watcher');
app.directive('forbiddenMessage', function () {
return {
restrict: 'E',
replace: true,
template: template,
transclude: true,
scope: {},
controllerAs: 'forbiddenMessage',
bindToController: true,
controller: class ForbiddenMessageController {
constructor() { }
}
};
});

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './forbidden_message';

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFormRow } from '@elastic/eui';
import React, { Children, cloneElement, Fragment, ReactElement } from 'react';
export const ErrableFormRow = ({
errorKey,
isShowingErrors,
errors,
children,
...rest
}: {
errorKey: string;
isShowingErrors: boolean;
errors: { [key: string]: string[] };
children: ReactElement;
[key: string]: any;
}) => {
return (
<EuiFormRow
isInvalid={isShowingErrors && errors[errorKey].length > 0}
error={errors[errorKey]}
{...rest}
>
<Fragment>{Children.map(children, child => cloneElement(child))}</Fragment>
</EuiFormRow>
);
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { getPageErrorCode, PageError } from './page_error';
export { ConfirmWatchesModal } from './confirm_watches_modal';
export { DeleteWatchesModal } from './delete_watches_modal';
export { ErrableFormRow } from './form_errors';
export { WatchStatus } from './watch_status';
export { SectionLoading } from './section_loading';
export { SectionError } from './section_error';

View file

@ -1 +0,0 @@
@import 'index_select';

View file

@ -1,55 +0,0 @@
@mixin indexSelectSubText() {
color: $euiColorDarkShade;
font-size: $euiFontSizeXS;
padding: $euiSizeXS $euiSizeL;
line-height: $euiLineHeight;
}
.indexSelectNoChoice {
@include indexSelectSubText;
}
/**
* 1. To ensure a smooth UX, prevent the dropdown from hiding while we async search for more results
* 2. ui-select likes to clear the result set out of the UI when you enter
* a new search query which can cause a jarring UX. This helps mitigate
* that by at least providing something in the box so it's not empty
*/
.indexSelect--activeSearch {
.ui-select-dropdown.ng-hide {
display: block !important; /* 1 */
&:after {
content: 'Choose...'; /* 2 */
@include indexSelectSubText; /* 2 */
}
}
}
/**
* 1. By default, hide this until the user searches or there are results
*/
.indexSelect--noSearch {
.ui-select-no-choice {
display: none !important; /* 1 */
}
}
/**
* 1. Provide a loading indicator to the user
*/
.indexSelect--fetchingWithNoIndices {
.ui-select-choices:before {
content: 'Loading...'; /* 1 */
margin-left: $euiSize; /* 1 */
font-style: italic; /* 1 */
color: $euiColorDarkShade; /* 1 */
}
}
/**
* 1. Ensure that an invalid ui-select shows a red border
*/
.ui-select-multiple.ui-select-bootstrap.ng-invalid {
border-color: $euiColorDanger !important; /* 1 */
}

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './index_select';

View file

@ -1,46 +0,0 @@
<div
class="indexSelect"
ng-class="{
'indexSelect--activeSearch': indexSelect.hasIndexPattern,
'indexSelect--noSearch': !indexSelect.hasIndexPattern,
'indexSelect--fetchingWithNoIndices': indexSelect.fetchingWithNoIndices
}"
>
<ui-select
ng-model="indexSelect.selectedIndices"
on-select="indexSelect.onIndicesChanged()"
on-remove="indexSelect.onIndicesChanged()"
uis-open-close="indexSelect.onDropdownToggled(isOpen)"
multiple
>
<ui-select-match
placeholder="{{ 'xpack.watcher.indexSelect.startTypingPlaceholder' | i18n: { defaultMessage: 'Start typing…' } }}"
>
{{$item.indexName}}
</ui-select-match>
<ui-select-choices
repeat="index in indexSelect.fetchedIndices | filter: {indexName: $select.search}"
refresh="indexSelect.fetchIndices($select.search)"
refresh-delay="300"
group-by="indexSelect.groupIndices"
group-filter="indexSelect.sortGroupedIndices"
>
<div class="actionTypeChoice">
<div class="actionTypeDescription">
<div
class="indexSelectionName"
ng-bind-html="index.indexName | highlight: $select.search"
>
</div>
</div>
</div>
</ui-select-choices>
<ui-select-no-choice>
<p
class="indexSelectNoChoice"
i18n-id="xpack.watcher.indexSelect.buildingListTextMessage"
i18n-default-message="Building list… please wait"
></p>
</ui-select-no-choice>
</ui-select>
</div>

View file

@ -1,207 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { sortBy, pluck, map, startsWith, endsWith } from 'lodash';
import 'plugins/watcher/services/indices';
import { InitAfterBindingsWorkaround } from 'ui/compat';
import { uiModules } from 'ui/modules';
import template from './index_select.html';
function mapIndex(indexName, isFromIndexPattern = false, isUserEntered = false) {
return { indexName, isFromIndexPattern, isUserEntered };
}
function collapseIndices(allIndices, allIndexPatterns) {
const indices = map(allIndices, indexName => mapIndex(indexName, false));
const indexPatterns = map(allIndexPatterns, indexName => mapIndex(indexName, true));
indices.push(...indexPatterns);
return indices;
}
const INDICES_FROM_INDEX_PATTERNS_HEADER_COPY = 'Based on your index patterns';
const INDICES_FOR_CREATION_HEADER_COPY = 'Choose...';
const INDICES_FROM_INDICES_HEADER_COPY = 'Based on your indices and aliases';
const app = uiModules.get('xpack/watcher');
app.directive('indexSelect', ($injector) => {
const indicesService = $injector.get('xpackWatcherIndicesService');
const indexPatternsService = $injector.get('indexPatterns');
const $timeout = $injector.get('$timeout');
return {
restrict: 'E',
template,
scope: {
index: '=',
onChange: '=',
onTouched: '='
},
controllerAs: 'indexSelect',
bindToController: true,
link: ($scope, $ele) => {
const $searchBox = $ele.find('input[type="search"]');
$scope.indexSelect.$searchBox = $searchBox;
$searchBox.attr('id', 'indexSelectSearchBox');
},
controller: class IndexSelectController extends InitAfterBindingsWorkaround {
initAfterBindings($scope) {
this.$scope = $scope;
this.indexPattern = undefined;
this.fetchingWithNoIndices = true;
this.selectedIndices = [];
this.fetchedIndices = [];
if (Boolean(this.index)) {
if (Array.isArray(this.index)) {
this.selectedIndices.push(...this.index.map(mapIndex));
} else {
this.selectedIndices.push(mapIndex(this.index));
}
}
if (this.onTouched) {
$timeout(() => {
this.$searchBox.on('blur', () => {
$scope.$apply(this.onTouched);
});
});
$scope.$on('$destroy', () => {
this.$searchBox.off('blur');
});
}
$scope.$watch('indexSelect.index', () => {
$timeout(() => {
// Hack that forces the ui-select to resize itself
$scope.$$childHead.$select.sizeSearchInput();
}, 100);
});
}
get hasIndexPattern() {
return Boolean(this.indexPattern);
}
/**
* This method powers the `on-select` and `on-remove` within ui-select
* to handle when an index is added or removed from the list
*/
onIndicesChanged() {
const indexNames = pluck(this.selectedIndices, 'indexName');
this.onChange(indexNames);
}
/**
* This method powers the `tagging` within ui-select to format
* a search query that has no results into a valid result so it
* can be selected
*
* @param {object} item
*/
onNewItem(unmatchedIndexPattern) {
return mapIndex(unmatchedIndexPattern);
}
/**
* This method powers the `group-by` within ui-select to group
* our indices array based on the source
*
* @param {object} index
*/
groupIndices(index) {
if (index.isFromIndexPattern) {
return INDICES_FROM_INDEX_PATTERNS_HEADER_COPY;
}
if (index.isUserEntered) {
return INDICES_FOR_CREATION_HEADER_COPY;
}
return INDICES_FROM_INDICES_HEADER_COPY;
}
/**
* This method powers the `group-filter` within ui-select to allow
* us to sort the grouped object so we can control which group
* is shown first
*
* @param {object} grouped
*/
sortGroupedIndices(grouped) {
return sortBy(grouped, group => group.name);
}
/**
* This method powers the `uis-open-close` within ui-select to ensure
* we reset the search state once the dropdown is closed. The default
* behavior of ui-select is to clear the input field when the dropdown
* is closed and if we fail to reset the search state at the same time
* it will lead to a poor UX.
*
* @param {bool} isOpen
*/
onDropdownToggled(isOpen) {
if (!isOpen) {
this.reset();
}
}
/**
* Resets the search state so we have no stored query or results
*/
reset() {
this.fetchedIndices.length = 0;
this.indexPattern = undefined;
}
/**
* This powers the `refresh` within ui-select which is called
* as a way to async fetch results based on the typed in query
*
* @param {string} indexPattern
*/
fetchIndices(indexPattern) {
if (!Boolean(indexPattern)) {
this.reset();
return;
}
// Store this so we can display it if there are no matches
this.indexPattern = indexPattern;
let pattern = indexPattern;
// Use wildcards religiously to ensure partial matches
if (!endsWith(pattern, '*')) {
pattern += '*';
}
if (!startsWith(pattern, '*')) {
pattern = '*' + pattern;
}
const promises = [
indicesService.getMatchingIndices(pattern),
indexPatternsService.getTitles()
];
if (this.fetchedIndices.length === 0) {
this.fetchingWithNoIndices = true;
}
Promise.all(promises)
.then(([allIndices, allIndexPatterns]) => {
const indices = collapseIndices(allIndices, allIndexPatterns);
this.fetchedIndices = sortBy(indices, 'indexName');
this.fetchedIndices.push(mapIndex(this.indexPattern, false, true));
this.fetchingWithNoIndices = false;
this.$scope.$apply();
});
}
}
};
});

View file

@ -1 +0,0 @@
@import 'json_editor';

View file

@ -1,3 +0,0 @@
.json-editor {
border: 1px solid $euiColorLightShade;
}

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './json_editor';

View file

@ -1,12 +0,0 @@
<form name="jsonEditor.form" novalidate>
<div
class="json-editor"
json-input
require-keys="true"
ui-ace="{
mode: 'json',
onLoad: aceLoaded
}"
ng-model="jsonEditor.json"
></div>
</form>

View file

@ -1,48 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiModules } from 'ui/modules';
import template from './json_editor.html';
import 'plugins/watcher/directives/json_input';
import 'ace';
const app = uiModules.get('xpack/watcher');
app.directive('jsonEditor', function () {
return {
restrict: 'E',
template: template,
replace: true,
scope: {
json: '=',
onChange: '=',
onValid: '=',
onInvalid: '='
},
bindToController: true,
controllerAs: 'jsonEditor',
controller: class JsonEditorController {
constructor($scope) {
$scope.aceLoaded = (editor) => {
this.editor = editor;
editor.$blockScrolling = Infinity;
};
$scope.$watch('jsonEditor.form.$valid', () => {
if (this.form.$invalid) {
this.onInvalid();
} else {
this.onValid();
}
});
$scope.$watch('jsonEditor.json', () => {
this.onChange(this.json);
});
}
}
};
});

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './kbn_tab';

Some files were not shown because too many files have changed in this diff Show more