diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 906af1dfbb28e..11072509da1fc 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -218,7 +218,7 @@ There is a very limited set of cases when you'd want to change these settings. F | Determines if HTTP authentication schemes used by the enabled authentication providers should be automatically supported during HTTP authentication. By default, this setting is set to `true`. | `xpack.security.authc.http.schemes[]` -| List of HTTP authentication schemes that {kib} HTTP authentication should support. By default, this setting is set to `['apikey']` to support HTTP authentication with <> scheme. +| List of HTTP authentication schemes that {kib} HTTP authentication should support. By default, this setting is set to `['apikey', 'bearer']` to support HTTP authentication with the <> and <> schemes. |=== diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index bc564308c057e..2f2b279389799 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -437,14 +437,14 @@ This type of authentication is usually useful for machine-to-machine interaction By default {kib} supports <> authentication scheme _and_ any scheme supported by the currently enabled authentication provider. For example, `Basic` authentication scheme is automatically supported when basic authentication provider is enabled, or `Bearer` scheme when any of the token based authentication providers is enabled (Token, SAML, OpenID Connect, PKI or Kerberos). But it's also possible to add support for any other authentication scheme in the `kibana.yml` configuration file, as follows: -NOTE: Don't forget to explicitly specify default `apikey` scheme when you just want to add a new one to the list. +NOTE: Don't forget to explicitly specify the default `apikey` and `bearer` schemes when you just want to add a new one to the list. [source,yaml] -------------------------------------------------------------------------------- -xpack.security.authc.http.schemes: [apikey, basic, something-custom] +xpack.security.authc.http.schemes: [apikey, bearer, basic, something-custom] -------------------------------------------------------------------------------- -With this configuration, you can send requests to {kib} with the `Authorization` header using `ApiKey`, `Basic` or `Something-Custom` HTTP schemes (case insensitive). Under the hood, {kib} relays this header to {es}, then {es} authenticates the request using the credentials in the header. +With this configuration, you can send requests to {kib} with the `Authorization` header using `ApiKey`, `Bearer`, `Basic` or `Something-Custom` HTTP schemes (case insensitive). Under the hood, {kib} relays this header to {es}, then {es} authenticates the request using the credentials in the header. [float] [[embedded-content-authentication]] diff --git a/renovate.json5 b/renovate.json5 index dea7d311bae16..b08d7e0bcec1e 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -75,6 +75,16 @@ labels: ['Team:Operations', 'release_note:skip'], enabled: true, }, + { + groupName: 'polyfills', + packageNames: ['core-js'], + matchPackagePatterns: ["polyfill"], + excludePackageNames: ['@loaders.gl/polyfills'], + reviewers: ['team:kibana-operations'], + matchBaseBranches: ['master'], + labels: ['Team:Operations', 'release_note:skip'], + enabled: true, + }, { groupName: 'vega related modules', packageNames: ['vega', 'vega-lite', 'vega-schema-url-parser', 'vega-tooltip'], diff --git a/src/plugins/bfetch/common/util/index.ts b/src/plugins/bfetch/common/util/index.ts index 1651d24d96b14..f20d30eb3cdf0 100644 --- a/src/plugins/bfetch/common/util/index.ts +++ b/src/plugins/bfetch/common/util/index.ts @@ -8,3 +8,4 @@ export * from './normalize_error'; export * from './remove_leading_slash'; +export * from './query_params'; diff --git a/src/plugins/bfetch/common/util/query_params.ts b/src/plugins/bfetch/common/util/query_params.ts new file mode 100644 index 0000000000000..ed65699fbcafc --- /dev/null +++ b/src/plugins/bfetch/common/util/query_params.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const appendQueryParam = (url: string, key: string, value: string): string => { + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}${key}=${value}`; +}; diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.ts index a94c8d3980cba..77e5acffc1af3 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.ts @@ -10,6 +10,7 @@ import { map, share } from 'rxjs/operators'; import { inflateResponse } from '.'; import { fromStreamingXhr } from './from_streaming_xhr'; import { split } from './split'; +import { appendQueryParam } from '../../common'; export interface FetchStreamingParams { url: string; @@ -34,16 +35,15 @@ export function fetchStreaming({ }: FetchStreamingParams) { const xhr = new window.XMLHttpRequest(); - // Begin the request - xhr.open(method, url); - xhr.withCredentials = true; - const isCompressionDisabled = getIsCompressionDisabled(); - if (!isCompressionDisabled) { - headers['X-Chunk-Encoding'] = 'deflate'; + url = appendQueryParam(url, 'compress', 'true'); } + // Begin the request + xhr.open(method, url); + xhr.withCredentials = true; + // Set the HTTP headers Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); diff --git a/src/plugins/bfetch/server/index.ts b/src/plugins/bfetch/server/index.ts index c533b2ad7a3df..f4c41d10e42cb 100644 --- a/src/plugins/bfetch/server/index.ts +++ b/src/plugins/bfetch/server/index.ts @@ -10,7 +10,6 @@ import { PluginInitializerContext } from '../../../core/server'; import { BfetchServerPlugin } from './plugin'; export { BfetchServerSetup, BfetchServerStart, BatchProcessingRouteParams } from './plugin'; -export { StreamingRequestHandler } from './types'; export function plugin(initializerContext: PluginInitializerContext) { return new BfetchServerPlugin(initializerContext); diff --git a/src/plugins/bfetch/server/mocks.ts b/src/plugins/bfetch/server/mocks.ts index bbb596bf8d5ff..dfa365d9e70b2 100644 --- a/src/plugins/bfetch/server/mocks.ts +++ b/src/plugins/bfetch/server/mocks.ts @@ -17,7 +17,6 @@ const createSetupContract = (): Setup => { const setupContract: Setup = { addBatchProcessingRoute: jest.fn(), addStreamingResponseRoute: jest.fn(), - createStreamingRequestHandler: jest.fn(), }; return setupContract; }; diff --git a/src/plugins/bfetch/server/plugin.ts b/src/plugins/bfetch/server/plugin.ts index 7b60be9a8fc75..f7127445f96c5 100644 --- a/src/plugins/bfetch/server/plugin.ts +++ b/src/plugins/bfetch/server/plugin.ts @@ -13,9 +13,6 @@ import type { Plugin, Logger, KibanaRequest, - RouteMethod, - RequestHandler, - RequestHandlerContext, StartServicesAccessor, } from 'src/core/server'; import { schema } from '@kbn/config-schema'; @@ -28,7 +25,6 @@ import { removeLeadingSlash, normalizeError, } from '../common'; -import { StreamingRequestHandler } from './types'; import { createStream } from './streaming'; import { getUiSettings } from './ui_settings'; @@ -52,44 +48,6 @@ export interface BfetchServerSetup { path: string, params: (request: KibanaRequest) => StreamingResponseHandler ) => void; - /** - * Create a streaming request handler to be able to use an Observable to return chunked content to the client. - * This is meant to be used with the `fetchStreaming` API of the `bfetch` client-side plugin. - * - * @example - * ```ts - * setup({ http }: CoreStart, { bfetch }: SetupDeps) { - * const router = http.createRouter(); - * router.post( - * { - * path: '/api/my-plugin/stream-endpoint, - * validate: { - * body: schema.object({ - * term: schema.string(), - * }), - * } - * }, - * bfetch.createStreamingResponseHandler(async (ctx, req) => { - * const { term } = req.body; - * const results$ = await myApi.getResults$(term); - * return results$; - * }) - * )} - * - * ``` - * - * @param streamHandler - */ - createStreamingRequestHandler: < - Response, - P, - Q, - B, - Context extends RequestHandlerContext = RequestHandlerContext, - Method extends RouteMethod = any - >( - streamHandler: StreamingRequestHandler - ) => RequestHandler; } // eslint-disable-next-line @@ -124,15 +82,10 @@ export class BfetchServerPlugin logger, }); const addBatchProcessingRoute = this.addBatchProcessingRoute(addStreamingResponseRoute); - const createStreamingRequestHandler = this.createStreamingRequestHandler({ - getStartServices: core.getStartServices, - logger, - }); return { addBatchProcessingRoute, addStreamingResponseRoute, - createStreamingRequestHandler, }; } @@ -142,10 +95,6 @@ export class BfetchServerPlugin public stop() {} - private getCompressionDisabled(request: KibanaRequest) { - return request.headers['x-chunk-encoding'] !== 'deflate'; - } - private addStreamingResponseRoute = ({ getStartServices, @@ -162,42 +111,21 @@ export class BfetchServerPlugin path: `/${removeLeadingSlash(path)}`, validate: { body: schema.any(), + query: schema.object({ compress: schema.boolean({ defaultValue: false }) }), }, }, async (context, request, response) => { const handlerInstance = handler(request); const data = request.body; - const compressionDisabled = this.getCompressionDisabled(request); + const compress = request.query.compress; return response.ok({ headers: streamingHeaders, - body: createStream( - handlerInstance.getResponseStream(data), - logger, - compressionDisabled - ), + body: createStream(handlerInstance.getResponseStream(data), logger, compress), }); } ); }; - private createStreamingRequestHandler = - ({ - logger, - getStartServices, - }: { - logger: Logger; - getStartServices: StartServicesAccessor; - }): BfetchServerSetup['createStreamingRequestHandler'] => - (streamHandler) => - async (context, request, response) => { - const response$ = await streamHandler(context, request); - const compressionDisabled = this.getCompressionDisabled(request); - return response.ok({ - headers: streamingHeaders, - body: createStream(response$, logger, compressionDisabled), - }); - }; - private addBatchProcessingRoute = ( addStreamingResponseRoute: BfetchServerSetup['addStreamingResponseRoute'] diff --git a/src/plugins/bfetch/server/streaming/create_stream.ts b/src/plugins/bfetch/server/streaming/create_stream.ts index 7d6981294341b..756a806a60229 100644 --- a/src/plugins/bfetch/server/streaming/create_stream.ts +++ b/src/plugins/bfetch/server/streaming/create_stream.ts @@ -15,9 +15,9 @@ import { createNDJSONStream } from './create_ndjson_stream'; export function createStream( response$: Observable, logger: Logger, - compressionDisabled: boolean + compress: boolean ): Stream { - return compressionDisabled - ? createNDJSONStream(response$, logger) - : createCompressedStream(response$, logger); + return compress + ? createCompressedStream(response$, logger) + : createNDJSONStream(response$, logger); } diff --git a/src/plugins/bfetch/server/types.ts b/src/plugins/bfetch/server/types.ts deleted file mode 100644 index 4e54744f4c374..0000000000000 --- a/src/plugins/bfetch/server/types.ts +++ /dev/null @@ -1,27 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { Observable } from 'rxjs'; -import { KibanaRequest, RequestHandlerContext, RouteMethod } from 'kibana/server'; - -/** - * Request handler modified to allow to return an observable. - * - * See {@link BfetchServerSetup.createStreamingRequestHandler} for usage example. - * @public - */ -export type StreamingRequestHandler< - Response = unknown, - P = unknown, - Q = unknown, - B = unknown, - Method extends RouteMethod = any -> = ( - context: RequestHandlerContext, - request: KibanaRequest -) => Observable | Promise>; diff --git a/test/api_integration/apis/search/bsearch.ts b/test/api_integration/apis/search/bsearch.ts index f80bc1d0d9dfa..6aee2b542da0f 100644 --- a/test/api_integration/apis/search/bsearch.ts +++ b/test/api_integration/apis/search/bsearch.ts @@ -29,28 +29,25 @@ export default function ({ getService }: FtrProviderContext) { describe('bsearch', () => { describe('post', () => { it('should return 200 a single response', async () => { - const resp = await supertest - .post(`/internal/bsearch`) - .set({ 'X-Chunk-Encoding': '' }) - .send({ - batch: [ - { - request: { - params: { - index: '.kibana', - body: { - query: { - match_all: {}, - }, + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + index: '.kibana', + body: { + query: { + match_all: {}, }, }, }, - options: { - strategy: 'es', - }, }, - ], - }); + options: { + strategy: 'es', + }, + }, + ], + }); const jsonBody = parseBfetchResponse(resp); @@ -62,28 +59,25 @@ export default function ({ getService }: FtrProviderContext) { }); it('should return 200 a single response from compressed', async () => { - const resp = await supertest - .post(`/internal/bsearch`) - .set({ 'X-Chunk-Encoding': 'deflate' }) - .send({ - batch: [ - { - request: { - params: { - index: '.kibana', - body: { - query: { - match_all: {}, - }, + const resp = await supertest.post(`/internal/bsearch?compress=true`).send({ + batch: [ + { + request: { + params: { + index: '.kibana', + body: { + query: { + match_all: {}, }, }, }, - options: { - strategy: 'es', - }, }, - ], - }); + options: { + strategy: 'es', + }, + }, + ], + }); const jsonBody = parseBfetchResponse(resp, true); diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 1842e278282b1..5a015bddc8fbc 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -146,13 +146,14 @@ def functionalXpack(Map params = [:]) { } } - whenChanged([ - 'x-pack/plugins/apm/', - ]) { - if (githubPr.isPr()) { - task(kibanaPipeline.functionalTestProcess('xpack-APMCypress', './test/scripts/jenkins_apm_cypress.sh')) - } - } + //temporarily disable apm e2e test since it's breaking. + // whenChanged([ + // 'x-pack/plugins/apm/', + // ]) { + // if (githubPr.isPr()) { + // task(kibanaPipeline.functionalTestProcess('xpack-APMCypress', './test/scripts/jenkins_apm_cypress.sh')) + // } + // } whenChanged([ 'x-pack/plugins/uptime/', diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 58d2ca35dea7e..343960aee9dfb 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -122,6 +122,8 @@ The following table describes the properties of the `options` object. |useSavedObjectReferences.extractReferences|(Optional) When developing a rule type, you can choose to implement hooks for extracting saved object references from rule parameters. This hook will be invoked when a rule is created or updated. Implementing this hook is optional, but if an extract hook is implemented, an inject hook must also be implemented.|Function |useSavedObjectReferences.injectReferences|(Optional) When developing a rule type, you can choose to implement hooks for injecting saved object references into rule parameters. This hook will be invoked when a rule is retrieved (get or find). Implementing this hook is optional, but if an inject hook is implemented, an extract hook must also be implemented.|Function |isExportable|Whether the rule type is exportable from the Saved Objects Management UI.|boolean| +|defaultScheduleInterval|The default interval that will show up in the UI when creating a rule of this rule type.|boolean| +|minimumScheduleInterval|The minimum interval that will be allowed for all rules of this rule type.|boolean| ### Executor diff --git a/x-pack/plugins/alerting/common/alert_type.ts b/x-pack/plugins/alerting/common/alert_type.ts index e56034a4c41f8..d71540b4418e8 100644 --- a/x-pack/plugins/alerting/common/alert_type.ts +++ b/x-pack/plugins/alerting/common/alert_type.ts @@ -21,6 +21,8 @@ export interface AlertType< producer: string; minimumLicenseRequired: LicenseType; isExportable: boolean; + defaultScheduleInterval?: string; + minimumScheduleInterval?: string; } export interface ActionGroup { diff --git a/x-pack/plugins/alerting/server/routes/rule_types.test.ts b/x-pack/plugins/alerting/server/routes/rule_types.test.ts index 2e8f43508a969..e4247c9de6cad 100644 --- a/x-pack/plugins/alerting/server/routes/rule_types.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule_types.test.ts @@ -57,6 +57,8 @@ describe('ruleTypesRoute', () => { }, producer: 'test', enabledInLicense: true, + minimumScheduleInterval: '1m', + defaultScheduleInterval: '10m', } as RegistryAlertTypeWithAuth, ]; const expectedResult: Array> = [ @@ -70,7 +72,9 @@ describe('ruleTypesRoute', () => { }, ], default_action_group_id: 'default', + default_schedule_interval: '10m', minimum_license_required: 'basic', + minimum_schedule_interval: '1m', is_exportable: true, recovery_action_group: RecoveredActionGroup, authorized_consumers: {}, @@ -102,10 +106,12 @@ describe('ruleTypesRoute', () => { }, "authorized_consumers": Object {}, "default_action_group_id": "default", + "default_schedule_interval": "10m", "enabled_in_license": true, "id": "1", "is_exportable": true, "minimum_license_required": "basic", + "minimum_schedule_interval": "1m", "name": "name", "producer": "test", "recovery_action_group": Object { diff --git a/x-pack/plugins/alerting/server/routes/rule_types.ts b/x-pack/plugins/alerting/server/routes/rule_types.ts index 153ae96ff68ea..72502b25e9aff 100644 --- a/x-pack/plugins/alerting/server/routes/rule_types.ts +++ b/x-pack/plugins/alerting/server/routes/rule_types.ts @@ -22,6 +22,8 @@ const rewriteBodyRes: RewriteResponseCase = (result isExportable, actionVariables, authorizedConsumers, + minimumScheduleInterval, + defaultScheduleInterval, ...rest }) => ({ ...rest, @@ -33,6 +35,8 @@ const rewriteBodyRes: RewriteResponseCase = (result is_exportable: isExportable, action_variables: actionVariables, authorized_consumers: authorizedConsumers, + minimum_schedule_interval: minimumScheduleInterval, + default_schedule_interval: defaultScheduleInterval, }) ); }; diff --git a/x-pack/plugins/alerting/server/rule_type_registry.test.ts b/x-pack/plugins/alerting/server/rule_type_registry.test.ts index 1c44e862c261c..beb5f264eb725 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.test.ts @@ -114,7 +114,7 @@ describe('register()', () => { test('throws if AlertType ruleTaskTimeout is not a valid duration', () => { const alertType: AlertType = { - id: 123 as unknown as string, + id: '123', name: 'Test', actionGroups: [ { @@ -138,6 +138,59 @@ describe('register()', () => { ); }); + test('throws if defaultScheduleInterval isnt valid', () => { + const alertType: AlertType = { + id: '123', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + defaultScheduleInterval: 'foobar', + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + + expect(() => registry.register(alertType)).toThrowError( + new Error( + `Rule type \"123\" has invalid default interval: string is not a valid duration: foobar.` + ) + ); + }); + + test('throws if minimumScheduleInterval isnt valid', () => { + const alertType: AlertType = { + id: '123', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + minimumScheduleInterval: 'foobar', + }; + const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + + expect(() => registry.register(alertType)).toThrowError( + new Error( + `Rule type \"123\" has invalid minimum interval: string is not a valid duration: foobar.` + ) + ); + }); + test('throws if RuleType action groups contains reserved group id', () => { const alertType: AlertType = { id: 'test', @@ -465,10 +518,12 @@ describe('list()', () => { "state": Array [], }, "defaultActionGroupId": "testActionGroup", + "defaultScheduleInterval": undefined, "enabledInLicense": false, "id": "test", "isExportable": true, "minimumLicenseRequired": "basic", + "minimumScheduleInterval": undefined, "name": "Test", "producer": "alerts", "recoveryActionGroup": Object { diff --git a/x-pack/plugins/alerting/server/rule_type_registry.ts b/x-pack/plugins/alerting/server/rule_type_registry.ts index dc72b644b2c7b..db02edf4d19dd 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.ts @@ -48,6 +48,8 @@ export interface RegistryRuleType | 'producer' | 'minimumLicenseRequired' | 'isExportable' + | 'minimumScheduleInterval' + | 'defaultScheduleInterval' > { id: string; enabledInLicense: boolean; @@ -188,6 +190,44 @@ export class RuleTypeRegistry { } alertType.actionVariables = normalizedActionVariables(alertType.actionVariables); + // validate defaultScheduleInterval here + if (alertType.defaultScheduleInterval) { + const invalidDefaultTimeout = validateDurationSchema(alertType.defaultScheduleInterval); + if (invalidDefaultTimeout) { + throw new Error( + i18n.translate( + 'xpack.alerting.ruleTypeRegistry.register.invalidDefaultTimeoutAlertTypeError', + { + defaultMessage: 'Rule type "{id}" has invalid default interval: {errorMessage}.', + values: { + id: alertType.id, + errorMessage: invalidDefaultTimeout, + }, + } + ) + ); + } + } + + // validate minimumScheduleInterval here + if (alertType.minimumScheduleInterval) { + const invalidMinimumTimeout = validateDurationSchema(alertType.minimumScheduleInterval); + if (invalidMinimumTimeout) { + throw new Error( + i18n.translate( + 'xpack.alerting.ruleTypeRegistry.register.invalidMinimumTimeoutAlertTypeError', + { + defaultMessage: 'Rule type "{id}" has invalid minimum interval: {errorMessage}.', + values: { + id: alertType.id, + errorMessage: invalidMinimumTimeout, + }, + } + ) + ); + } + } + const normalizedAlertType = augmentActionGroupsWithReserved< Params, ExtractedParams, @@ -287,6 +327,8 @@ export class RuleTypeRegistry { producer, minimumLicenseRequired, isExportable, + minimumScheduleInterval, + defaultScheduleInterval, }, ]: [string, UntypedNormalizedAlertType]) => ({ id, @@ -298,6 +340,8 @@ export class RuleTypeRegistry { producer, minimumLicenseRequired, isExportable, + minimumScheduleInterval, + defaultScheduleInterval, enabledInLicense: !!this.licenseState.getLicenseCheckForAlertType( id, name, diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 231d19ce9a6f8..2228b5d27910f 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -296,6 +296,17 @@ export class RulesClient { await this.validateActions(ruleType, data.actions); + // Validate intervals, if configured + if (ruleType.minimumScheduleInterval) { + const intervalInMs = parseDuration(data.schedule.interval); + const minimumScheduleIntervalInMs = parseDuration(ruleType.minimumScheduleInterval); + if (intervalInMs < minimumScheduleIntervalInMs) { + throw Boom.badRequest( + `Error updating rule: the interval is less than the minimum interval of ${ruleType.minimumScheduleInterval}` + ); + } + } + // Extract saved object references for this rule const { references, @@ -847,6 +858,17 @@ export class RulesClient { ); await this.validateActions(ruleType, data.actions); + // Validate intervals, if configured + if (ruleType.minimumScheduleInterval) { + const intervalInMs = parseDuration(data.schedule.interval); + const minimumScheduleIntervalInMs = parseDuration(ruleType.minimumScheduleInterval); + if (intervalInMs < minimumScheduleIntervalInMs) { + throw Boom.badRequest( + `Error updating rule: the interval is less than the minimum interval of ${ruleType.minimumScheduleInterval}` + ); + } + } + // Extract saved object references for this rule const { references, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index 2bb92046db68f..fc8f272702e0d 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -2268,4 +2268,30 @@ describe('create()', () => { expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); }); + + test('throws error when updating with an interval less than the minimum configured one', async () => { + ruleTypeRegistry.get.mockImplementation(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + minimumScheduleInterval: '5m', + useSavedObjectReferences: { + extractReferences: jest.fn(), + injectReferences: jest.fn(), + }, + })); + + const data = getMockData({ schedule: { interval: '1m' } }); + await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error updating rule: the interval is less than the minimum interval of 5m"` + ); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); + expect(taskManager.schedule).not.toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index 1328b666f96e7..55ffc49fd3394 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -140,6 +140,7 @@ describe('update()', () => { recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'alerts', + minimumScheduleInterval: '5s', }); }); @@ -1966,4 +1967,49 @@ describe('update()', () => { ); }); }); + + test('throws error when updating with an interval less than the minimum configured one', async () => { + await expect( + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + ], + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error updating rule: the interval is less than the minimum interval of 5s"` + ); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); + expect(taskManager.schedule).not.toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index c73ce86acf785..1dc8291d28756 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -157,6 +157,8 @@ export interface AlertType< injectReferences: (params: ExtractedParams, references: SavedObjectReference[]) => Params; }; isExportable: boolean; + defaultScheduleInterval?: string; + minimumScheduleInterval?: string; ruleTaskTimeout?: string; } export type UntypedAlertType = AlertType< diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index b988f13ee34ce..b55542499fbe4 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -239,6 +239,29 @@ describe('Create case', () => { ); }); + it('should set sync alerts to false when the sync setting is passed in as false and alerts are disabled', async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => + expect(postCase).toBeCalledWith({ ...sampleData, settings: { syncAlerts: false } }) + ); + }); + it('it should select the default connector set in the configuration', async () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index f59e1822c70be..03d8ec56fb0ae 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -34,6 +34,7 @@ interface Props { children?: JSX.Element | JSX.Element[]; hideConnectorServiceNowSir?: boolean; onSuccess?: (theCase: Case) => Promise; + syncAlertsDefaultValue?: boolean; } export const FormContext: React.FC = ({ @@ -42,6 +43,7 @@ export const FormContext: React.FC = ({ children, hideConnectorServiceNowSir, onSuccess, + syncAlertsDefaultValue = true, }) => { const { connectors, loading: isLoadingConnectors } = useConnectors(); const owner = useOwnerContext(); @@ -51,7 +53,12 @@ export const FormContext: React.FC = ({ const submitCase = useCallback( async ( - { connectorId: dataConnectorId, fields, syncAlerts = true, ...dataWithoutConnectorId }, + { + connectorId: dataConnectorId, + fields, + syncAlerts = syncAlertsDefaultValue, + ...dataWithoutConnectorId + }, isValid ) => { if (isValid) { @@ -94,6 +101,7 @@ export const FormContext: React.FC = ({ onSuccess, postComment, pushCaseToExternalService, + syncAlertsDefaultValue, ] ); diff --git a/x-pack/plugins/cases/public/components/create/index.tsx b/x-pack/plugins/cases/public/components/create/index.tsx index 7f8b8f664529e..d3eaba1ea0bc4 100644 --- a/x-pack/plugins/cases/public/components/create/index.tsx +++ b/x-pack/plugins/cases/public/components/create/index.tsx @@ -58,6 +58,8 @@ const CreateCaseComponent = ({ caseType={caseType} hideConnectorServiceNowSir={hideConnectorServiceNowSir} onSuccess={onSuccess} + // if we are disabling alerts, then we should not sync alerts + syncAlertsDefaultValue={!disableAlerts} > ', () => { }); }); - describe('detail panel', () => { + // FLAKY: https://github.com/elastic/kibana/issues/100951 + describe.skip('detail panel', () => { test('should open a detail panel when clicking on a follower index', async () => { expect(exists('followerIndexDetail')).toBe(false); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx index 9d585789d8e50..c46c360934d0b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx @@ -37,7 +37,7 @@ describe('CrawlerStatusIndicator', () => { describe('when status is not a valid status', () => { it('is disabled', () => { // this tests a codepath that should be impossible to reach, status should always be a CrawlerStatus - // but we use a switch statement and need to test the default case for this to recieve 100% coverage + // but we use a switch statement and need to test the default case for this to receive 100% coverage setMockValues({ ...MOCK_VALUES, mostRecentCrawlRequestStatus: null, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts index 4fe8ad1cb851d..abaa67e06f606 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts @@ -14,7 +14,7 @@ import { IFlashMessage } from './types'; /** * The API errors we are handling can come from one of two ways: - * - When our http calls recieve a response containing an error code, such as a 404 or 500 + * - When our http calls receive a response containing an error code, such as a 404 or 500 * - Our own JS while handling a successful response * * In the first case, if it is a purposeful error (like a 404) we will receive an diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 7a2f46247d14a..4d940534c4a7b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -687,7 +687,7 @@ const UpgradeStatusCallout: React.FunctionComponent<{

diff --git a/x-pack/plugins/monitoring/common/types/alerts.ts b/x-pack/plugins/monitoring/common/types/alerts.ts index 1f68b0c55a046..bbd217169469d 100644 --- a/x-pack/plugins/monitoring/common/types/alerts.ts +++ b/x-pack/plugins/monitoring/common/types/alerts.ts @@ -32,6 +32,7 @@ export interface CommonAlertState { export interface CommonAlertFilter { nodeUuid?: string; shardId?: string; + shardIndex?: string; } export interface CommonAlertParamDetail { diff --git a/x-pack/plugins/monitoring/public/alerts/alerts_dropdown.tsx b/x-pack/plugins/monitoring/public/alerts/alerts_dropdown.tsx index 261685a532882..976569f39de4c 100644 --- a/x-pack/plugins/monitoring/public/alerts/alerts_dropdown.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alerts_dropdown.tsx @@ -14,13 +14,12 @@ import { import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Legacy } from '../legacy_shims'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { MonitoringStartPluginDependencies } from '../types'; +import { useAlertsModal } from '../application/hooks/use_alerts_modal'; export const AlertsDropdown: React.FC<{}> = () => { - const $injector = Legacy.shims.getAngularInjector(); - const alertsEnableModalProvider: any = $injector.get('enableAlertsModal'); + const alertsEnableModalProvider = useAlertsModal(); const { navigateToApp } = useKibana().services.application; diff --git a/x-pack/plugins/monitoring/public/application/external_config_context.tsx b/x-pack/plugins/monitoring/public/application/contexts/external_config_context.tsx similarity index 100% rename from x-pack/plugins/monitoring/public/application/external_config_context.tsx rename to x-pack/plugins/monitoring/public/application/contexts/external_config_context.tsx diff --git a/x-pack/plugins/monitoring/public/application/global_state_context.tsx b/x-pack/plugins/monitoring/public/application/contexts/global_state_context.tsx similarity index 90% rename from x-pack/plugins/monitoring/public/application/global_state_context.tsx rename to x-pack/plugins/monitoring/public/application/contexts/global_state_context.tsx index 6c952f80eff57..e6638b4c4fede 100644 --- a/x-pack/plugins/monitoring/public/application/global_state_context.tsx +++ b/x-pack/plugins/monitoring/public/application/contexts/global_state_context.tsx @@ -5,10 +5,10 @@ * 2.0. */ import React, { createContext } from 'react'; -import { GlobalState } from '../url_state'; -import { MonitoringStartPluginDependencies } from '../types'; -import { TimeRange, RefreshInterval } from '../../../../../src/plugins/data/public'; -import { Legacy } from '../legacy_shims'; +import { GlobalState } from '../../url_state'; +import { MonitoringStartPluginDependencies } from '../../types'; +import { TimeRange, RefreshInterval } from '../../../../../../src/plugins/data/public'; +import { Legacy } from '../../legacy_shims'; interface GlobalStateProviderProps { query: MonitoringStartPluginDependencies['data']['query']; diff --git a/x-pack/plugins/monitoring/public/components/table/index.d.ts b/x-pack/plugins/monitoring/public/application/contexts/header_action_menu_context.tsx similarity index 50% rename from x-pack/plugins/monitoring/public/components/table/index.d.ts rename to x-pack/plugins/monitoring/public/application/contexts/header_action_menu_context.tsx index 23406ba9e3a5e..88862d9e6a807 100644 --- a/x-pack/plugins/monitoring/public/components/table/index.d.ts +++ b/x-pack/plugins/monitoring/public/application/contexts/header_action_menu_context.tsx @@ -5,6 +5,11 @@ * 2.0. */ -export const euiTableStorageGetter: (string) => any; -export const euiTableStorageSetter: (string) => any; -export const EuiMonitoringTable: FunctionComponent; +import React from 'react'; +import { AppMountParameters } from 'kibana/public'; + +interface ContextProps { + setHeaderActionMenu?: AppMountParameters['setHeaderActionMenu']; +} + +export const HeaderActionMenuContext = React.createContext({}); diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts b/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts index 9a2a2b80cc40f..123dd39f7b54d 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts @@ -6,10 +6,11 @@ */ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { showAlertsToast } from '../../alerts/lib/alerts_toast'; -import { ajaxErrorHandlersProvider } from '../../lib/ajax_error_handler'; +import { useRequestErrorHandler } from './use_request_error_handler'; export const useAlertsModal = () => { const { services } = useKibana(); + const handleRequestError = useRequestErrorHandler(); function shouldShowAlertsModal(alerts: {}) { const modalHasBeenShown = @@ -28,12 +29,11 @@ export const useAlertsModal = () => { async function enableAlerts() { try { - const { data } = await services.http?.post('../api/monitoring/v1/alerts/enable', {}); + const response = await services.http?.post('../api/monitoring/v1/alerts/enable', {}); window.localStorage.setItem('ALERTS_MODAL_DECISION_MADE', 'true'); - showAlertsToast(data); + showAlertsToast(response); } catch (err) { - const ajaxErrorHandlers = ajaxErrorHandlersProvider(); - return ajaxErrorHandlers(err); + await handleRequestError(err); } } diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts b/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts index b4b8c21ca4d40..1961bd53b909f 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts @@ -7,6 +7,7 @@ import { useState, useEffect } from 'react'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { fetchClusters } from '../../lib/fetch_clusters'; +import { useRequestErrorHandler } from './use_request_error_handler'; export function useClusters(clusterUuid?: string | null, ccs?: any, codePaths?: string[]) { const { services } = useKibana<{ data: any }>(); @@ -17,6 +18,7 @@ export function useClusters(clusterUuid?: string | null, ccs?: any, codePaths?: const [clusters, setClusters] = useState([] as any); const [loaded, setLoaded] = useState(false); + const handleRequestError = useRequestErrorHandler(); useEffect(() => { async function makeRequest() { @@ -34,13 +36,13 @@ export function useClusters(clusterUuid?: string | null, ccs?: any, codePaths?: setClusters(response); } } catch (e) { - // TODO: Handle errors + handleRequestError(e); } finally { setLoaded(true); } } makeRequest(); - }, [clusterUuid, ccs, services.http, codePaths, min, max]); + }, [handleRequestError, clusterUuid, ccs, services.http, codePaths, min, max]); return { clusters, loaded }; } diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_monitoring_time.ts b/x-pack/plugins/monitoring/public/application/hooks/use_monitoring_time.ts index 3054714ec3aa6..e8973ce18232c 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_monitoring_time.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_monitoring_time.ts @@ -8,7 +8,7 @@ import { useCallback, useState, useContext, useEffect } from 'react'; import createContainer from 'constate'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { Legacy } from '../../legacy_shims'; -import { GlobalStateContext } from '../../application/global_state_context'; +import { GlobalStateContext } from '../contexts/global_state_context'; interface TimeOptions { from: string; diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.tsx b/x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.tsx new file mode 100644 index 0000000000000..3a64531844451 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useCallback } from 'react'; +import { includes } from 'lodash'; +import { IHttpFetchError } from 'kibana/public'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; +import { formatMsg } from '../../../../../../src/plugins/kibana_legacy/public'; +import { toMountPoint, useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { MonitoringStartPluginDependencies } from '../../types'; + +export function formatMonitoringError(err: IHttpFetchError) { + if (err.response?.status && err.response?.status !== -1) { + return ( + +

{err.body?.message}

+ + + +
+ ); + } + + return formatMsg(err); +} + +export const useRequestErrorHandler = () => { + const { services } = useKibana(); + return useCallback( + (err: IHttpFetchError) => { + if (err.response?.status === 403) { + // redirect to error message view + history.replaceState(null, '', '#/access-denied'); + } else if (err.response?.status === 404 && !includes(window.location.hash, 'no-data')) { + // pass through if this is a 404 and we're already on the no-data page + const formattedError = formatMonitoringError(err); + services.notifications?.toasts.addDanger({ + title: toMountPoint( + + ), + text: toMountPoint( +
+ {formattedError} + + window.location.reload()}> + + +
+ ), + }); + } else { + services.notifications?.toasts.addDanger({ + title: toMountPoint( + + ), + text: toMountPoint(formatMonitoringError(err)), + }); + } + }, + [services.notifications] + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_table.ts b/x-pack/plugins/monitoring/public/application/hooks/use_table.ts index 2e6018ec89809..be1983c373c27 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_table.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_table.ts @@ -6,8 +6,10 @@ */ import { useState, useCallback } from 'react'; +import { EuiTableSortingType } from '@elastic/eui'; import { euiTableStorageGetter, euiTableStorageSetter } from '../../components/table'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { EUI_SORT_ASCENDING } from '../../../common/constants'; interface Pagination { pageSize: number; @@ -23,12 +25,7 @@ interface Page { index: number; } -interface Sorting { - sort: { - field: string; - direction: string; - }; -} +type Sorting = EuiTableSortingType; const PAGE_SIZE_OPTIONS = [5, 10, 20, 50]; @@ -81,7 +78,9 @@ export function useTable(storageKey: string) { ); // get initial state from localStorage - const [sorting, setSorting] = useState(storageData.sort || { sort: {} }); + const [sorting, setSorting] = useState( + storageData.sort || { sort: { field: 'name', direction: EUI_SORT_ASCENDING } } + ); const [query, setQuery] = useState(''); diff --git a/x-pack/plugins/monitoring/public/application/index.tsx b/x-pack/plugins/monitoring/public/application/index.tsx index bc81dd826f849..7b4c73475338f 100644 --- a/x-pack/plugins/monitoring/public/application/index.tsx +++ b/x-pack/plugins/monitoring/public/application/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { CoreStart, AppMountParameters } from 'kibana/public'; +import { CoreStart, AppMountParameters, MountPoint } from 'kibana/public'; import React from 'react'; import ReactDOM from 'react-dom'; import { Route, Switch, Redirect, Router } from 'react-router-dom'; @@ -15,8 +15,8 @@ import { LicensePage } from './pages/license_page'; import { ClusterOverview } from './pages/cluster/overview_page'; import { ClusterListing } from './pages/home/cluster_listing'; import { MonitoringStartPluginDependencies } from '../types'; -import { GlobalStateProvider } from './global_state_context'; -import { ExternalConfigContext, ExternalConfig } from './external_config_context'; +import { GlobalStateProvider } from './contexts/global_state_context'; +import { ExternalConfigContext, ExternalConfig } from './contexts/external_config_context'; import { createPreserveQueryHistory } from './preserve_query_history'; import { RouteInit } from './route_init'; import { NoDataPage } from './pages/no_data'; @@ -45,6 +45,7 @@ import { ElasticsearchCcrPage } from './pages/elasticsearch/ccr_page'; import { ElasticsearchCcrShardPage } from './pages/elasticsearch/ccr_shard_page'; import { MonitoringTimeContainer } from './hooks/use_monitoring_time'; import { BreadcrumbContainer } from './hooks/use_breadcrumbs'; +import { HeaderActionMenuContext } from './contexts/header_action_menu_context'; import { LogStashOverviewPage } from './pages/logstash/overview'; import { LogStashNodesPage } from './pages/logstash/nodes'; import { LogStashPipelinesPage } from './pages/logstash/pipelines'; @@ -58,11 +59,16 @@ import { LogStashNodePipelinesPage } from './pages/logstash/node_pipelines'; export const renderApp = ( core: CoreStart, plugins: MonitoringStartPluginDependencies, - { element }: AppMountParameters, + { element, setHeaderActionMenu }: AppMountParameters, externalConfig: ExternalConfig ) => { ReactDOM.render( - , + , element ); @@ -75,236 +81,239 @@ const MonitoringApp: React.FC<{ core: CoreStart; plugins: MonitoringStartPluginDependencies; externalConfig: ExternalConfig; -}> = ({ core, plugins, externalConfig }) => { + setHeaderActionMenu: (element: MountPoint | undefined) => void; +}> = ({ core, plugins, externalConfig, setHeaderActionMenu }) => { const history = createPreserveQueryHistory(); return ( - - - - - - - - - - - {/* ElasticSearch Views */} - - - - - - - - - - - - - - - - - - - - - {/* Kibana Views */} - - - - - - - {/* Beats Views */} - - - - - - - {/* Logstash Routes */} - - - - - - - - - - - - - - - {/* APM Views */} - - - - - - - - - - - + + + + + + + + + + + + {/* ElasticSearch Views */} + + + + + + + + + + + + + + + + + + + + + {/* Kibana Views */} + + + + + + + {/* Beats Views */} + + + + + + + {/* Logstash Routes */} + + + + + + + + + + + + + + + {/* APM Views */} + + + + + + + + + + + + diff --git a/x-pack/plugins/monitoring/public/application/pages/apm/instance.tsx b/x-pack/plugins/monitoring/public/application/pages/apm/instance.tsx index dc55ecb22b61a..3fa7819c5e417 100644 --- a/x-pack/plugins/monitoring/public/application/pages/apm/instance.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/apm/instance.tsx @@ -10,7 +10,7 @@ import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useCharts } from '../../hooks/use_charts'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; diff --git a/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx index bc60f26cdbfad..fedb07fa65a40 100644 --- a/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx @@ -9,7 +9,7 @@ import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useTable } from '../../hooks/use_table'; import { ApmTemplate } from './apm_template'; diff --git a/x-pack/plugins/monitoring/public/application/pages/apm/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/apm/overview.tsx index cca31c0a7e65d..516c293c53546 100644 --- a/x-pack/plugins/monitoring/public/application/pages/apm/overview.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/apm/overview.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; import { ApmTemplate } from './apm_template'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useCharts } from '../../hooks/use_charts'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx index f7ff03898fda6..4c66bbba631fb 100644 --- a/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx @@ -10,7 +10,7 @@ import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useCharts } from '../../hooks/use_charts'; // @ts-ignore diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx index 18f941c398af0..489ad110c40fd 100644 --- a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx @@ -9,7 +9,7 @@ import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useTable } from '../../hooks/use_table'; import { BeatsTemplate } from './beats_template'; diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/overview.tsx index 8d28119c4ec1b..1fa37a2c7b3e6 100644 --- a/x-pack/plugins/monitoring/public/application/pages/beats/overview.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/beats/overview.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; import { BeatsTemplate } from './beats_template'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useCharts } from '../../hooks/use_charts'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; // @ts-ignore diff --git a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx index 3a717036396e9..b78df27cd12c4 100644 --- a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx @@ -10,14 +10,17 @@ import { i18n } from '@kbn/i18n'; import { CODE_PATH_ALL } from '../../../../common/constants'; import { PageTemplate } from '../page_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { TabMenuItem } from '../page_template'; import { Overview } from '../../../components/cluster/overview'; -import { ExternalConfigContext } from '../../external_config_context'; +import { ExternalConfigContext } from '../../contexts/external_config_context'; import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; import { fetchClusters } from '../../../lib/fetch_clusters'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { EnableAlertsModal } from '../../../alerts/enable_alerts_modal'; const CODE_PATHS = [CODE_PATH_ALL]; @@ -28,6 +31,7 @@ export const ClusterOverview: React.FC<{}> = () => { const clusterUuid = state.cluster_uuid; const ccs = state.ccs; const [clusters, setClusters] = useState([] as any); + const [alerts, setAlerts] = useState({}); const [loaded, setLoaded] = useState(false); const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); @@ -54,23 +58,27 @@ export const ClusterOverview: React.FC<{}> = () => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); - try { - if (services.http?.fetch) { - const response = await fetchClusters({ - fetch: services.http.fetch, - timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), - }, - ccs, - clusterUuid, - codePaths: CODE_PATHS, - }); - setClusters(response); - } - } catch (err) { - // TODO: handle errors - } finally { + if (services.http?.fetch && clusterUuid) { + const response = await fetchClusters({ + fetch: services.http.fetch, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + ccs, + clusterUuid, + codePaths: CODE_PATHS, + }); + setClusters(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + clusterUuid, + timeRange: { + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), + }, + }); + setAlerts(alertsResponse); setLoaded(true); } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); @@ -89,7 +97,7 @@ export const ClusterOverview: React.FC<{}> = () => { {flyoutComponent} @@ -98,6 +106,7 @@ export const ClusterOverview: React.FC<{}> = () => { )} /> + ); }; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx index 294aeade5e38b..8a9a736286c3f 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx @@ -9,13 +9,15 @@ import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ElasticsearchTemplate } from './elasticsearch_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; // @ts-ignore import { Ccr } from '../../../components/elasticsearch/ccr'; import { ComponentProps } from '../../route_init'; import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { ELASTICSEARCH_SYSTEM_ID } from '../../../../common/constants'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { ELASTICSEARCH_SYSTEM_ID, RULE_CCR_READ_EXCEPTIONS } from '../../../../common/constants'; interface SetupModeProps { setupMode: any; @@ -33,6 +35,7 @@ export const ElasticsearchCcrPage: React.FC = ({ clusters }) => }) as any; const ccs = globalState.ccs; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.ccr.title', { defaultMessage: 'Elasticsearch - Ccr', @@ -46,18 +49,30 @@ export const ElasticsearchCcrPage: React.FC = ({ clusters }) => const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/ccr`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_CCR_READ_EXCEPTIONS], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); return ( @@ -73,7 +88,7 @@ export const ElasticsearchCcrPage: React.FC = ({ clusters }) => render={({ flyoutComponent, bottomBarComponent }: SetupModeProps) => ( {flyoutComponent} - + {bottomBarComponent} )} diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx index bec2f278f1774..21f9fd10f0806 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx @@ -10,13 +10,15 @@ import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import { PageTemplate } from '../page_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; // @ts-ignore import { CcrShardReact } from '../../../components/elasticsearch/ccr_shard'; import { ComponentProps } from '../../route_init'; import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { ELASTICSEARCH_SYSTEM_ID } from '../../../../common/constants'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { ELASTICSEARCH_SYSTEM_ID, RULE_CCR_READ_EXCEPTIONS } from '../../../../common/constants'; interface SetupModeProps { setupMode: any; @@ -24,7 +26,7 @@ interface SetupModeProps { bottomBarComponent: any; } -export const ElasticsearchCcrShardPage: React.FC = ({ clusters }) => { +export const ElasticsearchCcrShardPage: React.FC = () => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); const { index, shardId }: { index: string; shardId: string } = useParams(); @@ -32,6 +34,7 @@ export const ElasticsearchCcrShardPage: React.FC = ({ clusters } const clusterUuid = globalState.cluster_uuid; const ccs = globalState.ccs; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.ccr.shard.title', { defaultMessage: 'Elasticsearch - Ccr - Shard', @@ -57,18 +60,34 @@ export const ElasticsearchCcrShardPage: React.FC = ({ clusters } const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/ccr/${index}/shard/${shardId}`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_CCR_READ_EXCEPTIONS], + clusterUuid, + filters: [ + { + shardId, + }, + ], timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http, index, shardId]); return ( @@ -84,7 +103,7 @@ export const ElasticsearchCcrShardPage: React.FC = ({ clusters } render={({ flyoutComponent, bottomBarComponent }: SetupModeProps) => ( {flyoutComponent} - + {bottomBarComponent} )} diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx index a635d98fcbbb0..86dba4e2f921c 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx @@ -8,7 +8,7 @@ import React, { useContext, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { useParams } from 'react-router-dom'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; @@ -16,15 +16,18 @@ import { useCharts } from '../../hooks/use_charts'; import { ItemTemplate } from './item_template'; // @ts-ignore import { AdvancedIndex } from '../../../components/elasticsearch/index/advanced'; -import { ELASTICSEARCH_SYSTEM_ID } from '../../../../common/constants'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { ELASTICSEARCH_SYSTEM_ID, RULE_LARGE_SHARD_SIZE } from '../../../../common/constants'; -export const ElasticsearchIndexAdvancedPage: React.FC = ({ clusters }) => { +export const ElasticsearchIndexAdvancedPage: React.FC = () => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); const { index }: { index: string } = useParams(); const { zoomInfo, onBrush } = useCharts(); const clusterUuid = globalState.cluster_uuid; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.index.advanced.title', { defaultMessage: 'Elasticsearch - Indices - {indexName} - Advanced', @@ -36,17 +39,34 @@ export const ElasticsearchIndexAdvancedPage: React.FC = ({ clust const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices/${index}`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: true, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_LARGE_SHARD_SIZE], + filters: [ + { + shardIndex: index, + }, + ], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: true, - }), - }); - setData(response); + }); + setAlerts(alertsResponse); + } }, [clusterUuid, services.data?.query.timefilter.timefilter, services.http, index]); return ( @@ -58,7 +78,7 @@ export const ElasticsearchIndexAdvancedPage: React.FC = ({ clust {flyoutComponent} = ({ clusters }) => { +export const ElasticsearchIndexPage: React.FC = () => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); const { index }: { index: string } = useParams(); @@ -31,6 +33,7 @@ export const ElasticsearchIndexPage: React.FC = ({ clusters }) = const [data, setData] = useState({} as any); const [indexLabel, setIndexLabel] = useState(labels.index as any); const [nodesByIndicesData, setNodesByIndicesData] = useState([]); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.index.overview.title', { defaultMessage: 'Elasticsearch - Indices - {indexName} - Overview', @@ -49,23 +52,40 @@ export const ElasticsearchIndexPage: React.FC = ({ clusters }) = const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices/${index}`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: false, + }), + }); + setData(response); + const transformer = indicesByNodes(); + setNodesByIndicesData(transformer(response.shards, response.nodes)); + + const shards = response.shards; + if (shards.some((shard: any) => shard.state === 'UNASSIGNED')) { + setIndexLabel(labels.indexWithUnassigned); + } + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_LARGE_SHARD_SIZE], + filters: [ + { + shardIndex: index, + }, + ], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: false, - }), - }); - setData(response); - const transformer = indicesByNodes(); - setNodesByIndicesData(transformer(response.shards, response.nodes)); - - const shards = response.shards; - if (shards.some((shard: any) => shard.state === 'UNASSIGNED')) { - setIndexLabel(labels.indexWithUnassigned); + }); + setAlerts(alertsResponse); } }, [clusterUuid, services.data?.query.timefilter.timefilter, services.http, index]); @@ -85,7 +105,7 @@ export const ElasticsearchIndexPage: React.FC = ({ clusters }) = = ({ clusters }) => { const globalState = useContext(GlobalStateContext); @@ -32,6 +34,7 @@ export const ElasticsearchIndicesPage: React.FC = ({ clusters }) 'showSystemIndices', false ); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.indices.routeTitle', { defaultMessage: 'Elasticsearch - Indices', @@ -49,26 +52,38 @@ export const ElasticsearchIndicesPage: React.FC = ({ clusters }) const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices`; - const response = await services.http?.fetch(url, { - method: 'POST', - query: { - show_system_indices: showSystemIndices, - }, - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + query: { + show_system_indices: showSystemIndices, + }, + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + clusterUuid, + alertTypeIds: [RULE_LARGE_SHARD_SIZE], timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - setData(response); + }); + setAlerts(alertsResponse); + } }, [ - ccs, - showSystemIndices, - clusterUuid, services.data?.query.timefilter.timefilter, services.http, + clusterUuid, + showSystemIndices, + ccs, ]); return ( @@ -88,7 +103,7 @@ export const ElasticsearchIndicesPage: React.FC = ({ clusters }) = (props) => { }, { id: 'advanced', + testSubj: 'esItemDetailAdvancedLink', label: i18n.translate('xpack.monitoring.esItemNavigation.advancedLinkText', { defaultMessage: 'Advanced', }), diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx index 66117b8d75b3e..7edf15886cc20 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ElasticsearchTemplate } from './elasticsearch_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ElasticsearchMLJobs } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_advanced_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_advanced_page.tsx index b35dab7ff58f8..9c0f5f4627b01 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_advanced_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_advanced_page.tsx @@ -9,13 +9,23 @@ import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { ItemTemplate } from './item_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; // @ts-ignore import { AdvancedNode } from '../../../components/elasticsearch/node/advanced'; import { ComponentProps } from '../../route_init'; import { useCharts } from '../../hooks/use_charts'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { + RULE_CPU_USAGE, + RULE_THREAD_POOL_SEARCH_REJECTIONS, + RULE_THREAD_POOL_WRITE_REJECTIONS, + RULE_MISSING_MONITORING_DATA, + RULE_DISK_USAGE, + RULE_MEMORY_USAGE, +} from '../../../../common/constants'; -export const ElasticsearchNodeAdvancedPage: React.FC = ({ clusters }) => { +export const ElasticsearchNodeAdvancedPage: React.FC = () => { const globalState = useContext(GlobalStateContext); const { zoomInfo, onBrush } = useCharts(); @@ -25,6 +35,7 @@ export const ElasticsearchNodeAdvancedPage: React.FC = ({ cluste const clusterUuid = globalState.cluster_uuid; const ccs = globalState.ccs; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.node.advanced.title', { defaultMessage: 'Elasticsearch - Nodes - {nodeName} - Advanced', @@ -43,20 +54,42 @@ export const ElasticsearchNodeAdvancedPage: React.FC = ({ cluste const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/nodes/${node}`; - - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: true, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + clusterUuid, + alertTypeIds: [ + RULE_CPU_USAGE, + RULE_THREAD_POOL_SEARCH_REJECTIONS, + RULE_THREAD_POOL_WRITE_REJECTIONS, + RULE_MISSING_MONITORING_DATA, + RULE_DISK_USAGE, + RULE_MEMORY_USAGE, + ], + filters: [ + { + nodeUuid: node, + }, + ], timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: true, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http, node]); return ( @@ -69,7 +102,7 @@ export const ElasticsearchNodeAdvancedPage: React.FC = ({ cluste > = ({ clusters }) => { +export const ElasticsearchNodePage: React.FC = () => { const globalState = useContext(GlobalStateContext); const { zoomInfo, onBrush } = useCharts(); const [showSystemIndices, setShowSystemIndices] = useLocalStorage( 'showSystemIndices', false ); + const [alerts, setAlerts] = useState({}); const { node }: { node: string } = useParams(); const { services } = useKibana<{ data: any }>(); @@ -54,30 +65,49 @@ export const ElasticsearchNodePage: React.FC = ({ clusters }) => const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/nodes/${node}`; + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + showSystemIndices, + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: false, + }), + }); - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - showSystemIndices, - ccs, + setData(response); + const transformer = nodesByIndices(); + setNodesByIndicesData(transformer(response.shards, response.nodes)); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [ + RULE_CPU_USAGE, + RULE_THREAD_POOL_SEARCH_REJECTIONS, + RULE_THREAD_POOL_WRITE_REJECTIONS, + RULE_MISSING_MONITORING_DATA, + RULE_DISK_USAGE, + RULE_MEMORY_USAGE, + ], + filters: [{ nodeUuid: node }], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: false, - }), - }); - - setData(response); - const transformer = nodesByIndices(); - setNodesByIndicesData(transformer(response.shards, response.nodes)); + }); + setAlerts(alertsResponse); + } }, [ - ccs, - clusterUuid, services.data?.query.timefilter.timefilter, services.http, + clusterUuid, node, showSystemIndices, + ccs, ]); const toggleShowSystemIndices = useCallback(() => { @@ -98,7 +128,7 @@ export const ElasticsearchNodePage: React.FC = ({ clusters }) => {flyoutComponent} = ({ clusters }) => { const globalState = useContext(GlobalStateContext); @@ -32,6 +42,7 @@ export const ElasticsearchNodesPage: React.FC = ({ clusters }) = cluster_uuid: clusterUuid, }) as any; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.nodes.routeTitle', { defaultMessage: 'Elasticsearch - Nodes', @@ -52,25 +63,44 @@ export const ElasticsearchNodesPage: React.FC = ({ clusters }) = const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/nodes`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + ...getPaginationRouteOptions(), + }), + }); + + setData(response); + updateTotalItemCount(response.totalNodeCount); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + clusterUuid, + alertTypeIds: [ + RULE_CPU_USAGE, + RULE_DISK_USAGE, + RULE_THREAD_POOL_SEARCH_REJECTIONS, + RULE_THREAD_POOL_WRITE_REJECTIONS, + RULE_MEMORY_USAGE, + RULE_MISSING_MONITORING_DATA, + ], timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - ...getPaginationRouteOptions(), - }), - }); - - setData(response); - updateTotalItemCount(response.totalNodeCount); + }); + setAlerts(alertsResponse); + } }, [ - ccs, - clusterUuid, services.data?.query.timefilter.timefilter, services.http, + clusterUuid, + ccs, getPaginationRouteOptions, updateTotalItemCount, ]); @@ -94,7 +124,7 @@ export const ElasticsearchNodesPage: React.FC = ({ clusters }) = clusterUuid={globalState.cluster_uuid} setupMode={setupMode} nodes={data.nodes} - alerts={{}} + alerts={alerts} showCgroupMetricsElasticsearch={showCgroupMetricsElasticsearch} {...getPaginationTableProps()} /> diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx index 3334c7e7b880a..c58aaa5dffb04 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ElasticsearchTemplate } from './elasticsearch_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ElasticsearchOverview } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; import { useCharts } from '../../hooks/use_charts'; diff --git a/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx b/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx index 906db1b57f0f5..a31f2bc317fa6 100644 --- a/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx @@ -12,8 +12,8 @@ import { useKibana } from '../../../../../../../src/plugins/kibana_react/public' // @ts-ignore import { Listing } from '../../../components/cluster/listing'; import { EnableAlertsModal } from '../../../alerts/enable_alerts_modal'; -import { GlobalStateContext } from '../../global_state_context'; -import { ExternalConfigContext } from '../../external_config_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; +import { ExternalConfigContext } from '../../contexts/external_config_context'; import { ComponentProps } from '../../route_init'; import { useTable } from '../../hooks/use_table'; import { PageTemplate, TabMenuItem } from '../page_template'; @@ -69,23 +69,19 @@ export const ClusterListing: React.FC = () => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); - try { - if (services.http?.fetch) { - const response = await fetchClusters({ - fetch: services.http.fetch, - timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), - }, - ccs: globalState.ccs, - codePaths: ['all'], - }); - setClusters(response); - } - } catch (err) { - // TODO: handle errors + if (services.http?.fetch) { + const response = await fetchClusters({ + fetch: services.http.fetch, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + ccs: globalState.ccs, + codePaths: ['all'], + }); + setClusters(response); } - }, [globalState, services.data?.query.timefilter.timefilter, services.http]); + }, [globalState.ccs, services.data?.query.timefilter.timefilter, services.http]); if (globalState.save && clusters.length === 1) { globalState.cluster_uuid = clusters[0].cluster_uuid; diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx index 8b88fc47a9007..444794d118b0f 100644 --- a/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx @@ -19,7 +19,7 @@ import { EuiPanel, } from '@elastic/eui'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useCharts } from '../../hooks/use_charts'; // @ts-ignore @@ -30,6 +30,9 @@ import { MonitoringTimeseriesContainer } from '../../../components/chart'; import { DetailStatus } from '../../../components/kibana/detail_status'; import { PageTemplate } from '../page_template'; import { AlertsCallout } from '../../../alerts/callout'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { RULE_KIBANA_VERSION_MISMATCH } from '../../../../common/constants'; const KibanaInstance = ({ data, alerts }: { data: any; alerts: any }) => { const { zoomInfo, onBrush } = useCharts(); @@ -112,6 +115,7 @@ export const KibanaInstancePage: React.FC = ({ clusters }) => { }) as any; const [data, setData] = useState({} as any); const [instanceName, setInstanceName] = useState(''); + const [alerts, setAlerts] = useState({}); const title = `Kibana - ${instanceName}`; const pageTitle = i18n.translate('xpack.monitoring.kibana.instance.pageTitle', { @@ -133,19 +137,30 @@ export const KibanaInstancePage: React.FC = ({ clusters }) => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/kibana/${instance}`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + setData(response); + setInstanceName(response.kibanaSummary.name); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_KIBANA_VERSION_MISMATCH], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - - setData(response); - setInstanceName(response.kibanaSummary.name); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, instance, services.data?.query.timefilter.timefilter, services.http]); return ( @@ -156,7 +171,7 @@ export const KibanaInstancePage: React.FC = ({ clusters }) => { data-test-subj="kibanaInstancePage" >
- +
); diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx index 436a1a72b2fdb..ae0237ea40472 100644 --- a/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx @@ -9,7 +9,7 @@ import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useTable } from '../../hooks/use_table'; import { KibanaTemplate } from './kibana_template'; @@ -19,7 +19,9 @@ import { KibanaInstances } from '../../../components/kibana/instances'; import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; -import { KIBANA_SYSTEM_ID } from '../../../../common/constants'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { KIBANA_SYSTEM_ID, RULE_KIBANA_VERSION_MISMATCH } from '../../../../common/constants'; export const KibanaInstancesPage: React.FC = ({ clusters }) => { const { cluster_uuid: clusterUuid, ccs } = useContext(GlobalStateContext); @@ -30,6 +32,7 @@ export const KibanaInstancesPage: React.FC = ({ clusters }) => { cluster_uuid: clusterUuid, }) as any; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.kibana.instances.routeTitle', { defaultMessage: 'Kibana - Instances', @@ -50,19 +53,31 @@ export const KibanaInstancesPage: React.FC = ({ clusters }) => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/kibana/instances`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + updateTotalItemCount(response.kibanas.length); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_KIBANA_VERSION_MISMATCH], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - - setData(response); - updateTotalItemCount(response.stats.total); + }); + setAlerts(alertsResponse); + } }, [ ccs, clusterUuid, @@ -85,7 +100,7 @@ export const KibanaInstancesPage: React.FC = ({ clusters }) => { {flyoutComponent} = ({ clusters }) => { const globalState = useContext(GlobalStateContext); @@ -42,6 +45,7 @@ export const LogStashNodeAdvancedPage: React.FC = ({ clusters }) }); const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.logstash.node.advanced.routeTitle', { defaultMessage: 'Logstash - {nodeName} - Advanced', @@ -60,19 +64,30 @@ export const LogStashNodeAdvancedPage: React.FC = ({ clusters }) const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/node/${match.params.uuid}`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: true, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_LOGSTASH_VERSION_MISMATCH], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: true, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ ccs, clusterUuid, @@ -105,7 +120,7 @@ export const LogStashNodeAdvancedPage: React.FC = ({ clusters }) {data.nodeSummary && } - + {metricsToShow.map((metric, index) => ( diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx index 301d3c45dedb5..1163a619dd84b 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx @@ -18,7 +18,7 @@ import { EuiFlexItem, } from '@elastic/eui'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; // @ts-ignore import { List } from '../../../components/logstash/pipeline_viewer/models/list'; @@ -30,6 +30,9 @@ import { DetailStatus } from '../../../components/logstash/detail_status'; import { MonitoringTimeseriesContainer } from '../../../components/chart'; import { AlertsCallout } from '../../../alerts/callout'; import { useCharts } from '../../hooks/use_charts'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { RULE_LOGSTASH_VERSION_MISMATCH } from '../../../../common/constants'; export const LogStashNodePage: React.FC = ({ clusters }) => { const match = useRouteMatch<{ uuid: string | undefined }>(); @@ -41,6 +44,7 @@ export const LogStashNodePage: React.FC = ({ clusters }) => { cluster_uuid: clusterUuid, }); const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const { zoomInfo, onBrush } = useCharts(); const title = i18n.translate('xpack.monitoring.logstash.node.routeTitle', { defaultMessage: 'Logstash - {nodeName}', @@ -59,19 +63,30 @@ export const LogStashNodePage: React.FC = ({ clusters }) => { const getPageData = useCallback(async () => { const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/node/${match.params.uuid}`; const bounds = services.data?.query.timefilter.timefilter.getBounds(); - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: false, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_LOGSTASH_VERSION_MISMATCH], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: false, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http, match.params]); const metricsToShow = useMemo(() => { @@ -99,7 +114,7 @@ export const LogStashNodePage: React.FC = ({ clusters }) => { {data.nodeSummary && } - + {metricsToShow.map((metric, index) => ( diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx index 1c956603f99bd..e09850eaad5c9 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx @@ -12,7 +12,7 @@ import { useRouteMatch } from 'react-router-dom'; // @ts-ignore import { isPipelineMonitoringSupportedInVersion } from '../../../lib/logstash/pipelines'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; // @ts-ignore import { Listing } from '../../../components/logstash/listing'; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx index 09a97925c56f5..0fd10a93bcd83 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx @@ -8,7 +8,7 @@ import React, { useContext, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; // @ts-ignore import { Listing } from '../../../components/logstash/listing'; @@ -16,7 +16,9 @@ import { LogstashTemplate } from './logstash_template'; import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useTable } from '../../hooks/use_table'; -import { LOGSTASH_SYSTEM_ID } from '../../../../common/constants'; +import { LOGSTASH_SYSTEM_ID, RULE_LOGSTASH_VERSION_MISMATCH } from '../../../../common/constants'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; interface SetupModeProps { setupMode: any; @@ -33,6 +35,7 @@ export const LogStashNodesPage: React.FC = ({ clusters }) => { cluster_uuid: clusterUuid, }); const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const { getPaginationTableProps } = useTable('logstash.nodes'); const title = i18n.translate('xpack.monitoring.logstash.nodes.routeTitle', { @@ -46,18 +49,30 @@ export const LogStashNodesPage: React.FC = ({ clusters }) => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/nodes`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_LOGSTASH_VERSION_MISMATCH], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); return ( @@ -78,6 +93,7 @@ export const LogStashNodesPage: React.FC = ({ clusters }) => { metrics={data.metrics} data={data.nodes} setupMode={setupMode} + alerts={alerts} {...getPaginationTableProps()} /> {bottomBarComponent} diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/overview.tsx index 1edbe5cf71e7d..339b9e9395569 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/overview.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/overview.tsx @@ -8,7 +8,7 @@ import React, { useContext, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; import { useCharts } from '../../hooks/use_charts'; // @ts-ignore diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx index abff0ab17b992..20f1caee2b1d8 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx @@ -10,7 +10,7 @@ import { find } from 'lodash'; import moment from 'moment'; import { useRouteMatch } from 'react-router-dom'; import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; // @ts-ignore import { List } from '../../../components/logstash/pipeline_viewer/models/list'; @@ -24,7 +24,7 @@ import { PipelineState } from '../../../components/logstash/pipeline_viewer/mode import { vertexFactory } from '../../../components/logstash/pipeline_viewer/models/graph/vertex_factory'; import { LogstashTemplate } from './logstash_template'; import { useTable } from '../../hooks/use_table'; -import { ExternalConfigContext } from '../../external_config_context'; +import { ExternalConfigContext } from '../../contexts/external_config_context'; import { formatTimestampToDuration } from '../../../../common'; import { CALCULATE_DURATION_SINCE } from '../../../../common/constants'; import { getSafeForExternalLink } from '../../../lib/get_safe_for_external_link'; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx index 5f4fe634177de..ac750ff81ddaa 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx @@ -8,7 +8,7 @@ import React, { useContext, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; import { useCharts } from '../../hooks/use_charts'; // @ts-ignore diff --git a/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx b/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx index b05bd783b2ff2..26072f53f4752 100644 --- a/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx @@ -18,7 +18,8 @@ import { Legacy } from '../../../legacy_shims'; import { Enabler } from './enabler'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; import { initSetupModeState } from '../../setup_mode/setup_mode'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; +import { useRequestErrorHandler } from '../../hooks/use_request_error_handler'; const CODE_PATHS = [CODE_PATH_LICENSE]; @@ -77,7 +78,8 @@ export const NoDataPage = () => { ]); const globalState = useContext(GlobalStateContext); - initSetupModeState(globalState, services.http); + const handleRequestError = useRequestErrorHandler(); + initSetupModeState(globalState, services.http, handleRequestError); // From x-pack/plugins/monitoring/public/views/no_data/model_updater.js const updateModel = useCallback( diff --git a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx index 927c464552087..5c030814d9cdf 100644 --- a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx @@ -6,8 +6,9 @@ */ import { EuiTab, EuiTabs } from '@elastic/eui'; -import React, { useContext, useState, useEffect } from 'react'; +import React, { useContext, useState, useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; +import { IHttpFetchError } from 'kibana/public'; import { useTitle } from '../hooks/use_title'; import { MonitoringToolbar } from '../../components/shared/toolbar'; import { MonitoringTimeContainer } from '../hooks/use_monitoring_time'; @@ -18,6 +19,9 @@ import { updateSetupModeData, } from '../setup_mode/setup_mode'; import { SetupModeFeature } from '../../../common/enums'; +import { AlertsDropdown } from '../../alerts/alerts_dropdown'; +import { ActionMenu } from '../../components/action_menu'; +import { useRequestErrorHandler } from '../hooks/use_request_error_handler'; export interface TabMenuItem { id: string; @@ -46,34 +50,52 @@ export const PageTemplate: React.FC = ({ const { currentTimerange } = useContext(MonitoringTimeContainer.Context); const [loaded, setLoaded] = useState(false); const history = useHistory(); + const [hasError, setHasError] = useState(false); + const handleRequestError = useRequestErrorHandler(); + + const getPageDataResponseHandler = useCallback( + (result: any) => { + setHasError(false); + return result; + }, + [setHasError] + ); useEffect(() => { getPageData?.() - .catch((err) => { - // TODO: handle errors + .then(getPageDataResponseHandler) + .catch((err: IHttpFetchError) => { + handleRequestError(err); + setHasError(true); }) .finally(() => { setLoaded(true); }); - }, [getPageData, currentTimerange]); + }, [getPageData, currentTimerange, getPageDataResponseHandler, handleRequestError]); const onRefresh = () => { - const requests = [getPageData?.()]; + getPageData?.().then(getPageDataResponseHandler).catch(handleRequestError); + if (isSetupModeFeatureEnabled(SetupModeFeature.MetricbeatMigration)) { - requests.push(updateSetupModeData()); + updateSetupModeData(); } - - Promise.allSettled(requests).then((results) => { - // TODO: handle errors - }); }; const createHref = (route: string) => history.createHref({ pathname: route }); const isTabSelected = (route: string) => history.location.pathname === route; + const renderContent = () => { + if (hasError) return null; + if (getPageData && !loaded) return ; + return children; + }; + return (
+ + + {tabs && ( @@ -93,7 +115,7 @@ export const PageTemplate: React.FC = ({ })} )} -
{!getPageData ? children : loaded ? children : }
+
{renderContent()}
); }; diff --git a/x-pack/plugins/monitoring/public/application/route_init.tsx b/x-pack/plugins/monitoring/public/application/route_init.tsx index 8a9a906dbd563..8a11df3de50ae 100644 --- a/x-pack/plugins/monitoring/public/application/route_init.tsx +++ b/x-pack/plugins/monitoring/public/application/route_init.tsx @@ -7,7 +7,7 @@ import React, { useContext } from 'react'; import { Route, Redirect, useLocation } from 'react-router-dom'; import { useClusters } from './hooks/use_clusters'; -import { GlobalStateContext } from './global_state_context'; +import { GlobalStateContext } from './contexts/global_state_context'; import { getClusterFromClusters } from '../lib/get_cluster_from_clusters'; export interface ComponentProps { diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx index 70932e5177337..bfdf96ef5b2c1 100644 --- a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx +++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { render } from 'react-dom'; import { get, includes } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { HttpStart } from 'kibana/public'; +import { HttpStart, IHttpFetchError } from 'kibana/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { Legacy } from '../../legacy_shims'; import { SetupModeEnterButton } from '../../components/setup_mode/enter_button'; import { SetupModeFeature } from '../../../common/enums'; import { ISetupModeContext } from '../../components/setup_mode/setup_mode_context'; -import { State as GlobalState } from '../../application/global_state_context'; +import { State as GlobalState } from '../contexts/global_state_context'; function isOnPage(hash: string) { return includes(window.location.hash, hash); @@ -23,6 +23,7 @@ function isOnPage(hash: string) { let globalState: GlobalState; let httpService: HttpStart; +let errorHandler: (error: IHttpFetchError) => void; interface ISetupModeState { enabled: boolean; @@ -65,8 +66,8 @@ export const fetchCollectionData = async (uuid?: string, fetchWithoutClusterUuid }); return response; } catch (err) { - // TODO: handle errors - throw new Error(err); + errorHandler(err); + throw err; } }; @@ -122,8 +123,8 @@ export const disableElasticsearchInternalCollection = async () => { const response = await httpService.post(url); return response; } catch (err) { - // TODO: handle errors - throw new Error(err); + errorHandler(err); + throw err; } }; @@ -161,10 +162,12 @@ export const setSetupModeMenuItem = () => { export const initSetupModeState = async ( state: GlobalState, http: HttpStart, + handleErrors: (error: IHttpFetchError) => void, callback?: () => void ) => { globalState = state; httpService = http; + errorHandler = handleErrors; if (callback) { setupModeState.callback = callback; } diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts index 48e8ee13059c0..c0eda496a09b2 100644 --- a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts +++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts @@ -5,8 +5,9 @@ * 2.0. */ -export const SetupModeRenderer: FunctionComponent; +import { FunctionComponent } from 'react'; +export const SetupModeRenderer: FunctionComponent>; export interface SetupModeProps { setupMode: any; flyoutComponent: any; diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js index 337dacd4ecae9..a9ee2464cd423 100644 --- a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js +++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js @@ -27,8 +27,9 @@ import { import { findNewUuid } from '../../components/renderers/lib/find_new_uuid'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { GlobalStateContext } from '../../application/global_state_context'; +import { GlobalStateContext } from '../../application/contexts/global_state_context'; import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { useRequestErrorHandler } from '../hooks/use_request_error_handler'; class WrappedSetupModeRenderer extends React.Component { globalState; @@ -42,8 +43,8 @@ class WrappedSetupModeRenderer extends React.Component { UNSAFE_componentWillMount() { this.globalState = this.context; - const { kibana } = this.props; - initSetupModeState(this.globalState, kibana.services.http, (_oldData) => { + const { kibana, onHttpError } = this.props; + initSetupModeState(this.globalState, kibana.services.http, onHttpError, (_oldData) => { const newState = { renderState: true }; const { productName } = this.props; @@ -213,5 +214,12 @@ class WrappedSetupModeRenderer extends React.Component { } } +function withErrorHandler(Component) { + return function WrappedComponent(props) { + const handleRequestError = useRequestErrorHandler(); + return ; + }; +} + WrappedSetupModeRenderer.contextType = GlobalStateContext; -export const SetupModeRenderer = withKibana(WrappedSetupModeRenderer); +export const SetupModeRenderer = withKibana(withErrorHandler(WrappedSetupModeRenderer)); diff --git a/x-pack/plugins/monitoring/public/components/action_menu/index.tsx b/x-pack/plugins/monitoring/public/components/action_menu/index.tsx new file mode 100644 index 0000000000000..1348ac170395e --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/action_menu/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useContext, useEffect } from 'react'; +import { + KibanaContextProvider, + toMountPoint, + useKibana, +} from '../../../../../../src/plugins/kibana_react/public'; +import { HeaderActionMenuContext } from '../../application/contexts/header_action_menu_context'; + +export const ActionMenu: React.FC<{}> = ({ children }) => { + const { services } = useKibana(); + const { setHeaderActionMenu } = useContext(HeaderActionMenuContext); + useEffect(() => { + if (setHeaderActionMenu) { + setHeaderActionMenu((element) => { + const mount = toMountPoint( + {children} + ); + return mount(element); + }); + return () => { + setHeaderActionMenu(undefined); + }; + } + }, [children, setHeaderActionMenu, services]); + + return null; +}; diff --git a/x-pack/plugins/monitoring/public/components/apm/status_icon.js b/x-pack/plugins/monitoring/public/components/apm/status_icon.js index f27bcefc20bcb..14a51313e4aa7 100644 --- a/x-pack/plugins/monitoring/public/components/apm/status_icon.js +++ b/x-pack/plugins/monitoring/public/components/apm/status_icon.js @@ -6,17 +6,17 @@ */ import React from 'react'; -import { StatusIcon } from '../../components/status_icon'; +import { StatusIcon, STATUS_ICON_TYPES } from '../../components/status_icon'; import { i18n } from '@kbn/i18n'; export function ApmStatusIcon({ status, availability = true }) { const type = (() => { if (!availability) { - return StatusIcon.TYPES.GRAY; + return STATUS_ICON_TYPES.GRAY; } const statusKey = status.toUpperCase(); - return StatusIcon.TYPES[statusKey] || StatusIcon.TYPES.YELLOW; + return STATUS_ICON_TYPES[statusKey] || STATUS_ICON_TYPES.YELLOW; })(); return ( diff --git a/x-pack/plugins/monitoring/public/components/cluster/overview/index.d.ts b/x-pack/plugins/monitoring/public/components/cluster/overview/index.d.ts index 2cfd37e8e27eb..3dc7121446a7a 100644 --- a/x-pack/plugins/monitoring/public/components/cluster/overview/index.d.ts +++ b/x-pack/plugins/monitoring/public/components/cluster/overview/index.d.ts @@ -5,4 +5,13 @@ * 2.0. */ -export const Overview: FunctionComponent; +import { FunctionComponent } from 'react'; + +export const Overview: FunctionComponent; + +export interface OverviewProps { + cluster: unknown; + setupMode: unknown; + showLicenseExpiration: boolean; + alerts: unknown; +} diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/cluster_status/index.d.ts b/x-pack/plugins/monitoring/public/components/elasticsearch/cluster_status/index.d.ts index b7196d25d1791..4f314101ed299 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/cluster_status/index.d.ts +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/cluster_status/index.d.ts @@ -5,4 +5,11 @@ * 2.0. */ -export const ClusterStatus: FunctionComponent; +import { FunctionComponent } from 'react'; + +export const ClusterStatus: FunctionComponent; + +export interface ClusterStatusProps { + stats: unknown; + alerts?: unknown; +} diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/index.d.ts b/x-pack/plugins/monitoring/public/components/elasticsearch/index.d.ts deleted file mode 100644 index 09f6c1085cfa3..0000000000000 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/index.d.ts +++ /dev/null @@ -1,12 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const ElasticsearchOverview: FunctionComponent; -export const ElasticsearchNodes: FunctionComponent; -export const ElasticsearchIndices: FunctionComponent; -export const ElasticsearchMLJobs: FunctionComponent; -export const NodeReact: FunctionComponent; diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/index.js b/x-pack/plugins/monitoring/public/components/elasticsearch/index.ts similarity index 100% rename from x-pack/plugins/monitoring/public/components/elasticsearch/index.js rename to x-pack/plugins/monitoring/public/components/elasticsearch/index.ts diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/indices/index.js b/x-pack/plugins/monitoring/public/components/elasticsearch/indices/index.ts similarity index 100% rename from x-pack/plugins/monitoring/public/components/elasticsearch/indices/index.js rename to x-pack/plugins/monitoring/public/components/elasticsearch/indices/index.ts diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/indices/indices.d.ts b/x-pack/plugins/monitoring/public/components/elasticsearch/indices/indices.d.ts new file mode 100644 index 0000000000000..2b8ea60b651a6 --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/indices/indices.d.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FunctionComponent } from 'react'; + +export const ElasticsearchIndices: FunctionComponent; +export interface ElasticsearchIndicesProps { + clusterStatus: unknown; + indices: unknown; + sorting: unknown; + pagination: unknown; + onTableChange: unknown; + toggleShowSystemIndices: unknown; + showSystemIndices: unknown; + alerts: unknown; +} diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/ml_job_listing/status_icon.tsx b/x-pack/plugins/monitoring/public/components/elasticsearch/ml_job_listing/status_icon.tsx index d5c65aecdec21..a45c8316d1aa3 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/ml_job_listing/status_icon.tsx +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/ml_job_listing/status_icon.tsx @@ -7,22 +7,22 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { StatusIcon } from '../../status_icon'; +import { StatusIcon, STATUS_ICON_TYPES } from '../../status_icon'; export function MachineLearningJobStatusIcon({ status }: { status: string }) { const type = (() => { const statusKey = status.toUpperCase(); if (statusKey === 'OPENED') { - return StatusIcon.TYPES.GREEN; + return STATUS_ICON_TYPES.GREEN; } else if (statusKey === 'CLOSED') { - return StatusIcon.TYPES.GRAY; + return STATUS_ICON_TYPES.GRAY; } else if (statusKey === 'FAILED') { - return StatusIcon.TYPES.RED; + return STATUS_ICON_TYPES.RED; } // basically a "changing" state like OPENING or CLOSING - return StatusIcon.TYPES.YELLOW; + return STATUS_ICON_TYPES.YELLOW; })(); return ( diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/ml_jobs/ml_jobs.tsx b/x-pack/plugins/monitoring/public/components/elasticsearch/ml_jobs/ml_jobs.tsx index 635f9ecd1e10a..dba9c40fabb2b 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/ml_jobs/ml_jobs.tsx +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/ml_jobs/ml_jobs.tsx @@ -31,7 +31,7 @@ import { ClusterStatus } from '../cluster_status'; interface Props { clusterStatus: boolean; jobs: MLJobs; - onTableChange: () => void; + onTableChange: (props: any) => void; sorting: EuiTableSortingType; pagination: Pagination; } diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node/index.js b/x-pack/plugins/monitoring/public/components/elasticsearch/node/index.ts similarity index 100% rename from x-pack/plugins/monitoring/public/components/elasticsearch/node/index.js rename to x-pack/plugins/monitoring/public/components/elasticsearch/node/index.ts diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.d.ts b/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.d.ts new file mode 100644 index 0000000000000..9d7a062e942bb --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.d.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FunctionComponent } from 'react'; + +export const Node: FunctionComponent; +export interface NodeProps { + nodeSummary: unknown; + metrics: unknown; + logs: unknown; + alerts: unknown; + nodeId: unknown; + clusterUuid: unknown; + scope: unknown; + [key: string]: any; +} diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node/node_react.d.ts b/x-pack/plugins/monitoring/public/components/elasticsearch/node/node_react.d.ts new file mode 100644 index 0000000000000..e0c4f6b301fdb --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node/node_react.d.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FunctionComponent } from 'react'; + +export const NodeReact: FunctionComponent; +export interface NodeReactProps { + nodeSummary: unknown; + metrics: unknown; + logs: unknown; + alerts: unknown; + nodeId: unknown; + clusterUuid: unknown; + [key: string]: any; +} diff --git a/x-pack/plugins/monitoring/public/components/index.d.ts b/x-pack/plugins/monitoring/public/components/elasticsearch/node/status_icon.d.ts similarity index 56% rename from x-pack/plugins/monitoring/public/components/index.d.ts rename to x-pack/plugins/monitoring/public/components/elasticsearch/node/status_icon.d.ts index fc1a81cc4dba2..dfa07524619c9 100644 --- a/x-pack/plugins/monitoring/public/components/index.d.ts +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node/status_icon.d.ts @@ -5,5 +5,10 @@ * 2.0. */ -export const PageLoading: FunctionComponent; -export const License: FunctionComponent; +import { FunctionComponent } from 'react'; + +export const NodeStatusIcon: FunctionComponent; +export interface NodeStatusIconProps { + isOnline: boolean; + status: string; +} diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node/status_icon.js b/x-pack/plugins/monitoring/public/components/elasticsearch/node/status_icon.js index 7bfffc7b73954..9905a6c3573f7 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/node/status_icon.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node/status_icon.js @@ -6,11 +6,11 @@ */ import React from 'react'; -import { StatusIcon } from '../../status_icon'; +import { StatusIcon, STATUS_ICON_TYPES } from '../../status_icon'; import { i18n } from '@kbn/i18n'; export function NodeStatusIcon({ isOnline, status }) { - const type = isOnline ? StatusIcon.TYPES.GREEN : StatusIcon.TYPES.GRAY; + const type = isOnline ? STATUS_ICON_TYPES.GREEN : STATUS_ICON_TYPES.GRAY; return ( ; +export interface ElasticsearchNodesProps { + clusterStatus: unknown; + showCgroupMetricsElasticsearch: unknown; + [key: string]: any; +} diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/overview/index.js b/x-pack/plugins/monitoring/public/components/elasticsearch/overview/index.ts similarity index 100% rename from x-pack/plugins/monitoring/public/components/elasticsearch/overview/index.js rename to x-pack/plugins/monitoring/public/components/elasticsearch/overview/index.ts diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/overview/overview.d.ts b/x-pack/plugins/monitoring/public/components/elasticsearch/overview/overview.d.ts new file mode 100644 index 0000000000000..d4c893f87cbd2 --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/overview/overview.d.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FunctionComponent } from 'react'; + +export const ElasticsearchOverview: FunctionComponent; +export interface ElasticsearchOverviewProps { + clusterStatus: unknown; + metrics: unknown; + logs: unknown; + cluster: unknown; + shardActivity: unknown; + [key: string]: any; +} diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/transformers/nodes_by_indices.d.ts b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/transformers/nodes_by_indices.d.ts index d0ec9b85edae7..c430c0ee7b48a 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/transformers/nodes_by_indices.d.ts +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/transformers/nodes_by_indices.d.ts @@ -5,4 +5,4 @@ * 2.0. */ -export const nodesByIndices: () => (shards, nodes) => any; +export const nodesByIndices: () => (shards: any, nodes: any) => any; diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/status_icon.js b/x-pack/plugins/monitoring/public/components/elasticsearch/status_icon.js index 7c51a1e89d91e..ec027d71a192d 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/status_icon.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/status_icon.js @@ -6,13 +6,13 @@ */ import React from 'react'; -import { StatusIcon } from '../status_icon'; +import { StatusIcon, STATUS_ICON_TYPES } from '../status_icon'; import { i18n } from '@kbn/i18n'; export function ElasticsearchStatusIcon({ status }) { const type = (() => { const statusKey = status.toUpperCase(); - return StatusIcon.TYPES[statusKey] || StatusIcon.TYPES.GRAY; + return STATUS_ICON_TYPES[statusKey] || STATUS_ICON_TYPES.GRAY; })(); return ( diff --git a/x-pack/plugins/monitoring/public/components/index.js b/x-pack/plugins/monitoring/public/components/index.ts similarity index 100% rename from x-pack/plugins/monitoring/public/components/index.js rename to x-pack/plugins/monitoring/public/components/index.ts diff --git a/x-pack/plugins/monitoring/public/components/kibana/instances/instances.tsx b/x-pack/plugins/monitoring/public/components/kibana/instances/instances.tsx index 4e939682b1dba..3766a09f91b80 100644 --- a/x-pack/plugins/monitoring/public/components/kibana/instances/instances.tsx +++ b/x-pack/plugins/monitoring/public/components/kibana/instances/instances.tsx @@ -25,8 +25,7 @@ import { capitalize, get } from 'lodash'; import { ClusterStatus } from '../cluster_status'; // @ts-ignore import { EuiMonitoringTable } from '../../table'; -// @ts-ignore -import { StatusIcon } from '../../status_icon'; +import { STATUS_ICON_TYPES } from '../../status_icon'; // @ts-ignore import { formatMetric, formatNumber } from '../../../lib/format_number'; import { getSafeForExternalLink } from '../../../lib/get_safe_for_external_link'; @@ -205,7 +204,7 @@ export const KibanaInstances: React.FC = (props: Props) => { _instances.push({ kibana: { ...(instance as any).instance.kibana, - status: StatusIcon.TYPES.GRAY, + status: STATUS_ICON_TYPES.GRAY, }, }); } diff --git a/x-pack/plugins/monitoring/public/components/kibana/status_icon.js b/x-pack/plugins/monitoring/public/components/kibana/status_icon.js index e5b501b1e15e7..976b3ff992e3b 100644 --- a/x-pack/plugins/monitoring/public/components/kibana/status_icon.js +++ b/x-pack/plugins/monitoring/public/components/kibana/status_icon.js @@ -6,17 +6,17 @@ */ import React from 'react'; -import { StatusIcon } from '../status_icon'; +import { StatusIcon, STATUS_ICON_TYPES } from '../status_icon'; import { i18n } from '@kbn/i18n'; export function KibanaStatusIcon({ status, availability = true }) { const type = (() => { if (!availability) { - return StatusIcon.TYPES.GRAY; + return STATUS_ICON_TYPES.GRAY; } const statusKey = status.toUpperCase(); - return StatusIcon.TYPES[statusKey] || StatusIcon.TYPES.YELLOW; + return STATUS_ICON_TYPES[statusKey] || STATUS_ICON_TYPES.YELLOW; })(); return ( diff --git a/x-pack/plugins/monitoring/public/components/license/index.js b/x-pack/plugins/monitoring/public/components/license/index.tsx similarity index 86% rename from x-pack/plugins/monitoring/public/components/license/index.js rename to x-pack/plugins/monitoring/public/components/license/index.tsx index ad16663c88ea7..766f0af3bccc8 100644 --- a/x-pack/plugins/monitoring/public/components/license/index.js +++ b/x-pack/plugins/monitoring/public/components/license/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React, { Fragment, FunctionComponent } from 'react'; import { EuiPage, EuiPageBody, @@ -27,7 +27,10 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { Legacy } from '../../legacy_shims'; -export const AddLicense = ({ uploadPath }) => { +interface AddLicenseProps { + uploadPath?: string; +} +const AddLicense: FunctionComponent = ({ uploadPath }) => { return ( { ); }; -export class LicenseStatus extends React.PureComponent { +export interface LicenseStatusProps { + isExpired: boolean; + status: string; + type: string; + expiryDate: string | Date; +} + +class LicenseStatus extends React.PureComponent { render() { const { isExpired, status, type, expiryDate } = this.props; const typeTitleCase = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase(); @@ -133,7 +143,15 @@ export class LicenseStatus extends React.PureComponent { } } -const LicenseUpdateInfoForPrimary = ({ isPrimaryCluster, uploadLicensePath }) => { +export interface LicenseUpdateInfoProps { + isPrimaryCluster: boolean; + uploadLicensePath?: string; +} + +const LicenseUpdateInfoForPrimary: FunctionComponent = ({ + isPrimaryCluster, + uploadLicensePath, +}) => { if (!isPrimaryCluster) { return null; } @@ -142,7 +160,9 @@ const LicenseUpdateInfoForPrimary = ({ isPrimaryCluster, uploadLicensePath }) => return ; }; -const LicenseUpdateInfoForRemote = ({ isPrimaryCluster }) => { +const LicenseUpdateInfoForRemote: FunctionComponent = ({ + isPrimaryCluster, +}) => { if (isPrimaryCluster) { return null; } @@ -168,7 +188,8 @@ const LicenseUpdateInfoForRemote = ({ isPrimaryCluster }) => { ); }; -export function License(props) { +export interface LicenseProps extends LicenseStatusProps, LicenseUpdateInfoProps {} +export const License: FunctionComponent = (props) => { const { status, type, isExpired, expiryDate } = props; const licenseManagement = `${Legacy.shims.getBasePath()}/app/management/stack/license_management`; return ( @@ -199,4 +220,4 @@ export function License(props) {
); -} +}; diff --git a/x-pack/plugins/monitoring/public/components/no_data/index.js b/x-pack/plugins/monitoring/public/components/no_data/index.ts similarity index 100% rename from x-pack/plugins/monitoring/public/components/no_data/index.js rename to x-pack/plugins/monitoring/public/components/no_data/index.ts diff --git a/x-pack/plugins/monitoring/public/components/status_icon/index.d.ts b/x-pack/plugins/monitoring/public/components/no_data/no_data.d.ts similarity index 71% rename from x-pack/plugins/monitoring/public/components/status_icon/index.d.ts rename to x-pack/plugins/monitoring/public/components/no_data/no_data.d.ts index 147c2821e3a2a..b87d326e834af 100644 --- a/x-pack/plugins/monitoring/public/components/status_icon/index.d.ts +++ b/x-pack/plugins/monitoring/public/components/no_data/no_data.d.ts @@ -5,4 +5,6 @@ * 2.0. */ -export const StatusIcon: FunctionComponent; +import { FunctionComponent } from 'react'; + +export const NoData: FunctionComponent>; diff --git a/x-pack/plugins/monitoring/public/components/page_loading/index.js b/x-pack/plugins/monitoring/public/components/page_loading/index.tsx similarity index 89% rename from x-pack/plugins/monitoring/public/components/page_loading/index.js rename to x-pack/plugins/monitoring/public/components/page_loading/index.tsx index fd4aa9d848150..e7535fc3dc859 100644 --- a/x-pack/plugins/monitoring/public/components/page_loading/index.js +++ b/x-pack/plugins/monitoring/public/components/page_loading/index.tsx @@ -48,17 +48,21 @@ function PageLoadingUI() { ); } -function PageLoadingTracking({ pageViewTitle }) { +const PageLoadingTracking: React.FunctionComponent<{ pageViewTitle: string }> = ({ + pageViewTitle, +}) => { const path = pageViewTitle.toLowerCase().replace(/-/g, '').replace(/\s+/g, '_'); useTrackPageview({ app: 'stack_monitoring', path }); useTrackPageview({ app: 'stack_monitoring', path, delay: 15000 }); return ; -} +}; -export function PageLoading({ pageViewTitle }) { +export const PageLoading: React.FunctionComponent<{ pageViewTitle?: string }> = ({ + pageViewTitle, +}) => { if (pageViewTitle) { return ; } return ; -} +}; diff --git a/x-pack/plugins/monitoring/public/components/setup_mode/formatting.js b/x-pack/plugins/monitoring/public/components/setup_mode/formatting.ts similarity index 93% rename from x-pack/plugins/monitoring/public/components/setup_mode/formatting.js rename to x-pack/plugins/monitoring/public/components/setup_mode/formatting.ts index 11e8ca48719fd..06eb029fcc4a0 100644 --- a/x-pack/plugins/monitoring/public/components/setup_mode/formatting.js +++ b/x-pack/plugins/monitoring/public/components/setup_mode/formatting.ts @@ -34,7 +34,7 @@ const SERVER_IDENTIFIER_PLURAL = i18n.translate('xpack.monitoring.setupMode.serv defaultMessage: `servers`, }); -export function formatProductName(productName) { +export function formatProductName(productName: string) { if (productName === APM_SYSTEM_ID) { return productName.toUpperCase(); } @@ -43,7 +43,7 @@ export function formatProductName(productName) { const PRODUCTS_THAT_USE_NODES = [LOGSTASH_SYSTEM_ID, ELASTICSEARCH_SYSTEM_ID]; const PRODUCTS_THAT_USE_INSTANCES = [KIBANA_SYSTEM_ID, BEATS_SYSTEM_ID]; -export function getIdentifier(productName, usePlural = false) { +export function getIdentifier(productName: string, usePlural = false) { if (PRODUCTS_THAT_USE_INSTANCES.includes(productName)) { return usePlural ? INSTANCE_IDENTIFIER_PLURAL : INSTANCE_IDENTIFIER_SINGULAR; } diff --git a/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx b/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx index 32bbdd6ecbeda..6a1ed1dd16f48 100644 --- a/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx +++ b/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx @@ -14,7 +14,7 @@ import { } from '@elastic/eui'; import React, { useContext, useCallback } from 'react'; import { MonitoringTimeContainer } from '../../application/hooks/use_monitoring_time'; -import { GlobalStateContext } from '../../application/global_state_context'; +import { GlobalStateContext } from '../../application/contexts/global_state_context'; import { Legacy } from '../../legacy_shims'; interface MonitoringToolbarProps { diff --git a/x-pack/plugins/monitoring/public/components/status_icon/index.js b/x-pack/plugins/monitoring/public/components/status_icon/index.js deleted file mode 100644 index bcd4b58d6912f..0000000000000 --- a/x-pack/plugins/monitoring/public/components/status_icon/index.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiIcon } from '@elastic/eui'; - -export function StatusIcon({ type, label }) { - const typeToIconMap = { - [StatusIcon.TYPES.RED]: 'danger', - [StatusIcon.TYPES.YELLOW]: 'warning', - [StatusIcon.TYPES.GREEN]: 'success', - [StatusIcon.TYPES.GRAY]: 'subdued', - }; - const icon = typeToIconMap[type]; - - return ; -} - -StatusIcon.TYPES = { - RED: 'RED', - YELLOW: 'YELLOW', - GREEN: 'GREEN', - GRAY: 'GRAY', -}; diff --git a/x-pack/plugins/monitoring/public/components/status_icon/index.tsx b/x-pack/plugins/monitoring/public/components/status_icon/index.tsx new file mode 100644 index 0000000000000..59c87866d57d3 --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/status_icon/index.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIcon } from '@elastic/eui'; + +export const STATUS_ICON_TYPES = { + RED: 'RED' as const, + YELLOW: 'YELLOW' as const, + GREEN: 'GREEN' as const, + GRAY: 'GRAY' as const, +}; + +const typeToIconMap = { + [STATUS_ICON_TYPES.RED]: 'danger', + [STATUS_ICON_TYPES.YELLOW]: 'warning', + [STATUS_ICON_TYPES.GREEN]: 'success', + [STATUS_ICON_TYPES.GRAY]: 'subdued', +}; + +export interface StatusIconProps { + type: keyof typeof STATUS_ICON_TYPES; + label: string; +} +export const StatusIcon: React.FunctionComponent = ({ type, label }) => { + const icon = typeToIconMap[type]; + + return ( + + ); +}; diff --git a/x-pack/plugins/monitoring/public/components/summary_status/summary_status.js b/x-pack/plugins/monitoring/public/components/summary_status/summary_status.js index 71fcf4e193f20..db4ac9098532b 100644 --- a/x-pack/plugins/monitoring/public/components/summary_status/summary_status.js +++ b/x-pack/plugins/monitoring/public/components/summary_status/summary_status.js @@ -9,7 +9,7 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { isEmpty, capitalize } from 'lodash'; import { EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui'; -import { StatusIcon } from '../status_icon/index.js'; +import { StatusIcon } from '../status_icon'; import { AlertsStatus } from '../../alerts/status'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; diff --git a/x-pack/plugins/monitoring/public/components/table/eui_table.js b/x-pack/plugins/monitoring/public/components/table/eui_table.tsx similarity index 88% rename from x-pack/plugins/monitoring/public/components/table/eui_table.js rename to x-pack/plugins/monitoring/public/components/table/eui_table.tsx index a702fdc033572..a383fcf1cd666 100644 --- a/x-pack/plugins/monitoring/public/components/table/eui_table.js +++ b/x-pack/plugins/monitoring/public/components/table/eui_table.tsx @@ -5,21 +5,21 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React, { Fragment, FunctionComponent } from 'react'; import { EuiInMemoryTable, EuiButton, EuiSpacer, EuiSearchBar } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { getIdentifier } from '../setup_mode/formatting'; import { isSetupModeFeatureEnabled } from '../../lib/setup_mode'; import { SetupModeFeature } from '../../../common/enums'; -export function EuiMonitoringTable({ +export const EuiMonitoringTable: FunctionComponent> = ({ rows: items, search = {}, columns: _columns, setupMode, productName, ...props -}) { +}) => { const [hasItems, setHasItem] = React.useState(items.length > 0); if (search.box && !search.box['data-test-subj']) { @@ -32,15 +32,17 @@ export function EuiMonitoringTable({ if (search) { const oldOnChange = search.onChange; - search.onChange = (arg) => { + search.onChange = (arg: any) => { const filteredItems = EuiSearchBar.Query.execute(arg.query, items, props.executeQueryOptions); setHasItem(filteredItems.length > 0); - oldOnChange && oldOnChange(arg); + if (oldOnChange) { + oldOnChange(arg); + } return true; }; } - const columns = _columns.map((column) => { + const columns = _columns.map((column: any) => { if (!('sortable' in column)) { column.sortable = true; } @@ -78,4 +80,4 @@ export function EuiMonitoringTable({ {footerContent} ); -} +}; diff --git a/x-pack/plugins/monitoring/public/lib/get_cluster_from_clusters.d.ts b/x-pack/plugins/monitoring/public/components/table/eui_table_ssp.d.ts similarity index 68% rename from x-pack/plugins/monitoring/public/lib/get_cluster_from_clusters.d.ts rename to x-pack/plugins/monitoring/public/components/table/eui_table_ssp.d.ts index 5a310c977efae..bdc8199b3c57c 100644 --- a/x-pack/plugins/monitoring/public/lib/get_cluster_from_clusters.d.ts +++ b/x-pack/plugins/monitoring/public/components/table/eui_table_ssp.d.ts @@ -5,8 +5,6 @@ * 2.0. */ -export const getClusterFromClusters: ( - clusters: any, - globalState: State, - unsetGlobalState: boolean -) => any; +import { FunctionComponent } from 'react'; + +export const EuiMonitoringSSPTable: FunctionComponent>; diff --git a/x-pack/plugins/monitoring/public/components/table/index.js b/x-pack/plugins/monitoring/public/components/table/index.ts similarity index 100% rename from x-pack/plugins/monitoring/public/components/table/index.js rename to x-pack/plugins/monitoring/public/components/table/index.ts diff --git a/x-pack/plugins/monitoring/public/components/table/storage.js b/x-pack/plugins/monitoring/public/components/table/storage.ts similarity index 71% rename from x-pack/plugins/monitoring/public/components/table/storage.js rename to x-pack/plugins/monitoring/public/components/table/storage.ts index b9694dc5db420..411bd09872858 100644 --- a/x-pack/plugins/monitoring/public/components/table/storage.js +++ b/x-pack/plugins/monitoring/public/components/table/storage.ts @@ -8,9 +8,22 @@ import { set } from '@elastic/safer-lodash-set'; import { get } from 'lodash'; import { STORAGE_KEY } from '../../../common/constants'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -export const tableStorageGetter = (keyPrefix) => { - return (storage) => { +interface TableValues { + filterText: any; + pageIndex: any; + sortKey: any; + sortOrder: any; +} + +interface EuiTableValues { + sort: any; + page: any; +} + +export const tableStorageGetter = (keyPrefix: string) => { + return (storage: Storage): TableValues => { const localStorageData = storage.get(STORAGE_KEY) || {}; const filterText = get(localStorageData, [keyPrefix, 'filterText']); const pageIndex = get(localStorageData, [keyPrefix, 'pageIndex']); @@ -21,8 +34,8 @@ export const tableStorageGetter = (keyPrefix) => { }; }; -export const tableStorageSetter = (keyPrefix) => { - return (storage, { filterText, pageIndex, sortKey, sortOrder }) => { +export const tableStorageSetter = (keyPrefix: string) => { + return (storage: Storage, { filterText, pageIndex, sortKey, sortOrder }: TableValues) => { const localStorageData = storage.get(STORAGE_KEY) || {}; set(localStorageData, [keyPrefix, 'filterText'], filterText || undefined); // don`t store empty data @@ -36,8 +49,8 @@ export const tableStorageSetter = (keyPrefix) => { }; }; -export const euiTableStorageGetter = (keyPrefix) => { - return (storage) => { +export const euiTableStorageGetter = (keyPrefix: string) => { + return (storage: Storage): EuiTableValues => { const localStorageData = storage.get(STORAGE_KEY) || {}; const sort = get(localStorageData, [keyPrefix, 'sort']); const page = get(localStorageData, [keyPrefix, 'page']); @@ -46,8 +59,8 @@ export const euiTableStorageGetter = (keyPrefix) => { }; }; -export const euiTableStorageSetter = (keyPrefix) => { - return (storage, { sort, page }) => { +export const euiTableStorageSetter = (keyPrefix: string) => { + return (storage: Storage, { sort, page }: EuiTableValues) => { const localStorageData = storage.get(STORAGE_KEY) || {}; set(localStorageData, [keyPrefix, 'sort'], sort || undefined); // don`t store empty data diff --git a/x-pack/plugins/monitoring/public/directives/main/index.html b/x-pack/plugins/monitoring/public/directives/main/index.html index fb24d9e678d56..558ed5e874cd6 100644 --- a/x-pack/plugins/monitoring/public/directives/main/index.html +++ b/x-pack/plugins/monitoring/public/directives/main/index.html @@ -103,7 +103,7 @@

{{pageTitle || monitoringMain.instance}} => { + const url = `../api/monitoring/v1/alert/${clusterUuid}/status`; + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify({ + alertTypeIds, + filters, + timeRange, + }), + }); + return response as unknown as AlertsByName; +}; diff --git a/x-pack/plugins/monitoring/public/lib/get_cluster_from_clusters.js b/x-pack/plugins/monitoring/public/lib/get_cluster_from_clusters.ts similarity index 73% rename from x-pack/plugins/monitoring/public/lib/get_cluster_from_clusters.js rename to x-pack/plugins/monitoring/public/lib/get_cluster_from_clusters.ts index 94bd39aa769fd..93d0c5a6f790e 100644 --- a/x-pack/plugins/monitoring/public/lib/get_cluster_from_clusters.js +++ b/x-pack/plugins/monitoring/public/lib/get_cluster_from_clusters.ts @@ -6,15 +6,20 @@ */ import { find, first } from 'lodash'; +import { State } from '../application/contexts/global_state_context'; -export function getClusterFromClusters(clusters, globalState, unsetGlobalState = false) { +export function getClusterFromClusters( + clusters: any, + globalState: State, + unsetGlobalState = false +) { const cluster = (() => { const existingCurrent = find(clusters, { cluster_uuid: globalState.cluster_uuid }); if (existingCurrent) { return existingCurrent; } - const firstCluster = first(clusters); + const firstCluster: any = first(clusters); if (firstCluster && firstCluster.cluster_uuid) { return firstCluster; } @@ -25,7 +30,9 @@ export function getClusterFromClusters(clusters, globalState, unsetGlobalState = if (cluster && cluster.license) { globalState.cluster_uuid = unsetGlobalState ? undefined : cluster.cluster_uuid; globalState.ccs = unsetGlobalState ? undefined : cluster.ccs; - globalState.save(); + if (globalState.save) { + globalState.save(); + } return cluster; } diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index ce97c142f5584..4e35b84a93119 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -210,7 +210,7 @@ describe('Authenticator', () => { expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider ).toHaveBeenCalledWith(expect.anything(), { - supportedSchemes: new Set(['apikey', 'basic']), + supportedSchemes: new Set(['apikey', 'bearer', 'basic']), }); }); @@ -238,7 +238,9 @@ describe('Authenticator', () => { expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider - ).toHaveBeenCalledWith(expect.anything(), { supportedSchemes: new Set(['apikey']) }); + ).toHaveBeenCalledWith(expect.anything(), { + supportedSchemes: new Set(['apikey', 'bearer']), + }); }); it('disabled if explicitly disabled', () => { diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 4a7d8c7961cf5..1baf3fd4aac50 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -27,6 +27,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Object { @@ -80,6 +81,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Object { @@ -133,6 +135,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Object { @@ -311,6 +314,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "oidc": Object { @@ -342,6 +346,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "oidc": Object { @@ -373,6 +378,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Array [ @@ -391,6 +397,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Array [ @@ -412,6 +419,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Array [ @@ -1485,6 +1493,7 @@ describe('createConfig()', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Object { diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 07ff81e092f5f..89918e73369d3 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -269,7 +269,7 @@ export const ConfigSchema = schema.object({ http: schema.object({ enabled: schema.boolean({ defaultValue: true }), autoSchemesEnabled: schema.boolean({ defaultValue: true }), - schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey'] }), + schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey', 'bearer'] }), }), }), audit: schema.object( diff --git a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts index 0515a1e1969bf..83f09ef017b01 100644 --- a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts +++ b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts @@ -46,7 +46,7 @@ describe('Security UsageCollector', () => { authProviderCount: 1, enabledAuthProviders: ['basic'], loginSelectorEnabled: false, - httpAuthSchemes: ['apikey'], + httpAuthSchemes: ['apikey', 'bearer'], sessionIdleTimeoutInMinutes: 60, sessionLifespanInMinutes: 43200, sessionCleanupInMinutes: 60, diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts index 79ca595fbb61b..8af353a3c9531 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts @@ -6,6 +6,7 @@ */ import { + CreateExceptionListItemSchema, ExceptionListItemSchema, FoundExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; @@ -65,6 +66,19 @@ export async function getHostIsolationExceptionItems({ return entries; } +export async function createHostIsolationExceptionItem({ + http, + exception, +}: { + http: HttpStart; + exception: CreateExceptionListItemSchema; +}): Promise { + await ensureHostIsolationExceptionsListExists(http); + return http.post(EXCEPTION_LIST_ITEM_URL, { + body: JSON.stringify(exception), + }); +} + export async function deleteHostIsolationExceptionItems(http: HttpStart, id: string) { await ensureHostIsolationExceptionsListExists(http); return http.delete(EXCEPTION_LIST_ITEM_URL, { diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts index 0a9f776655371..a5fae36486f98 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts @@ -14,6 +14,20 @@ export type HostIsolationExceptionsPageDataChanged = payload: HostIsolationExceptionsPageState['entries']; }; +export type HostIsolationExceptionsFormStateChanged = + Action<'hostIsolationExceptionsFormStateChanged'> & { + payload: HostIsolationExceptionsPageState['form']['status']; + }; + +export type HostIsolationExceptionsFormEntryChanged = + Action<'hostIsolationExceptionsFormEntryChanged'> & { + payload: HostIsolationExceptionsPageState['form']['entry']; + }; + +export type HostIsolationExceptionsCreateEntry = Action<'hostIsolationExceptionsCreateEntry'> & { + payload: HostIsolationExceptionsPageState['form']['entry']; +}; + export type HostIsolationExceptionsDeleteItem = Action<'hostIsolationExceptionsMarkToDelete'> & { payload?: ExceptionListItemSchema; }; @@ -24,9 +38,10 @@ export type HostIsolationExceptionsDeleteStatusChanged = Action<'hostIsolationExceptionsDeleteStatusChanged'> & { payload: HostIsolationExceptionsPageState['deletion']['status']; }; - export type HostIsolationExceptionsPageAction = | HostIsolationExceptionsPageDataChanged + | HostIsolationExceptionsCreateEntry + | HostIsolationExceptionsFormStateChanged | HostIsolationExceptionsDeleteItem | HostIsolationExceptionsSubmitDelete | HostIsolationExceptionsDeleteStatusChanged; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts index 68a50f9c813f4..8f32d9cf8d456 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts @@ -16,6 +16,10 @@ export const initialHostIsolationExceptionsPageState = (): HostIsolationExceptio page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, filter: '', }, + form: { + entry: undefined, + status: createUninitialisedResourceState(), + }, deletion: { item: undefined, status: createUninitialisedResourceState(), diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts index 984794e074ebb..266853fdab5e2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts @@ -5,10 +5,11 @@ * 2.0. */ +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { applyMiddleware, createStore, Store } from 'redux'; -import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; +import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; import { AppAction } from '../../../../common/store/actions'; import { createSpyMiddleware, @@ -19,8 +20,13 @@ import { isLoadedResourceState, isLoadingResourceState, } from '../../../state'; -import { getHostIsolationExceptionItems, deleteHostIsolationExceptionItems } from '../service'; +import { + createHostIsolationExceptionItem, + deleteHostIsolationExceptionItems, + getHostIsolationExceptionItems, +} from '../service'; import { HostIsolationExceptionsPageState } from '../types'; +import { createEmptyHostIsolationException } from '../utils'; import { initialHostIsolationExceptionsPageState } from './builders'; import { createHostIsolationExceptionsPageMiddleware } from './middleware'; import { hostIsolationExceptionsPageReducer } from './reducer'; @@ -29,6 +35,7 @@ import { getListFetchError } from './selector'; jest.mock('../service'); const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; const deleteHostIsolationExceptionItemsMock = deleteHostIsolationExceptionItems as jest.Mock; +const createHostIsolationExceptionItemMock = createHostIsolationExceptionItem as jest.Mock; const fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); @@ -81,7 +88,7 @@ describe('Host isolation exceptions middleware', () => { }; beforeEach(() => { - getHostIsolationExceptionItemsMock.mockClear(); + getHostIsolationExceptionItemsMock.mockReset(); getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock); }); @@ -145,11 +152,74 @@ describe('Host isolation exceptions middleware', () => { }); }); + describe('When adding an item to host isolation exceptions', () => { + let entry: CreateExceptionListItemSchema; + beforeEach(() => { + createHostIsolationExceptionItemMock.mockReset(); + entry = { + ...createEmptyHostIsolationException(), + name: 'test name', + description: 'description', + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'match', + value: '10.0.0.1', + }, + ], + }; + }); + it('should dispatch a form loading state when an entry is submited', async () => { + const waiter = spyMiddleware.waitForAction('hostIsolationExceptionsFormStateChanged', { + validate({ payload }) { + return isLoadingResourceState(payload); + }, + }); + store.dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + await waiter; + }); + it('should dispatch a form success state when an entry is confirmed by the API', async () => { + const waiter = spyMiddleware.waitForAction('hostIsolationExceptionsFormStateChanged', { + validate({ payload }) { + return isLoadedResourceState(payload); + }, + }); + store.dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + await waiter; + expect(createHostIsolationExceptionItemMock).toHaveBeenCalledWith({ + http: fakeCoreStart.http, + exception: entry, + }); + }); + it('should dispatch a form failure state when an entry is rejected by the API', async () => { + createHostIsolationExceptionItemMock.mockRejectedValue({ + body: { message: 'error message', statusCode: 500, error: 'Not today' }, + }); + const waiter = spyMiddleware.waitForAction('hostIsolationExceptionsFormStateChanged', { + validate({ payload }) { + return isFailedResourceState(payload); + }, + }); + store.dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + await waiter; + }); + }); + describe('When deleting an item from host isolation exceptions', () => { beforeEach(() => { - deleteHostIsolationExceptionItemsMock.mockClear(); + deleteHostIsolationExceptionItemsMock.mockReset(); deleteHostIsolationExceptionItemsMock.mockReturnValue(undefined); - getHostIsolationExceptionItemsMock.mockClear(); + getHostIsolationExceptionItemsMock.mockReset(); getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock); store.dispatch({ type: 'hostIsolationExceptionsMarkToDelete', diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts index 4946cac488700..bbc754e8155b0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts @@ -6,11 +6,13 @@ */ import { + CreateExceptionListItemSchema, ExceptionListItemSchema, FoundExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { CoreStart, HttpSetup, HttpStart } from 'kibana/public'; import { matchPath } from 'react-router-dom'; +import { transformNewItemOutput } from '@kbn/securitysolution-list-hooks'; import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; import { ImmutableMiddleware, ImmutableMiddlewareAPI } from '../../../../common/store'; import { AppAction } from '../../../../common/store/actions'; @@ -20,7 +22,11 @@ import { createFailedResourceState, createLoadedResourceState, } from '../../../state/async_resource_builders'; -import { deleteHostIsolationExceptionItems, getHostIsolationExceptionItems } from '../service'; +import { + deleteHostIsolationExceptionItems, + getHostIsolationExceptionItems, + createHostIsolationExceptionItem, +} from '../service'; import { HostIsolationExceptionsPageState } from '../types'; import { getCurrentListPageDataState, getCurrentLocation, getItemToDelete } from './selector'; @@ -39,12 +45,50 @@ export const createHostIsolationExceptionsPageMiddleware = ( if (action.type === 'userChangedUrl' && isHostIsolationExceptionsPage(action.payload)) { loadHostIsolationExceptionsList(store, coreStart.http); } + + if (action.type === 'hostIsolationExceptionsCreateEntry') { + createHostIsolationException(store, coreStart.http); + } + if (action.type === 'hostIsolationExceptionsSubmitDelete') { deleteHostIsolationExceptionsItem(store, coreStart.http); } }; }; +async function createHostIsolationException( + store: ImmutableMiddlewareAPI, + http: HttpStart +) { + const { dispatch } = store; + const entry = transformNewItemOutput( + store.getState().form.entry as CreateExceptionListItemSchema + ); + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'LoadingResourceState', + // @ts-expect-error-next-line will be fixed with when AsyncResourceState is refactored (#830) + previousState: entry, + }, + }); + try { + const response = await createHostIsolationExceptionItem({ + http, + exception: entry, + }); + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: createLoadedResourceState(response), + }); + } catch (error) { + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: createFailedResourceState(error.body ?? error), + }); + } +} + async function loadHostIsolationExceptionsList( store: ImmutableMiddlewareAPI, http: HttpStart diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts index 211b03f36d965..98b459fac41d3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts @@ -11,6 +11,7 @@ import { initialHostIsolationExceptionsPageState } from './builders'; import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; import { hostIsolationExceptionsPageReducer } from './reducer'; import { getCurrentLocation } from './selector'; +import { createEmptyHostIsolationException } from '../utils'; describe('Host Isolation Exceptions Reducer', () => { let initialState: HostIsolationExceptionsPageState; @@ -41,4 +42,13 @@ describe('Host Isolation Exceptions Reducer', () => { }); }); }); + it('should set an initial loading state when creating new entries', () => { + const entry = createEmptyHostIsolationException(); + const result = hostIsolationExceptionsPageReducer(initialState, { + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + expect(result.form.status).toEqual({ type: 'UninitialisedResourceState' }); + expect(result.form.entry).toBe(entry); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts index 09182661a80b3..d97295598f445 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts @@ -38,6 +38,24 @@ export const hostIsolationExceptionsPageReducer: StateReducer = ( action ) => { switch (action.type) { + case 'hostIsolationExceptionsCreateEntry': { + return { + ...state, + form: { + entry: action.payload, + status: createUninitialisedResourceState(), + }, + }; + } + case 'hostIsolationExceptionsFormStateChanged': { + return { + ...state, + form: { + ...state.form, + status: action.payload, + }, + }; + } case 'hostIsolationExceptionsPageDataChanged': { return { ...state, diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts index 443a86fefab83..1a74042fb652e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts @@ -6,6 +6,7 @@ */ import type { + CreateExceptionListItemSchema, ExceptionListItemSchema, FoundExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; @@ -27,4 +28,8 @@ export interface HostIsolationExceptionsPageState { item?: ExceptionListItemSchema; status: AsyncResourceState; }; + form: { + entry?: CreateExceptionListItemSchema; + status: AsyncResourceState; + }; } diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.ts new file mode 100644 index 0000000000000..bfb1ac048e286 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import ipaddr from 'ipaddr.js'; + +export function createEmptyHostIsolationException(): CreateExceptionListItemSchema { + return { + comments: [], + description: '', + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'match', + value: '', + }, + ], + item_id: undefined, + list_id: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + name: '', + namespace_type: 'agnostic', + os_types: ['windows', 'linux', 'macos'], + tags: ['policy:all'], + type: 'simple', + }; +} + +export function isValidIPv4OrCIDR(maybeIp: string): boolean { + try { + ipaddr.IPv4.parseCIDR(maybeIp); + return true; + } catch (e) { + return ipaddr.IPv4.isValid(maybeIp); + } +} diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx index d7c512794173c..eb53268a9fbd8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx @@ -7,7 +7,7 @@ import React, { memo } from 'react'; import styled, { css } from 'styled-components'; -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; const EmptyPrompt = styled(EuiEmptyPrompt)` @@ -16,7 +16,7 @@ const EmptyPrompt = styled(EuiEmptyPrompt)` `} `; -export const HostIsolationExceptionsEmptyState = memo<{}>(() => { +export const HostIsolationExceptionsEmptyState = memo<{ onAdd: () => void }>(({ onAdd }) => { return ( (() => { defaultMessage="There are currently no host isolation exceptions" /> } + actions={ + + + + } /> ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx new file mode 100644 index 0000000000000..b06449de69d8c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createEmptyHostIsolationException } from '../../utils'; +import { HostIsolationExceptionsForm } from './form'; +import React from 'react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import userEvent from '@testing-library/user-event'; + +describe('When on the host isolation exceptions add entry form', () => { + let render: ( + exception: CreateExceptionListItemSchema + ) => ReturnType; + let renderResult: ReturnType; + const onChange = jest.fn(); + const onError = jest.fn(); + + beforeEach(() => { + onChange.mockReset(); + onError.mockReset(); + const mockedContext = createAppRootMockRenderer(); + render = (exception: CreateExceptionListItemSchema) => { + return mockedContext.render( + + ); + }; + }); + + describe('When creating a new exception', () => { + let newException: CreateExceptionListItemSchema; + beforeEach(() => { + newException = createEmptyHostIsolationException(); + renderResult = render(newException); + }); + it('should render the form with empty inputs', () => { + expect(renderResult.getByTestId('hostIsolationExceptions-form-name-input')).toHaveValue(''); + expect(renderResult.getByTestId('hostIsolationExceptions-form-ip-input')).toHaveValue(''); + expect( + renderResult.getByTestId('hostIsolationExceptions-form-description-input') + ).toHaveValue(''); + }); + it('should call onError with true when a wrong ip value is introduced', () => { + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + userEvent.type(ipInput, 'not an ip'); + expect(onError).toHaveBeenCalledWith(true); + }); + it('should call onError with false when a correct values are introduced', () => { + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + const nameInput = renderResult.getByTestId('hostIsolationExceptions-form-name-input'); + + userEvent.type(nameInput, 'test name'); + userEvent.type(ipInput, '10.0.0.1'); + + expect(onError).toHaveBeenLastCalledWith(false); + }); + it('should call onChange when a value is introduced in a field', () => { + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + userEvent.type(ipInput, '10.0.0.1'); + expect(onChange).toHaveBeenLastCalledWith({ + ...newException, + entries: [ + { field: 'destination.ip', operator: 'included', type: 'match', value: '10.0.0.1' }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx new file mode 100644 index 0000000000000..84263f9d07c81 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiFieldText, + EuiForm, + EuiFormRow, + EuiHorizontalRule, + EuiSpacer, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { isValidIPv4OrCIDR } from '../../utils'; +import { + DESCRIPTION_LABEL, + DESCRIPTION_PLACEHOLDER, + IP_ERROR, + IP_LABEL, + IP_PLACEHOLDER, + NAME_ERROR, + NAME_LABEL, + NAME_PLACEHOLDER, +} from './translations'; + +interface ExceptionIpEntry { + field: 'destination.ip'; + operator: 'included'; + type: 'match'; + value: ''; +} + +export const HostIsolationExceptionsForm: React.FC<{ + exception: CreateExceptionListItemSchema; + onError: (error: boolean) => void; + onChange: (exception: CreateExceptionListItemSchema) => void; +}> = memo(({ exception, onError, onChange }) => { + const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); + const [hasBeenInputIpVisited, setHasBeenInputIpVisited] = useState(false); + const [hasNameError, setHasNameError] = useState(true); + const [hasIpError, setHasIpError] = useState(true); + + useEffect(() => { + onError(hasNameError || hasIpError); + }, [hasNameError, hasIpError, onError]); + + const handleOnChangeName = useCallback( + (event: React.ChangeEvent) => { + const name = event.target.value; + if (!name.trim()) { + setHasNameError(true); + return; + } + setHasNameError(false); + onChange({ ...exception, name }); + }, + [exception, onChange] + ); + + const handleOnIpChange = useCallback( + (event: React.ChangeEvent) => { + const ip = event.target.value; + if (!isValidIPv4OrCIDR(ip)) { + setHasIpError(true); + return; + } + setHasIpError(false); + onChange({ + ...exception, + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'match', + value: ip, + }, + ], + }); + }, + [exception, onChange] + ); + + const handleOnDescriptionChange = useCallback( + (event: React.ChangeEvent) => { + onChange({ ...exception, description: event.target.value }); + }, + [exception, onChange] + ); + + const nameInput = useMemo( + () => ( + + !hasBeenInputNameVisited && setHasBeenInputNameVisited(true)} + /> + + ), + [hasNameError, hasBeenInputNameVisited, exception.name, handleOnChangeName] + ); + + const ipInput = useMemo( + () => ( + + !hasBeenInputIpVisited && setHasBeenInputIpVisited(true)} + /> + + ), + [hasIpError, hasBeenInputIpVisited, exception.entries, handleOnIpChange] + ); + + const descriptionInput = useMemo( + () => ( + + + + ), + [exception.description, handleOnDescriptionChange] + ); + + return ( + + +

+ +

+
+ + + + + {nameInput} + {descriptionInput} + + + +

+ +

+
+ + + + + {ipInput} +
+ ); +}); + +HostIsolationExceptionsForm.displayName = 'HostIsolationExceptionsForm'; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx new file mode 100644 index 0000000000000..6cfc9f56beadf --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import userEvent from '@testing-library/user-event'; +import { HostIsolationExceptionsFormFlyout } from './form_flyout'; +import { act } from 'react-dom/test-utils'; +import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../../common/constants'; + +jest.mock('../../service.ts'); + +describe('When on the host isolation exceptions flyout form', () => { + let mockedContext: AppContextTestRender; + let render: () => ReturnType; + let renderResult: ReturnType; + let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + + // const createHostIsolationExceptionItemMock = createHostIsolationExceptionItem as jest.mock; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + render = () => { + return mockedContext.render(); + }; + waitForAction = mockedContext.middlewareSpy.waitForAction; + }); + + describe('When creating a new exception', () => { + describe('with invalid data', () => { + it('should show disabled buttons when the form first load', () => { + renderResult = render(); + expect(renderResult.getByTestId('add-exception-cancel-button')).not.toHaveAttribute( + 'disabled' + ); + expect(renderResult.getByTestId('add-exception-confirm-button')).toHaveAttribute( + 'disabled' + ); + }); + }); + describe('with valid data', () => { + beforeEach(() => { + renderResult = render(); + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + const nameInput = renderResult.getByTestId('hostIsolationExceptions-form-name-input'); + userEvent.type(nameInput, 'test name'); + userEvent.type(ipInput, '10.0.0.1'); + }); + it('should show enable buttons when the form is valid', () => { + expect(renderResult.getByTestId('add-exception-cancel-button')).not.toHaveAttribute( + 'disabled' + ); + expect(renderResult.getByTestId('add-exception-confirm-button')).not.toHaveAttribute( + 'disabled' + ); + }); + it('should submit the entry data when submit is pressed with valid data', async () => { + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton).not.toHaveAttribute('disabled'); + const waiter = waitForAction('hostIsolationExceptionsCreateEntry'); + userEvent.click(confirmButton); + await waiter; + }); + it('should disable the submit button when an operation is in progress', () => { + act(() => { + mockedContext.store.dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }); + }); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton).toHaveAttribute('disabled'); + }); + it('should show a toast and close the flyout when the operation is finished', () => { + mockedContext.history.push(`${HOST_ISOLATION_EXCEPTIONS_PATH}?show=create`); + act(() => { + mockedContext.store.dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'LoadedResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }); + }); + expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); + expect(mockedContext.history.location.search).toBe(''); + }); + it('should show an error toast operation fails and enable the submit button', () => { + act(() => { + mockedContext.store.dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'FailedResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }); + }); + expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalled(); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton).not.toHaveAttribute('disabled'); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx new file mode 100644 index 0000000000000..5502a1b8ea2b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; +import { Loader } from '../../../../../common/components/loader'; +import { useToasts } from '../../../../../common/lib/kibana'; +import { + isFailedResourceState, + isLoadedResourceState, + isLoadingResourceState, +} from '../../../../state/async_resource_state'; +import { HostIsolationExceptionsPageAction } from '../../store/action'; +import { createEmptyHostIsolationException } from '../../utils'; +import { + useHostIsolationExceptionsNavigateCallback, + useHostIsolationExceptionsSelector, +} from '../hooks'; +import { HostIsolationExceptionsForm } from './form'; + +export const HostIsolationExceptionsFormFlyout: React.FC<{}> = memo(() => { + const dispatch = useDispatch>(); + const toasts = useToasts(); + + const creationInProgress = useHostIsolationExceptionsSelector((state) => + isLoadingResourceState(state.form.status) + ); + const creationSuccessful = useHostIsolationExceptionsSelector((state) => + isLoadedResourceState(state.form.status) + ); + const creationFailure = useHostIsolationExceptionsSelector((state) => + isFailedResourceState(state.form.status) + ); + + const navigateCallback = useHostIsolationExceptionsNavigateCallback(); + + const [formHasError, setFormHasError] = useState(true); + const [exception, setException] = useState(undefined); + + const onCancel = useCallback( + () => + navigateCallback({ + show: undefined, + id: undefined, + }), + [navigateCallback] + ); + + useEffect(() => { + setException(createEmptyHostIsolationException()); + }, []); + + useEffect(() => { + if (creationSuccessful) { + onCancel(); + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'UninitialisedResourceState', + }, + }); + toasts.addSuccess( + i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.creationSuccessToastTitle', + { + defaultMessage: '"{name}" has been added to the host isolation exceptions list.', + values: { name: exception?.name }, + } + ) + ); + } + }, [creationSuccessful, onCancel, dispatch, toasts, exception?.name]); + + useEffect(() => { + if (creationFailure) { + toasts.addDanger( + i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.creationFailureToastTitle', + { + defaultMessage: 'There was an error creating the exception', + } + ) + ); + } + }, [dispatch, toasts, creationFailure]); + + const handleOnCancel = useCallback(() => { + if (creationInProgress) return; + onCancel(); + }, [creationInProgress, onCancel]); + + const handleOnSubmit = useCallback(() => { + dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: exception, + }); + }, [dispatch, exception]); + + const confirmButtonMemo = useMemo( + () => ( + + + + ), + [formHasError, creationInProgress, handleOnSubmit] + ); + + return exception ? ( + + + +

+ +

+
+
+ + + + + + + + + + + + + {confirmButtonMemo} + + +
+ ) : ( + + ); +}); + +HostIsolationExceptionsFormFlyout.displayName = 'HostIsolationExceptionsFormFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts new file mode 100644 index 0000000000000..df179c7a2221c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NAME_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.name.placeholder', + { + defaultMessage: 'New IP', + } +); + +export const NAME_LABEL = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.name.label', + { + defaultMessage: 'Name your host isolation exceptions', + } +); + +export const NAME_ERROR = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.name.error', + { + defaultMessage: "The name can't be empty", + } +); + +export const DESCRIPTION_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.description.placeholder', + { + defaultMessage: 'Describe your Host Isolation Exception', + } +); + +export const DESCRIPTION_LABEL = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.description.label', + { + defaultMessage: 'Description', + } +); + +export const IP_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.ip.placeholder', + { + defaultMessage: 'Ex 0.0.0.0/24', + } +); + +export const IP_LABEL = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.ip.label', + { + defaultMessage: 'Enter IP Address', + } +); + +export const IP_ERROR = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.ip.error', + { + defaultMessage: 'The ip is invalid. Only IPv4 with optional CIDR is supported', + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx index 53b8bc33c252f..ac472fdae4d7b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx @@ -5,16 +5,18 @@ * 2.0. */ -import React from 'react'; import { act } from '@testing-library/react'; -import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; -import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { isFailedResourceState, isLoadedResourceState } from '../../../state'; import { getHostIsolationExceptionItems } from '../service'; -import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; +import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; jest.mock('../service'); + const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; describe('When on the host isolation exceptions page', () => { @@ -103,5 +105,17 @@ describe('When on the host isolation exceptions page', () => { ).toEqual(' Server is too far away'); }); }); + it('should show the create flyout when the add button is pressed', () => { + render(); + act(() => { + userEvent.click(renderResult.getByTestId('hostIsolationExceptionsListAddButton')); + }); + expect(renderResult.getByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeTruthy(); + }); + it('should show the create flyout when the show location is create', () => { + history.push(`${HOST_ISOLATION_EXCEPTIONS_PATH}?show=create`); + render(); + expect(renderResult.getByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx index 53fb74d5bd8f7..cfb0121396e24 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx @@ -8,7 +8,7 @@ import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { i18n } from '@kbn/i18n'; import React, { Dispatch, useCallback } from 'react'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useDispatch } from 'react-redux'; import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item'; @@ -32,6 +32,7 @@ import { ArtifactEntryCard, ArtifactEntryCardProps } from '../../../components/a import { HostIsolationExceptionsEmptyState } from './components/empty'; import { HostIsolationExceptionsPageAction } from '../store/action'; import { HostIsolationExceptionDeleteModal } from './components/delete_modal'; +import { HostIsolationExceptionsFormFlyout } from './components/form_flyout'; type HostIsolationExceptionPaginatedContent = PaginatedContentProps< Immutable, @@ -54,6 +55,8 @@ export const HostIsolationExceptionsList = () => { const dispatch = useDispatch>(); const itemToDelete = useHostIsolationExceptionsSelector(getItemToDelete); + const showFlyout = !!location.show; + const navigateCallback = useHostIsolationExceptionsNavigateCallback(); const handleOnSearch = useCallback( @@ -92,6 +95,15 @@ export const HostIsolationExceptionsList = () => { [navigateCallback] ); + const handleAddButtonClick = useCallback( + () => + navigateCallback({ + show: 'create', + id: undefined, + }), + [navigateCallback] + ); + return ( { defaultMessage="Host Isolation Exceptions" /> } - actions={[]} + actions={ + + + + } > + {showFlyout && } + { pagination={pagination} contentClassName="host-isolation-exceptions-container" data-test-subj="hostIsolationExceptionsContent" - noItemsMessage={} + noItemsMessage={} /> ); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts b/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts index 771e3e059c336..91f83d5e7cb37 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts @@ -7,12 +7,14 @@ export const TELEMETRY_MAX_BUFFER_SIZE = 100; -export const TELEMETRY_CHANNEL_LISTS = 'security-lists'; +export const TELEMETRY_CHANNEL_LISTS = 'security-lists-v2'; export const TELEMETRY_CHANNEL_ENDPOINT_META = 'endpoint-metadata'; -export const LIST_TRUSTED_APPLICATION = 'trusted_application'; +export const LIST_DETECTION_RULE_EXCEPTION = 'detection_rule_exception'; export const LIST_ENDPOINT_EXCEPTION = 'endpoint_exception'; export const LIST_ENDPOINT_EVENT_FILTER = 'endpoint_event_filter'; + +export const LIST_TRUSTED_APPLICATION = 'trusted_application'; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts index 61172fac511f7..a29f195ed5ecc 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts @@ -129,13 +129,12 @@ export const allowlistEventFields: AllowlistFields = { export const exceptionListEventFields: AllowlistFields = { created_at: true, - description: true, effectScope: true, entries: true, id: true, name: true, - os: true, os_types: true, + rule_version: true, }; /** @@ -143,7 +142,7 @@ export const exceptionListEventFields: AllowlistFields = { * * @param allowlist * @param event - * @returns + * @returns TelemetryEvent with explicitly required fields */ export function copyAllowlistedFields( allowlist: AllowlistFields, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts index 647219e8c5585..528082d8cb5b1 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts @@ -8,13 +8,14 @@ import moment from 'moment'; import { createMockPackagePolicy } from './mocks'; import { + LIST_DETECTION_RULE_EXCEPTION, LIST_ENDPOINT_EXCEPTION, LIST_ENDPOINT_EVENT_FILTER, LIST_TRUSTED_APPLICATION, } from './constants'; import { getPreviousDiagTaskTimestamp, - getPreviousEpMetaTaskTimestamp, + getPreviousDailyTaskTimestamp, batchTelemetryRecords, isPackagePolicyList, templateExceptionList, @@ -53,7 +54,7 @@ describe('test endpoint meta telemetry scheduled task timing helper', () => { test('test -24 hours is returned when there is no previous task run', async () => { const executeTo = moment().utc().toISOString(); const executeFrom = undefined; - const newExecuteFrom = getPreviousEpMetaTaskTimestamp(executeTo, executeFrom); + const newExecuteFrom = getPreviousDailyTaskTimestamp(executeTo, executeFrom); expect(newExecuteFrom).toEqual(moment(executeTo).subtract(24, 'hours').toISOString()); }); @@ -61,7 +62,7 @@ describe('test endpoint meta telemetry scheduled task timing helper', () => { test('test -24 hours is returned when there was a previous task run', async () => { const executeTo = moment().utc().toISOString(); const executeFrom = moment(executeTo).subtract(24, 'hours').toISOString(); - const newExecuteFrom = getPreviousEpMetaTaskTimestamp(executeTo, executeFrom); + const newExecuteFrom = getPreviousDailyTaskTimestamp(executeTo, executeFrom); expect(newExecuteFrom).toEqual(executeFrom); }); @@ -71,7 +72,7 @@ describe('test endpoint meta telemetry scheduled task timing helper', () => { test('test 24 hours is returned when previous task run took longer than 24 hours', async () => { const executeTo = moment().utc().toISOString(); const executeFrom = moment(executeTo).subtract(72, 'hours').toISOString(); // down 3 days - const newExecuteFrom = getPreviousEpMetaTaskTimestamp(executeTo, executeFrom); + const newExecuteFrom = getPreviousDailyTaskTimestamp(executeTo, executeFrom); expect(newExecuteFrom).toEqual(moment(executeTo).subtract(24, 'hours').toISOString()); }); @@ -134,61 +135,88 @@ describe('test package policy type guard', () => { }); describe('list telemetry schema', () => { + test('detection rules document is correctly formed', () => { + const data = [{ id: 'test_1' }] as ExceptionListItem[]; + const templatedItems = templateExceptionList(data, LIST_DETECTION_RULE_EXCEPTION); + + expect(templatedItems[0]?.detection_rule).not.toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); + }); + + test('detection rules document is correctly formed with multiple entries', () => { + const data = [{ id: 'test_2' }, { id: 'test_2' }] as ExceptionListItem[]; + const templatedItems = templateExceptionList(data, LIST_DETECTION_RULE_EXCEPTION); + + expect(templatedItems[0]?.detection_rule).not.toBeUndefined(); + expect(templatedItems[1]?.detection_rule).not.toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); + }); + test('trusted apps document is correctly formed', () => { const data = [{ id: 'test_1' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_TRUSTED_APPLICATION); - expect(templatedItems[0]?.trusted_application.length).toEqual(1); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(0); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).not.toBeUndefined(); }); test('trusted apps document is correctly formed with multiple entries', () => { const data = [{ id: 'test_2' }, { id: 'test_2' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_TRUSTED_APPLICATION); - expect(templatedItems[0]?.trusted_application.length).toEqual(1); - expect(templatedItems[1]?.trusted_application.length).toEqual(1); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(0); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).not.toBeUndefined(); + expect(templatedItems[1]?.trusted_application).not.toBeUndefined(); }); test('endpoint exception document is correctly formed', () => { const data = [{ id: 'test_3' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EXCEPTION); - expect(templatedItems[0]?.trusted_application.length).toEqual(0); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(1); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(0); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).not.toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); }); test('endpoint exception document is correctly formed with multiple entries', () => { const data = [{ id: 'test_4' }, { id: 'test_4' }, { id: 'test_4' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EXCEPTION); - expect(templatedItems[0]?.trusted_application.length).toEqual(0); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(1); - expect(templatedItems[1]?.endpoint_exception.length).toEqual(1); - expect(templatedItems[2]?.endpoint_exception.length).toEqual(1); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(0); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).not.toBeUndefined(); + expect(templatedItems[1]?.endpoint_exception).not.toBeUndefined(); + expect(templatedItems[2]?.endpoint_exception).not.toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); }); test('endpoint event filters document is correctly formed', () => { const data = [{ id: 'test_5' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EVENT_FILTER); - expect(templatedItems[0]?.trusted_application.length).toEqual(0); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(1); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).not.toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); }); test('endpoint event filters document is correctly formed with multiple entries', () => { const data = [{ id: 'test_6' }, { id: 'test_6' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EVENT_FILTER); - expect(templatedItems[0]?.trusted_application.length).toEqual(0); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(1); - expect(templatedItems[1]?.endpoint_event_filter.length).toEqual(1); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).not.toBeUndefined(); + expect(templatedItems[1]?.endpoint_event_filter).not.toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts index a9eaef3ce6edc..e72b0ba7d16fe 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts @@ -11,6 +11,7 @@ import { PackagePolicy } from '../../../../fleet/common/types/models/package_pol import { copyAllowlistedFields, exceptionListEventFields } from './filters'; import { ExceptionListItem, ListTemplate, TelemetryEvent } from './types'; import { + LIST_DETECTION_RULE_EXCEPTION, LIST_ENDPOINT_EXCEPTION, LIST_ENDPOINT_EVENT_FILTER, LIST_TRUSTED_APPLICATION, @@ -46,7 +47,7 @@ export const getPreviousDiagTaskTimestamp = ( * @param lastExecutionTimestamp * @returns the timestamp to search from */ -export const getPreviousEpMetaTaskTimestamp = ( +export const getPreviousDailyTaskTimestamp = ( executeTo: string, lastExecutionTimestamp?: string ) => { @@ -97,18 +98,16 @@ export function isPackagePolicyList( * Maps trusted application to shared telemetry object * * @param exceptionListItem - * @returns collection of endpoint exceptions + * @returns collection of trusted applications */ export const trustedApplicationToTelemetryEntry = (trustedApplication: TrustedApp) => { return { id: trustedApplication.id, - version: trustedApplication.version || '', name: trustedApplication.name, - description: trustedApplication.description, created_at: trustedApplication.created_at, updated_at: trustedApplication.updated_at, entries: trustedApplication.entries, - os: trustedApplication.os, + os_types: [trustedApplication.os], } as ExceptionListItem; }; @@ -121,9 +120,29 @@ export const trustedApplicationToTelemetryEntry = (trustedApplication: TrustedAp export const exceptionListItemToTelemetryEntry = (exceptionListItem: ExceptionListItemSchema) => { return { id: exceptionListItem.id, - version: exceptionListItem._version || '', name: exceptionListItem.name, - description: exceptionListItem.description, + created_at: exceptionListItem.created_at, + updated_at: exceptionListItem.updated_at, + entries: exceptionListItem.entries, + os_types: exceptionListItem.os_types, + } as ExceptionListItem; +}; + +/** + * Maps detection rule exception list items to shared telemetry object + * + * @param exceptionListItem + * @param ruleVersion + * @returns collection of detection rule exceptions + */ +export const ruleExceptionListItemToTelemetryEvent = ( + exceptionListItem: ExceptionListItemSchema, + ruleVersion: number +) => { + return { + id: exceptionListItem.item_id, + name: exceptionListItem.description, + rule_version: ruleVersion, created_at: exceptionListItem.created_at, updated_at: exceptionListItem.updated_at, entries: exceptionListItem.entries, @@ -141,9 +160,7 @@ export const exceptionListItemToTelemetryEntry = (exceptionListItem: ExceptionLi export const templateExceptionList = (listData: ExceptionListItem[], listType: string) => { return listData.map((item) => { const template: ListTemplate = { - trusted_application: [], - endpoint_exception: [], - endpoint_event_filter: [], + '@timestamp': new Date().getTime(), }; // cast exception list type to a TelemetryEvent for allowlist filtering @@ -152,18 +169,23 @@ export const templateExceptionList = (listData: ExceptionListItem[], listType: s item as unknown as TelemetryEvent ); + if (listType === LIST_DETECTION_RULE_EXCEPTION) { + template.detection_rule = filteredListItem; + return template; + } + if (listType === LIST_TRUSTED_APPLICATION) { - template.trusted_application.push(filteredListItem); + template.trusted_application = filteredListItem; return template; } if (listType === LIST_ENDPOINT_EXCEPTION) { - template.endpoint_exception.push(filteredListItem); + template.endpoint_exception = filteredListItem; return template; } if (listType === LIST_ENDPOINT_EVENT_FILTER) { - template.endpoint_event_filter.push(filteredListItem); + template.endpoint_event_filter = filteredListItem; return template; } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts b/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts index 20a71657b2ffe..9168683141e48 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts @@ -8,7 +8,7 @@ // eslint-disable-next-line max-classes-per-file import { TelemetryEventsSender } from './sender'; import { TelemetryReceiver } from './receiver'; -import { DiagnosticTask, EndpointTask, ExceptionListsTask } from './tasks'; +import { DiagnosticTask, EndpointTask, ExceptionListsTask, DetectionRulesTask } from './tasks'; import { PackagePolicy } from '../../../../fleet/common/types/models/package_policy'; /** @@ -40,6 +40,8 @@ export const createMockTelemetryReceiver = (): jest.Mocked => fetchEndpointMetrics: jest.fn(), fetchEndpointPolicyResponses: jest.fn(), fetchTrustedApplications: jest.fn(), + fetchDetectionRules: jest.fn(), + fetchDetectionExceptionList: jest.fn(), } as unknown as jest.Mocked; }; @@ -79,3 +81,10 @@ export class MockTelemetryEndpointTask extends EndpointTask { export class MockExceptionListsTask extends ExceptionListsTask { public runTask = jest.fn(); } + +/** + * Creates a mocked Telemetry detection rules lists Task + */ +export class MockDetectionRuleListsTask extends DetectionRulesTask { + public runTask = jest.fn(); +} diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts index 038b7687784f4..94aa6c867304f 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts @@ -17,8 +17,18 @@ import { AgentService, AgentPolicyServiceInterface } from '../../../../fleet/ser import { ExceptionListClient } from '../../../../lists/server'; import { EndpointAppContextService } from '../../endpoint/endpoint_app_context_services'; import { TELEMETRY_MAX_BUFFER_SIZE } from './constants'; -import { exceptionListItemToTelemetryEntry, trustedApplicationToTelemetryEntry } from './helpers'; -import { TelemetryEvent, ESLicense, ESClusterInfo, GetEndpointListResponse } from './types'; +import { + exceptionListItemToTelemetryEntry, + trustedApplicationToTelemetryEntry, + ruleExceptionListItemToTelemetryEvent, +} from './helpers'; +import { + TelemetryEvent, + ESLicense, + ESClusterInfo, + GetEndpointListResponse, + RuleSearchResult, +} from './types'; export class TelemetryReceiver { private readonly logger: Logger; @@ -27,6 +37,7 @@ export class TelemetryReceiver { private esClient?: ElasticsearchClient; private exceptionListClient?: ExceptionListClient; private soClient?: SavedObjectsClientContract; + private kibanaIndex?: string; private readonly max_records = 10_000; constructor(logger: Logger) { @@ -35,9 +46,11 @@ export class TelemetryReceiver { public async start( core?: CoreStart, + kibanaIndex?: string, endpointContextService?: EndpointAppContextService, exceptionListClient?: ExceptionListClient ) { + this.kibanaIndex = kibanaIndex; this.agentService = endpointContextService?.getAgentService(); this.agentPolicyService = endpointContextService?.getAgentPolicyService(); this.esClient = core?.elasticsearch.client.asInternalUser; @@ -240,6 +253,57 @@ export class TelemetryReceiver { }; } + public async fetchDetectionRules() { + if (this.esClient === undefined || this.esClient === null) { + throw Error('elasticsearch client is unavailable: cannot retrieve diagnostic alerts'); + } + + const query: SearchRequest = { + expand_wildcards: 'open,hidden', + index: `${this.kibanaIndex}*`, + ignore_unavailable: true, + size: this.max_records, + body: { + query: { + bool: { + filter: [ + { term: { 'alert.alertTypeId': 'siem.signals' } }, + { term: { 'alert.params.immutable': true } }, + ], + }, + }, + }, + }; + + return this.esClient.search(query); + } + + public async fetchDetectionExceptionList(listId: string, ruleVersion: number) { + if (this?.exceptionListClient === undefined || this?.exceptionListClient === null) { + throw Error('exception list client is unavailable: could not retrieve trusted applications'); + } + + // Ensure list is created if it does not exist + await this.exceptionListClient.createTrustedAppsList(); + + const results = await this.exceptionListClient?.findExceptionListsItem({ + listId: [listId], + filter: [], + perPage: this.max_records, + page: 1, + sortField: 'exception-list.created_at', + sortOrder: 'desc', + namespaceType: ['single'], + }); + + return { + data: results?.data.map((r) => ruleExceptionListItemToTelemetryEvent(r, ruleVersion)) ?? [], + total: results?.total ?? 0, + page: results?.page ?? 1, + per_page: results?.per_page ?? this.max_records, + }; + } + public async fetchClusterInfo(): Promise { if (this.esClient === undefined || this.esClient === null) { throw Error('elasticsearch client is unavailable: cannot retrieve cluster infomation'); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts index 0037aaa28fee3..b0792ed7b4610 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts @@ -18,7 +18,7 @@ import { } from '../../../../task_manager/server'; import { TelemetryReceiver } from './receiver'; import { allowlistEventFields, copyAllowlistedFields } from './filters'; -import { DiagnosticTask, EndpointTask, ExceptionListsTask } from './tasks'; +import { DiagnosticTask, EndpointTask, ExceptionListsTask, DetectionRulesTask } from './tasks'; import { createUsageCounterLabel } from './helpers'; import { TelemetryEvent } from './types'; import { TELEMETRY_MAX_BUFFER_SIZE } from './constants'; @@ -42,6 +42,7 @@ export class TelemetryEventsSender { private diagnosticTask?: DiagnosticTask; private endpointTask?: EndpointTask; private exceptionListsTask?: ExceptionListsTask; + private detectionRulesTask?: DetectionRulesTask; constructor(logger: Logger) { this.logger = logger.get('telemetry_events'); @@ -59,6 +60,12 @@ export class TelemetryEventsSender { if (taskManager) { this.diagnosticTask = new DiagnosticTask(this.logger, taskManager, this, telemetryReceiver); this.endpointTask = new EndpointTask(this.logger, taskManager, this, telemetryReceiver); + this.detectionRulesTask = new DetectionRulesTask( + this.logger, + taskManager, + this, + telemetryReceiver + ); this.exceptionListsTask = new ExceptionListsTask( this.logger, taskManager, @@ -80,6 +87,7 @@ export class TelemetryEventsSender { this.logger.debug(`starting security telemetry tasks`); this.diagnosticTask.start(taskManager); this.endpointTask.start(taskManager); + this.detectionRulesTask?.start(taskManager); this.exceptionListsTask?.start(taskManager); } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.ts new file mode 100644 index 0000000000000..0a05afb8a6535 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from 'src/core/server/mocks'; +import { taskManagerMock } from '../../../../../task_manager/server/mocks'; +import { TaskStatus } from '../../../../../task_manager/server'; +import { + TelemetryDetectionRulesTask, + TelemetryDetectionRuleListsTaskConstants, +} from './detection_rule'; +import { + createMockTelemetryEventsSender, + MockDetectionRuleListsTask, + createMockTelemetryReceiver, +} from '../mocks'; + +describe('test detection rule exception lists telemetry', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + }); + + describe('basic telemetry sanity checks', () => { + test('detection rule lists task can register', () => { + const telemetryDiagTask = new TelemetryDetectionRulesTask( + logger, + taskManagerMock.createSetup(), + createMockTelemetryEventsSender(true), + createMockTelemetryReceiver() + ); + + expect(telemetryDiagTask).toBeInstanceOf(TelemetryDetectionRulesTask); + }); + }); + + test('detection rule task should be registered', () => { + const mockTaskManager = taskManagerMock.createSetup(); + new TelemetryDetectionRulesTask( + logger, + mockTaskManager, + createMockTelemetryEventsSender(true), + createMockTelemetryReceiver() + ); + + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalled(); + }); + + test('detection rule task should be scheduled', async () => { + const mockTaskManagerSetup = taskManagerMock.createSetup(); + const telemetryDiagTask = new TelemetryDetectionRulesTask( + logger, + mockTaskManagerSetup, + createMockTelemetryEventsSender(true), + createMockTelemetryReceiver() + ); + + const mockTaskManagerStart = taskManagerMock.createStart(); + await telemetryDiagTask.start(mockTaskManagerStart); + expect(mockTaskManagerStart.ensureScheduled).toHaveBeenCalled(); + }); + + test('detection rule task should run', async () => { + const mockContext = createMockTelemetryEventsSender(true); + const mockTaskManager = taskManagerMock.createSetup(); + const mockReceiver = createMockTelemetryReceiver(); + const telemetryDiagTask = new MockDetectionRuleListsTask( + logger, + mockTaskManager, + mockContext, + mockReceiver + ); + + const mockTaskInstance = { + id: TelemetryDetectionRuleListsTaskConstants.TYPE, + runAt: new Date(), + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: new Date(), + scheduledAt: new Date(), + retryAt: new Date(), + params: {}, + state: {}, + taskType: TelemetryDetectionRuleListsTaskConstants.TYPE, + }; + const createTaskRunner = + mockTaskManager.registerTaskDefinitions.mock.calls[0][0][ + TelemetryDetectionRuleListsTaskConstants.TYPE + ].createTaskRunner; + const taskRunner = createTaskRunner({ taskInstance: mockTaskInstance }); + await taskRunner.run(); + expect(telemetryDiagTask.runTask).toHaveBeenCalled(); + }); + + test('detection rule task should not query elastic if telemetry is not opted in', async () => { + const mockSender = createMockTelemetryEventsSender(false); + const mockTaskManager = taskManagerMock.createSetup(); + const mockReceiver = createMockTelemetryReceiver(); + new MockDetectionRuleListsTask(logger, mockTaskManager, mockSender, mockReceiver); + + const mockTaskInstance = { + id: TelemetryDetectionRuleListsTaskConstants.TYPE, + runAt: new Date(), + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: new Date(), + scheduledAt: new Date(), + retryAt: new Date(), + params: {}, + state: {}, + taskType: TelemetryDetectionRuleListsTaskConstants.TYPE, + }; + const createTaskRunner = + mockTaskManager.registerTaskDefinitions.mock.calls[0][0][ + TelemetryDetectionRuleListsTaskConstants.TYPE + ].createTaskRunner; + const taskRunner = createTaskRunner({ taskInstance: mockTaskInstance }); + await taskRunner.run(); + expect(mockReceiver.fetchDiagnosticAlerts).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts new file mode 100644 index 0000000000000..a362be187921d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { Logger } from 'src/core/server'; +import { + ConcreteTaskInstance, + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../../task_manager/server'; +import { LIST_DETECTION_RULE_EXCEPTION, TELEMETRY_CHANNEL_LISTS } from '../constants'; +import { batchTelemetryRecords, templateExceptionList } from '../helpers'; +import { TelemetryEventsSender } from '../sender'; +import { TelemetryReceiver } from '../receiver'; +import { ExceptionListItem, RuleSearchResult } from '../types'; + +export const TelemetryDetectionRuleListsTaskConstants = { + TIMEOUT: '10m', + TYPE: 'security:telemetry-detection-rules', + INTERVAL: '24h', + VERSION: '1.0.0', +}; + +const MAX_TELEMETRY_BATCH = 1_000; + +export class TelemetryDetectionRulesTask { + private readonly logger: Logger; + private readonly sender: TelemetryEventsSender; + private readonly receiver: TelemetryReceiver; + + constructor( + logger: Logger, + taskManager: TaskManagerSetupContract, + sender: TelemetryEventsSender, + receiver: TelemetryReceiver + ) { + this.logger = logger; + this.sender = sender; + this.receiver = receiver; + + taskManager.registerTaskDefinitions({ + [TelemetryDetectionRuleListsTaskConstants.TYPE]: { + title: 'Security Solution Detection Rule Lists Telemetry', + timeout: TelemetryDetectionRuleListsTaskConstants.TIMEOUT, + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + const { state } = taskInstance; + + return { + run: async () => { + const taskExecutionTime = moment().utc().toISOString(); + const hits = await this.runTask(taskInstance.id); + + return { + state: { + lastExecutionTimestamp: taskExecutionTime, + runs: (state.runs || 0) + 1, + hits, + }, + }; + }, + cancel: async () => {}, + }; + }, + }, + }); + } + + public start = async (taskManager: TaskManagerStartContract) => { + try { + await taskManager.ensureScheduled({ + id: this.getTaskId(), + taskType: TelemetryDetectionRuleListsTaskConstants.TYPE, + scope: ['securitySolution'], + schedule: { + interval: TelemetryDetectionRuleListsTaskConstants.INTERVAL, + }, + state: { runs: 0 }, + params: { version: TelemetryDetectionRuleListsTaskConstants.VERSION }, + }); + } catch (e) { + this.logger.error(`Error scheduling task, received ${e.message}`); + } + }; + + private getTaskId = (): string => { + return `${TelemetryDetectionRuleListsTaskConstants.TYPE}:${TelemetryDetectionRuleListsTaskConstants.VERSION}`; + }; + + public runTask = async (taskId: string) => { + if (taskId !== this.getTaskId()) { + return 0; + } + + const isOptedIn = await this.sender.isTelemetryOptedIn(); + if (!isOptedIn) { + return 0; + } + + // Lists Telemetry: Detection Rules + + const { body: prebuiltRules } = await this.receiver.fetchDetectionRules(); + + const cacheArray = prebuiltRules.hits.hits.reduce((cache, searchHit) => { + const rule = searchHit._source as RuleSearchResult; + const ruleId = rule.alert.params.ruleId; + + const shouldNotProcess = + rule === null || + rule === undefined || + ruleId === null || + ruleId === undefined || + searchHit._source?.alert.params.exceptionsList.length === 0; + + if (shouldNotProcess) { + return cache; + } + + cache.push(rule); + return cache; + }, [] as RuleSearchResult[]); + + const detectionRuleExceptions = [] as ExceptionListItem[]; + for (const item of cacheArray) { + const ruleVersion = item.alert.params.version; + + for (const ex of item.alert.params.exceptionsList) { + const listItem = await this.receiver.fetchDetectionExceptionList(ex.list_id, ruleVersion); + for (const exceptionItem of listItem.data) { + detectionRuleExceptions.push(exceptionItem); + } + } + } + + const detectionRuleExceptionsJson = templateExceptionList( + detectionRuleExceptions, + LIST_DETECTION_RULE_EXCEPTION + ); + + batchTelemetryRecords(detectionRuleExceptionsJson, MAX_TELEMETRY_BATCH).forEach((batch) => { + this.sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch); + }); + + return detectionRuleExceptions.length; + }; +} diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts index 0c066deea17d9..c6bf4b06e70f0 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts @@ -14,7 +14,7 @@ import { } from '../../../../../task_manager/server'; import { batchTelemetryRecords, - getPreviousEpMetaTaskTimestamp, + getPreviousDailyTaskTimestamp, isPackagePolicyList, } from '../helpers'; import { TelemetryEventsSender } from '../sender'; @@ -76,7 +76,7 @@ export class TelemetryEndpointTask { return { run: async () => { const taskExecutionTime = moment().utc().toISOString(); - const lastExecutionTimestamp = getPreviousEpMetaTaskTimestamp( + const lastExecutionTimestamp = getPreviousDailyTaskTimestamp( taskExecutionTime, taskInstance.state?.lastExecutionTimestamp ); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts index e090252b88d8f..a850f848567cb 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts @@ -8,3 +8,4 @@ export { TelemetryDiagTask as DiagnosticTask } from './diagnostic'; export { TelemetryEndpointTask as EndpointTask } from './endpoint'; export { TelemetryExceptionListsTask as ExceptionListsTask } from './security_lists'; +export { TelemetryDetectionRulesTask as DetectionRulesTask } from './detection_rule'; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index abcad26ed000c..6aaf6f4371475 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -217,18 +217,45 @@ export interface GetEndpointListResponse { export interface ExceptionListItem { id: string; - version: string; + rule_version?: number; name: string; - description: string; created_at: string; updated_at: string; entries: object; - os: string; os_types: object; } export interface ListTemplate { - trusted_application: TelemetryEvent[]; - endpoint_exception: TelemetryEvent[]; - endpoint_event_filter: TelemetryEvent[]; + '@timestamp': number; + detection_rule?: TelemetryEvent; + endpoint_exception?: TelemetryEvent; + endpoint_event_filter?: TelemetryEvent; + trusted_application?: TelemetryEvent; +} + +// Detection Rule types + +interface ExceptionListEntry { + id: string; + list_id: string; + type: string; + namespace_type: string; +} + +interface DetectionRuleParms { + ruleId: string; + version: number; + type: string; + exceptionsList: ExceptionListEntry[]; +} + +export interface RuleSearchResult { + alert: { + name: string; + enabled: boolean; + tags: string[]; + createdAt: string; + updatedAt: string; + params: DetectionRuleParms; + }; } diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index bffcc823d047e..f0a91f8b06c00 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -459,7 +459,13 @@ export class Plugin implements IPlugin = ({ }} actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} + ruleType={alertType} onSave={setAlert} /> )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index 514594ffb855b..4ae570a62f7d9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -69,7 +69,8 @@ describe('alert_add', () => { async function setup( initialValues?: Partial, - onClose: AlertAddProps['onClose'] = jest.fn() + onClose: AlertAddProps['onClose'] = jest.fn(), + defaultScheduleInterval?: string ) { const mocks = coreMock.createSetup(); const { loadAlertTypes } = jest.requireMock('../../lib/alert_api'); @@ -84,6 +85,7 @@ describe('alert_add', () => { }, ], defaultActionGroupId: 'testActionGroup', + defaultScheduleInterval, minimumLicenseRequired: 'basic', recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, producer: ALERTS_FEATURE_ID, @@ -243,6 +245,26 @@ describe('alert_add', () => { expect(onClose).toHaveBeenCalledWith(AlertFlyoutCloseReason.SAVED); }); + + it('should enforce any default inteval', async () => { + await setup({ alertTypeId: 'my-alert-type' }, jest.fn(), '3h'); + await delay(1000); + + // Wait for handlers to fire + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + const intervalInputUnit = wrapper + .find('[data-test-subj="intervalInputUnit"]') + .first() + .getElement().props.value; + const intervalInput = wrapper.find('[data-test-subj="intervalInput"]').first().getElement() + .props.value; + expect(intervalInputUnit).toBe('h'); + expect(intervalInput).toBe(3); + }); }); function mockAlert(overloads: Partial = {}): Alert { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index 5e4ca42523b39..2b376ea0d0b30 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -17,10 +17,12 @@ import { AlertFlyoutCloseReason, IErrorObject, AlertAddProps, + RuleTypeIndex, } from '../../../types'; -import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form'; +import { AlertForm } from './alert_form'; +import { getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_errors'; import { alertReducer, InitialAlert, InitialAlertReducer } from './alert_reducer'; -import { createAlert } from '../../lib/alert_api'; +import { createAlert, loadAlertTypes } from '../../lib/alert_api'; import { HealthCheck } from '../../components/health_check'; import { ConfirmAlertSave } from './confirm_alert_save'; import { ConfirmAlertClose } from './confirm_alert_close'; @@ -30,6 +32,7 @@ import { HealthContextProvider } from '../../context/health_context'; import { useKibana } from '../../../common/lib/kibana'; import { hasAlertChanged, haveAlertParamsChanged } from './has_alert_changed'; import { getAlertWithInvalidatedFields } from '../../lib/value_validators'; +import { DEFAULT_ALERT_INTERVAL } from '../../constants'; const AlertAdd = ({ consumer, @@ -39,26 +42,28 @@ const AlertAdd = ({ canChangeTrigger, alertTypeId, initialValues, + reloadAlerts, onSave, metadata, + ...props }: AlertAddProps) => { const onSaveHandler = onSave ?? reloadAlerts; - const initialAlert: InitialAlert = useMemo( - () => ({ + + const initialAlert: InitialAlert = useMemo(() => { + return { params: {}, consumer, alertTypeId, schedule: { - interval: '1m', + interval: DEFAULT_ALERT_INTERVAL, }, actions: [], tags: [], notifyWhen: 'onActionGroupChange', ...(initialValues ? initialValues : {}), - }), - [alertTypeId, consumer, initialValues] - ); + }; + }, [alertTypeId, consumer, initialValues]); const [{ alert }, dispatch] = useReducer(alertReducer as InitialAlertReducer, { alert: initialAlert, @@ -67,6 +72,10 @@ const AlertAdd = ({ const [isSaving, setIsSaving] = useState(false); const [isConfirmAlertSaveModalOpen, setIsConfirmAlertSaveModalOpen] = useState(false); const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState(false); + const [ruleTypeIndex, setRuleTypeIndex] = useState( + props.ruleTypeIndex + ); + const [changedFromDefaultInterval, setChangedFromDefaultInterval] = useState(false); const setAlert = (value: InitialAlert) => { dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', value } }); @@ -90,6 +99,19 @@ const AlertAdd = ({ } }, [alertTypeId]); + useEffect(() => { + if (!props.ruleTypeIndex) { + (async () => { + const alertTypes = await loadAlertTypes({ http }); + const index: RuleTypeIndex = new Map(); + for (const alertType of alertTypes) { + index.set(alertType.id, alertType); + } + setRuleTypeIndex(index); + })(); + } + }, [props.ruleTypeIndex, http]); + useEffect(() => { if (isEmpty(alert.params) && !isEmpty(initialAlertParams)) { // alert params are explicitly cleared when the alert type is cleared. @@ -115,6 +137,21 @@ const AlertAdd = ({ })(); }, [alert, actionTypeRegistry]); + useEffect(() => { + if (alert.alertTypeId && ruleTypeIndex) { + const type = ruleTypeIndex.get(alert.alertTypeId); + if (type?.defaultScheduleInterval && !changedFromDefaultInterval) { + setAlertProperty('schedule', { interval: type.defaultScheduleInterval }); + } + } + }, [alert.alertTypeId, ruleTypeIndex, alert.schedule.interval, changedFromDefaultInterval]); + + useEffect(() => { + if (alert.schedule.interval !== DEFAULT_ALERT_INTERVAL && !changedFromDefaultInterval) { + setChangedFromDefaultInterval(true); + } + }, [alert.schedule.interval, changedFromDefaultInterval]); + const checkForChangesAndCloseFlyout = () => { if ( hasAlertChanged(alert, initialAlert, false) || @@ -138,9 +175,11 @@ const AlertAdd = ({ }; const alertType = alert.alertTypeId ? ruleTypeRegistry.get(alert.alertTypeId) : null; + const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( alert as Alert, - alertType + alertType, + alert.alertTypeId ? ruleTypeIndex?.get(alert.alertTypeId) : undefined ); // Confirm before saving if user is able to add actions but hasn't added any to this alert diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index 1aea6c68acbf8..467f2af6ed704 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -35,6 +35,23 @@ jest.mock('../../lib/alert_api', () => ({ })), })); +jest.mock('./alert_errors', () => ({ + getAlertActionErrors: jest.fn().mockImplementation(() => { + return []; + }), + getAlertErrors: jest.fn().mockImplementation(() => ({ + alertParamsErrors: {}, + alertBaseErrors: {}, + alertErrors: { + name: new Array(), + interval: new Array(), + alertTypeId: new Array(), + actionConnectors: new Array(), + }, + })), + isValidAlert: jest.fn(), +})); + jest.mock('../../../common/lib/health_api', () => ({ triggersActionsUiHealth: jest.fn(() => ({ isAlertsAvailable: true })), })); @@ -47,7 +64,7 @@ describe('alert_edit', () => { mockedCoreSetup = coreMock.createSetup(); }); - async function setup() { + async function setup(initialAlertFields = {}) { const [ { application: { capabilities }, @@ -154,6 +171,7 @@ describe('alert_edit', () => { status: 'unknown', lastExecutionDate: new Date('2020-08-20T19:23:38Z'), }, + ...initialAlertFields, }; actionTypeRegistry.get.mockReturnValueOnce(actionTypeModel); actionTypeRegistry.has.mockReturnValue(true); @@ -188,7 +206,11 @@ describe('alert_edit', () => { }); it('displays a toast message on save for server errors', async () => { - await setup(); + const { isValidAlert } = jest.requireMock('./alert_errors'); + (isValidAlert as jest.Mock).mockImplementation(() => { + return true; + }); + await setup({ name: undefined }); await act(async () => { wrapper.find('[data-test-subj="saveEditedAlertButton"]').first().simulate('click'); @@ -197,4 +219,12 @@ describe('alert_edit', () => { 'Fail message' ); }); + + it('should pass in the server alert type into `getAlertErrors`', async () => { + const { getAlertErrors } = jest.requireMock('./alert_errors'); + await setup(); + const lastCall = getAlertErrors.mock.calls[getAlertErrors.mock.calls.length - 1]; + expect(lastCall[2]).toBeDefined(); + expect(lastCall[2].id).toBe('my-alert-type'); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx index 8b79950d03ac9..f8c506fa1e8ba 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -24,10 +24,17 @@ import { } from '@elastic/eui'; import { cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Alert, AlertFlyoutCloseReason, AlertEditProps, IErrorObject } from '../../../types'; -import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form'; +import { + Alert, + AlertFlyoutCloseReason, + AlertEditProps, + IErrorObject, + AlertType, +} from '../../../types'; +import { AlertForm } from './alert_form'; +import { getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_errors'; import { alertReducer, ConcreteAlertReducer } from './alert_reducer'; -import { updateAlert } from '../../lib/alert_api'; +import { updateAlert, loadAlertTypes } from '../../lib/alert_api'; import { HealthCheck } from '../../components/health_check'; import { HealthContextProvider } from '../../context/health_context'; import { useKibana } from '../../../common/lib/kibana'; @@ -43,6 +50,7 @@ export const AlertEdit = ({ ruleTypeRegistry, actionTypeRegistry, metadata, + ...props }: AlertEditProps) => { const onSaveHandler = onSave ?? reloadAlerts; const [{ alert }, dispatch] = useReducer(alertReducer as ConcreteAlertReducer, { @@ -55,6 +63,9 @@ export const AlertEdit = ({ const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState(false); const [alertActionsErrors, setAlertActionsErrors] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [serverRuleType, setServerRuleType] = useState | undefined>( + props.ruleType + ); const { http, @@ -75,9 +86,23 @@ export const AlertEdit = ({ })(); }, [alert, actionTypeRegistry]); + useEffect(() => { + if (!props.ruleType && !serverRuleType) { + (async () => { + const serverRuleTypes = await loadAlertTypes({ http }); + for (const _serverRuleType of serverRuleTypes) { + if (alertType.id === _serverRuleType.id) { + setServerRuleType(_serverRuleType); + } + } + })(); + } + }, [props.ruleType, alertType.id, serverRuleType, http]); + const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( alert as Alert, - alertType + alertType, + serverRuleType ); const checkForChangesAndCloseFlyout = () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_errors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_errors.test.tsx new file mode 100644 index 0000000000000..5ca0fd9dafc18 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_errors.test.tsx @@ -0,0 +1,284 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; +import React, { Fragment } from 'react'; +import { + validateBaseProperties, + getAlertErrors, + getAlertActionErrors, + hasObjectErrors, + isValidAlert, +} from './alert_errors'; +import { Alert, AlertType, AlertTypeModel } from '../../../types'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; + +describe('alert_errors', () => { + describe('validateBaseProperties()', () => { + it('should validate the name', () => { + const alert = mockAlert(); + alert.name = ''; + const result = validateBaseProperties(alert); + expect(result.errors).toStrictEqual({ + name: ['Name is required.'], + interval: [], + alertTypeId: [], + actionConnectors: [], + }); + }); + + it('should validate the interval', () => { + const alert = mockAlert(); + alert.schedule.interval = ''; + const result = validateBaseProperties(alert); + expect(result.errors).toStrictEqual({ + name: [], + interval: ['Check interval is required.'], + alertTypeId: [], + actionConnectors: [], + }); + }); + + it('should validate the minimumScheduleInterval', () => { + const alert = mockAlert(); + alert.schedule.interval = '2m'; + const result = validateBaseProperties( + alert, + mockserverRuleType({ minimumScheduleInterval: '5m' }) + ); + expect(result.errors).toStrictEqual({ + name: [], + interval: ['Interval is below minimum (5m) for this rule type'], + alertTypeId: [], + actionConnectors: [], + }); + }); + + it('should validate the alertTypeId', () => { + const alert = mockAlert(); + alert.alertTypeId = ''; + const result = validateBaseProperties(alert); + expect(result.errors).toStrictEqual({ + name: [], + interval: [], + alertTypeId: ['Rule type is required.'], + actionConnectors: [], + }); + }); + + it('should validate the connectors', () => { + const alert = mockAlert(); + alert.actions = [ + { + id: '1234', + actionTypeId: 'myActionType', + group: '', + params: { + name: 'yes', + }, + }, + ]; + const result = validateBaseProperties(alert); + expect(result.errors).toStrictEqual({ + name: [], + interval: [], + alertTypeId: [], + actionConnectors: ['Action for myActionType connector is required.'], + }); + }); + }); + + describe('getAlertErrors()', () => { + it('should return all errors', () => { + const result = getAlertErrors( + mockAlert({ + name: '', + }), + mockAlertTypeModel({ + validate: () => ({ + errors: { + field: ['This is wrong'], + }, + }), + }), + mockserverRuleType() + ); + expect(result).toStrictEqual({ + alertParamsErrors: { field: ['This is wrong'] }, + alertBaseErrors: { + name: ['Name is required.'], + interval: [], + alertTypeId: [], + actionConnectors: [], + }, + alertErrors: { + name: ['Name is required.'], + field: ['This is wrong'], + interval: [], + alertTypeId: [], + actionConnectors: [], + }, + }); + }); + }); + + describe('getAlertActionErrors()', () => { + it('should return an array of errors', async () => { + const actionTypeRegistry = actionTypeRegistryMock.create(); + actionTypeRegistry.get.mockImplementation((actionTypeId: string) => ({ + ...actionTypeRegistryMock.createMockActionTypeModel(), + validateParams: jest.fn().mockImplementation(() => ({ + errors: { + [actionTypeId]: ['Yes, this failed'], + }, + })), + })); + const result = await getAlertActionErrors( + mockAlert({ + actions: [ + { + id: '1234', + actionTypeId: 'myActionType', + group: '', + params: { + name: 'yes', + }, + }, + { + id: '5678', + actionTypeId: 'myActionType2', + group: '', + params: { + name: 'yes', + }, + }, + ], + }), + actionTypeRegistry + ); + expect(result).toStrictEqual([ + { + myActionType: ['Yes, this failed'], + }, + { + myActionType2: ['Yes, this failed'], + }, + ]); + }); + }); + + describe('hasObjectErrors()', () => { + it('should return true for any errors', () => { + expect( + hasObjectErrors({ + foo: ['1'], + }) + ).toBe(true); + expect( + hasObjectErrors({ + foo: { + foo: ['1'], + }, + }) + ).toBe(true); + }); + it('should return false for no errors', () => { + expect(hasObjectErrors({})).toBe(false); + }); + }); + + describe('isValidAlert()', () => { + it('should return true for a valid alert', () => { + const result = isValidAlert(mockAlert(), {}, []); + expect(result).toBe(true); + }); + it('should return false for an invalid alert', () => { + expect( + isValidAlert( + mockAlert(), + { + name: ['This is wrong'], + }, + [] + ) + ).toBe(false); + expect( + isValidAlert(mockAlert(), {}, [ + { + name: ['This is wrong'], + }, + ]) + ).toBe(false); + }); + }); +}); + +function mockserverRuleType( + overloads: Partial> = {} +): AlertType { + return { + actionGroups: [], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + recoveryActionGroup: { + id: 'recovery', + name: 'doRecovery', + }, + id: 'myAppAlertType', + name: 'myAppAlertType', + producer: 'myApp', + authorizedConsumers: {}, + enabledInLicense: true, + actionVariables: { + context: [], + state: [], + params: [], + }, + ...overloads, + }; +} + +function mockAlertTypeModel(overloads: Partial = {}): AlertTypeModel { + return { + id: 'alertTypeModel', + description: 'some alert', + iconClass: 'something', + documentationUrl: null, + validate: () => ({ errors: {} }), + alertParamsExpression: () => , + requiresAppContext: false, + ...overloads, + }; +} + +function mockAlert(overloads: Partial = {}): Alert { + return { + id: uuid.v4(), + enabled: true, + name: `alert-${uuid.v4()}`, + tags: [], + alertTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + notifyWhen: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + ...overloads, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_errors.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_errors.ts new file mode 100644 index 0000000000000..3ca6a822b2d2d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_errors.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { isObject } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { parseDuration } from '../../../../../alerting/common/parse_duration'; +import { + AlertTypeModel, + Alert, + IErrorObject, + AlertAction, + AlertType, + ValidationResult, + ActionTypeRegistryContract, +} from '../../../types'; +import { InitialAlert } from './alert_reducer'; + +export function validateBaseProperties( + alertObject: InitialAlert, + serverRuleType?: AlertType +): ValidationResult { + const validationResult = { errors: {} }; + const errors = { + name: new Array(), + interval: new Array(), + alertTypeId: new Array(), + actionConnectors: new Array(), + }; + validationResult.errors = errors; + if (!alertObject.name) { + errors.name.push( + i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredNameText', { + defaultMessage: 'Name is required.', + }) + ); + } + if (alertObject.schedule.interval.length < 2) { + errors.interval.push( + i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredIntervalText', { + defaultMessage: 'Check interval is required.', + }) + ); + } else if (serverRuleType?.minimumScheduleInterval) { + const duration = parseDuration(alertObject.schedule.interval); + const minimumDuration = parseDuration(serverRuleType.minimumScheduleInterval); + if (duration < minimumDuration) { + errors.interval.push( + i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.belowMinimumText', { + defaultMessage: 'Interval is below minimum ({minimum}) for this rule type', + values: { + minimum: serverRuleType.minimumScheduleInterval, + }, + }) + ); + } + } + + if (!alertObject.alertTypeId) { + errors.alertTypeId.push( + i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredRuleTypeIdText', { + defaultMessage: 'Rule type is required.', + }) + ); + } + const emptyConnectorActions = alertObject.actions.find( + (actionItem) => /^\d+$/.test(actionItem.id) && Object.keys(actionItem.params).length > 0 + ); + if (emptyConnectorActions !== undefined) { + errors.actionConnectors.push( + i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredActionConnector', { + defaultMessage: 'Action for {actionTypeId} connector is required.', + values: { actionTypeId: emptyConnectorActions.actionTypeId }, + }) + ); + } + return validationResult; +} + +export function getAlertErrors( + alert: Alert, + alertTypeModel: AlertTypeModel | null, + serverRuleType?: AlertType +) { + const alertParamsErrors: IErrorObject = alertTypeModel + ? alertTypeModel.validate(alert.params).errors + : []; + const alertBaseErrors = validateBaseProperties(alert, serverRuleType).errors as IErrorObject; + const alertErrors = { + ...alertParamsErrors, + ...alertBaseErrors, + } as IErrorObject; + + return { + alertParamsErrors, + alertBaseErrors, + alertErrors, + }; +} + +export async function getAlertActionErrors( + alert: Alert, + actionTypeRegistry: ActionTypeRegistryContract +): Promise { + return await Promise.all( + alert.actions.map( + async (alertAction: AlertAction) => + ( + await actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params) + ).errors + ) + ); +} + +export const hasObjectErrors: (errors: IErrorObject) => boolean = (errors) => + !!Object.values(errors).find((errorList) => { + if (isObject(errorList)) return hasObjectErrors(errorList as IErrorObject); + return errorList.length >= 1; + }); + +export function isValidAlert( + alertObject: InitialAlert | Alert, + validationResult: IErrorObject, + actionsErrors: IErrorObject[] +): alertObject is Alert { + return ( + !hasObjectErrors(validationResult) && + actionsErrors.every((error: IErrorObject) => !hasObjectErrors(error)) + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 5facda85e6c89..3f6bf3c955e8b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -35,7 +35,7 @@ import { EuiToolTip, EuiCallOut, } from '@elastic/eui'; -import { capitalize, isObject } from 'lodash'; +import { capitalize } from 'lodash'; import { KibanaFeature } from '../../../../../features/public'; import { getDurationNumberInItsUnit, @@ -48,9 +48,8 @@ import { Alert, IErrorObject, AlertAction, - AlertTypeIndex, + RuleTypeIndex, AlertType, - ValidationResult, RuleTypeRegistryContract, ActionTypeRegistryContract, } from '../../../types'; @@ -74,101 +73,10 @@ import { checkAlertTypeEnabled } from '../../lib/check_alert_type_enabled'; import { alertTypeCompare, alertTypeGroupCompare } from '../../lib/alert_type_compare'; import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; import { SectionLoading } from '../../components/section_loading'; +import { DEFAULT_ALERT_INTERVAL } from '../../constants'; const ENTER_KEY = 13; -export function validateBaseProperties(alertObject: InitialAlert): ValidationResult { - const validationResult = { errors: {} }; - const errors = { - name: new Array(), - interval: new Array(), - alertTypeId: new Array(), - actionConnectors: new Array(), - }; - validationResult.errors = errors; - if (!alertObject.name) { - errors.name.push( - i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredNameText', { - defaultMessage: 'Name is required.', - }) - ); - } - if (alertObject.schedule.interval.length < 2) { - errors.interval.push( - i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredIntervalText', { - defaultMessage: 'Check interval is required.', - }) - ); - } - if (!alertObject.alertTypeId) { - errors.alertTypeId.push( - i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredRuleTypeIdText', { - defaultMessage: 'Rule type is required.', - }) - ); - } - const emptyConnectorActions = alertObject.actions.find( - (actionItem) => /^\d+$/.test(actionItem.id) && Object.keys(actionItem.params).length > 0 - ); - if (emptyConnectorActions !== undefined) { - errors.actionConnectors.push( - i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredActionConnector', { - defaultMessage: 'Action for {actionTypeId} connector is required.', - values: { actionTypeId: emptyConnectorActions.actionTypeId }, - }) - ); - } - return validationResult; -} - -export function getAlertErrors(alert: Alert, alertTypeModel: AlertTypeModel | null) { - const alertParamsErrors: IErrorObject = alertTypeModel - ? alertTypeModel.validate(alert.params).errors - : []; - const alertBaseErrors = validateBaseProperties(alert).errors as IErrorObject; - const alertErrors = { - ...alertParamsErrors, - ...alertBaseErrors, - } as IErrorObject; - - return { - alertParamsErrors, - alertBaseErrors, - alertErrors, - }; -} - -export async function getAlertActionErrors( - alert: Alert, - actionTypeRegistry: ActionTypeRegistryContract -): Promise { - return await Promise.all( - alert.actions.map( - async (alertAction: AlertAction) => - ( - await actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params) - ).errors - ) - ); -} - -export const hasObjectErrors: (errors: IErrorObject) => boolean = (errors) => - !!Object.values(errors).find((errorList) => { - if (isObject(errorList)) return hasObjectErrors(errorList as IErrorObject); - return errorList.length >= 1; - }); - -export function isValidAlert( - alertObject: InitialAlert | Alert, - validationResult: IErrorObject, - actionsErrors: IErrorObject[] -): alertObject is Alert { - return ( - !hasObjectErrors(validationResult) && - actionsErrors.every((error: IErrorObject) => !hasObjectErrors(error)) - ); -} - function getProducerFeatureName(producer: string, kibanaFeatures: KibanaFeature[]) { return kibanaFeatures.find((featureItem) => featureItem.id === producer)?.name; } @@ -186,6 +94,9 @@ interface AlertFormProps> { metadata?: MetaData; } +const defaultScheduleInterval = getDurationNumberInItsUnit(DEFAULT_ALERT_INTERVAL); +const defaultScheduleIntervalUnit = getDurationUnitValue(DEFAULT_ALERT_INTERVAL); + export const AlertForm = ({ alert, canChangeTrigger = true, @@ -212,10 +123,14 @@ export const AlertForm = ({ const [alertTypeModel, setAlertTypeModel] = useState(null); const [alertInterval, setAlertInterval] = useState( - alert.schedule.interval ? getDurationNumberInItsUnit(alert.schedule.interval) : undefined + alert.schedule.interval + ? getDurationNumberInItsUnit(alert.schedule.interval) + : defaultScheduleInterval ); const [alertIntervalUnit, setAlertIntervalUnit] = useState( - alert.schedule.interval ? getDurationUnitValue(alert.schedule.interval) : 'm' + alert.schedule.interval + ? getDurationUnitValue(alert.schedule.interval) + : defaultScheduleIntervalUnit ); const [alertThrottle, setAlertThrottle] = useState( alert.throttle ? getDurationNumberInItsUnit(alert.throttle) : null @@ -224,7 +139,7 @@ export const AlertForm = ({ alert.throttle ? getDurationUnitValue(alert.throttle) : 'h' ); const [defaultActionGroupId, setDefaultActionGroupId] = useState(undefined); - const [alertTypesIndex, setAlertTypesIndex] = useState(null); + const [ruleTypeIndex, setRuleTypeIndex] = useState(null); const [availableAlertTypes, setAvailableAlertTypes] = useState< Array<{ alertTypeModel: AlertTypeModel; alertType: AlertType }> @@ -243,14 +158,14 @@ export const AlertForm = ({ (async () => { try { const alertTypesResult = await loadAlertTypes({ http }); - const index: AlertTypeIndex = new Map(); + const index: RuleTypeIndex = new Map(); for (const alertTypeItem of alertTypesResult) { index.set(alertTypeItem.id, alertTypeItem); } if (alert.alertTypeId && index.has(alert.alertTypeId)) { setDefaultActionGroupId(index.get(alert.alertTypeId)!.defaultActionGroupId); } - setAlertTypesIndex(index); + setRuleTypeIndex(index); const availableAlertTypesResult = getAvailableAlertTypes(alertTypesResult); setAvailableAlertTypes(availableAlertTypesResult); @@ -287,10 +202,24 @@ export const AlertForm = ({ useEffect(() => { setAlertTypeModel(alert.alertTypeId ? ruleTypeRegistry.get(alert.alertTypeId) : null); - if (alert.alertTypeId && alertTypesIndex && alertTypesIndex.has(alert.alertTypeId)) { - setDefaultActionGroupId(alertTypesIndex.get(alert.alertTypeId)!.defaultActionGroupId); + if (alert.alertTypeId && ruleTypeIndex && ruleTypeIndex.has(alert.alertTypeId)) { + setDefaultActionGroupId(ruleTypeIndex.get(alert.alertTypeId)!.defaultActionGroupId); + } + }, [alert, alert.alertTypeId, ruleTypeIndex, ruleTypeRegistry]); + + useEffect(() => { + if (alert.schedule.interval) { + const interval = getDurationNumberInItsUnit(alert.schedule.interval); + const intervalUnit = getDurationUnitValue(alert.schedule.interval); + + if (interval !== defaultScheduleInterval) { + setAlertInterval(interval); + } + if (intervalUnit !== defaultScheduleIntervalUnit) { + setAlertIntervalUnit(intervalUnit); + } } - }, [alert, alert.alertTypeId, alertTypesIndex, ruleTypeRegistry]); + }, [alert.schedule.interval]); const setAlertProperty = useCallback( (key: Key, value: Alert[Key] | null) => { @@ -372,9 +301,7 @@ export const AlertForm = ({ ? !item.alertTypeModel.requiresAppContext : item.alertType!.producer === alert.consumer ); - const selectedAlertType = alert?.alertTypeId - ? alertTypesIndex?.get(alert?.alertTypeId) - : undefined; + const selectedAlertType = alert?.alertTypeId ? ruleTypeIndex?.get(alert?.alertTypeId) : undefined; const recoveryActionGroup = selectedAlertType?.recoveryActionGroup?.id; const getDefaultActionParams = useCallback( (actionTypeId: string, actionGroupId: string): Record | undefined => @@ -499,8 +426,8 @@ export const AlertForm = ({ setActions([]); setAlertTypeModel(item.alertTypeItem); setAlertProperty('params', {}); - if (alertTypesIndex && alertTypesIndex.has(item.id)) { - setDefaultActionGroupId(alertTypesIndex.get(item.id)!.defaultActionGroupId); + if (ruleTypeIndex && ruleTypeIndex.has(item.id)) { + setDefaultActionGroupId(ruleTypeIndex.get(item.id)!.defaultActionGroupId); } }} /> @@ -518,8 +445,8 @@ export const AlertForm = ({
- {alert.alertTypeId && alertTypesIndex && alertTypesIndex.has(alert.alertTypeId) - ? alertTypesIndex.get(alert.alertTypeId)!.name + {alert.alertTypeId && ruleTypeIndex && ruleTypeIndex.has(alert.alertTypeId) + ? ruleTypeIndex.get(alert.alertTypeId)!.name : ''}
@@ -870,7 +797,7 @@ export const AlertForm = ({ ) : null} {alertTypeNodes} - ) : alertTypesIndex ? ( + ) : ruleTypeIndex ? ( ) : ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 941d400104082..1daaf3b996126 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -33,7 +33,14 @@ import { import { useHistory } from 'react-router-dom'; import { isEmpty } from 'lodash'; -import { ActionType, Alert, AlertTableItem, AlertTypeIndex, Pagination } from '../../../../types'; +import { + ActionType, + Alert, + AlertTableItem, + AlertType, + RuleTypeIndex, + Pagination, +} from '../../../../types'; import { AlertAdd, AlertEdit } from '../../alert_form'; import { BulkOperationPopover } from '../../common/components/bulk_operation_popover'; import { AlertQuickEditButtonsWithApi as AlertQuickEditButtons } from '../../common/components/alert_quick_edit_buttons'; @@ -74,7 +81,7 @@ const ENTER_KEY = 13; interface AlertTypeState { isLoading: boolean; isInitialized: boolean; - data: AlertTypeIndex; + data: RuleTypeIndex; } interface AlertState { isLoading: boolean; @@ -161,7 +168,7 @@ export const AlertsList: React.FunctionComponent = () => { try { setAlertTypesState({ ...alertTypesState, isLoading: true }); const alertTypes = await loadAlertTypes({ http }); - const index: AlertTypeIndex = new Map(); + const index: RuleTypeIndex = new Map(); for (const alertType of alertTypes) { index.set(alertType.id, alertType); } @@ -895,6 +902,7 @@ export const AlertsList: React.FunctionComponent = () => { }} actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} + ruleTypeIndex={alertTypesState.data} onSave={loadAlertsData} /> )} @@ -906,6 +914,9 @@ export const AlertsList: React.FunctionComponent = () => { }} actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} + ruleType={ + alertTypesState.data.get(currentRuleToEdit.alertTypeId) as AlertType + } onSave={loadAlertsData} /> )} @@ -944,17 +955,17 @@ function filterAlertsById(alerts: Alert[], ids: string[]): Alert[] { function convertAlertsToTableItems( alerts: Alert[], - alertTypesIndex: AlertTypeIndex, + ruleTypeIndex: RuleTypeIndex, canExecuteActions: boolean ) { return alerts.map((alert) => ({ ...alert, actionsCount: alert.actions.length, tagsText: alert.tags.join(', '), - alertType: alertTypesIndex.get(alert.alertTypeId)?.name ?? alert.alertTypeId, + alertType: ruleTypeIndex.get(alert.alertTypeId)?.name ?? alert.alertTypeId, isEditable: - hasAllPrivilege(alert, alertTypesIndex.get(alert.alertTypeId)) && + hasAllPrivilege(alert, ruleTypeIndex.get(alert.alertTypeId)) && (canExecuteActions || (!canExecuteActions && !alert.actions.length)), - enabledInLicense: !!alertTypesIndex.get(alert.alertTypeId)?.enabledInLicense, + enabledInLicense: !!ruleTypeIndex.get(alert.alertTypeId)?.enabledInLicense, })); } diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index ae4fd5152794f..2ef20f36b7ca9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -66,7 +66,7 @@ export { }; export type ActionTypeIndex = Record; -export type AlertTypeIndex = Map; +export type RuleTypeIndex = Map; export type ActionTypeRegistryContract< ActionConnector = unknown, ActionParams = unknown @@ -197,6 +197,8 @@ export interface AlertType< | 'minimumLicenseRequired' | 'recoveryActionGroup' | 'defaultActionGroupId' + | 'defaultScheduleInterval' + | 'minimumScheduleInterval' > { actionVariables: ActionVariables; authorizedConsumers: Record; @@ -285,6 +287,7 @@ export interface AlertEditProps> { reloadAlerts?: () => Promise; onSave?: () => Promise; metadata?: MetaData; + ruleType?: AlertType; } export interface AlertAddProps> { @@ -299,4 +302,5 @@ export interface AlertAddProps> { reloadAlerts?: () => Promise; onSave?: () => Promise; metadata?: MetaData; + ruleTypeIndex?: RuleTypeIndex; } diff --git a/x-pack/plugins/uptime/public/hooks/use_url_params.ts b/x-pack/plugins/uptime/public/hooks/use_url_params.ts index 329e0ccef4d96..1318b635693c7 100644 --- a/x-pack/plugins/uptime/public/hooks/use_url_params.ts +++ b/x-pack/plugins/uptime/public/hooks/use_url_params.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect } from 'react'; import { parse, stringify } from 'query-string'; import { useLocation, useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; @@ -28,7 +28,7 @@ const getParsedParams = (search: string) => { export const useGetUrlParams: GetUrlParams = () => { const { search } = useLocation(); - return useMemo(() => getSupportedUrlParams(getParsedParams(search)), [search]); + return getSupportedUrlParams(getParsedParams(search)); }; const getMapFromFilters = (value: any): Map | undefined => { diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 3c1cdd5790f3c..f7b978c2b58bd 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -54,6 +54,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/security_api_integration/session_lifespan.config.ts'), require.resolve('../test/security_api_integration/login_selector.config.ts'), require.resolve('../test/security_api_integration/audit.config.ts'), + require.resolve('../test/security_api_integration/http_bearer.config.ts'), require.resolve('../test/security_api_integration/kerberos.config.ts'), require.resolve('../test/security_api_integration/kerberos_anonymous_access.config.ts'), require.resolve('../test/security_api_integration/pki.config.ts'), diff --git a/x-pack/test/accessibility/apps/index_lifecycle_management.ts b/x-pack/test/accessibility/apps/index_lifecycle_management.ts index da56cfd702abf..65faa77fc497b 100644 --- a/x-pack/test/accessibility/apps/index_lifecycle_management.ts +++ b/x-pack/test/accessibility/apps/index_lifecycle_management.ts @@ -57,7 +57,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { throw new Error(`Could not find ${policyName} in policy table`); }; - describe('Index Lifecycle Management', async () => { + // FLAKY + // https://github.com/elastic/kibana/issues/114541 + // https://github.com/elastic/kibana/issues/114542 + describe.skip('Index Lifecycle Management', async () => { before(async () => { await esClient.ilm.putLifecycle({ policy: POLICY_NAME, body: POLICY_ALL_PHASES }); await esClient.indices.putIndexTemplate({ diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts b/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts index 0dcb767309608..7d4df75ccdcf7 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts @@ -81,7 +81,8 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); const browser = getService('browser'); - describe('custom urls', function () { + // FLAKY: https://github.com/elastic/kibana/issues/106053 + describe.skip('custom urls', function () { this.tags(['mlqa']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); diff --git a/x-pack/test/functional/services/monitoring/elasticsearch_node_detail.js b/x-pack/test/functional/services/monitoring/elasticsearch_node_detail.js index 38915d525a7f7..5030948158316 100644 --- a/x-pack/test/functional/services/monitoring/elasticsearch_node_detail.js +++ b/x-pack/test/functional/services/monitoring/elasticsearch_node_detail.js @@ -21,7 +21,7 @@ export function MonitoringElasticsearchNodeDetailProvider({ getService }) { return new (class ElasticsearchNodeDetail { async clickAdvanced() { - return testSubjects.click('esNodeDetailAdvancedLink'); + return testSubjects.click('esItemDetailAdvancedLink'); } async getSummary() { diff --git a/x-pack/test/security_api_integration/http_bearer.config.ts b/x-pack/test/security_api_integration/http_bearer.config.ts new file mode 100644 index 0000000000000..b0a9f4a920347 --- /dev/null +++ b/x-pack/test/security_api_integration/http_bearer.config.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; +import { services } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + return { + testFiles: [require.resolve('./tests/http_bearer')], + servers: xPackAPITestsConfig.get('servers'), + security: { disableTestUser: true }, + services, + junit: { + reportName: 'X-Pack Security API Integration Tests (HTTP Bearer)', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + serverArgs: [ + ...xPackAPITestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.token.timeout=15s', + ], + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + }, + }; +} diff --git a/x-pack/test/security_api_integration/tests/http_bearer/header.ts b/x-pack/test/security_api_integration/tests/http_bearer/header.ts new file mode 100644 index 0000000000000..f7ebef4f16d09 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/http_bearer/header.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { adminTestUser } from '@kbn/test'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const es = getService('es'); + + async function createToken() { + const { + body: { access_token: accessToken, authentication }, + } = await es.security.getToken({ + body: { + grant_type: 'password', + ...adminTestUser, + }, + }); + + return { + accessToken, + expectedUser: { + ...authentication, + authentication_provider: { name: '__http__', type: 'http' }, + authentication_type: 'token', + }, + }; + } + + describe('header', () => { + it('accepts valid access token via authorization Bearer header', async () => { + const { accessToken, expectedUser } = await createToken(); + + const response = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', `Bearer ${accessToken}`) + .expect(200, expectedUser); + + // Make sure we don't automatically create a session + expect(response.headers['set-cookie']).to.be(undefined); + }); + + it('accepts multiple requests for a single valid access token', async () => { + const { accessToken, expectedUser } = await createToken(); + + // try it once + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', `Bearer ${accessToken}`) + .expect(200, expectedUser); + + // try it again to verity it isn't invalidated after a single request + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', `Bearer ${accessToken}`) + .expect(200, expectedUser); + }); + + it('rejects invalid access token via authorization Bearer header', async () => { + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', 'Bearer notreal') + .expect(401); + }); + + it('rejects invalidated access token via authorization Bearer header', async () => { + const { accessToken } = await createToken(); + await es.security.invalidateToken({ body: { token: accessToken } }); + + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', `Bearer ${accessToken}`) + .expect(401); + }); + + it('rejects expired access token via authorization Bearer header', async function () { + this.timeout(40000); + + const { accessToken } = await createToken(); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await new Promise((resolve) => setTimeout(resolve, 20000)); + + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', `Bearer ${accessToken}`) + .expect(401); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/http_bearer/index.ts b/x-pack/test/security_api_integration/tests/http_bearer/index.ts new file mode 100644 index 0000000000000..4dbad2660ebaa --- /dev/null +++ b/x-pack/test/security_api_integration/tests/http_bearer/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('security APIs - HTTP Bearer', function () { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./header')); + }); +}