From 403b5d2fd51475aeb63a5156225592ec369e635a Mon Sep 17 00:00:00 2001 From: Maryam Saeidi Date: Tue, 20 Jun 2023 12:06:21 +0200 Subject: [PATCH 01/14] [AO] Make spaces plugin optional (#159980) ## Summary Based on this [comment](https://github.com/elastic/kibana/pull/158657#discussion_r1233750595), [spaces](https://github.com/elastic/kibana/issues/149687) should be an optional plugin. --- x-pack/plugins/observability/kibana.jsonc | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/observability/kibana.jsonc b/x-pack/plugins/observability/kibana.jsonc index a69ffbd0234f6..5c08133f15a95 100644 --- a/x-pack/plugins/observability/kibana.jsonc +++ b/x-pack/plugins/observability/kibana.jsonc @@ -25,12 +25,11 @@ "triggersActionsUi", "security", "share", - "spaces", "unifiedSearch", "visualizations" ], - "optionalPlugins": ["discover", "home", "licensing", "usageCollection", "cloud"], - "requiredBundles": ["data", "kibanaReact", "kibanaUtils", "unifiedSearch", "cloudChat"], + "optionalPlugins": ["discover", "home", "licensing", "usageCollection", "cloud", "spaces"], + "requiredBundles": ["data", "kibanaReact", "kibanaUtils", "unifiedSearch", "cloudChat", "spaces"], "extraPublicDirs": ["common"] } } From 97dc2ecba127e997e5229b499d83a0a38072c043 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 20 Jun 2023 06:13:29 -0400 Subject: [PATCH 02/14] [EBT] Add page url to browser-side context (#159916) ## Summary Part of https://github.com/elastic/kibana/issues/149249 Add a new EBT context providing the `page_url` field to events. `page_url` is based on the current url's `pathname` and `hash` exclusively (no domain, port, query param...) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../application_service.test.tsx | 82 +++++++++++++++++++ .../src/application_service.test.mocks.ts | 12 +++ .../src/application_service.test.ts | 34 ++++++++ .../src/application_service.tsx | 18 +++- ...egister_analytics_context_provider.test.ts | 38 +++++++++ .../register_analytics_context_provider.ts | 26 ++++++ .../src/utils/get_location_observable.test.ts | 65 +++++++++++++++ .../src/utils/get_location_observable.ts | 35 ++++++++ .../src/utils/index.ts | 1 + .../tsconfig.json | 2 + .../src/core_system.ts | 2 +- .../core_context_providers.ts | 5 ++ 12 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.test.ts create mode 100644 packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.ts create mode 100644 packages/core/application/core-application-browser-internal/src/utils/get_location_observable.test.ts create mode 100644 packages/core/application/core-application-browser-internal/src/utils/get_location_observable.ts diff --git a/packages/core/application/core-application-browser-internal/integration_tests/application_service.test.tsx b/packages/core/application/core-application-browser-internal/integration_tests/application_service.test.tsx index a4f117d5c883b..95dc6d8f8b181 100644 --- a/packages/core/application/core-application-browser-internal/integration_tests/application_service.test.tsx +++ b/packages/core/application/core-application-browser-internal/integration_tests/application_service.test.tsx @@ -12,6 +12,7 @@ import { act } from 'react-dom/test-utils'; import { createMemoryHistory, MemoryHistory } from 'history'; import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; import { themeServiceMock } from '@kbn/core-theme-browser-mocks'; import type { AppMountParameters, AppUpdater } from '@kbn/core-application-browser'; import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks'; @@ -38,11 +39,13 @@ describe('ApplicationService', () => { beforeEach(() => { history = createMemoryHistory(); const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + const analytics = analyticsServiceMock.createAnalyticsServiceSetup(); http.post.mockResolvedValue({ navLinks: {} }); setupDeps = { http, + analytics, history: history as any, }; startDeps = { @@ -87,6 +90,45 @@ describe('ApplicationService', () => { expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); }); + + it('updates the page_url analytics context', async () => { + const { register } = service.setup(setupDeps); + + const context$ = setupDeps.analytics.registerContextProvider.mock.calls[0][0] + .context$ as Observable<{ + page_url: string; + }>; + const locations: string[] = []; + context$.subscribe((context) => locations.push(context.page_url)); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async () => () => undefined, + }); + register(Symbol(), { + id: 'app2', + title: 'App2', + mount: async () => () => undefined, + }); + + const { getComponent } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await navigate('/app/app1/bar?hello=dolly'); + await flushPromises(); + await navigate('/app/app2#/foo'); + await flushPromises(); + await navigate('/app/app2#/another-path'); + await flushPromises(); + + expect(locations).toEqual([ + '/', + '/app/app1/bar', + '/app/app2#/foo', + '/app/app2#/another-path', + ]); + }); }); describe('using navigateToApp', () => { @@ -127,6 +169,46 @@ describe('ApplicationService', () => { expect(currentAppIds).toEqual(['app1']); }); + it('updates the page_url analytics context', async () => { + const { register } = service.setup(setupDeps); + + const context$ = setupDeps.analytics.registerContextProvider.mock.calls[0][0] + .context$ as Observable<{ + page_url: string; + }>; + const locations: string[] = []; + context$.subscribe((context) => locations.push(context.page_url)); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async () => () => undefined, + }); + register(Symbol(), { + id: 'app2', + title: 'App2', + mount: async () => () => undefined, + }); + + const { navigateToApp, getComponent } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await act(async () => { + await navigateToApp('app1'); + update(); + }); + await act(async () => { + await navigateToApp('app2', { path: '/nested' }); + update(); + }); + await act(async () => { + await navigateToApp('app2', { path: '/another-path' }); + update(); + }); + + expect(locations).toEqual(['/', '/app/app1', '/app/app2/nested', '/app/app2/another-path']); + }); + it('replaces the current history entry when the `replace` option is true', async () => { const { register } = service.setup(setupDeps); diff --git a/packages/core/application/core-application-browser-internal/src/application_service.test.mocks.ts b/packages/core/application/core-application-browser-internal/src/application_service.test.mocks.ts index 7197e7308def6..a41c27f348f3a 100644 --- a/packages/core/application/core-application-browser-internal/src/application_service.test.mocks.ts +++ b/packages/core/application/core-application-browser-internal/src/application_service.test.mocks.ts @@ -26,11 +26,23 @@ jest.doMock('history', () => ({ })); export const parseAppUrlMock = jest.fn(); +export const getLocationObservableMock = jest.fn(); jest.doMock('./utils', () => { const original = jest.requireActual('./utils'); return { ...original, parseAppUrl: parseAppUrlMock, + getLocationObservable: getLocationObservableMock, + }; +}); + +export const registerAnalyticsContextProviderMock = jest.fn(); +jest.doMock('./register_analytics_context_provider', () => { + const original = jest.requireActual('./register_analytics_context_provider'); + + return { + ...original, + registerAnalyticsContextProvider: registerAnalyticsContextProviderMock, }; }); diff --git a/packages/core/application/core-application-browser-internal/src/application_service.test.ts b/packages/core/application/core-application-browser-internal/src/application_service.test.ts index 65e5867db83a0..09e7ed5a385a4 100644 --- a/packages/core/application/core-application-browser-internal/src/application_service.test.ts +++ b/packages/core/application/core-application-browser-internal/src/application_service.test.ts @@ -10,17 +10,21 @@ import { MockCapabilitiesService, MockHistory, parseAppUrlMock, + getLocationObservableMock, + registerAnalyticsContextProviderMock, } from './application_service.test.mocks'; import { createElement } from 'react'; import { BehaviorSubject, firstValueFrom, Subject } from 'rxjs'; import { bufferCount, takeUntil } from 'rxjs/operators'; import { mount, shallow } from 'enzyme'; +import { createBrowserHistory } from 'history'; import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { themeServiceMock } from '@kbn/core-theme-browser-mocks'; import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks'; import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks'; +import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; import { MockLifecycle } from './test_helpers/test_types'; import { ApplicationService } from './application_service'; import { @@ -48,9 +52,12 @@ let service: ApplicationService; describe('#setup()', () => { beforeEach(() => { + jest.clearAllMocks(); const http = httpServiceMock.createSetupContract({ basePath: '/base-path' }); + const analytics = analyticsServiceMock.createAnalyticsServiceSetup(); setupDeps = { http, + analytics, redirectTo: jest.fn(), }; startDeps = { @@ -469,13 +476,38 @@ describe('#setup()', () => { ]); }); }); + + describe('analytics context provider', () => { + it('calls getLocationObservable with the correct parameters', () => { + const history = createBrowserHistory(); + service.setup({ ...setupDeps, history }); + + expect(getLocationObservableMock).toHaveBeenCalledTimes(1); + expect(getLocationObservableMock).toHaveBeenCalledWith(window.location, history); + }); + + it('calls registerAnalyticsContextProvider with the correct parameters', () => { + const location$ = new Subject(); + getLocationObservableMock.mockReturnValue(location$); + + service.setup(setupDeps); + + expect(registerAnalyticsContextProviderMock).toHaveBeenCalledTimes(1); + expect(registerAnalyticsContextProviderMock).toHaveBeenCalledWith({ + analytics: setupDeps.analytics, + location$, + }); + }); + }); }); describe('#start()', () => { beforeEach(() => { const http = httpServiceMock.createSetupContract({ basePath: '/base-path' }); + const analytics = analyticsServiceMock.createAnalyticsServiceSetup(); setupDeps = { http, + analytics, redirectTo: jest.fn(), }; startDeps = { @@ -1185,8 +1217,10 @@ describe('#stop()', () => { MockHistory.push.mockReset(); const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + const analytics = analyticsServiceMock.createAnalyticsServiceSetup(); setupDeps = { http, + analytics, }; startDeps = { http, diff --git a/packages/core/application/core-application-browser-internal/src/application_service.tsx b/packages/core/application/core-application-browser-internal/src/application_service.tsx index 5808410b07fe2..0c8207ca1d2f6 100644 --- a/packages/core/application/core-application-browser-internal/src/application_service.tsx +++ b/packages/core/application/core-application-browser-internal/src/application_service.tsx @@ -17,6 +17,7 @@ import type { HttpSetup, HttpStart } from '@kbn/core-http-browser'; import type { Capabilities } from '@kbn/core-capabilities-common'; import type { MountPoint } from '@kbn/core-mount-utils-browser'; import type { OverlayStart } from '@kbn/core-overlays-browser'; +import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser'; import type { App, AppDeepLink, @@ -35,10 +36,18 @@ import type { InternalApplicationSetup, InternalApplicationStart, Mounter } from import { getLeaveAction, isConfirmAction } from './application_leave'; import { getUserConfirmationHandler } from './navigation_confirm'; -import { appendAppPath, parseAppUrl, relativeToAbsolute, getAppInfo } from './utils'; +import { + appendAppPath, + parseAppUrl, + relativeToAbsolute, + getAppInfo, + getLocationObservable, +} from './utils'; +import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; export interface SetupDeps { http: HttpSetup; + analytics: AnalyticsServiceSetup; history?: History; /** Used to redirect to external urls */ redirectTo?: (path: string) => void; @@ -111,6 +120,7 @@ export class ApplicationService { public setup({ http: { basePath }, + analytics, redirectTo = (path: string) => { window.location.assign(path); }, @@ -126,6 +136,12 @@ export class ApplicationService { }), }); + const location$ = getLocationObservable(window.location, this.history); + registerAnalyticsContextProvider({ + analytics, + location$, + }); + this.navigate = (url, state, replace) => { // basePath not needed here because `history` is configured with basename return replace ? this.history!.replace(url, state) : this.history!.push(url, state); diff --git a/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.test.ts b/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.test.ts new file mode 100644 index 0000000000000..075095db9cfa8 --- /dev/null +++ b/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { firstValueFrom, ReplaySubject, Subject } from 'rxjs'; +import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; +import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; + +describe('registerAnalyticsContextProvider', () => { + let analytics: ReturnType; + let location$: Subject; + + beforeEach(() => { + analytics = analyticsServiceMock.createAnalyticsServiceSetup(); + location$ = new ReplaySubject(1); + registerAnalyticsContextProvider({ analytics, location$ }); + }); + + test('should register the analytics context provider', () => { + expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1); + expect(analytics.registerContextProvider).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'page url', + }) + ); + }); + + test('emits a context value when location$ emits', async () => { + location$.next('/some_url'); + await expect( + firstValueFrom(analytics.registerContextProvider.mock.calls[0][0].context$) + ).resolves.toEqual({ page_url: '/some_url' }); + }); +}); diff --git a/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.ts b/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.ts new file mode 100644 index 0000000000000..9c79b0e15f070 --- /dev/null +++ b/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.ts @@ -0,0 +1,26 @@ +/* + * 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 type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser'; +import { type Observable, map } from 'rxjs'; + +export function registerAnalyticsContextProvider({ + analytics, + location$, +}: { + analytics: AnalyticsServiceSetup; + location$: Observable; +}) { + analytics.registerContextProvider({ + name: 'page url', + context$: location$.pipe(map((location) => ({ page_url: location }))), + schema: { + page_url: { type: 'text', _meta: { description: 'The page url' } }, + }, + }); +} diff --git a/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.test.ts b/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.test.ts new file mode 100644 index 0000000000000..3567929e6b688 --- /dev/null +++ b/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { createBrowserHistory, type History } from 'history'; +import { firstValueFrom } from 'rxjs'; +import { getLocationObservable } from './get_location_observable'; + +const nextTick = () => new Promise((resolve) => window.setTimeout(resolve, 1)); + +describe('getLocationObservable', () => { + let history: History; + + beforeEach(() => { + history = createBrowserHistory(); + }); + + it('emits with the initial location', async () => { + const location$ = getLocationObservable({ pathname: '/foo', hash: '' }, history); + expect(await firstValueFrom(location$)).toEqual('/foo'); + }); + + it('emits when the location changes', async () => { + const location$ = getLocationObservable({ pathname: '/foo', hash: '' }, history); + const locations: string[] = []; + location$.subscribe((location) => locations.push(location)); + + history.push({ pathname: '/bar' }); + history.push({ pathname: '/dolly' }); + + await nextTick(); + + expect(locations).toEqual(['/foo', '/bar', '/dolly']); + }); + + it('emits only once for a given url', async () => { + const location$ = getLocationObservable({ pathname: '/foo', hash: '' }, history); + const locations: string[] = []; + location$.subscribe((location) => locations.push(location)); + + history.push({ pathname: '/bar' }); + history.push({ pathname: '/bar' }); + history.push({ pathname: '/foo' }); + + await nextTick(); + + expect(locations).toEqual(['/foo', '/bar', '/foo']); + }); + + it('includes the hash when present', async () => { + const location$ = getLocationObservable({ pathname: '/foo', hash: '#/index' }, history); + const locations: string[] = []; + location$.subscribe((location) => locations.push(location)); + + history.push({ pathname: '/bar', hash: '#/home' }); + + await nextTick(); + + expect(locations).toEqual(['/foo#/index', '/bar#/home']); + }); +}); diff --git a/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.ts b/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.ts new file mode 100644 index 0000000000000..3e1957de38b63 --- /dev/null +++ b/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.ts @@ -0,0 +1,35 @@ +/* + * 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, Subject, startWith, shareReplay, distinctUntilChanged } from 'rxjs'; +import type { History } from 'history'; + +// interface compatible for both window.location and history.location... +export interface Location { + pathname: string; + hash: string; +} + +export const getLocationObservable = ( + initialLocation: Location, + history: History +): Observable => { + const subject = new Subject(); + history.listen((location) => { + subject.next(locationToUrl(location)); + }); + return subject.pipe( + startWith(locationToUrl(initialLocation)), + distinctUntilChanged(), + shareReplay(1) + ); +}; + +const locationToUrl = (location: Location) => { + return `${location.pathname}${location.hash}`; +}; diff --git a/packages/core/application/core-application-browser-internal/src/utils/index.ts b/packages/core/application/core-application-browser-internal/src/utils/index.ts index e88b1f7a8a6fc..f22146584f70a 100644 --- a/packages/core/application/core-application-browser-internal/src/utils/index.ts +++ b/packages/core/application/core-application-browser-internal/src/utils/index.ts @@ -11,3 +11,4 @@ export { getAppInfo } from './get_app_info'; export { parseAppUrl } from './parse_app_url'; export { relativeToAbsolute } from './relative_to_absolute'; export { removeSlashes } from './remove_slashes'; +export { getLocationObservable } from './get_location_observable'; diff --git a/packages/core/application/core-application-browser-internal/tsconfig.json b/packages/core/application/core-application-browser-internal/tsconfig.json index 8f54fa8aa6ae1..cc07927f15ed2 100644 --- a/packages/core/application/core-application-browser-internal/tsconfig.json +++ b/packages/core/application/core-application-browser-internal/tsconfig.json @@ -33,6 +33,8 @@ "@kbn/test-jest-helpers", "@kbn/core-custom-branding-browser", "@kbn/core-custom-branding-browser-mocks", + "@kbn/core-analytics-browser-mocks", + "@kbn/core-analytics-browser", ], "exclude": [ "target/**/*", diff --git a/packages/core/root/core-root-browser-internal/src/core_system.ts b/packages/core/root/core-root-browser-internal/src/core_system.ts index c4213e5efdb58..b4a8b6d2d2815 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.ts @@ -241,7 +241,7 @@ export class CoreSystem { const notifications = this.notifications.setup({ uiSettings }); const customBranding = this.customBranding.setup({ injectedMetadata }); - const application = this.application.setup({ http }); + const application = this.application.setup({ http, analytics }); this.coreApp.setup({ application, http, injectedMetadata, notifications }); const core: InternalCoreSetup = { diff --git a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts index 79e43f0df086f..3a7c075abd613 100644 --- a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts +++ b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts @@ -98,5 +98,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(event.context).to.have.property('viewport_height'); expect(event.context.viewport_height).to.be.a('number'); }); + + it('should have the properties provided by the "page url" context provider', () => { + expect(event.context).to.have.property('page_url'); + expect(event.context.page_url).to.be.a('string'); + }); }); } From 8d83f64383ddbfe6ada155a161e6cb274bd6c540 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 20 Jun 2023 12:24:06 +0200 Subject: [PATCH 03/14] [Synthetics] Add TLS Certificate expiry alert (#159697) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Abdul Wahab Zahid --- api_docs/deprecations_by_plugin.mdx | 2 +- .../current_mappings.json | 29 ++-- .../group2/check_registered_types.test.ts | 2 +- .../common/constants/monitor_defaults.ts | 2 +- .../common/constants/synthetics/rest_api.ts | 2 +- .../common/constants/synthetics_alerts.ts | 16 +- .../common/constants/uptime_alerts.ts | 12 -- .../plugins/synthetics/common/field_names.ts | 11 +- .../common/requests/get_certs_request_body.ts | 11 ++ .../common/rules/alert_actions.test.ts | 61 +++++++ .../synthetics/common/rules/alert_actions.ts | 14 ++ .../common/rules/synthetics/translations.ts | 52 ++++++ .../synthetics/common/runtime_types/certs.ts | 5 + .../project_monitor_read_only.journey.ts | 3 + .../components/alerts/alert_tls.tsx | 84 +++++++++ .../components/alerts/hooks/translations.ts | 6 + .../alerts/hooks/use_synthetics_alert.ts | 17 +- .../components/alerts/query_bar.tsx | 102 +++++++++++ .../components/alerts/tls_rule_ui.tsx | 47 +++++ .../alerts/toggle_alert_flyout_button.tsx | 30 +++- .../certificates/certificates_list.tsx | 4 + .../monitor_add_edit/form/field_config.tsx | 25 +++ .../monitor_add_edit/form/form_config.tsx | 3 + .../components/monitor_add_edit/types.ts | 2 + .../apps/synthetics/lib/alert_types/index.ts | 6 +- .../alert_types/lazy_wrapper/tls_alert.tsx | 36 ++++ .../lazy_wrapper/validate_tls_alert.ts | 27 +++ .../apps/synthetics/lib/alert_types/tls.tsx | 57 +++++++ .../synthetics/state/alert_rules/actions.ts | 7 +- .../apps/synthetics/state/alert_rules/api.ts | 2 +- .../apps/synthetics/state/ui/actions.ts | 10 +- .../public/apps/synthetics/state/ui/index.ts | 15 +- .../apps/synthetics/state/ui/selectors.ts | 17 -- .../__mocks__/synthetics_store.mock.ts | 2 +- .../certificates/certificates_list.tsx | 4 + .../alert_rules/tls_rule/message_utils.ts | 70 ++++++++ .../server/alert_rules/tls_rule/tls_rule.ts | 161 ++++++++++++++++++ .../tls_rule/tls_rule_executor.test.ts | 82 +++++++++ .../alert_rules/tls_rule/tls_rule_executor.ts | 147 ++++++++++++++++ .../server/alert_rules/tls_rule/types.ts | 23 +++ .../server/alert_rules/translations.ts | 88 ++++++++++ .../lib/requests/get_certs.test.ts | 7 + .../migrations/monitors/8.9.0.test.ts | 88 ++++++++++ .../migrations/monitors/8.9.0.ts | 52 ++++++ .../migrations/monitors/index.ts | 2 + .../lib/saved_objects/synthetics_monitor.ts | 8 + .../synthetics/server/queries/get_certs.ts | 37 ++++ .../default_alerts/enable_default_alert.ts | 19 ++- .../default_alerts/status_alert_service.ts | 1 - .../default_alerts/tls_alert_service.ts | 131 ++++++++++++++ .../default_alerts/update_default_alert.ts | 7 +- x-pack/plugins/synthetics/server/server.ts | 11 ++ .../normalizers/common_fields.test.ts | 6 + .../normalizers/common_fields.ts | 37 ++-- .../normalizers/http_monitor.ts | 2 +- .../group4/check_registered_rule_types.ts | 1 + .../apis/synthetics/add_monitor_project.ts | 23 ++- .../check_registered_task_types.ts | 1 + 58 files changed, 1635 insertions(+), 94 deletions(-) create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/alert_tls.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/query_bar.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/tls_rule_ui.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/tls_alert.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/validate_tls_alert.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/tls.tsx create mode 100644 x-pack/plugins/synthetics/server/alert_rules/tls_rule/message_utils.ts create mode 100644 x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule.ts create mode 100644 x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule_executor.test.ts create mode 100644 x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule_executor.ts create mode 100644 x-pack/plugins/synthetics/server/alert_rules/tls_rule/types.ts create mode 100644 x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.9.0.test.ts create mode 100644 x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.9.0.ts create mode 100644 x-pack/plugins/synthetics/server/queries/get_certs.ts create mode 100644 x-pack/plugins/synthetics/server/routes/default_alerts/tls_alert_service.ts diff --git a/api_docs/deprecations_by_plugin.mdx b/api_docs/deprecations_by_plugin.mdx index d5cba68bc1777..d0ba182370c29 100644 --- a/api_docs/deprecations_by_plugin.mdx +++ b/api_docs/deprecations_by_plugin.mdx @@ -1211,7 +1211,7 @@ migrates to using the Kibana Privilege model: https://github.com/elastic/kibana/ | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| -| | [common.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/common.ts#:~:text=alertFactory), [status_check.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/status_check.ts#:~:text=alertFactory), [tls.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/tls.ts#:~:text=alertFactory), [tls_legacy.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/tls_legacy.ts#:~:text=alertFactory), [duration_anomaly.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/duration_anomaly.ts#:~:text=alertFactory), [common.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/alert_rules/common.ts#:~:text=alertFactory), [monitor_status_rule.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/alert_rules/status_rule/monitor_status_rule.ts#:~:text=alertFactory) | - | +| | [common.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/common.ts#:~:text=alertFactory), [status_check.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/status_check.ts#:~:text=alertFactory), [tls.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/tls.ts#:~:text=alertFactory), [tls_legacy.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/tls_legacy.ts#:~:text=alertFactory), [duration_anomaly.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/duration_anomaly.ts#:~:text=alertFactory), [common.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/alert_rules/common.ts#:~:text=alertFactory), [tls_rule.ts](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/server/alert_rules/status_rule/tls_rule.ts#:~:text=alertFactory) | - | | | [filter_group.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/filter_group/filter_group.tsx#:~:text=title), [filters_expression_select.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_expressions/filters_expression_select.tsx#:~:text=title), [filter_group.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/filter_group/filter_group.tsx#:~:text=title), [filters_expression_select.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_expressions/filters_expression_select.tsx#:~:text=title) | - | | | [filter_group.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/filter_group/filter_group.tsx#:~:text=title), [filters_expression_select.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_expressions/filters_expression_select.tsx#:~:text=title), [filter_group.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/filter_group/filter_group.tsx#:~:text=title), [filters_expression_select.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_expressions/filters_expression_select.tsx#:~:text=title) | - | | | [filter_group.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/filter_group/filter_group.tsx#:~:text=title), [filters_expression_select.tsx](https://github.com/elastic/kibana/tree/main/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_expressions/filters_expression_select.tsx#:~:text=title) | - | diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index e0a24046196d6..1e0ac8548906e 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -931,17 +931,6 @@ } } }, - "event-annotation-group": { - "dynamic": false, - "properties": { - "title": { - "type": "text" - }, - "description": { - "type": "text" - } - } - }, "visualization": { "dynamic": false, "properties": { @@ -959,6 +948,17 @@ } } }, + "event-annotation-group": { + "dynamic": false, + "properties": { + "title": { + "type": "text" + }, + "description": { + "type": "text" + } + } + }, "dashboard": { "properties": { "description": { @@ -2377,6 +2377,13 @@ "type": "boolean" } } + }, + "tls": { + "properties": { + "enabled": { + "type": "boolean" + } + } } } }, diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index 58ec8e1597cba..cd7ef7ea94031 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -140,7 +140,7 @@ describe('checking migration metadata changes on all registered SO types', () => "slo": "2048ab6791df2e1ae0936f29c20765cb8d2fcfaa", "space": "8de4ec513e9bbc6b2f1d635161d850be7747d38e", "spaces-usage-stats": "3abca98713c52af8b30300e386c7779b3025a20e", - "synthetics-monitor": "ca7c0710c0607e44b2c52e5a41086b8b4a214f63", + "synthetics-monitor": "33ddc4b8979f378edf58bcc7ba13c5c5b572f42d", "synthetics-param": "3ebb744e5571de678b1312d5c418c8188002cf5e", "synthetics-privates-locations": "9cfbd6d1d2e2c559bf96dd6fbc27ff0c47719dd3", "tag": "e2544392fe6563e215bb677abc8b01c2601ef2dc", diff --git a/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts b/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts index b8f307f12428c..36f9fbf467dc0 100644 --- a/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts +++ b/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts @@ -132,7 +132,7 @@ export const DEFAULT_COMMON_FIELDS: CommonFields = { [ConfigKey.MONITOR_TYPE]: DataStream.HTTP, [ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.MULTISTEP, [ConfigKey.ENABLED]: true, - [ConfigKey.ALERT_CONFIG]: { status: { enabled: true } }, + [ConfigKey.ALERT_CONFIG]: { status: { enabled: true }, tls: { enabled: true } }, [ConfigKey.SCHEDULE]: { number: '3', unit: ScheduleUnit.MINUTES, diff --git a/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts b/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts index 62f16b75fdb96..d83cecdf567bc 100644 --- a/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts +++ b/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts @@ -8,7 +8,7 @@ export enum SYNTHETICS_API_URLS { // Service end points INDEX_TEMPLATES = '/internal/synthetics/service/index_templates', - SERVICE_LOCATIONS = '/internal/synthetics/service/locations', + SERVICE_LOCATIONS = '/internal/uptime/service/locations', SYNTHETICS_MONITORS = '/internal/synthetics/service/monitors', SYNTHETICS_MONITOR_INSPECT = '/internal/synthetics/service/monitor/inspect', GET_SYNTHETICS_MONITOR = '/internal/synthetics/service/monitor/{monitorId}', diff --git a/x-pack/plugins/synthetics/common/constants/synthetics_alerts.ts b/x-pack/plugins/synthetics/common/constants/synthetics_alerts.ts index 7f6d306f8be88..620c5c88033a7 100644 --- a/x-pack/plugins/synthetics/common/constants/synthetics_alerts.ts +++ b/x-pack/plugins/synthetics/common/constants/synthetics_alerts.ts @@ -9,7 +9,8 @@ import { ActionGroup } from '@kbn/alerting-plugin/common'; import { i18n } from '@kbn/i18n'; export type MonitorStatusActionGroup = - ActionGroup<'xpack.synthetics.alerts.actionGroups.monitorStatus'>; + | ActionGroup<'xpack.synthetics.alerts.actionGroups.monitorStatus'> + | ActionGroup<'xpack.synthetics.alerts.actionGroups.tls'>; export const MONITOR_STATUS: MonitorStatusActionGroup = { id: 'xpack.synthetics.alerts.actionGroups.monitorStatus', @@ -18,18 +19,29 @@ export const MONITOR_STATUS: MonitorStatusActionGroup = { }), }; +export const TLS_CERTIFICATE: MonitorStatusActionGroup = { + id: 'xpack.synthetics.alerts.actionGroups.tls', + name: i18n.translate('xpack.synthetics.alertRules.actionGroups.tls', { + defaultMessage: 'Synthetics TLS certificate', + }), +}; + export const ACTION_GROUP_DEFINITIONS: { MONITOR_STATUS: MonitorStatusActionGroup; + TLS_CERTIFICATE: MonitorStatusActionGroup; } = { MONITOR_STATUS, + TLS_CERTIFICATE, }; export const SYNTHETICS_STATUS_RULE = 'xpack.synthetics.alerts.monitorStatus'; +export const SYNTHETICS_TLS_RULE = 'xpack.synthetics.alerts.tls'; export const SYNTHETICS_ALERT_RULE_TYPES = { MONITOR_STATUS: SYNTHETICS_STATUS_RULE, + TLS: SYNTHETICS_TLS_RULE, }; -export const SYNTHETICS_RULE_TYPES = [SYNTHETICS_STATUS_RULE]; +export const SYNTHETICS_RULE_TYPES = [SYNTHETICS_STATUS_RULE, SYNTHETICS_TLS_RULE]; export const SYNTHETICS_RULE_TYPES_ALERT_CONTEXT = 'observability.uptime'; diff --git a/x-pack/plugins/synthetics/common/constants/uptime_alerts.ts b/x-pack/plugins/synthetics/common/constants/uptime_alerts.ts index a265d13fb71d8..71f6bd1b183fb 100644 --- a/x-pack/plugins/synthetics/common/constants/uptime_alerts.ts +++ b/x-pack/plugins/synthetics/common/constants/uptime_alerts.ts @@ -34,18 +34,6 @@ export const DURATION_ANOMALY: DurationAnomalyActionGroup = { name: 'Uptime Duration Anomaly', }; -export const ACTION_GROUP_DEFINITIONS: { - MONITOR_STATUS: MonitorStatusActionGroup; - TLS_LEGACY: TLSLegacyActionGroup; - TLS: TLSActionGroup; - DURATION_ANOMALY: DurationAnomalyActionGroup; -} = { - MONITOR_STATUS, - TLS_LEGACY, - TLS, - DURATION_ANOMALY, -}; - export const CLIENT_ALERT_TYPES = { MONITOR_STATUS: 'xpack.uptime.alerts.monitorStatus', TLS_LEGACY: 'xpack.uptime.alerts.tls', diff --git a/x-pack/plugins/synthetics/common/field_names.ts b/x-pack/plugins/synthetics/common/field_names.ts index 3a2054089d41d..0407fad341d8a 100644 --- a/x-pack/plugins/synthetics/common/field_names.ts +++ b/x-pack/plugins/synthetics/common/field_names.ts @@ -6,16 +6,17 @@ */ export const AGENT_NAME = 'agent.name'; - export const MONITOR_ID = 'monitor.id'; export const MONITOR_NAME = 'monitor.name'; export const MONITOR_TYPE = 'monitor.type'; - export const URL_FULL = 'url.full'; export const URL_PORT = 'url.port'; - export const OBSERVER_GEO_NAME = 'observer.geo.name'; - export const ERROR_MESSAGE = 'error.message'; +export const STATE_ID = 'monitor.state.id'; -export const STATE_ID = 'montior.state.id'; +export const CERT_COMMON_NAME = 'tls.server.x509.subject.common_name'; +export const CERT_ISSUER_NAME = 'tls.server.x509.issuer.common_name'; +export const CERT_VALID_NOT_AFTER = 'tls.server.x509.not_after'; +export const CERT_VALID_NOT_BEFORE = 'tls.server.x509.not_before'; +export const CERT_HASH_SHA256 = 'tls.server.hash.sha256'; diff --git a/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts b/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts index 72ed6346df188..7a57f8ab13d41 100644 --- a/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts +++ b/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts @@ -31,6 +31,7 @@ function absoluteDate(relativeDate: string) { } export const getCertsRequestBody = ({ + monitorIds, pageIndex, search, notValidBefore, @@ -79,6 +80,9 @@ export const getCertsRequestBody = ({ : {}), filter: [ ...(filters ? [filters] : []), + ...(monitorIds && monitorIds.length > 0 + ? [{ terms: { 'monitor.id': monitorIds } }] + : []), { exists: { field: 'tls.server.hash.sha256', @@ -129,6 +133,9 @@ export const getCertsRequestBody = ({ _source: [ 'monitor.id', 'monitor.name', + 'monitor.type', + 'url.full', + 'observer.geo.name', 'tls.server.x509.issuer.common_name', 'tls.server.x509.subject.common_name', 'tls.server.hash.sha1', @@ -193,6 +200,10 @@ export const processCertsResult = (result: CertificatesResults): CertResult => { not_after: notAfter, not_before: notBefore, common_name: commonName, + monitorName: ping?.monitor?.name, + monitorUrl: ping?.url?.full, + monitorType: ping?.monitor?.type, + locationName: ping?.observer?.geo?.name, }; }); const total = result.aggregations?.total?.value ?? 0; diff --git a/x-pack/plugins/synthetics/common/rules/alert_actions.test.ts b/x-pack/plugins/synthetics/common/rules/alert_actions.test.ts index e8514303059d6..3315f5059ef86 100644 --- a/x-pack/plugins/synthetics/common/rules/alert_actions.test.ts +++ b/x-pack/plugins/synthetics/common/rules/alert_actions.test.ts @@ -211,6 +211,7 @@ describe('Legacy Alert Actions factory', () => { defaultSubjectMessage: MonitorStatusTranslations.defaultSubjectMessage, defaultRecoverySubjectMessage: MonitorStatusTranslations.defaultRecoverySubjectMessage, }, + isLegacy: true, }); expect(resp).toEqual([ { @@ -243,6 +244,11 @@ describe('Alert Actions factory', () => { groupId: SYNTHETICS_MONITOR_STATUS.id, defaultActions: [ { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, actionTypeId: '.pagerduty', group: 'xpack.uptime.alerts.actionGroups.monitorStatus', params: { @@ -264,6 +270,11 @@ describe('Alert Actions factory', () => { }); expect(resp).toEqual([ { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, group: 'recovered', id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', params: { @@ -274,6 +285,11 @@ describe('Alert Actions factory', () => { }, }, { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, group: 'xpack.synthetics.alerts.actionGroups.monitorStatus', id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', params: { @@ -291,6 +307,11 @@ describe('Alert Actions factory', () => { groupId: SYNTHETICS_MONITOR_STATUS.id, defaultActions: [ { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, actionTypeId: '.index', group: 'xpack.synthetics.alerts.actionGroups.monitorStatus', params: { @@ -312,6 +333,11 @@ describe('Alert Actions factory', () => { }); expect(resp).toEqual([ { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, group: 'recovered', id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', params: { @@ -329,6 +355,11 @@ describe('Alert Actions factory', () => { }, }, { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, group: 'xpack.synthetics.alerts.actionGroups.monitorStatus', id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', params: { @@ -352,6 +383,11 @@ describe('Alert Actions factory', () => { groupId: SYNTHETICS_MONITOR_STATUS.id, defaultActions: [ { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, actionTypeId: '.pagerduty', group: 'xpack.synthetics.alerts.actionGroups.monitorStatus', params: { @@ -374,6 +410,11 @@ describe('Alert Actions factory', () => { }); expect(resp).toEqual([ { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, group: 'recovered', id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', params: { @@ -384,6 +425,11 @@ describe('Alert Actions factory', () => { }, }, { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, group: 'xpack.synthetics.alerts.actionGroups.monitorStatus', id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', params: { @@ -401,6 +447,11 @@ describe('Alert Actions factory', () => { groupId: SYNTHETICS_MONITOR_STATUS.id, defaultActions: [ { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, actionTypeId: '.email', group: 'xpack.synthetics.alerts.actionGroups.monitorStatus', params: { @@ -426,6 +477,11 @@ describe('Alert Actions factory', () => { }); expect(resp).toEqual([ { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, group: 'recovered', id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', params: { @@ -444,6 +500,11 @@ describe('Alert Actions factory', () => { }, }, { + frequency: { + notifyWhen: 'onActionGroupChange', + summary: false, + throttle: null, + }, group: 'xpack.synthetics.alerts.actionGroups.monitorStatus', id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', params: { diff --git a/x-pack/plugins/synthetics/common/rules/alert_actions.ts b/x-pack/plugins/synthetics/common/rules/alert_actions.ts index db128ec6a26f5..70a2de4d7cf59 100644 --- a/x-pack/plugins/synthetics/common/rules/alert_actions.ts +++ b/x-pack/plugins/synthetics/common/rules/alert_actions.ts @@ -58,6 +58,13 @@ export function populateAlertActions({ id: aId.id, group: groupId, params: {}, + frequency: !isLegacy + ? { + notifyWhen: 'onActionGroupChange', + throttle: null, + summary: false, + } + : undefined, }; const recoveredAction: RuleAction = { @@ -66,6 +73,13 @@ export function populateAlertActions({ params: { message: translations.defaultRecoveryMessage, }, + frequency: !isLegacy + ? { + notifyWhen: 'onActionGroupChange', + throttle: null, + summary: false, + } + : undefined, }; switch (aId.actionTypeId) { diff --git a/x-pack/plugins/synthetics/common/rules/synthetics/translations.ts b/x-pack/plugins/synthetics/common/rules/synthetics/translations.ts index b288ff25fc045..4ac78b9620efe 100644 --- a/x-pack/plugins/synthetics/common/rules/synthetics/translations.ts +++ b/x-pack/plugins/synthetics/common/rules/synthetics/translations.ts @@ -72,3 +72,55 @@ export const SyntheticsMonitorStatusTranslations = { defaultMessage: 'Alert when a monitor is down.', }), }; + +export const TlsTranslations = { + defaultActionMessage: i18n.translate('xpack.synthetics.rules.tls.defaultActionMessage', { + defaultMessage: `Detected TLS certificate {commonName} is {status} - Elastic Synthetics\n\nDetails:\n\n- Summary: {summary}\n- Common name: {commonName}\n- Issuer: {issuer}\n- Monitor: {monitorName} \n- Monitor URL: {monitorUrl} \n- Monitor type: {monitorType} \n- From: {locationName}`, + values: { + commonName: '{{context.commonName}}', + issuer: '{{context.issuer}}', + summary: '{{context.summary}}', + status: '{{context.status}}', + monitorName: '{{context.monitorName}}', + monitorUrl: '{{{context.monitorUrl}}}', + monitorType: '{{context.monitorType}}', + locationName: '{{context.locationName}}', + }, + }), + defaultRecoveryMessage: i18n.translate('xpack.synthetics.rules.tls.defaultRecoveryMessage', { + defaultMessage: `Alert for TLS certificate {commonName} from issuer {issuer} has recovered - Elastic Synthetics\n\nDetails:\n\n- Summary: {summary}\n- Common name: {commonName}\n- Issuer: {issuer}\n- Monitor: {monitorName} \n- Monitor URL: {monitorUrl} \n- Monitor type: {monitorType} \n- From: {locationName}`, + values: { + commonName: '{{context.commonName}}', + issuer: '{{context.issuer}}', + summary: '{{context.summary}}', + monitorName: '{{context.monitorName}}', + monitorUrl: '{{{context.monitorUrl}}}', + monitorType: '{{context.monitorType}}', + locationName: '{{context.locationName}}', + }, + }), + name: i18n.translate('xpack.synthetics.rules.tls.clientName', { + defaultMessage: 'Synthetics TLS', + }), + description: i18n.translate('xpack.synthetics.rules.tls.description', { + defaultMessage: 'Alert when the TLS certificate of a Synthetics monitor is about to expire.', + }), + defaultSubjectMessage: i18n.translate( + 'xpack.synthetics.alerts.syntheticsMonitorTLS.defaultSubjectMessage', + { + defaultMessage: 'Alert triggered for certificate {commonName} - Elastic Synthetics', + values: { + commonName: '{{context.commonName}}', + }, + } + ), + defaultRecoverySubjectMessage: i18n.translate( + 'xpack.synthetics.alerts.syntheticsMonitorTLS.defaultRecoverySubjectMessage', + { + defaultMessage: 'Alert has resolved for certificate {commonName} - Elastic Synthetics', + values: { + commonName: '{{context.commonName}}', + }, + } + ), +}; diff --git a/x-pack/plugins/synthetics/common/runtime_types/certs.ts b/x-pack/plugins/synthetics/common/runtime_types/certs.ts index f7de2280d4b65..bb854930f8e4d 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/certs.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/certs.ts @@ -21,6 +21,7 @@ export const GetCertsParamsType = t.intersection([ direction: t.string, size: t.number, filters: t.unknown, + monitorIds: t.array(t.string), }), ]); @@ -44,6 +45,10 @@ export const CertType = t.intersection([ common_name: t.string, issuer: t.string, sha1: t.string, + monitorName: t.string, + monitorType: t.string, + monitorUrl: t.string, + locationName: t.string, }), ]); diff --git a/x-pack/plugins/synthetics/e2e/synthetics/journeys/project_monitor_read_only.journey.ts b/x-pack/plugins/synthetics/e2e/synthetics/journeys/project_monitor_read_only.journey.ts index e3cf2d4eee4ea..37da92bf3277e 100644 --- a/x-pack/plugins/synthetics/e2e/synthetics/journeys/project_monitor_read_only.journey.ts +++ b/x-pack/plugins/synthetics/e2e/synthetics/journeys/project_monitor_read_only.journey.ts @@ -94,6 +94,9 @@ journey('ProjectMonitorReadOnly', async ({ page, params }) => { status: { enabled: !(originalMonitorConfiguration?.alert?.status?.enabled as boolean), }, + tls: { + enabled: originalMonitorConfiguration?.alert?.tls?.enabled as boolean, + }, }, enabled: !originalMonitorConfiguration?.enabled, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/alert_tls.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/alert_tls.tsx new file mode 100644 index 0000000000000..6c3c8619a13ec --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/alert_tls.tsx @@ -0,0 +1,84 @@ +/* + * 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 { EuiExpression, EuiFlexItem, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { ValueExpression } from '@kbn/triggers-actions-ui-plugin/public'; +import { i18n } from '@kbn/i18n'; + +interface Props { + ageThreshold: number; + expirationThreshold: number; + setAgeThreshold: (value: number) => void; + setExpirationThreshold: (value: number) => void; +} + +export const AlertTlsComponent: React.FC = ({ + ageThreshold, + expirationThreshold, + setAgeThreshold, + setExpirationThreshold, +}) => ( + <> + + + + + + + { + setExpirationThreshold(val); + }} + description={TlsTranslations.expirationDescription} + errors={[]} + /> + + + { + setAgeThreshold(val); + }} + description={TlsTranslations.ageDescription} + errors={[]} + /> + + + + +); + +export const TlsTranslations = { + criteriaAriaLabel: i18n.translate('xpack.synthetics.rules.tls.criteriaExpression.ariaLabel', { + defaultMessage: + 'An expression displaying the criteria for the monitors that are being watched by this alert', + }), + criteriaDescription: i18n.translate( + 'xpack.synthetics.alerts.tls.criteriaExpression.description', + { + defaultMessage: 'when', + description: + 'The context of this `when` is in the conditional sense, like "when there are three cookies, eat them all".', + } + ), + criteriaValue: i18n.translate('xpack.synthetics.tls.criteriaExpression.value', { + defaultMessage: 'matching monitor', + }), + expirationDescription: i18n.translate('xpack.synthetics.tls.expirationExpression.description', { + defaultMessage: 'has a certificate expiring within days: ', + }), + ageDescription: i18n.translate('xpack.synthetics.tls.ageExpression.description', { + defaultMessage: 'or older than days: ', + }), +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/hooks/translations.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/hooks/translations.ts index 31b336c81b748..2901b67820485 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/hooks/translations.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/hooks/translations.ts @@ -18,9 +18,15 @@ export const ToggleFlyoutTranslations = { toggleMonitorStatusAriaLabel: i18n.translate('xpack.synthetics.toggleAlertFlyout.ariaLabel', { defaultMessage: 'Open add rule flyout', }), + toggleTlsAriaLabel: i18n.translate('xpack.synthetics.toggleAlertFlyout.tls.ariaLabel', { + defaultMessage: 'Open add tls rule flyout', + }), toggleMonitorStatusContent: i18n.translate('xpack.synthetics.toggleAlertButton.content', { defaultMessage: 'Monitor status rule', }), + toggleTlsContent: i18n.translate('xpack.synthetics.toggleTlsAlertButton.label.content', { + defaultMessage: 'TLS certificate rule', + }), navigateToAlertingUIAriaLabel: i18n.translate('xpack.synthetics.app.navigateToAlertingUi', { defaultMessage: 'Leave Synthetics and go to Alerting Management page', }), diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_alert.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_alert.ts index 120597c62636d..de661f9d33d09 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_alert.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/hooks/use_synthetics_alert.ts @@ -6,18 +6,20 @@ */ import { useFetcher } from '@kbn/observability-shared-plugin/public'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useEffect, useMemo, useState } from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; -import { setAlertFlyoutVisible } from '../../../state'; +import { SYNTHETICS_TLS_RULE } from '../../../../../../common/constants/synthetics_alerts'; +import { selectAlertFlyoutVisibility, setAlertFlyoutVisible } from '../../../state'; import { enableDefaultAlertingAPI } from '../../../state/alert_rules/api'; import { ClientPluginsStart } from '../../../../../plugin'; export const useSyntheticsAlert = (isOpen: boolean) => { const dispatch = useDispatch(); - const [alert, setAlert] = useState(null); + const [defaultRules, setAlert] = useState<{ statusRule: Rule; tlsRule: Rule } | null>(null); + const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); const { data, loading } = useFetcher(() => { if (isOpen) { @@ -34,15 +36,16 @@ export const useSyntheticsAlert = (isOpen: boolean) => { const { triggersActionsUi } = useKibana().services; const EditAlertFlyout = useMemo(() => { - if (!alert) { + if (!defaultRules) { return null; } return triggersActionsUi.getEditRuleFlyout({ - onClose: () => dispatch(setAlertFlyoutVisible(false)), + onClose: () => dispatch(setAlertFlyoutVisible(null)), hideInterval: true, - initialRule: alert, + initialRule: + alertFlyoutVisible === SYNTHETICS_TLS_RULE ? defaultRules.tlsRule : defaultRules.statusRule, }); - }, [alert, dispatch, triggersActionsUi]); + }, [defaultRules, dispatch, triggersActionsUi, alertFlyoutVisible]); return useMemo(() => ({ loading, EditAlertFlyout }), [EditAlertFlyout, loading]); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/query_bar.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/query_bar.tsx new file mode 100644 index 0000000000000..dd9aada9b8d20 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/query_bar.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { QueryStringInput } from '@kbn/unified-search-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useFetcher } from '@kbn/observability-shared-plugin/public'; +import { SYNTHETICS_INDEX_PATTERN } from '../../../../../common/constants'; +import { ClientPluginsStart } from '../../../../plugin'; + +interface Props { + query: string; + onChange: (query: string) => void; +} +export const isValidKuery = (query: string) => { + if (query === '') { + return true; + } + const listOfOperators = [':', '>=', '=>', '>', '<']; + for (let i = 0; i < listOfOperators.length; i++) { + const operator = listOfOperators[i]; + const qParts = query.trim().split(operator); + if (query.includes(operator) && qParts.length > 1 && qParts[1]) { + return true; + } + } + return false; +}; + +export const AlertQueryBar = ({ query = '', onChange }: Props) => { + const { services } = useKibana(); + + const { + appName, + notifications, + http, + docLinks, + uiSettings, + data, + dataViews, + unifiedSearch, + storage, + usageCollection, + } = services; + + const [inputVal, setInputVal] = useState(query); + + const { data: dataView } = useFetcher(async () => { + return await dataViews.create({ title: SYNTHETICS_INDEX_PATTERN }); + }, []); + + useEffect(() => { + onChange(query); + setInputVal(query); + }, [onChange, query]); + + return ( + + { + setInputVal(queryN?.query as string); + if (isValidKuery(queryN?.query as string)) { + // we want to submit when user clears or paste a complete kuery + onChange(queryN.query as string); + } + }} + onSubmit={(queryN) => { + if (queryN) onChange(queryN.query as string); + }} + query={{ query: inputVal, language: 'kuery' }} + dataTestSubj="xpack.synthetics.alerts.monitorStatus.filterBar" + autoSubmit={true} + disableLanguageSwitcher={true} + isInvalid={!!(inputVal && !query)} + placeholder={i18n.translate('xpack.synthetics.alerts.searchPlaceholder.kql', { + defaultMessage: 'Filter using kql syntax', + })} + appName={appName} + deps={{ + unifiedSearch, + data, + dataViews, + storage, + notifications, + http, + docLinks, + uiSettings, + usageCollection, + }} + /> + + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/tls_rule_ui.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/tls_rule_ui.tsx new file mode 100644 index 0000000000000..a4e653bb1ddb0 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/tls_rule_ui.tsx @@ -0,0 +1,47 @@ +/* + * 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 { useDispatch, useSelector } from 'react-redux'; +import React, { useEffect } from 'react'; +import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { AlertTlsComponent } from './alert_tls'; +import { getDynamicSettings } from '../../state/settings/api'; +import { selectDynamicSettings } from '../../state/settings'; +import { TLSParams } from '../../../../../common/runtime_types/alerts/tls'; +import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../../common/constants'; + +export const TLSRuleComponent: React.FC<{ + ruleParams: RuleTypeParamsExpressionProps['ruleParams']; + setRuleParams: RuleTypeParamsExpressionProps['setRuleParams']; +}> = ({ ruleParams, setRuleParams }) => { + const dispatch = useDispatch(); + + const { settings } = useSelector(selectDynamicSettings); + + useEffect(() => { + if (typeof settings === 'undefined') { + dispatch(getDynamicSettings()); + } + }, [dispatch, settings]); + + return ( + setRuleParams('certAgeThreshold', Number(value))} + setExpirationThreshold={(value) => setRuleParams('certExpirationThreshold', Number(value))} + /> + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx index 96c00d08843b5..89ee3fd6faab0 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/alerts/toggle_alert_flyout_button.tsx @@ -19,6 +19,10 @@ import { EuiPopover, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { + SYNTHETICS_STATUS_RULE, + SYNTHETICS_TLS_RULE, +} from '../../../../../common/constants/synthetics_alerts'; import { ManageRulesLink } from '../common/links/manage_rules_link'; import { ClientPluginsStart } from '../../../../plugin'; import { ToggleFlyoutTranslations } from './hooks/translations'; @@ -48,7 +52,29 @@ export const ToggleAlertFlyoutButton = () => { ), onClick: () => { - dispatch(setAlertFlyoutVisible(true)); + dispatch(setAlertFlyoutVisible(SYNTHETICS_STATUS_RULE)); + setIsOpen(false); + }, + toolTipContent: !hasUptimeWrite ? noWritePermissionsTooltipContent : null, + disabled: !hasUptimeWrite || loading, + icon: 'bell', + }; + + const tlsAlertContextMenuItem: EuiContextMenuPanelItemDescriptor = { + 'aria-label': ToggleFlyoutTranslations.toggleMonitorStatusAriaLabel, + 'data-test-subj': 'xpack.synthetics.toggleAlertFlyout.tls', + name: ( + + {ToggleFlyoutTranslations.toggleTlsContent} + {loading && ( + + + + )} + + ), + onClick: () => { + dispatch(setAlertFlyoutVisible(SYNTHETICS_TLS_RULE)); setIsOpen(false); }, toolTipContent: !hasUptimeWrite ? noWritePermissionsTooltipContent : null, @@ -66,7 +92,7 @@ export const ToggleAlertFlyoutButton = () => { const panels: EuiContextMenuPanelDescriptor[] = [ { id: 0, - items: [monitorStatusAlertContextMenuItem, managementContextItem], + items: [monitorStatusAlertContextMenuItem, tlsAlertContextMenuItem, managementContextItem], }, ]; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/certificates/certificates_list.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/certificates/certificates_list.tsx index f85f16eea0a69..1832d9a8796d9 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/certificates/certificates_list.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/certificates/certificates_list.tsx @@ -21,6 +21,10 @@ interface Page { } export type CertFields = + | 'monitorName' + | 'locationName' + | 'monitorType' + | 'monitorUrl' | 'sha256' | 'sha1' | 'issuer' diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx index 49148bb7cb4bf..17d540c05ca9c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx @@ -500,6 +500,31 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ // isDisabled: readOnly, }), }, + [AlertConfigKey.TLS_ENABLED]: { + fieldKey: AlertConfigKey.TLS_ENABLED, + component: Switch, + label: i18n.translate('xpack.synthetics.monitorConfig.enabledAlerting.tls.label', { + defaultMessage: 'Enable TLS alerts', + }), + controlled: true, + props: ({ isEdit, setValue, field }): EuiSwitchProps => ({ + id: 'syntheticsMonitorConfigIsTlsAlertEnabled', + label: isEdit + ? i18n.translate('xpack.synthetics.monitorConfig.edit.alertTlsEnabled.label', { + defaultMessage: 'Disabling will stop tls alerting on this monitor.', + }) + : i18n.translate('xpack.synthetics.monitorConfig.create.alertTlsEnabled.label', { + defaultMessage: 'Enable tls alerts on this monitor.', + }), + checked: field?.value || false, + onChange: (event) => { + setValue(AlertConfigKey.TLS_ENABLED, !!event.target.checked); + }, + 'data-test-subj': 'syntheticsAlertStatusSwitch', + // alert config is an allowed field for read only + // isDisabled: readOnly, + }), + }, [ConfigKey.TAGS]: { fieldKey: ConfigKey.TAGS, component: FormattedComboBox, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx index 8e74162ecef77..ffd8f1c3cfccd 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx @@ -199,6 +199,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({ FIELD(readOnly)[ConfigKey.TIMEOUT], FIELD(readOnly)[ConfigKey.ENABLED], FIELD(readOnly)[AlertConfigKey.STATUS_ENABLED], + FIELD(readOnly)[AlertConfigKey.TLS_ENABLED], ], advanced: [ DEFAULT_DATA_OPTIONS(readOnly), @@ -218,6 +219,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({ FIELD(readOnly)[ConfigKey.TIMEOUT], FIELD(readOnly)[ConfigKey.ENABLED], FIELD(readOnly)[AlertConfigKey.STATUS_ENABLED], + FIELD(readOnly)[AlertConfigKey.TLS_ENABLED], ], advanced: [ DEFAULT_DATA_OPTIONS(readOnly), @@ -285,6 +287,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({ FIELD(readOnly)[ConfigKey.TIMEOUT], FIELD(readOnly)[ConfigKey.ENABLED], FIELD(readOnly)[AlertConfigKey.STATUS_ENABLED], + FIELD(readOnly)[AlertConfigKey.TLS_ENABLED], ], advanced: [DEFAULT_DATA_OPTIONS(readOnly), ICMP_ADVANCED(readOnly).requestConfig], }, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts index 3f81386a8cbdd..e63d65b73fdfb 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts @@ -45,6 +45,7 @@ export type FormConfig = MonitorFields & { ['schedule.number']: string; ['source.inline']: string; [AlertConfigKey.STATUS_ENABLED]: boolean; + [AlertConfigKey.TLS_ENABLED]: boolean; [ConfigKey.LOCATIONS]: FormLocation[]; /* Dot notation keys must have a type configuration both for their flattened and nested @@ -127,6 +128,7 @@ export interface FieldMap { [ConfigKey.SCREENSHOTS]: FieldMeta; [ConfigKey.ENABLED]: FieldMeta; [AlertConfigKey.STATUS_ENABLED]: FieldMeta; + [AlertConfigKey.TLS_ENABLED]: FieldMeta; [ConfigKey.NAMESPACE]: FieldMeta; [ConfigKey.TIMEOUT]: FieldMeta; [ConfigKey.MAX_REDIRECTS]: FieldMeta; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/index.ts index 464ff13077872..0ae1a1da2729b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/index.ts @@ -7,6 +7,7 @@ import { CoreStart } from '@kbn/core/public'; import { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public'; +import { initTlsAlertType } from './tls'; import { ClientPluginsStart } from '../../../../plugin'; import { initMonitorStatusAlertType } from './monitor_status'; @@ -15,4 +16,7 @@ export type AlertTypeInitializer = plugins: ClientPluginsStart; }) => TAlertTypeModel; -export const syntheticsAlertTypeInitializers: AlertTypeInitializer[] = [initMonitorStatusAlertType]; +export const syntheticsAlertTypeInitializers: AlertTypeInitializer[] = [ + initMonitorStatusAlertType, + initTlsAlertType, +]; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/tls_alert.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/tls_alert.tsx new file mode 100644 index 0000000000000..1f04e9388c63f --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/tls_alert.tsx @@ -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 React from 'react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { CoreStart } from '@kbn/core/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { TLSRuleComponent } from '../../../components/alerts/tls_rule_ui'; +import { ClientPluginsStart } from '../../../../../plugin'; +import { TLSParams } from '../../../../../../common/runtime_types/alerts/tls'; +import { kibanaService } from '../../../../../utils/kibana_service'; +import { store } from '../../../state'; + +interface Props { + core: CoreStart; + plugins: ClientPluginsStart; + ruleParams: RuleTypeParamsExpressionProps['ruleParams']; + setRuleParams: RuleTypeParamsExpressionProps['setRuleParams']; +} + +// eslint-disable-next-line import/no-default-export +export default function TLSAlert({ core, plugins, ruleParams, setRuleParams }: Props) { + kibanaService.core = core; + return ( + + + + + + ); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/validate_tls_alert.ts b/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/validate_tls_alert.ts new file mode 100644 index 0000000000000..68f635b843693 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/lazy_wrapper/validate_tls_alert.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { isRight } from 'fp-ts/lib/Either'; +import { ValidationResult } from '@kbn/triggers-actions-ui-plugin/public'; +import { TLSParamsType } from '../../../../../../common/runtime_types/alerts/tls'; + +export function validateTLSAlertParams(ruleParams: any): ValidationResult { + const errors: Record = {}; + const decoded = TLSParamsType.decode(ruleParams); + + if (!isRight(decoded)) { + return { + errors: { + typeCheckFailure: 'Provided parameters do not conform to the expected type.', + typeCheckParsingMessage: PathReporter.report(decoded), + }, + }; + } + + return { errors }; +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/tls.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/tls.tsx new file mode 100644 index 0000000000000..c1ab8d88b4612 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/lib/alert_types/tls.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ALERT_REASON } from '@kbn/rule-data-utils'; +import { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public'; +import type { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { ValidationResult } from '@kbn/triggers-actions-ui-plugin/public'; +import { TlsTranslations } from '../../../../../common/rules/synthetics/translations'; +import { CERTIFICATES_ROUTE } from '../../../../../common/constants/ui'; +import { SYNTHETICS_ALERT_RULE_TYPES } from '../../../../../common/constants/synthetics_alerts'; +import type { TLSParams } from '../../../../../common/runtime_types/alerts/tls'; +import { AlertTypeInitializer } from '.'; + +let validateFunc: (ruleParams: any) => ValidationResult; + +const { defaultActionMessage, defaultRecoveryMessage, description } = TlsTranslations; +const TLSAlert = React.lazy(() => import('./lazy_wrapper/tls_alert')); +export const initTlsAlertType: AlertTypeInitializer = ({ + core, + plugins, +}): ObservabilityRuleTypeModel => ({ + id: SYNTHETICS_ALERT_RULE_TYPES.TLS, + iconClass: 'uptimeApp', + documentationUrl(docLinks) { + return `${docLinks.links.observability.tlsCertificate}`; + }, + ruleParamsExpression: (params: RuleTypeParamsExpressionProps) => ( + + ), + description, + validate: (ruleParams: any) => { + if (!validateFunc) { + (async function loadValidate() { + const { validateTLSAlertParams } = await import('./lazy_wrapper/validate_tls_alert'); + validateFunc = validateTLSAlertParams; + })(); + } + return validateFunc ? validateFunc(ruleParams) : ({} as ValidationResult); + }, + defaultActionMessage, + defaultRecoveryMessage, + requiresAppContext: false, + format: ({ fields }) => ({ + reason: fields[ALERT_REASON] || '', + link: `/app/synthetics${CERTIFICATES_ROUTE}`, + }), +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/alert_rules/actions.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/alert_rules/actions.ts index f25669f08285f..b168024f34c8c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/alert_rules/actions.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/alert_rules/actions.ts @@ -8,9 +8,10 @@ import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; import { createAsyncAction } from '../utils/actions'; -export const enableDefaultAlertingAction = createAsyncAction( - 'enableDefaultAlertingAction' -); +export const enableDefaultAlertingAction = createAsyncAction< + void, + { statusRule: Rule; tlsRule: Rule } +>('enableDefaultAlertingAction'); export const updateDefaultAlertingAction = createAsyncAction( 'updateDefaultAlertingAction' diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/alert_rules/api.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/alert_rules/api.ts index 62f1a975d7cf0..7eb9921bb9aeb 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/alert_rules/api.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/alert_rules/api.ts @@ -9,7 +9,7 @@ import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; import { SYNTHETICS_API_URLS } from '../../../../../common/constants'; import { apiService } from '../../../../utils/api_service'; -export async function enableDefaultAlertingAPI(): Promise { +export async function enableDefaultAlertingAPI(): Promise<{ statusRule: Rule; tlsRule: Rule }> { return apiService.post(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING); } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/actions.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/actions.ts index 92f261e29dde5..e3738f3737cf0 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/actions.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/actions.ts @@ -6,15 +6,19 @@ */ import { createAction } from '@reduxjs/toolkit'; +import { + SYNTHETICS_STATUS_RULE, + SYNTHETICS_TLS_RULE, +} from '../../../../../common/constants/synthetics_alerts'; export interface PopoverState { id: string; open: boolean; } -export const setAlertFlyoutVisible = createAction('[UI] TOGGLE ALERT FLYOUT'); - -export const setAlertFlyoutType = createAction('[UI] SET ALERT FLYOUT TYPE'); +export const setAlertFlyoutVisible = createAction< + typeof SYNTHETICS_STATUS_RULE | typeof SYNTHETICS_TLS_RULE | null +>('[UI] TOGGLE ALERT FLYOUT'); export const setBasePath = createAction('[UI] SET BASE PATH'); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/index.ts index 9a8a41308d4a9..6c6ef93bbf3a7 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/index.ts @@ -7,13 +7,16 @@ import { createReducer } from '@reduxjs/toolkit'; +import { + SYNTHETICS_STATUS_RULE, + SYNTHETICS_TLS_RULE, +} from '../../../../../common/constants/synthetics_alerts'; import { CLIENT_DEFAULTS_SYNTHETICS } from '../../../../../common/constants/synthetics/client_defaults'; import { PopoverState, toggleIntegrationsPopover, setBasePath, setEsKueryString, - setAlertFlyoutType, setAlertFlyoutVisible, setSearchTextAction, setSelectedMonitorId, @@ -23,8 +26,7 @@ import { const { AUTOREFRESH_INTERVAL_SECONDS, AUTOREFRESH_IS_PAUSED } = CLIENT_DEFAULTS_SYNTHETICS; export interface UiState { - alertFlyoutVisible: boolean; - alertFlyoutType?: string; + alertFlyoutVisible: typeof SYNTHETICS_TLS_RULE | typeof SYNTHETICS_STATUS_RULE | null; basePath: string; esKuery: string; searchText: string; @@ -35,7 +37,7 @@ export interface UiState { } const initialState: UiState = { - alertFlyoutVisible: false, + alertFlyoutVisible: null, basePath: '', esKuery: '', searchText: '', @@ -51,10 +53,7 @@ export const uiReducer = createReducer(initialState, (builder) => { state.integrationsPopoverOpen = action.payload; }) .addCase(setAlertFlyoutVisible, (state, action) => { - state.alertFlyoutVisible = action.payload ?? !state.alertFlyoutVisible; - }) - .addCase(setAlertFlyoutType, (state, action) => { - state.alertFlyoutType = action.payload; + state.alertFlyoutVisible = action.payload; }) .addCase(setBasePath, (state, action) => { state.basePath = action.payload; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/selectors.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/selectors.ts index 657980c3f6431..4e365d8343555 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/selectors.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ui/selectors.ts @@ -9,29 +9,12 @@ import { createSelector } from 'reselect'; import type { SyntheticsAppState } from '../root_reducer'; const uiStateSelector = (appState: SyntheticsAppState) => appState.ui; -export const selectBasePath = createSelector(uiStateSelector, ({ basePath }) => basePath); - -export const selectIsIntegrationsPopupOpen = createSelector( - uiStateSelector, - ({ integrationsPopoverOpen }) => integrationsPopoverOpen -); export const selectAlertFlyoutVisibility = createSelector( uiStateSelector, ({ alertFlyoutVisible }) => alertFlyoutVisible ); -export const selectAlertFlyoutType = createSelector( - uiStateSelector, - ({ alertFlyoutType }) => alertFlyoutType -); - -export const selectEsKuery = createSelector(uiStateSelector, ({ esKuery }) => esKuery); - -export const selectSearchText = createSelector(uiStateSelector, ({ searchText }) => searchText); - -export const selectMonitorId = createSelector(uiStateSelector, ({ monitorId }) => monitorId); - export const selectRefreshPaused = createSelector( uiStateSelector, ({ refreshPaused }) => refreshPaused diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index c97d99782ec02..a5c656d253fa3 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -23,7 +23,7 @@ import { */ export const mockState: SyntheticsAppState = { ui: { - alertFlyoutVisible: false, + alertFlyoutVisible: null, basePath: 'yyz', esKuery: '', integrationsPopoverOpen: null, diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/certificates/certificates_list.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/certificates/certificates_list.tsx index 381ee9cd5b792..0c083db058f97 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/certificates/certificates_list.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/certificates/certificates_list.tsx @@ -21,6 +21,10 @@ interface Page { } export type CertFields = + | 'monitorName' + | 'locationName' + | 'monitorType' + | 'monitorUrl' | 'sha256' | 'sha1' | 'issuer' diff --git a/x-pack/plugins/synthetics/server/alert_rules/tls_rule/message_utils.ts b/x-pack/plugins/synthetics/server/alert_rules/tls_rule/message_utils.ts new file mode 100644 index 0000000000000..6f87c5242ec79 --- /dev/null +++ b/x-pack/plugins/synthetics/server/alert_rules/tls_rule/message_utils.ts @@ -0,0 +1,70 @@ +/* + * 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/moment'; +import { tlsTranslations } from '../translations'; +import { Cert } from '../../../common/runtime_types'; +interface TLSContent { + summary: string; + status?: string; +} + +const getValidBefore = ({ not_before: date }: Cert): TLSContent => { + if (!date) return { summary: 'Error, missing `certificate_not_valid_before` date.' }; + const relativeDate = moment().diff(date, 'days'); + const formattedDate = moment(date).format('MMM D, YYYY z'); + return relativeDate >= 0 + ? { + summary: tlsTranslations.validBeforeExpiredString(formattedDate, relativeDate), + status: tlsTranslations.agingLabel, + } + : { + summary: tlsTranslations.validBeforeExpiringString(formattedDate, Math.abs(relativeDate)), + status: tlsTranslations.invalidLabel, + }; +}; +const getValidAfter = ({ not_after: date }: Cert): TLSContent => { + if (!date) return { summary: 'Error, missing `certificate_not_valid_after` date.' }; + const relativeDate = moment().diff(date, 'days'); + const formattedDate = moment(date).format('MMM D, YYYY z'); + return relativeDate >= 0 + ? { + summary: tlsTranslations.validAfterExpiredString(formattedDate, relativeDate), + status: tlsTranslations.expiredLabel, + } + : { + summary: tlsTranslations.validAfterExpiringString(formattedDate, Math.abs(relativeDate)), + status: tlsTranslations.expiringLabel, + }; +}; +const mapCertsToSummaryString = ( + cert: Cert, + certLimitMessage: (cert: Cert) => TLSContent +): TLSContent => certLimitMessage(cert); +export const getCertSummary = (cert: Cert, expirationThreshold: number, ageThreshold: number) => { + const isExpiring = new Date(cert.not_after ?? '').valueOf() < expirationThreshold; + const isAging = new Date(cert.not_before ?? '').valueOf() < ageThreshold; + let content: TLSContent | null = null; + + if (isExpiring) { + content = mapCertsToSummaryString(cert, getValidAfter); + } else if (isAging) { + content = mapCertsToSummaryString(cert, getValidBefore); + } + + const { summary = '', status = '' } = content || {}; + return { + summary, + status, + commonName: cert.common_name ?? '', + issuer: cert.issuer ?? '', + monitorName: cert.monitorName, + monitorType: cert.monitorType, + locationName: cert.locationName, + monitorUrl: cert.monitorUrl, + }; +}; diff --git a/x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule.ts b/x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule.ts new file mode 100644 index 0000000000000..fd31b5e8140ff --- /dev/null +++ b/x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule.ts @@ -0,0 +1,161 @@ +/* + * 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 { ActionGroupIdsOf } from '@kbn/alerting-plugin/common'; +import { createLifecycleRuleTypeFactory, IRuleDataClient } from '@kbn/rule-registry-plugin/server'; +import { asyncForEach } from '@kbn/std'; +import { ALERT_REASON, ALERT_UUID } from '@kbn/rule-data-utils'; +import { + alertsLocatorID, + AlertsLocatorParams, + getAlertUrl, +} from '@kbn/observability-plugin/common'; +import { LocatorPublic } from '@kbn/share-plugin/common'; +import { schema } from '@kbn/config-schema'; +import { TlsTranslations } from '../../../common/rules/synthetics/translations'; +import { + CERT_COMMON_NAME, + CERT_HASH_SHA256, + CERT_ISSUER_NAME, + CERT_VALID_NOT_AFTER, + CERT_VALID_NOT_BEFORE, +} from '../../../common/field_names'; +import { getCertSummary } from './message_utils'; +import { SyntheticsCommonState } from '../../../common/runtime_types/alert_rules/common'; +import { UptimeCorePluginsSetup, UptimeServerSetup } from '../../legacy_uptime/lib/adapters'; +import { TLSRuleExecutor } from './tls_rule_executor'; +import { + SYNTHETICS_ALERT_RULE_TYPES, + TLS_CERTIFICATE, +} from '../../../common/constants/synthetics_alerts'; +import { updateState } from '../common'; +import { getActionVariables } from '../action_variables'; +import { ALERT_DETAILS_URL } from '../../legacy_uptime/lib/alerts/action_variables'; +import { UMServerLibs } from '../../legacy_uptime/uptime_server'; +import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client'; +import { + generateAlertMessage, + setRecoveredAlertsContext, + UptimeRuleTypeAlertDefinition, +} from '../../legacy_uptime/lib/alerts/common'; + +export type ActionGroupIds = ActionGroupIdsOf; + +export const registerSyntheticsTLSCheckRule = ( + server: UptimeServerSetup, + libs: UMServerLibs, + plugins: UptimeCorePluginsSetup, + syntheticsMonitorClient: SyntheticsMonitorClient, + ruleDataClient: IRuleDataClient +) => { + const createLifecycleRuleType = createLifecycleRuleTypeFactory({ + ruleDataClient, + logger: server.logger, + }); + + return createLifecycleRuleType({ + id: SYNTHETICS_ALERT_RULE_TYPES.TLS, + producer: 'uptime', + name: TLS_CERTIFICATE.name, + validate: { + params: schema.object({ + search: schema.maybe(schema.string()), + certExpirationThreshold: schema.maybe(schema.number()), + certAgeThreshold: schema.maybe(schema.number()), + }), + }, + defaultActionGroupId: TLS_CERTIFICATE.id, + actionGroups: [TLS_CERTIFICATE], + actionVariables: getActionVariables({ plugins }), + isExportable: true, + minimumLicenseRequired: 'basic', + doesSetRecoveryContext: true, + async executor({ state, params, services, spaceId, previousStartedAt, startedAt }) { + const ruleState = state as SyntheticsCommonState; + + const { basePath, share } = server; + const alertsLocator: LocatorPublic | undefined = + share.url.locators.get(alertsLocatorID); + + const { + alertFactory, + getAlertUuid, + savedObjectsClient, + scopedClusterClient, + alertWithLifecycle, + getAlertStartedDate, + } = services; + + const tlsRule = new TLSRuleExecutor( + previousStartedAt, + params, + savedObjectsClient, + scopedClusterClient.asCurrentUser, + server, + syntheticsMonitorClient + ); + + const { foundCerts, certs, absoluteExpirationThreshold, absoluteAgeThreshold } = + await tlsRule.getExpiredCertificates(); + + if (foundCerts) { + await asyncForEach(certs, async (cert) => { + const summary = getCertSummary(cert, absoluteExpirationThreshold, absoluteAgeThreshold); + + if (!summary.summary || !summary.status) { + return; + } + + const alertId = `${cert.common_name}-${cert.issuer?.replace(/\s/g, '_')}-${cert.sha256}`; + const alertUuid = getAlertUuid(alertId); + const indexedStartedAt = getAlertStartedDate(alertId) ?? startedAt.toISOString(); + + const alertInstance = alertWithLifecycle({ + id: alertId, + fields: { + [CERT_COMMON_NAME]: cert.common_name, + [CERT_ISSUER_NAME]: cert.issuer, + [CERT_VALID_NOT_AFTER]: cert.not_after, + [CERT_VALID_NOT_BEFORE]: cert.not_before, + [CERT_HASH_SHA256]: cert.sha256, + [ALERT_UUID]: alertUuid, + [ALERT_REASON]: generateAlertMessage(TlsTranslations.defaultActionMessage, summary), + }, + }); + + alertInstance.replaceState({ + ...updateState(ruleState, foundCerts), + ...summary, + }); + + alertInstance.scheduleActions(TLS_CERTIFICATE.id, { + [ALERT_DETAILS_URL]: await getAlertUrl( + alertUuid, + spaceId, + indexedStartedAt, + alertsLocator, + basePath.publicBaseUrl + ), + ...summary, + }); + }); + } + + await setRecoveredAlertsContext({ + alertFactory, + basePath, + defaultStartedAt: startedAt.toISOString(), + getAlertStartedDate, + getAlertUuid, + spaceId, + alertsLocator, + }); + + return { state: updateState(ruleState, foundCerts) }; + }, + alerts: UptimeRuleTypeAlertDefinition, + }); +}; diff --git a/x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule_executor.test.ts b/x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule_executor.test.ts new file mode 100644 index 0000000000000..a1377e19dbd29 --- /dev/null +++ b/x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule_executor.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { loggerMock } from '@kbn/logging-mocks'; +import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; +import { TLSRuleExecutor } from './tls_rule_executor'; +import { UptimeServerSetup } from '../../legacy_uptime/lib/adapters'; +import { mockEncryptedSO } from '../../synthetics_service/utils/mocks'; +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client'; +import { SyntheticsService } from '../../synthetics_service/synthetics_service'; +import * as monitorUtils from '../../saved_objects/synthetics_monitor/get_all_monitors'; +import * as locationsUtils from '../../synthetics_service/get_all_locations'; +import type { PublicLocation } from '../../../common/runtime_types'; + +describe('tlsRuleExecutor', () => { + const mockEsClient = elasticsearchClientMock.createElasticsearchClient(); + const logger = loggerMock.create(); + const soClient = savedObjectsClientMock.create(); + jest.spyOn(locationsUtils, 'getAllLocations').mockResolvedValue({ + // @ts-ignore + publicLocations: [ + { + id: 'us_central_qa', + label: 'US Central QA', + }, + { + id: 'us_central_dev', + label: 'US Central DEV', + }, + ] as unknown as PublicLocation, + privateLocations: [], + }); + + const serverMock: UptimeServerSetup = { + logger, + uptimeEsClient: mockEsClient, + authSavedObjectsClient: soClient, + config: { + service: { + username: 'dev', + password: '12345', + manifestUrl: 'http://localhost:8080/api/manifest', + }, + }, + spaces: { + spacesService: { + getSpaceId: jest.fn().mockReturnValue('test-space'), + }, + }, + encryptedSavedObjects: mockEncryptedSO(), + } as unknown as UptimeServerSetup; + + const syntheticsService = new SyntheticsService(serverMock); + + const monitorClient = new SyntheticsMonitorClient(syntheticsService, serverMock); + + it('should only query enabled monitors', async () => { + const spy = jest.spyOn(monitorUtils, 'getAllMonitors').mockResolvedValue([]); + const tlsRule = new TLSRuleExecutor( + moment().toDate(), + {}, + soClient, + mockEsClient, + serverMock, + monitorClient + ); + + const { certs } = await tlsRule.getExpiredCertificates(); + + expect(certs).toEqual([]); + + expect(spy).toHaveBeenCalledWith({ + filter: 'synthetics-monitor.attributes.alert.tls.enabled: true', + soClient, + }); + }); +}); diff --git a/x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule_executor.ts b/x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule_executor.ts new file mode 100644 index 0000000000000..5949aed727740 --- /dev/null +++ b/x-pack/plugins/synthetics/server/alert_rules/tls_rule/tls_rule_executor.ts @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + SavedObjectsClientContract, + SavedObjectsFindResult, +} from '@kbn/core-saved-objects-api-server'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import moment from 'moment'; +import { getSyntheticsCerts } from '../../queries/get_certs'; +import { TLSParams } from '../../../common/runtime_types/alerts/tls'; +import { savedObjectsAdapter } from '../../legacy_uptime/lib/saved_objects'; +import { DYNAMIC_SETTINGS_DEFAULTS, SYNTHETICS_INDEX_PATTERN } from '../../../common/constants'; +import { + getAllMonitors, + processMonitors, +} from '../../saved_objects/synthetics_monitor/get_all_monitors'; +import { UptimeEsClient } from '../../legacy_uptime/lib/lib'; +import { CertResult, EncryptedSyntheticsMonitor } from '../../../common/runtime_types'; +import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client'; +import { UptimeServerSetup } from '../../legacy_uptime/lib/adapters'; +import { monitorAttributes } from '../../../common/types/saved_objects'; +import { AlertConfigKey } from '../../../common/constants/monitor_management'; +import { formatFilterString } from '../../legacy_uptime/lib/alerts/status_check'; + +export class TLSRuleExecutor { + previousStartedAt: Date | null; + params: TLSParams; + esClient: UptimeEsClient; + soClient: SavedObjectsClientContract; + server: UptimeServerSetup; + syntheticsMonitorClient: SyntheticsMonitorClient; + monitors: Array> = []; + + constructor( + previousStartedAt: Date | null, + p: TLSParams, + soClient: SavedObjectsClientContract, + scopedClient: ElasticsearchClient, + server: UptimeServerSetup, + syntheticsMonitorClient: SyntheticsMonitorClient + ) { + this.previousStartedAt = previousStartedAt; + this.params = p; + this.soClient = soClient; + this.esClient = new UptimeEsClient(this.soClient, scopedClient, { + heartbeatIndices: SYNTHETICS_INDEX_PATTERN, + }); + this.server = server; + this.syntheticsMonitorClient = syntheticsMonitorClient; + } + + async getMonitors() { + this.monitors = await getAllMonitors({ + soClient: this.soClient, + filter: `${monitorAttributes}.${AlertConfigKey.TLS_ENABLED}: true`, + }); + + const { + allIds, + enabledMonitorQueryIds, + listOfLocations, + monitorLocationMap, + projectMonitorsCount, + monitorQueryIdToConfigIdMap, + } = await processMonitors( + this.monitors, + this.server, + this.soClient, + this.syntheticsMonitorClient + ); + + return { + enabledMonitorQueryIds, + listOfLocations, + allIds, + monitorLocationMap, + projectMonitorsCount, + monitorQueryIdToConfigIdMap, + }; + } + + async getExpiredCertificates() { + const { enabledMonitorQueryIds } = await this.getMonitors(); + + const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(this.soClient); + + const expiryThreshold = + this.params.certExpirationThreshold ?? + dynamicSettings?.certExpirationThreshold ?? + DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold; + + const ageThreshold = + this.params.certAgeThreshold ?? + dynamicSettings?.certAgeThreshold ?? + DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold; + + const absoluteExpirationThreshold = moment().add(expiryThreshold, 'd').valueOf(); + const absoluteAgeThreshold = moment().subtract(ageThreshold, 'd').valueOf(); + + if (enabledMonitorQueryIds.length === 0) { + return { + certs: [], + total: 0, + foundCerts: false, + expiryThreshold, + ageThreshold, + absoluteExpirationThreshold, + absoluteAgeThreshold, + }; + } + + let filters: QueryDslQueryContainer | undefined; + + if (this.params.search) { + filters = await formatFilterString(this.esClient, undefined, this.params.search); + } + + const { certs, total }: CertResult = await getSyntheticsCerts({ + uptimeEsClient: this.esClient, + pageIndex: 0, + size: 1000, + notValidAfter: `now+${expiryThreshold}d`, + notValidBefore: `now-${ageThreshold}d`, + sortBy: 'common_name', + direction: 'desc', + filters, + monitorIds: enabledMonitorQueryIds, + }); + + const foundCerts = total > 0; + + return { + foundCerts, + certs, + total, + expiryThreshold, + ageThreshold, + absoluteExpirationThreshold, + absoluteAgeThreshold, + }; + } +} diff --git a/x-pack/plugins/synthetics/server/alert_rules/tls_rule/types.ts b/x-pack/plugins/synthetics/server/alert_rules/tls_rule/types.ts new file mode 100644 index 0000000000000..9c8cc0b9a6e36 --- /dev/null +++ b/x-pack/plugins/synthetics/server/alert_rules/tls_rule/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface MonitorSummaryStatusRule { + reason: string; + status: string; + configId: string; + hostName: string; + monitorId: string; + checkedAt: string; + monitorUrl: string; + locationId: string; + monitorType: string; + monitorName: string; + locationName: string; + lastErrorMessage: string; + stateId: string | null; + monitorUrlLabel: string; +} diff --git a/x-pack/plugins/synthetics/server/alert_rules/translations.ts b/x-pack/plugins/synthetics/server/alert_rules/translations.ts index 99bc5143e66a6..f501a8bf1c5c7 100644 --- a/x-pack/plugins/synthetics/server/alert_rules/translations.ts +++ b/x-pack/plugins/synthetics/server/alert_rules/translations.ts @@ -164,3 +164,91 @@ export const commonStateTranslations = [ ), }, ]; + +export const tlsTranslations = { + actionVariables: [ + { + name: 'count', + description: i18n.translate('xpack.synthetics.rules.tls.actionVariables.state.count', { + defaultMessage: 'The number of certs detected by the alert executor', + }), + }, + { + name: 'expiringCount', + description: i18n.translate( + 'xpack.synthetics.rules.tls.actionVariables.state.expiringCount', + { + defaultMessage: 'The number of expiring certs detected by the alert.', + } + ), + }, + { + name: 'expiringCommonNameAndDate', + description: i18n.translate( + 'xpack.synthetics.rules.tls.actionVariables.state.expiringCommonNameAndDate', + { + defaultMessage: 'The common names and expiration date/time of the detected certs', + } + ), + }, + { + name: 'agingCount', + description: i18n.translate('xpack.synthetics.rules.tls.actionVariables.state.agingCount', { + defaultMessage: 'The number of detected certs that are becoming too old.', + }), + }, + { + name: 'agingCommonNameAndDate', + description: i18n.translate( + 'xpack.synthetics.rules.tls.actionVariables.state.agingCommonNameAndDate', + { + defaultMessage: 'The common names and expiration date/time of the detected certs.', + } + ), + }, + ], + validAfterExpiredString: (date: string, relativeDate: number) => + i18n.translate('xpack.synthetics.rules.tls.validAfterExpiredString', { + defaultMessage: `Expired on {date}, {relativeDate} days ago.`, + values: { + date, + relativeDate, + }, + }), + validAfterExpiringString: (date: string, relativeDate: number) => + i18n.translate('xpack.synthetics.rules.tls.validAfterExpiringString', { + defaultMessage: `Expires on {date} in {relativeDate} days.`, + values: { + date, + relativeDate, + }, + }), + validBeforeExpiredString: (date: string, relativeDate: number) => + i18n.translate('xpack.synthetics.rules.tls.validBeforeExpiredString', { + defaultMessage: 'valid since {date}, {relativeDate} days ago.', + values: { + date, + relativeDate, + }, + }), + validBeforeExpiringString: (date: string, relativeDate: number) => + i18n.translate('xpack.synthetics.rules.tls.validBeforeExpiringString', { + defaultMessage: 'invalid until {date}, {relativeDate} days from now.', + values: { + date, + relativeDate, + }, + }), + expiredLabel: i18n.translate('xpack.synthetics.rules.tls.expiredLabel', { + defaultMessage: 'expired', + }), + expiringLabel: i18n.translate('xpack.synthetics.rules.tls.expiringLabel', { + defaultMessage: 'expiring', + }), + agingLabel: i18n.translate('xpack.synthetics.rules.tls.agingLabel', { + defaultMessage: 'becoming too old', + }), + invalidLabel: i18n.translate('xpack.synthetics.rules.tls.invalidLabel', { + defaultMessage: 'invalid', + }), +}; diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/requests/get_certs.test.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/requests/get_certs.test.ts index e6874dbfd79a8..f9523c09e43a1 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/requests/get_certs.test.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/requests/get_certs.test.ts @@ -112,6 +112,10 @@ describe('getCerts', () => { Object { "common_name": "r2.shared.global.fastly.net", "issuer": "GlobalSign CloudSSL CA - SHA256 - G3", + "locationName": undefined, + "monitorName": "Real World Test", + "monitorType": undefined, + "monitorUrl": "https://fullurl.com", "monitors": Array [ Object { "configId": undefined, @@ -137,6 +141,9 @@ describe('getCerts', () => { "_source": Array [ "monitor.id", "monitor.name", + "monitor.type", + "url.full", + "observer.geo.name", "tls.server.x509.issuer.common_name", "tls.server.x509.subject.common_name", "tls.server.hash.sha1", diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.9.0.test.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.9.0.test.ts new file mode 100644 index 0000000000000..3c23c008824a4 --- /dev/null +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.9.0.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { migrationMocks } from '@kbn/core/server/mocks'; +import { ConfigKey } from '../../../../../../common/runtime_types'; +import { browserUI } from './test_fixtures/8.7.0'; +import { httpUI as httpUI850 } from './test_fixtures/8.5.0'; +import { migration890 } from './8.9.0'; + +const context = migrationMocks.createContext(); +const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); + +describe('Monitor migrations v8.8.0 -> v8.9.0', () => { + beforeEach(() => { + jest.resetAllMocks(); + encryptedSavedObjectsSetup.createMigration.mockImplementation(({ migration }) => migration); + }); + + describe('alerting config', () => { + it('sets alerting config when it is not defined', () => { + expect(httpUI850.attributes[ConfigKey.ALERT_CONFIG]).toBeUndefined(); + const actual = migration890(encryptedSavedObjectsSetup)(httpUI850, context); + expect(actual.attributes[ConfigKey.ALERT_CONFIG]).toEqual({ + status: { + enabled: true, + }, + tls: { + enabled: true, + }, + }); + }); + + it('uses existing alerting config when it is defined', () => { + const testMonitor = { + ...browserUI, + attributes: { + ...browserUI.attributes, + [ConfigKey.ALERT_CONFIG]: { + status: { + enabled: false, + }, + tls: { + enabled: true, + }, + }, + }, + }; + expect(testMonitor.attributes[ConfigKey.ALERT_CONFIG]).toBeTruthy(); + const actual = migration890(encryptedSavedObjectsSetup)(testMonitor, context); + expect(actual.attributes[ConfigKey.ALERT_CONFIG]).toEqual({ + status: { + enabled: false, + }, + tls: { + enabled: true, + }, + }); + }); + + it('uses existing alerting config when it already exists', () => { + const testMonitor = { + ...browserUI, + attributes: { + ...browserUI.attributes, + [ConfigKey.ALERT_CONFIG]: { + status: { + enabled: false, + }, + }, + }, + }; + expect(testMonitor.attributes[ConfigKey.ALERT_CONFIG]).toBeTruthy(); + const actual = migration890(encryptedSavedObjectsSetup)(testMonitor, context); + expect(actual.attributes[ConfigKey.ALERT_CONFIG]).toEqual({ + status: { + enabled: false, + }, + tls: { + enabled: true, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.9.0.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.9.0.ts new file mode 100644 index 0000000000000..d18c6473cc729 --- /dev/null +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.9.0.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; +import { SavedObjectUnsanitizedDoc } from '@kbn/core/server'; +import { ConfigKey, SyntheticsMonitorWithSecrets } from '../../../../../../common/runtime_types'; +import { SYNTHETICS_MONITOR_ENCRYPTED_TYPE } from '../../synthetics_monitor'; + +export const migration890 = (encryptedSavedObjects: EncryptedSavedObjectsPluginSetup) => { + return encryptedSavedObjects.createMigration< + SyntheticsMonitorWithSecrets, + SyntheticsMonitorWithSecrets + >({ + isMigrationNeededPredicate: function shouldBeMigrated( + doc + ): doc is SavedObjectUnsanitizedDoc { + return true; + }, + migration: ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectUnsanitizedDoc => { + let migrated = doc; + migrated = { + ...migrated, + attributes: { + ...migrated.attributes, + [ConfigKey.ALERT_CONFIG]: { + status: { + enabled: true, + }, + tls: { + enabled: true, + }, + ...(migrated.attributes[ConfigKey.ALERT_CONFIG] ?? {}), + }, + // when any action to change a project monitor configuration is taken + // outside the synthetics agent cli, we should set the config hash back + // to an empty string so that the project monitors configuration + // will be updated on next push + [ConfigKey.CONFIG_HASH]: '', + }, + }; + + return migrated; + }, + inputType: SYNTHETICS_MONITOR_ENCRYPTED_TYPE, + migratedType: SYNTHETICS_MONITOR_ENCRYPTED_TYPE, + }); +}; diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/index.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/index.ts index b200e7b09b389..6e20a0231e0bc 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/index.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/index.ts @@ -5,10 +5,12 @@ * 2.0. */ +import { migration890 } from './8.9.0'; import { migration860 } from './8.6.0'; import { migration880 } from './8.8.0'; export const monitorMigrations = { '8.6.0': migration860, '8.8.0': migration880, + '8.9.0': migration890, }; diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/synthetics_monitor.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/synthetics_monitor.ts index cec7d8600a380..8b57943b3b7ce 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/synthetics_monitor.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/synthetics_monitor.ts @@ -60,6 +60,7 @@ export const getSyntheticsMonitorSavedObjectType = ( migrations: { '8.6.0': monitorMigrations['8.6.0'](encryptedSavedObjects), '8.8.0': monitorMigrations['8.8.0'](encryptedSavedObjects), + '8.9.0': monitorMigrations['8.9.0'](encryptedSavedObjects), }, mappings: { dynamic: false, @@ -167,6 +168,13 @@ export const getSyntheticsMonitorSavedObjectType = ( }, }, }, + tls: { + properties: { + enabled: { + type: 'boolean', + }, + }, + }, }, }, throttling: { diff --git a/x-pack/plugins/synthetics/server/queries/get_certs.ts b/x-pack/plugins/synthetics/server/queries/get_certs.ts new file mode 100644 index 0000000000000..0f3ea6d2a4294 --- /dev/null +++ b/x-pack/plugins/synthetics/server/queries/get_certs.ts @@ -0,0 +1,37 @@ +/* + * 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 { CertResult, GetCertsParams, Ping } from '../../common/runtime_types'; +import { + getCertsRequestBody, + processCertsResult, +} from '../../common/requests/get_certs_request_body'; +import { UptimeEsClient } from '../legacy_uptime/lib/lib'; + +export const getSyntheticsCerts = async ( + requestParams: GetCertsParams & { uptimeEsClient: UptimeEsClient } +): Promise => { + const result = await getCertsResults(requestParams); + + return processCertsResult(result); +}; + +const getCertsResults = async ( + requestParams: GetCertsParams & { uptimeEsClient: UptimeEsClient } +) => { + const { uptimeEsClient } = requestParams; + + const searchBody = getCertsRequestBody(requestParams); + + const request = { body: searchBody }; + + const { body: result } = await uptimeEsClient.search({ + body: searchBody, + }); + + return result; +}; diff --git a/x-pack/plugins/synthetics/server/routes/default_alerts/enable_default_alert.ts b/x-pack/plugins/synthetics/server/routes/default_alerts/enable_default_alert.ts index eb14a6dea8a65..f483ffa186b38 100644 --- a/x-pack/plugins/synthetics/server/routes/default_alerts/enable_default_alert.ts +++ b/x-pack/plugins/synthetics/server/routes/default_alerts/enable_default_alert.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { TLSAlertService } from './tls_alert_service'; import { StatusAlertService } from './status_alert_service'; import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; @@ -16,7 +17,23 @@ export const enableDefaultAlertingRoute: SyntheticsRestApiRouteFactory = () => ( writeAccess: true, handler: async ({ context, server, savedObjectsClient }): Promise => { const statusAlertService = new StatusAlertService(context, server, savedObjectsClient); + const tlsAlertService = new TLSAlertService(context, server, savedObjectsClient); - return await statusAlertService.createDefaultAlertIfNotExist(); + const [statusRule, tlsRule] = await Promise.allSettled([ + statusAlertService.createDefaultAlertIfNotExist(), + tlsAlertService.createDefaultAlertIfNotExist(), + ]); + + if (statusRule.status === 'rejected') { + throw statusRule.reason; + } + if (tlsRule.status === 'rejected') { + throw tlsRule.reason; + } + + return { + statusRule: statusRule.status === 'fulfilled' ? statusRule.value : null, + tlsRule: tlsRule.status === 'fulfilled' ? tlsRule.value : null, + }; }, }); diff --git a/x-pack/plugins/synthetics/server/routes/default_alerts/status_alert_service.ts b/x-pack/plugins/synthetics/server/routes/default_alerts/status_alert_service.ts index e175325d51f4c..c6da83c73681f 100644 --- a/x-pack/plugins/synthetics/server/routes/default_alerts/status_alert_service.ts +++ b/x-pack/plugins/synthetics/server/routes/default_alerts/status_alert_service.ts @@ -66,7 +66,6 @@ export class StatusAlertService { consumer: 'uptime', alertTypeId: SYNTHETICS_ALERT_RULE_TYPES.MONITOR_STATUS, schedule: { interval: '1m' }, - notifyWhen: 'onActionGroupChange', tags: ['SYNTHETICS_DEFAULT_ALERT'], name: `Synthetics internal alert`, enabled: true, diff --git a/x-pack/plugins/synthetics/server/routes/default_alerts/tls_alert_service.ts b/x-pack/plugins/synthetics/server/routes/default_alerts/tls_alert_service.ts new file mode 100644 index 0000000000000..62c400281f28b --- /dev/null +++ b/x-pack/plugins/synthetics/server/routes/default_alerts/tls_alert_service.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { FindActionResult } from '@kbn/actions-plugin/server'; +import { TLSParams } from '../../../common/runtime_types/alerts/tls'; +import { savedObjectsAdapter } from '../../legacy_uptime/lib/saved_objects'; +import { UptimeServerSetup } from '../../legacy_uptime/lib/adapters'; +import { populateAlertActions } from '../../../common/rules/alert_actions'; +import { TlsTranslations } from '../../../common/rules/synthetics/translations'; +import { UptimeRequestHandlerContext } from '../../types'; +import { + ACTION_GROUP_DEFINITIONS, + SYNTHETICS_ALERT_RULE_TYPES, +} from '../../../common/constants/synthetics_alerts'; + +// uuid based on the string 'uptime-tls-default-alert' +const TLS_DEFAULT_ALERT_ID = '7a532181-ff1d-4317-9367-7ca789133920'; + +export class TLSAlertService { + context: UptimeRequestHandlerContext; + soClient: SavedObjectsClientContract; + server: UptimeServerSetup; + + constructor( + context: UptimeRequestHandlerContext, + server: UptimeServerSetup, + soClient: SavedObjectsClientContract + ) { + this.context = context; + this.server = server; + this.soClient = soClient; + } + + async getExistingAlert() { + const rulesClient = (await this.context.alerting)?.getRulesClient(); + try { + const alert = await rulesClient.get({ id: TLS_DEFAULT_ALERT_ID }); + return { ...alert, ruleTypeId: alert.alertTypeId }; + } catch (e) { + return null; + } + } + async createDefaultAlertIfNotExist() { + const alert = await this.getExistingAlert(); + if (alert) { + return alert; + } + + const actions = await this.getAlertActions(); + + const rulesClient = (await this.context.alerting)?.getRulesClient(); + const newAlert = await rulesClient.create({ + data: { + actions, + params: {}, + consumer: 'uptime', + alertTypeId: SYNTHETICS_ALERT_RULE_TYPES.TLS, + schedule: { interval: '1m' }, + tags: ['SYNTHETICS_TLS_DEFAULT_ALERT'], + name: `Synthetics internal TLS alert`, + enabled: true, + throttle: null, + }, + options: { + id: TLS_DEFAULT_ALERT_ID, + }, + }); + return { ...newAlert, ruleTypeId: newAlert.alertTypeId }; + } + + async updateDefaultAlert() { + const rulesClient = (await this.context.alerting)?.getRulesClient(); + + const alert = await this.getExistingAlert(); + if (alert) { + const actions = await this.getAlertActions(); + const updatedAlert = await rulesClient.update({ + id: alert.id, + data: { + actions, + name: alert.name, + tags: alert.tags, + schedule: alert.schedule, + params: alert.params, + notifyWhen: alert.notifyWhen, + }, + }); + return { ...updatedAlert, ruleTypeId: updatedAlert.alertTypeId }; + } + + return await this.createDefaultAlertIfNotExist(); + } + + async getAlertActions() { + const { actionConnectors, settings } = await this.getActionConnectors(); + + const defaultActions = (actionConnectors ?? []).filter((act) => + settings?.defaultConnectors?.includes(act.id) + ); + + return populateAlertActions({ + groupId: ACTION_GROUP_DEFINITIONS.TLS_CERTIFICATE.id, + defaultActions, + defaultEmail: settings?.defaultEmail!, + translations: { + defaultActionMessage: TlsTranslations.defaultActionMessage, + defaultRecoveryMessage: TlsTranslations.defaultRecoveryMessage, + defaultSubjectMessage: TlsTranslations.defaultSubjectMessage, + defaultRecoverySubjectMessage: TlsTranslations.defaultRecoverySubjectMessage, + }, + }); + } + + async getActionConnectors() { + const actionsClient = (await this.context.actions)?.getActionsClient(); + + const settings = await savedObjectsAdapter.getUptimeDynamicSettings(this.soClient); + let actionConnectors: FindActionResult[] = []; + try { + actionConnectors = await actionsClient.getAll(); + } catch (e) { + this.server.logger.error(e); + } + return { actionConnectors, settings }; + } +} diff --git a/x-pack/plugins/synthetics/server/routes/default_alerts/update_default_alert.ts b/x-pack/plugins/synthetics/server/routes/default_alerts/update_default_alert.ts index 8945b4b4455db..4435a4e962daf 100644 --- a/x-pack/plugins/synthetics/server/routes/default_alerts/update_default_alert.ts +++ b/x-pack/plugins/synthetics/server/routes/default_alerts/update_default_alert.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { TLSAlertService } from './tls_alert_service'; import { StatusAlertService } from './status_alert_service'; import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; @@ -16,7 +17,11 @@ export const updateDefaultAlertingRoute: SyntheticsRestApiRouteFactory = () => ( writeAccess: true, handler: async ({ context, server, savedObjectsClient }): Promise => { const statusAlertService = new StatusAlertService(context, server, savedObjectsClient); + const tlsAlertService = new TLSAlertService(context, server, savedObjectsClient); - return await statusAlertService.updateDefaultAlert(); + return Promise.allSettled([ + statusAlertService.updateDefaultAlert(), + tlsAlertService.updateDefaultAlert(), + ]); }, }); diff --git a/x-pack/plugins/synthetics/server/server.ts b/x-pack/plugins/synthetics/server/server.ts index 49652f1ccc27a..e7a3031056f49 100644 --- a/x-pack/plugins/synthetics/server/server.ts +++ b/x-pack/plugins/synthetics/server/server.ts @@ -6,6 +6,7 @@ */ import { Subject } from 'rxjs'; import { IRuleDataClient } from '@kbn/rule-registry-plugin/server'; +import { registerSyntheticsTLSCheckRule } from './alert_rules/tls_rule/tls_rule'; import { registerSyntheticsStatusCheckRule } from './alert_rules/status_rule/monitor_status_rule'; import { UptimeRequestHandlerContext } from './types'; import { createSyntheticsRouteWithAuth } from './routes/create_route_with_auth'; @@ -73,6 +74,16 @@ export const initSyntheticsServer = ( registerType(statusAlert); + const tlsRule = registerSyntheticsTLSCheckRule( + server, + libs, + plugins, + syntheticsMonitorClient, + ruleDataClient + ); + + registerType(tlsRule); + syntheticsAppStreamingApiRoutes.forEach((route) => { const { method, streamHandler, path, options } = syntheticsRouteWrapper( createSyntheticsRouteWithAuth(libs, route), diff --git a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/common_fields.test.ts b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/common_fields.test.ts index 60f2089243043..f86c3097e809c 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/common_fields.test.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/common_fields.test.ts @@ -108,6 +108,9 @@ describe('getNormalizeCommonFields', () => { status: { enabled: statusEnabled ?? true, }, + tls: { + enabled: true, + }, }, custom_heartbeat_id: 'test-id-test-projectId-test-namespace', enabled: true, @@ -169,6 +172,9 @@ describe('getNormalizeCommonFields', () => { status: { enabled: true, }, + tls: { + enabled: true, + }, }, custom_heartbeat_id: 'test-id-test-projectId-test-namespace', enabled: true, diff --git a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/common_fields.ts b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/common_fields.ts index 4f44dcb3283be..bbe233a66e763 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/common_fields.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/common_fields.ts @@ -88,22 +88,35 @@ export const getNormalizeCommonFields = ({ ? JSON.stringify(monitor.params) : defaultFields[ConfigKey.PARAMS], // picking out keys specifically, so users can't add arbitrary fields - [ConfigKey.ALERT_CONFIG]: monitor.alert - ? { - ...defaultFields[ConfigKey.ALERT_CONFIG], - status: { - ...defaultFields[ConfigKey.ALERT_CONFIG]?.status, - enabled: - monitor.alert?.status?.enabled ?? - defaultFields[ConfigKey.ALERT_CONFIG]?.status?.enabled ?? - true, - }, - } - : defaultFields[ConfigKey.ALERT_CONFIG], + [ConfigKey.ALERT_CONFIG]: getAlertConfig(monitor), }; return { normalizedFields, errors }; }; +const getAlertConfig = (monitor: ProjectMonitor) => { + const defaultFields = DEFAULT_COMMON_FIELDS; + + return monitor.alert + ? { + ...defaultFields[ConfigKey.ALERT_CONFIG], + status: { + ...defaultFields[ConfigKey.ALERT_CONFIG]?.status, + enabled: + monitor.alert?.status?.enabled ?? + defaultFields[ConfigKey.ALERT_CONFIG]?.status?.enabled ?? + true, + }, + tls: { + ...defaultFields[ConfigKey.ALERT_CONFIG]?.tls, + enabled: + monitor.alert?.tls?.enabled ?? + defaultFields[ConfigKey.ALERT_CONFIG]?.tls?.enabled ?? + true, + }, + } + : defaultFields[ConfigKey.ALERT_CONFIG]; +}; + export const getCustomHeartbeatId = ( monitor: NormalizedProjectProps['monitor'], projectId: string, diff --git a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts index 0045cc711f9bc..892fbd53617a1 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts @@ -46,7 +46,7 @@ export const getNormalizeHTTPFields = ({ version, }); - // Add common erros to errors arary + // Add common errors to errors array errors.push(...commonErrors); /* Check if monitor has multiple urls */ diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/check_registered_rule_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/check_registered_rule_types.ts index 101cf69fc6034..cb74808e3d63b 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/check_registered_rule_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/check_registered_rule_types.ts @@ -31,6 +31,7 @@ export default function createRegisteredRuleTypeTests({ getService }: FtrProvide 'xpack.ml.anomaly_detection_alert', 'xpack.ml.anomaly_detection_jobs_health', 'xpack.synthetics.alerts.monitorStatus', + 'xpack.synthetics.alerts.tls', 'xpack.uptime.alerts.monitorStatus', 'xpack.uptime.alerts.tlsCertificate', 'xpack.uptime.alerts.durationAnomaly', diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts index bf0b46022f83e..439bd1b986a11 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts @@ -176,6 +176,9 @@ export default function ({ getService }: FtrProviderContext) { status: { enabled: true, }, + tls: { + enabled: true, + }, }, 'filter_journeys.match': 'check if title is present', 'filter_journeys.tags': [], @@ -358,6 +361,9 @@ export default function ({ getService }: FtrProviderContext) { status: { enabled: true, }, + tls: { + enabled: true, + }, }, form_monitor_type: 'http', journey_id: journeyId, @@ -474,6 +480,9 @@ export default function ({ getService }: FtrProviderContext) { status: { enabled: true, }, + tls: { + enabled: true, + }, }, form_monitor_type: 'tcp', journey_id: journeyId, @@ -580,6 +589,9 @@ export default function ({ getService }: FtrProviderContext) { status: { enabled: true, }, + tls: { + enabled: true, + }, }, form_monitor_type: 'icmp', journey_id: journeyId, @@ -1920,7 +1932,13 @@ export default function ({ getService }: FtrProviderContext) { it('project monitors - handles alert config without adding arbitrary fields', async () => { const project = `test-project-${uuidv4()}`; const testAlert = { - status: { enabled: false, doesnotexit: true }, + status: { + enabled: false, + doesnotexit: true, + tls: { + enabled: true, + }, + }, }; try { await supertest @@ -1953,6 +1971,9 @@ export default function ({ getService }: FtrProviderContext) { status: { enabled: testAlert.status.enabled, }, + tls: { + enabled: true, + }, }); } finally { await deleteMonitor(httpProjectMonitors.monitors[1].id, project); diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 48d15ce6d8c8c..fb2fb52dbbd52 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -105,6 +105,7 @@ export default function ({ getService }: FtrProviderContext) { 'alerting:xpack.ml.anomaly_detection_alert', 'alerting:xpack.ml.anomaly_detection_jobs_health', 'alerting:xpack.synthetics.alerts.monitorStatus', + 'alerting:xpack.synthetics.alerts.tls', 'alerting:xpack.uptime.alerts.durationAnomaly', 'alerting:xpack.uptime.alerts.monitorStatus', 'alerting:xpack.uptime.alerts.tls', From 06a00d6b596d839ef084a5489237c6927279b02b Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Tue, 20 Jun 2023 13:23:47 +0200 Subject: [PATCH 04/14] [Logs onboarding] design feedback (#159790) closes https://github.com/elastic/kibana/issues/159655. - [x] Make sure inputs in configure logs step occupy the whole width - [x] Fix copy button in apiKey callout, currently is cropped - [x] Add space bellow action buttons (back and continue) Apart from the tasks above, we are hiding `Give feedback` button from plugin root page, since the form is dedicated to logs onboarding. https://github.com/elastic/kibana/assets/1313018/80872e8a-f239-4584-9b15-7ec8cdd32d5d --- .../app/custom_logs/wizard/api_key_banner.tsx | 6 ++- .../app/custom_logs/wizard/configure_logs.tsx | 2 +- .../app/header_action_menu/index.tsx | 41 +++++++++++-------- .../public/components/shared/step_panel.tsx | 5 ++- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/api_key_banner.tsx b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/api_key_banner.tsx index f7e73161fa8b3..2254105f979ff 100644 --- a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/api_key_banner.tsx +++ b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/api_key_banner.tsx @@ -94,7 +94,11 @@ export function ApiKeyBanner({ iconType="copyClipboard" onClick={copy} color="success" - style={{ backgroundColor: 'transparent' }} + css={{ + '> svg.euiIcon': { + borderRadius: '0 !important', + }, + }} aria-label={i18n.translate( 'xpack.observability_onboarding.apiKeyBanner.field.copyButton', { diff --git a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx index e86710f71c5c8..317b615c7226d 100644 --- a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx +++ b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/configure_logs.tsx @@ -258,7 +258,7 @@ export function ConfigureLogs() { direction="column" gutterSize="xs" > - + - {i18n.translate('xpack.observability_onboarding.header.feedback', { - defaultMessage: 'Give feedback', - })} - - ); + const location = useLocation(); + const normalizedPathname = location.pathname.replace(/\/$/, ''); + + const isRootPage = normalizedPathname === ''; + + if (!isRootPage) { + return ( + + {i18n.translate('xpack.observability_onboarding.header.feedback', { + defaultMessage: 'Give feedback', + })} + + ); + } + + return <>; } diff --git a/x-pack/plugins/observability_onboarding/public/components/shared/step_panel.tsx b/x-pack/plugins/observability_onboarding/public/components/shared/step_panel.tsx index b38eaedb0df8f..f5b53cc6b93eb 100644 --- a/x-pack/plugins/observability_onboarding/public/components/shared/step_panel.tsx +++ b/x-pack/plugins/observability_onboarding/public/components/shared/step_panel.tsx @@ -13,6 +13,7 @@ import { EuiPanelProps, EuiSpacer, EuiTitle, + useEuiTheme, } from '@elastic/eui'; interface StepPanelProps { @@ -57,8 +58,10 @@ interface StepPanelFooterProps { } export function StepPanelFooter(props: StepPanelFooterProps) { const { items = [], children } = props; + const { euiTheme } = useEuiTheme(); + return ( - + {children} {items && ( From 27df64c2bc266f2c861719dcc2891de03a12dc3d Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 20 Jun 2023 07:31:25 -0400 Subject: [PATCH 05/14] [EBT] Add page title to browser-side context (#159936) ## Summary Part of https://github.com/elastic/kibana/issues/149249 Add a new EBT context providing the page_title field to events. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/chrome_service.test.mocks.ts | 14 ++ .../src/chrome_service.test.tsx | 22 ++- .../src/chrome_service.tsx | 13 +- .../src/doc_title/doc_title_service.test.ts | 135 ++++++++++++++---- .../src/doc_title/doc_title_service.ts | 29 +++- ...egister_analytics_context_provider.test.ts | 39 +++++ .../register_analytics_context_provider.ts | 33 +++++ .../tsconfig.json | 2 + .../src/chrome_service.mock.ts | 1 + .../src/core_system.test.ts | 5 + .../src/core_system.ts | 1 + .../core_context_providers.ts | 5 + 12 files changed, 265 insertions(+), 34 deletions(-) create mode 100644 packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.mocks.ts create mode 100644 packages/core/chrome/core-chrome-browser-internal/src/register_analytics_context_provider.test.ts create mode 100644 packages/core/chrome/core-chrome-browser-internal/src/register_analytics_context_provider.ts diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.mocks.ts b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.mocks.ts new file mode 100644 index 0000000000000..6ac3e812413c4 --- /dev/null +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.mocks.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 registerAnalyticsContextProviderMock = jest.fn(); +jest.doMock('./register_analytics_context_provider', () => { + return { + registerAnalyticsContextProvider: registerAnalyticsContextProviderMock, + }; +}); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx index 0e18b5b72c367..f680a1b5d6ba9 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { registerAnalyticsContextProviderMock } from './chrome_service.test.mocks'; import { shallow, mount } from 'enzyme'; import React from 'react'; import * as Rx from 'rxjs'; @@ -18,6 +19,7 @@ import { applicationServiceMock } from '@kbn/core-application-browser-mocks'; import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks'; +import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; import { getAppInfo } from '@kbn/core-application-browser-internal'; import { findTestSubject } from '@kbn/test-jest-helpers'; import { ChromeService } from './chrome_service'; @@ -84,15 +86,19 @@ async function start({ startDeps.injectedMetadata.getCspConfig.mockReturnValue(cspConfigMock); } + await service.setup({ analytics: analyticsServiceMock.createAnalyticsServiceSetup() }); + const chromeStart = await service.start(startDeps); + return { service, startDeps, - chrome: await service.start(startDeps), + chrome: chromeStart, }; } beforeEach(() => { store.clear(); + registerAnalyticsContextProviderMock.mockReset(); window.history.pushState(undefined, '', '#/home?a=b'); }); @@ -100,6 +106,20 @@ afterAll(() => { (window as any).localStorage = originalLocalStorage; }); +describe('setup', () => { + it('calls registerAnalyticsContextProvider with the correct parameters', async () => { + const service = new ChromeService(defaultStartTestOptions({})); + const analytics = analyticsServiceMock.createAnalyticsServiceSetup(); + await service.setup({ analytics }); + + expect(registerAnalyticsContextProviderMock).toHaveBeenCalledTimes(1); + expect(registerAnalyticsContextProviderMock).toHaveBeenCalledWith( + analytics, + expect.any(Object) + ); + }); +}); + describe('start', () => { it('adds legacy browser warning if browserSupportsCsp is disabled and warnLegacyBrowsers is enabled', async () => { const { startDeps } = await start({ diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx index ede90c7098b5f..56fdcf3142bef 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx @@ -14,6 +14,7 @@ import { parse } from 'url'; import { EuiLink } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import type { InternalInjectedMetadataStart } from '@kbn/core-injected-metadata-browser-internal'; +import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser'; import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import type { HttpStart } from '@kbn/core-http-browser'; import { mountReactNode } from '@kbn/core-mount-utils-browser-internal'; @@ -40,6 +41,7 @@ import { NavLinksService } from './nav_links'; import { ProjectNavigationService } from './project_navigation'; import { RecentlyAccessedService } from './recently_accessed'; import { Header, ProjectHeader, ProjectSideNavigation } from './ui'; +import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; import type { InternalChromeStart } from './types'; const IS_LOCKED_KEY = 'core.chrome.isLocked'; @@ -50,6 +52,10 @@ interface ConstructorParams { kibanaVersion: string; } +export interface SetupDeps { + analytics: AnalyticsServiceSetup; +} + export interface StartDeps { application: InternalApplicationStart; docLinks: DocLinksStart; @@ -104,6 +110,11 @@ export class ChromeService { ); } + public setup({ analytics }: SetupDeps) { + const docTitle = this.docTitle.setup({ document: window.document }); + registerAnalyticsContextProvider(analytics, docTitle.title$); + } + public async start({ application, docLinks, @@ -155,7 +166,7 @@ export class ChromeService { const navLinks = this.navLinks.start({ application, http }); const projectNavigation = this.projectNavigation.start({ application, navLinks }); const recentlyAccessed = await this.recentlyAccessed.start({ http }); - const docTitle = this.docTitle.start({ document: window.document }); + const docTitle = this.docTitle.start(); const { customBranding$ } = customBranding; // erase chrome fields from a previous app while switching to a next app diff --git a/packages/core/chrome/core-chrome-browser-internal/src/doc_title/doc_title_service.test.ts b/packages/core/chrome/core-chrome-browser-internal/src/doc_title/doc_title_service.test.ts index f728321fa6dcf..145f0f1a59627 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/doc_title/doc_title_service.test.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/doc_title/doc_title_service.test.ts @@ -10,47 +10,128 @@ import { DocTitleService } from './doc_title_service'; describe('DocTitleService', () => { const defaultTitle = 'KibanaTest'; - const document = { title: '' }; + let document: { title: string }; const getStart = (title: string = defaultTitle) => { document.title = title; - return new DocTitleService().start({ document }); + const docTitle = new DocTitleService(); + docTitle.setup({ document }); + return docTitle.start(); }; beforeEach(() => { - document.title = defaultTitle; + document = { title: defaultTitle }; }); - describe('#change()', () => { - it('changes the title of the document', async () => { - getStart().change('TitleA'); - expect(document.title).toEqual('TitleA - KibanaTest'); + describe('#setup', () => { + describe('title$', () => { + it('emits with the initial title', () => { + document.title = 'Kibana'; + const docTitle = new DocTitleService(); + const { title$ } = docTitle.setup({ document }); + docTitle.start(); + + const titles: string[] = []; + title$.subscribe((title) => { + titles.push(title); + }); + + expect(titles).toEqual(['Kibana']); + }); + + it('emits when the title changes', () => { + document.title = 'Kibana'; + const docTitle = new DocTitleService(); + const { title$ } = docTitle.setup({ document }); + const { change } = docTitle.start(); + + const titles: string[] = []; + title$.subscribe((title) => { + titles.push(title); + }); + + change('title 2'); + change('title 3'); + + expect(titles).toEqual(['Kibana', 'title 2 - Kibana', 'title 3 - Kibana']); + }); + + it('emits when the title is reset', () => { + document.title = 'Kibana'; + const docTitle = new DocTitleService(); + const { title$ } = docTitle.setup({ document }); + const { change, reset } = docTitle.start(); + + const titles: string[] = []; + title$.subscribe((title) => { + titles.push(title); + }); + + change('title 2'); + reset(); + + expect(titles).toEqual(['Kibana', 'title 2 - Kibana', 'Kibana']); + }); + + it('only emits on unique titles', () => { + document.title = 'Kibana'; + const docTitle = new DocTitleService(); + const { title$ } = docTitle.setup({ document }); + const { change } = docTitle.start(); + + const titles: string[] = []; + title$.subscribe((title) => { + titles.push(title); + }); + + change('title 2'); + change('title 2'); + change('title 3'); + + expect(titles).toEqual(['Kibana', 'title 2 - Kibana', 'title 3 - Kibana']); + }); }); + }); - it('appends the baseTitle to the title', async () => { - const start = getStart('BaseTitle'); - start.change('TitleA'); - expect(document.title).toEqual('TitleA - BaseTitle'); - start.change('TitleB'); - expect(document.title).toEqual('TitleB - BaseTitle'); + describe('#start', () => { + it('throws if called before #setup', () => { + const docTitle = new DocTitleService(); + expect(() => docTitle.start()).toThrowErrorMatchingInlineSnapshot( + `"DocTitleService#setup must be called before DocTitleService#start"` + ); }); - it('accepts string arrays as input', async () => { - const start = getStart(); - start.change(['partA', 'partB']); - expect(document.title).toEqual(`partA - partB - ${defaultTitle}`); - start.change(['partA', 'partB', 'partC']); - expect(document.title).toEqual(`partA - partB - partC - ${defaultTitle}`); + describe('#change()', () => { + it('changes the title of the document', async () => { + getStart().change('TitleA'); + expect(document.title).toEqual('TitleA - KibanaTest'); + }); + + it('appends the baseTitle to the title', async () => { + const start = getStart('BaseTitle'); + start.change('TitleA'); + expect(document.title).toEqual('TitleA - BaseTitle'); + start.change('TitleB'); + expect(document.title).toEqual('TitleB - BaseTitle'); + }); + + it('accepts string arrays as input', async () => { + const start = getStart(); + start.change(['partA', 'partB']); + expect(document.title).toEqual(`partA - partB - ${defaultTitle}`); + start.change(['partA', 'partB', 'partC']); + expect(document.title).toEqual(`partA - partB - partC - ${defaultTitle}`); + }); }); - }); - describe('#reset()', () => { - it('resets the title to the initial value', async () => { - const start = getStart('InitialTitle'); - start.change('TitleA'); - expect(document.title).toEqual('TitleA - InitialTitle'); - start.reset(); - expect(document.title).toEqual('InitialTitle'); + describe('#reset()', () => { + it('resets the title to the initial value', async () => { + const start = getStart('InitialTitle'); + start.change('TitleA'); + expect(document.title).toEqual('TitleA - InitialTitle'); + start.reset(); + expect(document.title).toEqual('InitialTitle'); + }); }); }); }); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/doc_title/doc_title_service.ts b/packages/core/chrome/core-chrome-browser-internal/src/doc_title/doc_title_service.ts index ee884b59d8122..04a118a842d54 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/doc_title/doc_title_service.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/doc_title/doc_title_service.ts @@ -7,9 +7,14 @@ */ import { compact, flattenDeep, isString } from 'lodash'; +import { Observable, ReplaySubject, distinctUntilChanged } from 'rxjs'; import type { ChromeDocTitle } from '@kbn/core-chrome-browser'; -interface StartDeps { +export interface InternalChromeDocTitleSetup { + title$: Observable; +} + +interface SetupDeps { document: { title: string }; } @@ -18,12 +23,24 @@ const titleSeparator = ' - '; /** @internal */ export class DocTitleService { - private document = { title: '' }; - private baseTitle = ''; + private document?: { title: string }; + private baseTitle?: string; + private titleSubject = new ReplaySubject(1); - public start({ document }: StartDeps): ChromeDocTitle { + public setup({ document }: SetupDeps): InternalChromeDocTitleSetup { this.document = document; this.baseTitle = document.title; + this.titleSubject.next(this.baseTitle); + + return { + title$: this.titleSubject.asObservable().pipe(distinctUntilChanged()), + }; + } + + public start(): ChromeDocTitle { + if (this.document === undefined || this.baseTitle === undefined) { + throw new Error('DocTitleService#setup must be called before DocTitleService#start'); + } return { change: (title: string | string[]) => { @@ -36,7 +53,9 @@ export class DocTitleService { } private applyTitle(title: string | string[]) { - this.document.title = this.render(title); + const rendered = this.render(title); + this.document!.title = rendered; + this.titleSubject.next(rendered); } private render(title: string | string[]) { diff --git a/packages/core/chrome/core-chrome-browser-internal/src/register_analytics_context_provider.test.ts b/packages/core/chrome/core-chrome-browser-internal/src/register_analytics_context_provider.test.ts new file mode 100644 index 0000000000000..85caf49f12f0a --- /dev/null +++ b/packages/core/chrome/core-chrome-browser-internal/src/register_analytics_context_provider.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { firstValueFrom, of, ReplaySubject } from 'rxjs'; +import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; +import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; + +describe('registerAnalyticsContextProvider', () => { + let analytics: ReturnType; + + beforeEach(() => { + analytics = analyticsServiceMock.createAnalyticsServiceSetup(); + }); + + test('it registers the context provider', async () => { + registerAnalyticsContextProvider(analytics, of('some title')); + expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1); + expect(analytics.registerContextProvider).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'page title', + }) + ); + }); + + test('emits a context value when location$ emits', async () => { + const title$ = new ReplaySubject(1); + registerAnalyticsContextProvider(analytics, title$); + title$.next('kibana title'); + + await expect( + firstValueFrom(analytics.registerContextProvider.mock.calls[0][0].context$) + ).resolves.toEqual({ page_title: 'kibana title' }); + }); +}); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/register_analytics_context_provider.ts b/packages/core/chrome/core-chrome-browser-internal/src/register_analytics_context_provider.ts new file mode 100644 index 0000000000000..be811577d3977 --- /dev/null +++ b/packages/core/chrome/core-chrome-browser-internal/src/register_analytics_context_provider.ts @@ -0,0 +1,33 @@ +/* + * 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 { type Observable, map } from 'rxjs'; +import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser'; + +/** + * Registers the Analytics context provider to enrich events with the page title. + * @param analytics Analytics service. + * @param pageTitle$ Observable emitting the page title. + * @private + */ +export function registerAnalyticsContextProvider( + analytics: AnalyticsServiceSetup, + pageTitle$: Observable +) { + analytics.registerContextProvider({ + name: 'page title', + context$: pageTitle$.pipe( + map((pageTitle) => ({ + page_title: pageTitle, + })) + ), + schema: { + page_title: { type: 'text', _meta: { description: 'The page title' } }, + }, + }); +} diff --git a/packages/core/chrome/core-chrome-browser-internal/tsconfig.json b/packages/core/chrome/core-chrome-browser-internal/tsconfig.json index cd27209bef12c..687c2a2d9eace 100644 --- a/packages/core/chrome/core-chrome-browser-internal/tsconfig.json +++ b/packages/core/chrome/core-chrome-browser-internal/tsconfig.json @@ -39,6 +39,8 @@ "@kbn/core-custom-branding-browser-mocks", "@kbn/core-custom-branding-browser", "@kbn/core-custom-branding-common", + "@kbn/core-analytics-browser-mocks", + "@kbn/core-analytics-browser", ], "exclude": [ "target/**/*", diff --git a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts index 68554c646cd99..a2e511a68b90f 100644 --- a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts +++ b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts @@ -87,6 +87,7 @@ const createStartContractMock = () => { type ChromeServiceContract = PublicMethodsOf; const createMock = () => { const mocked: jest.Mocked = { + setup: jest.fn(), start: jest.fn(), stop: jest.fn(), }; diff --git a/packages/core/root/core-root-browser-internal/src/core_system.test.ts b/packages/core/root/core-root-browser-internal/src/core_system.test.ts index 0e4c0eb1340d6..d77ccaedb5279 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.test.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.test.ts @@ -311,6 +311,11 @@ describe('#setup()', () => { await setupCore(); expect(MockThemeService.setup).toHaveBeenCalledTimes(1); }); + + it('calls chrome#setup()', async () => { + await setupCore(); + expect(MockChromeService.setup).toHaveBeenCalledTimes(1); + }); }); describe('#start()', () => { diff --git a/packages/core/root/core-root-browser-internal/src/core_system.ts b/packages/core/root/core-root-browser-internal/src/core_system.ts index b4a8b6d2d2815..cf9a172479b31 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.ts @@ -236,6 +236,7 @@ export class CoreSystem { fatalErrors: this.fatalErrorsSetup, executionContext, }); + this.chrome.setup({ analytics }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const settings = this.settings.setup({ http, injectedMetadata }); const notifications = this.notifications.setup({ uiSettings }); diff --git a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts index 3a7c075abd613..19a0bff55153d 100644 --- a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts +++ b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts @@ -99,6 +99,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(event.context.viewport_height).to.be.a('number'); }); + it('should have the properties provided by the "page title" context provider', async () => { + expect(event.context).to.have.property('page_title'); + expect(event.context.page_title).to.be.a('string'); + }); + it('should have the properties provided by the "page url" context provider', () => { expect(event.context).to.have.property('page_url'); expect(event.context.page_url).to.be.a('string'); From 3a34e3593d51e19ecf265a7c16d61fdc9d7b0692 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 20 Jun 2023 07:43:17 -0400 Subject: [PATCH 06/14] feat(slo): Support for calendar aligned time window (#159949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves https://github.com/elastic/kibana/issues/159948 ## 📝 Summary This PR updates the SLO form to support the calendar aligned time windows for both create and edit flow. I've also moved the budgeting method selector down, so when selecting "timeslices", the timeslices related inputs are shown next to it on the same line. | Screenshot | Screenshot | |--------|--------| | ![screencapture-localhost-5601-kibana-app-observability-slos-edit-c1a51ac0-0eb0-11ee-8f7a-0da90ce06520-2023-06-19-11_53_05](https://github.com/elastic/kibana/assets/1376800/9e786a17-ebce-43b5-b063-090fe89a1821) | ![screencapture-localhost-5601-kibana-app-observability-slos-edit-c1a51ac0-0eb0-11ee-8f7a-0da90ce06520-2023-06-19-11_52_31](https://github.com/elastic/kibana/assets/1376800/c3e7cab1-31c2-490b-b38f-6f7b01a3fc95) | --- .../kbn-slo-schema/src/rest_specs/slo.ts | 3 + .../kbn-slo-schema/src/schema/time_window.ts | 5 + .../slo_edit/components/slo_edit_form.tsx | 7 +- .../slo_edit_form_objective_section.tsx | 118 ++++++++++++++---- ...edit_form_objective_section_timeslices.tsx | 10 +- .../public/pages/slo_edit/constants.ts | 42 +++++-- .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 9 files changed, 142 insertions(+), 52 deletions(-) diff --git a/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts b/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts index 9d37c36c94534..cbab659f07c82 100644 --- a/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts +++ b/x-pack/packages/kbn-slo-schema/src/rest_specs/slo.ts @@ -22,6 +22,7 @@ import { summarySchema, tagsSchema, timeWindowSchema, + timeWindowTypeSchema, } from '../schema'; const createSLOParamsSchema = t.type({ @@ -166,6 +167,7 @@ type GetPreviewDataParams = t.TypeOf; type BudgetingMethod = t.TypeOf; +type TimeWindow = t.TypeOf; type Indicator = t.OutputOf; type MetricCustomIndicator = t.OutputOf; @@ -211,4 +213,5 @@ export type { Indicator, MetricCustomIndicator, KQLCustomIndicator, + TimeWindow, }; diff --git a/x-pack/packages/kbn-slo-schema/src/schema/time_window.ts b/x-pack/packages/kbn-slo-schema/src/schema/time_window.ts index ebbb957d723b6..b495f7e545314 100644 --- a/x-pack/packages/kbn-slo-schema/src/schema/time_window.ts +++ b/x-pack/packages/kbn-slo-schema/src/schema/time_window.ts @@ -20,6 +20,10 @@ const calendarAlignedTimeWindowSchema = t.type({ type: calendarAlignedTimeWindowTypeSchema, }); +const timeWindowTypeSchema = t.union([ + rollingTimeWindowTypeSchema, + calendarAlignedTimeWindowTypeSchema, +]); const timeWindowSchema = t.union([rollingTimeWindowSchema, calendarAlignedTimeWindowSchema]); export { @@ -28,4 +32,5 @@ export { calendarAlignedTimeWindowSchema, calendarAlignedTimeWindowTypeSchema, timeWindowSchema, + timeWindowTypeSchema, }; diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form.tsx index a4f24da9345e3..1d1f8878de20e 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form.tsx @@ -272,17 +272,16 @@ export function SloEditForm({ slo }: Props) { })} - navigateToUrl(basePath.prepend(paths.observability.slos))} > {i18n.translate('xpack.observability.slo.sloEdit.cancelButton', { defaultMessage: 'Cancel', })} - + (); + const { control, watch, getFieldState, resetField } = useFormContext(); const budgetingSelect = useGeneratedHtmlId({ prefix: 'budgetingSelect' }); + const timeWindowTypeSelect = useGeneratedHtmlId({ prefix: 'timeWindowTypeSelect' }); const timeWindowSelect = useGeneratedHtmlId({ prefix: 'timeWindowSelect' }); + const timeWindowType = watch('timeWindow.type'); + + useEffect(() => { + resetField('timeWindow.duration', { + defaultValue: + timeWindowType === 'calendarAligned' + ? CALENDARALIGNED_TIMEWINDOW_OPTIONS[1].value + : ROLLING_TIMEWINDOW_OPTIONS[1].value, + }); + }, [timeWindowType, resetField]); return ( - {i18n.translate('xpack.observability.slo.sloEdit.budgetingMethod.label', { - defaultMessage: 'Budgeting method', + {i18n.translate('xpack.observability.slo.sloEdit.timeWindowType.label', { + defaultMessage: 'Time window', })}{' '} ( )} /> - - {i18n.translate('xpack.observability.slo.sloEdit.timeWindow.label', { - defaultMessage: 'Time window', + {i18n.translate('xpack.observability.slo.sloEdit.timeWindowDuration.label', { + defaultMessage: 'Duration', })}{' '} @@ -96,6 +113,7 @@ export function SloEditFormObjectiveSection() { ( )} /> + + + + + + + {i18n.translate('xpack.observability.slo.sloEdit.budgetingMethod.label', { + defaultMessage: 'Budgeting method', + })}{' '} + + + } + > + ( + + )} + /> + + + + {watch('budgetingMethod') === 'timeslices' ? ( + + ) : null} + + + + + - - {watch('budgetingMethod') === 'timeslices' ? ( - <> - - - - ) : null} ); } diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form_objective_section_timeslices.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form_objective_section_timeslices.tsx index 5cd05e75f4825..f5c0dd068e460 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form_objective_section_timeslices.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/slo_edit_form_objective_section_timeslices.tsx @@ -5,17 +5,17 @@ * 2.0. */ -import React from 'react'; -import { EuiFieldNumber, EuiFlexGrid, EuiFlexItem, EuiFormRow, EuiIconTip } from '@elastic/eui'; +import { EuiFieldNumber, EuiFlexItem, EuiFormRow, EuiIconTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Controller, useFormContext } from 'react-hook-form'; import type { CreateSLOInput } from '@kbn/slo-schema'; +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; export function SloEditFormObjectiveSectionTimeslices() { const { control, getFieldState } = useFormContext(); return ( - + <> - + ); } diff --git a/x-pack/plugins/observability/public/pages/slo_edit/constants.ts b/x-pack/plugins/observability/public/pages/slo_edit/constants.ts index 3fd9e0681783d..8430babc45a5b 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/constants.ts +++ b/x-pack/plugins/observability/public/pages/slo_edit/constants.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { BudgetingMethod, CreateSLOInput } from '@kbn/slo-schema'; +import { BudgetingMethod, CreateSLOInput, TimeWindow } from '@kbn/slo-schema'; import { BUDGETING_METHOD_OCCURRENCES, BUDGETING_METHOD_TIMESLICES, @@ -49,9 +49,39 @@ export const BUDGETING_METHOD_OPTIONS: Array<{ value: BudgetingMethod; text: str }, ]; -export const TIMEWINDOW_OPTIONS = [90, 30, 7].map((number) => ({ +export const TIMEWINDOW_TYPE_OPTIONS: Array<{ value: TimeWindow; text: string }> = [ + { + value: 'rolling', + text: i18n.translate('xpack.observability.slo.sloEdit.timeWindow.rolling', { + defaultMessage: 'Rolling', + }), + }, + { + value: 'calendarAligned', + text: i18n.translate('xpack.observability.slo.sloEdit.timeWindow.calendarAligned', { + defaultMessage: 'Calendar aligned', + }), + }, +]; + +export const CALENDARALIGNED_TIMEWINDOW_OPTIONS = [ + { + value: '1w', + text: i18n.translate('xpack.observability.slo.sloEdit.calendarTimeWindow.weekly', { + defaultMessage: 'Weekly', + }), + }, + { + value: '1M', + text: i18n.translate('xpack.observability.slo.sloEdit.calendarTimeWindow.monthly', { + defaultMessage: 'Monthly', + }), + }, +]; + +export const ROLLING_TIMEWINDOW_OPTIONS = [90, 30, 7].map((number) => ({ value: `${number}d`, - text: i18n.translate('xpack.observability.slo.sloEdit.timeWindow.days', { + text: i18n.translate('xpack.observability.slo.sloEdit.rollingTimeWindow.days', { defaultMessage: '{number} days', values: { number }, }), @@ -71,8 +101,7 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOInput = { }, }, timeWindow: { - duration: - TIMEWINDOW_OPTIONS[TIMEWINDOW_OPTIONS.findIndex((option) => option.value === '30d')].value, + duration: ROLLING_TIMEWINDOW_OPTIONS[1].value, type: 'rolling', }, tags: [], @@ -96,8 +125,7 @@ export const SLO_EDIT_FORM_DEFAULT_VALUES_CUSTOM_METRIC: CreateSLOInput = { }, }, timeWindow: { - duration: - TIMEWINDOW_OPTIONS[TIMEWINDOW_OPTIONS.findIndex((option) => option.value === '30d')].value, + duration: ROLLING_TIMEWINDOW_OPTIONS[1].value, type: 'rolling', }, tags: [], diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 62a79c1cff886..3e8a5818e1766 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -26635,7 +26635,6 @@ "xpack.observability.slo.sloDetails.overview.observedValueSubtitle": "{value} (l'objectif est {objective})", "xpack.observability.slo.sloDetails.overview.rollingTimeWindow": "{duration} en cours", "xpack.observability.slo.sloDetails.sliHistoryChartPanel.duration": "Dernière(s) {duration}", - "xpack.observability.slo.sloEdit.timeWindow.days": "{number} jours", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.ux.coreVitals.averageMessage": " et inférieur à {bad}", "xpack.observability.ux.coreVitals.paletteLegend.rankPercentage": "{labelsInd} ({ranksInd} %)", @@ -26998,8 +26997,6 @@ "xpack.observability.slo.sloEdit.timeSliceTarget.tooltip": "La cible d'intervalle de temps individuel utilisée pour déterminer si l'intervalle est bon ou mauvais.", "xpack.observability.slo.sloEdit.timesliceWindow.label": "Fenêtre d'intervalle de temps (en minutes)", "xpack.observability.slo.sloEdit.timesliceWindow.tooltip": "La taille de la fenêtre d'intervalle de temps utilisée pour évaluer les données.", - "xpack.observability.slo.sloEdit.timeWindow.label": "Fenêtre temporelle", - "xpack.observability.slo.sloEdit.timeWindow.tooltip": "La durée de la fenêtre temporelle glissante utilisée pour calculer le SLO.", "xpack.observability.slo.sloList.pageHeader.createNewButtonLabel": "Créer un nouveau SLO", "xpack.observability.slo.sloList.welcomePrompt.buttonLabel": "Créer un SLO", "xpack.observability.slo.sloList.welcomePrompt.getStartedMessage": "Pour commencer, créez votre premier SLO.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fbda4b3dfe09c..c46500f6890e8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -26617,7 +26617,6 @@ "xpack.observability.slo.sloDetails.overview.observedValueSubtitle": "{objective}(目的は{value})", "xpack.observability.slo.sloDetails.overview.rollingTimeWindow": "{duration}ローリング", "xpack.observability.slo.sloDetails.sliHistoryChartPanel.duration": "過去{duration}", - "xpack.observability.slo.sloEdit.timeWindow.days": "{number}日", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.ux.coreVitals.averageMessage": " {bad}未満", "xpack.observability.ux.coreVitals.paletteLegend.rankPercentage": "{labelsInd} ({ranksInd}%)", @@ -26980,8 +26979,6 @@ "xpack.observability.slo.sloEdit.timeSliceTarget.tooltip": "スライスが良好か問題があるかどうかを判断するために使用される、個別のタイムスライス目標。", "xpack.observability.slo.sloEdit.timesliceWindow.label": "タイムスライス期間(分)", "xpack.observability.slo.sloEdit.timesliceWindow.tooltip": "データを評価するために使用されるタイムスライス期間サイズ。", - "xpack.observability.slo.sloEdit.timeWindow.label": "時間枠", - "xpack.observability.slo.sloEdit.timeWindow.tooltip": "SLOを計算するために使用されるローリング時間枠期間。", "xpack.observability.slo.sloList.pageHeader.createNewButtonLabel": "新規SLOを作成", "xpack.observability.slo.sloList.welcomePrompt.buttonLabel": "SLOの作成", "xpack.observability.slo.sloList.welcomePrompt.getStartedMessage": "開始するには、まずSLOを作成します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 306f183b72169..ae12d1043483b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -26615,7 +26615,6 @@ "xpack.observability.slo.sloDetails.overview.observedValueSubtitle": "{value}(目标为 {objective})", "xpack.observability.slo.sloDetails.overview.rollingTimeWindow": "{duration} 滚动", "xpack.observability.slo.sloDetails.sliHistoryChartPanel.duration": "过去 {duration}", - "xpack.observability.slo.sloEdit.timeWindow.days": "{number} 天", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.ux.coreVitals.averageMessage": " 且小于 {bad}", "xpack.observability.ux.coreVitals.paletteLegend.rankPercentage": "{labelsInd} ({ranksInd}%)", @@ -26978,8 +26977,6 @@ "xpack.observability.slo.sloEdit.timeSliceTarget.tooltip": "用于确定切片是良好还是不良的单个时间片目标。", "xpack.observability.slo.sloEdit.timesliceWindow.label": "时间片窗口(分钟)", "xpack.observability.slo.sloEdit.timesliceWindow.tooltip": "用于评估接收的数据的时间片窗口大小。", - "xpack.observability.slo.sloEdit.timeWindow.label": "时间窗口", - "xpack.observability.slo.sloEdit.timeWindow.tooltip": "用于在其间计算 SLO 的滚动时间窗口持续时间。", "xpack.observability.slo.sloList.pageHeader.createNewButtonLabel": "创建新 SLO", "xpack.observability.slo.sloList.welcomePrompt.buttonLabel": "创建 SLO", "xpack.observability.slo.sloList.welcomePrompt.getStartedMessage": "要开始使用,请创建您的首个 SLO。", From 12a2203d1030bfa738b3e09dd6ca127de6e6a664 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 20 Jun 2023 07:43:53 -0400 Subject: [PATCH 07/14] chore(slo): improve index selection (#159849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Summary This PR changes the index selection component behaviours. We are fetching the Data Views (limit to 10 at a time). The user can select one of them, or search for other Data Views and index pattern that matches at least on index. Therefore the user can choose any Data Views matching a search, or the index pattern derived from the search, if it matches at least one index. | Screenshot | | -- | | ![image](https://github.com/elastic/kibana/assets/1376800/ae0b1304-d701-4b17-823d-f0a727df65d6) | | ![image](https://github.com/elastic/kibana/assets/1376800/187814bd-849e-4164-bb52-517d144507ad) | | ![image](https://github.com/elastic/kibana/assets/1376800/3f1a2d3c-e9f2-4490-81ba-5d76e8d1cbb8) | ## 🧪 Testing 1. **Create 11 data views or more** Easiest method is to use curl with a POST on `/api/data_views/data_view` with a random payload: ``` { "data_view": { "title": "{% uuid 'v4' %}-log*", "name": "{% uuid 'v4' %} " } } ``` 2. **Go to the SLO form and select custom KQL, then search for a data view or another index pattern** --- .../__storybook_mocks__/use_fetch_indices.ts | 11 +- .../public/hooks/use_fetch_data_views.ts | 17 ++- .../public/hooks/use_fetch_indices.ts | 43 ++++--- .../custom_common/index_selection.tsx | 110 +++++++++--------- .../custom_kql_indicator_type_form.tsx | 12 +- .../public/pages/slo_edit/slo_edit.test.tsx | 27 +++-- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 9 files changed, 110 insertions(+), 113 deletions(-) diff --git a/x-pack/plugins/observability/public/hooks/__storybook_mocks__/use_fetch_indices.ts b/x-pack/plugins/observability/public/hooks/__storybook_mocks__/use_fetch_indices.ts index fdedecfef7243..a709dea0600f8 100644 --- a/x-pack/plugins/observability/public/hooks/__storybook_mocks__/use_fetch_indices.ts +++ b/x-pack/plugins/observability/public/hooks/__storybook_mocks__/use_fetch_indices.ts @@ -12,18 +12,13 @@ export const useFetchIndices = (): UseFetchIndicesResponse => { isLoading: false, isError: false, isSuccess: true, - indices: [ + data: [ ...Array(10) .fill(0) - .map((_, i) => ({ - name: `.index-${i}`, - })), + .map((_, i) => `.index-${i}`), ...Array(10) .fill(0) - .map((_, i) => ({ - name: `.some-other-index-${i}`, - })), + .map((_, i) => `.some-other-index-${i}`), ] as Index[], - refetch: function () {} as UseFetchIndicesResponse['refetch'], }; }; diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_data_views.ts b/x-pack/plugins/observability/public/hooks/use_fetch_data_views.ts index 976d6426d4ce3..1da32948e2e50 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_data_views.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_data_views.ts @@ -18,34 +18,31 @@ export interface UseFetchDataViewsResponse { isLoading: boolean; isSuccess: boolean; isError: boolean; - dataViews: DataView[] | undefined; + data: DataView[] | undefined; refetch: ( options?: (RefetchOptions & RefetchQueryFilters) | undefined ) => Promise>; } -interface FetchDataViewParams { +interface Params { name?: string; size?: number; } -export function useFetchDataViews({ - name = '', - size = 10, -}: FetchDataViewParams): UseFetchDataViewsResponse { +export function useFetchDataViews({ name = '', size = 10 }: Params): UseFetchDataViewsResponse { const { dataViews } = useKibana().services; + const search = name.endsWith('*') ? name : `${name}*`; const { isLoading, isError, isSuccess, data, refetch } = useQuery({ - queryKey: ['fetchDataViews', name], + queryKey: ['fetchDataViews', search], queryFn: async () => { try { - const response = await dataViews.find(`${name}*`, size); - return response; + return await dataViews.find(search, size); } catch (error) { throw new Error(`Something went wrong. Error: ${error}`); } }, }); - return { isLoading, isError, isSuccess, dataViews: data, refetch }; + return { isLoading, isError, isSuccess, data, refetch }; } diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_indices.ts b/x-pack/plugins/observability/public/hooks/use_fetch_indices.ts index 88c7b1d5561c1..2d622a702a839 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_indices.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_indices.ts @@ -5,41 +5,46 @@ * 2.0. */ -import { - QueryObserverResult, - RefetchOptions, - RefetchQueryFilters, - useQuery, -} from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { useKibana } from '../utils/kibana_react'; +export type Index = string; + export interface UseFetchIndicesResponse { isLoading: boolean; isSuccess: boolean; isError: boolean; - indices: Index[] | undefined; - refetch: ( - options?: (RefetchOptions & RefetchQueryFilters) | undefined - ) => Promise>; + data: Index[] | undefined; +} + +interface Params { + search?: string; } -export interface Index { - name: string; + +interface ResolveIndexReponse { + indices: Array<{ name: string }>; } -export function useFetchIndices(): UseFetchIndicesResponse { +export function useFetchIndices({ search }: Params): UseFetchIndicesResponse { const { http } = useKibana().services; - const { isLoading, isError, isSuccess, data, refetch } = useQuery({ - queryKey: ['fetchIndices'], - queryFn: async ({ signal }) => { + const { isLoading, isError, isSuccess, data } = useQuery({ + queryKey: ['fetchIndices', search], + queryFn: async () => { + const searchPattern = search?.endsWith('*') ? search : `${search}*`; try { - const response = await http.get(`/api/index_management/indices`, { signal }); - return response; + const response = await http.get( + `/internal/index-pattern-management/resolve_index/${searchPattern}` + ); + return response.indices.map((index) => index.name); } catch (error) { throw new Error(`Something went wrong. Error: ${error}`); } }, + retry: false, + enabled: Boolean(search), + refetchOnWindowFocus: false, }); - return { isLoading, isError, isSuccess, indices: data, refetch }; + return { isLoading, isError, isSuccess, data }; } diff --git a/x-pack/plugins/observability/public/pages/slo_edit/components/custom_common/index_selection.tsx b/x-pack/plugins/observability/public/pages/slo_edit/components/custom_common/index_selection.tsx index 691761bd8a94b..8d914991bf926 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/components/custom_common/index_selection.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/components/custom_common/index_selection.tsx @@ -5,16 +5,15 @@ * 2.0. */ -import React, { useEffect, useMemo, useState } from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; +import { DataView } from '@kbn/data-views-plugin/public'; import { i18n } from '@kbn/i18n'; import { CreateSLOInput } from '@kbn/slo-schema'; -import { DataView } from '@kbn/data-views-plugin/public'; import { debounce } from 'lodash'; - +import React, { useEffect, useMemo, useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import { useFetchDataViews } from '../../../../hooks/use_fetch_data_views'; -import { useFetchIndices, Index } from '../../../../hooks/use_fetch_indices'; +import { useFetchIndices } from '../../../../hooks/use_fetch_indices'; interface Option { label: string; @@ -23,55 +22,62 @@ interface Option { export function IndexSelection() { const { control, getFieldState } = useFormContext(); - const { isLoading: isIndicesLoading, indices = [] } = useFetchIndices(); + const [searchValue, setSearchValue] = useState(''); - const { isLoading: isDataViewsLoading, dataViews = [] } = useFetchDataViews({ + const [dataViewOptions, setDataViewOptions] = useState([]); + const [indexPatternOption, setIndexPatternOption] = useState