From a0730f795152ad251ef277e7f5c8de8c42040dd1 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 19 Mar 2020 16:42:53 +0000 Subject: [PATCH 01/22] [ML] Fixing file data visualizer override arguments (#60627) --- .../datavisualizer/file_based/components/utils/utils.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js index 3bf128f84aa78..39cd25ba87d8c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js @@ -66,6 +66,10 @@ export function createUrlOverrides(overrides, originalSettings) { ) { formattedOverrides.format = originalSettings.format; } + + if (Array.isArray(formattedOverrides.column_names)) { + formattedOverrides.column_names = formattedOverrides.column_names.join(); + } } if (formattedOverrides.format === '' && originalSettings.format === 'semi_structured_text') { @@ -82,11 +86,6 @@ export function createUrlOverrides(overrides, originalSettings) { formattedOverrides.column_names = ''; } - // escape grok pattern as it can contain bad characters - if (formattedOverrides.grok_pattern !== '') { - formattedOverrides.grok_pattern = encodeURIComponent(formattedOverrides.grok_pattern); - } - if (formattedOverrides.lines_to_sample === '') { formattedOverrides.lines_to_sample = overrides.linesToSample; } From fcf439625ba6934dcedd31338178c391d8270364 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Thu, 19 Mar 2020 12:50:05 -0400 Subject: [PATCH 02/22] [Uptime] Add Alerting UI (#57919) * WIP trying things. Add new alert type for Uptime. Add defensive checks to alert executor. Move status check code to dedicated adapter function. Clean up code. * Port adapter function to dedicated file. * WIP. * Working on parameter selection. * Selector expressions working. * Working on actions. * Change anchor prop for popovers. * Reference migrated alerting plugin. * Clean up code for draft. * Add button to expose flyout. Clean up some client code. * Add test for requests function, add support for filters. * Reorganize and clean up files. * Add location and filter support to monitor status request function. * Add tests for monitor status request function. * Specify default action group id in alert registration. * Extract repeated string value to a constant. * Move test file to server in NP plugin. * Update imports after NP migration. * Fix UI bug that caused incorrect location selections in alert creation. * Change alert expression language to clarify meaning. * Add ability for user to select timerange units. * Add code that fixes active item highlighting. * Add better default value for active index selection. * Introduce dedicated field number component. * Add message to status check alert. * Add tests for context message. * Formalize alert action group definitions. * Extract monitor id squashing from context message generator. * Write test for monitor ID uniqueness function. * Add alert state creator function and tests. * Update action group id value. * Add tests for alert factory and executor function. * Rename alert context props to be more domain-specific. * Clean up unnecessary type markup. * Clean up alert ui controls file. * Better organize new registration code. * Simplify some logic code. * Clean up bootstrap code. * Add unit tests for alert type. * Delete temporary test code from triggers_actions_ui. * Rename a test file. * Add some comments to annotate a file. * Add io-ts type checking to alert create validation and alert executor. * Add translation of plaintext content string. * Further simplify monitor status alert validation. * Add io-ts type checking to alert params. * Update a comment. * Prefer inline snapshots to more error-prone assertions. * Clean up and comment request function. * Rename a symbol. * Fix broken types in reducer file and add a test. * Fix a validation logic error and add tests. * Delete unused import. * Delete obsolete dependency. * Fix function call to have correct parameters. * Fixing some import weirdness. * Reintroduce accidentally-deleted code. * Delete unneeded require from legacy entry file. * Remove unneeded connected component. * Update flyout controls for new interface and delete connected components. * Remove unneeded require from app index file. * Introduce data-test-subj attributes to various components to assist with functional tests. * Introduce functional test helpers for alert flyout. * Add functional test arch and a test for alerting UI to ES SSL test suite. * Add explicit exports to module index. * Reorganize file to keep interfaces closer to their implementations. * Move create alert button to better position. * Clean up a file. * Update a functional test attribute, clean up a file, rename a selector, add tests. * Add a comment. * Make better default alert message, translate messages, add/update tests. * Fix broken type. * Update obsolete snapshot. * Introduce mock provider to tests and update snapshots. * Reduce a strange type to `any`. * Add alert flyout button connected component. * Add alert flyout wrapper connected component. * Create connected component for alert monitor status alert. * Clean up index files. * Update i18nrc file to cover translation in server plugin code. * Fix broken imports. * Update test snapshots. * Prefer more descriptive type. * Prefer more descriptive type. * Prefer built-in React propType to custom. * Prefer simpler validation. * Add whitespace to clean up file. * Extract function and write tests. * Simplify validation function. * Add navigate to alerting button. * Move context item inside the items list. * Clean up alert creation component. * Update type check parsing and error messaging, and update snapshot/test assertions. * Update broken snapshot. * Update README for running functional tests. * Update functional test service to reflect improved UX. * Fix broken type that resulted from a mistake during a merge resolution. * Add spacer between alert title and kuery bar. * Update the id and name of our alert type because it was never changed from placeholder value. * Rename alert keys. * Fix broken unit tests. * Add aria-labels to alert UI. * Implement design feedback. * Fix broken test snapshots. * Add missing props to unit tests to staisfy updated types. Co-authored-by: Elastic Machine --- x-pack/.i18nrc.json | 2 +- x-pack/legacy/plugins/uptime/README.md | 10 + .../plugins/uptime/common/constants/alerts.ts | 19 + .../plugins/uptime/common/constants/index.ts | 1 + .../uptime/common/constants/index_names.ts | 1 - .../common/runtime_types/alerts/index.ts | 12 + .../runtime_types/alerts/status_check.ts | 39 ++ .../uptime/common/runtime_types/index.ts | 1 + x-pack/legacy/plugins/uptime/index.ts | 2 +- .../plugins/uptime/public/apps/index.ts | 5 +- .../plugins/uptime/public/apps/plugin.ts | 2 + .../connected/alerts/alert_monitor_status.tsx | 43 ++ .../components/connected/alerts/index.ts | 9 + .../alerts/toggle_alert_flyout_button.tsx | 19 + .../alerts/uptime_alerts_flyout_wrapper.tsx | 34 + .../public/components/connected/index.ts | 1 + .../kuerybar/kuery_bar_container.tsx | 2 +- .../__tests__/alert_monitor_status.test.tsx | 179 ++++++ .../alerts/alert_monitor_status.tsx | 431 +++++++++++++ .../components/functional/alerts/index.ts | 10 + .../alerts/toggle_alert_flyout_button.tsx | 79 +++ .../alerts/uptime_alerts_context_provider.tsx | 38 ++ .../alerts/uptime_alerts_flyout_wrapper.tsx | 30 + .../public/components/functional/index.ts | 6 + .../functional/kuery_bar/kuery_bar.tsx | 6 + .../functional/kuery_bar/typeahead/index.js | 3 +- .../ping_list/__tests__/ping_list.test.tsx | 3 +- .../framework/new_platform_adapter.tsx | 16 + .../__tests__/monitor_status.test.ts | 181 ++++++ .../uptime/public/lib/alert_types/index.ts | 14 + .../public/lib/alert_types/monitor_status.tsx | 71 +++ .../__snapshots__/page_header.test.tsx.snap | 66 ++ .../pages/__tests__/page_header.test.tsx | 54 +- .../plugins/uptime/public/pages/overview.tsx | 8 +- .../uptime/public/pages/page_header.tsx | 4 + .../plugins/uptime/public/state/actions/ui.ts | 2 + .../__tests__/__snapshots__/ui.test.ts.snap | 3 + .../state/reducers/__tests__/ui.test.ts | 34 +- .../uptime/public/state/reducers/ui.ts | 12 +- .../state/selectors/__tests__/index.test.ts | 1 + .../uptime/public/state/selectors/index.ts | 9 + .../plugins/uptime/public/uptime_app.tsx | 15 +- x-pack/plugins/uptime/kibana.json | 2 +- x-pack/plugins/uptime/server/kibana.index.ts | 2 +- .../lib/adapters/framework/adapter_types.ts | 2 + .../lib/alerts/__tests__/status_check.test.ts | 587 ++++++++++++++++++ .../plugins/uptime/server/lib/alerts/index.ts | 10 + .../uptime/server/lib/alerts/status_check.ts | 234 +++++++ .../plugins/uptime/server/lib/alerts/types.ts | 11 + .../__tests__/get_monitor_status.test.ts | 553 +++++++++++++++++ .../server/lib/requests/get_monitor_status.ts | 150 +++++ .../uptime/server/lib/requests/index.ts | 2 + .../server/lib/requests/uptime_requests.ts | 3 + x-pack/plugins/uptime/server/uptime_server.ts | 12 +- .../functional/page_objects/uptime_page.ts | 40 +- x-pack/test/functional/services/uptime.ts | 94 ++- .../apps/uptime/alert_flyout.ts | 78 +++ .../apps/uptime/index.ts | 27 + x-pack/test/functional_with_es_ssl/config.ts | 5 +- 59 files changed, 3245 insertions(+), 44 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/common/constants/alerts.ts create mode 100644 x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts create mode 100644 x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts create mode 100644 x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts create mode 100644 x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx create mode 100644 x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/index.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/status_check.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/types.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/uptime/index.ts diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 1564eb94a6903..d568e9b951d28 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -42,7 +42,7 @@ "xpack.transform": "plugins/transform", "xpack.triggersActionsUI": "plugins/triggers_actions_ui", "xpack.upgradeAssistant": "plugins/upgrade_assistant", - "xpack.uptime": "legacy/plugins/uptime", + "xpack.uptime": ["plugins/uptime", "legacy/plugins/uptime"], "xpack.watcher": "plugins/watcher" }, "translations": [ diff --git a/x-pack/legacy/plugins/uptime/README.md b/x-pack/legacy/plugins/uptime/README.md index 308f78ecdc368..2ed0e2fc77cbc 100644 --- a/x-pack/legacy/plugins/uptime/README.md +++ b/x-pack/legacy/plugins/uptime/README.md @@ -62,3 +62,13 @@ You can login with username `elastic` and password `changeme` by default. If you want to freeze a UI or API test you can include an async call like `await new Promise(r => setTimeout(r, 1000 * 60))` to freeze the execution for 60 seconds if you need to click around or check things in the state that is loaded. + +#### Running --ssl tests + +Some of our tests require there to be an SSL connection between Kibana and Elasticsearch. + +We can run these tests like described above, but with some special config. + +`node scripts/functional_tests_server.js --config=test/functional_with_es_ssl/config.ts` + +`node scripts/functional_test_runner.js --config=test/functional_with_es_ssl/config.ts` diff --git a/x-pack/legacy/plugins/uptime/common/constants/alerts.ts b/x-pack/legacy/plugins/uptime/common/constants/alerts.ts new file mode 100644 index 0000000000000..c0db9ae309843 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/constants/alerts.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +interface ActionGroupDefinition { + id: string; + name: string; +} + +type ActionGroupDefinitions = Record; + +export const ACTION_GROUP_DEFINITIONS: ActionGroupDefinitions = { + MONITOR_STATUS: { + id: 'xpack.uptime.alerts.actionGroups.monitorStatus', + name: 'Uptime Down Monitor', + }, +}; diff --git a/x-pack/legacy/plugins/uptime/common/constants/index.ts b/x-pack/legacy/plugins/uptime/common/constants/index.ts index 0425fc19a7b45..19f2de3c6f0f4 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/index.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { ACTION_GROUP_DEFINITIONS } from './alerts'; export { CHART_FORMAT_LIMITS } from './chart_format_limits'; export { CLIENT_DEFAULTS } from './client_defaults'; export { CONTEXT_DEFAULTS } from './context_defaults'; diff --git a/x-pack/legacy/plugins/uptime/common/constants/index_names.ts b/x-pack/legacy/plugins/uptime/common/constants/index_names.ts index e9c6b1e1106ab..9f33d280a1268 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/index_names.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/index_names.ts @@ -6,5 +6,4 @@ export const INDEX_NAMES = { HEARTBEAT: 'heartbeat-8*', - HEARTBEAT_STATES: 'heartbeat-states-8*', }; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts new file mode 100644 index 0000000000000..ee284249c38c0 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { + StatusCheckAlertStateType, + StatusCheckAlertState, + StatusCheckExecutorParamsType, + StatusCheckExecutorParams, +} from './status_check'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts new file mode 100644 index 0000000000000..bc234b268df27 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts @@ -0,0 +1,39 @@ +/* + * 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 * as t from 'io-ts'; + +export const StatusCheckAlertStateType = t.intersection([ + t.partial({ + currentTriggerStarted: t.string, + firstTriggeredAt: t.string, + lastTriggeredAt: t.string, + lastResolvedAt: t.string, + }), + t.type({ + firstCheckedAt: t.string, + lastCheckedAt: t.string, + isTriggered: t.boolean, + }), +]); + +export type StatusCheckAlertState = t.TypeOf; + +export const StatusCheckExecutorParamsType = t.intersection([ + t.partial({ + filters: t.string, + }), + t.type({ + locations: t.array(t.string), + numTimes: t.number, + timerange: t.type({ + from: t.string, + to: t.string, + }), + }), +]); + +export type StatusCheckExecutorParams = t.TypeOf; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts index 58f79abcf91ec..82fc9807300ed 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './alerts'; export * from './common'; export * from './monitor'; export * from './overview_filters'; diff --git a/x-pack/legacy/plugins/uptime/index.ts b/x-pack/legacy/plugins/uptime/index.ts index feecef5857895..f52ad8ce867b6 100644 --- a/x-pack/legacy/plugins/uptime/index.ts +++ b/x-pack/legacy/plugins/uptime/index.ts @@ -14,7 +14,7 @@ export const uptime = (kibana: any) => configPrefix: 'xpack.uptime', id: PLUGIN.ID, publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch', 'xpack_main'], + require: ['alerting', 'kibana', 'elasticsearch', 'xpack_main'], uiExports: { app: { description: i18n.translate('xpack.uptime.pluginDescription', { diff --git a/x-pack/legacy/plugins/uptime/public/apps/index.ts b/x-pack/legacy/plugins/uptime/public/apps/index.ts index d322c35364d1a..d58bf8398fcde 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/index.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/index.ts @@ -8,8 +8,9 @@ import { npSetup } from 'ui/new_platform'; import { Plugin } from './plugin'; import 'uiExports/embeddableFactories'; -new Plugin({ +const plugin = new Plugin({ opaqueId: Symbol('uptime'), env: {} as any, config: { get: () => ({} as any) }, -}).setup(npSetup); +}); +plugin.setup(npSetup); diff --git a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts index 2204d7e4097dd..eec49418910f8 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts @@ -36,6 +36,7 @@ export class Plugin { public setup(setup: SetupObject) { const { core, plugins } = setup; const { home } = plugins; + home.featureCatalogue.register({ category: FeatureCatalogueCategory.DATA, description: PLUGIN.DESCRIPTION, @@ -45,6 +46,7 @@ export class Plugin { showOnHomePage: true, title: PLUGIN.TITLE, }); + core.application.register({ id: PLUGIN.ID, euiIconType: 'uptimeApp', diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx new file mode 100644 index 0000000000000..1529ab6db8875 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx @@ -0,0 +1,43 @@ +/* + * 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 { useSelector } from 'react-redux'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { selectMonitorStatusAlert } from '../../../state/selectors'; +import { AlertMonitorStatusComponent } from '../../functional/alerts/alert_monitor_status'; + +interface Props { + autocomplete: DataPublicPluginSetup['autocomplete']; + enabled: boolean; + numTimes: number; + setAlertParams: (key: string, value: any) => void; + timerange: { + from: string; + to: string; + }; +} + +export const AlertMonitorStatus = ({ + autocomplete, + enabled, + numTimes, + setAlertParams, + timerange, +}: Props) => { + const { filters, locations } = useSelector(selectMonitorStatusAlert); + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts new file mode 100644 index 0000000000000..87179a96fc0b2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { AlertMonitorStatus } from './alert_monitor_status'; +export { ToggleAlertFlyoutButton } from './toggle_alert_flyout_button'; +export { UptimeAlertsFlyoutWrapper } from './uptime_alerts_flyout_wrapper'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx new file mode 100644 index 0000000000000..43b0be45365a1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx @@ -0,0 +1,19 @@ +/* + * 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 { useDispatch } from 'react-redux'; +import { ToggleAlertFlyoutButtonComponent } from '../../functional'; +import { setAlertFlyoutVisible } from '../../../state/actions'; + +export const ToggleAlertFlyoutButton = () => { + const dispatch = useDispatch(); + return ( + dispatch(setAlertFlyoutVisible(value))} + /> + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx new file mode 100644 index 0000000000000..b547f8b076f93 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx @@ -0,0 +1,34 @@ +/* + * 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 { useDispatch, useSelector } from 'react-redux'; +import { UptimeAlertsFlyoutWrapperComponent } from '../../functional'; +import { setAlertFlyoutVisible } from '../../../state/actions'; +import { selectAlertFlyoutVisibility } from '../../../state/selectors'; + +interface Props { + alertTypeId?: string; + canChangeTrigger?: boolean; +} + +export const UptimeAlertsFlyoutWrapper = ({ alertTypeId, canChangeTrigger }: Props) => { + const dispatch = useDispatch(); + const setAddFlyoutVisiblity = (value: React.SetStateAction) => + // @ts-ignore the value here is a boolean, and it works with the action creator function + dispatch(setAlertFlyoutVisible(value)); + + const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts index baa961ddc87d2..7e442cbe850ba 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { AlertMonitorStatus, ToggleAlertFlyoutButton, UptimeAlertsFlyoutWrapper } from './alerts'; export { PingHistogram } from './charts/ping_histogram'; export { Snapshot } from './charts/snapshot_container'; export { KueryBar } from './kuerybar/kuery_bar_container'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx index a42f96962b95e..132ae57b5154f 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { AppState } from '../../../state'; import { selectIndexPattern } from '../../../state/selectors'; import { getIndexPattern } from '../../../state/actions'; -import { KueryBarComponent } from '../../functional'; +import { KueryBarComponent } from '../../functional/kuery_bar/kuery_bar'; const mapStateToProps = (state: AppState) => ({ ...selectIndexPattern(state) }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx new file mode 100644 index 0000000000000..af8d17d1fc242 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx @@ -0,0 +1,179 @@ +/* + * 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 { + selectedLocationsToString, + AlertFieldNumber, + handleAlertFieldNumberChange, +} from '../alert_monitor_status'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +describe('alert monitor status component', () => { + describe('handleAlertFieldNumberChange', () => { + let mockSetIsInvalid: jest.Mock; + let mockSetFieldValue: jest.Mock; + + beforeEach(() => { + mockSetIsInvalid = jest.fn(); + mockSetFieldValue = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets a valid number', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: '23' } }, + false, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).not.toHaveBeenCalled(); + expect(mockSetFieldValue).toHaveBeenCalledTimes(1); + expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 23, + ], + ] + `); + }); + + it('sets invalid for NaN value', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: 'foo' } }, + false, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).toHaveBeenCalledTimes(1); + expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + true, + ], + ] + `); + expect(mockSetFieldValue).not.toHaveBeenCalled(); + }); + + it('sets invalid to false when a valid value is received and invalid is true', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: '23' } }, + true, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).toHaveBeenCalledTimes(1); + expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + false, + ], + ] + `); + expect(mockSetFieldValue).toHaveBeenCalledTimes(1); + expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 23, + ], + ] + `); + }); + }); + + describe('AlertFieldNumber', () => { + it('responds with correct number value when a valid number is specified', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: '45' } }); + expect(mockValueHandler).toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 45, + ], + ] + `); + }); + + it('does not set an invalid number value', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: 'not a number' } }); + expect(mockValueHandler).not.toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toEqual([]); + }); + + it('does not set a number value less than 1', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: '0' } }); + expect(mockValueHandler).not.toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toEqual([]); + }); + }); + + describe('selectedLocationsToString', () => { + it('generates a formatted string for a valid list of options', () => { + const locations = [ + { + checked: 'on', + label: 'fairbanks', + }, + { + checked: 'on', + label: 'harrisburg', + }, + { + checked: undefined, + label: 'orlando', + }, + ]; + expect(selectedLocationsToString(locations)).toEqual('fairbanks, harrisburg'); + }); + + it('generates a formatted string for a single item', () => { + expect(selectedLocationsToString([{ checked: 'on', label: 'fairbanks' }])).toEqual( + 'fairbanks' + ); + }); + + it('returns an empty string when no valid options are available', () => { + expect(selectedLocationsToString([{ checked: 'off', label: 'harrisburg' }])).toEqual(''); + }); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx new file mode 100644 index 0000000000000..5143e1c963904 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx @@ -0,0 +1,431 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { + EuiExpression, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiSelectable, + EuiSpacer, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { KueryBar } from '../../connected/kuerybar/kuery_bar_container'; + +interface AlertFieldNumberProps { + 'aria-label': string; + 'data-test-subj': string; + disabled: boolean; + fieldValue: number; + setFieldValue: React.Dispatch>; +} + +export const handleAlertFieldNumberChange = ( + e: React.ChangeEvent, + isInvalid: boolean, + setIsInvalid: React.Dispatch>, + setFieldValue: React.Dispatch> +) => { + const num = parseInt(e.target.value, 10); + if (isNaN(num) || num < 1) { + setIsInvalid(true); + } else { + if (isInvalid) setIsInvalid(false); + setFieldValue(num); + } +}; + +export const AlertFieldNumber = ({ + 'aria-label': ariaLabel, + 'data-test-subj': dataTestSubj, + disabled, + fieldValue, + setFieldValue, +}: AlertFieldNumberProps) => { + const [isInvalid, setIsInvalid] = useState(false); + + return ( + handleAlertFieldNumberChange(e, isInvalid, setIsInvalid, setFieldValue)} + disabled={disabled} + value={fieldValue} + isInvalid={isInvalid} + /> + ); +}; + +interface AlertExpressionPopoverProps { + 'aria-label': string; + content: React.ReactElement; + description: string; + 'data-test-subj': string; + id: string; + value: string; +} + +const AlertExpressionPopover: React.FC = ({ + 'aria-label': ariaLabel, + content, + 'data-test-subj': dataTestSubj, + description, + id, + value, +}) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(!isOpen)} + value={value} + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + {content} + + ); +}; + +export const selectedLocationsToString = (selectedLocations: any[]) => + // create a nicely-formatted description string for all `on` locations + selectedLocations + .filter(({ checked }) => checked === 'on') + .map(({ label }) => label) + .sort() + .reduce((acc, cur) => { + if (acc === '') { + return cur; + } + return acc + `, ${cur}`; + }, ''); + +interface AlertMonitorStatusProps { + autocomplete: DataPublicPluginSetup['autocomplete']; + enabled: boolean; + filters: string; + locations: string[]; + numTimes: number; + setAlertParams: (key: string, value: any) => void; + timerange: { + from: string; + to: string; + }; +} + +export const AlertMonitorStatusComponent: React.FC = props => { + const { filters, locations } = props; + const [numTimes, setNumTimes] = useState(5); + const [numMins, setNumMins] = useState(15); + const [allLabels, setAllLabels] = useState(true); + + // locations is an array of `Option[]`, but that type doesn't seem to be exported by EUI + const [selectedLocations, setSelectedLocations] = useState( + locations.map(location => ({ + 'aria-label': i18n.translate('xpack.uptime.alerts.locationSelectionItem.ariaLabel', { + defaultMessage: 'Location selection item for "{location}"', + values: { + location, + }, + }), + disabled: allLabels, + label: location, + })) + ); + const [timerangeUnitOptions, setTimerangeUnitOptions] = useState([ + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.secondsOption.ariaLabel', + { + defaultMessage: '"Seconds" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.secondsOption', + key: 's', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.seconds', { + defaultMessage: 'seconds', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.minutesOption.ariaLabel', + { + defaultMessage: '"Minutes" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.minutesOption', + checked: 'on', + key: 'm', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.minutes', { + defaultMessage: 'minutes', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.hoursOption.ariaLabel', + { + defaultMessage: '"Hours" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption', + key: 'h', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.hours', { + defaultMessage: 'hours', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.daysOption.ariaLabel', + { + defaultMessage: '"Days" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.daysOption', + key: 'd', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.days', { + defaultMessage: 'days', + }), + }, + ]); + + const { setAlertParams } = props; + + useEffect(() => { + setAlertParams('numTimes', numTimes); + }, [numTimes, setAlertParams]); + + useEffect(() => { + const timerangeUnit = timerangeUnitOptions.find(({ checked }) => checked === 'on')?.key ?? 'm'; + setAlertParams('timerange', { from: `now-${numMins}${timerangeUnit}`, to: 'now' }); + }, [numMins, timerangeUnitOptions, setAlertParams]); + + useEffect(() => { + if (allLabels) { + setAlertParams('locations', []); + } else { + setAlertParams( + 'locations', + selectedLocations.filter(l => l.checked === 'on').map(l => l.label) + ); + } + }, [selectedLocations, setAlertParams, allLabels]); + + useEffect(() => { + setAlertParams('filters', filters); + }, [filters, setAlertParams]); + + return ( + <> + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.numTimesExpression" + description="any monitor is down >" + id="ping-count" + value={`${numTimes} times`} + /> + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeValueExpression" + description="within" + id="timerange" + value={`last ${numMins}`} + /> + + + + +
+ +
+
+ { + if (newOptions.reduce((acc, { checked }) => acc || checked === 'on', false)) { + setTimerangeUnitOptions(newOptions); + } + }} + singleSelection={true} + listProps={{ + showIcons: true, + }} + > + {list => list} + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeUnitExpression" + description="" + id="timerange-unit" + value={ + timerangeUnitOptions.find(({ checked }) => checked === 'on')?.label.toLowerCase() ?? + '' + } + /> +
+
+ + {selectedLocations.length === 0 && ( + + )} + {selectedLocations.length > 0 && ( + + + { + setAllLabels(!allLabels); + setSelectedLocations( + selectedLocations.map((l: any) => ({ + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.monitorStatus.locationSelection', + { + defaultMessage: 'Select the location {location}', + values: { + location: l, + }, + } + ), + ...l, + 'data-test-subj': `xpack.uptime.alerts.monitorStatus.locationSelection.${l.label}LocationOption`, + disabled: !allLabels, + })) + ); + }} + /> + + + setSelectedLocations(e)} + > + {location => location} + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.locationsSelectionExpression" + description="from" + id="locations" + value={ + selectedLocations.length === 0 || allLabels + ? 'any location' + : selectedLocationsToString(selectedLocations) + } + /> + )} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts new file mode 100644 index 0000000000000..275333b60c5ee --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts @@ -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 { AlertMonitorStatusComponent } from './alert_monitor_status'; +export { ToggleAlertFlyoutButtonComponent } from './toggle_alert_flyout_button'; +export { UptimeAlertsContextProvider } from './uptime_alerts_context_provider'; +export { UptimeAlertsFlyoutWrapperComponent } from './uptime_alerts_flyout_wrapper'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx new file mode 100644 index 0000000000000..99853a9f775ec --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx @@ -0,0 +1,79 @@ +/* + * 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 { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; + +interface Props { + setAlertFlyoutVisible: (value: boolean) => void; +} + +export const ToggleAlertFlyoutButtonComponent = ({ setAlertFlyoutVisible }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const kibana = useKibana(); + + return ( + setIsOpen(!isOpen)} + > + + + } + closePopover={() => setIsOpen(false)} + isOpen={isOpen} + ownFocus + > + setAlertFlyoutVisible(true)} + > + + , + + + , + ]} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx new file mode 100644 index 0000000000000..a174a7d9c0ea4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx @@ -0,0 +1,38 @@ +/* + * 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 { AlertsContextProvider } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; + +export const UptimeAlertsContextProvider: React.FC = ({ children }) => { + const { + services: { + data: { fieldFormats }, + http, + charts, + notifications, + triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry }, + uiSettings, + }, + } = useKibana(); + + return ( + + {children} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx new file mode 100644 index 0000000000000..13705e7d19293 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx @@ -0,0 +1,30 @@ +/* + * 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 { AlertAdd } from '../../../../../../../plugins/triggers_actions_ui/public'; + +interface Props { + alertFlyoutVisible: boolean; + alertTypeId?: string; + canChangeTrigger?: boolean; + setAlertFlyoutVisibility: React.Dispatch>; +} + +export const UptimeAlertsFlyoutWrapperComponent = ({ + alertFlyoutVisible, + alertTypeId, + canChangeTrigger, + setAlertFlyoutVisibility, +}: Props) => ( + +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts index daba13d8df641..8d0352e01d40e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts @@ -4,6 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +export { + ToggleAlertFlyoutButtonComponent, + UptimeAlertsContextProvider, + UptimeAlertsFlyoutWrapperComponent, +} from './alerts'; +export * from './alerts'; export { DonutChart } from './charts/donut_chart'; export { KueryBarComponent } from './kuery_bar/kuery_bar'; export { MonitorCharts } from './monitor_charts'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx index 2f5ccc2adf313..63aceed2be636 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx @@ -33,14 +33,18 @@ function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) { } interface Props { + 'aria-label': string; autocomplete: DataPublicPluginSetup['autocomplete']; + 'data-test-subj': string; loadIndexPattern: () => void; indexPattern: IIndexPattern | null; loading: boolean; } export function KueryBarComponent({ + 'aria-label': ariaLabel, autocomplete: autocompleteService, + 'data-test-subj': dataTestSubj, loadIndexPattern, indexPattern, loading, @@ -119,6 +123,8 @@ export function KueryBarComponent({ return ( -
+
{ @@ -205,7 +204,7 @@ describe('PingList component', () => { loading={false} data={{ allPings }} onPageCountChange={jest.fn()} - onSelectedLocationChange={(loc: EuiComboBoxOptionOption[]) => {}} + onSelectedLocationChange={(_loc: any[]) => {}} onSelectedStatusChange={jest.fn()} pageSize={30} selectedOption="down" diff --git a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx index a377b9ed1507b..a2f3328b98612 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx +++ b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx @@ -10,6 +10,7 @@ import ReactDOM from 'react-dom'; import { get } from 'lodash'; import { i18n as i18nFormatter } from '@kbn/i18n'; import { PluginsSetup } from 'ui/new_platform/new_platform'; +import { alertTypeInitializers } from '../../alert_types'; import { UptimeApp, UptimeAppProps } from '../../../uptime_app'; import { getIntegratedAppAvailability } from './capabilities_adapter'; import { @@ -32,15 +33,30 @@ export const getKibanaFrameworkAdapter = ( http: { basePath }, i18n, } = core; + + const { + data: { autocomplete }, + // TODO: after NP migration we can likely fix this typing problem + // @ts-ignore we don't control this type + triggers_actions_ui, + } = plugins; + + alertTypeInitializers.forEach(init => + triggers_actions_ui.alertTypeRegistry.register(init({ autocomplete })) + ); + let breadcrumbs: ChromeBreadcrumb[] = []; core.chrome.getBreadcrumbs$().subscribe((nextBreadcrumbs?: ChromeBreadcrumb[]) => { breadcrumbs = nextBreadcrumbs || []; }); + const { apm, infrastructure, logs } = getIntegratedAppAvailability( capabilities, INTEGRATED_SOLUTIONS ); + const canSave = get(capabilities, 'uptime.save', false); + const props: UptimeAppProps = { basePath: basePath.get(), canSave, diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts new file mode 100644 index 0000000000000..6323ee3951e21 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts @@ -0,0 +1,181 @@ +/* + * 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 { validate, initMonitorStatusAlertType } from '../monitor_status'; + +describe('monitor status alert type', () => { + describe('validate', () => { + let params: any; + + beforeEach(() => { + params = { + locations: [], + numTimes: 5, + timerange: { + from: 'now-15m', + to: 'now', + }, + }; + }); + + it(`doesn't throw on empty set`, () => { + expect(validate({})).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/locations: Array", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }", + ], + }, + } + `); + }); + + describe('timerange', () => { + it('is undefined', () => { + delete params.timerange; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }", + ], + }, + } + `); + }); + + it('is missing `from` or `to` value', () => { + expect( + validate({ + ...params, + timerange: {}, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/from: string", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/to: string", + ], + }, + } + `); + }); + + it('is invalid timespan', () => { + expect( + validate({ + ...params, + timerange: { + from: 'now', + to: 'now-15m', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "invalidTimeRange": "Time range start cannot exceed time range end", + }, + } + `); + }); + + it('has unparse-able `from` value', () => { + expect( + validate({ + ...params, + timerange: { + from: 'cannot parse this to a date', + to: 'now', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "timeRangeStartValueNaN": "Specified time range \`from\` is an invalid value", + }, + } + `); + }); + + it('has unparse-able `to` value', () => { + expect( + validate({ + ...params, + timerange: { + from: 'now-15m', + to: 'cannot parse this to a date', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "timeRangeEndValueNaN": "Specified time range \`to\` is an invalid value", + }, + } + `); + }); + }); + + describe('numTimes', () => { + it('is missing', () => { + delete params.numTimes; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + ], + }, + } + `); + }); + + it('is NaN', () => { + expect(validate({ ...params, numTimes: `this isn't a number` })).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value \\"this isn't a number\\" supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + ], + }, + } + `); + }); + + it('is < 1', () => { + expect(validate({ ...params, numTimes: 0 })).toMatchInlineSnapshot(` + Object { + "errors": Object { + "invalidNumTimes": "Number of alert check down times must be an integer greater than 0", + }, + } + `); + }); + }); + }); + + describe('initMonitorStatusAlertType', () => { + expect(initMonitorStatusAlertType({ autocomplete: {} })).toMatchInlineSnapshot(` + Object { + "alertParamsExpression": [Function], + "defaultActionMessage": "{{context.message}} + {{context.completeIdList}}", + "iconClass": "uptimeApp", + "id": "xpack.uptime.alerts.monitorStatus", + "name": "Uptime Monitor Status", + "validate": [Function], + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts new file mode 100644 index 0000000000000..f764505a6d683 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +// TODO: after NP migration is complete we should be able to remove this lint ignore comment +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../../../plugins/triggers_actions_ui/public/types'; +import { initMonitorStatusAlertType } from './monitor_status'; + +export type AlertTypeInitializer = (dependenies: { autocomplete: any }) => AlertTypeModel; + +export const alertTypeInitializers: AlertTypeInitializer[] = [initMonitorStatusAlertType]; diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx new file mode 100644 index 0000000000000..effbb59539d16 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx @@ -0,0 +1,71 @@ +/* + * 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 { PathReporter } from 'io-ts/lib/PathReporter'; +import React from 'react'; +import DateMath from '@elastic/datemath'; +import { isRight } from 'fp-ts/lib/Either'; +import { + AlertTypeModel, + ValidationResult, + // TODO: this typing issue should be resolved after NP migration + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../plugins/triggers_actions_ui/public/types'; +import { AlertTypeInitializer } from '.'; +import { StatusCheckExecutorParamsType } from '../../../common/runtime_types'; +import { AlertMonitorStatus } from '../../components/connected/alerts'; + +export const validate = (alertParams: any): ValidationResult => { + const errors: Record = {}; + const decoded = StatusCheckExecutorParamsType.decode(alertParams); + + /* + * When the UI initially loads, this validate function is called with an + * empty set of params, we don't want to type check against that. + */ + if (!isRight(decoded)) { + errors.typeCheckFailure = 'Provided parameters do not conform to the expected type.'; + errors.typeCheckParsingMessage = PathReporter.report(decoded); + } + + if (isRight(decoded)) { + const { numTimes, timerange } = decoded.right; + const { from, to } = timerange; + const fromAbs = DateMath.parse(from)?.valueOf(); + const toAbs = DateMath.parse(to)?.valueOf(); + if (!fromAbs || isNaN(fromAbs)) { + errors.timeRangeStartValueNaN = 'Specified time range `from` is an invalid value'; + } + if (!toAbs || isNaN(toAbs)) { + errors.timeRangeEndValueNaN = 'Specified time range `to` is an invalid value'; + } + + // the default values for this test will pass, we only want to specify an error + // in the case that `from` is more recent than `to` + if ((fromAbs ?? 0) > (toAbs ?? 1)) { + errors.invalidTimeRange = 'Time range start cannot exceed time range end'; + } + + if (numTimes < 1) { + errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0'; + } + } + + return { errors }; +}; + +export const initMonitorStatusAlertType: AlertTypeInitializer = ({ + autocomplete, +}): AlertTypeModel => ({ + id: 'xpack.uptime.alerts.monitorStatus', + name: 'Uptime Monitor Status', + iconClass: 'uptimeApp', + alertParamsExpression: params => { + return ; + }, + validate, + defaultActionMessage: '{{context.message}}\n{{context.completeIdList}}', +}); diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index 5906a77f55441..30e15ba132996 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -14,6 +14,39 @@ Array [ TestingHeading
+
+
+
+ +
+
+
@@ -130,6 +163,39 @@ Array [ TestingHeading
+
+
+
+ +
+
+
,
{ const simpleBreadcrumbs: ChromeBreadcrumb[] = [ @@ -21,22 +22,26 @@ describe('PageHeader', () => { it('shallow renders with breadcrumbs and the date picker', () => { const component = renderWithRouter( - + + + ); expect(component).toMatchSnapshot('page_header_with_date_picker'); }); it('shallow renders with breadcrumbs without the date picker', () => { const component = renderWithRouter( - + + + ); expect(component).toMatchSnapshot('page_header_no_date_picker'); }); @@ -45,13 +50,15 @@ describe('PageHeader', () => { const [getBreadcrumbs, core] = mockCore(); mountWithRouter( - - - + + + + + ); @@ -62,6 +69,19 @@ describe('PageHeader', () => { }); }); +const MockReduxProvider = ({ children }: { children: React.ReactElement }) => ( + + {children} + +); + const mockCore: () => [() => ChromeBreadcrumb[], any] = () => { let breadcrumbObj: ChromeBreadcrumb[] = []; const get = () => { diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index af9b8bf046416..f9184e2a0587f 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -83,7 +83,13 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi - + diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx index b0fb2d0ed7869..56d9ae2d5caa6 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx @@ -13,6 +13,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useUrlParams } from '../hooks'; import { UptimeUrlParams } from '../lib/helper'; +import { ToggleAlertFlyoutButton } from '../components/connected'; interface PageHeaderProps { headingText: string; @@ -60,6 +61,9 @@ export const PageHeader = ({ headingText, breadcrumbs, datePicker = true }: Page

{headingText}

+ + + {datePickerComponent}
diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts index d15d601737b2d..4885f974dbbd4 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts @@ -12,6 +12,8 @@ export interface PopoverState { export type UiPayload = PopoverState & string & number & Map; +export const setAlertFlyoutVisible = createAction('TOGGLE ALERT FLYOUT'); + export const setBasePath = createAction('SET BASE PATH'); export const triggerAppRefresh = createAction('REFRESH APP'); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index 5d03c0058c3c1..1dc4e45606c60 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -2,6 +2,7 @@ exports[`ui reducer adds integration popover status to state 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "", "esKuery": "", "integrationsPopoverOpen": Object { @@ -14,6 +15,7 @@ Object { exports[`ui reducer sets the application's base path 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "yyz", "esKuery": "", "integrationsPopoverOpen": null, @@ -23,6 +25,7 @@ Object { exports[`ui reducer updates the refresh value 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "abc", "esKuery": "", "integrationsPopoverOpen": null, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index 417095b64ba2d..3c134366347aa 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { setBasePath, toggleIntegrationsPopover, triggerAppRefresh } from '../../actions'; +import { + setBasePath, + toggleIntegrationsPopover, + triggerAppRefresh, + setAlertFlyoutVisible, +} from '../../actions'; import { uiReducer } from '../ui'; import { Action } from 'redux-actions'; @@ -14,6 +19,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: 'abc', esKuery: '', integrationsPopoverOpen: null, @@ -32,6 +38,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: '', esKuery: '', integrationsPopoverOpen: null, @@ -47,6 +54,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: 'abc', esKuery: '', integrationsPopoverOpen: null, @@ -56,4 +64,28 @@ describe('ui reducer', () => { ) ).toMatchSnapshot(); }); + + it('updates the alert flyout value', () => { + const action = setAlertFlyoutVisible(true) as Action; + expect( + uiReducer( + { + alertFlyoutVisible: false, + basePath: '', + esKuery: '', + integrationsPopoverOpen: null, + lastRefresh: 125, + }, + action + ) + ).toMatchInlineSnapshot(` + Object { + "alertFlyoutVisible": true, + "basePath": "", + "esKuery": "", + "integrationsPopoverOpen": null, + "lastRefresh": 125, + } + `); + }); }); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts index bb5bd22085ac6..702d314250521 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts @@ -12,19 +12,22 @@ import { setEsKueryString, triggerAppRefresh, UiPayload, + setAlertFlyoutVisible, } from '../actions/ui'; export interface UiState { - integrationsPopoverOpen: PopoverState | null; + alertFlyoutVisible: boolean; basePath: string; esKuery: string; + integrationsPopoverOpen: PopoverState | null; lastRefresh: number; } const initialState: UiState = { - integrationsPopoverOpen: null, + alertFlyoutVisible: false, basePath: '', esKuery: '', + integrationsPopoverOpen: null, lastRefresh: Date.now(), }; @@ -35,6 +38,11 @@ export const uiReducer = handleActions( integrationsPopoverOpen: action.payload as PopoverState, }), + [String(setAlertFlyoutVisible)]: (state, action: Action) => ({ + ...state, + alertFlyoutVisible: action.payload ?? !state.alertFlyoutVisible, + }), + [String(setBasePath)]: (state, action: Action) => ({ ...state, basePath: action.payload as string, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index de446418632b8..b1da995709f93 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -35,6 +35,7 @@ describe('state selectors', () => { loading: false, }, ui: { + alertFlyoutVisible: false, basePath: 'yyz', esKuery: '', integrationsPopoverOpen: null, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts index 4767c25e8f52f..7b5a5ddf8d3ca 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts @@ -46,6 +46,15 @@ export const selectDurationLines = ({ monitorDuration }: AppState) => { return monitorDuration; }; +export const selectAlertFlyoutVisibility = ({ ui: { alertFlyoutVisible } }: AppState) => + alertFlyoutVisible; + +export const selectMonitorStatusAlert = ({ indexPattern, overviewFilters, ui }: AppState) => ({ + filters: ui.esKuery, + indexPattern: indexPattern.index_pattern, + locations: overviewFilters.filters.locations, +}); + export const indexStatusSelector = ({ indexStatus }: AppState) => { return indexStatus; }; diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx index 09156db9ca7d2..fa2998532d145 100644 --- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx +++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx @@ -23,6 +23,8 @@ import { CommonlyUsedRange } from './components/functional/uptime_date_picker'; import { store } from './state'; import { setBasePath } from './state/actions'; import { PageRouter } from './routes'; +import { UptimeAlertsFlyoutWrapper } from './components/connected'; +import { UptimeAlertsContextProvider } from './components/functional/alerts'; import { kibanaService } from './state/kibana_service'; export interface UptimeAppColors { @@ -99,11 +101,14 @@ const Application = (props: UptimeAppProps) => { - -
- -
-
+ + +
+ + +
+
+
diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index dd61716325afc..603cfac316b2d 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack"], "id": "uptime", "kibanaVersion": "kibana", - "requiredPlugins": ["features", "licensing", "usageCollection"], + "requiredPlugins": ["alerting", "features", "licensing", "usageCollection"], "server": true, "ui": false, "version": "8.0.0" diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index c7ac3a70c0494..2c1f34aa8a8e7 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -55,5 +55,5 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor }, }); - initUptimeServer(libs); + initUptimeServer(server, libs, plugins); }; diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index 8dde6050d5d36..6fc488e949e9c 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -31,6 +31,8 @@ export interface UptimeCoreSetup { export interface UptimeCorePlugins { features: PluginSetupContract; + alerting: any; + elasticsearch: any; usageCollection: UsageCollectionSetup; } diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts new file mode 100644 index 0000000000000..8a11270a4740a --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -0,0 +1,587 @@ +/* + * 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 { + contextMessage, + uniqueMonitorIds, + updateState, + statusCheckAlertFactory, + fullListByIdAndLocation, +} from '../status_check'; +import { GetMonitorStatusResult } from '../../requests'; +import { AlertType } from '../../../../../alerting/server'; +import { IRouter } from 'kibana/server'; +import { UMServerLibs } from '../../lib'; +import { UptimeCoreSetup } from '../../adapters'; + +/** + * The alert takes some dependencies as parameters; these are things like + * kibana core services and plugins. This function helps reduce the amount of + * boilerplate required. + * @param customRequests client tests can use this paramter to provide their own request mocks, + * so we don't have to mock them all for each test. + */ +const bootstrapDependencies = (customRequests?: any) => { + const route: IRouter = {} as IRouter; + // these server/libs parameters don't have any functionality, which is fine + // because we aren't testing them here + const server: UptimeCoreSetup = { route }; + const libs: UMServerLibs = { requests: {} } as UMServerLibs; + libs.requests = { ...libs.requests, ...customRequests }; + return { server, libs }; +}; + +/** + * This function aims to provide an easy way to give mock props that will + * reduce boilerplate for tests. + * @param params the params received at alert creation time + * @param services the core services provided by kibana/alerting platforms + * @param state the state the alert maintains + */ +const mockOptions = ( + params = { numTimes: 5, locations: [], timerange: { from: 'now-15m', to: 'now' } }, + services = { callCluster: 'mockESFunction' }, + state = {} +): any => ({ + params, + services, + state, +}); + +describe('status check alert', () => { + describe('executor', () => { + it('does not trigger when there are no monitors down', async () => { + expect.assertions(4); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([]); + const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs); + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(mockOptions()); + + expect(state).not.toBeUndefined(); + expect(state?.isTriggered).toBe(false); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": "mockESFunction", + "locations": Array [], + "numTimes": 5, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + }); + + it('triggers when monitors are down and provides expected state', async () => { + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 234, + status: 'down', + }, + ]); + const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs); + const mockInstanceFactory = jest.fn(); + const mockReplaceState = jest.fn(); + const mockScheduleActions = jest.fn(); + mockInstanceFactory.mockReturnValue({ + replaceState: mockReplaceState, + scheduleActions: mockScheduleActions, + }); + const options = mockOptions(); + options.services = { + ...options.services, + alertInstanceFactory: mockInstanceFactory, + }; + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(options); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockInstanceFactory).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": "mockESFunction", + "locations": Array [], + "numTimes": 5, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + expect(mockReplaceState).toHaveBeenCalledTimes(1); + expect(mockReplaceState.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "monitors": Array [ + Object { + "count": 234, + "location": "fairbanks", + "monitor_id": "first", + "status": "down", + }, + Object { + "count": 234, + "location": "harrisburg", + "monitor_id": "first", + "status": "down", + }, + ], + }, + ] + `); + expect(mockScheduleActions).toHaveBeenCalledTimes(1); + expect(mockScheduleActions.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "xpack.uptime.alerts.actionGroups.monitorStatus", + Object { + "completeIdList": "first from fairbanks; first from harrisburg; ", + "message": "Down monitor: first", + "server": Object { + "route": Object {}, + }, + }, + ] + `); + }); + }); + + describe('fullListByIdAndLocation', () => { + it('renders a list of all monitors', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + ]; + expect(fullListByIdAndLocation(statuses)).toMatchInlineSnapshot( + `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; "` + ); + }); + + it('renders a list of monitors when greater than limit', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + ]; + expect(fullListByIdAndLocation(statuses.slice(0, 2), 1)).toMatchInlineSnapshot( + `"first from fairbanks; ...and 1 other monitor/location"` + ); + }); + + it('renders expected list of monitors when limit difference > 1', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'third', + status: 'down', + count: 34, + }, + { + location: 'fairbanks', + monitor_id: 'third', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + ]; + expect(fullListByIdAndLocation(statuses, 4)).toMatchInlineSnapshot( + `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; ...and 2 other monitors/locations"` + ); + }); + }); + + describe('alert factory', () => { + let alert: AlertType; + + beforeEach(() => { + const { server, libs } = bootstrapDependencies(); + alert = statusCheckAlertFactory(server, libs); + }); + + it('creates an alert with expected params', () => { + // @ts-ignore the `props` key here isn't described + expect(Object.keys(alert.validate?.params?.props ?? {})).toMatchInlineSnapshot(` + Array [ + "filters", + "numTimes", + "timerange", + "locations", + ] + `); + }); + + it('contains the expected static fields like id, name, etc.', () => { + expect(alert.id).toBe('xpack.uptime.alerts.monitorStatus'); + expect(alert.name).toBe('Uptime Monitor Status'); + expect(alert.defaultActionGroupId).toBe('xpack.uptime.alerts.actionGroups.monitorStatus'); + expect(alert.actionGroups).toMatchInlineSnapshot(` + Array [ + Object { + "id": "xpack.uptime.alerts.actionGroups.monitorStatus", + "name": "Uptime Down Monitor", + }, + ] + `); + }); + }); + + describe('updateState', () => { + let spy: jest.SpyInstance; + beforeEach(() => { + spy = jest.spyOn(Date.prototype, 'toISOString'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets initial state values', () => { + spy.mockImplementation(() => 'foo date string'); + const result = updateState({}, false); + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "foo date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "foo date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + }); + + it('updates the correct field in subsequent calls', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string'); + const firstState = updateState({}, false); + const secondState = updateState(firstState, true); + expect(spy).toHaveBeenCalledTimes(2); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "second date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + }); + + it('correctly marks resolution times', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string') + .mockImplementationOnce(() => 'third date string'); + const firstState = updateState({}, true); + const secondState = updateState(firstState, true); + const thirdState = updateState(secondState, false); + expect(spy).toHaveBeenCalledTimes(3); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "first date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": true, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "first date string", + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "first date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + expect(thirdState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": false, + "lastCheckedAt": "third date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "second date string", + } + `); + }); + + it('correctly marks state fields across multiple triggers/resolutions', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string') + .mockImplementationOnce(() => 'third date string') + .mockImplementationOnce(() => 'fourth date string') + .mockImplementationOnce(() => 'fifth date string'); + const firstState = updateState({}, false); + const secondState = updateState(firstState, true); + const thirdState = updateState(secondState, false); + const fourthState = updateState(thirdState, true); + const fifthState = updateState(fourthState, false); + expect(spy).toHaveBeenCalledTimes(5); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "second date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + expect(thirdState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": false, + "lastCheckedAt": "third date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "second date string", + } + `); + expect(fourthState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "fourth date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "fourth date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "fourth date string", + } + `); + expect(fifthState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": false, + "lastCheckedAt": "fifth date string", + "lastResolvedAt": "fifth date string", + "lastTriggeredAt": "fourth date string", + } + `); + }); + }); + + describe('uniqueMonitorIds', () => { + let items: GetMonitorStatusResult[]; + beforeEach(() => { + items = [ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 312, + status: 'down', + }, + { + monitor_id: 'second', + location: 'harrisburg', + count: 325, + status: 'down', + }, + { + monitor_id: 'second', + location: 'fairbanks', + count: 331, + status: 'down', + }, + { + monitor_id: 'third', + location: 'harrisburg', + count: 331, + status: 'down', + }, + { + monitor_id: 'third', + location: 'fairbanks', + count: 342, + status: 'down', + }, + { + monitor_id: 'fourth', + location: 'harrisburg', + count: 355, + status: 'down', + }, + { + monitor_id: 'fourth', + location: 'fairbanks', + count: 342, + status: 'down', + }, + { + monitor_id: 'fifth', + location: 'harrisburg', + count: 342, + status: 'down', + }, + { + monitor_id: 'fifth', + location: 'fairbanks', + count: 342, + status: 'down', + }, + ]; + }); + + it('creates a set of unique IDs from a list of composite-unique objects', () => { + expect(uniqueMonitorIds(items)).toEqual( + new Set(['first', 'second', 'third', 'fourth', 'fifth']) + ); + }); + }); + + describe('contextMessage', () => { + let ids: string[]; + beforeEach(() => { + ids = ['first', 'second', 'third', 'fourth', 'fifth']; + }); + + it('creates a message with appropriate number of monitors', () => { + expect(contextMessage(ids, 3)).toMatchInlineSnapshot( + `"Down monitors: first, second, third... and 2 other monitors"` + ); + }); + + it('throws an error if `max` is less than 2', () => { + expect(() => contextMessage(ids, 1)).toThrowErrorMatchingInlineSnapshot( + '"Maximum value must be greater than 2, received 1."' + ); + }); + + it('returns only the ids if length < max', () => { + expect(contextMessage(ids.slice(0, 2), 3)).toMatchInlineSnapshot( + `"Down monitors: first, second"` + ); + }); + + it('returns a default message when no monitors are provided', () => { + expect(contextMessage([], 3)).toMatchInlineSnapshot(`"No down monitor IDs received"`); + }); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/index.ts b/x-pack/plugins/uptime/server/lib/alerts/index.ts new file mode 100644 index 0000000000000..0e61fd70e0024 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/index.ts @@ -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. + */ + +import { UptimeAlertTypeFactory } from './types'; +import { statusCheckAlertFactory } from './status_check'; + +export const uptimeAlertTypeFactories: UptimeAlertTypeFactory[] = [statusCheckAlertFactory]; diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts new file mode 100644 index 0000000000000..3e90d2ce95a10 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -0,0 +1,234 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { isRight } from 'fp-ts/lib/Either'; +import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; +import { i18n } from '@kbn/i18n'; +import { AlertExecutorOptions } from '../../../../alerting/server'; +import { ACTION_GROUP_DEFINITIONS } from '../../../../../legacy/plugins/uptime/common/constants'; +import { UptimeAlertTypeFactory } from './types'; +import { GetMonitorStatusResult } from '../requests'; +import { + StatusCheckExecutorParamsType, + StatusCheckAlertStateType, + StatusCheckAlertState, +} from '../../../../../legacy/plugins/uptime/common/runtime_types'; + +const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS; + +/** + * Reduce a composite-key array of status results to a set of unique IDs. + * @param items to reduce + */ +export const uniqueMonitorIds = (items: GetMonitorStatusResult[]): Set => + items.reduce((acc, { monitor_id }) => { + acc.add(monitor_id); + return acc; + }, new Set()); + +/** + * Generates a message to include in contexts of alerts. + * @param monitors the list of monitors to include in the message + * @param max + */ +export const contextMessage = (monitorIds: string[], max: number): string => { + const MIN = 2; + if (max < MIN) throw new Error(`Maximum value must be greater than ${MIN}, received ${max}.`); + + // generate the message + let message; + if (monitorIds.length === 1) { + message = i18n.translate('xpack.uptime.alerts.message.singularTitle', { + defaultMessage: 'Down monitor: ', + }); + } else if (monitorIds.length) { + message = i18n.translate('xpack.uptime.alerts.message.multipleTitle', { + defaultMessage: 'Down monitors: ', + }); + } + // this shouldn't happen because the function should only be called + // when > 0 monitors are down + else { + message = i18n.translate('xpack.uptime.alerts.message.emptyTitle', { + defaultMessage: 'No down monitor IDs received', + }); + } + + for (let i = 0; i < monitorIds.length; i++) { + const id = monitorIds[i]; + if (i === max) { + return ( + message + + i18n.translate('xpack.uptime.alerts.message.overflowBody', { + defaultMessage: `... and {overflowCount} other monitors`, + values: { + overflowCount: monitorIds.length - i, + }, + }) + ); + } else if (i === 0) { + message = message + id; + } else { + message = message + `, ${id}`; + } + } + + return message; +}; + +/** + * Creates an exhaustive list of all the down monitors. + * @param list all the monitors that are down + * @param sizeLimit the max monitors, we shouldn't allow an arbitrarily long string + */ +export const fullListByIdAndLocation = ( + list: GetMonitorStatusResult[], + sizeLimit: number = 1000 +) => { + return ( + list + // sort by id, then location + .sort((a, b) => { + if (a.monitor_id > b.monitor_id) { + return 1; + } else if (a.monitor_id < b.monitor_id) { + return -1; + } else if (a.location > b.location) { + return 1; + } + return -1; + }) + .slice(0, sizeLimit) + .reduce((cur, { monitor_id: id, location }) => cur + `${id} from ${location}; `, '') + + (sizeLimit < list.length + ? i18n.translate('xpack.uptime.alerts.message.fullListOverflow', { + defaultMessage: '...and {overflowCount} other {pluralizedMonitor}', + values: { + pluralizedMonitor: + list.length - sizeLimit === 1 ? 'monitor/location' : 'monitors/locations', + overflowCount: list.length - sizeLimit, + }, + }) + : '') + ); +}; + +export const updateState = ( + state: Record, + isTriggeredNow: boolean +): StatusCheckAlertState => { + const now = new Date().toISOString(); + const decoded = StatusCheckAlertStateType.decode(state); + if (!isRight(decoded)) { + const triggerVal = isTriggeredNow ? now : undefined; + return { + currentTriggerStarted: triggerVal, + firstCheckedAt: now, + firstTriggeredAt: triggerVal, + isTriggered: isTriggeredNow, + lastTriggeredAt: triggerVal, + lastCheckedAt: now, + lastResolvedAt: undefined, + }; + } + const { + currentTriggerStarted, + firstCheckedAt, + firstTriggeredAt, + lastTriggeredAt, + // this is the stale trigger status, we're naming it `wasTriggered` + // to differentiate it from the `isTriggeredNow` param + isTriggered: wasTriggered, + lastResolvedAt, + } = decoded.right; + + let cts: string | undefined; + if (isTriggeredNow && !currentTriggerStarted) { + cts = now; + } else if (isTriggeredNow) { + cts = currentTriggerStarted; + } + + return { + currentTriggerStarted: cts, + firstCheckedAt: firstCheckedAt ?? now, + firstTriggeredAt: isTriggeredNow && !firstTriggeredAt ? now : firstTriggeredAt, + lastCheckedAt: now, + lastTriggeredAt: isTriggeredNow ? now : lastTriggeredAt, + lastResolvedAt: !isTriggeredNow && wasTriggered ? now : lastResolvedAt, + isTriggered: isTriggeredNow, + }; +}; + +// Right now the maximum number of monitors shown in the message is hardcoded here. +// we might want to make this a parameter in the future +const DEFAULT_MAX_MESSAGE_ROWS = 3; + +export const statusCheckAlertFactory: UptimeAlertTypeFactory = (server, libs) => ({ + id: 'xpack.uptime.alerts.monitorStatus', + name: i18n.translate('xpack.uptime.alerts.monitorStatus', { + defaultMessage: 'Uptime Monitor Status', + }), + validate: { + params: schema.object({ + filters: schema.maybe(schema.string()), + numTimes: schema.number(), + timerange: schema.object({ + from: schema.string(), + to: schema.string(), + }), + locations: schema.arrayOf(schema.string()), + }), + }, + defaultActionGroupId: MONITOR_STATUS.id, + actionGroups: [ + { + id: MONITOR_STATUS.id, + name: MONITOR_STATUS.name, + }, + ], + async executor(options: AlertExecutorOptions) { + const { params: rawParams } = options; + const decoded = StatusCheckExecutorParamsType.decode(rawParams); + if (!isRight(decoded)) { + ThrowReporter.report(decoded); + return { + error: 'Alert param types do not conform to required shape.', + }; + } + + const params = decoded.right; + + /* This is called `monitorsByLocation` but it's really + * monitors by location by status. The query we run to generate this + * filters on the status field, so effectively there should be one and only one + * status represented in the result set. */ + const monitorsByLocation = await libs.requests.getMonitorStatus({ + callES: options.services.callCluster, + ...params, + }); + + // if no monitors are down for our query, we don't need to trigger an alert + if (monitorsByLocation.length) { + const uniqueIds = uniqueMonitorIds(monitorsByLocation); + const alertInstance = options.services.alertInstanceFactory(MONITOR_STATUS.id); + alertInstance.replaceState({ + ...options.state, + monitors: monitorsByLocation, + }); + alertInstance.scheduleActions(MONITOR_STATUS.id, { + message: contextMessage(Array.from(uniqueIds.keys()), DEFAULT_MAX_MESSAGE_ROWS), + server, + completeIdList: fullListByIdAndLocation(monitorsByLocation), + }); + } + + // this stateful data is at the cluster level, not an alert instance level, + // so any alert of this type will flush/overwrite the state when they return + return updateState(options.state, monitorsByLocation.length > 0); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts new file mode 100644 index 0000000000000..bc1e82224f7b0 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/types.ts @@ -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 { AlertType } from '../../../../alerting/server'; +import { UptimeCoreSetup } from '../adapters'; +import { UMServerLibs } from '../lib'; + +export type UptimeAlertTypeFactory = (server: UptimeCoreSetup, libs: UMServerLibs) => AlertType; diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts new file mode 100644 index 0000000000000..74b8c352c8553 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts @@ -0,0 +1,553 @@ +/* + * 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 { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; +import { getMonitorStatus } from '../get_monitor_status'; +import { ScopedClusterClient } from 'src/core/server/elasticsearch'; + +interface BucketItemCriteria { + monitor_id: string; + status: string; + location: string; + doc_count: number; +} + +interface BucketKey { + monitor_id: string; + status: string; + location: string; +} + +interface BucketItem { + key: BucketKey; + doc_count: number; +} + +interface MultiPageCriteria { + after_key?: BucketKey; + bucketCriteria: BucketItemCriteria[]; +} + +const genBucketItem = ({ + monitor_id, + status, + location, + doc_count, +}: BucketItemCriteria): BucketItem => ({ + key: { + monitor_id, + status, + location, + }, + doc_count, +}); + +type MockCallES = (method: any, params: any) => Promise; + +const setupMock = ( + criteria: MultiPageCriteria[] +): [MockCallES, jest.Mocked>] => { + const esMock = elasticsearchServiceMock.createScopedClusterClient(); + + criteria.forEach(({ after_key, bucketCriteria }) => { + const mockResponse = { + aggregations: { + monitors: { + after_key, + buckets: bucketCriteria.map(item => genBucketItem(item)), + }, + }, + }; + esMock.callAsCurrentUser.mockResolvedValueOnce(mockResponse); + }); + return [(method: any, params: any) => esMock.callAsCurrentUser(method, params), esMock]; +}; + +describe('getMonitorStatus', () => { + it('applies bool filters to params', async () => { + const [callES, esMock] = setupMock([]); + const exampleFilter = `{ + "bool": { + "should": [ + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "apm-dev" + } + } + ], + "minimum_should_match": 1 + } + }, + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A" + } + } + ], + "minimum_should_match": 1 + } + } + ], + "minimum_should_match": 1 + } + }`; + await getMonitorStatus({ + callES, + filters: exampleFilter, + locations: [], + numTimes: 5, + timerange: { + from: 'now-10m', + to: 'now-1m', + }, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-10m", + "lte": "now-1m", + }, + }, + }, + ], + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "apm-dev", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('applies locations to params', async () => { + const [callES, esMock] = setupMock([]); + await getMonitorStatus({ + callES, + locations: ['fairbanks', 'harrisburg'], + numTimes: 1, + timerange: { + from: 'now-2m', + to: 'now', + }, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-2m", + "lte": "now", + }, + }, + }, + Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "harrisburg", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('fetches single page of results', async () => { + const [callES, esMock] = setupMock([ + { + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + ]); + const clientParameters = { + filters: undefined, + locations: [], + numTimes: 5, + timerange: { + from: 'now-12m', + to: 'now-2m', + }, + }; + const result = await getMonitorStatus({ + callES, + ...clientParameters, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-12m", + "lte": "now-2m", + }, + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 43, + "location": "fairbanks", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 53, + "location": "harrisburg", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 44, + "location": "harrisburg", + "monitor_id": "foo", + "status": "down", + }, + ] + `); + }); + + it('fetches multiple pages of results in the thing', async () => { + const criteria = [ + { + after_key: { + monitor_id: 'foo', + location: 'harrisburg', + status: 'down', + }, + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + { + after_key: { + monitor_id: 'bar', + status: 'down', + location: 'fairbanks', + }, + bucketCriteria: [ + { + monitor_id: 'sna', + status: 'down', + location: 'fairbanks', + doc_count: 21, + }, + { + monitor_id: 'fu', + status: 'down', + location: 'fairbanks', + doc_count: 21, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'fairbanks', + doc_count: 45, + }, + ], + }, + { + bucketCriteria: [ + { + monitor_id: 'sna', + status: 'down', + location: 'harrisburg', + doc_count: 21, + }, + { + monitor_id: 'fu', + status: 'down', + location: 'harrisburg', + doc_count: 21, + }, + ], + }, + ]; + const [callES] = setupMock(criteria); + const result = await getMonitorStatus({ + callES, + locations: [], + numTimes: 5, + timerange: { + from: 'now-10m', + to: 'now-1m', + }, + }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 43, + "location": "fairbanks", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 53, + "location": "harrisburg", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 44, + "location": "harrisburg", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 21, + "location": "fairbanks", + "monitor_id": "sna", + "status": "down", + }, + Object { + "count": 21, + "location": "fairbanks", + "monitor_id": "fu", + "status": "down", + }, + Object { + "count": 45, + "location": "fairbanks", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 21, + "location": "harrisburg", + "monitor_id": "sna", + "status": "down", + }, + Object { + "count": 21, + "location": "harrisburg", + "monitor_id": "fu", + "status": "down", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts new file mode 100644 index 0000000000000..2cebd532fd29b --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts @@ -0,0 +1,150 @@ +/* + * 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 { UMElasticsearchQueryFn } from '../adapters'; +import { INDEX_NAMES } from '../../../../../legacy/plugins/uptime/common/constants'; + +export interface GetMonitorStatusParams { + filters?: string; + locations: string[]; + numTimes: number; + timerange: { from: string; to: string }; +} + +export interface GetMonitorStatusResult { + monitor_id: string; + status: string; + location: string; + count: number; +} + +interface MonitorStatusKey { + monitor_id: string; + status: string; + location: string; +} + +const formatBuckets = async ( + buckets: any[], + numTimes: number +): Promise => { + return buckets + .filter((monitor: any) => monitor?.doc_count > numTimes) + .map(({ key, doc_count }: any) => ({ ...key, count: doc_count })); +}; + +const getLocationClause = (locations: string[]) => ({ + bool: { + should: [ + ...locations.map(location => ({ + term: { + 'observer.geo.name': location, + }, + })), + ], + }, +}); + +export const getMonitorStatus: UMElasticsearchQueryFn< + GetMonitorStatusParams, + GetMonitorStatusResult[] +> = async ({ callES, filters, locations, numTimes, timerange: { from, to } }) => { + const queryResults: Array> = []; + let afterKey: MonitorStatusKey | undefined; + + do { + // today this value is hardcoded. In the future we may support + // multiple status types for this alert, and this will become a parameter + const STATUS = 'down'; + const esParams: any = { + index: INDEX_NAMES.HEARTBEAT, + body: { + query: { + bool: { + filter: [ + { + term: { + 'monitor.status': STATUS, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + size: 0, + aggs: { + monitors: { + composite: { + size: 2000, + sources: [ + { + monitor_id: { + terms: { + field: 'monitor.id', + }, + }, + }, + { + status: { + terms: { + field: 'monitor.status', + }, + }, + }, + { + location: { + terms: { + field: 'observer.geo.name', + missing_bucket: true, + }, + }, + }, + ], + }, + }, + }, + }, + }; + + /** + * `filters` are an unparsed JSON string. We parse them and append the bool fields of the query + * to the bool of the parsed filters. + */ + if (filters) { + const parsedFilters = JSON.parse(filters); + esParams.body.query.bool = Object.assign({}, esParams.body.query.bool, parsedFilters.bool); + } + + /** + * Perform a logical `and` against the selected location filters. + */ + if (locations.length) { + esParams.body.query.bool.filter.push(getLocationClause(locations)); + } + + /** + * We "paginate" results by utilizing the `afterKey` field + * to tell Elasticsearch where it should start on subsequent queries. + */ + if (afterKey) { + esParams.body.aggs.monitors.composite.after = afterKey; + } + + const result = await callES('search', esParams); + afterKey = result?.aggregations?.monitors?.after_key; + + queryResults.push(formatBuckets(result?.aggregations?.monitors?.buckets || [], numTimes)); + } while (afterKey !== undefined); + + return (await Promise.all(queryResults)).reduce((acc, cur) => acc.concat(cur), []); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index b1d7ff2c2ce02..7225d329d3c7f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -12,6 +12,8 @@ export { getMonitorDurationChart, GetMonitorChartsParams } from './get_monitor_d export { getMonitorDetails, GetMonitorDetailsParams } from './get_monitor_details'; export { getMonitorLocations, GetMonitorLocationsParams } from './get_monitor_locations'; export { getMonitorStates, GetMonitorStatesParams } from './get_monitor_states'; +export { getMonitorStatus, GetMonitorStatusParams } from './get_monitor_status'; +export * from './get_monitor_status'; export { getPings, GetPingsParams } from './get_pings'; export { getPingHistogram, GetPingHistogramParams } from './get_ping_histogram'; export { UptimeRequests } from './uptime_requests'; diff --git a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts index 7f192994bd075..ddf506786f145 100644 --- a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts +++ b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts @@ -16,6 +16,8 @@ import { GetMonitorStatesParams, GetPingsParams, GetPingHistogramParams, + GetMonitorStatusParams, + GetMonitorStatusResult, } from '.'; import { OverviewFilters, @@ -42,6 +44,7 @@ export interface UptimeRequests { getMonitorDetails: ESQ; getMonitorLocations: ESQ; getMonitorStates: ESQ; + getMonitorStatus: ESQ; getPings: ESQ; getPingHistogram: ESQ; getSnapshotCount: ESQ; diff --git a/x-pack/plugins/uptime/server/uptime_server.ts b/x-pack/plugins/uptime/server/uptime_server.ts index 4dfa1373db8d9..d4b38b8ad27a0 100644 --- a/x-pack/plugins/uptime/server/uptime_server.ts +++ b/x-pack/plugins/uptime/server/uptime_server.ts @@ -8,12 +8,22 @@ import { makeExecutableSchema } from 'graphql-tools'; import { DEFAULT_GRAPHQL_PATH, resolvers, typeDefs } from './graphql'; import { UMServerLibs } from './lib/lib'; import { createRouteWithAuth, restApiRoutes, uptimeRouteWrapper } from './rest_api'; +import { UptimeCoreSetup, UptimeCorePlugins } from './lib/adapters'; +import { uptimeAlertTypeFactories } from './lib/alerts'; -export const initUptimeServer = (libs: UMServerLibs) => { +export const initUptimeServer = ( + server: UptimeCoreSetup, + libs: UMServerLibs, + plugins: UptimeCorePlugins +) => { restApiRoutes.forEach(route => libs.framework.registerRoute(uptimeRouteWrapper(createRouteWithAuth(libs, route))) ); + uptimeAlertTypeFactories.forEach(alertTypeFactory => + plugins.alerting.registerType(alertTypeFactory(server, libs)) + ); + const graphQLSchema = makeExecutableSchema({ resolvers: resolvers.map(createResolversFn => createResolversFn(libs)), typeDefs, diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index f6e93cd14e497..57842ffbb2c5d 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -24,11 +24,13 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo public async goToUptimeOverviewAndLoadData( datePickerStartValue: string, datePickerEndValue: string, - monitorIdToCheck: string + monitorIdToCheck?: string ) { await pageObjects.common.navigateToApp('uptime'); await pageObjects.timePicker.setAbsoluteRange(datePickerStartValue, datePickerEndValue); - await uptimeService.monitorIdExists(monitorIdToCheck); + if (monitorIdToCheck) { + await uptimeService.monitorIdExists(monitorIdToCheck); + } } public async loadDataAndGoToMonitorPage( @@ -96,5 +98,39 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo public locationMissingIsDisplayed() { return uptimeService.locationMissingExists(); } + + public async openAlertFlyoutAndCreateMonitorStatusAlert({ + alertInterval, + alertName, + alertNumTimes, + alertTags, + alertThrottleInterval, + alertTimerangeSelection, + filters, + }: { + alertName: string; + alertTags: string[]; + alertInterval: string; + alertThrottleInterval: string; + alertNumTimes: string; + alertTimerangeSelection: string; + filters?: string; + }) { + const { alerts, setKueryBarText } = uptimeService; + await alerts.openFlyout(); + await alerts.openMonitorStatusAlertType(); + await alerts.setAlertName(alertName); + await alerts.setAlertTags(alertTags); + await alerts.setAlertInterval(alertInterval); + await alerts.setAlertThrottleInterval(alertThrottleInterval); + if (filters) { + await setKueryBarText('xpack.uptime.alerts.monitorStatus.filterBar', filters); + } + await alerts.setAlertStatusNumTimes(alertNumTimes); + await alerts.setAlertTimerangeSelection(alertTimerangeSelection); + await alerts.setMonitorStatusSelectableToHours(); + await alerts.setLocationsSelectable(); + await alerts.clickSaveAlertButtion(); + } })(); } diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 938be2c71ae74..7994a7e934033 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -12,6 +12,91 @@ export function UptimeProvider({ getService }: FtrProviderContext) { const retry = getService('retry'); return { + alerts: { + async openFlyout() { + await testSubjects.click('xpack.uptime.alertsPopover.toggleButton', 5000); + await testSubjects.click('xpack.uptime.toggleAlertFlyout', 5000); + }, + async openMonitorStatusAlertType() { + return testSubjects.click('xpack.uptime.alerts.monitorStatus-SelectOption', 5000); + }, + async setAlertTags(tags: string[]) { + for (let i = 0; i < tags.length; i += 1) { + await testSubjects.click('comboBoxSearchInput', 5000); + await testSubjects.setValue('comboBoxInput', tags[i]); + await browser.pressKeys(browser.keys.ENTER); + } + }, + async setAlertName(name: string) { + return testSubjects.setValue('alertNameInput', name); + }, + async setAlertInterval(value: string) { + return testSubjects.setValue('intervalInput', value); + }, + async setAlertThrottleInterval(value: string) { + return testSubjects.setValue('throttleInput', value); + }, + async setAlertExpressionValue( + expressionAttribute: string, + fieldAttribute: string, + value: string + ) { + await testSubjects.click(expressionAttribute); + await testSubjects.setValue(fieldAttribute, value); + return browser.pressKeys(browser.keys.ESCAPE); + }, + async setAlertStatusNumTimes(value: string) { + return this.setAlertExpressionValue( + 'xpack.uptime.alerts.monitorStatus.numTimesExpression', + 'xpack.uptime.alerts.monitorStatus.numTimesField', + value + ); + }, + async setAlertTimerangeSelection(value: string) { + return this.setAlertExpressionValue( + 'xpack.uptime.alerts.monitorStatus.timerangeValueExpression', + 'xpack.uptime.alerts.monitorStatus.timerangeValueField', + value + ); + }, + async setAlertExpressionSelectable( + expressionAttribute: string, + selectableAttribute: string, + optionAttributes: string[] + ) { + await testSubjects.click(expressionAttribute, 5000); + await testSubjects.click(selectableAttribute, 5000); + for (let i = 0; i < optionAttributes.length; i += 1) { + await testSubjects.click(optionAttributes[i], 5000); + } + return browser.pressKeys(browser.keys.ESCAPE); + }, + async setMonitorStatusSelectableToHours() { + return this.setAlertExpressionSelectable( + 'xpack.uptime.alerts.monitorStatus.timerangeUnitExpression', + 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable', + ['xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption'] + ); + }, + async setLocationsSelectable() { + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionExpression', + 5000 + ); + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionSwitch', + 5000 + ); + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionSelectable', + 5000 + ); + return browser.pressKeys(browser.keys.ESCAPE); + }, + async clickSaveAlertButtion() { + return testSubjects.click('saveAlertButton'); + }, + }, async assertExists(key: string) { if (!(await testSubjects.exists(key))) { throw new Error(`Couldn't find expected element with key "${key}".`); @@ -35,11 +120,14 @@ export function UptimeProvider({ getService }: FtrProviderContext) { async getMonitorNameDisplayedOnPageTitle() { return await testSubjects.getVisibleText('monitor-page-title'); }, - async setFilterText(filterQuery: string) { - await testSubjects.click('xpack.uptime.filterBar'); - await testSubjects.setValue('xpack.uptime.filterBar', filterQuery); + async setKueryBarText(attribute: string, value: string) { + await testSubjects.click(attribute); + await testSubjects.setValue(attribute, value); await browser.pressKeys(browser.keys.ENTER); }, + async setFilterText(filterQuery: string) { + await this.setKueryBarText('xpack.uptime.filterBar', filterQuery); + }, async goToNextPage() { await testSubjects.click('xpack.uptime.monitorList.nextButton', 5000); }, diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts new file mode 100644 index 0000000000000..2a0358160da51 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts @@ -0,0 +1,78 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + describe('overview page alert flyout controls', function() { + const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; + const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; + const pageObjects = getPageObjects(['common', 'uptime']); + const supertest = getService('supertest'); + const retry = getService('retry'); + + it('posts an alert, verfies its presence, and deletes the alert', async () => { + await pageObjects.uptime.goToUptimeOverviewAndLoadData(DEFAULT_DATE_START, DEFAULT_DATE_END); + + await pageObjects.uptime.openAlertFlyoutAndCreateMonitorStatusAlert({ + alertInterval: '11', + alertName: 'uptime-test', + alertNumTimes: '3', + alertTags: ['uptime', 'another'], + alertThrottleInterval: '30', + alertTimerangeSelection: '1', + filters: 'monitor.id: "0001-up"', + }); + + // The creation of the alert could take some time, so the first few times we query after + // the previous line resolves, the API may not be done creating the alert yet, so we + // put the fetch code in a retry block with a timeout. + let alert: any; + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get('/api/alert/_find'); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === 'uptime-test' + ); + expect(alertsFromThisTest).to.have.length(1); + alert = alertsFromThisTest[0]; + }); + + // Ensure the parameters and other stateful data + // on the alert match up with the values we provided + // for our test helper to input into the flyout. + const { + actions, + alertTypeId, + consumer, + id, + params: { numTimes, timerange, locations, filters }, + schedule: { interval }, + tags, + } = alert; + + // we're not testing the flyout's ability to associate alerts with action connectors + expect(actions).to.eql([]); + + expect(alertTypeId).to.eql('xpack.uptime.alerts.monitorStatus'); + expect(consumer).to.eql('uptime'); + expect(interval).to.eql('11m'); + expect(tags).to.eql(['uptime', 'another']); + expect(numTimes).to.be(3); + expect(timerange.from).to.be('now-1h'); + expect(timerange.to).to.be('now'); + expect(locations).to.eql(['mpls']); + expect(filters).to.eql( + '{"bool":{"should":[{"match_phrase":{"monitor.id":"0001-up"}}],"minimum_should_match":1}}' + ); + + await supertest + .delete(`/api/alert/${id}`) + .set('kbn-xsrf', 'true') + .expect(204); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts new file mode 100644 index 0000000000000..a433175acae01 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts @@ -0,0 +1,27 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +const ARCHIVE = 'uptime/full_heartbeat'; + +export default ({ getService, loadTestFile }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + describe('Uptime app', function() { + this.tags('ciGroup6'); + + describe('with real-world data', () => { + before(async () => { + await esArchiver.load(ARCHIVE); + await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'UTC' }); + }); + after(async () => await esArchiver.unload(ARCHIVE)); + + loadTestFile(require.resolve('./alert_flyout')); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index b19ec95c68916..538817bd9d14c 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -28,7 +28,10 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { services, pageObjects, // list paths to the files that contain your plugins tests - testFiles: [resolve(__dirname, './apps/triggers_actions_ui')], + testFiles: [ + resolve(__dirname, './apps/triggers_actions_ui'), + resolve(__dirname, './apps/uptime'), + ], apps: { ...xpackFunctionalConfig.get('apps'), triggersActions: { From 3bd3364a5567969558245cdfa5a7381ee4ae7c83 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Thu, 19 Mar 2020 09:58:22 -0700 Subject: [PATCH 03/22] [Canvas] Add Lens embeddables (#57499) * Added lens embeddables to embed flyout Fixed import embedded panel styles (#58654) Merging to WIP draft branch * Added i18n strings for savedLens * Added tests for lens embeddables * Updated tests * Updated tests * Added style overrides for lens table * DDisables triggers on lens emebeddable * Updated test * Sets embeddable view mode according to app state * Fix embeddable component * Removed embeddable view mode logic * Removed unused import --- .../expression_types/embeddable_types.ts | 9 +- .../functions/common/index.ts | 2 + .../functions/common/saved_lens.test.ts | 43 ++++++++++ .../functions/common/saved_lens.ts | 83 +++++++++++++++++++ .../renderers/embeddable/embeddable.scss | 33 ++++++++ .../renderers/embeddable/embeddable.tsx | 7 +- .../embeddable_input_to_expression.test.ts | 48 ++++++++++- .../embeddable_input_to_expression.ts | 19 +++++ .../plugins/canvas/common/lib/constants.ts | 1 + .../canvas/i18n/functions/dict/saved_lens.ts | 27 ++++++ .../canvas/i18n/functions/function_help.ts | 2 + .../components/embeddable_flyout/index.tsx | 3 + .../workpad_interactive_page/index.js | 10 ++- .../plugins/canvas/public/style/index.scss | 1 + x-pack/plugins/lens/common/constants.ts | 1 + 15 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss create mode 100644 x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts index d9e841092be56..538aa9f74e2a6 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts @@ -7,9 +7,16 @@ // @ts-ignore import { MAP_SAVED_OBJECT_TYPE } from '../../../maps/common/constants'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/visualizations/public'; +import { LENS_EMBEDDABLE_TYPE } from '../../../../../plugins/lens/common/constants'; import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants'; -export const EmbeddableTypes: { map: string; search: string; visualization: string } = { +export const EmbeddableTypes: { + lens: string; + map: string; + search: string; + visualization: string; +} = { + lens: LENS_EMBEDDABLE_TYPE, map: MAP_SAVED_OBJECT_TYPE, search: SEARCH_EMBEDDABLE_TYPE, visualization: VISUALIZE_EMBEDDABLE_TYPE, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 48b50930d563e..36fa6497ab6f3 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -48,6 +48,7 @@ import { rounddate } from './rounddate'; import { rowCount } from './rowCount'; import { repeatImage } from './repeatImage'; import { revealImage } from './revealImage'; +import { savedLens } from './saved_lens'; import { savedMap } from './saved_map'; import { savedSearch } from './saved_search'; import { savedVisualization } from './saved_visualization'; @@ -109,6 +110,7 @@ export const functions = [ revealImage, rounddate, rowCount, + savedLens, savedMap, savedSearch, savedVisualization, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts new file mode 100644 index 0000000000000..6b197148e6373 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts @@ -0,0 +1,43 @@ +/* + * 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. + */ +jest.mock('ui/new_platform'); +import { savedLens } from './saved_lens'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; + +const filterContext = { + and: [ + { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, + { + and: [], + column: 'time-column', + type: 'time', + from: '2019-06-04T04:00:00.000Z', + to: '2019-06-05T04:00:00.000Z', + }, + ], +}; + +describe('savedLens', () => { + const fn = savedLens().fn; + const args = { + id: 'some-id', + title: null, + timerange: null, + }; + + it('accepts null context', () => { + const expression = fn(null, args, {} as any); + + expect(expression.input.filters).toEqual([]); + }); + + it('accepts filter context', () => { + const expression = fn(filterContext, args, {} as any); + const embeddableFilters = getQueryFilters(filterContext.and); + + expect(expression.input.filters).toEqual(embeddableFilters); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts new file mode 100644 index 0000000000000..60026adc0998a --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts @@ -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 { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { TimeRange } from 'src/plugins/data/public'; +import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { Filter, TimeRange as TimeRangeArg } from '../../../types'; +import { + EmbeddableTypes, + EmbeddableExpressionType, + EmbeddableExpression, +} from '../../expression_types'; +import { getFunctionHelp } from '../../../i18n'; +import { Filter as DataFilter } from '../../../../../../../src/plugins/data/public'; + +interface Arguments { + id: string; + title: string | null; + timerange: TimeRangeArg | null; +} + +export type SavedLensInput = EmbeddableInput & { + id: string; + timeRange?: TimeRange; + filters: DataFilter[]; +}; + +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; + +type Return = EmbeddableExpression; + +export function savedLens(): ExpressionFunctionDefinition< + 'savedLens', + Filter | null, + Arguments, + Return +> { + const { help, args: argHelp } = getFunctionHelp().savedLens; + return { + name: 'savedLens', + help, + args: { + id: { + types: ['string'], + required: false, + help: argHelp.id, + }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + title: { + types: ['string'], + help: argHelp.title, + required: false, + }, + }, + type: EmbeddableExpressionType, + fn: (context, args) => { + const filters = context ? context.and : []; + + return { + type: EmbeddableExpressionType, + input: { + id: args.id, + filters: getQueryFilters(filters), + timeRange: args.timerange || defaultTimeRange, + title: args.title ? args.title : undefined, + disableTriggers: true, + }, + embeddableType: EmbeddableTypes.lens, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss new file mode 100644 index 0000000000000..04f2f393d1e80 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss @@ -0,0 +1,33 @@ +.canvasEmbeddable { + .embPanel { + border: none; + background: none; + + .embPanel__title { + margin-bottom: $euiSizeXS; + } + + .embPanel__optionsMenuButton { + border-radius: $euiBorderRadius; + } + + .canvas-isFullscreen & { + .embPanel__optionsMenuButton { + opacity: 0; + } + + &:focus .embPanel__optionsMenuButton, + &:hover .embPanel__optionsMenuButton { + opacity: 1; + } + } + } + + .euiTable { + background: none; + } + + .lnsExpressionRenderer { + @include euiScrollBar; + } +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 549e69e57e921..d91e70e43bfd5 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -18,11 +18,12 @@ import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_a import { EmbeddableExpression } from '../../expression_types/embeddable'; import { RendererStrings } from '../../../i18n'; import { getSavedObjectFinder } from '../../../../../../../src/plugins/saved_objects/public'; - -const { embeddable: strings } = RendererStrings; import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { EmbeddableInput } from '../../expression_types'; import { RendererHandlers } from '../../../types'; +import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib'; + +const { embeddable: strings } = RendererStrings; const embeddablesRegistry: { [key: string]: IEmbeddable; @@ -31,7 +32,7 @@ const embeddablesRegistry: { const renderEmbeddable = (embeddableObject: IEmbeddable, domNode: HTMLElement) => { return (
diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts index 8694c0e2c7f9f..4c622b0c247fa 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -7,12 +7,17 @@ jest.mock('ui/new_platform'); import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { SavedMapInput } from '../../functions/common/saved_map'; +import { SavedLensInput } from '../../functions/common/saved_lens'; import { EmbeddableTypes } from '../../expression_types'; import { fromExpression, Ast } from '@kbn/interpreter/common'; -const baseSavedMapInput = { +const baseEmbeddableInput = { id: 'embeddableId', filters: [], +}; + +const baseSavedMapInput = { + ...baseEmbeddableInput, isLayerTOCOpen: false, refreshConfig: { isPaused: true, @@ -73,4 +78,45 @@ describe('input to expression', () => { expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); }); }); + + describe('Lens Embeddable', () => { + it('converts to a savedLens expression', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.lens); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedLens'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); + expect(ast.chain[0].arguments).toHaveProperty('timerange'); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts index a3cb53acebed2..6428507b16a0c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -6,6 +6,7 @@ import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; import { SavedMapInput } from '../../functions/common/saved_map'; +import { SavedLensInput } from '../../functions/common/saved_lens'; /* Take the input from an embeddable and the type of embeddable and convert it into an expression @@ -46,5 +47,23 @@ export function embeddableInputToExpression( } } + if (embeddableType === EmbeddableTypes.lens) { + const lensInput = input as SavedLensInput; + + expressionParts.push('savedLens'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (lensInput.timeRange) { + expressionParts.push( + `timerange={timerange from="${lensInput.timeRange.from}" to="${lensInput.timeRange.to}"}` + ); + } + } + return expressionParts.join(' '); } diff --git a/x-pack/legacy/plugins/canvas/common/lib/constants.ts b/x-pack/legacy/plugins/canvas/common/lib/constants.ts index 40e143b9ec589..ac8e80b8d7b89 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/constants.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/constants.ts @@ -39,3 +39,4 @@ export const API_ROUTE_SHAREABLE_BASE = '/public/canvas'; export const API_ROUTE_SHAREABLE_ZIP = '/public/canvas/zip'; export const API_ROUTE_SHAREABLE_RUNTIME = '/public/canvas/runtime'; export const API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD = `/public/canvas/${SHAREABLE_RUNTIME_NAME}.js`; +export const CANVAS_EMBEDDABLE_CLASSNAME = `canvasEmbeddable`; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts new file mode 100644 index 0000000000000..1efcbc9d3a18e --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts @@ -0,0 +1,27 @@ +/* + * 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'; +import { savedLens } from '../../../canvas_plugin_src/functions/common/saved_lens'; +import { FunctionHelp } from '../function_help'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.savedLensHelpText', { + defaultMessage: `Returns an embeddable for a saved lens object`, + }), + args: { + id: i18n.translate('xpack.canvas.functions.savedLens.args.idHelpText', { + defaultMessage: `The ID of the Saved Lens Object`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedLens.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + title: i18n.translate('xpack.canvas.functions.savedLens.args.titleHelpText', { + defaultMessage: `The title for the lens emebeddable`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts index dbdadd09df67f..e7d7b4ca4321b 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts @@ -62,6 +62,7 @@ import { help as replace } from './dict/replace'; import { help as revealImage } from './dict/reveal_image'; import { help as rounddate } from './dict/rounddate'; import { help as rowCount } from './dict/row_count'; +import { help as savedLens } from './dict/saved_lens'; import { help as savedMap } from './dict/saved_map'; import { help as savedSearch } from './dict/saved_search'; import { help as savedVisualization } from './dict/saved_visualization'; @@ -216,6 +217,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ revealImage, rounddate, rowCount, + savedLens, savedMap, savedSearch, savedVisualization, diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index 565ca5fa5bbd6..353a59397d6b6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -21,6 +21,9 @@ const allowedEmbeddables = { [EmbeddableTypes.map]: (id: string) => { return `savedMap id="${id}" | render`; }, + [EmbeddableTypes.lens]: (id: string) => { + return `savedLens id="${id}" | render`; + }, // FIX: Only currently allow Map embeddables /* [EmbeddableTypes.visualization]: (id: string) => { return `filters | savedVisualization id="${id}" | render`; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js index b775524acf639..2500a412c0fac 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js @@ -19,6 +19,7 @@ import { } from '../../../state/actions/elements'; import { selectToplevelNodes } from '../../../state/actions/transient'; import { crawlTree, globalStateUpdater, shapesForNodes } from '../integration_utils'; +import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../../common/lib'; import { InteractiveWorkpadPage as InteractiveComponent } from './interactive_workpad_page'; import { eventHandlers } from './event_handlers'; @@ -79,9 +80,14 @@ const isEmbeddableBody = element => { const hasClosest = typeof element.closest === 'function'; if (hasClosest) { - return element.closest('.embeddable') && !element.closest('.embPanel__header'); + return ( + element.closest(`.${CANVAS_EMBEDDABLE_CLASSNAME}`) && !element.closest('.embPanel__header') + ); } else { - return closest.call(element, '.embeddable') && !closest.call(element, '.embPanel__header'); + return ( + closest.call(element, `.${CANVAS_EMBEDDABLE_CLASSNAME}`) && + !closest.call(element, '.embPanel__header') + ); } }; diff --git a/x-pack/legacy/plugins/canvas/public/style/index.scss b/x-pack/legacy/plugins/canvas/public/style/index.scss index 4b85620863692..39e5903ff1d96 100644 --- a/x-pack/legacy/plugins/canvas/public/style/index.scss +++ b/x-pack/legacy/plugins/canvas/public/style/index.scss @@ -61,6 +61,7 @@ @import '../../canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.scss'; @import '../../canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.scss'; +@import '../../canvas_plugin_src/renderers/embeddable/embeddable.scss'; @import '../../canvas_plugin_src/renderers/plot/plot.scss'; @import '../../canvas_plugin_src/renderers/reveal_image/reveal_image.scss'; @import '../../canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.scss'; diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 57f2a633e4524..16ae1b8da752b 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -5,6 +5,7 @@ */ export const PLUGIN_ID = 'lens'; +export const LENS_EMBEDDABLE_TYPE = 'lens'; export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; export const BASE_APP_URL = '/app/kibana'; export const BASE_API_URL = '/api/lens'; From bafd45fff2eea61f16764753884b9d9c709cf683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Thu, 19 Mar 2020 13:20:48 -0400 Subject: [PATCH 04/22] Fix race condition in flaky alerting test (#60438) * Fix race condition in flaky test * Fix flakiness in test * Fix more flakiness --- .../common/lib/task_manager_utils.ts | 40 ++++++++++++++- .../tests/alerting/alerts.ts | 51 +++++++++++-------- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts b/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts index 3a1d035a023c2..8eb0d11bbb569 100644 --- a/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts @@ -13,7 +13,7 @@ export class TaskManagerUtils { this.retry = retry; } - async waitForIdle(taskRunAtFilter: Date) { + async waitForEmpty(taskRunAtFilter: Date) { return await this.retry.try(async () => { const searchResult = await this.es.search({ index: '.kibana_task_manager', @@ -44,6 +44,44 @@ export class TaskManagerUtils { }); } + async waitForAllTasksIdle(taskRunAtFilter: Date) { + return await this.retry.try(async () => { + const searchResult = await this.es.search({ + index: '.kibana_task_manager', + body: { + query: { + bool: { + must: [ + { + terms: { + 'task.scope': ['actions', 'alerting'], + }, + }, + { + range: { + 'task.scheduledAt': { + gte: taskRunAtFilter, + }, + }, + }, + ], + must_not: [ + { + term: { + 'task.status': 'idle', + }, + }, + ], + }, + }, + }, + }); + if (searchResult.hits.total.value) { + throw new Error(`Expected 0 non-idle tasks but received ${searchResult.hits.total.value}`); + } + }); + } + async waitForActionTaskParamsToBeCleanedUp(createdAtFilter: Date): Promise { return await this.retry.try(async () => { const searchResult = await this.es.search({ diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 6766705f688a6..6eed28cc381dd 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -26,9 +26,7 @@ export default function alertTests({ getService }: FtrProviderContext) { const esTestIndexTool = new ESTestIndexTool(es, retry); const taskManagerUtils = new TaskManagerUtils(es, retry); - // FLAKY: https://github.com/elastic/kibana/issues/58643 - // FLAKY: https://github.com/elastic/kibana/issues/58991 - describe.skip('alerts', () => { + describe('alerts', () => { const authorizationIndex = '.kibana-test-authorization'; const objectRemover = new ObjectRemover(supertest); @@ -99,9 +97,11 @@ export default function alertTests({ getService }: FtrProviderContext) { // Wait for the action to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference); + await taskManagerUtils.waitForAllTasksIdle(testStart); + const alertId = response.body.id; await alertUtils.disable(alertId); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 alert executed with proper params const alertSearchResult = await esTestIndexTool.search( @@ -166,17 +166,23 @@ instanceStateValue: true }); it('should pass updated alert params to executor', async () => { + const testStart = new Date(); // create an alert const reference = alertUtils.generateReference(); - const overwrites = { - throttle: '1s', - schedule: { interval: '1s' }, - }; - const response = await alertUtils.createAlwaysFiringAction({ reference, overwrites }); + const response = await alertUtils.createAlwaysFiringAction({ + reference, + overwrites: { throttle: null }, + }); // only need to test creation success paths if (response.statusCode !== 200) return; + // Wait for the action to index a document before disabling the alert and waiting for tasks to finish + await esTestIndexTool.waitForDocs('action:test.index-record', reference); + + // Avoid invalidating an API key while the alert is executing + await taskManagerUtils.waitForAllTasksIdle(testStart); + // update the alert with super user const alertId = response.body.id; const reference2 = alertUtils.generateReference(); @@ -188,8 +194,8 @@ instanceStateValue: true overwrites: { name: 'def', tags: ['fee', 'fi', 'fo'], - throttle: '1s', - schedule: { interval: '1s' }, + // This will cause the task to re-run on update + schedule: { interval: '59s' }, }, }); @@ -197,6 +203,9 @@ instanceStateValue: true // make sure alert info passed to executor is correct await esTestIndexTool.waitForDocs('alert:test.always-firing', reference2); + + await taskManagerUtils.waitForAllTasksIdle(testStart); + await alertUtils.disable(alertId); const alertSearchResult = await esTestIndexTool.search( 'alert:test.always-firing', @@ -359,7 +368,7 @@ instanceStateValue: true // Wait for test.authorization to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document exists with proper params searchResult = await esTestIndexTool.search('alert:test.authorization', reference); @@ -387,7 +396,7 @@ instanceStateValue: true // Wait for test.authorization to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document exists with proper params searchResult = await esTestIndexTool.search('alert:test.authorization', reference); @@ -467,7 +476,7 @@ instanceStateValue: true // Ensure test.authorization indexed 1 document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document with proper params exists searchResult = await esTestIndexTool.search('action:test.authorization', reference); @@ -495,7 +504,7 @@ instanceStateValue: true // Ensure test.authorization indexed 1 document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document with proper params exists searchResult = await esTestIndexTool.search('action:test.authorization', reference); @@ -544,7 +553,7 @@ instanceStateValue: true // Wait until alerts scheduled actions 3 times before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.always-firing', reference, 3); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure actions only executed once const searchResult = await esTestIndexTool.search( @@ -610,7 +619,7 @@ instanceStateValue: true // Wait for actions to execute twice before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 2 actions with proper params exists const searchResult = await esTestIndexTool.search( @@ -660,7 +669,7 @@ instanceStateValue: true // Actions should execute twice before widning things down await esTestIndexTool.waitForDocs('action:test.index-record', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 2 actions are executed const searchResult = await esTestIndexTool.search( @@ -705,7 +714,7 @@ instanceStateValue: true // execution once before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.always-firing', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Should not have executed any action const executedActionsResult = await esTestIndexTool.search( @@ -750,7 +759,7 @@ instanceStateValue: true // once before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.always-firing', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Should not have executed any action const executedActionsResult = await esTestIndexTool.search( @@ -796,7 +805,7 @@ instanceStateValue: true // Ensure actions are executed once before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference, 1); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Should have one document indexed by the action const searchResult = await esTestIndexTool.search( From f5355a9ee86a0657c9d935ed84bf042b494ed299 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Thu, 19 Mar 2020 14:35:04 -0400 Subject: [PATCH 05/22] [ML] Data Visualizer: Replace KqlFilterBar with QueryStringInput (#60544) * data visualizer:replace kqlFilterBar * remove unused translation * show syntax error toast --- x-pack/plugins/ml/public/application/app.tsx | 5 + .../content_types/number_content.tsx | 2 +- .../components/search_panel/search_panel.tsx | 126 +++++++++++------- .../datavisualizer/index_based/page.tsx | 10 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 6 files changed, 91 insertions(+), 54 deletions(-) diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 2597715488399..6269c11fca896 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -9,6 +9,8 @@ import ReactDOM from 'react-dom'; import { AppMountParameters, CoreStart } from 'kibana/public'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; + import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { setDependencyCache, clearCache } from './util/dependency_cache'; import { setLicenseCache } from './license'; @@ -24,6 +26,8 @@ interface AppProps { appMountParams: AppMountParameters; } +const localStorage = new Storage(window.localStorage); + const App: FC = ({ coreStart, deps, appMountParams }) => { setDependencyCache({ indexPatterns: deps.data.indexPatterns, @@ -62,6 +66,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { appName: 'ML', data: deps.data, security: deps.security, + storage: localStorage, ...coreStart, }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx index 29be9d2e1e2a4..e2c156fc66ded 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx @@ -157,7 +157,7 @@ export const NumberContent: FC = ({ config }) => { buttonSize="compressed" /> - {detailsMode === DETAILS_MODE.DISTRIBUTION && ( + {distribution && detailsMode === DETAILS_MODE.DISTRIBUTION && ( diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx index 527cd31ed91d4..50c76725f5245 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx @@ -4,18 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; +import React, { FC, useState } from 'react'; -import { - EuiFieldSearch, - EuiFlexItem, - EuiFlexGroup, - EuiForm, - EuiFormRow, - EuiIconTip, - EuiSuperSelect, - EuiText, -} from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup, EuiIconTip, EuiSuperSelect, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -23,18 +14,24 @@ import { i18n } from '@kbn/i18n'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../../common/constants/search'; -import { SavedSearchQuery } from '../../../../contexts/ml'; -// @ts-ignore -import { KqlFilterBar } from '../../../../components/kql_filter_bar/index'; +import { + esKuery, + esQuery, + Query, + QueryStringInput, +} from '../../../../../../../../../src/plugins/data/public'; + +import { getToastNotifications } from '../../../../util/dependency_cache'; interface Props { indexPattern: IndexPattern; - searchString: string | SavedSearchQuery; - setSearchString(s: string): void; - searchQuery: string | SavedSearchQuery; - setSearchQuery(q: string | SavedSearchQuery): void; + searchString: Query['query']; + setSearchString(s: Query['query']): void; + searchQuery: Query['query']; + setSearchQuery(q: Query['query']): void; searchQueryLanguage: SEARCH_QUERY_LANGUAGE; + setSearchQueryLanguage(q: any): void; samplerShardSize: number; setSamplerShardSize(s: number): void; totalCount: number; @@ -59,6 +56,20 @@ const searchSizeOptions = [1000, 5000, 10000, 100000, -1].map(v => { }; }); +const kqlSyntaxErrorMessage = i18n.translate( + 'xpack.ml.datavisualizer.invalidKqlSyntaxErrorMessage', + { + defaultMessage: + 'Invalid syntax in search bar. The input must be valid Kibana Query Language (KQL)', + } +); +const luceneSyntaxErrorMessage = i18n.translate( + 'xpack.ml.datavisualizer.invalidLuceneSyntaxErrorMessage', + { + defaultMessage: 'Invalid syntax in search bar. The input must be valid Lucene', + } +); + export const SearchPanel: FC = ({ indexPattern, searchString, @@ -66,44 +77,65 @@ export const SearchPanel: FC = ({ searchQuery, setSearchQuery, searchQueryLanguage, + setSearchQueryLanguage, samplerShardSize, setSamplerShardSize, totalCount, }) => { - const searchHandler = (d: Record) => { - setSearchQuery(d.filterQuery); + // The internal state of the input query bar updated on every key stroke. + const [searchInput, setSearchInput] = useState({ + query: searchString || '', + language: searchQueryLanguage, + }); + + const searchHandler = (query: Query) => { + let filterQuery; + try { + if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) { + filterQuery = esKuery.toElasticsearchQuery( + esKuery.fromKueryExpression(query.query), + indexPattern + ); + } else if (query.language === SEARCH_QUERY_LANGUAGE.LUCENE) { + filterQuery = esQuery.luceneStringToDsl(query.query); + } else { + filterQuery = {}; + } + + setSearchQuery(filterQuery); + setSearchString(query.query); + setSearchQueryLanguage(query.language); + } catch (e) { + console.log('Invalid syntax', e); // eslint-disable-line no-console + const toastNotifications = getToastNotifications(); + const notification = + query.language === SEARCH_QUERY_LANGUAGE.KUERY + ? kqlSyntaxErrorMessage + : luceneSyntaxErrorMessage; + toastNotifications.addDanger(notification); + } }; + const searchChangeHandler = (query: Query) => setSearchInput(query); return ( - {searchQueryLanguage === SEARCH_QUERY_LANGUAGE.KUERY ? ( - - ) : ( - - - - - - )} + diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index b66d12b6c9ebe..3a37274edbc16 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -24,6 +24,7 @@ import { import { IFieldType, KBN_FIELD_TYPES, + Query, esQuery, esKuery, } from '../../../../../../../src/plugins/data/public'; @@ -36,7 +37,7 @@ import { checkPermission } from '../../privilege/check_privilege'; import { mlNodesAvailable } from '../../ml_nodes_check/check_ml_nodes'; import { FullTimeRangeSelector } from '../../components/full_time_range_selector'; import { mlTimefilterRefresh$ } from '../../services/timefilter_refresh_service'; -import { useMlContext, SavedSearchQuery } from '../../contexts/ml'; +import { useMlContext } from '../../contexts/ml'; import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; @@ -49,8 +50,8 @@ import { SearchPanel } from './components/search_panel'; import { DataLoader } from './data_loader'; interface DataVisualizerPageState { - searchQuery: string | SavedSearchQuery; - searchString: string | SavedSearchQuery; + searchQuery: Query['query']; + searchString: Query['query']; searchQueryLanguage: SEARCH_QUERY_LANGUAGE; samplerShardSize: number; overallStats: any; @@ -160,7 +161,7 @@ export const Page: FC = () => { const [searchString, setSearchString] = useState(initSearchString); const [searchQuery, setSearchQuery] = useState(initSearchQuery); - const [searchQueryLanguage] = useState(initQueryLanguage); + const [searchQueryLanguage, setSearchQueryLanguage] = useState(initQueryLanguage); const [samplerShardSize, setSamplerShardSize] = useState(defaults.samplerShardSize); // TODO - type overallStats and stats @@ -676,6 +677,7 @@ export const Page: FC = () => { searchQuery={searchQuery} setSearchQuery={setSearchQuery} searchQueryLanguage={searchQueryLanguage} + setSearchQueryLanguage={setSearchQueryLanguage} samplerShardSize={samplerShardSize} setSamplerShardSize={setSamplerShardSize} totalCount={overallStats.totalCount} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 03bfb089d8bd0..95fa13c028c30 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7677,7 +7677,6 @@ "xpack.ml.datavisualizer.page.fieldsPanelTitle": "フィールド", "xpack.ml.datavisualizer.page.metricsPanelTitle": "メトリック", "xpack.ml.datavisualizer.searchPanel.allOptionLabel": "すべて", - "xpack.ml.datavisualizer.searchPanel.kqlEditOnlyLabel": "現在 KQAL で保存された検索のみ編集できます。", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholder": "小さいサンプルサイズを選択することで、クエリの実行時間を短縮しクラスターへの負荷を軽減できます。", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholderText": "検索… (例: status:200 AND extension:\"PHP\")", "xpack.ml.datavisualizer.searchPanel.sampleSizeAriaLabel": "サンプリングするドキュメント数を選択してください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 682ac4c0bba10..9f9b5cc442b9a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7677,7 +7677,6 @@ "xpack.ml.datavisualizer.page.fieldsPanelTitle": "字段", "xpack.ml.datavisualizer.page.metricsPanelTitle": "指标", "xpack.ml.datavisualizer.searchPanel.allOptionLabel": "全部", - "xpack.ml.datavisualizer.searchPanel.kqlEditOnlyLabel": "当前仅可以编辑 KQL 已保存搜索", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholder": "选择较小的样例大小将减少查询运行时间和集群上的负载。", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholderText": "搜索……(例如,status:200 AND extension:\"PHP\")", "xpack.ml.datavisualizer.searchPanel.sampleSizeAriaLabel": "选择要采样的文档数目", From 58b7e20795d7a734874db0367120807cf32a7d16 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 19 Mar 2020 14:38:12 -0400 Subject: [PATCH 06/22] Refactor to use new top-level `PackageIcon` component (#60628) - removes PackageIcon from EPM section - refactors code to use new top-level `PackageIcon` component --- .../components/package_icon.tsx | 2 +- .../components/layout.tsx | 9 +++++-- .../step_select_package.tsx | 6 ++--- .../sections/epm/components/index.ts | 2 -- .../sections/epm/components/package_card.tsx | 4 +-- .../sections/epm/components/package_icon.tsx | 26 ------------------- 6 files changed, 13 insertions(+), 36 deletions(-) delete mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx index 1ac222802e7d4..c5a0e600b7d50 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx @@ -25,7 +25,7 @@ export const PackageIcon: React.FunctionComponent<{ const usePackageIcon = (packageName: string, version?: string, icons?: Package['icons']) => { const { toImage } = useLinks(); - const [iconType, setIconType] = useState(''); + const [iconType, setIconType] = useState(''); // FIXME: use `empty` icon during initialization - see: https://github.com/elastic/kibana/issues/60622 const pkgKey = `${packageName}-${version ?? ''}`; // Generates an icon path or Eui Icon name based on an icon list from the package diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx index c063155c571d2..8bb7b2553c1b1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import { WithHeaderLayout } from '../../../../layouts'; import { AgentConfig, PackageInfo } from '../../../../types'; -import { PackageIcon } from '../../../epm/components'; +import { PackageIcon } from '../../../../components/package_icon'; import { CreateDatasourceFrom, CreateDatasourceStep } from '../types'; import { CreateDatasourceStepsNavigation } from './navigation'; @@ -94,7 +94,12 @@ export const CreateDatasourcePageLayout: React.FunctionComponent<{ - + {packageInfo?.title || packageInfo?.name || '-'} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx index f90e7f0ab0460..0b48020c3cac1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx @@ -18,7 +18,7 @@ import { import { Error } from '../../../components'; import { AgentConfig, PackageInfo } from '../../../types'; import { useGetOneAgentConfig, useGetPackages, sendGetPackageInfoByKey } from '../../../hooks'; -import { PackageIcon } from '../../epm/components'; +import { PackageIcon } from '../../../components/package_icon'; export const StepSelectPackage: React.FunctionComponent<{ agentConfigId: string; @@ -125,12 +125,12 @@ export const StepSelectPackage: React.FunctionComponent<{ allowExclusions={false} singleSelection={true} isLoading={isPackagesLoading} - options={packages.map(({ title, name, version }) => { + options={packages.map(({ title, name, version, icons }) => { const pkgkey = `${name}-${version}`; return { label: title || name, key: pkgkey, - prepend: , + prepend: , checked: selectedPkgKey === pkgkey ? 'on' : undefined, }; })} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts index 2cb940e2ff40c..41bc2aa258807 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts @@ -3,5 +3,3 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -export { PackageIcon } from './package_icon'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx index d1d7cfc180cad..8ad081cbbabe4 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx @@ -8,7 +8,7 @@ import styled from 'styled-components'; import { EuiCard } from '@elastic/eui'; import { PackageInfo, PackageListItem } from '../../../types'; import { useLinks } from '../hooks'; -import { PackageIcon } from './package_icon'; +import { PackageIcon } from '../../../components/package_icon'; export interface BadgeProps { showInstalledBadge?: boolean; @@ -40,7 +40,7 @@ export function PackageCard({ layout="horizontal" title={title || ''} description={description} - icon={} + icon={} href={url} /> ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx deleted file mode 100644 index dd2f46adc3188..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx +++ /dev/null @@ -1,26 +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 React from 'react'; -import { ICON_TYPES, EuiIcon, EuiIconProps } from '@elastic/eui'; -import { PackageInfo, PackageListItem } from '../../../types'; -import { useLinks } from '../hooks'; - -type Package = PackageInfo | PackageListItem; - -export const PackageIcon: React.FunctionComponent<{ - packageName: string; - icons?: Package['icons']; -} & Omit> = ({ packageName, icons, ...euiIconProps }) => { - const { toImage } = useLinks(); - // try to find a logo in EUI - const euiLogoIcon = ICON_TYPES.find(key => key.toLowerCase() === `logo${packageName}`); - const svgIcons = icons?.filter(icon => icon.type === 'image/svg+xml'); - const localIcon = svgIcons && Array.isArray(svgIcons) && svgIcons[0]; - const pathToLocal = localIcon && toImage(localIcon.src); - const euiIconType = pathToLocal || euiLogoIcon || 'package'; - - return ; -}; From 020e4d0f037be42dbd522a23e9f4609bdf7657ed Mon Sep 17 00:00:00 2001 From: Alex Holmansky Date: Thu, 19 Mar 2020 15:07:29 -0400 Subject: [PATCH 07/22] Switch back to a dedicated workflow token (#60673) --- .github/workflows/pr-project-assigner.yml | 2 +- .github/workflows/project-assigner.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-project-assigner.yml b/.github/workflows/pr-project-assigner.yml index d8b25b980a478..0516f2cf95640 100644 --- a/.github/workflows/pr-project-assigner.yml +++ b/.github/workflows/pr-project-assigner.yml @@ -17,5 +17,5 @@ jobs: { "label": "Feature:Lens", "projectNumber": 32, "columnName": "In progress" }, { "label": "Team:Canvas", "projectNumber": 38, "columnName": "Review in progress" } ] - ghToken: ${{ secrets.GITHUB_TOKEN }} + ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 30032c9a7f998..eb5827e121c74 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -12,6 +12,6 @@ jobs: id: project_assigner with: issue-mappings: '[{"label": "Team:AppArch", "projectNumber": 37, "columnName": "To triage"}, {"label": "Feature:Lens", "projectNumber": 32, "columnName": "Long-term goals"}, {"label": "Team:Canvas", "projectNumber": 38, "columnName": "Inbox"}]' - ghToken: ${{ secrets.GITHUB_TOKEN }} + ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} From 431b06fee03e5b9376644f64c08bad96edf0b6c7 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Thu, 19 Mar 2020 14:12:01 -0500 Subject: [PATCH 08/22] [Metrics Alerts] Add functional and unit tests (#60442) * Add tests for metric threshold alerts * Fix count aggregator * Remove redundant typedefs Co-authored-by: Elastic Machine --- .../metric_threshold_executor.test.ts | 244 +++++++++++++++++ .../metric_threshold_executor.ts | 255 ++++++++++++++++++ .../register_metric_threshold_alert_type.ts | 239 +--------------- .../alerting/metric_threshold/test_mocks.ts | 110 ++++++++ .../lib/alerting/metric_threshold/types.ts | 1 - .../test/api_integration/apis/infra/index.js | 1 + .../apis/infra/metrics_alerting.ts | 98 +++++++ 7 files changed, 712 insertions(+), 236 deletions(-) create mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts create mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts create mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts create mode 100644 x-pack/test/api_integration/apis/infra/metrics_alerting.ts diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts new file mode 100644 index 0000000000000..a6b9b70feede2 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -0,0 +1,244 @@ +/* + * 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 { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; +import { Comparator, AlertStates } from './types'; +import * as mocks from './test_mocks'; +import { AlertExecutorOptions } from '../../../../../alerting/server'; + +const executor = createMetricThresholdExecutor('test') as (opts: { + params: AlertExecutorOptions['params']; + services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; +}) => Promise; +const alertInstances = new Map(); + +const services = { + callCluster(_: string, { body }: any) { + const metric = body.query.bool.filter[1].exists.field; + if (body.aggs.groupings) { + if (body.aggs.groupings.composite.after) { + return mocks.compositeEndResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateCompositeResponse; + } + return mocks.basicCompositeResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateMetricResponse; + } + return mocks.basicMetricResponse; + }, + alertInstanceFactory(instanceID: string) { + let state: any; + const actionQueue: any[] = []; + const instance = { + actionQueue: [], + get state() { + return state; + }, + get mostRecentAction() { + return actionQueue.pop(); + }, + }; + alertInstances.set(instanceID, instance); + return { + instanceID, + scheduleActions(id: string, action: any) { + actionQueue.push({ id, action }); + }, + replaceState(newState: any) { + state = newState; + }, + }; + }, +}; + +const baseCriterion = { + aggType: 'avg', + metric: 'test.metric.1', + timeSize: 1, + timeUnit: 'm', + indexPattern: 'metricbeat-*', +}; +describe('The metric threshold alert type', () => { + describe('querying the entire infrastructure', () => { + const instanceID = 'test-*'; + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + }, + ], + }, + }); + test('alerts as expected with the > comparator', async () => { + await execute(Comparator.GT, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.GT, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the < comparator', async () => { + await execute(Comparator.LT, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the >= comparator', async () => { + await execute(Comparator.GT_OR_EQ, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.GT_OR_EQ, [1.0]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.GT_OR_EQ, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the <= comparator', async () => { + await execute(Comparator.LT_OR_EQ, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT_OR_EQ, [1.0]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT_OR_EQ, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the between comparator', async () => { + await execute(Comparator.BETWEEN, [0, 1.5]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.BETWEEN, [0, 0.75]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + }); + + describe('querying with a groupBy parameter', () => { + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + groupBy: 'something', + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + }, + ], + }, + }); + const instanceIdA = 'test-a'; + const instanceIdB = 'test-b'; + test('sends an alert when all groups pass the threshold', async () => { + await execute(Comparator.GT, [0.75]); + expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); + expect(alertInstances.get(instanceIdB).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.ALERT); + }); + test('sends an alert when only some groups pass the threshold', async () => { + await execute(Comparator.LT, [1.5]); + expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); + expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + }); + test('sends no alert when no groups pass the threshold', async () => { + await execute(Comparator.GT, [5]); + expect(alertInstances.get(instanceIdA).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.OK); + expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + }); + }); + + describe('querying with multiple criteria', () => { + const execute = ( + comparator: Comparator, + thresholdA: number[], + thresholdB: number[], + groupBy: string = '' + ) => + executor({ + services, + params: { + groupBy, + criteria: [ + { + ...baseCriterion, + comparator, + threshold: thresholdA, + }, + { + ...baseCriterion, + comparator, + threshold: thresholdB, + metric: 'test.metric.2', + }, + ], + }, + }); + test('sends an alert when all criteria cross the threshold', async () => { + const instanceID = 'test-*'; + await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + }); + test('sends no alert when some, but not all, criteria cross the threshold', async () => { + const instanceID = 'test-*'; + await execute(Comparator.LT_OR_EQ, [1.0], [3.0]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts only on groups that meet all criteria when querying with a groupBy parameter', async () => { + const instanceIdA = 'test-a'; + const instanceIdB = 'test-b'; + await execute(Comparator.GT_OR_EQ, [1.0], [3.0], 'something'); + expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); + expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + }); + }); + describe('querying with the count aggregator', () => { + const instanceID = 'test-*'; + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + aggType: 'count', + }, + ], + }, + }); + test('alerts based on the doc_count value instead of the aggregatedValue', async () => { + await execute(Comparator.GT, [2]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts new file mode 100644 index 0000000000000..8c509c017cf20 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -0,0 +1,255 @@ +/* + * 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 { mapValues } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types'; +import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler'; +import { getAllCompositeData } from '../../../utils/get_all_composite_data'; +import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; +import { MetricExpressionParams, Comparator, AlertStates } from './types'; +import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/server'; + +interface Aggregation { + aggregatedIntervals: { + buckets: Array<{ aggregatedValue: { value: number }; doc_count: number }>; + }; +} + +interface CompositeAggregationsResponse { + groupings: { + buckets: Aggregation[]; + }; +} + +const getCurrentValueFromAggregations = ( + aggregations: Aggregation, + aggType: MetricExpressionParams['aggType'] +) => { + try { + const { buckets } = aggregations.aggregatedIntervals; + if (!buckets.length) return null; // No Data state + const mostRecentBucket = buckets[buckets.length - 1]; + if (aggType === 'count') { + return mostRecentBucket.doc_count; + } + const { value } = mostRecentBucket.aggregatedValue; + return value; + } catch (e) { + return undefined; // Error state + } +}; + +const getParsedFilterQuery: ( + filterQuery: string | undefined +) => Record = filterQuery => { + if (!filterQuery) return {}; + try { + return JSON.parse(filterQuery).bool; + } catch (e) { + return { + query_string: { + query: filterQuery, + analyze_wildcard: true, + }, + }; + } +}; + +export const getElasticsearchMetricQuery = ( + { metric, aggType, timeUnit, timeSize }: MetricExpressionParams, + groupBy?: string, + filterQuery?: string +) => { + const interval = `${timeSize}${timeUnit}`; + + const aggregations = + aggType === 'count' + ? {} + : aggType === 'rate' + ? networkTraffic('aggregatedValue', metric) + : { + aggregatedValue: { + [aggType]: { + field: metric, + }, + }, + }; + + const baseAggs = { + aggregatedIntervals: { + date_histogram: { + field: '@timestamp', + fixed_interval: interval, + }, + aggregations, + }, + }; + + const aggs = groupBy + ? { + groupings: { + composite: { + size: 10, + sources: [ + { + groupBy: { + terms: { + field: groupBy, + }, + }, + }, + ], + }, + aggs: baseAggs, + }, + } + : baseAggs; + + const parsedFilterQuery = getParsedFilterQuery(filterQuery); + + return { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${interval}`, + }, + }, + }, + { + exists: { + field: metric, + }, + }, + ], + ...parsedFilterQuery, + }, + }, + size: 0, + aggs, + }; +}; + +const getMetric: ( + services: AlertServices, + params: MetricExpressionParams, + groupBy: string | undefined, + filterQuery: string | undefined +) => Promise> = async function( + { callCluster }, + params, + groupBy, + filterQuery +) { + const { indexPattern, aggType } = params; + const searchBody = getElasticsearchMetricQuery(params, groupBy, filterQuery); + + try { + if (groupBy) { + const bucketSelector = ( + response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse> + ) => response.aggregations?.groupings?.buckets || []; + const afterKeyHandler = createAfterKeyHandler( + 'aggs.groupings.composite.after', + response => response.aggregations?.groupings?.after_key + ); + const compositeBuckets = (await getAllCompositeData( + body => callCluster('search', { body, index: indexPattern }), + searchBody, + bucketSelector, + afterKeyHandler + )) as Array; + return compositeBuckets.reduce( + (result, bucket) => ({ + ...result, + [bucket.key.groupBy]: getCurrentValueFromAggregations(bucket, aggType), + }), + {} + ); + } + const result = await callCluster('search', { + body: searchBody, + index: indexPattern, + }); + return { '*': getCurrentValueFromAggregations(result.aggregations, aggType) }; + } catch (e) { + return { '*': undefined }; // Trigger an Error state + } +}; + +const comparatorMap = { + [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => + value >= Math.min(a, b) && value <= Math.max(a, b), + // `threshold` is always an array of numbers in case the BETWEEN comparator is + // used; all other compartors will just destructure the first value in the array + [Comparator.GT]: (a: number, [b]: number[]) => a > b, + [Comparator.LT]: (a: number, [b]: number[]) => a < b, + [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, + [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, +}; + +export const createMetricThresholdExecutor = (alertUUID: string) => + async function({ services, params }: AlertExecutorOptions) { + const { criteria, groupBy, filterQuery } = params as { + criteria: MetricExpressionParams[]; + groupBy: string | undefined; + filterQuery: string | undefined; + }; + + const alertResults = await Promise.all( + criteria.map(criterion => + (async () => { + const currentValues = await getMetric(services, criterion, groupBy, filterQuery); + const { threshold, comparator } = criterion; + const comparisonFunction = comparatorMap[comparator]; + return mapValues(currentValues, value => ({ + shouldFire: + value !== undefined && value !== null && comparisonFunction(value, threshold), + currentValue: value, + isNoData: value === null, + isError: value === undefined, + })); + })() + ) + ); + + const groups = Object.keys(alertResults[0]); + for (const group of groups) { + const alertInstance = services.alertInstanceFactory(`${alertUUID}-${group}`); + + // AND logic; all criteria must be across the threshold + const shouldAlertFire = alertResults.every(result => result[group].shouldFire); + // AND logic; because we need to evaluate all criteria, if one of them reports no data then the + // whole alert is in a No Data/Error state + const isNoData = alertResults.some(result => result[group].isNoData); + const isError = alertResults.some(result => result[group].isError); + if (shouldAlertFire) { + alertInstance.scheduleActions(FIRED_ACTIONS.id, { + group, + value: alertResults.map(result => result[group].currentValue), + }); + } + // Future use: ability to fetch display current alert state + alertInstance.replaceState({ + alertState: isError + ? AlertStates.ERROR + : isNoData + ? AlertStates.NO_DATA + : shouldAlertFire + ? AlertStates.ALERT + : AlertStates.OK, + }); + } + }; + +export const FIRED_ACTIONS = { + id: 'metrics.threshold.fired', + name: i18n.translate('xpack.infra.metrics.alerting.threshold.fired', { + defaultMessage: 'Fired', + }), +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index d318171f3bb48..501d7549e1712 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -4,188 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ import uuid from 'uuid'; -import { mapValues } from 'lodash'; -import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types'; -import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler'; -import { getAllCompositeData } from '../../../utils/get_all_composite_data'; -import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; -import { - MetricExpressionParams, - Comparator, - AlertStates, - METRIC_THRESHOLD_ALERT_TYPE_ID, -} from './types'; -import { AlertServices, PluginSetupContract } from '../../../../../alerting/server'; - -interface Aggregation { - aggregatedIntervals: { buckets: Array<{ aggregatedValue: { value: number } }> }; -} - -interface CompositeAggregationsResponse { - groupings: { - buckets: Aggregation[]; - }; -} - -const FIRED_ACTIONS = { - id: 'metrics.threshold.fired', - name: i18n.translate('xpack.infra.metrics.alerting.threshold.fired', { - defaultMessage: 'Fired', - }), -}; - -const getCurrentValueFromAggregations = (aggregations: Aggregation) => { - try { - const { buckets } = aggregations.aggregatedIntervals; - if (!buckets.length) return null; // No Data state - const { value } = buckets[buckets.length - 1].aggregatedValue; - return value; - } catch (e) { - return undefined; // Error state - } -}; - -const getParsedFilterQuery: ( - filterQuery: string | undefined -) => Record = filterQuery => { - if (!filterQuery) return {}; - try { - return JSON.parse(filterQuery).bool; - } catch (e) { - return { - query_string: { - query: filterQuery, - analyze_wildcard: true, - }, - }; - } -}; - -const getMetric: ( - services: AlertServices, - params: MetricExpressionParams, - groupBy: string | undefined, - filterQuery: string | undefined -) => Promise> = async function( - { callCluster }, - { metric, aggType, timeUnit, timeSize, indexPattern }, - groupBy, - filterQuery -) { - const interval = `${timeSize}${timeUnit}`; - - const aggregations = - aggType === 'rate' - ? networkTraffic('aggregatedValue', metric) - : { - aggregatedValue: { - [aggType]: { - field: metric, - }, - }, - }; - - const baseAggs = { - aggregatedIntervals: { - date_histogram: { - field: '@timestamp', - fixed_interval: interval, - }, - aggregations, - }, - }; - - const aggs = groupBy - ? { - groupings: { - composite: { - size: 10, - sources: [ - { - groupBy: { - terms: { - field: groupBy, - }, - }, - }, - ], - }, - aggs: baseAggs, - }, - } - : baseAggs; - - const parsedFilterQuery = getParsedFilterQuery(filterQuery); - - const searchBody = { - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: `now-${interval}`, - }, - }, - }, - { - exists: { - field: metric, - }, - }, - ], - ...parsedFilterQuery, - }, - }, - size: 0, - aggs, - }; - - try { - if (groupBy) { - const bucketSelector = ( - response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse> - ) => response.aggregations?.groupings?.buckets || []; - const afterKeyHandler = createAfterKeyHandler( - 'aggs.groupings.composite.after', - response => response.aggregations?.groupings?.after_key - ); - const compositeBuckets = (await getAllCompositeData( - body => callCluster('search', { body, index: indexPattern }), - searchBody, - bucketSelector, - afterKeyHandler - )) as Array; - return compositeBuckets.reduce( - (result, bucket) => ({ - ...result, - [bucket.key.groupBy]: getCurrentValueFromAggregations(bucket), - }), - {} - ); - } - const result = await callCluster('search', { - body: searchBody, - index: indexPattern, - }); - return { '*': getCurrentValueFromAggregations(result.aggregations) }; - } catch (e) { - return { '*': undefined }; // Trigger an Error state - } -}; - -const comparatorMap = { - [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => - value >= Math.min(a, b) && value <= Math.max(a, b), - // `threshold` is always an array of numbers in case the BETWEEN comparator is - // used; all other compartors will just destructure the first value in the array - [Comparator.GT]: (a: number, [b]: number[]) => a > b, - [Comparator.LT]: (a: number, [b]: number[]) => a < b, - [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, - [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, -}; +import { PluginSetupContract } from '../../../../../alerting/server'; +import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from './types'; export async function registerMetricThresholdAlertType(alertingPlugin: PluginSetupContract) { if (!alertingPlugin) { @@ -217,59 +39,6 @@ export async function registerMetricThresholdAlertType(alertingPlugin: PluginSet }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - async executor({ services, params }) { - const { criteria, groupBy, filterQuery } = params as { - criteria: MetricExpressionParams[]; - groupBy: string | undefined; - filterQuery: string | undefined; - }; - - const alertResults = await Promise.all( - criteria.map(criterion => - (async () => { - const currentValues = await getMetric(services, criterion, groupBy, filterQuery); - const { threshold, comparator } = criterion; - const comparisonFunction = comparatorMap[comparator]; - - return mapValues(currentValues, value => ({ - shouldFire: - value !== undefined && value !== null && comparisonFunction(value, threshold), - currentValue: value, - isNoData: value === null, - isError: value === undefined, - })); - })() - ) - ); - - const groups = Object.keys(alertResults[0]); - for (const group of groups) { - const alertInstance = services.alertInstanceFactory(`${alertUUID}-${group}`); - - // AND logic; all criteria must be across the threshold - const shouldAlertFire = alertResults.every(result => result[group].shouldFire); - // AND logic; because we need to evaluate all criteria, if one of them reports no data then the - // whole alert is in a No Data/Error state - const isNoData = alertResults.some(result => result[group].isNoData); - const isError = alertResults.some(result => result[group].isError); - if (shouldAlertFire) { - alertInstance.scheduleActions(FIRED_ACTIONS.id, { - group, - value: alertResults.map(result => result[group].currentValue), - }); - } - - // Future use: ability to fetch display current alert state - alertInstance.replaceState({ - alertState: isError - ? AlertStates.ERROR - : isNoData - ? AlertStates.NO_DATA - : shouldAlertFire - ? AlertStates.ALERT - : AlertStates.OK, - }); - } - }, + executor: createMetricThresholdExecutor(alertUUID), }); } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts new file mode 100644 index 0000000000000..e87ffcfb2b912 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts @@ -0,0 +1,110 @@ +/* + * 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. + */ + +const bucketsA = [ + { + doc_count: 2, + aggregatedValue: { value: 0.5 }, + }, + { + doc_count: 3, + aggregatedValue: { value: 1.0 }, + }, +]; + +const bucketsB = [ + { + doc_count: 4, + aggregatedValue: { value: 2.5 }, + }, + { + doc_count: 5, + aggregatedValue: { value: 3.5 }, + }, +]; + +export const basicMetricResponse = { + aggregations: { + aggregatedIntervals: { + buckets: bucketsA, + }, + }, +}; + +export const alternateMetricResponse = { + aggregations: { + aggregatedIntervals: { + buckets: bucketsB, + }, + }, +}; + +export const basicCompositeResponse = { + aggregations: { + groupings: { + after_key: 'foo', + buckets: [ + { + key: { + groupBy: 'a', + }, + aggregatedIntervals: { + buckets: bucketsA, + }, + }, + { + key: { + groupBy: 'b', + }, + aggregatedIntervals: { + buckets: bucketsB, + }, + }, + ], + }, + }, + hits: { + total: { + value: 2, + }, + }, +}; + +export const alternateCompositeResponse = { + aggregations: { + groupings: { + after_key: 'foo', + buckets: [ + { + key: { + groupBy: 'a', + }, + aggregatedIntervals: { + buckets: bucketsB, + }, + }, + { + key: { + groupBy: 'b', + }, + aggregatedIntervals: { + buckets: bucketsA, + }, + }, + ], + }, + }, + hits: { + total: { + value: 2, + }, + }, +}; + +export const compositeEndResponse = { + aggregations: {}, + hits: { total: { value: 0 } }, +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts index e247eb8a3f889..07739c9d81bc4 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts @@ -33,5 +33,4 @@ export interface MetricExpressionParams { indexPattern: string; threshold: number[]; comparator: Comparator; - filterQuery: string; } diff --git a/x-pack/test/api_integration/apis/infra/index.js b/x-pack/test/api_integration/apis/infra/index.js index f5bdf280c46d2..8bb3475da6cc9 100644 --- a/x-pack/test/api_integration/apis/infra/index.js +++ b/x-pack/test/api_integration/apis/infra/index.js @@ -16,6 +16,7 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./sources')); loadTestFile(require.resolve('./waffle')); loadTestFile(require.resolve('./log_item')); + loadTestFile(require.resolve('./metrics_alerting')); loadTestFile(require.resolve('./metrics_explorer')); loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./ip_to_hostname')); diff --git a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts new file mode 100644 index 0000000000000..09f5a498ddc00 --- /dev/null +++ b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts @@ -0,0 +1,98 @@ +/* + * 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 { getElasticsearchMetricQuery } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor'; +import { MetricExpressionParams } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/types'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const client = getService('legacyEs'); + const index = 'test-index'; + const baseParams = { + metric: 'test.metric', + timeUnit: 'm', + timeSize: 5, + }; + describe('Metrics Threshold Alerts', () => { + before(async () => { + await client.index({ + index, + body: {}, + }); + }); + const aggs = ['avg', 'min', 'max', 'rate', 'cardinality', 'count']; + + describe('querying the entire infrastructure', () => { + for (const aggType of aggs) { + it(`should work with the ${aggType} aggregator`, async () => { + const searchBody = getElasticsearchMetricQuery({ + ...baseParams, + aggType, + } as MetricExpressionParams); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + } + it('should work with a filterQuery', async () => { + const searchBody = getElasticsearchMetricQuery( + { + ...baseParams, + aggType: 'avg', + } as MetricExpressionParams, + undefined, + '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' + ); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + }); + describe('querying with a groupBy parameter', () => { + for (const aggType of aggs) { + it(`should work with the ${aggType} aggregator`, async () => { + const searchBody = getElasticsearchMetricQuery( + { + ...baseParams, + aggType, + } as MetricExpressionParams, + 'agent.id' + ); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + } + it('should work with a filterQuery', async () => { + const searchBody = getElasticsearchMetricQuery( + { + ...baseParams, + aggType: 'avg', + } as MetricExpressionParams, + 'agent.id', + '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' + ); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + }); + }); +} From 915b784cd6cd23b86b5384874496cff1bd3dd203 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Thu, 19 Mar 2020 20:29:13 +0100 Subject: [PATCH 09/22] =?UTF-8?q?Use=20static=20initializer=20in=20Validat?= =?UTF-8?q?edDualRange=20for=20storybook=20com=E2=80=A6=20(#60601)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #60356. --- .../public/validated_range/validated_dual_range.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx b/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx index e7392eeba3830..ce583236e7c81 100644 --- a/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx +++ b/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx @@ -47,7 +47,11 @@ interface State { } export class ValidatedDualRange extends Component { - static defaultProps: { fullWidth: boolean; allowEmptyRange: boolean; compressed: boolean }; + static defaultProps: { fullWidth: boolean; allowEmptyRange: boolean; compressed: boolean } = { + allowEmptyRange: true, + fullWidth: false, + compressed: false, + }; static getDerivedStateFromProps(nextProps: Props, prevState: State) { if (nextProps.value !== prevState.prevValue) { @@ -125,9 +129,3 @@ export class ValidatedDualRange extends Component { ); } } - -ValidatedDualRange.defaultProps = { - allowEmptyRange: true, - fullWidth: false, - compressed: false, -}; From ce2e3fd621028a05cf312bc19706b92c53f87878 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Thu, 19 Mar 2020 12:36:19 -0700 Subject: [PATCH 10/22] [Reporting] Allow reports to be deleted in Management > Kibana > Reporting (#60077) * [Reporting] Feature Delete Button in Job Listing * refactor listing buttons * multi-delete * confirm modal * remove unused * fix test * mock the id generator for snapshotting * simplify * add search bar above table * fix types errors --- .../reporting/server/lib/jobs_query.ts | 18 + .../plugins/reporting/server/routes/jobs.ts | 52 +- .../server/routes/lib/job_response_handler.ts | 34 +- .../routes/lib/route_config_factories.ts | 17 +- x-pack/legacy/plugins/reporting/types.d.ts | 1 + .../report_listing.test.tsx.snap | 728 +++++++++++++++++- .../report_info_button.test.tsx.snap | 0 .../public/components/buttons/index.tsx | 10 + .../buttons/report_delete_button.tsx | 99 +++ .../buttons/report_download_button.tsx | 72 ++ .../{ => buttons}/report_error_button.tsx | 20 +- .../{ => buttons}/report_info_button.test.tsx | 5 +- .../{ => buttons}/report_info_button.tsx | 4 +- ...oad_button.tsx => job_download_button.tsx} | 0 .../public/components/job_success.tsx | 2 +- .../components/job_warning_formulas.tsx | 2 +- .../components/job_warning_max_size.tsx | 2 +- .../public/components/report_listing.test.tsx | 7 +- .../public/components/report_listing.tsx | 339 ++++---- .../public/lib/reporting_api_client.ts | 6 + 20 files changed, 1225 insertions(+), 193 deletions(-) rename x-pack/plugins/reporting/public/components/{ => buttons}/__snapshots__/report_info_button.test.tsx.snap (100%) create mode 100644 x-pack/plugins/reporting/public/components/buttons/index.tsx create mode 100644 x-pack/plugins/reporting/public/components/buttons/report_delete_button.tsx create mode 100644 x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx rename x-pack/plugins/reporting/public/components/{ => buttons}/report_error_button.tsx (83%) rename x-pack/plugins/reporting/public/components/{ => buttons}/report_info_button.test.tsx (94%) rename x-pack/plugins/reporting/public/components/{ => buttons}/report_info_button.tsx (98%) rename x-pack/plugins/reporting/public/components/{download_button.tsx => job_download_button.tsx} (100%) diff --git a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts index 3562834230ea1..c01e6377b039e 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; +import Boom from 'boom'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; @@ -152,5 +154,21 @@ export function jobsQueryFactory(server: ServerFacade, elasticsearch: Elasticsea return hits[0]; }); }, + + async delete(deleteIndex: string, id: string) { + try { + const query = { id, index: deleteIndex }; + return callAsInternalUser('delete', query); + } catch (error) { + const wrappedError = new Error( + i18n.translate('xpack.reporting.jobsQuery.deleteError', { + defaultMessage: 'Could not delete the report: {error}', + values: { error: error.message }, + }) + ); + + throw Boom.boomify(wrappedError, { statusCode: error.status }); + } + }, }; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts index 2de420e6577c3..b9aa75e0ddd00 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts @@ -18,9 +18,13 @@ import { } from '../../types'; import { jobsQueryFactory } from '../lib/jobs_query'; import { ReportingSetupDeps, ReportingCore } from '../types'; -import { jobResponseHandlerFactory } from './lib/job_response_handler'; +import { + deleteJobResponseHandlerFactory, + downloadJobResponseHandlerFactory, +} from './lib/job_response_handler'; import { makeRequestFacade } from './lib/make_request_facade'; import { + getRouteConfigFactoryDeletePre, getRouteConfigFactoryDownloadPre, getRouteConfigFactoryManagementPre, } from './lib/route_config_factories'; @@ -40,7 +44,6 @@ export function registerJobInfoRoutes( const { elasticsearch } = plugins; const jobsQuery = jobsQueryFactory(server, elasticsearch); const getRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); - const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); // list jobs in the queue, paginated server.route({ @@ -138,7 +141,8 @@ export function registerJobInfoRoutes( // trigger a download of the output from a job const exportTypesRegistry = reporting.getExportTypesRegistry(); - const jobResponseHandler = jobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); + const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); + const downloadResponseHandler = downloadJobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); // prettier-ignore server.route({ path: `${MAIN_ENTRY}/download/{docId}`, method: 'GET', @@ -147,7 +151,47 @@ export function registerJobInfoRoutes( const request = makeRequestFacade(legacyRequest); const { docId } = request.params; - let response = await jobResponseHandler( + let response = await downloadResponseHandler( + request.pre.management.jobTypes, + request.pre.user, + h, + { docId } + ); + + if (isResponse(response)) { + const { statusCode } = response; + + if (statusCode !== 200) { + if (statusCode === 500) { + logger.error(`Report ${docId} has failed: ${JSON.stringify(response.source)}`); + } else { + logger.debug( + `Report ${docId} has non-OK status: [${statusCode}] Reason: [${JSON.stringify( + response.source + )}]` + ); + } + } + + response = response.header('accept-ranges', 'none'); + } + + return response; + }, + }); + + // allow a report to be deleted + const getRouteConfigDelete = getRouteConfigFactoryDeletePre(server, plugins, logger); + const deleteResponseHandler = deleteJobResponseHandlerFactory(server, elasticsearch); + server.route({ + path: `${MAIN_ENTRY}/delete/{docId}`, + method: 'DELETE', + options: getRouteConfigDelete(), + handler: async (legacyRequest: Legacy.Request, h: ReportingResponseToolkit) => { + const request = makeRequestFacade(legacyRequest); + const { docId } = request.params; + + let response = await deleteResponseHandler( request.pre.management.jobTypes, request.pre.user, h, diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts index 62f0d0a72b389..30627d5b23230 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -20,7 +20,7 @@ interface JobResponseHandlerOpts { excludeContent?: boolean; } -export function jobResponseHandlerFactory( +export function downloadJobResponseHandlerFactory( server: ServerFacade, elasticsearch: ElasticsearchServiceSetup, exportTypesRegistry: ExportTypesRegistry @@ -36,6 +36,7 @@ export function jobResponseHandlerFactory( opts: JobResponseHandlerOpts = {} ) { const { docId } = params; + // TODO: async/await return jobsQuery.get(user, docId, { includeContent: !opts.excludeContent }).then(doc => { if (!doc) return Boom.notFound(); @@ -67,3 +68,34 @@ export function jobResponseHandlerFactory( }); }; } + +export function deleteJobResponseHandlerFactory( + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup +) { + const jobsQuery = jobsQueryFactory(server, elasticsearch); + + return async function deleteJobResponseHander( + validJobTypes: string[], + user: any, + h: ResponseToolkit, + params: JobResponseHandlerParams + ) { + const { docId } = params; + const doc = await jobsQuery.get(user, docId, { includeContent: false }); + if (!doc) return Boom.notFound(); + + const { jobtype: jobType } = doc._source; + if (!validJobTypes.includes(jobType)) { + return Boom.unauthorized(`Sorry, you are not authorized to delete ${jobType} reports`); + } + + try { + const docIndex = doc._index; + await jobsQuery.delete(docIndex, docId); + return h.response({ deleted: true }); + } catch (error) { + return Boom.boomify(error, { statusCode: error.statusCode }); + } + }; +} diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts index 82ba9ba22c706..3d275d34e2f7d 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts @@ -106,7 +106,22 @@ export function getRouteConfigFactoryDownloadPre( const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), - tags: [API_TAG], + tags: [API_TAG, 'download'], + response: { + ranges: false, + }, + }); +} + +export function getRouteConfigFactoryDeletePre( + server: ServerFacade, + plugins: ReportingSetupDeps, + logger: Logger +): GetRouteConfigFactoryFn { + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + return (): RouteConfigFactory => ({ + ...getManagementRouteConfig(), + tags: [API_TAG, 'delete'], response: { ranges: false, }, diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 917e9d7daae40..238079ba92a29 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -197,6 +197,7 @@ export interface JobDocPayload { export interface JobSource { _id: string; + _index: string; _source: { jobtype: string; output: JobDocOutput; diff --git a/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap index b5304c6020c43..1da95dd0ba197 100644 --- a/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap @@ -2,6 +2,526 @@ exports[`ReportListing Report job listing with some items 1`] = ` Array [ + +
+ + +
+ +
+ + + +
+
+ + + + +
+ + +
+
+ + + +
+ +
+ + + +
+ + +
+
+ +
+ +
+ +
+ + +
+ +
+ +
+ + +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + +
+ +
+
+ + +
+
+
+ + Report + +
+
+
+ + Created at + +
+
+
+ + Status + +
+
+
+ + Actions + +
+
+
+ + Loading reports + +
+
+
+
+
+ +
+ ,
+ > + + +
+ +
+ +
+ + +
+ + +
+ + +
+ +
+
+ + +
+ +
+ > + + +
+ +
+ +
+ + +
+ + +
+ + +
+ +
+
+ + +
+ + Promise; +type Props = { jobsToDelete: Job[]; performDelete: DeleteFn } & ListingProps; +interface State { + showConfirm: boolean; +} + +export class ReportDeleteButton extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { showConfirm: false }; + } + + private hideConfirm() { + this.setState({ showConfirm: false }); + } + + private showConfirm() { + this.setState({ showConfirm: true }); + } + + private renderConfirm() { + const { intl, jobsToDelete } = this.props; + + const title = + jobsToDelete.length > 1 + ? intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteNumConfirmTitle', + defaultMessage: `Delete {num} reports?`, + }, + { num: jobsToDelete.length } + ) + : intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteConfirmTitle', + defaultMessage: `Delete the "{name}" report?`, + }, + { name: jobsToDelete[0].object_title } + ); + const message = intl.formatMessage({ + id: 'xpack.reporting.listing.table.deleteConfirmMessage', + defaultMessage: `You can't recover deleted reports.`, + }); + const confirmButtonText = intl.formatMessage({ + id: 'xpack.reporting.listing.table.deleteConfirmButton', + defaultMessage: `Delete`, + }); + const cancelButtonText = intl.formatMessage({ + id: 'xpack.reporting.listing.table.deleteCancelButton', + defaultMessage: `Cancel`, + }); + + return ( + + this.hideConfirm()} + onConfirm={() => this.props.performDelete()} + confirmButtonText={confirmButtonText} + cancelButtonText={cancelButtonText} + defaultFocusedButton="confirm" + buttonColor="danger" + > + {message} + + + ); + } + + public render() { + const { jobsToDelete, intl } = this.props; + if (jobsToDelete.length === 0) return null; + + return ( + + this.showConfirm()} iconType="trash" color={'danger'}> + {intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteReportButton', + defaultMessage: `Delete ({num})`, + }, + { num: jobsToDelete.length } + )} + + {this.state.showConfirm ? this.renderConfirm() : null} + + ); + } +} diff --git a/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx new file mode 100644 index 0000000000000..b0674c149609d --- /dev/null +++ b/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx @@ -0,0 +1,72 @@ +/* + * 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; +import { JobStatuses } from '../../../constants'; +import { Job as ListingJob, Props as ListingProps } from '../report_listing'; + +type Props = { record: ListingJob } & ListingProps; + +export const ReportDownloadButton: FunctionComponent = (props: Props) => { + const { record, apiClient, intl } = props; + + if (record.status !== JobStatuses.COMPLETED) { + return null; + } + + const button = ( + apiClient.downloadReport(record.id)} + iconType="importAction" + aria-label={intl.formatMessage({ + id: 'xpack.reporting.listing.table.downloadReportAriaLabel', + defaultMessage: 'Download report', + })} + /> + ); + + if (record.csv_contains_formulas) { + return ( + + {button} + + ); + } + + if (record.max_size_reached) { + return ( + + {button} + + ); + } + + return ( + + {button} + + ); +}; diff --git a/x-pack/plugins/reporting/public/components/report_error_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx similarity index 83% rename from x-pack/plugins/reporting/public/components/report_error_button.tsx rename to x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx index 252dee9c619a9..1e33cc0188b8c 100644 --- a/x-pack/plugins/reporting/public/components/report_error_button.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx @@ -7,12 +7,14 @@ import { EuiButtonIcon, EuiCallOut, EuiPopover } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React, { Component } from 'react'; -import { JobContent, ReportingAPIClient } from '../lib/reporting_api_client'; +import { JobStatuses } from '../../../constants'; +import { JobContent, ReportingAPIClient } from '../../lib/reporting_api_client'; +import { Job as ListingJob } from '../report_listing'; interface Props { - jobId: string; intl: InjectedIntl; apiClient: ReportingAPIClient; + record: ListingJob; } interface State { @@ -39,12 +41,18 @@ class ReportErrorButtonUi extends Component { } public render() { + const { record, intl } = this.props; + + if (record.status !== JobStatuses.FAILED) { + return null; + } + const button = ( { }; private loadError = async () => { + const { record, apiClient, intl } = this.props; + this.setState({ isLoading: true }); try { - const reportContent: JobContent = await this.props.apiClient.getContent(this.props.jobId); + const reportContent: JobContent = await apiClient.getContent(record.id); if (this.mounted) { this.setState({ isLoading: false, error: reportContent.content }); } @@ -99,7 +109,7 @@ class ReportErrorButtonUi extends Component { if (this.mounted) { this.setState({ isLoading: false, - calloutTitle: this.props.intl.formatMessage({ + calloutTitle: intl.formatMessage({ id: 'xpack.reporting.errorButton.unableToFetchReportContentTitle', defaultMessage: 'Unable to fetch report content', }), diff --git a/x-pack/plugins/reporting/public/components/report_info_button.test.tsx b/x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx similarity index 94% rename from x-pack/plugins/reporting/public/components/report_info_button.test.tsx rename to x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx index 2edd59e6de7a3..028a8e960040a 100644 --- a/x-pack/plugins/reporting/public/components/report_info_button.test.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx @@ -7,9 +7,10 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReportInfoButton } from './report_info_button'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; -jest.mock('../lib/reporting_api_client'); +jest.mock('../../lib/reporting_api_client'); + +import { ReportingAPIClient } from '../../lib/reporting_api_client'; const httpSetup = {} as any; const apiClient = new ReportingAPIClient(httpSetup); diff --git a/x-pack/plugins/reporting/public/components/report_info_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx similarity index 98% rename from x-pack/plugins/reporting/public/components/report_info_button.tsx rename to x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx index 81a5af3b87957..941baa5af6776 100644 --- a/x-pack/plugins/reporting/public/components/report_info_button.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx @@ -17,8 +17,8 @@ import { } from '@elastic/eui'; import React, { Component, Fragment } from 'react'; import { get } from 'lodash'; -import { USES_HEADLESS_JOB_TYPES } from '../../constants'; -import { JobInfo, ReportingAPIClient } from '../lib/reporting_api_client'; +import { USES_HEADLESS_JOB_TYPES } from '../../../constants'; +import { JobInfo, ReportingAPIClient } from '../../lib/reporting_api_client'; interface Props { jobId: string; diff --git a/x-pack/plugins/reporting/public/components/download_button.tsx b/x-pack/plugins/reporting/public/components/job_download_button.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/download_button.tsx rename to x-pack/plugins/reporting/public/components/job_download_button.tsx diff --git a/x-pack/plugins/reporting/public/components/job_success.tsx b/x-pack/plugins/reporting/public/components/job_success.tsx index c2feac382ca7a..ad16a506aeb70 100644 --- a/x-pack/plugins/reporting/public/components/job_success.tsx +++ b/x-pack/plugins/reporting/public/components/job_success.tsx @@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../index.d'; +import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; -import { DownloadButton } from './download_button'; export const getSuccessToast = ( job: JobSummary, diff --git a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx b/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx index 22f656dbe738c..8717ae16d1ba1 100644 --- a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx +++ b/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx @@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../index.d'; +import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; -import { DownloadButton } from './download_button'; export const getWarningFormulasToast = ( job: JobSummary, diff --git a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx b/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx index 1abba8888bb81..83fa129f0715a 100644 --- a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx +++ b/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx @@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../index.d'; +import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; -import { DownloadButton } from './download_button'; export const getWarningMaxSizeToast = ( job: JobSummary, diff --git a/x-pack/plugins/reporting/public/components/report_listing.test.tsx b/x-pack/plugins/reporting/public/components/report_listing.test.tsx index 5cf894580eae0..9b541261a690b 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.test.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.test.tsx @@ -5,12 +5,15 @@ */ import React from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { ReportListing } from './report_listing'; import { Observable } from 'rxjs'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ILicense } from '../../../licensing/public'; import { ReportingAPIClient } from '../lib/reporting_api_client'; +jest.mock('@elastic/eui/lib/components/form/form_row/make_id', () => () => 'generated-id'); + +import { ReportListing } from './report_listing'; + const reportingAPIClient = { list: () => Promise.resolve([ diff --git a/x-pack/plugins/reporting/public/components/report_listing.tsx b/x-pack/plugins/reporting/public/components/report_listing.tsx index 13fca019f3284..af7ff5941304a 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.tsx @@ -4,34 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; -import { get } from 'lodash'; -import moment from 'moment'; -import React, { Component } from 'react'; -import { Subscription } from 'rxjs'; - import { - EuiBasicTable, - EuiButtonIcon, + EuiInMemoryTable, EuiPageContent, EuiSpacer, EuiText, EuiTextColor, EuiTitle, - EuiToolTip, } from '@elastic/eui'; - -import { ToastsSetup, ApplicationStart } from 'src/core/public'; -import { LicensingPluginSetup, ILicense } from '../../../licensing/public'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import { get } from 'lodash'; +import moment from 'moment'; +import { Component, default as React } from 'react'; +import { Subscription } from 'rxjs'; +import { ApplicationStart, ToastsSetup } from 'src/core/public'; +import { ILicense, LicensingPluginSetup } from '../../../licensing/public'; import { Poller } from '../../common/poller'; import { JobStatuses, JOB_COMPLETION_NOTIFICATIONS_POLLER_CONFIG } from '../../constants'; -import { ReportingAPIClient, JobQueueEntry } from '../lib/reporting_api_client'; import { checkLicense } from '../lib/license_check'; -import { ReportErrorButton } from './report_error_button'; -import { ReportInfoButton } from './report_info_button'; +import { JobQueueEntry, ReportingAPIClient } from '../lib/reporting_api_client'; +import { + ReportDeleteButton, + ReportDownloadButton, + ReportErrorButton, + ReportInfoButton, +} from './buttons'; -interface Job { +export interface Job { id: string; type: string; object_type: string; @@ -49,7 +49,7 @@ interface Job { warnings: string[]; } -interface Props { +export interface Props { intl: InjectedIntl; apiClient: ReportingAPIClient; license$: LicensingPluginSetup['license$']; @@ -61,6 +61,7 @@ interface State { page: number; total: number; jobs: Job[]; + selectedJobs: Job[]; isLoading: boolean; showLinks: boolean; enableLinks: boolean; @@ -113,6 +114,7 @@ class ReportListingUi extends Component { page: 0, total: 0, jobs: [], + selectedJobs: [], isLoading: false, showLinks: false, enableLinks: false, @@ -182,6 +184,140 @@ class ReportListingUi extends Component { }); }; + private onSelectionChange = (jobs: Job[]) => { + this.setState(current => ({ ...current, selectedJobs: jobs })); + }; + + private removeRecord = (record: Job) => { + const { jobs } = this.state; + const filtered = jobs.filter(j => j.id !== record.id); + this.setState(current => ({ ...current, jobs: filtered })); + }; + + private renderDeleteButton = () => { + const { selectedJobs } = this.state; + if (selectedJobs.length === 0) return null; + + const performDelete = async () => { + for (const record of selectedJobs) { + try { + await this.props.apiClient.deleteReport(record.id); + this.removeRecord(record); + this.props.toasts.addSuccess( + this.props.intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteConfim', + defaultMessage: `The {reportTitle} report was deleted`, + }, + { reportTitle: record.object_title } + ) + ); + } catch (error) { + this.props.toasts.addDanger( + this.props.intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteFailedErrorMessage', + defaultMessage: `The report was not deleted: {error}`, + }, + { error } + ) + ); + throw error; + } + } + }; + + return ( + + ); + }; + + private onTableChange = ({ page }: { page: { index: number } }) => { + const { index: pageIndex } = page; + this.setState(() => ({ page: pageIndex }), this.fetchJobs); + }; + + private fetchJobs = async () => { + // avoid page flicker when poller is updating table - only display loading screen on first load + if (this.isInitialJobsFetch) { + this.setState(() => ({ isLoading: true })); + } + + let jobs: JobQueueEntry[]; + let total: number; + try { + jobs = await this.props.apiClient.list(this.state.page); + total = await this.props.apiClient.total(); + this.isInitialJobsFetch = false; + } catch (fetchError) { + if (!this.licenseAllowsToShowThisPage()) { + this.props.toasts.addDanger(this.state.badLicenseMessage); + this.props.redirect('kibana#/management'); + return; + } + + if (fetchError.message === 'Failed to fetch') { + this.props.toasts.addDanger( + fetchError.message || + this.props.intl.formatMessage({ + id: 'xpack.reporting.listing.table.requestFailedErrorMessage', + defaultMessage: 'Request failed', + }) + ); + } + if (this.mounted) { + this.setState(() => ({ isLoading: false, jobs: [], total: 0 })); + } + return; + } + + if (this.mounted) { + this.setState(() => ({ + isLoading: false, + total, + jobs: jobs.map( + (job: JobQueueEntry): Job => { + const { _source: source } = job; + return { + id: job._id, + type: source.jobtype, + object_type: source.payload.objectType, + object_title: source.payload.title, + created_by: source.created_by, + created_at: source.created_at, + started_at: source.started_at, + completed_at: source.completed_at, + status: source.status, + statusLabel: jobStatusLabelsMap.get(source.status as JobStatuses) || source.status, + max_size_reached: source.output ? source.output.max_size_reached : false, + attempts: source.attempts, + max_attempts: source.max_attempts, + csv_contains_formulas: get(source, 'output.csv_contains_formulas'), + warnings: source.output ? source.output.warnings : undefined, + }; + } + ), + })); + } + }; + + private licenseAllowsToShowThisPage = () => { + return this.state.showLinks && this.state.enableLinks; + }; + + private formatDate(timestamp: string) { + try { + return moment(timestamp).format('YYYY-MM-DD @ hh:mm A'); + } catch (error) { + // ignore parse error and display unformatted value + return timestamp; + } + } + private renderTable() { const { intl } = this.props; @@ -317,9 +453,9 @@ class ReportListingUi extends Component { render: (record: Job) => { return (
- {this.renderDownloadButton(record)} - {this.renderReportErrorButton(record)} - {this.renderInfoButton(record)} + + +
); }, @@ -335,13 +471,22 @@ class ReportListingUi extends Component { hidePerPageOptions: true, }; + const selection = { + itemId: 'id', + onSelectionChange: this.onSelectionChange, + }; + + const search = { + toolsRight: this.renderDeleteButton(), + }; + return ( - { }) } pagination={pagination} + selection={selection} + search={search} + isSelectable={true} onChange={this.onTableChange} data-test-subj="reportJobListing" /> ); } - - private renderDownloadButton = (record: Job) => { - if (record.status !== JobStatuses.COMPLETED) { - return; - } - - const { intl } = this.props; - const button = ( - this.props.apiClient.downloadReport(record.id)} - iconType="importAction" - aria-label={intl.formatMessage({ - id: 'xpack.reporting.listing.table.downloadReportAriaLabel', - defaultMessage: 'Download report', - })} - /> - ); - - if (record.csv_contains_formulas) { - return ( - - {button} - - ); - } - - if (record.max_size_reached) { - return ( - - {button} - - ); - } - - return button; - }; - - private renderReportErrorButton = (record: Job) => { - if (record.status !== JobStatuses.FAILED) { - return; - } - - return ; - }; - - private renderInfoButton = (record: Job) => { - return ; - }; - - private onTableChange = ({ page }: { page: { index: number } }) => { - const { index: pageIndex } = page; - this.setState(() => ({ page: pageIndex }), this.fetchJobs); - }; - - private fetchJobs = async () => { - // avoid page flicker when poller is updating table - only display loading screen on first load - if (this.isInitialJobsFetch) { - this.setState(() => ({ isLoading: true })); - } - - let jobs: JobQueueEntry[]; - let total: number; - try { - jobs = await this.props.apiClient.list(this.state.page); - total = await this.props.apiClient.total(); - this.isInitialJobsFetch = false; - } catch (fetchError) { - if (!this.licenseAllowsToShowThisPage()) { - this.props.toasts.addDanger(this.state.badLicenseMessage); - this.props.redirect('kibana#/management'); - return; - } - - if (fetchError.message === 'Failed to fetch') { - this.props.toasts.addDanger( - fetchError.message || - this.props.intl.formatMessage({ - id: 'xpack.reporting.listing.table.requestFailedErrorMessage', - defaultMessage: 'Request failed', - }) - ); - } - if (this.mounted) { - this.setState(() => ({ isLoading: false, jobs: [], total: 0 })); - } - return; - } - - if (this.mounted) { - this.setState(() => ({ - isLoading: false, - total, - jobs: jobs.map( - (job: JobQueueEntry): Job => { - const { _source: source } = job; - return { - id: job._id, - type: source.jobtype, - object_type: source.payload.objectType, - object_title: source.payload.title, - created_by: source.created_by, - created_at: source.created_at, - started_at: source.started_at, - completed_at: source.completed_at, - status: source.status, - statusLabel: jobStatusLabelsMap.get(source.status as JobStatuses) || source.status, - max_size_reached: source.output ? source.output.max_size_reached : false, - attempts: source.attempts, - max_attempts: source.max_attempts, - csv_contains_formulas: get(source, 'output.csv_contains_formulas'), - warnings: source.output ? source.output.warnings : undefined, - }; - } - ), - })); - } - }; - - private licenseAllowsToShowThisPage = () => { - return this.state.showLinks && this.state.enableLinks; - }; - - private formatDate(timestamp: string) { - try { - return moment(timestamp).format('YYYY-MM-DD @ hh:mm A'); - } catch (error) { - // ignore parse error and display unformatted value - return timestamp; - } - } } export const ReportListing = injectI18n(ReportListingUi); diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts index ddfeb144d3cd7..cddfcd3ec855a 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts @@ -85,6 +85,12 @@ export class ReportingAPIClient { window.open(location); } + public async deleteReport(jobId: string) { + return await this.http.delete(`${API_LIST_URL}/delete/${jobId}`, { + asSystemRequest: true, + }); + } + public list = (page = 0, jobIds: string[] = []): Promise => { const query = { page } as any; if (jobIds.length > 0) { From f47022a41d09e241fa8c49bfd4d84d4fa6913deb Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Thu, 19 Mar 2020 13:05:01 -0700 Subject: [PATCH 11/22] Disables PR Project Assigner workflow Signed-off-by: Tyler Smalley --- .github/workflows/pr-project-assigner.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-project-assigner.yml b/.github/workflows/pr-project-assigner.yml index 0516f2cf95640..ca5d0b9864f99 100644 --- a/.github/workflows/pr-project-assigner.yml +++ b/.github/workflows/pr-project-assigner.yml @@ -13,9 +13,9 @@ jobs: with: issue-mappings: | [ - { "label": "Team:AppArch", "projectNumber": 37, "columnName": "Review in progress" }, - { "label": "Feature:Lens", "projectNumber": 32, "columnName": "In progress" }, - { "label": "Team:Canvas", "projectNumber": 38, "columnName": "Review in progress" } ] ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} +# { "label": "Team:AppArch", "projectNumber": 37, "columnName": "Review in progress" }, +# { "label": "Feature:Lens", "projectNumber": 32, "columnName": "In progress" }, +# { "label": "Team:Canvas", "projectNumber": 38, "columnName": "Review in progress" } \ No newline at end of file From cd2d54d59af929f750025b0859426735df99cfcf Mon Sep 17 00:00:00 2001 From: kqualters-elastic <56408403+kqualters-elastic@users.noreply.github.com> Date: Thu, 19 Mar 2020 16:14:45 -0400 Subject: [PATCH 12/22] Use common event model for determining if event is v0 or v1 (#60667) --- .../server/routes/resolver/utils/normalize.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts index 67a532d949e81..6d5ac8efdc1da 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts @@ -4,28 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ResolverEvent, LegacyEndpointEvent } from '../../../../common/types'; - -function isLegacyData(data: ResolverEvent): data is LegacyEndpointEvent { - return data.agent?.type === 'endgame'; -} +import { ResolverEvent } from '../../../../common/types'; +import { isLegacyEvent } from '../../../../common/models/event'; export function extractEventID(event: ResolverEvent) { - if (isLegacyData(event)) { + if (isLegacyEvent(event)) { return String(event.endgame.serial_event_id); } return event.event.id; } export function extractEntityID(event: ResolverEvent) { - if (isLegacyData(event)) { + if (isLegacyEvent(event)) { return String(event.endgame.unique_pid); } return event.process.entity_id; } export function extractParentEntityID(event: ResolverEvent) { - if (isLegacyData(event)) { + if (isLegacyEvent(event)) { const ppid = event.endgame.unique_ppid; return ppid && String(ppid); // if unique_ppid is undefined return undefined } From 404e941e636450d2ad0dcc68b914715bcc669a9f Mon Sep 17 00:00:00 2001 From: marshallmain <55718608+marshallmain@users.noreply.github.com> Date: Thu, 19 Mar 2020 17:01:39 -0400 Subject: [PATCH 13/22] [Endpoint] Log random seed for sample data CLI to console (#60646) * log random seed to console * fix off by 1 error with children --- x-pack/plugins/endpoint/common/generate_data.ts | 2 +- x-pack/plugins/endpoint/scripts/resolver_generator.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts index f5ed6da197273..75351bb3bf07d 100644 --- a/x-pack/plugins/endpoint/common/generate_data.ts +++ b/x-pack/plugins/endpoint/common/generate_data.ts @@ -325,7 +325,7 @@ export class EndpointDocGenerator { for (let i = 0; i < generations; i++) { const newParents: EndpointEvent[] = []; parents.forEach(element => { - const numChildren = this.randomN(maxChildrenPerNode); + const numChildren = this.randomN(maxChildrenPerNode + 1); for (let j = 0; j < numChildren; j++) { timestamp = timestamp + 1000; const child = this.generateEvent({ diff --git a/x-pack/plugins/endpoint/scripts/resolver_generator.ts b/x-pack/plugins/endpoint/scripts/resolver_generator.ts index 503999daec587..3d11ccaad005d 100644 --- a/x-pack/plugins/endpoint/scripts/resolver_generator.ts +++ b/x-pack/plugins/endpoint/scripts/resolver_generator.ts @@ -131,8 +131,13 @@ async function main() { process.exit(1); } } - - const generator = new EndpointDocGenerator(argv.seed); + let seed = argv.seed; + if (!seed) { + seed = Math.random().toString(); + // eslint-disable-next-line no-console + console.log('No seed supplied, using random seed: ' + seed); + } + const generator = new EndpointDocGenerator(seed); for (let i = 0; i < argv.numHosts; i++) { await client.index({ index: argv.metadataIndex, From b2b5fcedcc2bb7cce0008074bc1fccc075c6d5bf Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Thu, 19 Mar 2020 22:02:16 +0100 Subject: [PATCH 14/22] [ML] Transforms: Fix pivot preview table mapping. (#60609) - Fixes regression caused by elastic/elasticsearch#53572. - Adjusts the TS mappings and code to reflect the newly returned API response. - Re-enables functional tests. --- .../transform/public/app/common/index.ts | 1 + .../public/app/common/pivot_preview.ts | 29 +++++++++++++++++++ .../pivot_preview/use_pivot_preview_data.ts | 24 ++++----------- .../transform/public/app/hooks/use_api.ts | 4 +-- .../test/functional/apps/transform/index.ts | 3 +- 5 files changed, 38 insertions(+), 23 deletions(-) create mode 100644 x-pack/plugins/transform/public/app/common/pivot_preview.ts diff --git a/x-pack/plugins/transform/public/app/common/index.ts b/x-pack/plugins/transform/public/app/common/index.ts index e81fadddbea69..ee026e2e590a4 100644 --- a/x-pack/plugins/transform/public/app/common/index.ts +++ b/x-pack/plugins/transform/public/app/common/index.ts @@ -36,6 +36,7 @@ export { TRANSFORM_MODE, } from './transform_stats'; export { getDiscoverUrl } from './navigation'; +export { GetTransformsResponse, PreviewData, PreviewMappings } from './pivot_preview'; export { getEsAggFromAggConfig, isPivotAggsConfigWithUiSupport, diff --git a/x-pack/plugins/transform/public/app/common/pivot_preview.ts b/x-pack/plugins/transform/public/app/common/pivot_preview.ts new file mode 100644 index 0000000000000..14368a80b0131 --- /dev/null +++ b/x-pack/plugins/transform/public/app/common/pivot_preview.ts @@ -0,0 +1,29 @@ +/* + * 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 { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; + +import { Dictionary } from '../../../common/types/common'; + +interface EsMappingType { + type: ES_FIELD_TYPES; +} + +export type PreviewItem = Dictionary; +export type PreviewData = PreviewItem[]; +export interface PreviewMappings { + properties: Dictionary; +} + +export interface GetTransformsResponse { + preview: PreviewData; + generated_dest_index: { + mappings: PreviewMappings; + // Not in use yet + aliases: any; + settings: any; + }; +} diff --git a/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts b/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts index c3ccddbfc2906..83fa7ba189ff0 100644 --- a/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts +++ b/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts @@ -9,8 +9,7 @@ import { useEffect, useState } from 'react'; import { dictionaryToArray } from '../../../../common/types/common'; import { useApi } from '../../hooks/use_api'; -import { Dictionary } from '../../../../common/types/common'; -import { IndexPattern, ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; +import { IndexPattern } from '../../../../../../../src/plugins/data/public'; import { getPreviewRequestBody, @@ -18,6 +17,8 @@ import { PivotAggsConfigDict, PivotGroupByConfigDict, PivotQuery, + PreviewData, + PreviewMappings, } from '../../common'; export enum PIVOT_PREVIEW_STATUS { @@ -27,16 +28,6 @@ export enum PIVOT_PREVIEW_STATUS { ERROR, } -interface EsMappingType { - type: ES_FIELD_TYPES; -} - -export type PreviewItem = Dictionary; -type PreviewData = PreviewItem[]; -interface PreviewMappings { - properties: Dictionary; -} - export interface UsePivotPreviewDataReturnType { errorMessage: string; status: PIVOT_PREVIEW_STATUS; @@ -45,11 +36,6 @@ export interface UsePivotPreviewDataReturnType { previewRequest: PreviewRequestBody; } -export interface GetTransformsResponse { - preview: PreviewData; - mappings: PreviewMappings; -} - export const usePivotPreviewData = ( indexPatternTitle: IndexPattern['title'], query: PivotQuery, @@ -77,9 +63,9 @@ export const usePivotPreviewData = ( setStatus(PIVOT_PREVIEW_STATUS.LOADING); try { - const resp: GetTransformsResponse = await api.getTransformsPreview(previewRequest); + const resp = await api.getTransformsPreview(previewRequest); setPreviewData(resp.preview); - setPreviewMappings(resp.mappings); + setPreviewMappings(resp.generated_dest_index.mappings); setStatus(PIVOT_PREVIEW_STATUS.LOADED); } catch (e) { setErrorMessage(JSON.stringify(e, null, 2)); diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index c503051ed90af..39341dd1add65 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -8,7 +8,7 @@ import { TransformId, TransformEndpointRequest, TransformEndpointResult } from ' import { API_BASE_PATH } from '../../../common/constants'; import { useAppDependencies } from '../app_dependencies'; -import { PreviewRequestBody } from '../common'; +import { GetTransformsResponse, PreviewRequestBody } from '../common'; import { EsIndex } from './use_api_types'; @@ -37,7 +37,7 @@ export const useApi = () => { body: JSON.stringify(transformsInfo), }); }, - getTransformsPreview(obj: PreviewRequestBody): Promise { + getTransformsPreview(obj: PreviewRequestBody): Promise { return http.post(`${API_BASE_PATH}transforms/_preview`, { body: JSON.stringify(obj) }); }, startTransforms(transformsInfo: TransformEndpointRequest[]): Promise { diff --git a/x-pack/test/functional/apps/transform/index.ts b/x-pack/test/functional/apps/transform/index.ts index 5dcfd876f5b53..60b72f122f113 100644 --- a/x-pack/test/functional/apps/transform/index.ts +++ b/x-pack/test/functional/apps/transform/index.ts @@ -8,8 +8,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService, loadTestFile }: FtrProviderContext) { const transform = getService('transform'); - // prevent test failures with current ES snapshot, see https://github.com/elastic/kibana/issues/60516 - describe.skip('transform', function() { + describe('transform', function() { this.tags(['ciGroup9', 'transform']); before(async () => { From 347160b71aaef4790d6db2f5b14670b8b8bc07c3 Mon Sep 17 00:00:00 2001 From: Eric Davis Date: Thu, 19 Mar 2020 17:10:56 -0400 Subject: [PATCH 15/22] [Endpoint] TEST: GET alert details - boundary test for first alert retrieval (#60320) * boundary test for first alert retrieval * boundary test for first alert retrieval cleaned up * redo merge conflict resolving for api test * redo merge conflict resolving for api test try 2 * updating to current dataset expectations Co-authored-by: Elastic Machine --- .../test/api_integration/apis/endpoint/alerts.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/endpoint/alerts.ts b/x-pack/test/api_integration/apis/endpoint/alerts.ts index 140d8ca813694..568c30aa5484f 100644 --- a/x-pack/test/api_integration/apis/endpoint/alerts.ts +++ b/x-pack/test/api_integration/apis/endpoint/alerts.ts @@ -215,7 +215,7 @@ export default function({ getService }: FtrProviderContext) { expect(body.result_from_index).to.eql(0); }); - it('should return alert details by id', async () => { + it('should return alert details by id, getting last alert', async () => { const documentID = 'zbNm0HABdD75WLjLYgcB'; const prevDocumentID = '2rNm0HABdD75WLjLYgcU'; const { body } = await supertest @@ -227,6 +227,18 @@ export default function({ getService }: FtrProviderContext) { expect(body.next).to.eql(null); // last alert, no more beyond this }); + it('should return alert details by id, getting first alert', async () => { + const documentID = 'p7Nm0HABdD75WLjLYghv'; + const nextDocumentID = 'mbNm0HABdD75WLjLYgho'; + const { body } = await supertest + .get(`/api/endpoint/alerts/${documentID}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.id).to.eql(documentID); + expect(body.next).to.eql(`/api/endpoint/alerts/${nextDocumentID}`); + expect(body.prev).to.eql(null); // first alert, no more before this + }); + it('should return 404 when alert is not found', async () => { await supertest .get('/api/endpoint/alerts/does-not-exist') From 3acbbcd2b04b45c3aa1904a91abd33c91bb280d8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 19 Mar 2020 23:23:37 +0200 Subject: [PATCH 16/22] Return incident's url (#60617) --- .../servicenow/action_handlers.test.ts | 25 +++++++++++++++++++ .../servicenow/action_handlers.ts | 15 +++++++---- .../servicenow/index.test.ts | 4 ++- .../servicenow/lib/constants.ts | 3 +++ .../servicenow/lib/index.test.ts | 2 ++ .../servicenow/lib/index.ts | 8 +++++- .../servicenow/lib/types.ts | 1 + .../builtin_action_types/servicenow/mock.ts | 2 ++ .../builtin_action_types/servicenow/types.ts | 7 ++---- .../builtin_action_types/servicenow.ts | 7 +++++- 10 files changed, 61 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts index be687e33e2201..2712b8f6ea9b5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts @@ -78,11 +78,13 @@ beforeAll(() => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }), updateIncident: jest.fn().mockResolvedValue({ incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }), batchCreateComments: jest .fn() @@ -107,6 +109,7 @@ describe('handleIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', comments: [ { commentId: '456', @@ -129,6 +132,7 @@ describe('handleIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', comments: [ { commentId: '456', @@ -161,6 +165,7 @@ describe('handleCreateIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); @@ -203,6 +208,7 @@ describe('handleCreateIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', comments: [ { commentId: '456', @@ -236,6 +242,7 @@ describe('handleUpdateIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); @@ -326,6 +333,7 @@ describe('handleUpdateIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', comments: [ { commentId: '456', @@ -383,8 +391,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('nothing & append', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -426,8 +436,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('append & append', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -471,8 +483,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('nothing & nothing', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -511,8 +525,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('overwrite & nothing', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -553,8 +569,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('overwrite & overwrite', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -596,8 +614,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('nothing & overwrite', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -638,8 +658,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('append & overwrite', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -682,8 +704,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('append & nothing', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -725,6 +749,7 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts index 6439a68813fd5..fb296089e9ec5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts @@ -47,11 +47,11 @@ export const handleCreateIncident = async ({ fields, }); - const { incidentId, number, pushedDate } = await serviceNow.createIncident({ + const createdIncident = await serviceNow.createIncident({ ...incident, }); - const res: HandlerResponse = { incidentId, number, pushedDate }; + const res: HandlerResponse = { ...createdIncident }; if ( comments && @@ -61,7 +61,12 @@ export const handleCreateIncident = async ({ ) { comments = transformComments(comments, params, ['informationAdded']); res.comments = [ - ...(await createComments(serviceNow, incidentId, mapping.get('comments').target, comments)), + ...(await createComments( + serviceNow, + res.incidentId, + mapping.get('comments').target, + comments + )), ]; } @@ -88,11 +93,11 @@ export const handleUpdateIncident = async ({ currentIncident, }); - const { number, pushedDate } = await serviceNow.updateIncident(incidentId, { + const updatedIncident = await serviceNow.updateIncident(incidentId, { ...incident, }); - const res: HandlerResponse = { incidentId, number, pushedDate }; + const res: HandlerResponse = { ...updatedIncident }; if ( comments && diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts index 8ee81c5e76451..67d595cc3ec56 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -231,8 +231,10 @@ describe('execute()', () => { services, }; + handleIncidentMock.mockImplementation(() => incidentResponse); + const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toEqual({ actionId, status: 'ok' }); + expect(actionResponse).toEqual({ actionId, status: 'ok', data: incidentResponse }); }); test('should throw an error when failed to update an incident', async () => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts index c84e1928e2e5a..3f102ae19f437 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts @@ -8,3 +8,6 @@ export const API_VERSION = 'v2'; export const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; export const USER_URL = `api/now/${API_VERSION}/table/sys_user?user_name=`; export const COMMENT_URL = `api/now/${API_VERSION}/table/incident`; + +// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html +export const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts index 17c8bce651403..40eeb0f920f82 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts @@ -92,6 +92,7 @@ describe('ServiceNow lib', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); @@ -116,6 +117,7 @@ describe('ServiceNow lib', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts index 2d1d8975c9efc..1acb6c563801c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts @@ -6,7 +6,7 @@ import axios, { AxiosInstance, Method, AxiosResponse } from 'axios'; -import { INCIDENT_URL, USER_URL, COMMENT_URL } from './constants'; +import { INCIDENT_URL, USER_URL, COMMENT_URL, VIEW_INCIDENT_URL } from './constants'; import { Instance, Incident, IncidentResponse, UpdateIncident, CommentResponse } from './types'; import { Comment } from '../types'; @@ -72,6 +72,10 @@ class ServiceNow { return `[Action][ServiceNow]: ${msg}`; } + private _getIncidentViewURL(id: string) { + return `${this.instance.url}/${VIEW_INCIDENT_URL}${id}`; + } + async getUserID(): Promise { try { const res = await this._request({ url: `${this.userUrl}${this.instance.username}` }); @@ -109,6 +113,7 @@ class ServiceNow { number: res.data.result.number, incidentId: res.data.result.sys_id, pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), + url: this._getIncidentViewURL(res.data.result.sys_id), }; } catch (error) { throw new Error(this._getErrorMessage(`Unable to create incident. Error: ${error.message}`)); @@ -126,6 +131,7 @@ class ServiceNow { number: res.data.result.number, incidentId: res.data.result.sys_id, pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + url: this._getIncidentViewURL(res.data.result.sys_id), }; } catch (error) { throw new Error( diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts index 3c245bf3f688f..a65e417dbc486 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts @@ -21,6 +21,7 @@ export interface IncidentResponse { number: string; incidentId: string; pushedDate: string; + url: string; } export interface CommentResponse { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts index b9608511159b6..06c006fb37825 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts @@ -69,6 +69,8 @@ const params: ExecutorParams = { const incidentResponse = { incidentId: 'c816f79cc0a8016401c5a33be04be441', number: 'INC0010001', + pushedDate: '2020-03-13T08:34:53.450Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }; const userId = '2e9a0a5e2f79001016ab51172799b670'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 418b78add2429..71b05be8f3e4d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -16,7 +16,7 @@ import { } from './schema'; import { ServiceNow } from './lib'; -import { Incident } from './lib/types'; +import { Incident, IncidentResponse } from './lib/types'; // config definition export type ConfigType = TypeOf; @@ -50,11 +50,8 @@ export type IncidentHandlerArguments = CreateHandlerArguments & { incidentId: string | null; }; -export interface HandlerResponse { - incidentId: string; - number: string; +export interface HandlerResponse extends IncidentResponse { comments?: SimpleComment[]; - pushedDate: string; } export interface SimpleComment { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index b735dae2ca5b1..48f348e1b834d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -294,7 +294,12 @@ export default function servicenowTest({ getService }: FtrProviderContext) { expect(result).to.eql({ status: 'ok', actionId: simulatedActionId, - data: { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z' }, + data: { + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${servicenowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + }, }); }); From d1aaa4430af53087eab9e120de3ed2c35dbb9ae3 Mon Sep 17 00:00:00 2001 From: nnamdifrankie <56440728+nnamdifrankie@users.noreply.github.com> Date: Thu, 19 Mar 2020 18:15:56 -0400 Subject: [PATCH 17/22] [Ingest]EMT-248: add post action request handler and resources (#60581) [Ingest]EMT-248: add resource to allow to post new agent action. --- .../ingest_manager/common/constants/routes.ts | 1 + .../common/types/models/agent.ts | 9 +- .../common/types/rest_spec/agent.ts | 16 ++- .../routes/agent/actions_handlers.test.ts | 103 ++++++++++++++++++ .../server/routes/agent/actions_handlers.ts | 57 ++++++++++ .../server/routes/agent/index.ts | 15 +++ .../server/services/agents/actions.test.ts | 67 ++++++++++++ .../server/services/agents/actions.ts | 50 +++++++++ .../server/services/agents/index.ts | 1 + .../server/types/models/agent.ts | 11 ++ .../server/types/rest_spec/agent.ts | 11 +- .../apis/fleet/agents/actions.ts | 86 +++++++++++++++ .../test/api_integration/apis/fleet/index.js | 1 + 13 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/actions.ts create mode 100644 x-pack/test/api_integration/apis/fleet/agents/actions.ts diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 1dc98f9bc8947..5bf7c910168c0 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -50,6 +50,7 @@ export const AGENT_API_ROUTES = { EVENTS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/events`, CHECKIN_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/checkin`, ACKS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/acks`, + ACTIONS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/actions`, ENROLL_PATTERN: `${FLEET_API_ROOT}/agents/enroll`, UNENROLL_PATTERN: `${FLEET_API_ROOT}/agents/unenroll`, STATUS_PATTERN: `${FLEET_API_ROOT}/agent-status`, diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index 179cc3fc9eb55..aa5729a101e11 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -14,14 +14,17 @@ export type AgentType = export type AgentStatus = 'offline' | 'error' | 'online' | 'inactive' | 'warning'; -export interface AgentAction extends SavedObjectAttributes { +export interface NewAgentAction { type: 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE'; - id: string; - created_at: string; data?: string; sent_at?: string; } +export type AgentAction = NewAgentAction & { + id: string; + created_at: string; +} & SavedObjectAttributes; + export interface AgentEvent { type: 'STATE' | 'ERROR' | 'ACTION_RESULT' | 'ACTION'; subtype: // State diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts index 7bbaf42422bb2..21ab41740ce3e 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType } from '../models'; +import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType, NewAgentAction } from '../models'; export interface GetAgentsRequest { query: { @@ -81,6 +81,20 @@ export interface PostAgentAcksResponse { success: boolean; } +export interface PostNewAgentActionRequest { + body: { + action: NewAgentAction; + }; + params: { + agentId: string; + }; +} + +export interface PostNewAgentActionResponse { + success: boolean; + item: AgentAction; +} + export interface PostAgentUnenrollRequest { body: { kuery: string } | { ids: string[] }; } diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts new file mode 100644 index 0000000000000..a20ba4a880537 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { NewAgentActionSchema } from '../../types/models'; +import { + KibanaResponseFactory, + RequestHandlerContext, + SavedObjectsClientContract, +} from 'kibana/server'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock'; +import { httpServerMock } from '../../../../../../src/core/server/http/http_server.mocks'; +import { ActionsService } from '../../services/agents'; +import { AgentAction } from '../../../common/types/models'; +import { postNewAgentActionHandlerBuilder } from './actions_handlers'; +import { + PostNewAgentActionRequest, + PostNewAgentActionResponse, +} from '../../../common/types/rest_spec'; + +describe('test actions handlers schema', () => { + it('validate that new agent actions schema is valid', async () => { + expect( + NewAgentActionSchema.validate({ + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }) + ).toBeTruthy(); + }); + + it('validate that new agent actions schema is invalid when required properties are not provided', async () => { + expect(() => { + NewAgentActionSchema.validate({ + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }); + }).toThrowError(); + }); +}); + +describe('test actions handlers', () => { + let mockResponse: jest.Mocked; + let mockSavedObjectsClient: jest.Mocked; + + beforeEach(() => { + mockSavedObjectsClient = savedObjectsClientMock.create(); + mockResponse = httpServerMock.createResponseFactory(); + }); + + it('should succeed on valid new agent action', async () => { + const postNewAgentActionRequest: PostNewAgentActionRequest = { + body: { + action: { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }, + }, + params: { + agentId: 'id', + }, + }; + + const mockRequest = httpServerMock.createKibanaRequest(postNewAgentActionRequest); + + const agentAction = ({ + type: 'CONFIG_CHANGE', + id: 'action1', + sent_at: '2020-03-14T19:45:02.620Z', + timestamp: '2019-01-04T14:32:03.36764-05:00', + created_at: '2020-03-14T19:45:02.620Z', + } as unknown) as AgentAction; + + const actionsService: ActionsService = { + getAgent: jest.fn().mockReturnValueOnce({ + id: 'agent', + }), + updateAgentActions: jest.fn().mockReturnValueOnce(agentAction), + } as jest.Mocked; + + const postNewAgentActionHandler = postNewAgentActionHandlerBuilder(actionsService); + await postNewAgentActionHandler( + ({ + core: { + savedObjects: { + client: mockSavedObjectsClient, + }, + }, + } as unknown) as RequestHandlerContext, + mockRequest, + mockResponse + ); + + const expectedAgentActionResponse = (mockResponse.ok.mock.calls[0][0] + ?.body as unknown) as PostNewAgentActionResponse; + + expect(expectedAgentActionResponse.item).toEqual(agentAction); + expect(expectedAgentActionResponse.success).toEqual(true); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts new file mode 100644 index 0000000000000..2b9c230803593 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts @@ -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. + */ + +// handlers that handle agent actions request + +import { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { PostNewAgentActionRequestSchema } from '../../types/rest_spec'; +import { ActionsService } from '../../services/agents'; +import { NewAgentAction } from '../../../common/types/models'; +import { PostNewAgentActionResponse } from '../../../common/types/rest_spec'; + +export const postNewAgentActionHandlerBuilder = function( + actionsService: ActionsService +): RequestHandler< + TypeOf, + undefined, + TypeOf +> { + return async (context, request, response) => { + try { + const soClient = context.core.savedObjects.client; + + const agent = await actionsService.getAgent(soClient, request.params.agentId); + + const newAgentAction = request.body.action as NewAgentAction; + + const savedAgentAction = await actionsService.updateAgentActions( + soClient, + agent, + newAgentAction + ); + + const body: PostNewAgentActionResponse = { + success: true, + item: savedAgentAction, + }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom) { + return response.customError({ + statusCode: e.output.statusCode, + body: { message: e.message }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } + }; +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts index 414d2d79e9067..d461027017842 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -22,6 +22,7 @@ import { PostAgentAcksRequestSchema, PostAgentUnenrollRequestSchema, GetAgentStatusRequestSchema, + PostNewAgentActionRequestSchema, } from '../../types'; import { getAgentsHandler, @@ -37,6 +38,7 @@ import { } from './handlers'; import { postAgentAcksHandlerBuilder } from './acks_handlers'; import * as AgentService from '../../services/agents'; +import { postNewAgentActionHandlerBuilder } from './actions_handlers'; export const registerRoutes = (router: IRouter) => { // Get one @@ -111,6 +113,19 @@ export const registerRoutes = (router: IRouter) => { }) ); + // Agent actions + router.post( + { + path: AGENT_API_ROUTES.ACTIONS_PATTERN, + validate: PostNewAgentActionRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postNewAgentActionHandlerBuilder({ + getAgent: AgentService.getAgent, + updateAgentActions: AgentService.updateAgentActions, + }) + ); + router.post( { path: AGENT_API_ROUTES.UNENROLL_PATTERN, diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts new file mode 100644 index 0000000000000..b500aeb825fec --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { createAgentAction, updateAgentActions } from './actions'; +import { Agent, AgentAction, NewAgentAction } from '../../../common/types/models'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock'; +import { AGENT_TYPE_PERMANENT } from '../../../common/constants'; + +interface UpdatedActions { + actions: AgentAction[]; +} + +describe('test agent actions services', () => { + it('should update agent current actions with new action', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + + const newAgentAction: NewAgentAction = { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }; + + await updateAgentActions( + mockSavedObjectsClient, + ({ + id: 'id', + type: AGENT_TYPE_PERMANENT, + actions: [ + { + type: 'CONFIG_CHANGE', + id: 'action1', + sent_at: '2020-03-14T19:45:02.620Z', + timestamp: '2019-01-04T14:32:03.36764-05:00', + created_at: '2020-03-14T19:45:02.620Z', + }, + ], + } as unknown) as Agent, + newAgentAction + ); + + const updatedAgentActions = (mockSavedObjectsClient.update.mock + .calls[0][2] as unknown) as UpdatedActions; + + expect(updatedAgentActions.actions.length).toEqual(2); + const actualAgentAction = updatedAgentActions.actions.find(action => action?.data === 'data'); + expect(actualAgentAction?.type).toEqual(newAgentAction.type); + expect(actualAgentAction?.data).toEqual(newAgentAction.data); + expect(actualAgentAction?.sent_at).toEqual(newAgentAction.sent_at); + }); + + it('should create agent action from new agent action model', async () => { + const newAgentAction: NewAgentAction = { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }; + const now = new Date(); + const agentAction = createAgentAction(now, newAgentAction); + + expect(agentAction.type).toEqual(newAgentAction.type); + expect(agentAction.data).toEqual(newAgentAction.data); + expect(agentAction.sent_at).toEqual(newAgentAction.sent_at); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts new file mode 100644 index 0000000000000..2f8ed9f504453 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import uuid from 'uuid'; +import { + Agent, + AgentAction, + AgentSOAttributes, + NewAgentAction, +} from '../../../common/types/models'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../../common/constants'; + +export async function updateAgentActions( + soClient: SavedObjectsClientContract, + agent: Agent, + newAgentAction: NewAgentAction +): Promise { + const agentAction = createAgentAction(new Date(), newAgentAction); + + agent.actions.push(agentAction); + + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, { + actions: agent.actions, + }); + + return agentAction; +} + +export function createAgentAction(createdAt: Date, newAgentAction: NewAgentAction): AgentAction { + const agentAction = { + id: uuid.v4(), + created_at: createdAt.toISOString(), + }; + + return Object.assign(agentAction, newAgentAction); +} + +export interface ActionsService { + getAgent: (soClient: SavedObjectsClientContract, agentId: string) => Promise; + + updateAgentActions: ( + soClient: SavedObjectsClientContract, + agent: Agent, + newAgentAction: NewAgentAction + ) => Promise; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/index.ts b/x-pack/plugins/ingest_manager/server/services/agents/index.ts index 477f081d1900b..c95c9ecc2a1d8 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/index.ts @@ -12,3 +12,4 @@ export * from './unenroll'; export * from './status'; export * from './crud'; export * from './update'; +export * from './actions'; diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts index e0d252faaaf87..f70b3cf0ed092 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts @@ -52,3 +52,14 @@ export const AckEventSchema = schema.object({ export const AgentEventSchema = schema.object({ ...AgentEventBase, }); + +export const NewAgentActionSchema = schema.object({ + type: schema.oneOf([ + schema.literal('CONFIG_CHANGE'), + schema.literal('DATA_DUMP'), + schema.literal('RESUME'), + schema.literal('PAUSE'), + ]), + data: schema.maybe(schema.string()), + sent_at: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts index 9fe84c12521ad..f94c02ccee40b 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { AckEventSchema, AgentEventSchema, AgentTypeSchema } from '../models'; +import { AckEventSchema, AgentEventSchema, AgentTypeSchema, NewAgentActionSchema } from '../models'; export const GetAgentsRequestSchema = { query: schema.object({ @@ -52,6 +52,15 @@ export const PostAgentAcksRequestSchema = { }), }; +export const PostNewAgentActionRequestSchema = { + body: schema.object({ + action: NewAgentActionSchema, + }), + params: schema.object({ + agentId: schema.string(), + }), +}; + export const PostAgentUnenrollRequestSchema = { body: schema.oneOf([ schema.object({ diff --git a/x-pack/test/api_integration/apis/fleet/agents/actions.ts b/x-pack/test/api_integration/apis/fleet/agents/actions.ts new file mode 100644 index 0000000000000..f27b932cff5cb --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/agents/actions.ts @@ -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. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function(providerContext: FtrProviderContext) { + const { getService } = providerContext; + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('fleet_agents_actions', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should return a 200 if this a valid actions request', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'CONFIG_CHANGE', + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(200); + + expect(apiResponse.success).to.be(true); + expect(apiResponse.item.data).to.be('action_data'); + expect(apiResponse.item.sent_at).to.be('2020-03-18T19:45:02.620Z'); + + const { body: agentResponse } = await supertest + .get(`/api/ingest_manager/fleet/agents/agent1`) + .set('kbn-xsrf', 'xx') + .expect(200); + + const updatedAction = agentResponse.item.actions.find( + (itemAction: Record) => itemAction?.data === 'action_data' + ); + + expect(updatedAction.type).to.be('CONFIG_CHANGE'); + expect(updatedAction.data).to.be('action_data'); + expect(updatedAction.sent_at).to.be('2020-03-18T19:45:02.620Z'); + }); + + it('should return a 400 when request does not have type information', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(400); + expect(apiResponse.message).to.eql( + '[request body.action.type]: expected at least one defined value but got [undefined]' + ); + }); + + it('should return a 404 when agent does not exist', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent100/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'CONFIG_CHANGE', + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(404); + expect(apiResponse.message).to.eql('Saved object [agents/agent100] not found'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/api_integration/apis/fleet/index.js index 69d30291f030b..547bbb8c7c6ee 100644 --- a/x-pack/test/api_integration/apis/fleet/index.js +++ b/x-pack/test/api_integration/apis/fleet/index.js @@ -15,5 +15,6 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./agents/acks')); loadTestFile(require.resolve('./enrollment_api_keys/crud')); loadTestFile(require.resolve('./install')); + loadTestFile(require.resolve('./agents/actions')); }); } From d5989e8baa55350caff19e721cd5d15ff5621f93 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Thu, 19 Mar 2020 18:29:26 -0400 Subject: [PATCH 18/22] [Alerting] add functional tests for index threshold alertType (#60597) resolves https://github.com/elastic/kibana/issues/58902 --- .../alert_types/index_threshold/alert_type.ts | 2 + .../common/lib/es_test_index_tool.ts | 23 +- .../index_threshold/alert.ts | 398 ++++++++++++++++++ .../index_threshold/create_test_data.ts | 48 +-- .../index_threshold/index.ts | 1 + .../time_series_query_endpoint.ts | 24 +- 6 files changed, 447 insertions(+), 49 deletions(-) create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts index b79321a8803fa..6d27f8a99dd4b 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts @@ -113,6 +113,7 @@ export function getAlertType(service: Service): AlertType { timeWindowUnit: params.timeWindowUnit, interval: undefined, }; + // console.log(`index_threshold: query: ${JSON.stringify(queryParams, null, 4)}`); const result = await service.indexThreshold.timeSeriesQuery({ logger, callCluster, @@ -121,6 +122,7 @@ export function getAlertType(service: Service): AlertType { logger.debug(`alert ${ID}:${alertId} "${name}" query result: ${JSON.stringify(result)}`); const groupResults = result.results || []; + // console.log(`index_threshold: response: ${JSON.stringify(groupResults, null, 4)}`); for (const groupResult of groupResults) { const instanceId = groupResult.group; const value = groupResult.metrics[0][1]; diff --git a/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts b/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts index ccd7748d9e899..999a8686e0ee7 100644 --- a/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts +++ b/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts @@ -4,20 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ES_TEST_INDEX_NAME = '.kibaka-alerting-test-data'; +export const ES_TEST_INDEX_NAME = '.kibana-alerting-test-data'; export class ESTestIndexTool { - private readonly es: any; - private readonly retry: any; - - constructor(es: any, retry: any) { - this.es = es; - this.retry = retry; - } + constructor( + private readonly es: any, + private readonly retry: any, + private readonly index: string = ES_TEST_INDEX_NAME + ) {} async setup() { return await this.es.indices.create({ - index: ES_TEST_INDEX_NAME, + index: this.index, body: { mappings: { properties: { @@ -56,12 +54,13 @@ export class ESTestIndexTool { } async destroy() { - return await this.es.indices.delete({ index: ES_TEST_INDEX_NAME, ignore: [404] }); + return await this.es.indices.delete({ index: this.index, ignore: [404] }); } async search(source: string, reference: string) { return await this.es.search({ - index: ES_TEST_INDEX_NAME, + index: this.index, + size: 1000, body: { query: { bool: { @@ -86,7 +85,7 @@ export class ESTestIndexTool { async waitForDocs(source: string, reference: string, numDocs: number = 1) { return await this.retry.try(async () => { const searchResult = await this.search(source, reference); - if (searchResult.hits.total.value !== numDocs) { + if (searchResult.hits.total.value < numDocs) { throw new Error(`Expected ${numDocs} but received ${searchResult.hits.total.value}.`); } return searchResult.hits.hits; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts new file mode 100644 index 0000000000000..13f3a4971183c --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts @@ -0,0 +1,398 @@ +/* + * 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 { Spaces } from '../../../../scenarios'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { + ESTestIndexTool, + ES_TEST_INDEX_NAME, + getUrlPrefix, + ObjectRemover, +} from '../../../../../common/lib'; +import { createEsDocuments } from './create_test_data'; + +const ALERT_TYPE_ID = '.index-threshold'; +const ACTION_TYPE_ID = '.index'; +const ES_TEST_INDEX_SOURCE = 'builtin-alert:index-threshold'; +const ES_TEST_INDEX_REFERENCE = '-na-'; +const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-output`; + +const ALERT_INTERVALS_TO_WRITE = 5; +const ALERT_INTERVAL_SECONDS = 3; +const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000; + +// eslint-disable-next-line import/no-default-export +export default function alertTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + const es = getService('legacyEs'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME); + + describe('alert', async () => { + let endDate: string; + let actionId: string; + const objectRemover = new ObjectRemover(supertest); + + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + await esTestIndexToolOutput.destroy(); + await esTestIndexToolOutput.setup(); + + actionId = await createAction(supertest, objectRemover); + + // write documents in the future, figure out the end date + const endDateMillis = Date.now() + (ALERT_INTERVALS_TO_WRITE - 1) * ALERT_INTERVAL_MILLIS; + endDate = new Date(endDateMillis).toISOString(); + + // write documents from now to the future end date in 3 groups + createEsDocumentsInGroups(3); + }); + + afterEach(async () => { + await objectRemover.removeAll(); + await esTestIndexTool.destroy(); + await esTestIndexToolOutput.destroy(); + }); + + // The tests below create two alerts, one that will fire, one that will + // never fire; the tests ensure the ones that should fire, do fire, and + // those that shouldn't fire, do not fire. + it('runs correctly: count all < >', async () => { + await createAlert({ + name: 'never fire', + aggType: 'count', + groupBy: 'all', + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'count', + groupBy: 'all', + thresholdComparator: '>', + threshold: [-1], + }); + + const docs = await waitForDocs(2); + for (const doc of docs) { + const { group } = doc._source; + const { name, value, title, message } = doc._source.params; + + expect(name).to.be('always fire'); + expect(group).to.be('all documents'); + + // we'll check title and message in this test, but not subsequent ones + expect(title).to.be('alert always fire group all documents exceeded threshold'); + + const expectedPrefix = `alert always fire group all documents value ${value} exceeded threshold count > -1 over`; + const messagePrefix = message.substr(0, expectedPrefix.length); + expect(messagePrefix).to.be(expectedPrefix); + } + }); + + it('runs correctly: count grouped <= =>', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'count', + groupBy: 'top', + termField: 'group', + termSize: 2, + thresholdComparator: '<=', + threshold: [-1], + }); + + await createAlert({ + name: 'always fire', + aggType: 'count', + groupBy: 'top', + termField: 'group', + termSize: 2, // two actions will fire each interval + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + let inGroup0 = 0; + + for (const doc of docs) { + const { group } = doc._source; + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + if (group === 'group-0') inGroup0++; + } + + // there should be 2 docs in group-0, rando split between others + expect(inGroup0).to.be(2); + }); + + it('runs correctly: sum all between', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'sum', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: 'between', + threshold: [-2, -1], + }); + + await createAlert({ + name: 'always fire', + aggType: 'sum', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: 'between', + threshold: [0, 1000000], + }); + + const docs = await waitForDocs(2); + for (const doc of docs) { + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + } + }); + + it('runs correctly: avg all', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'avg', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'avg', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + for (const doc of docs) { + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + } + }); + + it('runs correctly: max grouped', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'max', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'max', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, // two actions will fire each interval + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + let inGroup2 = 0; + + for (const doc of docs) { + const { group } = doc._source; + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + if (group === 'group-2') inGroup2++; + } + + // there should be 2 docs in group-2, rando split between others + expect(inGroup2).to.be(2); + }); + + it('runs correctly: min grouped', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'min', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'min', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, // two actions will fire each interval + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + let inGroup0 = 0; + + for (const doc of docs) { + const { group } = doc._source; + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + if (group === 'group-0') inGroup0++; + } + + // there should be 2 docs in group-0, rando split between others + expect(inGroup0).to.be(2); + }); + + async function createEsDocumentsInGroups(groups: number) { + await createEsDocuments( + es, + esTestIndexTool, + endDate, + ALERT_INTERVALS_TO_WRITE, + ALERT_INTERVAL_MILLIS, + groups + ); + } + + async function waitForDocs(count: number): Promise { + return await esTestIndexToolOutput.waitForDocs( + ES_TEST_INDEX_SOURCE, + ES_TEST_INDEX_REFERENCE, + count + ); + } + + interface CreateAlertParams { + name: string; + aggType: string; + aggField?: string; + groupBy: 'all' | 'top'; + termField?: string; + termSize?: number; + thresholdComparator: string; + threshold: number[]; + } + + async function createAlert(params: CreateAlertParams): Promise { + const action = { + id: actionId, + group: 'threshold met', + params: { + documents: [ + { + source: ES_TEST_INDEX_SOURCE, + reference: ES_TEST_INDEX_REFERENCE, + params: { + name: '{{{alertName}}}', + value: '{{{context.value}}}', + title: '{{{context.title}}}', + message: '{{{context.message}}}', + }, + date: '{{{context.date}}}', + // TODO: I wanted to write the alert value here, but how? + // We only mustache interpolate string values ... + // testedValue: '{{{context.value}}}', + group: '{{{context.group}}}', + }, + ], + }, + }; + + const { statusCode, body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send({ + name: params.name, + consumer: 'function test', + enabled: true, + alertTypeId: ALERT_TYPE_ID, + schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` }, + actions: [action], + params: { + index: ES_TEST_INDEX_NAME, + timeField: 'date', + aggType: params.aggType, + aggField: params.aggField, + groupBy: params.groupBy, + termField: params.termField, + termSize: params.termSize, + timeWindowSize: ALERT_INTERVAL_SECONDS * 5, + timeWindowUnit: 's', + thresholdComparator: params.thresholdComparator, + threshold: params.threshold, + }, + }); + + // will print the error body, if an error occurred + // if (statusCode !== 200) console.log(createdAlert); + + expect(statusCode).to.be(200); + + const alertId = createdAlert.id; + objectRemover.add(Spaces.space1.id, alertId, 'alert'); + + return alertId; + } + }); +} + +async function createAction(supertest: any, objectRemover: ObjectRemover): Promise { + const { statusCode, body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'index action for index threshold FT', + actionTypeId: ACTION_TYPE_ID, + config: { + index: ES_TEST_OUTPUT_INDEX_NAME, + }, + secrets: {}, + }); + + // will print the error body, if an error occurred + // if (statusCode !== 200) console.log(createdAction); + + expect(statusCode).to.be(200); + + const actionId = createdAction.id; + objectRemover.add(Spaces.space1.id, actionId, 'action'); + + return actionId; +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts index 523c348e26049..21f73ac9b9833 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts @@ -8,53 +8,50 @@ import { times } from 'lodash'; import { v4 as uuid } from 'uuid'; import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '../../../../../common/lib'; -// date to start writing data -export const START_DATE = '2020-01-01T00:00:00Z'; +// default end date +export const END_DATE = '2020-01-01T00:00:00Z'; -const DOCUMENT_SOURCE = 'queryDataEndpointTests'; +export const DOCUMENT_SOURCE = 'queryDataEndpointTests'; +export const DOCUMENT_REFERENCE = '-na-'; // Create a set of es documents to run the queries against. -// Will create 2 documents for each interval. +// Will create `groups` documents for each interval. // The difference between the dates of the docs will be intervalMillis. // The date of the last documents will be startDate - intervalMillis / 2. -// So there will be 2 documents written in the middle of each interval range. -// The data value written to each doc is a power of 2, with 2^0 as the value -// of the last documents, the values increasing for older documents. The -// second document for each time value will be power of 2 + 1 +// So the documents will be written in the middle of each interval range. +// The data value written to each doc is a power of 2 + the group index, with +// 2^0 as the value of the last documents, the values increasing for older +// documents. export async function createEsDocuments( es: any, esTestIndexTool: ESTestIndexTool, - startDate: string = START_DATE, + endDate: string = END_DATE, intervals: number = 1, - intervalMillis: number = 1000 + intervalMillis: number = 1000, + groups: number = 2 ) { - const totalDocuments = intervals * 2; - const startDateMillis = Date.parse(startDate) - intervalMillis / 2; + const endDateMillis = Date.parse(endDate) - intervalMillis / 2; times(intervals, interval => { - const date = startDateMillis - interval * intervalMillis; + const date = endDateMillis - interval * intervalMillis; - // base value for each window is 2^window + // base value for each window is 2^interval const testedValue = 2 ** interval; // don't need await on these, wait at the end of the function - createEsDocument(es, '-na-', date, testedValue, 'groupA'); - createEsDocument(es, '-na-', date, testedValue + 1, 'groupB'); + times(groups, group => { + createEsDocument(es, date, testedValue + group, `group-${group}`); + }); }); - await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, '-na-', totalDocuments); + const totalDocuments = intervals * groups; + await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, DOCUMENT_REFERENCE, totalDocuments); } -async function createEsDocument( - es: any, - reference: string, - epochMillis: number, - testedValue: number, - group: string -) { +async function createEsDocument(es: any, epochMillis: number, testedValue: number, group: string) { const document = { source: DOCUMENT_SOURCE, - reference, + reference: DOCUMENT_REFERENCE, date: new Date(epochMillis).toISOString(), testedValue, group, @@ -65,6 +62,7 @@ async function createEsDocument( index: ES_TEST_INDEX_NAME, body: document, }); + // console.log(`writing document to ${ES_TEST_INDEX_NAME}:`, JSON.stringify(document, null, 4)); if (response.result !== 'created') { throw new Error(`document not created: ${JSON.stringify(response)}`); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts index 9158954f23364..507548f94aaf3 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts @@ -12,5 +12,6 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./time_series_query_endpoint')); loadTestFile(require.resolve('./fields_endpoint')); loadTestFile(require.resolve('./indices_endpoint')); + loadTestFile(require.resolve('./alert')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts index 1aa1d3d21f00d..c9b488da5dec5 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts @@ -39,12 +39,12 @@ const START_DATE_MINUS_2INTERVALS = getStartDate(-2 * INTERVAL_MILLIS); are offset from the top of the minute by 30 seconds, the queries always run from the top of the hour. - { "date":"2019-12-31T23:59:30.000Z", "testedValue":1, "group":"groupA" } - { "date":"2019-12-31T23:59:30.000Z", "testedValue":2, "group":"groupB" } - { "date":"2019-12-31T23:58:30.000Z", "testedValue":2, "group":"groupA" } - { "date":"2019-12-31T23:58:30.000Z", "testedValue":3, "group":"groupB" } - { "date":"2019-12-31T23:57:30.000Z", "testedValue":4, "group":"groupA" } - { "date":"2019-12-31T23:57:30.000Z", "testedValue":5, "group":"groupB" } + { "date":"2019-12-31T23:59:30.000Z", "testedValue":1, "group":"group-0" } + { "date":"2019-12-31T23:59:30.000Z", "testedValue":2, "group":"group-1" } + { "date":"2019-12-31T23:58:30.000Z", "testedValue":2, "group":"group-0" } + { "date":"2019-12-31T23:58:30.000Z", "testedValue":3, "group":"group-1" } + { "date":"2019-12-31T23:57:30.000Z", "testedValue":4, "group":"group-0" } + { "date":"2019-12-31T23:57:30.000Z", "testedValue":5, "group":"group-1" } */ // eslint-disable-next-line import/no-default-export @@ -162,7 +162,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider const expected = { results: [ { - group: 'groupA', + group: 'group-0', metrics: [ [START_DATE_MINUS_2INTERVALS, 1], [START_DATE_MINUS_1INTERVALS, 2], @@ -170,7 +170,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider ], }, { - group: 'groupB', + group: 'group-1', metrics: [ [START_DATE_MINUS_2INTERVALS, 1], [START_DATE_MINUS_1INTERVALS, 2], @@ -197,7 +197,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider const expected = { results: [ { - group: 'groupB', + group: 'group-1', metrics: [ [START_DATE_MINUS_2INTERVALS, 5 / 1], [START_DATE_MINUS_1INTERVALS, (5 + 3) / 2], @@ -205,7 +205,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider ], }, { - group: 'groupA', + group: 'group-0', metrics: [ [START_DATE_MINUS_2INTERVALS, 4 / 1], [START_DATE_MINUS_1INTERVALS, (4 + 2) / 2], @@ -230,7 +230,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider }); const result = await runQueryExpect(query, 200); expect(result.results.length).to.be(1); - expect(result.results[0].group).to.be('groupB'); + expect(result.results[0].group).to.be('group-1'); }); it('should return correct sorted group for min', async () => { @@ -245,7 +245,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider }); const result = await runQueryExpect(query, 200); expect(result.results.length).to.be(1); - expect(result.results[0].group).to.be('groupA'); + expect(result.results[0].group).to.be('group-0'); }); it('should return an error when passed invalid input', async () => { From 0163a71d24670eb4a813b27850582bec3aa9534a Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 19 Mar 2020 17:08:53 -0600 Subject: [PATCH 19/22] [SIEM] [Case] Bulk status update, add comment avatar, id => title in breadcrumbs (#60410) --- .../__snapshots__/index.test.tsx.snap | 82 +++---- .../siem/public/containers/case/api.ts | 24 +- .../siem/public/containers/case/types.ts | 6 + .../containers/case/use_bulk_update_case.tsx | 106 +++++++++ x-pack/legacy/plugins/siem/public/legacy.ts | 13 +- .../plugins/siem/public/lib/kibana/hooks.ts | 64 ++++++ .../components/all_cases/__mock__/index.tsx | 2 +- .../case/components/all_cases/index.test.tsx | 215 ++++++++++++++++-- .../pages/case/components/all_cases/index.tsx | 44 ++-- .../case/components/all_cases/translations.ts | 4 +- .../case/components/bulk_actions/index.tsx | 19 +- .../components/bulk_actions/translations.ts | 2 +- .../case/components/case_view/index.test.tsx | 39 +++- .../pages/case/components/case_view/index.tsx | 4 + .../components/user_action_tree/index.tsx | 9 +- .../user_action_tree/user_action_item.tsx | 14 +- .../plugins/siem/public/pages/case/utils.ts | 2 +- x-pack/legacy/plugins/siem/public/plugin.tsx | 12 +- .../siem/public/utils/route/spy_routes.tsx | 30 +-- 19 files changed, 565 insertions(+), 126 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index c3ce9a97bbea1..e15ce0ae5f543 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -38,18 +38,18 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] = data-test-subj="stat-item" >

@@ -258,18 +258,18 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] = data-test-subj="stat-item" >

@@ -548,18 +548,18 @@ exports[`Stat Items Component rendering kpis with charts it renders the default data-test-subj="stat-item" >

1,714 @@ -734,10 +734,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default key="stat-items-field-uniqueDestinationIps" >

2,359 @@ -815,10 +815,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default >

=> { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch(CASES_URL, { method: 'POST', body: JSON.stringify(newCase), }); @@ -104,13 +112,21 @@ export const patchCase = async ( updatedCase: Partial, version: string ): Promise => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch(CASES_URL, { method: 'PATCH', body: JSON.stringify({ cases: [{ ...updatedCase, id: caseId, version }] }), }); return convertToCamelCase(decodeCasesResponse(response)); }; +export const patchCasesStatus = async (cases: BulkUpdateStatus[]): Promise => { + const response = await KibanaServices.get().http.fetch(CASES_URL, { + method: 'PATCH', + body: JSON.stringify({ cases }), + }); + return convertToCamelCase(decodeCasesResponse(response)); +}; + export const postComment = async (newComment: CommentRequest, caseId: string): Promise => { const response = await KibanaServices.get().http.fetch( `${CASES_URL}/${caseId}/comments`, @@ -139,7 +155,7 @@ export const patchComment = async ( }; export const deleteCases = async (caseIds: string[]): Promise => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch(CASES_URL, { method: 'DELETE', query: { ids: JSON.stringify(caseIds) }, }); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 5b6ff8438be8c..44519031e91cb 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -78,3 +78,9 @@ export interface FetchCasesProps { export interface ApiProps { signal: AbortSignal; } + +export interface BulkUpdateStatus { + status: string; + id: string; + version: string; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx new file mode 100644 index 0000000000000..77d779ab906cf --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx @@ -0,0 +1,106 @@ +/* + * 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 { useCallback, useReducer } from 'react'; +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import * as i18n from './translations'; +import { patchCasesStatus } from './api'; +import { BulkUpdateStatus, Case } from './types'; + +interface UpdateState { + isUpdated: boolean; + isLoading: boolean; + isError: boolean; +} +type Action = + | { type: 'FETCH_INIT' } + | { type: 'FETCH_SUCCESS'; payload: boolean } + | { type: 'FETCH_FAILURE' } + | { type: 'RESET_IS_UPDATED' }; + +const dataFetchReducer = (state: UpdateState, action: Action): UpdateState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS': + return { + ...state, + isLoading: false, + isError: false, + isUpdated: action.payload, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + case 'RESET_IS_UPDATED': + return { + ...state, + isUpdated: false, + }; + default: + return state; + } +}; +interface UseUpdateCase extends UpdateState { + updateBulkStatus: (cases: Case[], status: string) => void; + dispatchResetIsUpdated: () => void; +} + +export const useUpdateCases = (): UseUpdateCase => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + isUpdated: false, + }); + const [, dispatchToaster] = useStateToaster(); + + const dispatchUpdateCases = useCallback((cases: BulkUpdateStatus[]) => { + let cancel = false; + const patchData = async () => { + try { + dispatch({ type: 'FETCH_INIT' }); + await patchCasesStatus(cases); + if (!cancel) { + dispatch({ type: 'FETCH_SUCCESS', payload: true }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: 'FETCH_FAILURE' }); + } + } + }; + patchData(); + return () => { + cancel = true; + }; + }, []); + + const dispatchResetIsUpdated = useCallback(() => { + dispatch({ type: 'RESET_IS_UPDATED' }); + }, []); + + const updateBulkStatus = useCallback((cases: Case[], status: string) => { + const updateCasesStatus: BulkUpdateStatus[] = cases.map(theCase => ({ + status, + id: theCase.id, + version: theCase.version, + })); + dispatchUpdateCases(updateCasesStatus); + }, []); + return { ...state, updateBulkStatus, dispatchResetIsUpdated }; +}; diff --git a/x-pack/legacy/plugins/siem/public/legacy.ts b/x-pack/legacy/plugins/siem/public/legacy.ts index 157ec54353a3e..b3a06a170bb80 100644 --- a/x-pack/legacy/plugins/siem/public/legacy.ts +++ b/x-pack/legacy/plugins/siem/public/legacy.ts @@ -5,19 +5,12 @@ */ import { npSetup, npStart } from 'ui/new_platform'; -import { PluginsSetup, PluginsStart } from 'ui/new_platform/new_platform'; import { PluginInitializerContext } from '../../../../../src/core/public'; import { plugin } from './'; -import { - TriggersAndActionsUIPublicPluginSetup, - TriggersAndActionsUIPublicPluginStart, -} from '../../../../plugins/triggers_actions_ui/public'; +import { SetupPlugins, StartPlugins } from './plugin'; const pluginInstance = plugin({} as PluginInitializerContext); -type myPluginsSetup = PluginsSetup & { triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup }; -type myPluginsStart = PluginsStart & { triggers_actions_ui: TriggersAndActionsUIPublicPluginStart }; - -pluginInstance.setup(npSetup.core, npSetup.plugins as myPluginsSetup); -pluginInstance.start(npStart.core, npStart.plugins as myPluginsStart); +pluginInstance.setup(npSetup.core, (npSetup.plugins as unknown) as SetupPlugins); +pluginInstance.start(npStart.core, (npStart.plugins as unknown) as StartPlugins); diff --git a/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts b/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts index a4a70c77833c0..95ecee7b12bb1 100644 --- a/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts +++ b/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts @@ -6,8 +6,13 @@ import moment from 'moment-timezone'; +import { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../common/constants'; import { useUiSetting, useKibana } from './kibana_react'; +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { AuthenticatedUser } from '../../../../../../plugins/security/common/model'; +import { convertToCamelCase } from '../../containers/case/utils'; export const useDateFormat = (): string => useUiSetting(DEFAULT_DATE_FORMAT); @@ -17,3 +22,62 @@ export const useTimeZone = (): string => { }; export const useBasePath = (): string => useKibana().services.http.basePath.get(); + +interface UserRealm { + name: string; + type: string; +} + +export interface AuthenticatedElasticUser { + username: string; + email: string; + fullName: string; + roles: string[]; + enabled: boolean; + metadata?: { + _reserved: boolean; + }; + authenticationRealm: UserRealm; + lookupRealm: UserRealm; + authenticationProvider: string; +} + +export const useCurrentUser = (): AuthenticatedElasticUser | null => { + const [user, setUser] = useState(null); + + const [, dispatchToaster] = useStateToaster(); + + const { security } = useKibana().services; + + const fetchUser = useCallback(() => { + let didCancel = false; + const fetchData = async () => { + try { + const response = await security.authc.getCurrentUser(); + if (!didCancel) { + setUser(convertToCamelCase(response)); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.translate('xpack.siem.getCurrentUser.Error', { + defaultMessage: 'Error getting user', + }), + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setUser(null); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }, [security]); + + useEffect(() => { + fetchUser(); + }, []); + return user; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 5d00b770b3ca9..48fbb4e74c407 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -10,7 +10,7 @@ import { UseGetCasesState } from '../../../../../containers/case/use_get_cases'; export const useGetCasesMockState: UseGetCasesState = { data: { countClosedCases: 0, - countOpenCases: 0, + countOpenCases: 5, cases: [ { closedAt: null, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index 001acc1d4d36e..13869c79c45fd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -10,35 +10,86 @@ import moment from 'moment-timezone'; import { AllCases } from './'; import { TestProviders } from '../../../../mock'; import { useGetCasesMockState } from './__mock__'; -import * as apiHook from '../../../../containers/case/use_get_cases'; -import { act } from '@testing-library/react'; -import { wait } from '../../../../lib/helpers'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +import { useGetCases } from '../../../../containers/case/use_get_cases'; +import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status'; +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; +jest.mock('../../../../containers/case/use_bulk_update_case'); +jest.mock('../../../../containers/case/use_delete_cases'); +jest.mock('../../../../containers/case/use_get_cases'); +jest.mock('../../../../containers/case/use_get_cases_status'); +const useDeleteCasesMock = useDeleteCases as jest.Mock; +const useGetCasesMock = useGetCases as jest.Mock; +const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; +const useUpdateCasesMock = useUpdateCases as jest.Mock; describe('AllCases', () => { + const dispatchResetIsDeleted = jest.fn(); + const dispatchResetIsUpdated = jest.fn(); const dispatchUpdateCaseProperty = jest.fn(); + const handleOnDeleteConfirm = jest.fn(); + const handleToggleModal = jest.fn(); const refetchCases = jest.fn(); const setFilters = jest.fn(); const setQueryParams = jest.fn(); const setSelectedCases = jest.fn(); + const updateBulkStatus = jest.fn(); + const fetchCasesStatus = jest.fn(); + + const defaultGetCases = { + ...useGetCasesMockState, + dispatchUpdateCaseProperty, + refetchCases, + setFilters, + setQueryParams, + setSelectedCases, + }; + const defaultDeleteCases = { + dispatchResetIsDeleted, + handleOnDeleteConfirm, + handleToggleModal, + isDeleted: false, + isDisplayConfirmDeleteModal: false, + isLoading: false, + }; + const defaultCasesStatus = { + countClosedCases: 0, + countOpenCases: 5, + fetchCasesStatus, + isError: false, + isLoading: true, + }; + const defaultUpdateCases = { + isUpdated: false, + isLoading: false, + isError: false, + dispatchResetIsUpdated, + updateBulkStatus, + }; + /* eslint-disable no-console */ + // Silence until enzyme fixed to use ReactTestUtils.act() + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + /* eslint-enable no-console */ beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(apiHook, 'useGetCases').mockReturnValue({ - ...useGetCasesMockState, - dispatchUpdateCaseProperty, - refetchCases, - setFilters, - setQueryParams, - setSelectedCases, - }); + useUpdateCasesMock.mockImplementation(() => defaultUpdateCases); + useGetCasesMock.mockImplementation(() => defaultGetCases); + useDeleteCasesMock.mockImplementation(() => defaultDeleteCases); + useGetCasesStatusMock.mockImplementation(() => defaultCasesStatus); moment.tz.setDefault('UTC'); }); - it('should render AllCases', async () => { + it('should render AllCases', () => { const wrapper = mount( ); - await act(() => wait()); expect( wrapper .find(`a[data-test-subj="case-details-link"]`) @@ -76,13 +127,12 @@ describe('AllCases', () => { .text() ).toEqual('Showing 10 cases'); }); - it('should tableHeaderSortButton AllCases', async () => { + it('should tableHeaderSortButton AllCases', () => { const wrapper = mount( ); - await act(() => wait()); wrapper .find('[data-test-subj="tableHeaderSortButton"]') .first() @@ -94,4 +144,139 @@ describe('AllCases', () => { sortOrder: 'asc', }); }); + it('closes case when row action icon clicked', () => { + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="action-close"]') + .first() + .simulate('click'); + const firstCase = useGetCasesMockState.data.cases[0]; + expect(dispatchUpdateCaseProperty).toBeCalledWith({ + caseId: firstCase.id, + updateKey: 'status', + updateValue: 'closed', + refetchCasesStatus: fetchCasesStatus, + version: firstCase.version, + }); + }); + it('Bulk delete', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + selectedCases: useGetCasesMockState.data.cases, + })); + useDeleteCasesMock + .mockReturnValueOnce({ + ...defaultDeleteCases, + isDisplayConfirmDeleteModal: false, + }) + .mockReturnValue({ + ...defaultDeleteCases, + isDisplayConfirmDeleteModal: true, + }); + + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="case-table-bulk-actions"] button') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="cases-bulk-delete-button"]') + .first() + .simulate('click'); + expect(handleToggleModal).toBeCalled(); + + wrapper + .find( + '[data-test-subj="confirm-delete-case-modal"] [data-test-subj="confirmModalConfirmButton"]' + ) + .last() + .simulate('click'); + expect(handleOnDeleteConfirm.mock.calls[0][0]).toStrictEqual( + useGetCasesMockState.data.cases.map(theCase => theCase.id) + ); + }); + it('Bulk close status update', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + selectedCases: useGetCasesMockState.data.cases, + })); + + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="case-table-bulk-actions"] button') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="cases-bulk-close-button"]') + .first() + .simulate('click'); + expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'closed'); + }); + it('Bulk open status update', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + selectedCases: useGetCasesMockState.data.cases, + filterOptions: { + ...defaultGetCases.filterOptions, + status: 'closed', + }, + })); + + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="case-table-bulk-actions"] button') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="cases-bulk-open-button"]') + .first() + .simulate('click'); + expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'open'); + }); + it('isDeleted is true, refetch', () => { + useDeleteCasesMock.mockImplementation(() => ({ + ...defaultDeleteCases, + isDeleted: true, + })); + + mount( + + + + ); + expect(refetchCases).toBeCalled(); + expect(fetchCasesStatus).toBeCalled(); + expect(dispatchResetIsDeleted).toBeCalled(); + }); + it('isUpdated is true, refetch', () => { + useUpdateCasesMock.mockImplementation(() => ({ + ...defaultUpdateCases, + isUpdated: true, + })); + + mount( + + + + ); + expect(refetchCases).toBeCalled(); + expect(fetchCasesStatus).toBeCalled(); + expect(dispatchResetIsUpdated).toBeCalled(); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 9a84dd07b0af4..e7e1e624ccba2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -43,6 +43,7 @@ import { OpenClosedStats } from '../open_closed_stats'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; const CONFIGURE_CASES_URL = getConfigureCasesUrl(); const CREATE_CASE_URL = getCreateCaseUrl(); @@ -106,13 +107,20 @@ export const AllCases = React.memo(() => { isDisplayConfirmDeleteModal, } = useDeleteCases(); + const { dispatchResetIsUpdated, isUpdated, updateBulkStatus } = useUpdateCases(); + useEffect(() => { if (isDeleted) { refetchCases(filterOptions, queryParams); fetchCasesStatus(); dispatchResetIsDeleted(); } - }, [isDeleted, filterOptions, queryParams]); + if (isUpdated) { + refetchCases(filterOptions, queryParams); + fetchCasesStatus(); + dispatchResetIsUpdated(); + } + }, [isDeleted, isUpdated, filterOptions, queryParams]); const [deleteThisCase, setDeleteThisCase] = useState({ title: '', @@ -135,36 +143,38 @@ export const AllCases = React.memo(() => { [deleteBulk, deleteThisCase, isDisplayConfirmDeleteModal] ); - const toggleDeleteModal = useCallback( - (deleteCase: Case) => { - handleToggleModal(); - setDeleteThisCase(deleteCase); - }, - [isDisplayConfirmDeleteModal] - ); + const toggleDeleteModal = useCallback((deleteCase: Case) => { + handleToggleModal(); + setDeleteThisCase(deleteCase); + }, []); + + const toggleBulkDeleteModal = useCallback((deleteCases: string[]) => { + handleToggleModal(); + setDeleteBulk(deleteCases); + }, []); - const toggleBulkDeleteModal = useCallback( - (deleteCases: string[]) => { - handleToggleModal(); - setDeleteBulk(deleteCases); + const handleUpdateCaseStatus = useCallback( + (status: string) => { + updateBulkStatus(selectedCases, status); }, - [isDisplayConfirmDeleteModal] + [selectedCases] ); const selectedCaseIds = useMemo( - (): string[] => - selectedCases.reduce((arr: string[], caseObj: Case) => [...arr, caseObj.id], []), + (): string[] => selectedCases.map((caseObj: Case) => caseObj.id), [selectedCases] ); const getBulkItemsPopoverContent = useCallback( (closePopover: () => void) => ( ), @@ -322,7 +332,7 @@ export const AllCases = React.memo(() => { void; deleteCasesAction: (cases: string[]) => void; selectedCaseIds: string[]; - caseStatus: string; + updateCaseStatus: (status: string) => void; } export const getBulkItems = ({ - deleteCasesAction, - closePopover, caseStatus, + closePopover, + deleteCasesAction, selectedCaseIds, + updateCaseStatus, }: GetBulkItems) => { return [ caseStatus === 'open' ? ( { + onClick={() => { closePopover(); + updateCaseStatus('closed'); }} > {i18n.BULK_ACTION_CLOSE_SELECTED} ) : ( { closePopover(); + updateCaseStatus('open'); }} > {i18n.BULK_ACTION_OPEN_SELECTED} ), { + onClick={() => { closePopover(); deleteCasesAction(selectedCaseIds); }} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts index 0bf213868bd76..97045c8ebaf8b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts @@ -16,7 +16,7 @@ export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( export const BULK_ACTION_OPEN_SELECTED = i18n.translate( 'xpack.siem.case.caseTable.bulkActions.openSelectedTitle', { - defaultMessage: 'Open selected', + defaultMessage: 'Reopen selected', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index ec18bdb2bf9ab..41100ec6d50f1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { Router } from 'react-router-dom'; import { mount } from 'enzyme'; import { CaseComponent } from './'; import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; @@ -12,6 +13,27 @@ import { TestProviders } from '../../../../mock'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; jest.mock('../../../../containers/case/use_update_case'); const useUpdateCaseMock = useUpdateCase as jest.Mock; +type Action = 'PUSH' | 'POP' | 'REPLACE'; +const pop: Action = 'POP'; +const location = { + pathname: '/network', + search: '', + state: '', + hash: '', +}; +const mockHistory = { + length: 2, + location, + action: pop, + push: jest.fn(), + replace: jest.fn(), + go: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + block: jest.fn(), + createHref: jest.fn(), + listen: jest.fn(), +}; describe('CaseView ', () => { const updateCaseProperty = jest.fn(); @@ -42,7 +64,9 @@ describe('CaseView ', () => { it('should render CaseComponent', () => { const wrapper = mount( - + + + ); expect( @@ -83,6 +107,7 @@ describe('CaseView ', () => { .prop('raw') ).toEqual(data.description); }); + it('should show closed indicators in header when case is closed', () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, @@ -90,7 +115,9 @@ describe('CaseView ', () => { })); const wrapper = mount( - + + + ); expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); @@ -111,7 +138,9 @@ describe('CaseView ', () => { it('should dispatch update state when button is toggled', () => { const wrapper = mount( - + + + ); @@ -128,7 +157,9 @@ describe('CaseView ', () => { it('should render comments', () => { const wrapper = mount( - + + + ); expect( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index dce7bde2225c9..08af603cb0dbf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -23,6 +23,7 @@ import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; +import { SpyRoute } from '../../../../utils/route/spy_routes'; interface Props { caseId: string; @@ -93,6 +94,8 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); + const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); + const caseStatusData = useMemo( () => caseData.status === 'open' @@ -179,6 +182,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => + ); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index cebc66a0c8363..04697e63b7451 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -12,6 +12,7 @@ import { useUpdateComment } from '../../../../containers/case/use_update_comment import { UserActionItem } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; import { AddComment } from '../add_comment'; +import { useCurrentUser } from '../../../../lib/kibana'; export interface UserActionTreeProps { data: Case; @@ -20,14 +21,14 @@ export interface UserActionTreeProps { } const DescriptionId = 'description'; -const NewId = 'newComent'; +const NewId = 'newComment'; export const UserActionTree = React.memo( ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment( caseData.comments ); - + const currentUser = useCurrentUser(); const [manageMarkdownEditIds, setManangeMardownEditIds] = useState([]); const handleManageMarkdownEditId = useCallback( @@ -112,10 +113,10 @@ export const UserActionTree = React.memo( id={NewId} isEditable={true} isLoading={isLoadingIds.includes(NewId)} - fullName="to be determined" + fullName={currentUser != null ? currentUser.fullName : ''} markdown={MarkdownNewComment} onEdit={handleManageMarkdownEditId.bind(null, NewId)} - userName="to be determined" + userName={currentUser != null ? currentUser.username : ''} /> ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 0a33301010535..7b99f2ef76ab3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; import React from 'react'; import styled, { css } from 'styled-components'; @@ -48,6 +48,12 @@ const UserActionItemContainer = styled(EuiFlexGroup)` margin-right: ${theme.eui.euiSize}; vertical-align: top; } + .userAction_loadingAvatar { + position: relative; + margin-right: ${theme.eui.euiSizeXL}; + top: ${theme.eui.euiSizeM}; + left: ${theme.eui.euiSizeS}; + } .userAction__title { padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; background: ${theme.eui.euiColorLightestShade}; @@ -74,7 +80,11 @@ export const UserActionItem = ({ }: UserActionItemProps) => ( - + {fullName.length > 0 || userName.length > 0 ? ( + + ) : ( + + )} {isEditable && markdown} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index bd6cb5da5eb01..ccb3b71a476ec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -28,7 +28,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { breadcrumb = [ ...breadcrumb, { - text: params.detailName, + text: params.state?.caseTitle ?? '', href: getCaseDetailsUrl(params.detailName), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/plugin.tsx b/x-pack/legacy/plugins/siem/public/plugin.tsx index 71fa3a54df768..da4aad97e5b48 100644 --- a/x-pack/legacy/plugins/siem/public/plugin.tsx +++ b/x-pack/legacy/plugins/siem/public/plugin.tsx @@ -27,21 +27,24 @@ import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '../../../../plugins/triggers_actions_ui/public'; +import { SecurityPluginSetup } from '../../../../plugins/security/public'; export { AppMountParameters, CoreSetup, CoreStart, PluginInitializerContext }; export interface SetupPlugins { home: HomePublicPluginSetup; - usageCollection: UsageCollectionSetup; + security: SecurityPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; + usageCollection: UsageCollectionSetup; } export interface StartPlugins { data: DataPublicPluginStart; embeddable: EmbeddableStart; inspector: InspectorStart; newsfeed?: NewsfeedStart; - uiActions: UiActionsStart; + security: SecurityPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; + uiActions: UiActionsStart; } export type StartServices = CoreStart & StartPlugins; @@ -61,6 +64,8 @@ export class Plugin implements IPlugin { public setup(core: CoreSetup, plugins: SetupPlugins) { initTelemetry(plugins.usageCollection, this.id); + const security = plugins.security; + core.application.register({ id: this.id, title: this.name, @@ -69,8 +74,7 @@ export class Plugin implements IPlugin { const { renderApp } = await import('./app'); plugins.triggers_actions_ui.actionTypeRegistry.register(serviceNowActionType()); - - return renderApp(coreStart, startPlugins as StartPlugins, params); + return renderApp(coreStart, { ...startPlugins, security } as StartPlugins, params); }, }); diff --git a/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx b/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx index ddee2359b28ba..9030e2713548b 100644 --- a/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx +++ b/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx @@ -39,12 +39,13 @@ export const SpyRouteComponent = memo( dispatch({ type: 'updateRouteWithOutSearch', route: { - pageName, detailName, - tabName, - pathName: pathname, - history, flowTarget, + history, + pageName, + pathName: pathname, + state, + tabName, }, }); setIsInitializing(false); @@ -52,13 +53,14 @@ export const SpyRouteComponent = memo( dispatch({ type: 'updateRoute', route: { - pageName, detailName, - tabName, - search, - pathName: pathname, - history, flowTarget, + history, + pageName, + pathName: pathname, + search, + state, + tabName, }, }); } @@ -67,14 +69,14 @@ export const SpyRouteComponent = memo( dispatch({ type: 'updateRoute', route: { - pageName, detailName, - tabName, - search, - pathName: pathname, - history, flowTarget, + history, + pageName, + pathName: pathname, + search, state, + tabName, }, }); } From 182acdb6666807094f5b92e2fda9211f353e518a Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Thu, 19 Mar 2020 19:33:36 -0500 Subject: [PATCH 20/22] [SIEM] Fixes Modification of ML Rules (#60662) * Fix updating of ML rules * Add a regression test for updating ML Rules * Allow ML Rules to be patched And adds a regression unit test. * Allow ML rule params to be imported when overwriting * Add a basic regression test for creating a rule with ML params * Prevent users from changing an existing Rule's type --- .../components/select_rule_type/index.tsx | 5 +- .../components/step_define_rule/index.tsx | 8 ++- .../routes/__mocks__/request_responses.ts | 18 ++++++ .../routes/rules/import_rules_route.ts | 2 + .../rules/create_rules.test.ts | 50 +++++++++++++++++ .../rules/patch_rules.test.ts | 51 +++++++++++++++++ .../lib/detection_engine/rules/patch_rules.ts | 6 ++ .../rules/update_rules.test.ts | 56 +++++++++++++++++++ .../detection_engine/rules/update_rules.ts | 6 ++ 9 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx index b3b35699914f6..229ccde54ecab 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx @@ -14,9 +14,10 @@ import { isMlRule } from '../../helpers'; interface SelectRuleTypeProps { field: FieldHook; + isReadOnly: boolean; } -export const SelectRuleType: React.FC = ({ field }) => { +export const SelectRuleType: React.FC = ({ field, isReadOnly = false }) => { const ruleType = field.value as RuleType; const setType = useCallback( (type: RuleType) => { @@ -37,6 +38,7 @@ export const SelectRuleType: React.FC = ({ field }) => { description={i18n.QUERY_TYPE_DESCRIPTION} icon={} selectable={{ + isDisabled: isReadOnly, onClick: setQuery, isSelected: !isMlRule(ruleType), }} @@ -49,6 +51,7 @@ export const SelectRuleType: React.FC = ({ field }) => { isDisabled={!license} icon={} selectable={{ + isDisabled: isReadOnly, onClick: setMl, isSelected: isMlRule(ruleType), }} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index 6b1a9a828d950..d3ef185f3786b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -178,7 +178,13 @@ const StepDefineRuleComponent: FC = ({ <>
- + <> ({ scheduledTaskId: '2dabe330-0702-11ea-8b50-773b89126888', }); +export const getMlResult = (): RuleAlertType => { + const result = getResult(); + + return { + ...result, + params: { + ...result.params, + query: undefined, + language: undefined, + filters: undefined, + index: undefined, + type: 'machine_learning', + anomalyThreshold: 44, + machineLearningJobId: 'some_job_id', + }, + }; +}; + export const updateActionResult = (): ActionResult => ({ id: 'result-1', actionTypeId: 'action-id-1', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 920cf97d32a7a..d95ef595e5c40 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -228,6 +228,8 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config references, note, version, + anomalyThreshold, + machineLearningJobId, }); resolve({ rule_id: ruleId, status_code: 200 }); } else if (rule != null) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts new file mode 100644 index 0000000000000..4c8d0f51f251b --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { getMlResult } from '../routes/__mocks__/request_responses'; +import { createRules } from './create_rules'; + +describe('createRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + }); + + it('calls the alertsClient with ML params', async () => { + const params = { + ...getMlResult().params, + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }; + + await createRules({ + alertsClient, + actionsClient, + ...params, + ruleId: 'new-rule-id', + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts new file mode 100644 index 0000000000000..b424d2912ebc8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { getMlResult } from '../routes/__mocks__/request_responses'; +import { patchRules } from './patch_rules'; + +describe('patchRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + let savedObjectsClient: ReturnType; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('calls the alertsClient with ML params', async () => { + alertsClient.get.mockResolvedValue(getMlResult()); + const params = { + ...getMlResult().params, + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }; + + await patchRules({ + alertsClient, + actionsClient, + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...params, + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index 4fb73235854c0..a8da01f87a6fb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -46,6 +46,8 @@ export const patchRules = async ({ version, throttle, lists, + anomalyThreshold, + machineLearningJobId, }: PatchRuleParams): Promise => { const rule = await readRules({ alertsClient, ruleId, id }); if (rule == null) { @@ -79,6 +81,8 @@ export const patchRules = async ({ throttle, note, lists, + anomalyThreshold, + machineLearningJobId, }); const nextParams = defaults( @@ -109,6 +113,8 @@ export const patchRules = async ({ note, version: calculatedVersion, lists, + anomalyThreshold, + machineLearningJobId, } ); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts new file mode 100644 index 0000000000000..5ee740a8b8845 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts @@ -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 { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { getMlResult } from '../routes/__mocks__/request_responses'; +import { updateRules } from './update_rules'; + +describe('updateRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + let savedObjectsClient: ReturnType; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('calls the alertsClient with ML params', async () => { + alertsClient.get.mockResolvedValue(getMlResult()); + + const params = { + ...getMlResult().params, + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }; + + await updateRules({ + alertsClient, + actionsClient, + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...params, + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index b2a1d2a6307d2..ae8ea9dd32cd2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -46,6 +46,8 @@ export const updateRules = async ({ throttle, note, lists, + anomalyThreshold, + machineLearningJobId, }: UpdateRuleParams): Promise => { const rule = await readRules({ alertsClient, ruleId, id }); if (rule == null) { @@ -78,6 +80,8 @@ export const updateRules = async ({ version, throttle, note, + anomalyThreshold, + machineLearningJobId, }); // TODO: Remove this and use regular lists once the feature is stable for a release @@ -115,6 +119,8 @@ export const updateRules = async ({ references, note, version: calculatedVersion, + anomalyThreshold, + machineLearningJobId, ...listsParam, }, }, From c3957d855442c238b3403b76d6487fb7af9359ce Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 19 Mar 2020 17:41:28 -0700 Subject: [PATCH 21/22] [canvas/shareable_runtime] sync sass loaders with kbn/optimizer (#60653) * [canvas/shareable_runtime] sync sass loaders with kbn/optimizer * limit sass options to those relevant in this context Co-authored-by: spalger Co-authored-by: Elastic Machine --- .../shareable_runtime/webpack.config.js | 55 +++++++++++++++++-- x-pack/package.json | 1 + 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js b/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js index 0ce722eb90d43..66b0a7bc558cb 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js @@ -6,6 +6,7 @@ const path = require('path'); const webpack = require('webpack'); +const { stringifyRequest } = require('loader-utils'); // eslint-disable-line const { KIBANA_ROOT, @@ -140,19 +141,63 @@ module.exports = { }, { test: /\.scss$/, - exclude: /\.module.(s(a|c)ss)$/, + exclude: [/node_modules/, /\.module\.s(a|c)ss$/], use: [ - { loader: 'style-loader' }, - { loader: 'css-loader', options: { importLoaders: 2 } }, + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + options: { + sourceMap: !isProd, + }, + }, { loader: 'postcss-loader', options: { + sourceMap: !isProd, config: { - path: require.resolve('./postcss.config.js'), + path: require.resolve('./postcss.config'), + }, + }, + }, + { + loader: 'resolve-url-loader', + options: { + // eslint-disable-next-line no-unused-vars + join: (_, __) => (uri, base) => { + if (!base) { + return null; + } + + // manually force ui/* urls in legacy styles to resolve to ui/legacy/public + if (uri.startsWith('ui/') && base.split(path.sep).includes('legacy')) { + return path.resolve(KIBANA_ROOT, 'src/legacy/ui/public', uri.replace('ui/', '')); + } + + return null; + }, + }, + }, + { + loader: 'sass-loader', + options: { + // must always be enabled as long as we're using the `resolve-url-loader` to + // rewrite `ui/*` urls. They're dropped by subsequent loaders though + sourceMap: true, + prependData(loaderContext) { + return `@import ${stringifyRequest( + loaderContext, + path.resolve(KIBANA_ROOT, 'src/legacy/ui/public/styles/_styling_constants.scss') + )};\n`; + }, + webpackImporter: false, + sassOptions: { + outputStyle: 'nested', + includePaths: [path.resolve(KIBANA_ROOT, 'node_modules')], }, }, }, - { loader: 'sass-loader' }, ], }, { diff --git a/x-pack/package.json b/x-pack/package.json index bc00dc21d9908..5d75e0c9edc4c 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -142,6 +142,7 @@ "jest-cli": "^24.9.0", "jest-styled-components": "^7.0.0", "jsdom": "^15.2.1", + "loader-utils": "^1.2.3", "madge": "3.4.4", "marge": "^1.0.1", "mocha": "^6.2.2", From 19f719ccb5caee6c02160d08e7544154b6e682a6 Mon Sep 17 00:00:00 2001 From: MadameSheema Date: Fri, 20 Mar 2020 03:44:54 +0100 Subject: [PATCH 22/22] [SIEM] Cypress screenshots upload to google cloud (#60556) * testing screenshots upload to google cloud * testing another pattern * fixes artifact pattern * uploads only the .png files * only limit uploads from kibana-siem directory Co-authored-by: spalger --- vars/kibanaPipeline.groovy | 1 + 1 file changed, 1 insertion(+) diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index cb5508642711a..6252a103d2881 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -98,6 +98,7 @@ def withGcsArtifactUpload(workerName, closure) { def uploadPrefix = "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/${workerName}" def ARTIFACT_PATTERNS = [ 'target/kibana-*', + 'target/kibana-siem/**/*.png', 'target/junit/**/*', 'test/**/screenshots/**/*.png', 'test/functional/failure_debug/html/*.html',