From 53f4d4840be38d8ec67ece6b1386b82ab9925954 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Thu, 11 Feb 2021 17:48:18 -0500 Subject: [PATCH 001/175] [Uptime] Feature/80166 add waterfall flyout (#89449) * adjust network events * add metaData to data formatting * add useFlyout * adjust waterfall data types * adjust MiddleTruncatedText to use span instead of div * add flyout * adjust sidebar button style * update tests * convert content to use sentence case * pass onBarClick and onProjectionClick as WaterfallChart props * use undefined value for initial flyoutData state * add telemetry * adjust typo in get_network_events * adjust connection time * added space between value and units * adjust flyout spacing, rearrange certificates, and right align values * adjust flyout labels * add focus management support to flyout * improve performance with memoization * add external link to MiddleTruncatedText * update data_formatting function * remove EuiPortal * add moment mock to data_formatting test * adjust data_formatting * adjust network_events runtime types * remove extra space in test tile * toggle flyout on sidebar click * update styling and html for open in new tab resource link * rename metaData to metadata * adjust MiddleTruncatedText styling * adjust WaterfallFlyout heading * adjust waterfall sidebar item types * adjust SidebarItem onClick type * fix license header * align middle truncated text left * move flyout logic to a render prop for better composability * add ip to flyout * update label for bytes downloaded (compressed) * lowercase compressed Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/runtime_types/network_events.ts | 16 +- .../waterfall/data_formatting.test.ts | 679 +++++++++++------- .../step_detail/waterfall/data_formatting.ts | 172 ++++- .../synthetics/step_detail/waterfall/types.ts | 73 ++ .../waterfall_chart_wrapper.test.tsx | 77 +- .../waterfall/waterfall_chart_wrapper.tsx | 48 +- .../waterfall/waterfall_flyout.test.tsx | 139 ++++ .../waterfall/waterfall_flyout.tsx | 125 ++++ .../waterfall/waterfall_sidebar_item.tsx | 41 +- .../waterfalll_sidebar_item.test.tsx | 22 +- .../components/middle_truncated_text.test.tsx | 35 +- .../components/middle_truncated_text.tsx | 95 ++- .../waterfall/components/sidebar.tsx | 25 +- .../synthetics/waterfall/components/styles.ts | 15 +- .../waterfall/components/use_flyout.test.tsx | 91 +++ .../waterfall/components/use_flyout.ts | 93 +++ .../components/waterfall_bar_chart.tsx | 10 +- .../waterfall/components/waterfall_chart.tsx | 21 +- .../components/waterfall_flyout_table.tsx | 78 ++ .../waterfall/context/waterfall_chart.tsx | 19 +- .../monitor/synthetics/waterfall/index.tsx | 8 +- .../monitor/synthetics/waterfall/types.ts | 18 + .../lib/requests/get_network_events.test.ts | 32 + .../server/lib/requests/get_network_events.ts | 17 + 24 files changed, 1573 insertions(+), 376 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.ts create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_flyout_table.tsx diff --git a/x-pack/plugins/uptime/common/runtime_types/network_events.ts b/x-pack/plugins/uptime/common/runtime_types/network_events.ts index 668e17d2a848bb..7b651b6a919514 100644 --- a/x-pack/plugins/uptime/common/runtime_types/network_events.ts +++ b/x-pack/plugins/uptime/common/runtime_types/network_events.ts @@ -20,24 +20,36 @@ const NetworkTimingsType = t.type({ ssl: t.number, }); -export type NetworkTimings = t.TypeOf; +const CertificateDataType = t.partial({ + validFrom: t.number, + validTo: t.number, + issuer: t.string, + subjectName: t.string, +}); const NetworkEventType = t.intersection([ t.type({ timestamp: t.string, requestSentTime: t.number, loadEndTime: t.number, + url: t.string, }), t.partial({ + bytesDownloadedCompressed: t.number, + certificates: CertificateDataType, + ip: t.string, method: t.string, - url: t.string, status: t.number, mimeType: t.string, requestStartTime: t.number, + responseHeaders: t.record(t.string, t.string), + requestHeaders: t.record(t.string, t.string), timings: NetworkTimingsType, }), ]); +export type NetworkTimings = t.TypeOf; +export type CertificateData = t.TypeOf; export type NetworkEvent = t.TypeOf; export const SyntheticsNetworkEventsApiResponseType = t.type({ diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts index a02116877f49a4..9376a83f48b3d5 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts @@ -4,12 +4,25 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { colourPalette, getSeriesAndDomain, getSidebarItems } from './data_formatting'; -import { NetworkItems, MimeType } from './types'; +import moment from 'moment'; +import { + colourPalette, + getConnectingTime, + getSeriesAndDomain, + getSidebarItems, +} from './data_formatting'; +import { + NetworkItems, + MimeType, + FriendlyFlyoutLabels, + FriendlyTimingLabels, + Timings, + Metadata, +} from './types'; +import { mockMoment } from '../../../../../lib/helper/test_helpers'; import { WaterfallDataEntry } from '../../waterfall/types'; -const networkItems: NetworkItems = [ +export const networkItems: NetworkItems = [ { timestamp: '2021-01-05T19:22:28.928Z', method: 'GET', @@ -31,6 +44,20 @@ const networkItems: NetworkItems = [ ssl: 55.38700000033714, dns: 3.559999997378327, }, + bytesDownloadedCompressed: 1000, + requestHeaders: { + sample_request_header: 'sample request header', + }, + responseHeaders: { + sample_response_header: 'sample response header', + }, + certificates: { + issuer: 'Sample Issuer', + validFrom: 1578441600000, + validTo: 1617883200000, + subjectName: '*.elastic.co', + }, + ip: '104.18.8.22', }, { timestamp: '2021-01-05T19:22:28.928Z', @@ -56,7 +83,7 @@ const networkItems: NetworkItems = [ }, ]; -const networkItemsWithoutFullTimings: NetworkItems = [ +export const networkItemsWithoutFullTimings: NetworkItems = [ networkItems[0], { timestamp: '2021-01-05T19:22:28.928Z', @@ -81,7 +108,7 @@ const networkItemsWithoutFullTimings: NetworkItems = [ }, ]; -const networkItemsWithoutAnyTimings: NetworkItems = [ +export const networkItemsWithoutAnyTimings: NetworkItems = [ { timestamp: '2021-01-05T19:22:28.928Z', method: 'GET', @@ -105,7 +132,7 @@ const networkItemsWithoutAnyTimings: NetworkItems = [ }, ]; -const networkItemsWithoutTimingsObject: NetworkItems = [ +export const networkItemsWithoutTimingsObject: NetworkItems = [ { timestamp: '2021-01-05T19:22:28.928Z', method: 'GET', @@ -117,7 +144,7 @@ const networkItemsWithoutTimingsObject: NetworkItems = [ }, ]; -const networkItemsWithUncommonMimeType: NetworkItems = [ +export const networkItemsWithUncommonMimeType: NetworkItems = [ { timestamp: '2021-01-05T19:22:28.928Z', method: 'GET', @@ -142,6 +169,28 @@ const networkItemsWithUncommonMimeType: NetworkItems = [ }, ]; +describe('getConnectingTime', () => { + it('returns `connect` value if `ssl` is undefined', () => { + expect(getConnectingTime(10)).toBe(10); + }); + + it('returns `undefined` if `connect` is not defined', () => { + expect(getConnectingTime(undefined, 23)).toBeUndefined(); + }); + + it('returns `connect` value if `ssl` is 0', () => { + expect(getConnectingTime(10, 0)).toBe(10); + }); + + it('returns `connect` value if `ssl` is -1', () => { + expect(getConnectingTime(10, 0)).toBe(10); + }); + + it('reduces `connect` value by `ssl` value if both are defined', () => { + expect(getConnectingTime(10, 3)).toBe(7); + }); +}); + describe('Palettes', () => { it('A colour palette comprising timing and mime type colours is correctly generated', () => { expect(colourPalette).toEqual({ @@ -163,299 +212,326 @@ describe('Palettes', () => { }); describe('getSeriesAndDomain', () => { - it('formats timings', () => { + beforeEach(() => { + mockMoment(); + }); + + it('formats series timings', () => { const actual = getSeriesAndDomain(networkItems); - expect(actual).toMatchInlineSnapshot(` - Object { - "domain": Object { - "max": 140.7760000010603, - "min": 0, - }, - "series": Array [ - Object { - "config": Object { + expect(actual.series).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "colour": "#dcd4c4", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#dcd4c4", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#dcd4c4", - "value": "Queued / Blocked: 0.854ms", - }, + "value": "Queued / Blocked: 0.854ms", }, - "x": 0, - "y": 0.8540000017092098, - "y0": 0, }, - Object { - "config": Object { + "x": 0, + "y": 0.8540000017092098, + "y0": 0, + }, + Object { + "config": Object { + "colour": "#54b399", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#54b399", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#54b399", - "value": "DNS: 3.560ms", - }, + "value": "DNS: 3.560ms", }, - "x": 0, - "y": 4.413999999087537, - "y0": 0.8540000017092098, }, - Object { - "config": Object { + "x": 0, + "y": 4.413999999087537, + "y0": 0.8540000017092098, + }, + Object { + "config": Object { + "colour": "#da8b45", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#da8b45", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#da8b45", - "value": "Connecting: 25.721ms", - }, + "value": "Connecting: 25.721ms", }, - "x": 0, - "y": 30.135000000882428, - "y0": 4.413999999087537, }, - Object { - "config": Object { + "x": 0, + "y": 30.135000000882428, + "y0": 4.413999999087537, + }, + Object { + "config": Object { + "colour": "#edc5a2", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#edc5a2", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#edc5a2", - "value": "TLS: 55.387ms", - }, + "value": "TLS: 55.387ms", }, - "x": 0, - "y": 85.52200000121957, - "y0": 30.135000000882428, }, - Object { - "config": Object { + "x": 0, + "y": 85.52200000121957, + "y0": 30.135000000882428, + }, + Object { + "config": Object { + "colour": "#d36086", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#d36086", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#d36086", - "value": "Sending request: 0.360ms", - }, + "value": "Sending request: 0.360ms", }, - "x": 0, - "y": 85.88200000303914, - "y0": 85.52200000121957, }, - Object { - "config": Object { + "x": 0, + "y": 85.88200000303914, + "y0": 85.52200000121957, + }, + Object { + "config": Object { + "colour": "#b0c9e0", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#b0c9e0", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#b0c9e0", - "value": "Waiting (TTFB): 34.578ms", - }, + "value": "Waiting (TTFB): 34.578ms", }, - "x": 0, - "y": 120.4600000019127, - "y0": 85.88200000303914, }, - Object { - "config": Object { + "x": 0, + "y": 120.4600000019127, + "y0": 85.88200000303914, + }, + Object { + "config": Object { + "colour": "#ca8eae", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#ca8eae", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#ca8eae", - "value": "Content downloading (CSS): 0.552ms", - }, + "value": "Content downloading (CSS): 0.552ms", }, - "x": 0, - "y": 121.01200000324752, - "y0": 120.4600000019127, }, - Object { - "config": Object { + "x": 0, + "y": 121.01200000324752, + "y0": 120.4600000019127, + }, + Object { + "config": Object { + "colour": "#dcd4c4", + "id": 1, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#dcd4c4", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#dcd4c4", - "value": "Queued / Blocked: 84.546ms", - }, + "value": "Queued / Blocked: 84.546ms", }, - "x": 1, - "y": 84.90799999795854, - "y0": 0.3619999997317791, }, - Object { - "config": Object { + "x": 1, + "y": 84.90799999795854, + "y0": 0.3619999997317791, + }, + Object { + "config": Object { + "colour": "#d36086", + "id": 1, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#d36086", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#d36086", - "value": "Sending request: 0.239ms", - }, + "value": "Sending request: 0.239ms", }, - "x": 1, - "y": 85.14699999883305, - "y0": 84.90799999795854, }, - Object { - "config": Object { + "x": 1, + "y": 85.14699999883305, + "y0": 84.90799999795854, + }, + Object { + "config": Object { + "colour": "#b0c9e0", + "id": 1, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#b0c9e0", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#b0c9e0", - "value": "Waiting (TTFB): 52.561ms", - }, + "value": "Waiting (TTFB): 52.561ms", }, - "x": 1, - "y": 137.70799999925657, - "y0": 85.14699999883305, }, - Object { - "config": Object { + "x": 1, + "y": 137.70799999925657, + "y0": 85.14699999883305, + }, + Object { + "config": Object { + "colour": "#9170b8", + "id": 1, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#9170b8", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#9170b8", - "value": "Content downloading (JS): 3.068ms", - }, + "value": "Content downloading (JS): 3.068ms", }, - "x": 1, - "y": 140.7760000010603, - "y0": 137.70799999925657, }, - ], - "totalHighlightedRequests": 2, - } + "x": 1, + "y": 140.7760000010603, + "y0": 137.70799999925657, + }, + ] `); }); - it('handles formatting when only total timing values are available', () => { - const actual = getSeriesAndDomain(networkItemsWithoutFullTimings); - expect(actual).toMatchInlineSnapshot(` - Object { - "domain": Object { - "max": 121.01200000324752, - "min": 0, - }, - "series": Array [ - Object { - "config": Object { + it('handles series formatting when only total timing values are available', () => { + const { series } = getSeriesAndDomain(networkItemsWithoutFullTimings); + expect(series).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "colour": "#dcd4c4", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#dcd4c4", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#dcd4c4", - "value": "Queued / Blocked: 0.854ms", - }, + "value": "Queued / Blocked: 0.854ms", }, - "x": 0, - "y": 0.8540000017092098, - "y0": 0, }, - Object { - "config": Object { + "x": 0, + "y": 0.8540000017092098, + "y0": 0, + }, + Object { + "config": Object { + "colour": "#54b399", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#54b399", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#54b399", - "value": "DNS: 3.560ms", - }, + "value": "DNS: 3.560ms", }, - "x": 0, - "y": 4.413999999087537, - "y0": 0.8540000017092098, }, - Object { - "config": Object { + "x": 0, + "y": 4.413999999087537, + "y0": 0.8540000017092098, + }, + Object { + "config": Object { + "colour": "#da8b45", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#da8b45", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#da8b45", - "value": "Connecting: 25.721ms", - }, + "value": "Connecting: 25.721ms", }, - "x": 0, - "y": 30.135000000882428, - "y0": 4.413999999087537, }, - Object { - "config": Object { + "x": 0, + "y": 30.135000000882428, + "y0": 4.413999999087537, + }, + Object { + "config": Object { + "colour": "#edc5a2", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#edc5a2", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#edc5a2", - "value": "TLS: 55.387ms", - }, + "value": "TLS: 55.387ms", }, - "x": 0, - "y": 85.52200000121957, - "y0": 30.135000000882428, }, - Object { - "config": Object { + "x": 0, + "y": 85.52200000121957, + "y0": 30.135000000882428, + }, + Object { + "config": Object { + "colour": "#d36086", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#d36086", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#d36086", - "value": "Sending request: 0.360ms", - }, + "value": "Sending request: 0.360ms", }, - "x": 0, - "y": 85.88200000303914, - "y0": 85.52200000121957, }, - Object { - "config": Object { + "x": 0, + "y": 85.88200000303914, + "y0": 85.52200000121957, + }, + Object { + "config": Object { + "colour": "#b0c9e0", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#b0c9e0", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#b0c9e0", - "value": "Waiting (TTFB): 34.578ms", - }, + "value": "Waiting (TTFB): 34.578ms", }, - "x": 0, - "y": 120.4600000019127, - "y0": 85.88200000303914, }, - Object { - "config": Object { + "x": 0, + "y": 120.4600000019127, + "y0": 85.88200000303914, + }, + Object { + "config": Object { + "colour": "#ca8eae", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#ca8eae", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#ca8eae", - "value": "Content downloading (CSS): 0.552ms", - }, + "value": "Content downloading (CSS): 0.552ms", }, - "x": 0, - "y": 121.01200000324752, - "y0": 120.4600000019127, }, - Object { - "config": Object { + "x": 0, + "y": 121.01200000324752, + "y0": 120.4600000019127, + }, + Object { + "config": Object { + "colour": "#9170b8", + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#9170b8", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#9170b8", - "value": "Content downloading (JS): 2.793ms", - }, + "value": "Content downloading (JS): 2.793ms", }, - "x": 1, - "y": 3.714999998046551, - "y0": 0.9219999983906746, }, - ], - "totalHighlightedRequests": 2, - } + "x": 1, + "y": 3.714999998046551, + "y0": 0.9219999983906746, + }, + ] + `); + }); + + it('handles series formatting when there is no timing information available', () => { + const { series } = getSeriesAndDomain(networkItemsWithoutAnyTimings); + expect(series).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "colour": "", + "isHighlighted": true, + "showTooltip": false, + "tooltipProps": undefined, + }, + "x": 0, + "y": 0, + "y0": 0, + }, + ] `); }); @@ -467,6 +543,53 @@ describe('getSeriesAndDomain', () => { "max": 0, "min": 0, }, + "metadata": Array [ + Object { + "certificates": undefined, + "details": Array [ + Object { + "name": "Content type", + "value": "text/javascript", + }, + Object { + "name": "Request start", + "value": "0.000 ms", + }, + Object { + "name": "DNS", + "value": undefined, + }, + Object { + "name": "Connecting", + "value": undefined, + }, + Object { + "name": "TLS", + "value": undefined, + }, + Object { + "name": "Waiting (TTFB)", + "value": undefined, + }, + Object { + "name": "Content downloading", + "value": undefined, + }, + Object { + "name": "Bytes downloaded (compressed)", + "value": undefined, + }, + Object { + "name": "IP", + "value": undefined, + }, + ], + "requestHeaders": undefined, + "responseHeaders": undefined, + "url": "file:///Users/dominiqueclarke/dev/synthetics/examples/todos/app/app.js", + "x": 0, + }, + ], "series": Array [ Object { "config": Object { @@ -486,32 +609,24 @@ describe('getSeriesAndDomain', () => { }); it('handles formatting when the timings object is undefined', () => { - const actual = getSeriesAndDomain(networkItemsWithoutTimingsObject); - expect(actual).toMatchInlineSnapshot(` - Object { - "domain": Object { - "max": 0, - "min": 0, - }, - "series": Array [ - Object { - "config": Object { - "isHighlighted": true, - "showTooltip": false, - }, - "x": 0, - "y": 0, - "y0": 0, + const { series } = getSeriesAndDomain(networkItemsWithoutTimingsObject); + expect(series).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "isHighlighted": true, + "showTooltip": false, }, - ], - "totalHighlightedRequests": 1, - } + "x": 0, + "y": 0, + "y0": 0, + }, + ] `); }); it('handles formatting when mime type is not mapped to a specific mime type bucket', () => { - const actual = getSeriesAndDomain(networkItemsWithUncommonMimeType); - const { series } = actual; + const { series } = getSeriesAndDomain(networkItemsWithUncommonMimeType); /* verify that raw mime type appears in the tooltip config and that * the colour is mapped to mime type other */ const contentDownloadedingConfigItem = series.find((item: WaterfallDataEntry) => { @@ -527,6 +642,48 @@ describe('getSeriesAndDomain', () => { expect(contentDownloadedingConfigItem).toBeDefined(); }); + it.each([ + [FriendlyFlyoutLabels[Metadata.MimeType], 'text/css'], + [FriendlyFlyoutLabels[Metadata.RequestStart], '0.000 ms'], + [FriendlyTimingLabels[Timings.Dns], '3.560 ms'], + [FriendlyTimingLabels[Timings.Connect], '25.721 ms'], + [FriendlyTimingLabels[Timings.Ssl], '55.387 ms'], + [FriendlyTimingLabels[Timings.Wait], '34.578 ms'], + [FriendlyTimingLabels[Timings.Receive], '0.552 ms'], + [FriendlyFlyoutLabels[Metadata.BytesDownloadedCompressed], '1.000 KB'], + [FriendlyFlyoutLabels[Metadata.IP], '104.18.8.22'], + ])('handles metadata details formatting', (name, value) => { + const { metadata } = getSeriesAndDomain(networkItems); + const metadataEntry = metadata[0]; + expect( + metadataEntry.details.find((item) => item.value === value && item.name === name) + ).toBeDefined(); + }); + + it('handles metadata headers formatting', () => { + const { metadata } = getSeriesAndDomain(networkItems); + const metadataEntry = metadata[0]; + metadataEntry.requestHeaders?.forEach((header) => { + expect(header).toEqual({ name: header.name, value: header.value }); + }); + metadataEntry.responseHeaders?.forEach((header) => { + expect(header).toEqual({ name: header.name, value: header.value }); + }); + }); + + it('handles certificate formatting', () => { + const { metadata } = getSeriesAndDomain([networkItems[0]]); + const metadataEntry = metadata[0]; + expect(metadataEntry.certificates).toEqual([ + { name: 'Issuer', value: networkItems[0].certificates?.issuer }, + { name: 'Valid from', value: moment(networkItems[0].certificates?.validFrom).format('L LT') }, + { name: 'Valid until', value: moment(networkItems[0].certificates?.validTo).format('L LT') }, + { name: 'Common name', value: networkItems[0].certificates?.subjectName }, + ]); + metadataEntry.responseHeaders?.forEach((header) => { + expect(header).toEqual({ name: header.name, value: header.value }); + }); + }); it('counts the total number of highlighted items', () => { // only one CSS file in this array of network Items const actual = getSeriesAndDomain(networkItems, false, '', ['stylesheet']); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts index 46f0d23d0a6b99..23d9b2d8563ae3 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts @@ -6,20 +6,23 @@ */ import { euiPaletteColorBlind } from '@elastic/eui'; +import moment from 'moment'; import { NetworkItems, NetworkItem, + FriendlyFlyoutLabels, FriendlyTimingLabels, FriendlyMimetypeLabels, MimeType, MimeTypesMap, Timings, + Metadata, TIMING_ORDER, SidebarItems, LegendItems, } from './types'; -import { WaterfallData } from '../../waterfall'; +import { WaterfallData, WaterfallMetadata } from '../../waterfall'; import { NetworkEvent } from '../../../../../../common/runtime_types'; export const extractItems = (data: NetworkEvent[]): NetworkItems => { @@ -71,6 +74,29 @@ export const isHighlightedItem = ( return !!(matchQuery && matchFilters); }; +const getFriendlyMetadataValue = ({ value, postFix }: { value?: number; postFix?: string }) => { + // value === -1 indicates timing data cannot be extracted + if (value === undefined || value === -1) { + return undefined; + } + + let formattedValue = formatValueForDisplay(value); + + if (postFix) { + formattedValue = `${formattedValue} ${postFix}`; + } + + return formattedValue; +}; + +export const getConnectingTime = (connect?: number, ssl?: number) => { + if (ssl && connect && ssl > 0) { + return connect - ssl; + } else { + return connect; + } +}; + export const getSeriesAndDomain = ( items: NetworkItems, onlyHighlighted = false, @@ -80,34 +106,36 @@ export const getSeriesAndDomain = ( const getValueForOffset = (item: NetworkItem) => { return item.requestSentTime; }; - // The earliest point in time a request is sent or started. This will become our notion of "0". - const zeroOffset = items.reduce((acc, item) => { - const offsetValue = getValueForOffset(item); - return offsetValue < acc ? offsetValue : acc; - }, Infinity); + let zeroOffset = Infinity; + items.forEach((i) => (zeroOffset = Math.min(zeroOffset, getValueForOffset(i)))); const getValue = (timings: NetworkEvent['timings'], timing: Timings) => { if (!timings) return; // SSL is a part of the connect timing - if (timing === Timings.Connect && timings.ssl > 0) { - return timings.connect - timings.ssl; - } else { - return timings[timing]; + if (timing === Timings.Connect) { + return getConnectingTime(timings.connect, timings.ssl); } + return timings[timing]; }; + const series: WaterfallData = []; + const metadata: WaterfallMetadata = []; let totalHighlightedRequests = 0; - const series = items.reduce((acc, item, index) => { + items.forEach((item, index) => { + const mimeTypeColour = getColourForMimeType(item.mimeType); + const offsetValue = getValueForOffset(item); + let currentOffset = offsetValue - zeroOffset; + metadata.push(formatMetadata({ item, index, requestStart: currentOffset })); const isHighlighted = isHighlightedItem(item, query, activeFilters); if (isHighlighted) { totalHighlightedRequests++; } if (!item.timings) { - acc.push({ + series.push({ x: index, y0: 0, y: 0, @@ -116,14 +144,9 @@ export const getSeriesAndDomain = ( showTooltip: false, }, }); - return acc; + return; } - const offsetValue = getValueForOffset(item); - const mimeTypeColour = getColourForMimeType(item.mimeType); - - let currentOffset = offsetValue - zeroOffset; - let timingValueFound = false; TIMING_ORDER.forEach((timing) => { @@ -133,11 +156,12 @@ export const getSeriesAndDomain = ( const colour = timing === Timings.Receive ? mimeTypeColour : colourPalette[timing]; const y = currentOffset + value; - acc.push({ + series.push({ x: index, y0: currentOffset, y, config: { + id: index, colour, isHighlighted, showTooltip: true, @@ -161,7 +185,7 @@ export const getSeriesAndDomain = ( if (!timingValueFound) { const total = item.timings.total; const hasTotal = total !== -1; - acc.push({ + series.push({ x: index, y0: hasTotal ? currentOffset : 0, y: hasTotal ? currentOffset + item.timings.total : 0, @@ -182,8 +206,7 @@ export const getSeriesAndDomain = ( }, }); } - return acc; - }, []); + }); const yValues = series.map((serie) => serie.y); const domain = { min: 0, max: Math.max(...yValues) }; @@ -193,7 +216,108 @@ export const getSeriesAndDomain = ( filteredSeries = series.filter((item) => item.config.isHighlighted); } - return { series: filteredSeries, domain, totalHighlightedRequests }; + return { series: filteredSeries, domain, metadata, totalHighlightedRequests }; +}; + +const formatHeaders = (headers?: Record) => { + if (typeof headers === 'undefined') { + return undefined; + } + return Object.keys(headers).map((key) => ({ + name: key, + value: `${headers[key]}`, + })); +}; + +const formatMetadata = ({ + item, + index, + requestStart, +}: { + item: NetworkItem; + index: number; + requestStart: number; +}) => { + const { + bytesDownloadedCompressed, + certificates, + ip, + mimeType, + requestHeaders, + responseHeaders, + url, + } = item; + const { dns, connect, ssl, wait, receive, total } = item.timings || {}; + const contentDownloaded = receive && receive > 0 ? receive : total; + return { + x: index, + url, + requestHeaders: formatHeaders(requestHeaders), + responseHeaders: formatHeaders(responseHeaders), + certificates: certificates + ? [ + { + name: FriendlyFlyoutLabels[Metadata.CertificateIssuer], + value: certificates.issuer, + }, + { + name: FriendlyFlyoutLabels[Metadata.CertificateIssueDate], + value: certificates.validFrom + ? moment(certificates.validFrom).format('L LT') + : undefined, + }, + { + name: FriendlyFlyoutLabels[Metadata.CertificateExpiryDate], + value: certificates.validTo ? moment(certificates.validTo).format('L LT') : undefined, + }, + { + name: FriendlyFlyoutLabels[Metadata.CertificateSubject], + value: certificates.subjectName, + }, + ] + : undefined, + details: [ + { name: FriendlyFlyoutLabels[Metadata.MimeType], value: mimeType }, + { + name: FriendlyFlyoutLabels[Metadata.RequestStart], + value: getFriendlyMetadataValue({ value: requestStart, postFix: 'ms' }), + }, + { + name: FriendlyTimingLabels[Timings.Dns], + value: getFriendlyMetadataValue({ value: dns, postFix: 'ms' }), + }, + { + name: FriendlyTimingLabels[Timings.Connect], + value: getFriendlyMetadataValue({ value: getConnectingTime(connect, ssl), postFix: 'ms' }), + }, + { + name: FriendlyTimingLabels[Timings.Ssl], + value: getFriendlyMetadataValue({ value: ssl, postFix: 'ms' }), + }, + { + name: FriendlyTimingLabels[Timings.Wait], + value: getFriendlyMetadataValue({ value: wait, postFix: 'ms' }), + }, + { + name: FriendlyTimingLabels[Timings.Receive], + value: getFriendlyMetadataValue({ + value: contentDownloaded, + postFix: 'ms', + }), + }, + { + name: FriendlyFlyoutLabels[Metadata.BytesDownloadedCompressed], + value: getFriendlyMetadataValue({ + value: bytesDownloadedCompressed ? bytesDownloadedCompressed / 1000 : undefined, + postFix: 'KB', + }), + }, + { + name: FriendlyFlyoutLabels[Metadata.IP], + value: ip, + }, + ], + }; }; export const getSidebarItems = ( @@ -206,7 +330,7 @@ export const getSidebarItems = ( const isHighlighted = isHighlightedItem(item, query, activeFilters); const offsetIndex = index + 1; const { url, status, method } = item; - return { url, status, method, isHighlighted, offsetIndex }; + return { url, status, method, isHighlighted, offsetIndex, index }; }); if (onlyHighlighted) { return sideBarItems.filter((item) => item.isHighlighted); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts index e22caae0d9eb2a..cedf9c667d0f28 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts @@ -18,6 +18,17 @@ export enum Timings { Receive = 'receive', } +export enum Metadata { + BytesDownloadedCompressed = 'bytesDownloadedCompressed', + CertificateIssuer = 'certificateIssuer', + CertificateIssueDate = 'certificateIssueDate', + CertificateExpiryDate = 'certificateExpiryDate', + CertificateSubject = 'certificateSubject', + IP = 'ip', + MimeType = 'mimeType', + RequestStart = 'requestStart', +} + export const FriendlyTimingLabels = { [Timings.Blocked]: i18n.translate( 'xpack.uptime.synthetics.waterfallChart.labels.timings.blocked', @@ -51,6 +62,54 @@ export const FriendlyTimingLabels = { ), }; +export const FriendlyFlyoutLabels = { + [Metadata.MimeType]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.contentType', + { + defaultMessage: 'Content type', + } + ), + [Metadata.RequestStart]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.requestStart', + { + defaultMessage: 'Request start', + } + ), + [Metadata.BytesDownloadedCompressed]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.bytesDownloadedCompressed', + { + defaultMessage: 'Bytes downloaded (compressed)', + } + ), + [Metadata.CertificateIssuer]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateIssuer', + { + defaultMessage: 'Issuer', + } + ), + [Metadata.CertificateIssueDate]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateIssueDate', + { + defaultMessage: 'Valid from', + } + ), + [Metadata.CertificateExpiryDate]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateExpiryDate', + { + defaultMessage: 'Valid until', + } + ), + [Metadata.CertificateSubject]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateSubject', + { + defaultMessage: 'Common name', + } + ), + [Metadata.IP]: i18n.translate('xpack.uptime.synthetics.waterfallChart.labels.metadata.ip', { + defaultMessage: 'IP', + }), +}; + export const TIMING_ORDER = [ Timings.Blocked, Timings.Dns, @@ -61,6 +120,19 @@ export const TIMING_ORDER = [ Timings.Receive, ] as const; +export const META_DATA_ORDER_FLYOUT = [ + Metadata.MimeType, + Timings.Dns, + Timings.Connect, + Timings.Ssl, + Timings.Wait, + Timings.Receive, +] as const; + +export type CalculatedTimings = { + [K in Timings]?: number; +}; + export enum MimeType { Html = 'html', Script = 'script', @@ -155,6 +227,7 @@ export type NetworkItems = NetworkItem[]; export type SidebarItem = Pick & { isHighlighted: boolean; + index: number; offsetIndex: number; }; export type SidebarItems = SidebarItem[]; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx index e22f4a4c63f596..47c18225f38d37 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx @@ -6,14 +6,12 @@ */ import React from 'react'; -import { act, fireEvent } from '@testing-library/react'; -import { WaterfallChartWrapper } from './waterfall_chart_wrapper'; - +import { act, fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../../../../lib/helper/rtl_helpers'; +import { WaterfallChartWrapper } from './waterfall_chart_wrapper'; +import { networkItems as mockNetworkItems } from './data_formatting.test'; import { extractItems, isHighlightedItem } from './data_formatting'; - -import 'jest-canvas-mock'; import { BAR_HEIGHT } from '../../waterfall/components/constants'; import { MimeType } from './types'; import { @@ -26,8 +24,10 @@ const getHighLightedItems = (query: string, filters: string[]) => { return NETWORK_EVENTS.events.filter((item) => isHighlightedItem(item, query, filters)); }; -describe('waterfall chart wrapper', () => { - jest.useFakeTimers(); +describe('WaterfallChartWrapper', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); it('renders the correct sidebar items', () => { const { getAllByTestId } = render( @@ -129,6 +129,69 @@ describe('waterfall chart wrapper', () => { expect(queryAllByTestId('sideBarHighlightedItem')).toHaveLength(0); expect(queryAllByTestId('sideBarDimmedItem')).toHaveLength(0); }); + + it('opens flyout on sidebar click and closes on flyout close button', async () => { + const { getByText, getAllByText, getByTestId, queryByText, getByRole } = render( + + ); + + expect(getByText(`1. ${mockNetworkItems[0].url}`)).toBeInTheDocument(); + expect(queryByText('Content type')).not.toBeInTheDocument(); + expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument(); + + // open flyout + // selecter matches both button and accessible text. Button is the second element in the array; + const sidebarButton = getAllByText(/1./)[1]; + fireEvent.click(sidebarButton); + + // check for sample flyout items + await waitFor(() => { + const waterfallFlyout = getByRole('dialog'); + expect(waterfallFlyout).toBeInTheDocument(); + expect(getByText('Content type')).toBeInTheDocument(); + expect(getByText(`${mockNetworkItems[0]?.mimeType}`)).toBeInTheDocument(); + // close flyout + const closeButton = getByTestId('euiFlyoutCloseButton'); + fireEvent.click(closeButton); + }); + + /* check that sample flyout items are gone from the DOM */ + await waitFor(() => { + expect(queryByText('Content type')).not.toBeInTheDocument(); + expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument(); + }); + }); + + it('opens flyout on sidebar click and closes on second sidebar click', async () => { + const { getByText, getAllByText, getByTestId, queryByText } = render( + + ); + + expect(getByText(`1. ${mockNetworkItems[0].url}`)).toBeInTheDocument(); + expect(queryByText('Content type')).not.toBeInTheDocument(); + expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument(); + + // open flyout + // selecter matches both button and accessible text. Button is the second element in the array; + const sidebarButton = getAllByText(/1./)[1]; + fireEvent.click(sidebarButton); + + // check for sample flyout items and that the flyout is focused + await waitFor(() => { + const waterfallFlyout = getByTestId('waterfallFlyout'); + expect(waterfallFlyout).toBeInTheDocument(); + expect(getByText('Content type')).toBeInTheDocument(); + expect(getByText(`${mockNetworkItems[0]?.mimeType}`)).toBeInTheDocument(); + }); + + fireEvent.click(sidebarButton); + + /* check that sample flyout items are gone from the DOM */ + await waitFor(() => { + expect(queryByText('Content type')).not.toBeInTheDocument(); + expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument(); + }); + }); }); const NETWORK_EVENTS = { diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx index 8a0e9729a635b0..8557837abbe466 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx @@ -7,11 +7,12 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiHealth } from '@elastic/eui'; -import { useTrackMetric, METRIC_TYPE } from '../../../../../../../observability/public'; import { getSeriesAndDomain, getSidebarItems, getLegendItems } from './data_formatting'; import { SidebarItem, LegendItem, NetworkItems } from './types'; -import { WaterfallProvider, WaterfallChart, RenderItem } from '../../waterfall'; +import { WaterfallProvider, WaterfallChart, RenderItem, useFlyout } from '../../waterfall'; +import { useTrackMetric, METRIC_TYPE } from '../../../../../../../observability/public'; import { WaterfallFilter } from './waterfall_filter'; +import { WaterfallFlyout } from './waterfall_flyout'; import { WaterfallSidebarItem } from './waterfall_sidebar_item'; export const renderLegendItem: RenderItem = (item) => { @@ -32,7 +33,7 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { const hasFilters = activeFilters.length > 0; - const { series, domain, totalHighlightedRequests } = useMemo(() => { + const { series, domain, metadata, totalHighlightedRequests } = useMemo(() => { return getSeriesAndDomain(networkData, onlyHighlighted, query, activeFilters); }, [networkData, query, activeFilters, onlyHighlighted]); @@ -40,7 +41,18 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { return getSidebarItems(networkData, onlyHighlighted, query, activeFilters); }, [networkData, query, activeFilters, onlyHighlighted]); - const legendItems = getLegendItems(); + const legendItems = useMemo(() => { + return getLegendItems(); + }, []); + + const { + flyoutData, + onBarClick, + onProjectionClick, + onSidebarClick, + isFlyoutVisible, + onFlyoutClose, + } = useFlyout(metadata); const renderFilter = useCallback(() => { return ( @@ -55,16 +67,27 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { ); }, [activeFilters, setActiveFilters, onlyHighlighted, setOnlyHighlighted, query, setQuery]); + const renderFlyout = useCallback(() => { + return ( + + ); + }, [flyoutData, isFlyoutVisible, onFlyoutClose]); + const renderSidebarItem: RenderItem = useCallback( (item) => { return ( ); }, - [hasFilters, onlyHighlighted] + [hasFilters, onlyHighlighted, onSidebarClick] ); useTrackMetric({ app: 'uptime', metric: 'waterfall_chart_view', metricType: METRIC_TYPE.COUNT }); @@ -81,17 +104,21 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { fetchedNetworkRequests={networkData.length} highlightedNetworkRequests={totalHighlightedRequests} data={series} + onElementClick={useCallback(onBarClick, [onBarClick])} + onProjectionClick={useCallback(onProjectionClick, [onProjectionClick])} + onSidebarClick={onSidebarClick} showOnlyHighlightedNetworkRequests={onlyHighlighted} sidebarItems={sidebarItems} legendItems={legendItems} - renderTooltipItem={(tooltipProps) => { + metadata={metadata} + renderTooltipItem={useCallback((tooltipProps) => { return {tooltipProps?.value}; - }} + }, [])} > `${Number(d).toFixed(0)} ms`} + tickFormat={useCallback((d: number) => `${Number(d).toFixed(0)} ms`, [])} domain={domain} - barStyleAccessor={(datum) => { + barStyleAccessor={useCallback((datum) => { if (!datum.datum.config.isHighlighted) { return { rect: { @@ -101,9 +128,10 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { }; } return datum.datum.config.colour; - }} + }, [])} renderSidebarItem={renderSidebarItem} renderLegendItem={renderLegendItem} + renderFlyout={renderFlyout} renderFilter={renderFilter} fullHeight={true} /> diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.test.tsx new file mode 100644 index 00000000000000..4028bc0821b290 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.test.tsx @@ -0,0 +1,139 @@ +/* + * 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 { render } from '../../../../../lib/helper/rtl_helpers'; +import { + WaterfallFlyout, + DETAILS, + CERTIFICATES, + REQUEST_HEADERS, + RESPONSE_HEADERS, +} from './waterfall_flyout'; +import { WaterfallMetadataEntry } from '../../waterfall/types'; + +describe('WaterfallFlyout', () => { + const flyoutData: WaterfallMetadataEntry = { + x: 0, + url: 'http://elastic.co', + requestHeaders: undefined, + responseHeaders: undefined, + certificates: undefined, + details: [ + { + name: 'Content type', + value: 'text/html', + }, + ], + }; + + const defaultProps = { + flyoutData, + isFlyoutVisible: true, + onFlyoutClose: () => null, + }; + + it('displays flyout information and omits sections that are undefined', () => { + const { getByText, queryByText } = render(); + + expect(getByText(flyoutData.url)).toBeInTheDocument(); + expect(queryByText(DETAILS)).toBeInTheDocument(); + flyoutData.details.forEach((detail) => { + expect(getByText(detail.name)).toBeInTheDocument(); + expect(getByText(`${detail.value}`)).toBeInTheDocument(); + }); + + expect(queryByText(CERTIFICATES)).not.toBeInTheDocument(); + expect(queryByText(REQUEST_HEADERS)).not.toBeInTheDocument(); + expect(queryByText(RESPONSE_HEADERS)).not.toBeInTheDocument(); + }); + + it('displays flyout certificates information', () => { + const certificates = [ + { + name: 'Issuer', + value: 'Sample Issuer', + }, + { + name: 'Valid From', + value: 'January 1, 2020 7:00PM', + }, + { + name: 'Valid Until', + value: 'January 31, 2020 7:00PM', + }, + { + name: 'Common Name', + value: '*.elastic.co', + }, + ]; + const flyoutDataWithCertificates = { + ...flyoutData, + certificates, + }; + + const { getByText } = render( + + ); + + expect(getByText(flyoutData.url)).toBeInTheDocument(); + expect(getByText(DETAILS)).toBeInTheDocument(); + expect(getByText(CERTIFICATES)).toBeInTheDocument(); + flyoutData.certificates?.forEach((detail) => { + expect(getByText(detail.name)).toBeInTheDocument(); + expect(getByText(`${detail.value}`)).toBeInTheDocument(); + }); + }); + + it('displays flyout request and response headers information', () => { + const requestHeaders = [ + { + name: 'sample_request_header', + value: 'Sample Request Header value', + }, + ]; + const responseHeaders = [ + { + name: 'sample_response_header', + value: 'sample response header value', + }, + ]; + const flyoutDataWithHeaders = { + ...flyoutData, + requestHeaders, + responseHeaders, + }; + const { getByText } = render( + + ); + + expect(getByText(flyoutData.url)).toBeInTheDocument(); + expect(getByText(DETAILS)).toBeInTheDocument(); + expect(getByText(REQUEST_HEADERS)).toBeInTheDocument(); + expect(getByText(RESPONSE_HEADERS)).toBeInTheDocument(); + flyoutData.requestHeaders?.forEach((detail) => { + expect(getByText(detail.name)).toBeInTheDocument(); + expect(getByText(`${detail.value}`)).toBeInTheDocument(); + }); + flyoutData.responseHeaders?.forEach((detail) => { + expect(getByText(detail.name)).toBeInTheDocument(); + expect(getByText(`${detail.value}`)).toBeInTheDocument(); + }); + }); + + it('renders null when isFlyoutVisible is false', () => { + const { queryByText } = render(); + + expect(queryByText(flyoutData.url)).not.toBeInTheDocument(); + }); + + it('renders null when flyoutData is undefined', () => { + const { queryByText } = render(); + + expect(queryByText(flyoutData.url)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.tsx new file mode 100644 index 00000000000000..4f92c882340b94 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.tsx @@ -0,0 +1,125 @@ +/* + * 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, useRef } from 'react'; + +import styled from 'styled-components'; + +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiTitle, + EuiSpacer, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Table } from '../../waterfall/components/waterfall_flyout_table'; +import { MiddleTruncatedText } from '../../waterfall'; +import { WaterfallMetadataEntry } from '../../waterfall/types'; +import { OnFlyoutClose } from '../../waterfall/components/use_flyout'; +import { METRIC_TYPE, useUiTracker } from '../../../../../../../observability/public'; + +export const DETAILS = i18n.translate('xpack.uptime.synthetics.waterfall.flyout.details', { + defaultMessage: 'Details', +}); + +export const CERTIFICATES = i18n.translate( + 'xpack.uptime.synthetics.waterfall.flyout.certificates', + { + defaultMessage: 'Certificate headers', + } +); + +export const REQUEST_HEADERS = i18n.translate( + 'xpack.uptime.synthetics.waterfall.flyout.requestHeaders', + { + defaultMessage: 'Request headers', + } +); + +export const RESPONSE_HEADERS = i18n.translate( + 'xpack.uptime.synthetics.waterfall.flyout.responseHeaders', + { + defaultMessage: 'Response headers', + } +); + +const FlyoutContainer = styled(EuiFlyout)` + z-index: ${(props) => props.theme.eui.euiZLevel5}; +`; + +export interface WaterfallFlyoutProps { + flyoutData?: WaterfallMetadataEntry; + onFlyoutClose: OnFlyoutClose; + isFlyoutVisible?: boolean; +} + +export const WaterfallFlyout = ({ + flyoutData, + isFlyoutVisible, + onFlyoutClose, +}: WaterfallFlyoutProps) => { + const flyoutRef = useRef(null); + const trackMetric = useUiTracker({ app: 'uptime' }); + + useEffect(() => { + if (isFlyoutVisible && flyoutData && flyoutRef.current) { + flyoutRef.current?.focus(); + } + }, [flyoutData, isFlyoutVisible, flyoutRef]); + + if (!flyoutData || !isFlyoutVisible) { + return null; + } + + const { url, details, certificates, requestHeaders, responseHeaders } = flyoutData; + + trackMetric({ metric: 'waterfall_flyout', metricType: METRIC_TYPE.CLICK }); + + return ( +
+ + + +

+ + + +

+
+
+ + + {!!requestHeaders && ( + <> + +
+ + )} + {!!responseHeaders && ( + <> + +
+ + )} + {!!certificates && ( + <> + +
+ + )} + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx index 25b577ef9403aa..f9d56422ba75cb 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx @@ -5,20 +5,35 @@ * 2.0. */ -import React from 'react'; +import React, { RefObject, useMemo, useCallback, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; import { SidebarItem } from '../waterfall/types'; import { MiddleTruncatedText } from '../../waterfall'; import { SideBarItemHighlighter } from '../../waterfall/components/styles'; import { SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL } from '../../waterfall/components/translations'; +import { OnSidebarClick } from '../../waterfall/components/use_flyout'; interface SidebarItemProps { item: SidebarItem; renderFilterScreenReaderText?: boolean; + onClick?: OnSidebarClick; } -export const WaterfallSidebarItem = ({ item, renderFilterScreenReaderText }: SidebarItemProps) => { - const { status, offsetIndex, isHighlighted } = item; +export const WaterfallSidebarItem = ({ + item, + renderFilterScreenReaderText, + onClick, +}: SidebarItemProps) => { + const [buttonRef, setButtonRef] = useState>(); + const { status, offsetIndex, index, isHighlighted, url } = item; + + const handleSidebarClick = useMemo(() => { + if (onClick) { + return () => onClick({ buttonRef, networkItemIndex: index }); + } + }, [buttonRef, index, onClick]); + + const setRef = useCallback((ref) => setButtonRef(ref), [setButtonRef]); const isErrorStatusCode = (statusCode: number) => { const is400 = statusCode >= 400 && statusCode <= 499; @@ -40,11 +55,23 @@ export const WaterfallSidebarItem = ({ item, renderFilterScreenReaderText }: Sid data-test-subj={isHighlighted ? 'sideBarHighlightedItem' : 'sideBarDimmedItem'} > {!status || !isErrorStatusCode(status) ? ( - + ) : ( - - - + + + {status} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx index 578d66a1ea3f1d..7f32cac92bd9fc 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx @@ -6,20 +6,22 @@ */ import React from 'react'; -import { SidebarItem } from '../waterfall/types'; +import 'jest-canvas-mock'; +import { fireEvent } from '@testing-library/react'; +import { SidebarItem } from '../waterfall/types'; import { render } from '../../../../../lib/helper/rtl_helpers'; - -import 'jest-canvas-mock'; import { WaterfallSidebarItem } from './waterfall_sidebar_item'; import { SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL } from '../../waterfall/components/translations'; describe('waterfall filter', () => { const url = 'http://www.elastic.co'; - const offsetIndex = 1; + const index = 0; + const offsetIndex = index + 1; const item: SidebarItem = { url, isHighlighted: true, + index, offsetIndex, }; @@ -40,12 +42,14 @@ describe('waterfall filter', () => { }); it('does not render screen reader text when renderFilterScreenReaderText is false', () => { - const { queryByLabelText } = render( - + const onClick = jest.fn(); + const { getByRole } = render( + ); + const button = getByRole('button'); + fireEvent.click(button); - expect( - queryByLabelText(`${SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL} ${offsetIndex}. ${url}`) - ).not.toBeInTheDocument(); + expect(button).toBeInTheDocument(); + expect(onClick).toBeCalled(); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx index d6c1d777a40a78..de352186e26fda 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx @@ -6,7 +6,7 @@ */ import { getChunks, MiddleTruncatedText } from './middle_truncated_text'; -import { render, within } from '@testing-library/react'; +import { render, within, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; const longString = @@ -25,9 +25,10 @@ describe('getChunks', () => { }); describe('Component', () => { + const url = 'http://www.elastic.co'; it('renders truncated text and aria label', () => { const { getByText, getByLabelText } = render( - + ); expect(getByText(first)).toBeInTheDocument(); @@ -38,11 +39,39 @@ describe('Component', () => { it('renders screen reader only text', () => { const { getByTestId } = render( - + ); const { getByText } = within(getByTestId('middleTruncatedTextSROnly')); expect(getByText(longString)).toBeInTheDocument(); }); + + it('renders external link', () => { + const { getByText } = render( + + ); + const link = getByText('Open resource in new tab').closest('a'); + + expect(link).toHaveAttribute('href', url); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('renders a button when onClick function is passed', async () => { + const handleClick = jest.fn(); + const { getByTestId } = render( + + ); + const button = getByTestId('middleTruncatedTextButton'); + fireEvent.click(button); + + await waitFor(() => { + expect(handleClick).toBeCalled(); + }); + }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx index ec363ed2b40a4e..a0993d54bbd074 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx @@ -7,41 +7,57 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { EuiScreenReaderOnly, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiScreenReaderOnly, EuiToolTip, EuiButtonEmpty, EuiLink } from '@elastic/eui'; import { FIXED_AXIS_HEIGHT } from './constants'; interface Props { ariaLabel: string; text: string; + onClick?: (event: React.MouseEvent) => void; + setButtonRef?: (ref: HTMLButtonElement | HTMLAnchorElement | null) => void; + url: string; } -const OuterContainer = styled.div` - width: 100%; - height: 100%; +const OuterContainer = styled.span` position: relative; -`; + display: inline-flex; + align-items: center; + .euiToolTipAnchor { + min-width: 0; + } +`; // NOTE: min-width: 0 ensures flexbox and no-wrap children can co-exist const InnerContainer = styled.span` - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; overflow: hidden; display: flex; - min-width: 0; -`; // NOTE: min-width: 0 ensures flexbox and no-wrap children can co-exist + align-items: center; +`; const FirstChunk = styled.span` text-overflow: ellipsis; white-space: nowrap; overflow: hidden; line-height: ${FIXED_AXIS_HEIGHT}px; -`; + text-align: left; +`; // safari doesn't auto align text left in some cases const LastChunk = styled.span` flex-shrink: 0; line-height: ${FIXED_AXIS_HEIGHT}px; + text-align: left; +`; // safari doesn't auto align text left in some cases + +const StyledButton = styled(EuiButtonEmpty)` + &&& { + height: auto; + border: none; + + .euiButtonContent { + display: inline-block; + padding: 0; + } + } `; export const getChunks = (text: string) => { @@ -55,24 +71,49 @@ export const getChunks = (text: string) => { // Helper component for adding middle text truncation, e.g. // really-really-really-long....ompressed.js // Can be used to accomodate content in sidebar item rendering. -export const MiddleTruncatedText = ({ ariaLabel, text }: Props) => { +export const MiddleTruncatedText = ({ ariaLabel, text, onClick, setButtonRef, url }: Props) => { const chunks = useMemo(() => { return getChunks(text); }, [text]); return ( - <> - - - {text} - - - - {chunks.first} - {chunks.last} - - - - + + + {text} + + + <> + {onClick ? ( + + + {chunks.first} + {chunks.last} + + + ) : ( + + {chunks.first} + {chunks.last} + + )} + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx index 86ab4488cca938..0e57a210f032ac 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx @@ -5,15 +5,15 @@ * 2.0. */ -import React from 'react'; -import { EuiFlexItem } from '@elastic/eui'; +import React, { useMemo } from 'react'; import { FIXED_AXIS_HEIGHT, SIDEBAR_GROW_SIZE } from './constants'; -import { IWaterfallContext } from '../context/waterfall_chart'; +import { IWaterfallContext, useWaterfallContext } from '../context/waterfall_chart'; import { WaterfallChartSidebarContainer, WaterfallChartSidebarContainerInnerPanel, WaterfallChartSidebarContainerFlexGroup, WaterfallChartSidebarFlexItem, + WaterfallChartSidebarWrapper, } from './styles'; import { WaterfallChartProps } from './waterfall_chart'; @@ -23,8 +23,11 @@ interface SidebarProps { } export const Sidebar: React.FC = ({ items, render }) => { + const { onSidebarClick } = useWaterfallContext(); + const handleSidebarClick = useMemo(() => onSidebarClick, [onSidebarClick]); + return ( - + = ({ items, render }) => { gutterSize="none" responsive={false} > - {items.map((item) => ( - - {render(item)} - - ))} + {items.map((item, index) => { + return ( + + {render(item, index, handleSidebarClick)} + + ); + })} - + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts index 9177902f8a613b..c0a75e0e09b223 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts @@ -5,12 +5,12 @@ * 2.0. */ -import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText, EuiPanelProps } from '@elastic/eui'; -import { rgba } from 'polished'; import { FunctionComponent } from 'react'; import { StyledComponent } from 'styled-components'; +import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText, EuiPanelProps } from '@elastic/eui'; +import { rgba } from 'polished'; +import { FIXED_AXIS_HEIGHT, SIDEBAR_GROW_SIZE } from './constants'; import { euiStyled, EuiTheme } from '../../../../../../../../../src/plugins/kibana_react/common'; -import { FIXED_AXIS_HEIGHT } from './constants'; interface WaterfallChartOuterContainerProps { height?: string; @@ -82,6 +82,11 @@ interface WaterfallChartSidebarContainer { height: number; } +export const WaterfallChartSidebarWrapper = euiStyled(EuiFlexItem)` + max-width: ${SIDEBAR_GROW_SIZE * 10}%; + z-index: ${(props) => props.theme.eui.euiZLevel5}; +`; + export const WaterfallChartSidebarContainer = euiStyled.div` height: ${(props) => `${props.height}px`}; overflow-y: hidden; @@ -104,10 +109,10 @@ export const WaterfallChartSidebarFlexItem = euiStyled(EuiFlexItem)` min-width: 0; padding-left: ${(props) => props.theme.eui.paddingSizes.m}; padding-right: ${(props) => props.theme.eui.paddingSizes.m}; - z-index: ${(props) => props.theme.eui.euiZLevel4}; + justify-content: space-around; `; -export const SideBarItemHighlighter = euiStyled.span<{ isHighlighted: boolean }>` +export const SideBarItemHighlighter = euiStyled(EuiFlexItem)<{ isHighlighted: boolean }>` opacity: ${(props) => (props.isHighlighted ? 1 : 0.4)}; height: 100%; `; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.test.tsx new file mode 100644 index 00000000000000..5b388874d508e3 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useFlyout } from './use_flyout'; +import { IWaterfallContext } from '../context/waterfall_chart'; + +import { ProjectedValues, XYChartElementEvent } from '@elastic/charts'; + +describe('useFlyoutHook', () => { + const metadata: IWaterfallContext['metadata'] = [ + { + x: 0, + url: 'http://elastic.co', + requestHeaders: undefined, + responseHeaders: undefined, + certificates: undefined, + details: [ + { + name: 'Content type', + value: 'text/html', + }, + ], + }, + ]; + + it('sets isFlyoutVisible to true and sets flyoutData when calling onSidebarClick', () => { + const index = 0; + const { result } = renderHook((props) => useFlyout(props.metadata), { + initialProps: { metadata }, + }); + + expect(result.current.isFlyoutVisible).toBe(false); + + act(() => { + result.current.onSidebarClick({ buttonRef: { current: null }, networkItemIndex: index }); + }); + + expect(result.current.isFlyoutVisible).toBe(true); + expect(result.current.flyoutData).toEqual(metadata[index]); + }); + + it('sets isFlyoutVisible to true and sets flyoutData when calling onBarClick', () => { + const index = 0; + const elementData = [ + { + datum: { + config: { + id: index, + }, + }, + }, + {}, + ]; + + const { result } = renderHook((props) => useFlyout(props.metadata), { + initialProps: { metadata }, + }); + + expect(result.current.isFlyoutVisible).toBe(false); + + act(() => { + result.current.onBarClick([elementData as XYChartElementEvent]); + }); + + expect(result.current.isFlyoutVisible).toBe(true); + expect(result.current.flyoutData).toEqual(metadata[0]); + }); + + it('sets isFlyoutVisible to true and sets flyoutData when calling onProjectionClick', () => { + const index = 0; + const geometry = { x: index }; + + const { result } = renderHook((props) => useFlyout(props.metadata), { + initialProps: { metadata }, + }); + + expect(result.current.isFlyoutVisible).toBe(false); + + act(() => { + result.current.onProjectionClick(geometry as ProjectedValues); + }); + + expect(result.current.isFlyoutVisible).toBe(true); + expect(result.current.flyoutData).toEqual(metadata[0]); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.ts new file mode 100644 index 00000000000000..206fc588c3053b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.ts @@ -0,0 +1,93 @@ +/* + * 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 { RefObject, useCallback, useState } from 'react'; + +import { + ElementClickListener, + ProjectionClickListener, + ProjectedValues, + XYChartElementEvent, +} from '@elastic/charts'; + +import { WaterfallMetadata, WaterfallMetadataEntry } from '../types'; + +interface OnSidebarClickParams { + buttonRef?: ButtonRef; + networkItemIndex: number; +} + +export type ButtonRef = RefObject; +export type OnSidebarClick = (params: OnSidebarClickParams) => void; +export type OnProjectionClick = ProjectionClickListener; +export type OnElementClick = ElementClickListener; +export type OnFlyoutClose = () => void; + +export const useFlyout = (metadata: WaterfallMetadata) => { + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [flyoutData, setFlyoutData] = useState(undefined); + const [currentSidebarItemRef, setCurrentSidebarItemRef] = useState< + RefObject + >(); + + const handleFlyout = useCallback( + (flyoutEntry: WaterfallMetadataEntry) => { + setFlyoutData(flyoutEntry); + setIsFlyoutVisible(true); + }, + [setIsFlyoutVisible, setFlyoutData] + ); + + const onFlyoutClose = useCallback(() => { + setIsFlyoutVisible(false); + currentSidebarItemRef?.current?.focus(); + }, [currentSidebarItemRef, setIsFlyoutVisible]); + + const onBarClick: ElementClickListener = useCallback( + ([elementData]) => { + setIsFlyoutVisible(false); + const { datum } = (elementData as XYChartElementEvent)[0]; + const metadataEntry = metadata[datum.config.id]; + handleFlyout(metadataEntry); + }, + [metadata, handleFlyout] + ); + + const onProjectionClick: ProjectionClickListener = useCallback( + (projectionData) => { + setIsFlyoutVisible(false); + const { x } = projectionData as ProjectedValues; + if (typeof x === 'number' && x >= 0) { + const metadataEntry = metadata[x]; + handleFlyout(metadataEntry); + } + }, + [metadata, handleFlyout] + ); + + const onSidebarClick: OnSidebarClick = useCallback( + ({ buttonRef, networkItemIndex }) => { + if (isFlyoutVisible && buttonRef === currentSidebarItemRef) { + setIsFlyoutVisible(false); + } else { + const metadataEntry = metadata[networkItemIndex]; + setCurrentSidebarItemRef(buttonRef); + handleFlyout(metadataEntry); + } + }, + [currentSidebarItemRef, handleFlyout, isFlyoutVisible, metadata, setIsFlyoutVisible] + ); + + return { + flyoutData, + onBarClick, + onProjectionClick, + onSidebarClick, + isFlyoutVisible, + onFlyoutClose, + }; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx index df00df147fc6c5..19a828aa097b6f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo, useCallback } from 'react'; import { Axis, BarSeries, @@ -67,6 +67,10 @@ export const WaterfallBarChart = ({ index, }: Props) => { const theme = useChartTheme(); + const { onElementClick, onProjectionClick } = useWaterfallContext(); + const handleElementClick = useMemo(() => onElementClick, [onElementClick]); + const handleProjectionClick = useMemo(() => onProjectionClick, [onProjectionClick]); + const memoizedTickFormat = useCallback(tickFormat, [tickFormat]); return ( = (item: I, index?: number) => JSX.Element; -export type RenderFilter = () => JSX.Element; +export type RenderItem = ( + item: I, + index: number, + onClick?: (event: any) => void +) => JSX.Element; +export type RenderElement = () => JSX.Element; export interface WaterfallChartProps { tickFormat: TickFormatter; @@ -36,7 +40,8 @@ export interface WaterfallChartProps { barStyleAccessor: BarStyleAccessor; renderSidebarItem?: RenderItem; renderLegendItem?: RenderItem; - renderFilter?: RenderFilter; + renderFilter?: RenderElement; + renderFlyout?: RenderElement; maxHeight?: string; fullHeight?: boolean; } @@ -48,6 +53,7 @@ export const WaterfallChart = ({ renderSidebarItem, renderLegendItem, renderFilter, + renderFlyout, maxHeight = '800px', fullHeight = false, }: WaterfallChartProps) => { @@ -82,7 +88,7 @@ export const WaterfallChart = ({ {shouldRenderSidebar && ( - + {renderFilter()} )} - + )} {shouldRenderLegend && } + {renderFlyout && renderFlyout()} ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_flyout_table.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_flyout_table.tsx new file mode 100644 index 00000000000000..8f723eb92fd947 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_flyout_table.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +import { EuiText, EuiBasicTable, EuiSpacer } from '@elastic/eui'; + +interface Row { + name: string; + value?: string; +} + +interface Props { + rows: Row[]; + title: string; +} + +const StyledText = styled(EuiText)` + width: 100%; +`; + +class TableWithoutHeader extends EuiBasicTable { + renderTableHead() { + return <>; + } +} + +export const Table = (props: Props) => { + const { rows, title } = props; + const columns = useMemo( + () => [ + { + field: 'name', + name: '', + sortable: false, + render: (_name: string, item: Row) => ( + + {item.name} + + ), + }, + { + field: 'value', + name: '', + sortable: false, + render: (_name: string, item: Row) => { + return ( + + {item.value ?? '--'} + + ); + }, + }, + ], + [] + ); + + return ( + <> + +

{title}

+
+ + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx index 9e87d69ce38a82..b960491162010c 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx @@ -6,7 +6,8 @@ */ import React, { createContext, useContext, Context } from 'react'; -import { WaterfallData, WaterfallDataEntry } from '../types'; +import { WaterfallData, WaterfallDataEntry, WaterfallMetadata } from '../types'; +import { OnSidebarClick, OnElementClick, OnProjectionClick } from '../components/use_flyout'; import { SidebarItems } from '../../step_detail/waterfall/types'; export interface IWaterfallContext { @@ -14,9 +15,13 @@ export interface IWaterfallContext { highlightedNetworkRequests: number; fetchedNetworkRequests: number; data: WaterfallData; + onElementClick?: OnElementClick; + onProjectionClick?: OnProjectionClick; + onSidebarClick?: OnSidebarClick; showOnlyHighlightedNetworkRequests: boolean; sidebarItems?: SidebarItems; legendItems?: unknown[]; + metadata: WaterfallMetadata; renderTooltipItem: ( item: WaterfallDataEntry['config']['tooltipProps'], index?: number @@ -30,18 +35,26 @@ interface ProviderProps { highlightedNetworkRequests: number; fetchedNetworkRequests: number; data: IWaterfallContext['data']; + onElementClick?: IWaterfallContext['onElementClick']; + onProjectionClick?: IWaterfallContext['onProjectionClick']; + onSidebarClick?: IWaterfallContext['onSidebarClick']; showOnlyHighlightedNetworkRequests: IWaterfallContext['showOnlyHighlightedNetworkRequests']; sidebarItems?: IWaterfallContext['sidebarItems']; legendItems?: IWaterfallContext['legendItems']; + metadata: IWaterfallContext['metadata']; renderTooltipItem: IWaterfallContext['renderTooltipItem']; } export const WaterfallProvider: React.FC = ({ children, data, + onElementClick, + onProjectionClick, + onSidebarClick, showOnlyHighlightedNetworkRequests, sidebarItems, legendItems, + metadata, renderTooltipItem, totalNetworkRequests, highlightedNetworkRequests, @@ -54,6 +67,10 @@ export const WaterfallProvider: React.FC = ({ showOnlyHighlightedNetworkRequests, sidebarItems, legendItems, + metadata, + onElementClick, + onProjectionClick, + onSidebarClick, renderTooltipItem, totalNetworkRequests, highlightedNetworkRequests, diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/index.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/index.tsx index 5a6daa30450d13..0de1b50ecce8f7 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/index.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/index.tsx @@ -8,4 +8,10 @@ export { WaterfallChart, RenderItem, WaterfallChartProps } from './components/waterfall_chart'; export { WaterfallProvider, useWaterfallContext } from './context/waterfall_chart'; export { MiddleTruncatedText } from './components/middle_truncated_text'; -export { WaterfallData, WaterfallDataEntry } from './types'; +export { useFlyout } from './components/use_flyout'; +export { + WaterfallData, + WaterfallDataEntry, + WaterfallMetadata, + WaterfallMetadataEntry, +} from './types'; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/types.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/types.ts index 6cffc3a2df382d..f1775a6fd18921 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/types.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/types.ts @@ -16,8 +16,26 @@ export interface WaterfallDataSeriesConfigProperties { showTooltip: boolean; } +export interface WaterfallMetadataItem { + name: string; + value?: string; +} + +export interface WaterfallMetadataEntry { + x: number; + url: string; + requestHeaders?: WaterfallMetadataItem[]; + responseHeaders?: WaterfallMetadataItem[]; + certificates?: WaterfallMetadataItem[]; + details: WaterfallMetadataItem[]; +} + export type WaterfallDataEntry = PlotProperties & { config: WaterfallDataSeriesConfigProperties & Record; }; +export type WaterfallMetadata = WaterfallMetadataEntry[]; + export type WaterfallData = WaterfallDataEntry[]; + +export type RenderItem = (item: I, index: number) => JSX.Element; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts index f5b6d21d40d8f2..9d4e42337fd756 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts @@ -239,11 +239,43 @@ describe('getNetworkEvents', () => { Object { "events": Array [ Object { + "bytesDownloadedCompressed": 337, + "certificates": Object { + "issuer": "DigiCert TLS RSA SHA256 2020 CA1", + "subjectName": "syndication.twitter.com", + "validFrom": 1606694400000, + "validTo": 1638230399000, + }, + "ip": "104.244.42.200", "loadEndTime": 3287298.251, "method": "GET", "mimeType": "image/gif", + "requestHeaders": Object { + "referer": "www.test.com", + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/88.0.4324.0 Safari/537.36", + }, "requestSentTime": 3287154.973, "requestStartTime": 3287155.502, + "responseHeaders": Object { + "cache_control": "no-cache, no-store, must-revalidate, pre-check=0, post-check=0", + "content_encoding": "gzip", + "content_length": "65", + "content_type": "image/gif;charset=utf-8", + "date": "Mon, 14 Dec 2020 10:46:39 GMT", + "expires": "Tue, 31 Mar 1981 05:00:00 GMT", + "last_modified": "Mon, 14 Dec 2020 10:46:39 GMT", + "pragma": "no-cache", + "server": "tsa_f", + "status": "200 OK", + "strict_transport_security": "max-age=631138519", + "x_connection_hash": "cb6fe99b8676f4e4b827cc3e6512c90d", + "x_content_type_options": "nosniff", + "x_frame_options": "SAMEORIGIN", + "x_response_time": "108", + "x_transaction": "008fff3d00a1e64c", + "x_twitter_response_tags": "BouncerCompliant", + "x_xss_protection": "0", + }, "status": 200, "timestamp": "2020-12-14T10:46:39.183Z", "timings": Object { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts b/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts index fa76da00253051..970af80576cad8 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts @@ -50,6 +50,7 @@ export const getNetworkEvents: UMElasticsearchQueryFn< event._source.synthetics.payload.response.timing ? secondsToMillis(event._source.synthetics.payload.response.timing.request_time) : undefined; + const securityDetails = event._source.synthetics.payload.response?.security_details; return { timestamp: event._source['@timestamp'], @@ -61,6 +62,22 @@ export const getNetworkEvents: UMElasticsearchQueryFn< requestStartTime, loadEndTime, timings: event._source.synthetics.payload.timings, + bytesDownloadedCompressed: event._source.synthetics.payload.response?.encoded_data_length, + certificates: securityDetails + ? { + issuer: securityDetails.issuer, + subjectName: securityDetails.subject_name, + validFrom: securityDetails.valid_from + ? secondsToMillis(securityDetails.valid_from) + : undefined, + validTo: securityDetails.valid_to + ? secondsToMillis(securityDetails.valid_to) + : undefined, + } + : undefined, + requestHeaders: event._source.synthetics.payload.request?.headers, + responseHeaders: event._source.synthetics.payload.response?.headers, + ip: event._source.synthetics.payload.response?.remote_i_p_address, }; }), }; From a96aa5e34ebc76997fa118489a73aa01d1845771 Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Thu, 11 Feb 2021 15:01:41 -0800 Subject: [PATCH 002/175] Unskip and Fix maps x-pack functional test - ( sample data and layer_errors) and also added baseline images (#91205) * fixes https://github.com/elastic/kibana/issues/74449 * unskip maps tests (#90323) * unskip maps tests * checking the baseline images * updated the test to move the mouse away and close the Legend * more changes to the test * reducing the threshold limit * updating the baseline images * added a comment about the baseline images * updating flights baseline image and adjusting threshold * updated threshold and baseline image for web log * session image of weblogsmap * skipping layer_errors test * skip the test - as it fails on cloud and windows on snapshot --- .../sources/ems_tms_source/ems_tms_source.js | 2 +- .../test/functional/apps/maps/layer_errors.js | 15 ++- .../test/functional/apps/maps/sample_data.js | 109 ++++++++++++++---- .../screenshots/baseline/ecommerce_map.png | Bin 59752 -> 68442 bytes .../screenshots/baseline/flights_map.png | Bin 108541 -> 104337 bytes .../screenshots/baseline/web_logs_map.png | Bin 73803 -> 134984 bytes 6 files changed, 95 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.js b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.js index 71476be2f9c2c0..f2216f2afd2da4 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.js +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.js @@ -101,7 +101,7 @@ export class EMSTMSSource extends AbstractTMSSource { return tmsService; } - throw new Error(getErrorInfo()); + throw new Error(getErrorInfo(emsTileLayerId)); } async getDisplayName() { diff --git a/x-pack/test/functional/apps/maps/layer_errors.js b/x-pack/test/functional/apps/maps/layer_errors.js index c3f231ae125c65..64973461c107b2 100644 --- a/x-pack/test/functional/apps/maps/layer_errors.js +++ b/x-pack/test/functional/apps/maps/layer_errors.js @@ -10,7 +10,6 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects }) { const PageObjects = getPageObjects(['maps', 'header']); - // Failing: See https://github.com/elastic/kibana/issues/69617 describe.skip('layer errors', () => { before(async () => { await PageObjects.maps.loadSavedMap('layer with errors'); @@ -66,14 +65,15 @@ export default function ({ getPageObjects }) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/36011 - describe.skip('EMSFileSource with missing EMS id', () => { + describe('EMSFileSource with missing EMS id', () => { const MISSING_EMS_ID = 'idThatDoesNotExitForEMSFileSource'; const LAYER_NAME = 'EMS_vector_shapes'; it('should diplay error message in layer panel', async () => { const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME); - expect(errorMsg).to.equal(`Unable to find EMS vector shapes for id: ${MISSING_EMS_ID}`); + expect(errorMsg).to.equal( + `Unable to find EMS vector shapes for id: ${MISSING_EMS_ID}. Kibana is unable to access Elastic Maps Service. Contact your system administrator.` + ); }); it('should allow deletion of layer', async () => { @@ -87,10 +87,13 @@ export default function ({ getPageObjects }) { const MISSING_EMS_ID = 'idThatDoesNotExitForEMSTile'; const LAYER_NAME = 'EMS_tiles'; - it('should diplay error message in layer panel', async () => { + // Flaky test on cloud and windows when run against a snapshot build of 7.11. + // https://github.com/elastic/kibana/issues/91043 + + it.skip('should diplay error message in layer panel', async () => { const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME); expect(errorMsg).to.equal( - `Unable to find EMS tile configuration for id: ${MISSING_EMS_ID}` + `Unable to find EMS tile configuration for id: ${MISSING_EMS_ID}. Kibana is unable to access Elastic Maps Service. Contact your system administrator.` ); }); diff --git a/x-pack/test/functional/apps/maps/sample_data.js b/x-pack/test/functional/apps/maps/sample_data.js index 602b5877bcf156..0c0af2affe50b9 100644 --- a/x-pack/test/functional/apps/maps/sample_data.js +++ b/x-pack/test/functional/apps/maps/sample_data.js @@ -6,29 +6,82 @@ */ import expect from '@kbn/expect'; +import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; export default function ({ getPageObjects, getService, updateBaselines }) { const PageObjects = getPageObjects(['common', 'maps', 'header', 'home', 'timePicker']); const screenshot = getService('screenshots'); + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); - // FLAKY: https://github.com/elastic/kibana/issues/38137 - describe.skip('maps loaded from sample data', () => { - // Sample data is shifted to be relative to current time - // This means that a static timerange will return different documents - // Setting the time range to a window larger than the sample data set - // ensures all documents are coverered by time query so the ES results will always be the same - async function setTimerangeToCoverAllSampleData() { - const past = new Date(); - past.setMonth(past.getMonth() - 6); - const future = new Date(); - future.setMonth(future.getMonth() + 6); - await PageObjects.maps.setAbsoluteRange( - PageObjects.timePicker.formatDateToAbsoluteTimeString(past), - PageObjects.timePicker.formatDateToAbsoluteTimeString(future) - ); - } + // Only update the baseline images from Jenkins session images after comparing them + // These tests might fail locally because of scaling factors and resolution. + + describe('maps loaded from sample data', () => { + before(async () => { + const SAMPLE_DATA_RANGE = `[ + { + "from": "now-30d", + "to": "now+40d", + "display": "sample data range" + }, + { + "from": "now/d", + "to": "now/d", + "display": "Today" + }, + { + "from": "now/w", + "to": "now/w", + "display": "This week" + }, + { + "from": "now-15m", + "to": "now", + "display": "Last 15 minutes" + }, + { + "from": "now-30m", + "to": "now", + "display": "Last 30 minutes" + }, + { + "from": "now-1h", + "to": "now", + "display": "Last 1 hour" + }, + { + "from": "now-24h", + "to": "now", + "display": "Last 24 hours" + }, + { + "from": "now-7d", + "to": "now", + "display": "Last 7 days" + }, + { + "from": "now-30d", + "to": "now", + "display": "Last 30 days" + }, + { + "from": "now-90d", + "to": "now", + "display": "Last 90 days" + }, + { + "from": "now-1y", + "to": "now", + "display": "Last 1 year" + } + ]`; + + await kibanaServer.uiSettings.update({ + [UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: SAMPLE_DATA_RANGE, + }); + }); - // Skipped because EMS vectors are not accessible in CI describe('ecommerce', () => { before(async () => { await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { @@ -42,8 +95,11 @@ export default function ({ getPageObjects, getService, updateBaselines }) { await PageObjects.maps.toggleLayerVisibility('France'); await PageObjects.maps.toggleLayerVisibility('United States'); await PageObjects.maps.toggleLayerVisibility('World Countries'); - await setTimerangeToCoverAllSampleData(); + await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); await PageObjects.maps.enterFullScreen(); + await PageObjects.maps.closeLegend(); + const mapContainerElement = await testSubjects.find('mapContainer'); + await mapContainerElement.moveMouseTo({ xOffset: 0, yOffset: 0 }); }); after(async () => { @@ -60,7 +116,7 @@ export default function ({ getPageObjects, getService, updateBaselines }) { 'ecommerce_map', updateBaselines ); - expect(percentDifference).to.be.lessThan(0.05); + expect(percentDifference).to.be.lessThan(0.02); }); }); @@ -73,8 +129,11 @@ export default function ({ getPageObjects, getService, updateBaselines }) { await PageObjects.home.addSampleDataSet('flights'); await PageObjects.maps.loadSavedMap('[Flights] Origin and Destination Flight Time'); await PageObjects.maps.toggleLayerVisibility('Road map'); - await setTimerangeToCoverAllSampleData(); + await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); await PageObjects.maps.enterFullScreen(); + await PageObjects.maps.closeLegend(); + const mapContainerElement = await testSubjects.find('mapContainer'); + await mapContainerElement.moveMouseTo({ xOffset: 0, yOffset: 0 }); }); after(async () => { @@ -91,11 +150,10 @@ export default function ({ getPageObjects, getService, updateBaselines }) { 'flights_map', updateBaselines ); - expect(percentDifference).to.be.lessThan(0.05); + expect(percentDifference).to.be.lessThan(0.02); }); }); - // Skipped because EMS vectors are not accessible in CI describe('web logs', () => { before(async () => { await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { @@ -106,8 +164,11 @@ export default function ({ getPageObjects, getService, updateBaselines }) { await PageObjects.maps.loadSavedMap('[Logs] Total Requests and Bytes'); await PageObjects.maps.toggleLayerVisibility('Road map'); await PageObjects.maps.toggleLayerVisibility('Total Requests by Country'); - await setTimerangeToCoverAllSampleData(); + await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); await PageObjects.maps.enterFullScreen(); + await PageObjects.maps.closeLegend(); + const mapContainerElement = await testSubjects.find('mapContainer'); + await mapContainerElement.moveMouseTo({ xOffset: 0, yOffset: 0 }); }); after(async () => { @@ -124,7 +185,7 @@ export default function ({ getPageObjects, getService, updateBaselines }) { 'web_logs_map', updateBaselines ); - expect(percentDifference).to.be.lessThan(0.06); + expect(percentDifference).to.be.lessThan(0.02); }); }); }); diff --git a/x-pack/test/functional/screenshots/baseline/ecommerce_map.png b/x-pack/test/functional/screenshots/baseline/ecommerce_map.png index 1450e48012a0b4f86316c0171ea82baf81be9250..8b0e308b7ecb5feddf9f8b9ed75c0cf09267e3c7 100644 GIT binary patch literal 68442 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

H4+2rFj%u^!5yqU_ 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 BMZy38 literal 59752 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 Dy-|o- diff --git a/x-pack/test/functional/screenshots/baseline/flights_map.png b/x-pack/test/functional/screenshots/baseline/flights_map.png index 2a896652e42047b302522705cff5a1b2de04f7d3..23ece6fb7fa3a05321a67808334492db25250848 100644 GIT binary patch literal 104337 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

tW{cr9P541Z$F4eV1b7CDTt6Q_ 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 z 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%0AF3#6T-E}Y1wCq?wDpci?8C<`P6Vq)01 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�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 diff --git a/x-pack/test/functional/screenshots/baseline/web_logs_map.png b/x-pack/test/functional/screenshots/baseline/web_logs_map.png index 0f2bfed5e0dde93a1a646ffef8cecebabbd447fc..c3526e73044e5dfc518df09baa02784490b07fe2 100644 GIT binary patch literal 134984 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

v$(NBHH*EX^iqeY|@U~pS8a5 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
3QIrmg=zwB@+!|&qj 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⪙E>UN`s* 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 z zgTW6`+l+#7eiM@e8tkm`y<2qf-_RFqdV9Wq_|JNJLa|qCW6gZgs@p%7r!` zbYMlj*Y5V4Rkr6yl#G62Xi)R*8V((|py8$t8ToVTn?&&6UW#MX_b|B*%`!qIu6J2^ zvQuonT<{tY*w->a;x-ulAz+ngG8T8NSA2PzYO+ML@tzPvgI{+_gVRo|QnyYUVSWO4_a z7!v9-2d3q5b-O|+wSmqJ-@y-k=Z2Z*F4<`hVwtA<&2^K+g!3bAx{u_*<7oan&1!OW z{)cfD;3=zb)6$eQH3OUHfeZF?^Dt_jSy3p>UNurR3?c-KfWuP7!pK6ph|>mB+O1fL zD`bcw5nV~dJ{t78Xw{0yJ#?ewrXJdwduzHXTQa$_=6f`#PMN|KFFAX?&W#L z{pbZOG5VRB2i&*;gLAukBB!qs+56M;dQVcb=+HoIIH%P@Y?*7t#Qdv4>A)h}z`U%C z0=W4|m2H}&O%ILyp&$#2ii-rj0`$bwOrMwkFL>Grs9q z%hUXB=t7AWlf7&82pgXpn!`cRK(rzT3GY^XRh%c%xa&3z7V--?f?sRBDdte__Uv-fxGkS zA>HxrtcVwUnBWDMyx=)IXc`n0Jn{r*u06zOI5a21(HceD>8XZ>2B0k(f1U`&iXrJ-){ghpp4|@YusVz)Cl~gi4^J}gsFCMZo#eA3@ z)(NMah%zKxV!zL9P=h>x&$>owm%P@i7zOJN<&sw7A24;+(|gt5A<2o4G4bVTyt zXeB-D=c@hKrX$kyfZ_ybDWG9-cj}>u1s58T) zmzVdef@s?TrtNXUc92>Lf-vGX7)zs@;`!uQuE!hgY%`?gHD&VzulMKC&QCmi#N{ma zKIB>M;Y@4fNkx9<8OqnfP8j*vL51oe$bk1X`u1DyS%UE{?EbVki_}f_hL#v z&2Or)%|4U`8a%%7jRbtsFadi(Yp`NL7)LcCv{wR%=97b&{gjVpQ7sJ_v9mYKbrRp=@=xP zWmRY_QV*ut_DnnJvlG$7;8j-E4gT=L_p5~7USn%hJ z3<&;mAm9Kh7Dy0|?=kw_nHs>ep2g;Zk?;lw^ALuh*G<@cOi6Ao@l3$Dyr7c5eZyezQZYAy=%s&KNcWF`6G1oJe;GnBl|AJ39-s6kYTUo{fSyd4z<7@iGAy*CcBx;FmY8 z=GrJi#6+m(e${^b^GsFsLBvc{We4ZA%1H(UEkYCsYv$6Vf0{Ww(yspi!s<~_JJ_|3 zFYke;cWGbgE(>PK_Rke!K-WG;u zdMfTbe!Ur|3yukrQc|z?cNW5BC7szV(wgHk*kB@*%R2MJ(W>Jd>qF&mD{9&nA2t1qGS2UzeNP7)%S-px z3N0&~P+2yNMNAur^el?1mdSV&BqHBEqV~L0!2=W*DPsY)19muqYy%v(KamY`M+lx3pUhp)+in7hMB zs+@F8wnhT`hc*iuDMP+mVH)h7Gaq4mrWtK3`kn`eXnbT$(2i?&Fgb}HXCpud)*)K; zWY4dbAQx~;Q^ZAuw;BZHkghc-cfJ}3llwiZJ5-ixv~^AiO_Iigi`^}+%Z~s9VM|;$ zfZ604;I1Q5^pd^p^Gi#UZVho1im{JIQ@PFlXhYX4ErVjx=|=hn4S%Y4irbd#escns z%>&@=QGJy8@^Ol9r%x5hcL6$Q({Dv@CGq0xbFYXI(r`o$k{Pc{j~Ul>Cg^4+K=}wo zqW?vOH_+6ufe8YhSGO57IY+3<%%C@>n7qDta;ozHJKzn}+EJ0oEz2p^Q==vH@}m)v zk!5|B{~?R-^by{f4>LuA49!2MIvYnvsuk(s71N17S1GWSKXiyCJD+eWcXzk16Qy{~i${KP*~>F-zhPz!(l8oOIF>E8lyPNR=R`On zy-jn%MD@T{c$aIO)Cbk`H>gIq^>)XfGu8R3mtE&9Pkv#?2FC{?!tH-D5Nb+IP5qA3 zGAnEN1DpOW6siao$r&s|5uF9b-ckHz%=rKEokR zsbs+e;3MYh0lERGvB_-AJl8h9)S!TDw+F**D<~s}VYpBr@6^<+etn0OY37`)FZ1C( zE*?g=t^Ez(E7>f;v^jT`aUqNy0k+=Dzus5*;9M za?-N86p51{Q2u+$o-MzsHpj_9pJnaCpN%TAU@Dqe>d%#IAKJ*j84X0lnWgeQ4S2uR z5HPyF1RO+Kf+&+I{b9F2(N+si^2p^Sh4RVG=xqP-q1g7Kqq0s>MZb_5r>PdO2}3%0 zpdaMS7YeC%NlAG05WCsrIc#x800>Vv6&LS_b%28jaE(x(rL*nV->AF(kY#{v7cruO z?*C2f%-^tAKbWHz6#tp4eE%_bBYX6Pi))(wc>e=_{-l2H9_@w(RU~Fe;l5CSx{)JIQhqTdt9Ujh)@aOcm%=wH=u7qk=TY)2lySTU@t&;Hr)wc}FRf};IfHpum?%K^DIQ;&;rpixTy3 zCFGS=P?FTLsJdD&8@8_kOB4obRupt6Sb7XtBVrPS3dY~(=U-lMflK-;+{>p@Kc4+M zU*7#|rC<^LeZ}DltF$;-Gz)nlfTo0|^fKMn#;V%0WkUV~Kirw%53W5v)xV!TD5+@s zG+FFDpm~d#i)`oF>WAZUkyJ_rH-1l_dx1(7tNF;I))Z&7$m_Oz-yw{+;Xl#o1~{@; zRb3mk9lcNfwTac%VW&EWRjmk%-!g6g2;Kvdkf`6xtaO9D_frZN!CA}@+BIi=muFb( z=2JjoqKK2bcB9aJ$f#lI_ixtR^n6;Bf%!Xo`{FT+G+MxPiHLjsErR2^MkM5o0{Iwa zE=yu&lOFFL>@<)$X^4U*5kW5JLzEC*OFc`~Et!Z+yq@b1Z30;%-z-v>34&SAmbHno zzGa($^b=WzA@D>y6?t;`0gQs%D@@%I2h^*Nx8Kcy=b0WLT9B!Y0xEJZL<}}jFae7X zB(0H&5iOlPOps8-O30*+_Fd^0Flm8YKXN6wgtf-+gzb z3litZ4XU3LqFRfSA43`P0(Vt zXabpTykZ+^A2Kf)V)uJHU-vw)yIY3LZG#2jnaU-&;(sJ^eOfN-Krmi$aR=!pAPD`o zGqj>jDu3y3D!|wVB?+d8_sh2{&LG~9Uo=m}0idCxC|A!j|a;i&3mlgwrDUTYY6-$#Bps{E+QLwwR~k@)8e96eufKaR`1ZfubiX)W z27O?w+EtqGJhVZLc^l)^TpDgFhg{4FGF0Q;(HZaDT;I|KN+os4Cv{yixov4vWo@s< zj(Aw%gwPj^O$}tLj91f3_D349VU5kr&H!7-M%eS6d|vZV<>~RM*HacCI}4$KNh`Vq0%7$jvX(8&gW(4gN8%vZZ&N zO`K~wc|PHnW)o{|rYCGlq)(fI@%4MG6}@x5?3o*gwptxAiM%$$HLE)-zx6hFLcDL{0W0 zOrBquzuXV;RyoJRwD0S${9J&t?WSIIn_sGkKX#Di*CviY-fjL`!Alf+qX2R|GeV$g zQ}S=UHDXODA?6^c#D<=p?;Cae`D91K*It>*HbVxHGqQ~)^bQXMH-vhY+9W*y54k&dgs5qR-77m zc-Qs55(DhKTV-j&>rvrX=(c&gOmJY|Q>j1`cDy5sxbsG&A)$*g$6xE%#)%fQfe3@x zFPGL{nZVINy0;+4wg;DJ3-!j%aueTkKenjfNtxnG7B`6Us!M=A4c-Nq6Ag;dM_=Qq zc!}^pmgfx@F!>(jtnAR^<1SiH_ffqs>6oUXyj+e zpHySH4*TzZ1;Gq`8M%ikBYy?y?*v!~gAQ3Bl77{&A$h{AizeV`cVx$Xi0 z^IL^9ukY|L5dY3w{>@xTaxg37(~{5%NKmqFS*5xce!k9w@ihK5HmK*7$8o_V3sGDkv7G~NzVqK$br<(a8bQ!01LD!lqc53(4-U%jdF z9V*)~F-&+y)tX%?#94ThIMUnrHd+#u2dF4s?bMhApwtW_pQXci>BH4tIKTZh{bZY0 zNUp&j`@7NIlm$J_rOi$EVcgV))9vW7Q%>cZgp%^oc;n>%E5(|ASVU0V5)T0&2;kA& zqN3LI^&j`y=7I=6z=M8M@2 z0tN9KT0C0$II^bFWl1t0IYSVNr!!Z?`FsV@ibdf9z!eC8+g{w^-9v#2u^k&ns`k## z2aJqCz+wTjWKegLpel9MO7DNgnqb3KRx-KyU}1rR^SiplmRy4ZhjNM& z`SxiCR?OBAGy5O~e(`^i_Ldv9QSfykt~@BJffEe_{gAQ%{{eVwbb0T5-UYr3_7RgZ zKnDU=L;|-&0TG`>g(j0`NHB@==ah@PVDZfbI_HX7T0wFcik}Q=i*l&6DiHTIh^Q4e zaDK|l1-@TSagGGxkY0g9cOsrzmJZ5Ka?6xWoO)a6H?Bb)Ebfuw^Dss(TOatVTxRAS zcC|eEDucuynXOnsc6R%w7^@IYBh#xxl zl*s?hWW4&9lqyQ*4+Hul0v1vzkW#*QG_n^@7iYf$r{KK#8kJA_Y?~JUmB+O!4-gUE zqPpMeFlqvoV;f8@cY6R2Pjt9{n83*gWqE$QLT*Cc{EzmQ;oaTo)n^|l z+;u^k-oVJ{b-7Ca(w&l2>RwK#Pu8o{Q~qLp1GpFuSvj~Yi~@s#!qx^1BdHLsu**DY zeXYR$CNIg=^s>suj0qr>@=S?vF9KQkwtw@>)QauS+OK+8J`!K?(tB{14M~*8xj>@= z+T?O~=pW749G#Y#EIq%Qmy>fVz2EtZ|HJUSd?i~KJ+aw49W{;rltA7fVxp% za?b}F+Vb?8Ap7iulTAGM=0?3I^hKZXIR<0{RYi=9e$O|`2ys0yt<;2RF9~T`${t+f z@P|^0k->_zsZfor46h4c-6xXZl#b3aix1CUIwE0eMpL3=Bz+E_%yRD;XM)U+bjsj& zxqnG!E^rb!6O^UV(TO=X;+aU7-*EkuL_>|eg=bmX@Yk4o%$_JfL;q3qZ)0exJ2~I5 zA;`?iN(*xBm$os(TsRfZj`*_ce8)F%7pSMa77PC+ly`63kW?LRZU~ox-*a_>nxpdc zSSTA4R*0~VnpC%a=D8kQe}D#yjVN;JrC5o^QeRCa z;ONoLFrS9C$rN1RzT2Vzr@QfSs0V=$eJ|`VxqZlloghSP!;u#n_&KI;E8Tb<>u490OaBP|slHXKI=_-p`Q{F-vX!%QGJ5cH(s7G4_|NQ_qDwXINs6`%{ns zEC-MKTbA7Wgi4s{-G=Ehxc3*y_lkZ0VYN2|uL!?ITl? z#2g*}Ug1urxb^*MZeC|=2(gAXG2f!l-E75Qk@I3w(l_Vd?CT0|zZe4hP{=+tMhSjL zt%)cHu4X*AmTrfRjF98-3;q=cz5s{p)lRIgF^LiTh-8D*jEqH;5V^b8Q@wz03{YjG)rOI&>SFjO zw%OeIU?qNNgO6?g6|wDoo0XRr$f@`|JG2Rnq2ibmZt8?)i=N?iI5HwDHJv zz2(gxSXc#J%+ai32gvwJg1fI9Rxr5xUS4MWjMDi(E&$JRH%CH(fvvoqX(D?=f2>jg z>b)z5UaGOEKT;@urPik><$0846}g*!=z;VUWpojo?!~bEh>S&ugP+ch=9$EG$$kEjKelFi=puuid=PTvtMSXu$2`RkO#{Dq33kgltCb>^+i21SlvdH_@)%Mfn;H zs)10BDX}J~^xmOczhT%#3;Z+*Rt)tk&1z%q9mcpS%F5|}emAUqP~w$}*7LxT$X7(f zHMjV);Nf`Ltf=6h*e{vQiRXF2e(X@0yUNr{gPtA{35g1P$!|6su?`%OHXO-&(NARD zH*O!Hi~9Qg(o;61R2Jw`p~6;FRgL+zbA;RX%9Rlp4WhdX?r#-~=C)B*GUP>Ov_ zXC1?r;v{ndrLU|i_^d-Oek+^5&z#7~d_$K3LKTf0a3$W7LMT^Zb;--71_sZ;b`<>a z$}F|~^xC%$89}0kEQ}G`<#f{U%Ga4iu26tVv);bF5|W|az^Cvt_MUjh({ZvcYsmve zX}f$TjySEr=-50cdPZv+zB*_x+14aoR&(_}S2EOyJ@af5P7MdD(6SqDT>1Zg1Y(;1LH;T7<5@rDzfb%38&Q9h>;HZM|HCDPr~bbm z3ih|2{ok+u|4$HYdpeAYg0jspSryY^uvlAk^{w~!`4Z5?T`NOD8B`HSQHLGPlcXk) z61VKKd+g3YPp=3&n2RKN4I8=d8=`*8E;B0kub^<1Sk5+TgHv*(9^Ktr9n7+L8}%FR za-}b101{UL4bC;zD=34{QWbu}W(0VRYH}yq#=Vm#*23xaPMx7Mnr5g}fzMf1{)YZ639B# zpajam%K*6$`=>oqiql>-+aG5x!+xy)@9!ND8j6|F2!eU*6`q@B=YOl4`ngkh)wxHr zO~#53*45Ta+sFoydkC;UDC7%+h*Vx~ZUjh+tnKdbR9TvXj3YyUY95u^&eJ|p;*}VB z@jLjB#>9uO&^dzE*TmF#ZnFJzjpgY=RyGXz9%{+qQ56#)_eH5t!VU1weJ$s31%kQ! z<2Ysbso#vwzdPt}-@~E44)Quhe0@=dL=*Kk4UfOfT_yBO#d4p!CPfofJLhx9V6qCD zZvr3{R}8FO4JOM?vz%hZA6t2>L<$vD6obr*)iNzoSMxp`;qBC!Sz4;Ph*ok!*M8XW z8r@ZhU=vd{qDdWNb(n%w9S7)^lA^k6vWmuaH54VHRU3(E@}XMo~*q08QHS(^jr z2Kemv(o*()Nlq(In~p;uo()Y+6~NrS?B$zZ9a1znCx6aoM?n%a4fIZX64=CH2636A zu0b0lfnb#G;~G6me3os^C(v!pVCu;E^mO%L$I((&d~uScU|>M0N(Tu$SuDsoBM>J+ zrJ_hYsJF$dgmu;?jDdlZRk^lTbDhzCN9?)31qWXTL&m$|| z<&*C)PPx92tq2?&0^XjL7#lUMsF=gphfr}_N`Itzqa#nyHv1WE-|pT`)r_A+g&#G+jA zNSxwWd_AR?BD{sKq@ac7Kj$ebH(UjQd1e?J1PU}!il(NkxP8~Z+JnDuwuESdfCAp| z&%agu{lsQY}Al$HnCRgV& zD_YF+zs^(Psll6T;fCVv%o*td&IKU(wcp;1wGPorg${S34NqTw_qbk0H&>=v!P+Ui z@oZ_GMmXY?!X-fe1!cQZ7_{Y)8Q!yz#@Pp(r+)^)qSo}iRGMwWm8Crw*t6DMM@L`Q zh!ZP*XWqB(W11aJm+idk1azmAFi`JCA)Sv#jw-f4h>a$@e7Uz&0@{k*48MDdl(Yj(4yI-br(pn zwGx7mZq4GOd+WOWdZDiRLO1?+O~IeWb-DBP zri=VoGS{3$qbDP4g8ca~jVx)Lb&uNJ zNJiK@g}i^hh?FlsR}cd{7;^u$Te5|(7p*V;(k~glERK3e{!GS ze%vM_;=1#`X{DEUYY4RHa54Q4;K`bRxA_T}jb=atnm&)ni-{=2%>C3GH;B=an4#D- z(v*P5<3hMlvrIr)aK<)YBRJa#mCk)HYShl!e0p)4nRpgTq5} zXJ_ZvOWwxq5tJ^Grp{%-0TdYp1u_l}4rrId=43OC5SA=gOCF#7YGL&dvRoq`y`xpm zaQnpJ+xyD`^79q(lTB;S^g&I`fmDC;bl*6Vy#9LK^^>SbG#;at0N2F`@qOrAuG=l2 zN9`U*Bg#NYo+qn$NtF_P_UMdcx7W&zlQV(GIqG^*vt043nOredfuaIZ5tJ;#nQzxn zvYu;cft%ArM%GA8pqPH`ZYQH*LhNp`{dS~?J@l!~fs_iH3Od?lv5)yxvP|iSLgXA) zox%H}212^Qm}E;QqX92YgTU^1FpvfEmb&7)m=-H`Pu8RRmJecLp3)IKR#Z@u{Hnr9 z6k({L%zJx|v~$UJZDT`)HAd|_rb+$X*v(BB-ltvLDv2E?Z9luvZa%84sG!3S+FS?> z@?CbZeW~6cVoE(wg%+AE(?}u7N_BjG<@nqa)mI3QMEmuN_)ZHpG!v z%yfO>?r*UH6+l+c@Z^A=>~T)dU;4sr@HX{>1eTflb!j|E za-{z0Ss$Ka)ndH3cs^c~?X%Fyan7@G-nQ}q%UvOoos%cy`D+=w<=&)Uc{{FIrVRK& zt8Z_T{mcWC4;UzfV;Y%zf%J{%;%MQZQVXW<<@qz=L|h$^zUb>+e*B{3*>Z~41+E%a zy&VY7P0Y+fpbcofW8KIRxj-m|>jOX0exr3-jAiVa1VFOzu$lR z4i1VvMN^J95+;tm@72PvlmonmX7iDTnzQ5SfiL3!CGqh_NQ_s`s;W|hNH|ep8uche zH=;}mRag%$&0<20hdayQ^>a|Odq;i>l-437PFY%7uKoEFt4DyOCKU!9O7Eo~^1x7Z zrAdFiUY^fZ0_&Vfr}pJ#ou^5h-FKlB{A#r|-b>dy5HoaGqZLR^{uYhB zS}(9a@Ab32{X^r)aPits8@W2?$6-*S$?i*WYUFXsi0QyHnEdigPit9v%-?l`|J*D@}X9q{aCmxb#zg^^C4U)RW^pD>qh%2dtK zMRl95>XH9kZ<V8V@&k&)`Ys2#@f+CSQkiTg=IhG-D}J)e3} zKfRUW(e9xZY4&P?g7u=tm#g-gquPgtmW1teQH&dWw74bqD;o^wYxCsmAV55Dyqr>8 zHlZ<5qW6r=JdJ2-CLP{CB@k#Cgp?H8pKIz66}<9cjN0QDd7qlNYxFP=T-sOmf6@I3 z2&?}6k&*@V1*o1^0E*+{mnq>=c{R0!N+jVW#B+u?GId*nXAu&)6O)tKwFYyRlO=k- zHh_xEa zZa6q%Zt>BsRNJ@1CCu+!_5*!59h?%Qc>ZK4A8edliHW(+UOte0r?*ZCPT=Y(*N>5M z66EJz^25HDE=^WV=Z-OPaqIC-ryqQTzB|K(ysBm4>vN;cKZMH1k&Tr#4RXm$rQD>j zh6%koB)j2Ral)Lmt^AeMd5&HhEdIg5El-ryW%sQJjhe(Dub(=JR zVgwO=pPHkGU*(A-X2;_%ku=}KGB;IS|2j4Ogt@ESg-~8OV~>lJ{j*le5GuPw4dP*m zWjh`i0NQURKbLshIX*EIr@%)vG@!9mJ6B(>N{73jbV{!9bxRrQvtQ##dgRRe;tCYY zG~kx1&PSHA#_BD(;F634#Lb#`h)>_VsYR4HbrmjRK5lsRgyU|@7vQ&#_dag(xtLbJ zmx0^w%#dopnk%t5ZC$Lo0o9?=58uR5+~J<5I>ks|t`^i*+4cWoW6+dAGS$9EC7=A8 zJ$Ah&+p`dP4B)Hh)5~>wXKf-v>8o`v1HMwhkK#rPC@19w0Cb6RkEI_ypc;%cO@v^c zMfqMhH8&aAGTzkz|K5D(YY>fWuNfxlYNvL7(A96|U6f=9Q#a?@G#RX7$-K-P{ZXIw z<-ry4-HgI!REJH1IQIUWx=NnU{7he~x>>J=SLZ;`z<3*ORXfk<3Vspx&?C(&E0d~; zAQPn$b;oHC)-KL6h_eb5w8=KMHq$k)_2FgN%4*UlG#y@Wo11vdE9AdQp}lG+?g&C1I1;2$<^ zS>DS{OA|)2^r@#ukK^wGOex5A5oPjw-g1L^~1$_8Q2F~F5g7*|ABs>BTh%D!1R7?K^$A;_qp-5GXF!Y{N0sjgE_DCa?>wRQEx{y5 zN9Xm{x~ZHr92fI%Ui{wnb{(j*etRGnY-|x?taEzYc8xox{9kf8eq28UN2wENro?!7-7qfF7*sV19N}LGL`6=bn0IO*z1rs=$qxI zJUoq|SSU{Lqf|}?myF?|p^VdDU+w||=8aYVgXIFFEhi@+9|!+dKoef}g)hinC(D}e zVC4gl5#Yt)k&!>83Hxum9kgA%D!r6U+ly;tmud}{ECZiEIH|)UANf6B0fpiLe>&O; z6Gv;86LA+SMvavaB*Gwx^%;@8-$Lh!eXN}qblG>=FJu|SG4Yh4&F0p#nKXDiJ2cA0 zi26(@c)2Mhei)mJNs8UUC;733+^+cF|E*Al^P{SSkHa=tW_;B}LUzV5-3Q79#NHsiGKFL)-T{?g`snV8DMj`^hJ zCPihdx;ZoU&D9GC+<>+f6uc~2Ut4l8w+B+`daZf!u+yJQteDv75{6|?0UI1 zZO8i8R!BXh^<5&>tO*C6QD*6>nw@{HF#Vyl=hJLIJgjaG0Cd>-NgP9vsDZ|a&L>ry zL|&&N&5vfo_>wsf!rk_0&tg9GscBKA#;#$*CW_xi7D|U*fS|Q>SBuuofYePS=eE|@ zI@3?!GCDM3|D1)lcq8bE87Nf1gN=H*x_=MtJ+1^d>s3n90kursv;C(1IbW=`ZnnN5 ze|bJtuW|Oz?s|RDO!^U3q0e64rC+qX`gvp=?S?NADX9mwy&h^iuOfsVK;odrK_K&7 zAKi4vXBur2mZ~2p>0faN9`7;iO)@au&d)S#{662YNsa8wS7rzTi6UbMPo?F)?oep3 zZu;?D9odtePFejz-D0r4XnDQ$NFm`5=w+!!5GqUC#dsc{ zVEUn0;BRS|>9L-_c}}r3CHQ*GNU9GHnaj#cJ?wxH)?7a9pAvnp;5%qG?iR<09n|GO z>fmHG1TdN`>eVYuf*Ws+A6enc-)LqAVd`86l1X7TMRj`w`4%($lU2jBv&C>N<5h(| z=5$*Up~mguv`y5gGV-y83`8LaH~?$|Wo=GaGA8rC&@}i$T|Lfka3;f#ytqtTd9s{( zx8bY|6NE1$$-TVp-@3kdc#LcV=t9X{?g?I|BT!vklGC8<(+=H%iZ1=q7Z%m3s1OiA z8tK1NlnhGFrpMW@@@w;~c4@ClzNY?81#l434PuB=^%_v}noz3RZY>s!rHt_(NMtFI z?En0~CemGD*siZqnECqlvTBh-rru-RLM?x^VEFgq%ye4b<7hQ4{Y*%0|E@Xw6|b^f zm-jjuoaM_db&2J~;31MRSSpukuof!hY&wgS8C1w{tQv6)qr)w!a8`@i7z(egwovo( zYI@X#6oQFHJ^k%3l&%e8`(MDYo&klJQk{%Jod$?b;*FYWR=y` zzMaM|7>NEdl6-n`&Jx|;X+!Jz#E()`*i%&cD#Trv-RNF?_SjA{KdY%O+A~nV)Z--^pOUe^xHP zZY+4300=HqGqXMDVy{;W3ebDGq}MtHoA(z;S+2j#2KCYBO)!562{60$3}2G{fTU*G z8Qr;Qvv=9?_s0uQ5><0$p9mDf{DnFgzUR$&?a$Ji)Z~GUa~h$1(#yx$z^2P*RHhmI zc!rY060NY_eFIK3nUk@vbsWshpP96$U2VjlC467JIJGV=879Co`R0Rq)8GplWU38e zA#Y$~WTirXO;ts8eOJ0M2jtO$;4B4FN39&+&hv9nS?RA?_LTE*MntDuKVfHP zW)>|^(){*H0coq~)1`JaifR$dMLWLuopt0O5@H);9L!ZSkkns{zn7HZcy z4zwXvCRO5Rh`ln>zP`S!4?L3&-#=L?DDPG4nra~;>7tt}|9697PI0u@>pcQ4}|O55$m6Ha>Ox0 z?-Bpw%9CHte!EpnqYhZ@Mgvf@LWaM#scg+jV~nllGlUwIM~_vGdiSq16rEbNwfoB# z-Xu<2+Qwb+@&bcuZx+OQlKFvMio9qyGp=n7G9rcKvP-hXvlO!>*v3b?i>Y)pHPbrz4w@ObIx_mVo3}2Yn`KvMugM9hm_I3$+UxXX)e&*$dLQtI+-TR4&x z@0n-e7>PCo&Q{b5N@kalV%=hnYo2&h4NV-wt@lz+{ITKXpI9G-*YNAx_jnYr@*ojyj&6d)9-Wwf<5^4TSlraq-H>zt-0UFhW48g|(5^%R6K zl%^}so{Uu61HEST=i#Hl^~n}P?NsINW)u_(6v^j8D#(57Un$ZuB9#D&0d9!caDRWQ z0ZsZh_kglLT5i&tY%f)Bum&EHR#sKPJdwo{^*i-!P?oF_KV?szb()RSVp~xko6Q9F z!pU1$>Z^f&?OGYO(1i_~J6Ybd1NB+99;eA$Ik~x#ZSLD5Yh+y;jyi543CX7mQ2?!s z7{+O5T|@-G&b-2*e&R5^Pd-)*4F?Q#%se}GI!Uyf z9(lnx@X(G7?!UwcF^=ipXuR6VilN#n9O!q>@Ff{Lw_Yr=CaH}vP3X9wi8Lo;DJMG{ zI3==Cy!3EgBqjgywycEF;!fw7S=LHtwanz>=_dy;)w^E5*Bhmwl$koCO&nMKG`d6L zU>&4s%VzeH4_9*Z1=9qrNk)8Pzce0_AFe?OKZxbEukV17F-$OfP?&ajV^jcP&cWT4 zf$;plHoO5VbRb#LMF52(Kxe?u2fFQhu+mV8L$605u!B&oybtf$t4m+9nLm(1w;pZZ z_I|C(qWZ{0X-h%PYlc1Nx$A?wmib|;pA+!Vh`23hN7CPXc^+eDHXKK{avUn88s}f( zE|*!ByYsZB7I*t}(S9N`#{lBMe7 zi#x2|w^H)(Xh`B@!&l*768Ln`F` zPsoq0r{6SV@EpzgI)H8=v|lBOpIQxRJ56VT;;z&z+yIX3QmH$M3s9PHoPd2~1^`p4 zKmaX(&f&%8?1d~+1t1NJ9xdB=_nsb}g^FCCbR_XTm4te__iO2ns1MAAzJ1b%#d%H|$2HjJ>2dSOUU zs;52uS;Z1uZ{pxgb~}fHt!Smoo>2p^nR$yBYKx$Igq$r%=U$xLqe3|KCvGpv*BN%e zw%%4`zAa4G+&QFe-{bQ*I*J^+kZ&m7H`-atlLW$Xu7F55v~pi&^poOU&%M8mWK zJGUM7X>dy+_#@59dYu1qKd^RBz|3VFjXm({A8q{0!KB;x5&?>L(Ul7iSSm-_sKAKZ z#Qlq|ZsQ*wNIyMb<`){PO5NMJS$vNq8E&6CH1|~J&V8wS`H-bvgf{62iw8`~9eyu2 zeh#;OT}O-3LJue_yD>wOjJaxC2_}!ld$LpM6vtq>>)7eJ^_aXS5wL*~=8VKxcDCnU zJ!?X6DDNMx!gaFYJ0Ja#-sjDyE6DNvv~dUHz}`|xzDkC;I%KmxR!@SdlBLDB3Yi6A zdih0xY4hf)ai6`Z=M?1WNZaWk9j^3Q1O-w7#rpvTh=Y|i4@O$NjLSElXgjVxUIwuR zkK?Y<^-jj(pZa0^S?QrXQo1!4C)2G6hF`hiON$&Sy=`Z)xl5adylsT?OVN%>fE>6v zy++BBfzEx^-Eb<0{m|TWCyTg+{t{jN^k%6{C3xYY7F^+=Qli5}UgN4P+N$Y2zFaS= zLa1dB^b$Nx^Q?3mCl4mp^j#N?1qVR8-=Tv#&O}*PIJclM*plvJ54Ja~e@+{LtYQ6t z=tg#A$nmUF0UFAmjDvndRY@}qB!Yobw1d%xe9LuL+v!oFtggftn>J@VZTXdzs+5Cj z4x7Op$v3>uKP1Fpz#_$KKa+o%PWy^!x)j+Wn@Vpcawyd;Cs?**PW*!q`H0Xr&Q) zsy&;VJf0eLe51oSP3JMl9w79i>?(6~yPY`sqNvSl&%6*|21InQ(vqrpe&45)5r*m$ZfaX=77){77f4b*nxNdl|STPFP_Y?9Wk6{GllNtRF(B`ikfU;OblwSb(bv+*dcEHv}~Y41I} zn)-saQS6EeDk9QE6r_V7y^4tR8hS4R(vjY=f{GMFlNO|x0HGs+5D}H$6FNj{=tv2H z(7qjizTdt7!oBM~Yq63*NY2@3_TDqkJo8M0)SlQSMl~qF74w2+Cq+;G>=xEA4xa^t zi{|wQKEnqlBX`Y_V11!||F~*1oDn0jvgY#a2smoX@9rM49PYMs5IP?m!zU*#z}C4K zvM%)q2rrmohlZS+sZ6e$A28J3K&X9gEq3`>`7Nr3qtN;ojUwy0r{v(LIL?b`^_`ud3r;0}Wh$dw== zPB*V6scyz$X-nlLtp{o?SUtl+0Db~m%Qx>Ukw33f*KHcpOhtSe&s#>IEj}qf z_M7lY>NhZxdh*~y;;y~{gSKOt= zKV9wU8=}rQgem|cyYei%-_Fg(7&tCJ^mMo!&X&PtkpADmm0ly$sz54+ye>5+Sq>Tv z8ia}RYW*6!mEV~G0cLCme;3~A6H3_Q z>*C}Y3us)t_^cy3$HeJQ`)DcC5QObn;mll;_e7Lf5x{gag3Kj zL@+GD0KY&}hM?!VKp$uBUm~Y$+^P?F6?*C{z}^(!kx~CI%3$$h4*t^w*ev>sJF5%= zr-h-^jf*u#c~e~T9Xz*Mxo+_3n~pUF?=y%~03FBWa~M_&fW$C9&r7*H! z;{w}gi6@u&ytc<5nAd+CyW#_SGSq6#18D!nbPII@u_*HNo5KhJR_G->p|E4e`qcu|tKqcrHH-56Gf>+;gsMSXX zc;`!itfH5xtqmq8P){qo8!c@zMiUJSsIx@W5DO5TI>&jJNv%qVw<~IS`+gW(fb!dke{9P#h`T#t&~+p;DPqz@+)25D z7ZT1YQOYqCvdl(l)@d0o4mq6e&+EIR4!|4V07(1B1E7@A zJoUdDv;QbSsfsE9>ic9DnC}Y}Me_0u(Bhv!tX4SxyTNcRQS6dc_&!c?F>>8%83*_7w__%Dqb^xrq z62@(3VX**Q#sfCK;u?``?kvzQMum|Myv1MUuO~+FjS-LpOlsZIN!R-Gba3^z5WEXc z7P%$#1RtgjoIXkXT59S)0^4rULAU^N2LGGDdJ_Brwz^aI@ zec-Ibt^NWg;6HElZ2fiGUY^JuaI7okX-P1n7h*FG=V%7h-7jQp(%ihn>OPrkN8Sb% z{u@XCAwXn=vk343`1dBn>?-(3D)0XW4?Ly#vhW#VPJZ(D`1bVQWszU~FTM8vtDNHh z7{A~a{&@nDe-$IeWe&G&)7pbs!z zBVJK#?ui#U?|W?lB?&<53Dzb;#V;;3$KoYDnL!2@Ox2>sMvHK3bwG9nGfRseh7W-J zb?kQK31fos#pdFDcb_f zgp#EqRRB1w%}}0rNS>~qXWCdwLe%g2 zs&{p&yWHX^x!&?BmG{5k9f-A7&Sgd4kyi+h>s#58@RV`%@bD<=IP38u0H~`Zc!ju2 z9K^CUZ*$#rH#m-#UmY&D6)-pl=JevbTerc7EApmxk#ntbdu>|VK)|U85H|r6lQi5S zo9ERnqxJR`JU=7Y45(Suin>o0lM}=TTRnzBLG|FaIBtf}Qv_K8R9Hwt>173j;`dO+ z=FmM}6`_PKDF!usLM?N0c&3r#L*U&*;jiujXIVGkb&cDqE?_~OeEIEq4;k~@zxX$L zyfX+`i~fpl$=WF9K3J@it23k}oInDOYyn`q>Q-0#AVK?7GP!E`Gj*sknlvW>SieJ{ z>(`MfpjMO7Qow!X^aQ*+a1%2>Bp1CqEAq7jf(!l~T4D*ZQrgPo7*ckxP|KN(PyffG zz@0@7Z(In`(c4=OK(sj^2mD}8fG;i%p$i-rAmJm0g#bhQ7WxXY4Q{~oa*!RUd-9MN zyo|$0I=tL0wTz^aecbm%Q8O?)_V}&>@|b!`N)ft+7`-Iw-xL5A zz0IS|-BB?sMoFd-UB7hJhfInc$T+GZH+)`}TSIGM?W_+2uq@s{ZeY0}JGvhTC}c)4 ziY)AH4tuTAPYg6)&t=7OJ%pL5`pic z_wc6{r=K5aNx&CCSbghPklTA7Ek1odWa$C^S$CEl@`#5A3;2UR&K7#D?roHbPJpae zln;#k#o=jkdi&ugu&sx0jE>1<*l}Cckw`=CSiu&iY~zwjGH(~Kkn3O^ZE<*&mk%ET zlrYb+eXF4`6>z#AiKZqjUTl9Zt&hcUUwi)yTN$Poz{V+-(GNuL^2rpr=ac}W?*;lv z@$EN+Vdb=lVjTdFkelvWeS^|-0(qg&OI+Y)S;@&$lQD@qb}4z%el|QeGwh1ktQLMT zg3;g&nR;~*(B*+=2C8{L?9&Ej5umk(qK^|OetsTRq2P2vP#e3pB*-xgyrr`!s9!^> zk%@~Szy`6R&!7aJTLMP*h(Zo8jhrzoU2aRr1ib^vIr`8Hu7ORiq`y1Sx6=kMz>T`j zH6UzvGhDqH;@FGf16@r(Q*CpvRiDky7b~yebzXQUz@+kIHqK*hDNj&lBWDQeN`dK7 zXwM!=J{1uLja_U6-#%g`5C~l9nmiJ)*nQP6jOTYPFKRZt7jzj*WIYKyK8lqOiFJcWvv`F_EG)B73@sWt`VM-vosFq~T-H|e!DDX+SRhYw|I5{uV zMHKEK&I{7>5@$dN$^|3u&pUK3FTv`k{e#R3bjS!XklSsd76u|z0FY`=Zvg<0&w!!( z;9`D8>6lx+25|d8>Kjmoq90{`0rd6!GEa(g9{?2*5o`F3rT-W=8PX!Ae;CFThGS!n zuer_%xpMval%ci({mECSPG3(eusl(lb@Ib`&Wh7}lm#zLuQp{cUcbWhn?moQ=y~oS z?jY}nj+u2tqEd>mW1KLT-P@r!rCYA+bu;5$aw;8%<6aaUqa$YO}ATbO#<2We+ye1O;&b;XSeUHPShKMeHyr+%6G2rtKyWVfn-5V~HCi!qtP4>n9pSiNk{suYp4JwO@gIkXkZk{746d&)1! z5A2}eoeb#N+1qp9tI1*N6NKj2jwr>aClvnp@i-kQ%~y5gJt}uPnnSdb$a+TQ-vVN zgM@O2O8)xdL>n6$O~NAh>fgfhC&}9{?~J!(0(kFTrRdiCmq39%m#nOXXzHq-;pK0} zD+u&azu-?!$;OX1*(;vnE>Lzm=B~azrh!V>gnhx|j|!sAy@L6LcSj`JH4hCFxY^*k zxvKep$XqH9BAMS~M>@?+`vKl;c|KC#zig)7w??3#zF7hAmP+b74=zqtR=l{L| zynONQ-vyEX^qA?|ZSo`exIO*v%*ijVMw}+6$NnDw`xXB?P-H{+UyJ(x|Gapfwgi|5 ze~*ViiK<^$tj0YXpRsJdfLw1Blc%%v&IIukQ-32a=vX1-ppC6-&k^Y`vL+KG;_=4= z=ol0}HA@SpT12QOaPPVhw^l5-r;jcAVjkqHLen%M&EYZq?FsONG&2K*vjoUn$~oZG z2BL&W8qAX3%A)n=Awfj#5|bol-ES3cF+^P_CnVm8QPizJ>x>+&+Wz$j^ic5tJR1i9 z#6;D2&dmHk-vLEB?4_^L=#c##xE5MowWqaLk!}#eKz z$UpEE>C8x@#J2-}{`m1`A~ZkMN^pixyE9;m4mb7J3;28K`R|F7$J*f1X>?CMT^?LD zsd34jR{OQbyMBD2Eps&dx+rANj=4=(lP>j?OkOgg5)n5GN11)pRT z)0xQdH`4uGLmZm7R8J{V<8RbPd}PS0#T`D3yQeh}w%&MH<&OS-I+EV~rA;@Vc5uYZ z8>*Lf#z5=M8uj1pexY0wM7$-_zp5fKEyPg?_JhA%0N2SUwSGv$$RyC;1FPl8r6McV zu-=H5582r>JhNkKocQD;Lw0_U3~TdzP%80ge@$n#Lm-X~D~?9)u!t`}j#?FnP7AU4 zS&vy67P;k!3?GMlQ+Ac1Dx?W2@uC>7y{vaL?kYURd3rN>V5c%|-6%9Qy41gyJGZB zl$xDC=bIqMnV5ACCO_&cZYJ5{XCgV8?`zs<{2S6~A~t?eRYPrPt4F4U3*u04{30`D zuVnex7Nf1wQu!JR`&H*u52@Lbsi4A6m2S8P&Dm{ieb{)^W?E2J!8p4E zW7$L=QBeb2sF>Oo9WUkReAD!IKBLYvk?S6B5+tYIhoq_gAVc@y==P?T8P=b;RcAR? za}59ZQ9<%IRw`TmH4-{o$HlLL2b!uNuq3V69v_`b5%LLNL$AZWw2-D+BupColW%=R zcx4j65E*JlLo!08HA_rHhk*G=+kp~8EJi?1cilp3p?V0nK_0$yG_r#z^EK}6;YnaT z52F}GFj1?c3vI9oHWl>IPJMoZ^=l+DPyeEbi?(4safQH39MTLsL>c%U66Je%__{<8 zzxHS3FmW=-#d`O9a3!J=Ve;#H`6Rs9vxk;lX@oe*2_G~QRsA&@8p>iC&9QUWoc2YY zX&Z_-rR59cs~ZxQ@I~tnv;x~-RXb$5PL#n+s$ELY5)kohK1b{6NQv4ORVSwuoT%T$ z8+2|jZTK;}REn7wuDFTLcs~gSE-N^GtM{WEST2Vx>L>7-K53hSMpngGhNdG9_o)}w zf^K0-YW?~qQ9(W}>BPO_%4Zuw8V7%9P@9|gfBtw(hwK5~>B4;Us$Jy>rgW7++F$o+ zZ)@uttzMQ{u5t`10j*2eROCC@h8_<`P%-ap-#f2V_R`9wu9-D}nw_>aH_0qVOE$1) zi(8*t2f96kTgEh+GlFue)KT0QxnmO=U`-$Z8eA|8U*6Jcfrp}a^fQAcGB&lxWDC8a z`NY;q)V|)Rb8CsmXwRZ|(R$@;pM_rQ!}Xu?unQv-U{wAHHRItEZgE{}PR9?4wihEW zx*yqSQt~w_(B40xYbKVUMM%5W(4A)Z{O8{s0NLd?sU``qvnS}`k&T@IY)^vqFb2%Q(XY=d*@pf=@acvc&{v5nrBfMetJg^5EN&X9 zInO2!6jNkpf}-g6YuE}^X2RdP;SuR43$-)T435fGp;enP`bB!>k9z0!GX9MHuxBl*A>gAku0gemm)@pe>)JG-O`v#%VW4mayE53Zag%pdRF*% zo>iAj98?EbKcDKd>{NN%;wMg-)AqshRB~!JCsorlXWE|J_`Y@bzu+Lv4a^R}Z25h> z-5o@uhnS=#&5s$&&WZWCAPNT>_3h`c{`{GsjSGE^Z47aq2x#GBWtmBCJY^@DzO?aF zuBXCpGEhIy&S{}che6im;mcdssbzZ=6)c$wK~F=&jg1Wkp@NJR-&BiR5_+f3qBucY zt%^1~5$`f*Shy7s`Z&%m8LL??f-~$fWlw0NZ~x3^;p8uU{2M7Zp`a9VYe4O;5)Uh@ zyq3(~XNU87R-lyv-C3FZqf_Baf2NwbjVp4_Zin4l*sR+2&hTA6`q9iF{_N{y@O}uk z@j$5B)hoo}@KjaEqesCn1IM55cP$aet$e=jx#=T9OT(!dIk^sYytE!45k9VOgC91P z$gtxP{fyhI#+Vx4ts$@=-3gN-Mb=H*8`b$mHfz{iZ zH4Gixs!+3stoJoJTLbKc(q?TGfBIv;N9=8GAQ{X1<)NRH>>< zcP+2obzeS++c>Ro=Wk?6ULUE>%u_ty(hLsX>JnoRw0U!Mu%!jOF<4dmi6)?pRtUtt z1A!1PrpDE@XVXfBD)9B+bpk-7BTeqqSJo|<0XJiSul9{>6O{*dFaV&Di+QPRd&_*f zS5*Edj(q1Ldm;9q+1xl00~!E^_J9x9MvBXzc06KwC2iC*e?j-(&u@pQOlKvTRQz^eNAgXW5wsQ7+-9i>fCJ7-vazv^V>O9hKQAQ~LK zZzLYXEd9B3_*!4Z*eaBL#6>i~@6UP6*lUa1|Gwf%fXkXYFAq^ApgG}mxw=}OT{x=w z2A|2Zz0En0|LsT;Fihe%C+Fa>{1TdjOH=`E)GF6e!A_%whP-AG`+`99Bzr!==#m&;32r~spTZQ+^XGpTKlAdgxORLm!Z|3Vp z(t?*=qc}tY?$>G{E6*NLo%DP+tiRjuwY!U!USQWq5u~=R#%07aE&pw0A1q{_md^B% zj*f5JeL&*6L|-xD0^Ni%9R&vWUi=^Pxf40%oswv0EZY{7#+2`wrfFyA5RpIux|v0` zH&htSCmzD=HV~8CV@=LED`Av4dF&*^zW&F+ofYGFuO`RR`JYLPpVoS~OP>ZCjjiJ9 zO=()9`bZ-1*QK=%xX_=!ezaQGc1-=zc=eUzNzzS9avdA_=sruO0-KFsZRikOeB=6N$4!M3`m-jy0dz5YeqR(fwg}%cW zS?~UepU#qz8*|o&Qk@HP!YS?eVhbT0#kK~ex*G0b;;3)VKb%>@t3u4${0S_QqQ$mI z5vNhrW(&}MXXf7W!9{+my3#$goN4?!9(vzR1?V8cxTK-Z2iI%(2FLTetaG(rP*W5cO~+qb;txUrb9g_}OhqpSb9-#uH4aAcc%}OBo8AGO<1)$W1JH@Ydfr-&cR{+|L~Wz(lmCY0MeDo=b;z9k z{5>kO3$fop0kq@J%1DaGwyp*&fnBBO8eP6gE_}JBh^jieh{1+IQ5QQ2%>f-V^huL)^PLX#fvat!o$hNTBO>D2UJ8&lnR z^N46`cHh7&My9C$wZSvzC{ywb1LsRSmlhn)W2u6O+Se6vvY0mB>7@zrS-*>7s|yTn zkgzVwclj2H=&-L03h$@YYSwC3w0V&bpp)Z&-NK@g1;OT*t^ zn+Bh+@An^@6|&yN^?fGZTimcXm7s@o8OHl%eZ3Y~x+mKZ7tp9+{pt5VTX>=Dumuwy zl?i+0m{Q96`q9lNMW9IuL+#pUT*bC+f2a9#A*V+EpL!eUQ$epjMshfP-&yu1)Gx=U z%wZM+f`onC0|+Z4TjH8scI&Y8l)bJbB%$;inBGp9hR1Z4CZ?95q(&UUGN3#|eW1_* zwqRyuRFYL}c)gr`dV3ghN|@NwZh;J)gfc| z2%({G04B?>0tDxf2Oq*24*RzP59j2eIrb%Py_dx%GF37@4*YJ6acsxaU~3dOs=wx? zJD@X*XFMJk2EZAG*P^a6h*ZU>{^D4O4!XrXxFx{NBjz@-u?%auC(x+42IvA54mrt{ zq5boGrjf2Fh;>W1IXO(hzs-uQs*z5g*>Ukgw%jP6#ld&hpwzZ zBxt*2d%BM1cA{M!l%?Vk(lAZ0F6F3e8)rIultN|xIjt~=s5dxhvSnu`s;}yjR{Kgu z{+Hc=@H0o|2`R_Oo*LWv)jkceiR+v1KL)r%dzN?VS+?W5(gck+HZBxp^sCoS5?(wMw6?7Bf$u8b81z@3?2Qhq;U@e(K_9N+>b{gS^n?nN5yp!> z3aKZwY`!I}H2Yx=QW)8|a(vj@+-jXSIo6%W5~D^Ned55`DASo4@vL}VvU7{^m_Lp+ ztGle0=+H<#x7;?1pgT{KL8f5nE$**afk?LS^*QrnvAx*=6>vGaUSY``Dkhco>Ezhi z*}Va-7@6eQkqDS)BrZO}_p(o+#6^@vC;?*Pv=Ev7sA#NyU87e3-APT1m!9uD)L4&7 zT?kO=?`E#Adb;QtaxlUsGrp=)VqV*{UNcpt_Vr|SI=ZGXqrZR4X|kdOwn6}sMg7gP z_60uYXZ=U}_oV>=X|=Ke=cVwyG?FN*5S6Pv8#8vWL8mIS49+M!hOqhZ^pz#wm(j*) z{xb@m6E9FR-TRo`qGfWZvlzjGzwXR@DA6}GVL3je<+#Y@ zXBkODYwNJ&b72nUaYruVFvJ43)DtmtRn?SMi5J%DJgxQoN3C3RgZoxLbE2I}Vy^bu zW%oVjTz)>EPe-a-t9+ZPub(NltPfy$3^+wNlxTbpv#Hu>vDxkZ)nz50s*g?S6Icv} z9P{q+j~QE`3yn3wHX}AmLqCeH@98m4PKK?=FiREn$ORfw%S;+d`MC@U$c3?NhHAY% z+~0XNSAip}#2Qw+^hxuX3RyA^9&xE~o9CrX(_E`|)0kw$RPc7~Jr8FqeA?jrRd78= zJ-MQdJ3-InFuY;Oi>dj(XGA2WD|2l~kY7NfmX=fjbYr6>sQu@6PmPmMfx@eyN!0Tv zz{H_)UsyIccm=E=$9U!5_Zwny1Zmb-!GdHR_a%d zSw#`$KwvypU|jj@Eh4RFn;z%9SV8NAtEukU1dLivSyqbybaEjm16|hs#flz_7~tnH znR#;Ao0wkfI9DV`SPP=r-r$JCtoH3|?LMD~uH{2%966;p4K=DmtHO>?biurUz)rH9 zTO>o6S|b0@amaQZ(!24&iX@X(XGAkI#BHvFgD$`_e=}ssAPqmG1-h%YJB$~8Je2LB z8f*;NCmeY7a7sy;BBBEz0uAD-wifgj#VuT=J)yPJ?_wmp!XU>dn!P8g4VR}P*09ua z-Gh~f5uBo;PYgv|^@<4u{9|*X*e|3TTiC%OTbE^7>sF=dbIXhF4x9gDE~i%ay^kht zs&l&PGnM;Sgo>&D+!xad73Esz^7mV(PU>}|&`OuouT97QG2Q`&V)tuJuLY|XpAsh~ z)`|(K_a=NegA9z*@KTe@l}C#UKTkgH^eZ%~Hz>V?GVPe0kP5M&r+1W{OSI$SmNcOZ zQQfi8!7whm#{}ri;27BRA1DE%QDRtrO?0Lqw{={Cbw(;$gk94lL9`>C+1$ zSKCkHr!-%!+4wi^{-|!4Kgn7PZuyIWhpGxgmk;C&jzN~Y+GS4_h_^d}I|$852sOdPu!i zSMjF{=Y5dNven47dtD@J-x&UTI%1o+h zq}C43Ol0UhdBJ$w9reHhpgp3+TU&nW>ymXr?*2)0F(ul#yQQB=F-}8AF-w>_GeJQ? zR;l|}rp`#;`c+_G>U=kUG^n4E; znv>J~8s?Y2vP;G0cfPnm>N{YL$Or~tf&O{OmH%(wXbeGSUCkY>6{ev)uOGJhe1BO+Tr{SyK*qX z6>TC;Y+>= zpci<%VYx}59b~od#95HPF!J+7DlUt;=mrLc9~lwyOAEZ}va+)e+~juOCRN$u`B6fi zpP^7yT22;0&*B=FiPA|F68L>GvUZjl$)%VoZ<*uq0dB}WmBVwF2=R3p_8^Ncue==b z9WRHVv-pBE9Ms8er;VvM2beqj3|gT70jeGjz{tQktXS1k$H}huKh+YKU+zo7D~B`^ zzmNVJ`{-^@o|FKou{00fc#AAIPmLX$B?G_T4%0zRU~V}AWp0IgYmR>S-BGPJKAbS? zElD73ug`j?q(~ANtDQzV9n!7vG{q?LcZxUUvJR3NXOSVi^!uLt^xR$s`5L- z;}Z%#v&sgoc)EqpO|$(_g!)TvB=O0H4_!0lCs|sJmI+& z1u$E$5JATe_C0$gy~z6N#>NDL2_Sp+ZT);HG z#fl9(th{enQ@pxj_UL`KRd@Wd?r;!m@4n2jgXNc*b8y2iAsHXVxmIN8SKL28bftGP z4KV%cQRucdJgMK)22uFn5Y}>mxLz8^jC<+7<`NVbW~3`F77l?xuvv;1FEa=$hY=St zOPvj#O_zpIPPnNJUhQg|ETMLTmllTQId$h<%C(bVS1~W;g-6d3i+(Qjv1TYYr5-0b zz+#riCW^9^Dn)_@y;lz$Qg8XM(DPXe;nt;Z1G|UvVqN_c$gyk{<`nA?D<9#-&e}+> z)LMY@0Geb(%cI{HReK;dyE?(00ntFg7)yy3VJg6DF?{`mugZZ)rBcecFBdAO?>D|x zme$R*I=T^)QteW5HVQHLJ)n{{Bi6_&EYESN9)a#PH)jC^hmWG}CiwtxUhd|PSz4Df zT-Z7e;>hvQ^b6Qn8oT&rsu;{R52Vv@S!pBe2n7^nvT|~Cmx8r8Lbf;PQV7jGpsiD_ z2ty(36oN81R9~lSmF7+!92 zqbg2LF{K>+xK7L_Ee{rKFRunCR_j7hm@1VIp|C^v4Stg>uxt%} zNPus<;PTndgdHn44x!BIDn3L-VPE0soLi`ddS^>E*&n{DR#fP&=9EDKrvuUWgaA-j zgibRFc(pvgWy4^EYQWOM%>NqGFMVXk+^#$T=9tJKTu+8C0goRW&ZW+kzKBQw$QX51P7+()Q7$=~*cISgKjvM&R zk&o1pN_xR8yZlu{eD&H&k8ebCSpD^ybW>eju0wo<8X=>DAG;!fUK+yP@NcJdrXdbg zM^nfwDb$7T(dP0^xGnl;2TG3^9y1D>V7p69e&2g!P2Pf*u&D12ZtA`a z*z5u>HK^WWa`;AiQy=E1K4&<*(_}M(3w&D*3H`sb0JSly+?7J8G$=O9Z4zk6x#&FD zyZamwp@-K#=OsQlOwAULjLG;@&LCO|VcdvE-tD^g8(l^uQBImrqbIOm4W)~hW7-ZHF1SOehA66twIq@WT&L%iBA zcoFVyol(xQgrw8|;w1qiegiiDC_rF$X1%fpx6z`tTcErVA~el593(WnKKR{68m%3s zHA}bRmo7JTFyT`%BV~n>o{+uNCZx5d{g4mZ?)0-;top7kjE!s5LbY~iS0UH5>U;o; z)X_+hl4LElQ6s=IWH!F>$#zWMdeG^x?`AHQdTG@Plx{pC>jLb?3z*zDjJ(mwxBYdm zHZF%Rb#VFT37L?@x}mL7XKMm%Yep;u{DgJ@T3si7oQT)2r)V%QaRFWqj+5iYCt{3; zC69KP3z#WqH2vQo8~%y|d7*i>CevZjn!qN%aT(NFFpsQ?u8!1eMd>c7K^bx}L-jNF zid>TWPXwK-a>APVAd0gA1H$?H4=wNG5aM+sd^->SP{UrNnHGufxH;biyM*@cmPknW zd<7B3C`vmlDk-T7>K!i!99jj+?Dd>7--@y1rxn6N7{qL*LXX}>slu8=wqVO>kp`6uC4^Xsy57y zKT9?|QTLIeiTWMTm6pT##~=Bk*r1t03ltA|+t8aiSSqqyr%d33m_*(XATu&#tjxDs zEWaY;1GO*1cB;IF!MCJ7`J>PVJc{5a}jaTBwzmmn(kApK;}I z|7W7Tb#@L8aJ3;NpFTwqpZyZ%s^xr`TNh90^C?ZBh-8v@38Aa|bw z@>7PnY8TOdNw|zGG%fmo?50?A9!!04R)zL_&{qAQj|LS zRFe+Gfy{3`gnH7_qqW>>_1ao_11-C(ByKgVJ8pn{`v`eAh6Nz&djgL} z4JpyCn^IRn-QdY|HQmA=vD2Kf4;GQvcPwHb?W==D`}-2@GaQGc<|`COBuPvigbt=X zilNKK!|TX{mJ9janW36zJ*C68W*vCGwmR>^@3Xwf07aRqBMKP;S(?FF9DA%PO6iX6 z?X?PWFC#w)cv8oEZ1!Hwt`G=_V^J0F>=7JPD`tv<5;r| zHHEuR#6Kjn6TlY#<@wC9R;nmnnqGw;Vy(#H&;m(q#% z{KL66eF0^}1h{`f4`SX_h3bGg3otd_`_|qCtDBtzG8oR>MhgZBlZ&C2#rd${&h>_C zduM=Q0g^+%?L-jSI{?yt+ziqS##$pAH7eFa0%k8q`@8O6A`?<{baa5bLOMCu)XiZO zL|B|qxXg&bzWZb=ve@B>+%FvqzIWQ!GA57^j2=WVe(l->HUy#!;gz>XAKt>0A&l z-g@7{VxXxREqgr8BjO6xT@X}=I_IZeXSpV!|KXZe%a~>+I?cXa3UBNpl;@EhE0VKI&ldWrofG@o-h5D8;v>-C_Iul4zdanex^_-qwC?xUw5Eef*UHA- zU6~R~nqbdT*W)$4_rpetQcM*it{}};F#sm0VheS(EHNv~&FHb1bI0b_je zMFk1OTP0ytdt-*hUi!f_vN;j`r-FA80kElD#E&w4zdFffVKBNt5*P` zGGCT`OZ4>ncKq2N8|we+Ta^@8JZlb_;qyTfCd*ZgUru=7A1_0Iv`hma8l*IYeL^AXcmP&A!>%vy8h(#G?^Y2Qd=HdyP;@ta1olzG+H*w_EM05=!Xk%7bVd3;&4BX97QoLkxKln?qdC~e!<|N>H2ZVnU;)7JM4OGN?@hVB=L6Vg!ycZSemU=Tk zLd>;p7wqnyKCJdlpH}_RZe7`NbuPB+KAS=Xy=eHizn}A)*JHNzDk$wz02yr|je$yO zNl6pXyR%3;^OcJzy64m>iaW8*L?r}cQ{9D41`(%cbM3SobtVt}oiGJcq+gqwW=7oN zv}9dVKm`{Dawk9$yv<+@BSw$>C4gZ9^6<2p)~+WgI3hJX zC+_b&Mf0tnewTTUC3G8ur8@tKM>8sT13nI8`InY|rw1l1S>i=K=t4q~2P|vmS_nBm z>-CtYR4)MhrqOq85)Mk8U|FLlDKziR02mEqk}ICJ328{AC#K~=BwEtiJ7u$q?<$FG z?XMFi>)o9P@<8NeCC;06>)N$z-bZ_M3r!KX`UoYaJT;H2rfpPPui7O|*e6Z6wU&)M zZH=|Q31bC~Lp5R|kSBd1Hd{g|A%M-)Sg9gDZYF-fk-Mc6D0K@x zZ57fmPIP|6wJwpX-XXFb%Zg~rYgl7{aDp}%deLA~h{x|`7hGE4IV-(rDp*Y}DNv;< z_||JsMVObf+t3=`dD0jCOzY&2vbi0KPgBXKe&GLyoiCmZl(Gy|?AjfV+`SUw;$|QQ zb(<2B$J3+joxXfAQ8gW%sz6Bf=nK#CpuV?vRS_twnVy-^QpAEg^-`f7#p*IQaoYFt zd%IR^KTgT^c)Jv#k4*G6Od}c*>H}N@-t845Pvhdp!X87PCf8Y_ptqs;TYfLO=#|6M zWGLCLZ_fQ1Z=?1T({Hw)O{_mku3H{;@f~sTr5Ej>&F-U8R8nlS_G`6&AT7wmSw4l3 zWs~}AQsR|Q0d>~HKCj)7zYwTyI03E)nRJtXkZ9_*5$s+`&rM5Ll9_EW0NPY;&!@T< z>W!$5B7NYXw!w$Hh?+wDLhwC#Kb)}W)7CP2p|tRy{4AM9=~5!^X9>d`8=tK*)V*{r0K9HeZgb+(nxmJ zby~ZgNK<;DOC5d@nR@>bY0?CJS4VS)cZDj(E-_2{YlA!n1Yb(+zjn?jG4~Yx2sB5R zJ)6+A%|+Jv274yE%1(h107~`2UdcqI^lb)E63nQ2Ae%yFUdpD44!Yj;6Hpf_MxLw99(>duJtIBJMs*!^CqimkaUI_+r|%w9il%C;?RrwbX7>vo zb*Z2azeF|;1>ffR2a9!|Qt!7(rA&F5{T*75c|b@6{0@D)4viP_e5M9Mm*&JZ?3jJH z_di1&!YaUPdn#I<$C&ZBEgYg6S7ee`Ue zd+#aG3YvSDROV2nUGu7zvsA8jMv3rq?=Fon`3{vC)uH5E!Zk!wr&2h6IHQl|kI&!m zzVsfHhyh1^v1`%0y==tJI1hBOtoE3C)RiG;dEvqZ5V<5XSYjU)MnrS-kM_n2X8V9j zPtM%ukc<%VEZcWd^ z5wcFnI|jMaZvVOqS2`YCMebh+NYxP1FueAoN}WEOJ+vihjRUWHH-_v*Rdmj65usxW4kdqKUrobh3VLFB72X?Dj#5>iQ zQw-;Nuq{q$4fX!d1W2B8Kk5Zm-;MfyHP3TwtKv2HYt|?-D$fE4$DYb{%_ofmP!D;H18B55Vl_mK!`TOf=E@Yo z@;l+Z7^xLZe7-Z-)uGiDZ=s2GvcR^ z`Fm=_p1h~Ra6U1DIHVnp{*O$WkG{sP%855hw8@(BnSB-=&HTX5xRk9GbZ5*5ePs_x z%ZT>Y*4{r;0)aJZ8X8#8nm!&1%~GnD$=ij#&;P><47LCG)R2n&ADc}}8SDLR2R2#jjfJiR^=^)ZU2+~^!Hn31MG^H2mO=$rF1VunV zY6wUP0jUXu9s+@oIeZuW{{Nr#uQh9C)~w08dIN7e?>YPI{p@Ey+gExzYD_0NPBJhs zFsa{DF<@XgD$T%f$o}|I@S86k%LE37zZuk3ZX5b1FW~%w&-v`Ou23GSLvQ`n_xI`J z3U7N<&Zyr0{EXDK<`)>BR-c^unq1&Fo8qU`U7m3Eb5Q!P6#u7|x_ShOjORTux9&Kp z-y2HVqJ19fbPxRdgh)ebwA%NsAASxEa-1{K0ZX&n5+WUChry%&^Es{ZNDX!9_fHHY z)y4zAe|XA#;Mni)$7K(5{r>*<+-Z;B-$T=t|92NeZHSi?6&V^C#ksh+*d;pTdVhB) z7v$$xv9Ymv{qp5ptk<8Lj60ab+gA>JpZ0X7@6rlNN)}%$ZaohR6Oxp?&#Z>>yzu)> zk7QR!qrRD0L1H4uix)2hg@iPi!<8k?XCvJ06LT!Y652aDjEs!3-@ZNF+}i5bUi16H zTOKYF*?C|%+S-v{o0_ooL4GfuKle7PzKCVf|Ibh1;e|OlcjMyXQhXJEUv)azOk0SV zTb5t_bwtF%dYAZ=LN1c!_fZA|&Vy0I!)EFT4B>x07@u+Ul0{W*3Imf3g-+dwl;)d~LFG zq9;m=`^6Jm2CiqPls!>Dd?V*ne$Rh;hiUvJBtdZ)$KY}5anhnM6XefgrfY&@n2IS+ zRFtnV1DCRedx93m#p`=-{(;sKOO&{M`#J3oH&ju+B;pUqz5yKnD>XbJ{=F?%s8YAW z#Dx>mGo4Afu0d#Z-Mah!^6W} z@~r=!75euB)A-V-O{B}?40_#Ox}SCy&x+bIGwQ+mmHb*haxf`qE2^89;Iny^_5BA&*^JpZigc_RdQ_|~ z|JBG6VbgEq(T^84J1LWRN*b81p{c20cou9c9On-8GEa)^{k;D|ystyAlSMn${w^<` z>|xlveQkb`Y3zDI9VKGsPE_dOrZSOYbEHmOc)`^1$hXtJ`A)NvWFl5mP*AWqb?2QL zkF&T+9MXkPKtL_)Zse+4l6-)t9G+wU<`1S}dd`VCS{THLcL%XCKw2k7+H|DKZO|Or zQ1EzCXYSMF!8zG@=XSSR`$S*%FhLY<>>;b#t;Wy3Br`#NjnDb8;Q5C?PLqC`rrR`y zRR*p$z_y9C^CgTXvrN)=BRQ~6ocoWOOoC5``K>yfTty!d*rNJEJ~-f{tA-@m6lnFAnKgPyF*i8ewh2n{PWL<`c%=Or&ycsBZfHZ| zovAZ~>)H^>B<&pM_G=M$Z-+yZ^IpF`iLKUp(Y$fW>A?kG)r~ovME$;d@fiKI6ZYYR zKZ(AD_YSu5~3TN|cz{`pjgQ~h(-1*ik?-K~V$xnI1l-+LZK@_1z~qUaan;z(rD&ERiW z$ekDR=pp5|9Fqg0jxuxPlr~V!a-4}nj%kC0m>j3ZMjMWxuWhxCRGN?|4wF52Af+_3 zrFhHLcYaauLn5lc1S?h$H~j6IA+NbfL!+`m46Ah&m3FF)`14J0m&|OQIntdtP@L4I zxV^xvAi2Lhwh06Z)1&-d^Pj1_S&KIIgK-|!ICt9As_^>2NO^yj8jH5fycTd=gbBP- zCfG1KLwsR{P`sk7$9U*@QL}}SrAb}Icl?$fy8)ikT-5PrEIuUqSUR?F&}rf4bqx)T zJ|()0xe~Q^x}d1&l=Q5nA5IwK=CV9~TXy34=fGdDa>;YnJdmv~o|eJAFNh;jmP>VO zsUEkSo!HvU)zA%7 z^sZJZ3}uO^u(gOFCcUOJJSq@ z>vLuYuJJdmjR3b2hK<__+gKRNI&oHKz-Q1htA_VSFP@jKq_F)1g7`SJR41&SC`%4j z?-NK1o*C)h4jisvobpAHQQ`Ez)f2Bz9_b9;QthNYh!U%r`o~S^O3#Ad7^C!Th4buu zdG`!~CbM)!L`2Ivcq@*d(5ptzQ{xFRfuhN^?)Bu$DW2_ZLRU-YMj1oJ$cueV<1V!Z!L*Okw$fq!AKzGJKw#pczgaIi_P27=2uUG94c(i9}Z#XjUy?_e+eA$y8<0?oYeQoztq=HOOc^NLkV=Hcy+5n~OoU%v&QP<(>*- zLobrNrNu(rE!ziA#P|bYs->H@sli2a~Y09ua z6(A%fVp`TV*Qefc_Uu`5&ohcMRl(00S4*ZYEAbL=l$m=7W-jR~>a`xjhI?}zcqfuP zFXc2p;6_Y%mod>)Ug7IjqM=F2v%VWfrOfAMWSW?l0o_hr<>)l4gLGTJEB*{aIh(jdq@w;Y z7xNo2_>w==X0|(z@!5UJf2lc)`1)*+pzM!~=@EqkZPBeCox4`4p_8;}RasXP-R3a4 z^{>WBK7=_#T(FsSHs$yraJtm#twS&@H^Q+b0&N+e9dhE`>*$j*ne)@#;@U%Y-wO(- zt`Wa7(K8lzMr3#Vx_tkI=dqt^^PJi z$cn%o^K$zHzR)0g(l+3b(%;8EUIt zODl{s_SSnAlSSTLy`$jStEb-APCB+uo4L_xW?{WKena$y4Cz3tZiHf84a)o%qvR>| zFn!AK<)-BwB?*onBa=@4q3i433K z+kL^`tp`Dz8!S%Z7tDfMD=atGM|Pb#i&<@&-Q4t7ppRm}UhjF$DopN_VqA|-uo@_Z zugOJTxNgCB*Usmgd9(h;Sl^vEV^mxqMIK+vk&y3~aNY&X9yF!n)DHdO(H{~MrjnwX zzUXjj=aE-T^>q)DLdYQ{B`G~>Y|$FMjj6rVm9;m)BeqT4vR+P^Ga|wU)7Ow2#*Fe^!IrO5chk<8`O^g((pp23 zfpz)i*A0s{y*(D{* z&r7kL>g!*yBWu6Z>)kB~l~m%1DUj)4lyEf7$eH06{xJ zPoK`Wo;l7~9G{kMALh=dzPzWuar|&*wg+E&U{jeqD_1)&F!oz~)uWk12u4cj4)vuG%v`J7j|slPZ=iC-CWx2j}PbQCrl z>-T^~?E_+SqQ|O#oUx%(8zLKQAV^Mf?<4dh$)tip*LAG1wJWhgQ#@qn)o@)v`9Mjh zQE)Jewsb)7G0Ov7+Fr)zg=K%_ceLcRIhVq3;88)(LujS)u!`EBQs`z&x<%F#b$yOl zu41^@SjJPtNqfrcIDVh#Q@!@K81IgGVe$g&&`H1H_3fH@)-!!oYE;_Uto6r>?kOo) zh;LE`zT2`i?8C3v3QKgXc@-Dob^!lg3oiv3c*UpW-A%lPq&9>kMZ; z$a)gvQSL2OmnYLnu33u>HdvgLUB8jL$pIs-KZ(z9@zzA%4mO-KRW0U;m*@An&FfG} z=vbc<4!A0xwo7czBUPlp$xh^Y*Mgsf-S-Cyw0=A}i`RNAk9LGDIagO{+v4Iyos^(1 z(*q-W(YU##nOHMx)&CWWj%jiq1QzKtQY_^>T74FvCBA6+qS$^r*z%X~#GA7))w_2I zqh4JD3Y48YpFe+A+h}>L$LklgZG#m;OqnUe#4zokQOzc{me)Vrw|m` zF6839=hGGQ(;CL~l%hNEVjX?3r6R1VE?gkVX6}jCv36&ur(Vw8UH{(GxZMGvl#K-F zIbQC}pd35u3}tx<1H2pcq2;R<%F={++8N%A8f04DvDqo&hi)n1%cB`-$!~MQyEy$1 z8|OrxQXVsZMX}OnbM=2M!!dgERAC%P+1ESWT11d2h8*2EV?pqnw{b`x^7BZzUmxg@i0JqE5?jG0UV#vWH!4T0SfnX7l~N6#Gk?3x>bB z0w+{eSIva0s6oIkC1@vk1i*A!z{ za@Qd$rTrh#YI=Vbj+<9G40tBT1;Hk-?oT5MaSy_ZS5zk5n58A%hWiD%-L?JwH}z$% z?&Ibv=fG+q<#_w{Yx2sF00fhA4q=0{tiV-(1KNpb){IL)>U{V>lK{TEy`96i0(IE? zb&x}8FI*~~sLRG;YRNSve9 z9+eH9GA*;a`DnEz$1UmIJ|WY{VrCwe<1*TE{Jr65nY^-LfQtZ*7%LuQu`p@dx<$i%N;)%Lgpf>Qc@p#c^e=c&oz9TGk3g4uT z*E4b%`DDxxBE7T3G-lC%_ALi_g!WYb;P33c(?#_8aW2`Mkcgeo_Q=}G9A`<6_LB@m zEdH!W2f{g#>>3fqYJ2oIpA?!?DKNUcEdP3)`T8WUyZEU)A`%i6tf9PRIg3mOlp)yq z;}=@?__DoER0RI?4e}d=^`&rleb{WqB~~s5BKwVlUzxHNc{^@arQkKXW&eyMvH~60 z)Uo`6ok>{gAZ5CjKQ!lOVm;Jw{f{pG%=856SD+-^)6Y<9yS&YX|`$aBsGcB?I@k$3PuKHi&s27iDX}zWmnPO)c zZYm=RoGZ0(!rC@Lx{fqbuw?;wy)Jr@p@nT<=w5<>toe=l;%;#n}6f%e`4Vf9-X@LLA2c#dPfbeNGQ8Ax1pF zQaHjs7QZOqmQ*qF_O3_`TC9nZ-RA{E#ReMMnmLW3??b%Y`k~Ney|Py)z8`2zqjM?j z#=H&+&slwlc>M288H!ze<#!y~uN57&2}c$~nVHq6xEOY}ra2B=RI6`1(R$*KeZR|@ z8CxFoNSdHrWjK6;1IL|+LjQMekvV8fQt1|v3BSwGb1Q3G<*X5x)t?m_)_>9)IIXJ5 zawUI^p{Mq!5m)EokAIFbn`4?F63o|#Bd6UNoEW?7GYQMs^2k5dkE#%tH_~jop$k2@=@GPJG`r`)AUK4oUmc6PZ}H#76=@A3UN>?w+W`ZE3(bAc2C zSN=eOG*>ZqqeFR{xOVrGh!SqzZYlkjkzD(8EPY;r2O;Ef_i@J~o5e_-;87DmVeae% z101S}@>{y^n2ykeFMW*HFczH`JD#Yy_9XrIeLC)k1 z*eO8Y%%;H`eoan}_>9I08_$O&Ej{|&SvhEtF<(L(dgqNcdUO6NhUBL;D6lqW?CotB z;})_WQ&{0P!i@I>1MLf<^kpY#VYg;eaQQUlbm5ZP+X89gI#L#JpO_KKf*_`bZRg4Z zOMn0OVW)WVlH9BB1A`^JcIP=;M|qwj)R@@k(s<^~nG7P)#mLamJD^ctUw>kL-;afM z^;Y$Kby~HDxFyP3W%;BS)_?YxxC2}3l#-?ItV#GUkB7P`hcn0VyNy=s-*oKi|M}V0 zr(H0&V(TY6JB*B^2Yo}z&(dnz&5>3tqkaicOEWW#q-ZHxgY_OYji%$2-NteibUs|O zV$+e+MHpIHl`OG|VtIMClmM|j-=~@5(=LN*4~yKYT`k}wZzSF97pSN7F6x9=&k%)U zjo2Kx?+!l(40iIl(F>k!qqWePLpTeChhvUC{M_*kApIvw6P>offAMD6(3w7K%`hW_r7x}7T< zMZ)Y%i)YC5_47O7`7RRUZ$G|&e<3b5HV=#xObhqm;Qb3>89#si6qDoqcLRh*+26Z? zdf>SNh~Umd2~9RjZ~ZqDVa6&d&&BGN?{t2+sfHZ&eDn3|SJ?`sHwhw`H*|m1d8sMk z8#nIc>L#ZrL8;bwCyB`x-Hl4xdihl!Y`558W!2Y;~a6*v+lDOwEpv z!&zeF;DF{^G}h@l4il$CYH;pDBwuT5YyZbNl8;6|Zf-6n`V$XZ!f{3?$(+3cO6Xf~ z_^M2?RZQXX@-@p9Oo#V);{j3Y290i|?P`7kl@ng_<=N4>E;;uV(qO-bnVj!=Vt%f5 zPoKT;l()3Fr(KmhYC4d-@L_HQIZ7;2M9>% zzzSSQ+>f==VHNVRMok|i&76n706rnNr$>)GKe%!FZ$3Wd4<9~&>Iy_hM<<+Yzk;x* zZlzENbzZr1iG}cjva!t`J)SQ{(DNTk(A4gIM!|~$d~0kKqzQ8N+`Vn7s-ogF+w~7^M@Ki!y?z={YDjB1Y(c$d zR161Sy6R16#)$u2j$W*%4+buj!VNVv5DUI2Elhv+Qk^mG*RS>OydjrzG5KutmDh`P z^9SlzhL!A)Qao{a3WMA9-Hq6i8X`P45mCf4(}jWZ?v@8q7PB*ykE$aYsDV!w;mgR{ zb%b**Iexy<*704)8cHdDwI@T;k`s)R2#O~@iCgWQh>u=fishDroA)dOu#v-`uA{Zx zjxqh{|H0nv8!RyFAMFHboM1h3rgyO?S7V2G|XyT0gaOB_p z`1kRORg==k#|?Oc#U-Y+)C5ID3_-1Jq#pnM9bcsjwg8k%QiF*{QbL9W4|sV+Tu9C4 zrA-L|_J=LebB3R+zEu43r3Z*Fhksn`6&2ILEY&E-=qh0;9tskdOxY|0UwEz!xPDu< zaa#=Un7(hHJv$MoYM%{QkbIM zwAn0O@3v-0rGr}usQl&NXyyKS*o-wMN4yz}nR9uyRi(?l2=Sr%{c z!b{Td0j0aEx#2#2>H-yn3tvh^DM#`0T=(UKrHAJmpBw@<&$QVTy%?YOOEe%Qz-%9a zaTh$UR5kzlYG4LGH+S8tu1Tyi9QZs{Rn>5^4VVqY$LE*B+%>fB^?=ft*(kcI>OOpX z`(~olUi`@EGl2A?(T;zuZ_M1zP4j&n5=?l3_*jbGx({uTFjqqTx?x>CPU;}}jgB~3 zW5g?==-K)OC68O5uSfFswKlg@$c_I*LeW+s(@dSgK9O9i@cwZggMT z?lNa2M;sptcee2deY4z`a_M42_}z$Q!^5Yz-seZpz%pm8lL<-Yd^zr8(Z?3IUmRuO z`RPP=$H}wI*xEjbK>xO>vXBHEw)@lZ=dU zU?bVk<_1J{Xxol+P4wW=ad}m*$yB-y5{ZSSi#-jaAstM$lO@|A_?2KT>-3D(Z?E@742pZ)f~Q;$ z$Sk#78gmYWc3IH2pGAv5{B|uHMBn6vGl2^$x#SHcJT~+ItumnFerGBzBz%4TYyK=D zQp%A~GwV3F(#o^4P9EU&9#mk-udOu`h~^bY=qNT_`qA6Mlq5b+`hfT-3=jM;1K+7x z+-as!+w2qOdNPy~w}?p^>e|fGyPGjOJ$n&96&4cKl5)-L4ck==oGqBGXQn{->Yy&% zw>~6fwjLC;0zmDlKfxN7MWK^3QvG+jHFSvieG2weOcorIT6QCn<3lkHrMVu>Ug`o| zXv3?n!&!CPDqXCSJ>V;RZre{RKNZe(*9#1mx*)Dhvb7Kz{SvpQdaOzoBHUi z;^`pe@c|tcX;3V)4++ER#=l8K;ncNM;;75&Hp|NjYt8?pB|8RWN9Bs zs&vO4#jkWH-Y^|OF7*SDr&o>-u+B}Byp6pc1VWQSfJX~?qKC!{dcnD{urONNcy~-j<2r~m6D8%dwaT6fQJsOY4H+paktsSw1MvM!{Hd(XweQ{FHgJvW+jxbK z>^H3kk9o}sN=T@(&^K-?tk{i&1qZHV8wZyv-$~Zc*6t-C$MSfwommer7}(jVGzPtT zdED5bSr+f}y|3`>R)EO4t!LKHj4Hva8Va-qFb~jbxvu<_lxlR8qYC)@55s zZBR%25Ks7!F@(^Rih;mcP*~_Qr&oEDWnnO5$#LTA3y?Knn%S(2s;jFn zkHjV<%;WsGteMgCZFX);f-JhkxH&(K6tXu-C15g8h|GYcO%J#?+kZ#$P z582&Z0DS4tqp;bdb{ERntGY_Z_oMuBlkL68%gKu;1yZ~beHaWGq`xjb>9>j4!UO~Y zG36m`2x1nuoUaoY3WtPCtd1P1LTrgQw8;^qN?{Y6n^Au#pIjFcroJHq2*d~HGbkt} zW$Zdwf(_>l$!l-dxODll(Dm#336{Pez*FSvc^RTqX!T#^R{XQhpVHq)**hLS1E-C` zrr8tQ%j9GD%x_HlqNY8ALX3@#3(Lwhl#h~^>ZFeSoi|m|0Wx6|+-W0UnFUFZs^3HNmgAP@aA5R*=_Lp5>y3u9ECxsZeqTxEx&223 zW|`N>;34wg^G@Vp7<$-=OQL^xuPBp3Bkn0~24*!z3iBV%lRIppkiq*%N7)~0x#D>r zBSY)uKW8wU_WpCs`%J-Yh6=R~%BlbHFxP*TginWhOWX4b;b7Z)AkapCw9KyT+#lZvL({gg-yUmYmb{=nIzBW*FVfCnQph0QWqG6&UhGbR zIwqEBLCUmD-68Qt$VZ7cXagMu<|VGstzrsKlZyk29#W?DL7Ox9=*Yjwr@f$xLG6V??lW)I*1iyVkp_?&DebJ z0~B$iyl$`!Ifl%lB+SZgyHR!uwz)VKpxCq)%-D8~_-FL&G8wLXU=Vk5AzGk4pFTQ| za>l|I?mwdpR$H$sm0-P5jcbLwvi)S_ZmmgVmFC#|FUrWOxw~7?Ejjf3ES)lz4v$|V z7VNIMyj2^Z!}_mEjdr`ZE1|MlLJP1_w_DYPo{5E}#-d4F1tn0MZ`MdDAqQpNGnMK-g3 z(l~Eef0{1Soi|5koqI|fuYDx=KAAP9`Q*(*X+GCE+!E>ro0t5az0GYV9U$$Lq|xy~ z&vpZD9!EzP?=F3T!)UENQz3V~wznh}>gYI8yz(E5IxK;g8q6Penxs-s9utzz8`qh{DHr8uE4b9g`yLGc4|TnN90|k!MIrG2YZpvP5C4{Gg+_Vi-MOLNt?T^=g>n^Seah#;ekV;2sVTLc^e*#9&&Hk$P z$S}!ijT}@H>QFl4wPX9VduTcv_aJ#&%)G$>BGo--_y~#Us_?fC&mCg6``p7a$#NF9rLP3a>JH~ z5}W_bSC7utR#I$iKjhg_2bZBT*5NXr!lQN@(0(x~XgyFIV7{z+s;b^~`Tq7chotu> ze&@yTH6x2UO~jtG#i%>=ge<+T9LTjD>UILNGD_n`EdK-PG8wxQ_&?<2#}y7-0sQPV zD=Scm_cR`$Nj%)V#vb-qW$CKq<-2FjObo-vLxJ*Tx(|`vVe~1W(8bj??lfMRPt8F% z-!Zc^(%#gp#wBu~cqF}DIXw3L+jOAd>t7gJnoln8*3-}^^Ia6_Q(IH)WmT%z%E-9b z088S~=?J0;2??=sa%O#bcFd)xQ48;j94T(rjCY27Igd3Q1ky|sKxSwN3br-O6OHsf zsSXX9a)8_#5u1RtawRGzegA%siBr}@IsBAt=6qPmAL%s%3Fw|rN~G#D6Dv^Q=4z1f*dB(d9+>i>-l9`sPh;X%D@uyHR@n zCe^D_taD1dup-7TIrEhj1%7q#l+2x-gON(k$+bRjAdgqXV5*}SO7Uf3W77DEkBY*{G3KlWU+Fyg4)!<&h5>cmVqncs%mNk#G71~2_znH z+w;#qXR)3?FQnd%uP|jqn}7UldpFot~Es1VdX z+%KuvfXdNUD2)l~m;Zw%oWfIHs21es>*Ey(h>yFpo9kaj0}Aqr4DD2&^8-R#L->Bl zzzJKmZ6QfO)J&Z^p|7qE*mNNwg9#g(8>f^XKmJTwY;An5FjZ(igHy70A+y$APt1%oP-pYs5E_*Xt`jAglLN&|=^P06#+-~_6 zdsqe#BrV$@-Ciw_E7@wzX3Xqx|7_83AGf_Buc5m6C=$}7Lnp;$m zg=wu>jJe$PjhXfT4RQ6)wxb^5=c=oa-Q8F9%v=f{`6gMoBWo#hC3$mKSkFCLQMh(h zX!ue`#lbM3P`&uJ}9>`a0jq3!=El%gMy(EI--F%R-a^m zh#AJJal!tCgr}*O^6{sgA@187^HM5hLnC8jfX)Qs&Vphe=b0Z;2dmtM4aXU!nT?Fv z*ni0veaa{XIWrkY(X4RP{qPST-?_7ANiu2nmD?te94!Q9;_W5ES~q&t@Xp7a%_?If zV^`x9cTj?jKty`r|1HrkZDf#htj&<^^F1{_0JseKIs?XrTs*# zE186Ra}M}|qN0q*r`cgQZf>_#h)mCvU{thIX{Pw#wc@W|?;N`91a05*yMRkE0%ik>Hq=E_^9^mk zM*dkPl`rBdMsmCJ`?Oc%XF+)b4fTW^i(mQePTJ^;eM3rMuLW$l6#_4jot+JmriwG2 zAL8egLHC4bjky2vE$ixw>VPt?qIHVfE{{KX{W4;&7#BQm06|p3@gXn3n+GA?8`2UJ z%|P`}eYNw*ull1_TS+Gm#`6(p1cy+_5R1>?b`{8?5E&Vnwwt{70#qBfKurfYevoHQ zd!i~Gv`d(|y`tDsLdIA6HVb72+2p^2cH}|c8(PrC51g4 zjw=BahgtP<5UAP;UA$;TAk<^CT4g(F9t~kEygBdZuIxdA)#<|6I0?fOD=V*G9v^&r z@!2}b?~M_}ybzdL{-;kSfH^wKrF8kS+2_yK0YBllXF#W#dS1sB!dUqc=XiK>&hUj1 zI!e*KFN-@a$_sANWaX@Pq7MT=_&cDUyRR)TGE?!^G$0ZnF>4fsU z{rU64Gzi&b&egd7oBH7X!<8tNBuUn}y^wPDY;j+o!f=K28_%+E-rh>(!7}=c6nd+( zcX6Mz4z-Otvs^au;C81DOg?>(;oCp;ea<0yx0&FzA?c5&x_|888)5B z6HIG6(#nZYOe_@P|m?IOJbe8ihU&2T>6X$BBE5)6OL;MyZ7L>%nDOUgxs&XI6XnWF0Cizr zPBa}HgY1#3Y1Zi=RK|W7(gi4yFDqJzh1C=3D_Nx^w4F zMTl(!DB?MTDA^a3?fmumS=gH0$6ShmI)P`eQVu9TfZB9;lytyrG$>x;=LZdSS^)v` z_H72BB@g&^Lrcr7%9?<7OQh8{g;($Xz}ryD(-LC?v&*@di{Uu2q|VFWzb1$8Z?(g0 z4)5FxJ5@Mw%Y7f?hFnVwk~cw*K^0%thBm=@mzFgE({$%l8^YcU$Xs(wDSSvR)(%iDqX6-Q7k3V_c|B?@ZPO|+0&4~liC~c|In1|#(F0FFpx1fgM|3)+9pMdxO zdD8SJ8)EohUi5!Ar2i?)0t$jm&^=QKV%7Er$+b+|!Xg<63;sHLHty})o@Rx^J^FtD z3K!e`B9q>k@vofE7e;F!LqkKB{mKm7uPzqsPw3%#1mZ8CWoF`*tjHZV| zhbBC(NJ`!XrOp1v10Pa`(GLtv1Mck=`(~59a)5QqlH`H-z^qc%7=$&?no+QKb)Fry zUp3{r_+Q-m%pF(j+#ajmFzquhSx`}72)ctZp_W=&-bvAvwp{D)03@$tbO=2mf!Vo~ zf%My{$L1s?B!+9s@6l;XNN`)Ai`UCdE-zddg5=07bz?8F=^9yBEC7kon}MQE4AAi0 z2U-MA&%56zJ{9IUB{Xc!$N{C8d9Kz4cm?W0#h>*^X9Cy&fHy3%&bC#DG%|bK#82ea z>(|HU%mEw&N-yAsj=~zxh-?5=R;H?EY|PC&AZb`G=9T^%0;8_)HDBhhua9Xg;pVSw z-0=T1`L~VIzV{WT{QxLxA2K zr?x&;AyLt|5z>7F1Kmh=1p{DHa=$_V*DeTBJ+!51I0 z*S|;0yfj={sG^gr+!W5TSKD~~#t(>2x)J>ULmExpaCMO`K6H%5r8hHMNJJuxY1wqLXf)}wKx)v^kiu-&BJU0OcZDE# zSwCO!k$3Hu{=V_UzlEU}1 z5*@?arum#%__~5bu@>YqvlNeF@W(XzFJsxj^|xKg0j7``2z9(E8kjyS{zqDBYOn6@ z#4PB5NDEnhwj_kC&SO!MEq(P$y%Jd|Hc$2PGNnxkC_x+@!yDF{kyEp<6#7(V2$coh z!%Lgn*2u*qCMWlGhwMg0@yHZODXt~Xl$q9VFD}JVV6YX&Gz?>|j??yr8x>o+Yl`39 z7{vCDiMbXhdr^nF@%oyY=eaMEb%7R(g^71>KRO$QV zVe4Ikl~qG@@T}C{5a&zlGY1ClLfi$uP4HpeBc=xWiwq;|( z{nL#j0>8BY7#8|+(7b#W@z2(Nz*JA)X7O1b4}o3pP0A{T=I+iw;YH@irMd4D%EZM* z1KE`L*#K1qezQdL^#SuVY{s+g$!l3zS;}x4em6WUT&;f)S-uz`jdy2FK|#Hpo6r!N zFtw0QMH(ce+cs2-=85OW)%lol~x zQK@C4C`vw$LSQCnZ>3-{7*X}%o1e^UEOeIY;l51=qj+f^YX{#<|1-WHqI#BVQ7EbD zp9VvZ?(^|w1=7ejRY&CH>==#&2CmQbp5>OWMmuQ< z#q~AbLOVD*CP?HSV>#wC)svAqT?%fw_qb?YrQZCQ)=FEo(g+D^rxNXct`5&&S3}D1 zK>0d6Yasr-kFW0>4G+&tu$C=;@+3q;O3L*?I=szRGtC;hqWxv9tXwP>Iz{27si;|b?{XWJSzMN3B+LuI=K?! z;vYc)O=P}0BO{~wYKz(Xqz(-OeWh3T%*~Skr`0*RwpBM4L@#n5s}r657!|cqO$qbr zNb2|kjLzW~Wl^c8`Aq_ORWoX?rl#nw zTI+gTY4GQnU^6pwaR~{dwc&R)kM6s%#_zbeGF92n&Pwr*KX@gyZyP^v$@wkzV{&y9 zxIN@yR6{wx0rUa}K;Nf-Kk)UfDk6fv?-v(W2Hg){UlFMFnoVt3A(y&P7AyEc;!tR1iG}bI@`xJx(;=BANZja z@b)@{!~Fu}{MXHphPlTONDMRA&)gV+=!BH3DME*}X(;NRt<(ft>88nJm%2mptQ*!i zX}!@6)@w3IB$6O(z56uTi-VgRBfus`m{p?3?JjhOoI7?T?nhUb(N9V9a}f*dKZ|o_ z9B>U@y5;5Nz#yvZ?Asa_chg_(bX>i^(6n+DO-lj8UjHy?ZKUZ{9wvO4QU7H>*Dl2j z8d;%&9q#YipP|Of!=G)#&$F@l5c)1LF)@kCgg80fOAxc{1C4%V%J0k)laeak%%e-J z(WVtZ&t?|Qqtx4Y>oNMM(t@!51*vRqxj>0)J-qnSa9Qy>VnDWmVEs;?Gp;_!x~Yi| z@$_1bqdD@f+A}N8UNvLK+?JM>NuIxHG#Hrc63?wWCfB7Bu+NS?Qyp}2b;E+z%HFCk zUs_e8{gxI7dzN1Y)}r2&fYjf}zd<+Ln>-x(U@SEkhuS88&5r>S1W`;@79Y^%7s6m} zQa2|VJ-ga2WC2wu;btkiAXu?<<@2jVxT2IhPApJfT)e2QO^{4sJIHJs5yd5&2mY;! z`2ms~h;H{fy)^pv!cB1S9bVXu`cMN%h2~l!%|YTPwOP(SH`vqjsipPJY#^;g6zt%0 zM$oCX<}dn+);qAN?S@fL%@B^almz%z0=#A@W`AOri*Be|$2T0SCn^{k|6EF*nwqlk zP#)fDSly~S&*nAUi*oC9sC6~gTUlNwK$w`BiJ<&l3n27R>*i*4*-(-1;%GiVP^FuJ zEIji4Q&Wa@rwl)qmqU1im)|)!I7|nmrYdsxp?62ng&;Q{90RNDd;;JS zhzkonV+w~>s=zePw?rm!a3=M<;|u><<2#3=bh)^@>)h@8xmi6Hv{VF4A{!zQEf}Yf zpNleQ6SEx1`qJv+?$WpX{pBv%Z%GIfs}J1eXWK84yI?UG*{*i@HAGa_hLCa=$55qP zTU-7mpsxY5wZ^u-JdtjYAM4=Y=ri%_Qi?*5&h`d#9(XO~CO#^d*p!ItWi4}Um|W`k zXe%(-pkPr~1kCw|$3Sy2dCH5BZ|WVPz9L(xIHa(?SX(1Iz*fgc_yU{Tz>_vX!;J#18JSg`Sr zdU0`a`$vrI_7}ClRquN>&8m^nx^sQg!h1l-d+NJQ{p#u)#M8U8d%I$~!#K{l-AOD7 z+pKBqO&Vr`5#B(XJNxO=NHhjYC*gh8=WOizoE7|~^U$;rw6Uw^o4_EXa9Z~~bay-2 zhq9=^t>jxVvy&>J+?1t}3(*KjEvhUVXgA^G6Smdr4YxD-&0IDPdFr>>?!gA7G6U| ziZveUjErx9?D#PKJvz{$wL^)5O3xoBrMw_cV8x@8J&Jb9Pow?K)D$1Dj8>#xCM+-&{Tf z-h$D3vq;RME;CwjS4I0n#GWG^v<{L`)P+FqTJQdvCGW+M-CUM+^v)J?dH(>{P{uX+#q_Z*g#PC81uqa*9I~5k-%Ck4wqe0 z()AjqE+hk^#?}Va0yDISEl;$z&#nPz)^DB`1Zjtdg^mGa>>)69k$jgGkOf zM^QkD3N%P+lH{Co5Cm)xT0#SokXl7m^;`(yg)Nj+qYvZ(nl9slA|7=DyNgPhdD=ArW zzEN`4(7cD{1|m#NN#ZG-m~C{Dm8~sIor#{C4Cdq^3qut0mkSiml>RAx8h$OvN& zRlFJPvI)lg+?jvO9T3X;Q)9)!;iLYOUgLas_!9%;JZ}F}q5XgQ3&}%fD*rwk)+1b# zfok3;Svk;2&5Xi=)!0}NGgzS)Zk|G6lil-H8TO01_R!QdW0E!KGtc-w&Qw&quUntS z1mc9+x3HsDVPxFm&kR|3bbic0srlZ2y6k9;t#dK3J{3Bw+w~ms(pweFnuAD=?D#-9 zF(O%AMOgn-uVQuEDXdtD&Q6i5kGOvy)}Uqh?5k5JCl;eEVwd?R1-J0Xnhk=UD{Uxr zmFzh$Hx3^;F83~Y(T#|xEFTP@uwv6{J$1_9iKo*!h*fBkyEv*~*vFd25S8lOG*VI} zS)S~O4So&rr7HwJ?4MUBjKU{k6Iy99DEj+1!tf&_o=!Lau3~z%|8crx@d@ z`nhcD&tmY2U2ryX_r0qAegSSaApzR|1d(zVWD2Z7$*^mFZvz4&gV)tb|LJYv$GNb~%f23Wl=A<- zqwxR!Vf(*&0&~@m+y89KfT~UZllb$7r7i!nuyd{bYV*ma?3*d+`w;&H6F#D+GFWwBvnzNog(6Ba*CJ``8_LTTQ_J#JRZwM zQL8bPxk$h)N-zu1z3s=k_e>*1TXbXTCAdJWUTvp!} zQ^82q<$^zRdYF3l41qxk=d_$7C9RD7q)HH;+6juXf_75_qocK*%uFlmpGkuLTC=qU zIkzUd!8fVT-w7!&T_gJw!PNS znnP1l*`Ql?c_6FcsGqnc$9CKcA7f`fl8$@qI=^Jpi$bk}WceO_CY{f@?a>>)(w5^b z74Ql=BcoGm~$N%N>;A-7FCgSw$D#Mi8)+XT}%4DO{|78ib4wtGbb$Wwk z2}1b`_=y4c8HF!{w*VxaEvIW6yKMx_L&L+_af^4Fu*2rAOEZ3+ z2xj?}rKRtN=e_6OH#grN9l5RarFc`)P@q#!ki_U8{rBZT5j4tgdoY#LI`VgR{&hA@ zWUdI+lujjRZ+YI_BytyK?6dA{zqEp-@)F@I?CYEHO7&dR!0x($5FQ9-71-yjPc@Ys zr%JHo^DQs0hqhD69}NpOXP+NUpTR&`DXKvHyNBOydldo4FqQa!4i8noNu&GmUPjmJ z*HMhjG{R1~7RLZSG8HcfA*Pd$=1HgoARB`*=zC}0UCto}nUt&iKwDcNkg=Kr@pPsj zk>G<6V)E1m1LWfeDat)xbBmfdr_91t)tNF6ZD7$RMd)T<@1g$w;^k$E3p@uXk->ZW ztd0EAmnHyC!Ra+IHkOeh>b5jHlS$S~rp5K=WWUf3jmIYTn8A@IvC27N)!vAlxJ5IN zs58nR`WOIA2PSAg73ZubC+AK5_-TQkX~m9}r6p^_ONjH1h*(tMe7SKvmm!)tRS&>V z$z$W=01^WDWVVfP#o}Ui`3|3;pk*_|bt&T?-x%}b!v3`!t#M^jN}cSMmUhG7J)P>3 z)$-anHK+Ia1>+-L2VBIH?&}kSg3V_dpi%+pyvxbKu>`Wgtw+7RN|Y27>_;muW5wWbWAQ9&Rvt6q~4I2~ATIB@NYJ9mo?p26Kjn@9Z{?qDOMv zrzHz6I5FNEZV^#YtKcZgU_=~Vq*mK+^rQR_t()8yO#$S)JO;|VzWy4{g#gc@0b%jc zWJ^nq^fh9Zq?D0E0&>%)ZMQyvX>6RVq`LlEGP;=02{}xa@3Fwzbc?<8BK{vL+bB;) z=>v+FLtuUkys@TLm3-i->>~U9o&Y&s2Dw;&SdL1Eu`)ue7(hFzdbOWG4F|htHxl1` z5O;=A@xK#QHoUU7;iJCM><-&#_J{TDgVc%y%&}t2!9vNLBS_&iFwuHU)^*j`-6dQ7{nSDF^>K^;ZwzM`vd<`7ZPrvWN;$Vvd{7O3pDQ`37?h_WqED zV|R^0#X}YUExPXNQ~UGPR5Ec1j~6XOw4;WVYmbq}@;t*!MKx1HAT7~$)b3a1NEA z-BLH4y?en4JjHx!X(^Y8Ry^yqoU)aZlUiS0FJd5AS4-=AoioN|eL@&`6Z<`v-vJ7W zz8K{T+s(}F<(LqtJr4Wk=B%`5Ubsj@%_``rzWD0~5J(t8!YeJE z`E|Dm4^OAfUaY{x%uL$R(Z0pVdJ`oz^#mBz#Rs7ZvLi8b0A~O&v`8*>svkrZg5Dy8 zbKeqp!I@Oo)g3oe!LtxrcG0;Vquz~i27mDP3*@Qi?9P0~J!~!^H}r9=$f;nhwx6ce z{6-_`=M!W8dy+k1V8v1C`VDTDU?P&lBZ6NRF;3vXa!X27I<*e{5UJv>08mGnsTird zy0|QX(*%d+X8iu$G}4RBpyduA(a~S5X`lbH`90pawrT2r*vB@BdJ-)^)NwX%Y9UdV z|0Ef!op;rD>wx(^kyXCd%9}ri3e=JO4-dSMQ1bxF`TZGB=Td)6IyU8YK=9@g^7*8s zU>hC(pFIa_=M?{hPoG(~C9hxWB0YCIMn~^k(QG4**eZa-C{wTdT@8;=>9$x7pnu0S z_j_bGI0DI!WIHLd)DWGW%vcYLXNP(QeiF=*%!f`+ova(jPJJCU;`*^UcRPNBrgFdG zK$d+bN8+|^*)62s+-xkBL6g24QH3jTt|oXq<6|15iNF@Z9c5v%0*u<+jNA)(tdpOr z_wGz2Dr6OPf zJWM18#zcQoebJzn*|#SkPBuKwwp>08D6+^?cxU3FO3GK;?dulZ+ZqubUN-xJ zJcJfu&WM>02RK|bRxGQja$q^O5Zjh|gNf*A+>?KeO4?OsPG|j-qrSTnUlg;{6)-!| zJ0|22+XCFEBOd&?JcYV@@83#an?}0Ninib0U_%}I&7PR{K*@YbqO_e5S7=59S8V6? zzrlSKBDV@Pwy_o_CJ_ut*ToAzJc`!%&M>i4D7MN9uU5zr%NoeLH~L;X!!9%6kEc$2 z)OqESG)>R+)3E+e;wpYqF#|A!{k*qS38A5e2=8bFnD*As#F|Z3U(3j-0WeUwW z$V7;?k-Q;)tO1Y9;pvQ>r+#-vHB1`8t4dE~%#d08fd0Br<4grS96PU_S&8Y8BwjiE zF-v+9{s(_3wNforg@@U_uXi}4MDB=bV@y&?!UwuJ7vVM0+sfN1g-S@`q;Os9wyZ3@ zQ46ttq*k;uQ<;f|VV+U}f#7YHiK^KLFZ zw6+!EvfwaSO34&zyngGZ+~gF^s=Y0y~MvT+>! z$cZa=W>f#LQ6VWpv*728W!ALL0VpGyYC(h6k$dZ2tOFMjPm_m5>9=IP=T)XQLm&0F zZ4`zpJ{Jt=;Ke(aGvSLVTE2sw@CN^R{ZKqKb(L&wEvv&%7H#XftZXrMqvQr8f2K`-bJPYs*A zGF=YqazokfVnUQUQ<6U@9*k8Ff7MahW?W5}xoaG|U04VgquQHzUi(c${&N zwNFC+3l$chE{`gO$PDCVU4ylabD`1e&Os~&xs76Qg4Ypg*Fs|Ef0P>4-pH3IEaNe7 zc(UqgB%@g>1}8lc+Z3Y`WFYPuNxjto`d(E%w#G8apa; z6Mco|lY4I*n6C2{ez+kW-nan~Amh-!O-ufUx}Pj;`c(gp(;MPABH`R6uqT>HHPjBv zp+QAiU9vr0K~gfYfp5?h(FF17`0=?r`hS^yyf;%?F2zl29jIAE*r$4(--lu@2*X|U z;70R*;-AIjSgl}%=#BZ1)#YpZBB%Ez-pA-YN6va9!Z#p&-JjHqc(*r$vk9KuTm@x~ z*R-@;V{v+Z`IVRIS)Z~8+0IhO|L{m|#f`{yOCw>u;wJ;AVmsz>G@#n=d>fa~JNyPR z5Y(fL#G3>8VKg=)pNMLAtrSuCXj}35E7L+ku%nxVeB=cr>^FHCh5rM!K|;Oshd$t5 z`7Aqn($I?M&fe}!A^EF^giF%c#3VU}ZY};s;)n4W+1!oe&jX@WzT-GJf8P-x*w@jc zB_AZbaN33~EHgEFs@m?T`B5&&TxSP`GpfS|<uA4&yfO46eq{iRDhfzrsT~PuO}i@aEW?<$O*3|7zte zYIMr0oR)Qp)x19nvE!-!o<^Tv8+vRZ#BAM^meEci8kv+^PJdnE=fX-`8of?7+o*Y% z!8ga!TgvbM?4mK}XvMoGLXyQRZv(F|Gt<+XGqSTEHv6z^dpaHZc&PB+rQ(Dbx0~ku zOiFhAN}>_RkVw}P8+6CurS1<-T`HSAe+srXVzC}8U%GgK8=Bhp<+6f*c62d32@$Gc z;kz2uu&%{Up*uvujQ=nkgLf*Zr}e?;b$1a<>ve-wuHxZ`{Xw_x-~2N%WMP-X6$DT8 z`#(N>UkY*rP%%64k*FoHVk~HDX!S*1C484co-FD$SW_m24QV^0atflQiJoV~4V^Jr zcqOw%I9aYO*R$}{JbmLTrP=~cF-vLg_rOlLSh0_fql`?t7yYmH^e1#g)&$H%ud9Xeo=pF`Eh zd>JMcn~Vm7=%a6>)y(aLRFdII{z+|eP$(H5t(44L2J5+nJbXCCXC7BoX(wxZGKMWD zO&RAKT#A1z&y9EdMo+3Pzn@oiG>m*YIwNW*3+vO-HgxJl?kjz_Zx`0)URf|Q^&#I| zyFR^O8x2uoUGE@i^45zsX%d=$#-*cS-Hh~|zdNdy?;}l4<^a7RfPb8kwUA+pTnWt; zl$E!STzW+Zb-FSu>kv={>twyor=-*=f$x)i$LI<_Xefind zr@Wi*t7MxEb6X$IRS1;8FP}hAsg#(g3QturMaCnR^Tsgof%GLVvY1=_aQmy z&#S_Zs=u4JGf;jc&#%O95@_>Jc=Gn8&8!~MY*GGC7(WaxcWTco^?^cv7!?ubbA<=u zv=MhcZ4wcqxq!~%7JT0SGPk%Ci(Xs%SL z^L>KVrhjo#Q+-U5b1R$WYiTF)P08@VU0n-VT0HvDxcbm11JRxoT?PLLcX||~M|?yb zcO+HyKZp(Q>x(f_lABN(O^`%l+o!0d=p8eCB)4h_hou&>$Yu!95PFLD6esE50e$O;2$y(_7=F`;a&w{|g@@_cO15xxrdH64XdXM|y`Jr5nL*ew4`5&%Mvd*Q1U+p5A}_Ko(BCD8Mn9dEbt3A#NJRrQ zYW>ff0WyfJT7M65C$)m@5b!v+D>UGxh`1^wD4IDl8Q>AG!+ypa=;e`!Uq=}gMU1U{=}IiU5d}e@7^S02nXmoEEQ;&u zgfHkGe*5W+U5URwiZprxwC^RZ*;V<(goJ)Tm;r<|K*ixn2p16%8Je6_`VQcvfW%=d z5G(t5lF60-%y>9K8i-FNz!24Ecqj4AE4gK{P&1h4kGu>={;Ar&H8n96E0|-g)g0S3 zsvyVNvoDS^U0o0;-uZW@jxAA8Ulma2N}D-5J$5Q;a*c596;GsX z$pS@nXsDt1@E{=VU=Oy8O;gZstOt_SoT3N1^L;H8QWlGKbQ3Q$@2V;*ZT4IM=JI_e zoRvtpyRPlyjM0fuz3OsXjqSCK8MiG$4f2~rc5mX|P!$hjpK549tW%XaF=SZK4Ex8WB*eRzvoMdP|)=ptOJFS6k# z*eE};b8;5y-G{}evc~x(B#Nh|j-HTkpiWL2LB*`FUr&K}Qec7ogwPq<3wZP9!@KO+ zg%MUHADaR51tf6;lm(hMdUhJ~C56hIfV`$3x#Y&Yy-mMYy(@&@5)i2;7k9*x0QR`h z6Yo0WYSqm-`OSC0)CFlf^6UGXMNG-^!PXqrLkJkN4PM73*`FI%G|s%9-?a~=l}nAe zFULS6nrvb_ltUlR+vZ2RwXGpT4!J@zJB4a!F4_#W3LNDnzgqBwk}Q;DyI$ zC^W9OT`P7`1kC~Eb+wOVNP#8@@Es~qod80Js_ zVn$j&w4g25+$W1Ia>jAiT3+V9+%|Q;QeW*O&sqhzv(env(-vamZs~9!@Ldu5^>M^ck0{oe;(p9T+iom^FpZfaxdJ%ih=3ZVpnk5Dr0_gO* z#Y@6u`2vMK?8hdG;5GXRxEs?H&6eF|Ge2$|*L!SbEb7bpVWg0xXFfYzCD&9C06%&P zw9BTxXuo6xIO?4f8D#b(Xt2p^5MrJg6Fi0y?*M_7) zyPW%Lv%M3b{rifxVOS&8yxWv7DFskYGX|EA*qp%04G2}3O38_8{M zaWc-06ukijE(-~R>mrK%XKf8%3b-K}r%g*8D^0TmHl~m!e6}X#pDSTs;)Duw|I&J< zy5qo{Gd3%0iIuI4BC2ntyzmxlfbw@{b2=+KfIfsxCVX4&jG$V5D9QlJU)EDuu@y?L z5vXhtK5@|;pz7#~_%Xb-)K6a*H&x$x#5s;#>d$mVkPMF@W0+0NX?wn@Kxt>T%VYMd ziVE}rvOY126u$=gdHH5njwz~RZ^Q#G+-PDl0eB@{!$YO6=ojfLkBaBZLMgAyrHB<) zZQbJU{J42FSD}-9{i7^lI6J=-mwro&g+C2lE39&085N{dZ$b>xP3FB4c zDgXv5zof*zBBy>q2m>gjht|8A#g?Xg;^Inw0Rg3e(e1?4P_POb}YHtOU@v)0|zuxzB*AG_`jvr(oMpf!}Q zHM;eR>C&}QSz1ZI!W;rv%-M<|jJ(3us=+%xz?jCgLVKZU0gpPYT7GbFaFs8!i1`q* zI&c6lQw4nEi-3rLfPfN6QGyDw7gebUJHm8(fS|&z zi<6=sjjzzRlaF5RLn9@)kN>*oWqsFyj~#G_$9jK);AHbe8$?fu$JQJg8a_5b@1;BL z%WZ1qLwiXKxm3|4;f17bq`fa>J_9J#;%U#JK?$izY-+*T^UTsoGD7Y(l&VVY2fakL zqrd=MEnE;#Q}&dsHK62nEWxmfmW5SI!7VZo)Bc4Wil>+4)a`Vdn!JCH4ro>K0U>=g z^>~wl770ZO_6DKMvol~TMboxSc_>WFJvl9w+48bn5&PHDho8!I?4jyORIG- z7GzwVhX+Q^mu+qJyTodBDzMdi>l$jQdV5l*Jwo^UGvV_ngp5#Ffs7!-BGU~mTbeoL z(0;cfI7|>1RR$D7l#UBpKF3xrg>N68BqDC(0fw?>C#u`IuD;WJyTS_jDR)NeD(IV< z|4QN+vv0cnBh*;Y4(7J<3h-;2KUd&V8rs{JxFyzUq;`GH(-3C63a0XTznb7K`(!f5 z7~(5s{$wRbld+DA&0Uu-Mj?UYS)HWz+~Un+%?J9J*=QKq`6DE;#wm*k1)YzWF>a1HpW-yn z$21{Ya`%FaXuP*N2$Ls=zfVpU-DZvily0hSR>t_{_cjXK8{t*hG?{JN8bQvn zsSVKHl?cj)(7vY7>GoHynwWh@PArUrtP^iJsGt(1K}q*6Fk&HyDjL+ghM%y()jAKX zz4~VSzn?@oiMzVHuL4>y;Jp`T^Nv@|z)K{jqm!ULKWmkfh0=i?Rr+W;XKimklE7=u zCoG%?;;RKGZLkf1qTsW&wY4O1@iZd}W4?zE)9ON0Tn0A``h|632i-(Ve2)#Z=6-<5 z$9ZLSKLjcq{w$c9jYgM$>3wKBQJ&6>s@D`cjqV!4bk)3G!HCSt*yRtYHDh_AOZn?JU=gQMx#&tH3#zg& z>JE>#PiVp#UQuB(S}`SiyVO0(zZJ z4jk(WG~o%fBQz}#KN{2RNizfQ-q=W5aiD6ky>ek4UoZ}{&9g{quBFk}i8nh1wbFZL zcmMb@&;n7fdL;`rysNqFJ+3jSzSU^=?)04$da*LuK^nmJ!*4L2R+d(3ngTiRPR1Gw z_da3JBO^6Tm?ysBk9GrbzQ4a%%)ZZ3RYXKqL;S0G*655-ZvqN=8Z)U=Gg%rXRvO7O zKvMd)sCc1YNNAbI7TIlV6++$bmgjXIt|KMa*b+%qu#hhI<83!8Z>f=A1xGAXFQ_Cg z=jA=stV^Cn!b3DR475-nNsJVFDaUsmlMH%#vl0?0u)Dh!;QGlV@vH}65sv1^lurHJ zu#nAP&p4N=XqSib)U6-#;UCYZJ;7@mT1|nLv~Fs z5{EP+y?PhSA2}KG6K^Nu}G2Wq2}V^>ec~`S7upiq!3)3fTFyq@pn~EA~R02ee6%~ zE1U{iYWh&c<`dd5vinkS6!=&!Kd1NXTY%Xz?C;cY@m;|rgCLd9Ve<`xC8qV#WP{&q z0)bE@jc9h=4u*eKH)1}1`rgDduOd^0W+5Rtc?k3t=Yu?) zGck61+hifuDNMl|21p(&8yh*77(srhwxXS%e^ko`fytA_7e(h^yAAJxo;MXtO(VOD zyT;qgf{J>lH5B4g^EX_zSVvh>)v*`b=ke=@onLhd0L@IPw>L3`+Mfv*2iVu6UCZU4 zGLv7AylTT0BAMn^6?1Yq;MK1>fWRk0sn=p?b=apq$EJnZa4@QC7bM-FxglBt z@WNj}w3WZ!hX-E`dYJKY&_R9I6irJ5FOGqVHJ>HbHVWhAW^X34`K1gQ7ODE&DH0le zTT}LwjIv{Qw-Q{gd#A9-EL=HN;`CcHFF$C{0GG>AA`l}WTvR$n$KwGVp!Y~C6)yw8 z4TGtu91?FVqZG-Y2T=Z{vg zevmrQ$h#drWVSQ5&Y9o42ojzGKqK8D0v3+4^&)f=N-1HVc>8HG!)+l1+s$6W_if`5 z*tA%k)%Te{YblMooG>^qd}F7^3(2gYlr0m#kFLNM5OXszF@b=NMv%_d(oz+WX~7-9 z0^VLArBnvdR=o>0m^LfECD|lIvZ%>c3dIG&z9O@_n!DCfh6bamrnl3tl}5605olV@ z;7dnXjj+fN6TQIwG-!$4+sm)1aj(b$*(q!mpe2+L*OH}C*70H_QtGgo+j@->b%9Jc zqsL?Dg$?rWJRudl$!*PKkX|L5RZ&=*C$vdjaO_$Uo&rbJ)xV(so3AkRSvUu{Y12V= z0wD&=zG@4gzbYsW#>8n^cVEsWyb}8Tz6QQ%KCLGGZZ7{d9Bhl770vTg{;EB3$={r= z01*xj4iZX>O6Im&d8IL8k+H$9U4+1iVj%eM>p zO!$7Ia$q{a{j=Lu^#SzuVw@Z%BNnn?ic!UIsPoeL)OnZUus|flwOP#G^*&%}J(Ho!qIpi z*)_cWmibI00)#1rx|96A{`otAkyaWA-2}F)U?|VL8Sn+p z%FxJ24(R5!97C~LC&jzk*xNxvR1jJ@7f<7hgL4X39a&%UsOH?qT1|MJU%Bwd)$b1v zJpr*xN;=P=Hz}*&QF!u^ll0i&2oM+BvuVAVY7e#@;9#l>Bx!)I8Woi&(BNBFb_wS( z?u-EA=84Yo#_I1EZRG*8vkVwuiO&m|8dsFslO`LhF|t(+{_$6@UKMZ%Mx}zTQ6LD* zilIt~u?p!{xR@n-~($F&HYduwO)2r~EEFn5W zuDTv<&h{d}ZCVB8Sig!3HJHtbrHu_uKeO86va@gRy}Z3gJY}iu0ngTbeF}Z@L^+<@ za0(FlJY)ezq1hEhryhf`*41q2OBT+Ah7K3v1ax+uBQIu(58T)!P*o#f?RNCe#@i6> zXlEpwm^w72ZZ6>9Mnxar171`nMSq;{5jMT^!M|JpiTWRdmo{%&+uCYaTd!U)CjG;)cKGgEG`s!1brexgs!uMtfrnJ*N2QOmM?eWewY!4S$3YGVZeLI! z!H23-=#J$@Xi|zN1|)?*|9KoQB0N6sq*2cg1cj5J=l*-#ucnJrCx-f)(Q3oGv$Lb4 zrw0X*xU=()naahiw2J&G!d~=cQQCt4_xtm|Yu-BZ-&+@zC~s`sIo*eH1+nGDI@`Dw z;EId42i%}-4I>=w+`}8*qikHgLgYPS?QFY|I1^^eHPs>HMBZrdaCC9(MY+-##lRJ_ zj}*bnY3p$!xmhr_ECsa%5!ZFP-tdt0*Pn{>)w7No&u2R-S56Qx2$|pXj`!~y3L3ms=IyE)i*%^H6-5b!04K6!0mFHoQjs{9M z($cR%%ujan>VkuVBni$h%{Wu!!(83e*9ELqZnzkJpX?256_tlQ0Ax{d4PJ z@Y25e&Gz>wjQYd7P~ZoABvK>bmvNpO{vD)HX#wW)%1VCo!aDD;0dc^1-wv?Tn14TJFWp6?2=svF=(3W9jkvu8m+}A@4Lvng3_8eltS24| zB%u#?FxUuo`P1H-Y7LgDDc!{lI_xq=na|R};aN%w4P*W-lv!dnn9QGBwE(OKa!7-c z$3!90T=5W~CxCzDGWgl{5EIb6nWhueZp6vOwUXWu*zCesvdeXO9hRCtqUhmL*!^6XsDcvi&7})O8PzIO5fFvXaG*$XxNE3_!y7Y?|WHxS2Kv`cME4x6Dh< zb999EK^7nL#uzkte7b1aX8pl`38(dfxxGn)G*QE#QsYT}#mw5BAS7#e99iqK=enW> zrD+~(AQzl2v{Y`Z=JYYT(X|OQgnDVX!{~8+K?e($bWqiOxWe+UAI?W>+_I!fRuXKWDwG5NQ{NcYA$nv3I!egS;BY^h;z z!)(33U9C^_VAj&g16Ao< z7sIEp*xI<;3&M3aQB#I!XCg39p1qe=T0u}sTT41fZUz~N zTk-0%`zSK-GQSF$6CAuc4FzF_8*sRz>7a!Y4krW6N*WLd1V5xbR5p{16yM6m#ze?E z0yJDa?;Cv3UKjJdEn4;K_kw0cj-COKR>K4{x{pOab#fu6nGVOH55+=@OIYqq0@Ekj z;-2e0>!8IuTT2@i$K=4zDV-!7qO+6a>Z-;WgIsF+)i|~DxG`hZ&YZLA;3k$oY$R59 zbZl(kaA)ucEqTT=BElnM?AIhWpdqoRaAg#9mBjK*mIq2luWGyZ)}MfXDUdaZcvtg@kG#^U zuB`lYO%O6ZWg$E_q*I>e(O8~z>IS+$2B#(JY+35VOdttUq&~mP= zul*KkGkE}$mE4I_SvmQ}D3jSAT11dmUaVJ_;h)%-r-o`PE6nPG;R7f?|2s(9e%W84 z&h&ifo>wBVOS{HT=N|GQ|sa+pU5b*T7(lOZrjuWJDqSa26EKJho7JSrMpJ zt{v}DtgfzlIZRDOtokP?FLz|l)>{!&tMl^m9u=raq%ZfZR-^xl$ig<9Z)jq>T4o1m z+$W55X&wucg~>49y3z6S5vW0t;g1NM&=7@)(MEwqMh^^>ZW*R zKsTGIx*9am=xt#y;f$(W7kUmySP`Ik#aL@b*n7$=T`U!VXOT!PdBCsN=%K1BI~ds( zkz>BzULOQ0wbgqs1k-!PTHAQZkTe;}C8o zqMXn;a7ejp8pM(sAM-<)Vsv`q>@!)TommnTfk1bjjWzT&7VGNkJ3JL&%xJ`x)Kj3v z(LFm8fK?X|6m&H+lTWQjoyEPGmT36o-QuYYhV}aOYqxmZSVfLIg8(p6TMp;y2otH% z9vZ05T5HV8VDwB@`)Y2%aTncREmL(!gCF#S^6uSHa^^((4{vF;t8wpRq20t_US>65a`wX`Hs`p+nrAB%cQN2_WhY$ zeh^Mbx3?#@h%n2ozBSP^e~tWBa98*HmoKln$+fQG2etJ9dnlw2I>Eqk5REg=HeW5>)RUz7~zf*%7->oceF?U^|47Yq16s75VY@Hu+7QpMX?hqWChn zlm?7hU|_HEE+Xi@*nUphU*LwL(HdFF-VHIG z(do(UVB)jm;^J;;20N`-*vD&d02Lqz&j}}wj}+2DzN4|wMG+>D4*WmfE&K`!tQ$Ha1HL9m-3+x$QK_is{>tLxX5sdL;cAm?W8hvxp6(D{pjfS&RF=TUt9~8+1f+bB)!#IXz(pIH0{|#3jP{5mZp^FW$!guNb|oQm1!wbV5ZvNg=b3jTf_!rUIpb5aA%nkJ3D)* zY&#IPw5!og-+N!*Ks@w|#;U4yZtIuN_ha>MCskePR-D}2)~2R1GVP2#%kZ1h!3prp zkD79F*NP1Gz(q9_Y#Z5%;$lDzT3Cj+;U=ud#Im6EuT^s`1B2mXiM`xOQ=cf633ph_ z?rr9kmlA|~bUo!Rd%v{BefaaD3>%vwTpe6R`?=!Hz*#kcBrYSFTn2Ex=-HVc zc7K0dUt#jbmCK>#8PO%!VZ-DIfCnTHX0r)gq=KK*pX_Y9jx}rJ%Z-pmAb>_v*TIVx z2-xPuJm*V-4TYh-P32*hi3*GP^fRM<+B`~w>pb~wdwbPp@fo3v`P`djp>fUI2gz5j zTG{iG3;7;ZO1QHYm2k(c*D)oR?}kK~Vnr7<^kF@z7sg4KHxoe=z%h<3cx7}~W5>((nT?aL zoDlVJ*LszI=|P0gFMkyv`_sG56QD2bzMj0E(i_V+;i1qMJ9YY0xnhTapI^Og*5Rw= z4g`ma2M1?D!a(KK>ZVtdleV{H^;-G%@maBE8Z}5#h-k%F=q0!p>^3h6*KpDcT!=hB z)N53bc|c-ozRlxnTXQm&Q6uvs0|GV}S+0%0C6HzdXc2Qip^Coj>JZqz;FO0{r@188 zikT5!69qnpR>Bj#&SPic$C|ImWbIDT8p+AYX=!DuyKlfb-58S0+O%(?3_^w6gwo+P zH}<8feg4+DMxaLs3Lm1Nn^fdU`uh_zCsfpal6+Owq?a*q>poD+y3lEw=l){yl)Gn? zsOOoz-tqL3>ESjBXn9rq{Nnq%njoBsYwHVRZBU~5s+GlEXar=Q%sm^EhqyQ6Lx-!B z?vB`glgt+cxsDaKVQ7)nidS=tFtI^roAR#E^8srZeMtNLlEL?b?-i5mg(QY%e>T1T z1ZB(m(eZK8bYcJrDX7OB-?Fl>C~Y8}$^ISbByi?FPj{3y)Mi;?JbgsC=mhddxVB3a zuzq67(zKYW+brlS{66W$K=ct2%F#eqf4SqHdKj1FfO>^;cB`*xi z^JgH604XH>MD8WvCwbhatv)-|#-cR$X~xkPWHatH7Tbd4%+HNufl986^pE`(k;%ugnV}bdd z8m(>!*VP$UWQ*_lp&wiXkPOR2eqQ@@+M?p(m41hXC?>IHD>m9FO`pl8rRC-R7K&=- zc|W@v2$-kpLPDHYzbACpXP7QZfFQN0+Y#=VysusF7X=M5&F61Pe_mk-z-qGMTR8b4 zKqt5`qd16Kbc5ZNbVp;lJ3|MoH*1V=f!?E>u1xQ9+F&u6rVRE>B(mL=Od#mVees%U z`ngBQV&AcI<8mo+aq&G415?F8Xni-v{+Sw+=JPVGbcCcv2H3K$t{NYnJq0D}Y(_Fp zq|r0wIZ<9)9bKTx)w|;f2mGn;?PL;7_w|qKv?i)u9IPP#_9rv+7X*~si3Jy^!sm?sGm%xD)=$2kf#W~-!fhD z+@g|csuuV0|6=dG-r%s>d z@h3!v5Uty)$@emNDl*@;$sw^YJ|RKJ+*T1a$&mO8>*3!~@SS(FLcC7C{$ixAO__QS zU1GK8Z&`Ez-r%o=$&;fxY-1T3xDFU$ECuAC?R-Wp5RdLWB{;(ySQ~6f#DU){`04gs zj(1XZo{-ER=3^3PcEdmXnvr@`n_n?e*BOoQG2A-5Nn9}mK_jfG}~(o^!X zO&fEhlXH$H>&wXD7ZzY!QGo=WSA^+)T+rKVCkbrFtBaAh*8S`M#uh#l$dL>Bql5>BmfH$i zntk}I!%$c_Yks=vImXgS$vJ?tKqb|8s%rm%3Ugmjlx$p&YLkEx!D~ob1d#FLh0yzjXZdvSj`{T;j~Fi zy@&0n&akv(-Q7gJY^m@q+J8`0Vwg)qGsq9t! zx0nbu+JYhoba}onr)?oLo~NC6=hEAGFx4t?0jgaFv#J4jkBBPcTc>K`;z^!FHJ(5$~<+E+D4EntC4| zo1n(Gu(1-AF#rCDqg4nuebGe4(iRiJ$&+9ZqtLyym0RDN12-B94YyVb9}=r&4mQ?v z`KGrM4}QG&lhA$y2qR-vyM*AkkI~BLyMOQS_l-F+R=?scFFhZlW4e|R}`<&Z4rJL!~j9D!k2NqvZ{)q zD_V5=-`NY`4r7Hne2@zd(DHVcZ(*H)gp9A}n5o#J4jPS~JC}bWxw~~;f`&%-pPyve z@TIK&9Ta-)y)U>3Z)D=GY(%wMGrSzbauHp`H;y$@v{Y48hK&@&pm@vpx;FXPtT^4G zWNjs!V2DOnnF>~>2fS<3e<;=1(St&%fig_zA?QYEy4n#YE?XSp0hq00NC7(!u*$vI z{r(Q&9vqfL8m)9-y7zjGu(Z^_sun{z|2iapi*y+Cx*fmuyV101BdAW+HW)&*DQIn`UIf?qqc0Byaf+P(V4aNe1E(kCHq z*Fk^=_;`4_vXQ33SC#^LVd9#X?)L@*!_KbHC7{TkdUxljIlF_$V(aH|k5w#GC%GpDfBJ;!R6IX+y8Xtf3L(k{ zSf`ri{8kX@Po?GWvGh0#Odn*N-|Oy#!9l(_#R7qin00hODZo~#C^mW=;Gj||PZX z@vOmtm8=Ug0^j?TkJXF2&i*@2T%#_-?H>Qt`~D>E2AG@!Peq+zr);R3NoSq*UpsEd zk?@K-b1+}*zv6+mH8+<!HCtj!`?KW%Z*XSAAqW1ZG5xxkgb@5Nmor)4SkZYVc z`kbPQJ0iu#w^&wogdqgm*meIyDvsSXr>b857~KU?BH zH#DR+?(W(8eQzjhU+9!Vb=){r|6Tsr22T-I8O4eaK!Ym@|2`7RZS6MewTW?i`UU8q zU%mkK9F3c&-RC!+t@43XDtd2pSPEc3AQk~3Qgu8>u6Xe*Izn{n{Eu51XIIpyA5yeg zJEfr_&n+n_xx)a9)`b0+@*_@%lfJyTI0dZB65}U&vKpleoD)$%@HgUT>P%xoRo$0b z&0ICQCHj!93Nh1La*pMr2@k>v%(cAdbBf`c#ji1t2@TI6#A}w?zjufI3=jd7!fE$E zYu&QqiZ0P9PCptm&}*KJZMu})0bB=o7yBT*CLaqd+%vzf#P8zU`;QzQRgH~}+ZRrb zzRIdNr^x-by{+`R@QxJWk)wu!{c}mDpB!UvZz|*o%z+QkJGyBaoQRl&vRqnqr6AW= zO<6|jP4C<0ph1}1_3VTuU$hmHI6_2?!EXA@xQoxN@62xPE^R)9KD=zFU)yh!p`#S; zLuAJs{QHU`oC0iN3Xm<*Dd25XmaoATA-x|m{5}=lESD6X_xIWF3dS+906fM0-~~L; zpM)3u`Ggd%a@Avh&)d(fzhK?C5GCVZ-89f4?gqrDHdMmQ+_2hd{u3DB9>itxnN+6? zdMTZD^ePXpA=`$EsB7{&b?0S`6u^cPz#-pl_1|d8>ePY)UZ@j!qPiYkS{b<0p7Ur@ zqv6pPD}xiYykyaL=N)bgE|MRan?KQpn@W@Y$1e+}54=!ndPx=nR_GuC0Pl{0&l^1Y^j< zi{+C|<{H6XcSlt2?f9zozAgsXc9{>SY2U5GGkcgs^jVuEJh{ z#PRC?d2@RH=g18={r@=y%Ht6fO)(3ow}D^_2jE{C0N!{W2<9|TMqxB59KBHMH-CH6 z+$0qS&$RMCZNl;=1R%7w9+;HkZ0oz{bsLeYSz4w|Wc<`U3@T>z8=0BuOS0+n#b`HO z=Qb{Wc#gLwF|f`lD1k0$&%Y6F0*DM@@|P`o1YS(y-iN1gkHI?a$S7vc-XZ)-Mpacg zD=lay`{vt`JCq`B5S;;BAkg|RW(+6Le3=UT&bGX7wO7TH_v@X4K8sT^BTXr$?kR?y zuW2FGMoyHum!LQr<}lJpwSJe$m9=gb@Gy}t2Hw+ahM$*7Hb9}E_3Rn3@lB5co1wO( zZuOjH>CXEuUJef@uAGBaUHq_Y+T=#~KY`+L(8K8ZfxZg6mOH+#knuc!pJ#==$rUvzYIz^FI? zIiT;T6j>!SDNgh+0e5%+wX3Z}RPT&U4EaY+UR+ISVW*eUh{Nq@4(7mFa`nJaUrO*> zy86ZK6ryxrUgptc0OSzhi5EtY_I}k^ z8Y`}^F96j7m3VEz!EXW$uD08#2{_UpjSRsX7(V%5@S#P3P=Mb9)<>?9OZbzb zGsX`7u>I1b-artew6M#hCMB_v$>gY%K1z6}wa5Hd=K38={c@(ZXuta8gMFuZ;*KAP z2cZUq-zxE6B;00$&Mc65Vy1%)soxIv;#_x(Pv=kaR*hv;HR3aB7Ya2Y*J)qzjPZR< z1(08AXEuUD4M@VpzIfO&**xhg|z{<5VZKb95M)B^z z2i-jQcCNCrQb|Ky0M#xr)Rtu98IEtHy7d zZL^ye^orYfl3NiX1f=>ERaG#x#4zx|FjYVM1dt$rAP=w{{DApG!MY9;`^1!e9Eti5 zo!SLBP?cMT3WPy$5eBUy{Cl19#zQg#V<|G2iFeRi642DYbH(~e2ExCKGz4`5_tURuW0Y-{Y)_0>FjRP=Z8E(qP=b8THq(3pD z2mAKPr{hpOgTBko+uIOUJ!W>}z4Q+}OhvIhEEj0%zVK#pV|2l;_!`2E)!IkFaf~SW zspBL+r4IY!V}wQgsYU05X<>2C<#$k0I(q1&L6Q-~CX*xTw;f*Ku@O{9>Af!6rP3h(xjgI zS;mlSK=R6AmKFCEOp680q|(Ct@R1W4_rs(0W(be$`^@#vuHQ;}0OMu?JW1gp@>rg? zCTc;UWMNLGQ=_O8a7tU!VSk=GPmFJ>YG_DvnETT)^VDC$NguVef=`K5)@aMU3M z^5R)-5c?>N;L4PN@mKQF4cJRP24o&~uPumepuIiUwPs8vwTvCSEabzF%$*$UwW2h| zR=@8BDh!^W_%9oolw2Uppj|@ip!--u;nqG(Cq+q=B=&YUkk3-SAI^g2Chgt|9xLRE zDF;&T?O3{DCY+D-S}{OiP|0ySI6C47?g0LfR9TX!FAXXQP%dgL{L+;%Lo#;#T%J?e zncEAc~b{_0DREe$+I-B-UtY2wg z2!}G!LplNQZBl8}O~}!f`QX6}lu+J9!y+sW1I8T!E7!S~IU1Z_%CD4hik)hkdjn$$ zfs`5;9L$itXpXM=5DF{_B62#|&nvBY%Ya?} z!JFurd(wN|*9LL@HO>tSK2e?4459k^J!P@iIg`6P<}B0@~K^RvmsfuhN@~O3|uM3CXAg6zPrJ{1vl|kJZ1cFKcynMZj3140Qxko5$<<< z`=@fQUxPaC$*+MUC{tvwruYG&~ z9nCyUr}VPz%+Y#!pl!<$^V@hih_0PJ*h}li_XmvTM@OfCWMN`%*-$)j=)2LK8WRs^ zjfM3iDB4z!TZz#K*>u5JEcbehCsW`BI&m|23cEklAmXM3IygBIHev-S)A&efmr9oo zmzj~$%#ECm%>BJKkmNLG=jj(k=V+OeKxBE2F=hixs>czv3VCAN31QM3DSW2+T{R36 z6(uF9yA6gchwDgwv!x;;BFlWu2q=4~5g5+~<+3TRtg>1=*ofp8XD=Z4-U5yy#`&0y zm}~1wQ}eXAcxL4Ts{ZMa3WquLY;H0MyYh`%Z0xb+6R)ORK)&`=@faL*F zkjy&L8ZtZw+c6Sx_wQ8aq{7#4*5hpox!>L& z28IPvq@$yC;`MLJv5{F7!nJl3dZYq@+1RqNu$vs6oPccsgNIs4}Kf4 zA<`FVXbJ(sT6Y_Mm=nEk|HlZ>xo6=aJ45%av*|&&#~CLuKalqYu=)a ze{xn5GTvLYEeX-vxjd4b9ZHmPA|FctUJ#Ng;DE}odvR~u9@3kJEuK8BHil~Fr8b!b zs9GL&tepvk0A{5QgM`lM&v?d^T<*v_4t>=HXeiKMkV-7cotrIzi^B@y+ zAs5QY%4#LLZbgCEb5T1+#c`pd>hrP86BODljtTFD#D8?A-h!P5)R>8;YWSk$VLkCG ztoOBO4k;K&o_11fVj{V(n?svGTGDlPc8=vP^aEYh>fJkLJ{#c^8Oy`F`}muzVm*16 z6+(wm?S+to`-+|E7iga=dU1h_vG@RhdmBkpIZe4Fow_pSzj!|nOmTiiP%(JIci~Zh z8H|UuSWY9xL3s>&fkKHSr{`?R)D}G9&oJ}xHx2R`f^OTkAJR0Hpua_Qk!S9uRZ33P zi`gq8Oqy7oz(cO8xk?%^hecVl)L-Va)K@bz==BuP zbz>oc1Y5|SXZps{-tR$IAfH-NAU*1bBn=>V48G|J=(SgVo((ml-pA;4N}#|-`~XNk zSKe<)z8iL{UR4QJ(L7k$|7V{P9}kWd>2S0tYjDvlCs`RnIMDjk`<{29)413`pd3%^ z!IY*b$Ji~@bcuKkc?$zE-M|zVO1ZD){ycM+#J?dGcGQ z>i9oUvO2(dJe~=){d&J3W>^0^X2TADj9pqNW{?yhoVmV`#Sn)3{O5g|-437`&aCmQ z40}(JvU$=wx1-Ml-(}_*I{>PtQk%Jx!%61WRB%2QuamI{l@zU(3!p z;NB=D5@QYpGcjg;8PI=Z46GH+8?ZnaLtqjZ)datLy;_PP;gH$5;%f_M? zu9$MO`wQf_z0`cs{H?T5TNw4Py z`3LQ>`ABy)QtrlMMvZ5;^t(T}K`PP+S1AS608(Zw;ATQHIvRJ%lZ41qZ15C!*17uv zm}Fl#IDu22&@#S6tU1krkH0@(#V3Qx2`bzTYb!Np$vLe$g9w(2j2l4X?(${0KTZ>9 z_L*9L%?mR2T#e!Y0~cYWn*jf^g1OMj?Fhry_t*1E8;hzsW>&KcJe<2%qt4l_LiX@a zy88;f0x-Ihn}nv2qKV1np>BV(AfTmq8bHDPfef&W4^pHANJb4<)VZ>t_5VKNM{o#< zK9_wGnDO}dctmLna`-tV+%a|u#<14N5qbMun+_H>wWJ9|SswM!N>ru)$exsizV1stYV(U z3cr{^VIV*~EXq+b>(9DE3h?esn5{jr~Z%F{d%~X;3 zm6h4y>EtgJPtv0IS>;mSnC|c0^qY&1fiyVU|HqCcP%^-8>LP|j0r8#O(cFtCv;MXM zbT(aPC+Dx%r^z`PE&G=iU@fZywZ^~K zH9MqGVxS|L)YGhAR+)dWbCQ|t&Ic>@Q#3hGK2@W~vo9&&xz_#kQem9ab*58x4VM`t=sxhZ%sfT~ z&(ibFXsjC7oacFTDXw=&Z)I}s56K9Sw1YBCgm~4Kzx4pRAorv0 zP!>FT?+~9U7k6VZVWEM00b6ztnF13;9-dMQB3vDRQ%LI;VA0YLaSt8=LvZ94amfha zr?2Oe1g+!rIy~_GZ;_F1v8(MbS~@-ddC25H6u`R2BI?$#L!z#h-L zZM)Egv3JHA(RYivlL>(uMH}C%lQ1j)hO$fK5{aX?@JNBEzT*Fk#sO|cILs^@ zT4ulKZOxIj;#S|%~d+tqZJD1IJ zVUcgjpFk_m1tt@b%JIB|8ds%Z4fbRFM4(9_1@rQqQieN+9m$-$zI23jYBYC=Sq{K# z$EH!KRR+7w!C8_Ta5Ya;J!##m9$mUnhD{*#;ur=}d)2JFS7ag10E|SIP<>6=VRXc^ zvl-*`yIXXITdrpnK2eY%@6pwJ)fpmi)za9+t{{{ZN_q9@=_juN7Aw4)msVBEVaxS^ z;KD7x%Q#RDyO;yzGgkiP7E47#Ll^3Wl?eF9jqlhNU@#ADZO~+&5JO?Ew5Lzo1oW#K zfse&|+1$+%<-t`hxWsGf`w@#Oaof@_VC*e66ib@wIgsVaaHKrBNnx&Ok5h ztJBg~3oV}B1TAmvmiM!+=k07=Y}1>w7U+cWg{k+1MZEw?11%IE_5iY58VP71U%mqh z<{XAybqMV`w~DZqH-DoWD2K!Mm8CO!CyVtps5^hZU^N7I;C_QCLrn9i3p&^9C0)#< z#l6&znRM(@M*5DS?U!J?$`+SSH8XDBA`oU(VV~EUAB_J)*3lesSfLx{dw~R1)}o2z z`m2puSJgqyV{e~#RRK#5UwDn*Z{-_9b@dayiP>~vTL`p*QtDjA)C0p1afKDmZQMg0 zgh7Eh6P@H>9-xHQ>?wf%NaXv?ChIj1p0c0 zdLR3nR_61B8yHMd*5+J^))?_DlxgK_WcI}W*?2e6f& z%F137e~)pk-^;pXR3A9XV&cJ&!1z*r&Jht`#W$}GWLnlEMC0szEaVM}HpbeWNt0gf zP0x>k!1c>waE`oV)^dQOqr(qEL8E&Z*a|H3Wy}M14WCF89StX7isp<^^Z%Vg81Wre z5qL8|7j0z=@B53>8uL|bbs3OFt5eId=aPi87+)Sy2)(l61@0wNjRZnA<&sJ&d}Qfxn7 zCvEC-GOj?S5~KE&_~|5xsc>h=2{sid1u+N40V*=PLt$g6(8;Z6eS47Wv2_#YWuWL! zX+UC>#`SYi1xdbgBhNA{N?iR$Ex~*1Q-jY1 zeZIq1QH7gl-SB}XjXCrPyGuERRXE_c6nc36roIcs4SQeYvC1P+`cs0bAOV0V<_Lm5 zaaWi7B=7aS4dVcc{20M&%eg_Rm5w$qtt$yQ0-9nUvR^z?@iASOh=u+O<29z5z-7h7 zH*%wQ-;JkUs)-YywIhaS`I*R@#Y406`i3>XXCcUT{N0=jhW4!ePR~}vcg$gGSB`wU z=S@9Wp78d1XnT8$I+f$6ZdH_@sMeQAOs|R!WHulE%f^8I;n~}76m(>Zlr@_cG@BS1 z80K#K@oR2qdTX*V{vIX2>Gdt4A$Phm<3i+ zzdcFvA^Mf-@uPJFwSmE}q#^)fx@)LU_Gk4;rB?e}^;ABot<6I~{|^huz>P_G@$B&M z?+{;#i!e$r{+*h>_<_|TE)z1dIeS9HH_Tr5IK#hftjZu0{S@flH3a%54O_qdFu{lI z&eon26Z6A~r+`0ItMtmoutiOTJ*UpjC5)Yv&C2E)mIPI0==?1tWI!i(vg4Fz@m#6&|s_P31^+gL!^F;&#N}R;=MFmTVI@9dABj z()#gJo zENyJe$cB(&LUwuOU-F2O$*UiqoqvCb<4o|0Ylm}{h0X&9{KkL3SY2kczWyUM+B(L6 zrdmO6*9T2)`T;sn%qCl-<2t+JI@kHvqapznidW8HnjGsxUgQPH_vZO;6rVp&u=kXA z1n3*a7?Yox`MllVUP0&Z8BI`H3#J)7FjRd_9{(1M0<4V?ZS16Z z-BYoOLytVt8_s5Q!2&7zRy9^uarPe6_Gs;OLYge#Jbo1nVXQb_!-Xb(dVNQS(S>JGIFiet z(mI%e3OOoi@^17J6lE`2xN7RueV=6Oei`_BDb16KiKz!NDcA^5Pv{EN3DCxv=qWno zL0@{h6!kEaBc2LJ9ITiEVaVBFY-etXlZwn+6~gzpj37pp*VlL*>90p!Z?n38yDD|F zIJH?dFz~!d$UncqbfVKob}W}i$`qW%qkBod9*znNE6!`#Qf$-SD>p$VAl{w*i+l`cx54t$~RvMIR1uFjTuY24} zaIGoNk}6~zpDe;A^=1Xu76U?n^kY2-fC^~tUZR&MT_m(+N0|1+3`yjwUxOw>tZH%v>>L7o;qVd62{T$jY=QmFA}Zhid(ky8Ru z@06EkLsW*+>G19*;WdA~F8EdL+|leU zyovy^eNuWe-#*#@(0C-&G`sA+294Wzt1$tlaOsWGqpXeOb9_20NQ-MH-}Me$j~n^! zFuMhVKs$e!?W0Hk%2^!xkP5gF3}wqf`##pq@tvFcyODVRzKs9%+fA##pF77$u0Phj zNZUw$j7^h#UzMjBKpm6G1O?+t+#ri+bLn*k$V*&^M_|{{_4Kxq}Kv6kCPq@ZqvZ?ibhtxW@F%i!4LuB}z+3)aNlp(eF@QcS?z!-o$?BJvVhh6hKE70bic zCt4UCN=u0-lUTpBYZIp6?h_DETU+b-P#V}Tq(iOtzJ|K(7tYu!p-Q9CAur8FWs;Y* zPWK2S?pt%vh_&lUAm_gKz9C|as=7=}2bL{d73Tdq?458$4g1XCLGtEKYES4_u-c)S zo-c;uHWN%{mLjtB$m}+K42cUB3Li0T8YXgbatw)36z)~Q0)>%;cGg@u0pd|Z!LfUp zZ0xHnn7O7mNken}kkAU3{pL0X&~{g%M3xU*?Ry!@uCtDVaI38I%gNoB{5?rsdZ3Kd zfB2KD9FuE06xjp@f*a(w!hYLNUx&Oph#vbJqZeQT+UVJFaFBF(qm~7u<1=UPDV^?| z^&mYoP3w@iIuYj2vm%h5S!}FeT#p%IYc%@X2|7Ljgj_#wY7MYb9MC#h4UbLP_q&fp zwmB90*1nxAJW3r^pGr;L|A#`M&OSFZ&~?jt2%J z5!YeD$GUy|`n8Ro5w6(yy?@Uh-ywgrSw`}p^r6r10LNhnYvqXq4;&iV8aM*15MT!B z6=z8^q>km3uVoZa*VlYbQ3u|>Ar7C_Ff(IkFNUrf3*4ky*_~!nBuxb26qr`b$7o}w z$6u+LzU$xq8ls1Ta1#Qmho&d`ovX!^S{9F`ry{H)xLlVWXc#{VHwx)lY-E5JrV$N( zb~yV7uN4?Few2kyrKt9VC4L{(Z1rim3SRMET z7pM}0NLy3XgC*A*kbQwY2M{}OXK!sF$F&RR3A&VLK<6Tz7SQYYA~L6gM_#okru=Oq z<&Xpcog+oibAGO%dMO6-XGG!B(_`)ib}BzVvYqHJe4uNyE(0+td;)SAegzb*vKLb{ zI6ry*@!xk9AE!sSr=K4m9-DZ=BK_}U`1*;TYYh3%>0AHjcX9qyT_XSg$qd}~|9!MV zR`kD*P*|A%`}>8U|IMkTp}9r((-G+Bms~FV|1a?0J^KIf7KKgxdQeZwfvyC!+R?$` zi;yaFU@GV-LA4F6;TY>_C>04zh)Yb+&kHuM0^!&~S7QIYt65qpw==I@XXaCker2JT zv|L(uPZL5K6Z$cL-ZR){ZnyZ3wU9Tul;kHR}jNgMeP(N)^u0)8A$ z__bi3r~=}8kc-%4u!@QXa)ygF@S?XXJ{|6ijidVt~Uko67;r1Yf;UF zQ@r+J{m!pnv+0M&zkSP)eGbwVIQh6ELAUTh?AJ=ar34HzP|}E(O5E4QCO1LM^l~|$ z>UI0a!Wj;Q+2OWrGXnH~cnyyKIkZo_DrJ?8YTdj>e|!fL{1o7kfU@7JdXBmttI~d= z+?jAQx+4pOHmP^vGP~~leA^%XLUuWrz~<}-Q`Z;N2uo9q`3}U-jF^+*0yWR{hWgGk zqk01TEDgfp*Lp0l?vf+WbU7K9GKoZ|mYG#n3ZGrFl_8WNU{?V<9cBx_C$tvAC-C&d ze|6KK4PKzK^!=ANwsEdC!yXVcmz@GV99Kl1pgM$96~7;b){JZ==mI#;$~t)1CTnBi zz`ZDQ0oNhoX@*Yor)H6Qni}+g_2N1c9O<|`@!sG%GNc@uP?6vgtz=}_ZD+-s=U#YG zPV3g9wwz?P*C&3E3V~@907h_)sCuj6=F>9yD%o<;B{e>${p@4h!MU~lZ%E*mGy#Vg z0Alv)wQG=)TeZEp)OI7iwpJWWbUPN-M_B)&(YQoE<`SZn1L~WTD|;JbN%OFF8Ypy3RHHHQex{QOYRkr~aw(8+$G zVOiiI6XapqAgzJ~9muoJ;HP)UW`vgVm^h2@z7f*^Ul%ATbDN^DjFWL~lq* z6#%<9;bU^PJp;@NxYbc zNbA64Lrs|kT)Ev}kP5v7CKWu>?NAS_q}1JG-enj#sX3}XhTBUrkfxFKo^=%i!5Gr6 zcv;Za&&o^wT5GtG+PRJ4D*wPs!gu||`&b809$gadIj030OK@7+^FN@qrs1{+oCs!E z_m1sP3&y=eO|0&mUQu5>+=z*R(n-h6tZ`;tKM(E)3Lx((-yb2;Kf9`;9+&)Px6EKX`|jYtz(eCuJH(Z+ z!xH$l2vwf zW5*ayyzsnn2`-%c9@b4T=!DK*Q@0f!_UI2=edjIwP8ks+9)W<>{s71z=)R}?sIuPJ zImXy7FpsJd!gqE%A$J){#ts4N0?sHU!~|<}G_DU{4&u(VE|FE(+q+bxXTNm@6#gRD zJOjZxRLUK5*Lkl%J$vJMa#rHUONcP3XsD;@DGD17bqYA77@2KvdQ*F+Ch9HMDRper z5ec*WnIKGgz`;IiJdQ6zOA#=`c6ybl-(Cwy9iYsaG7`pXth|*BS#P;KfwF{VItY$@ z?59tk{`nkT+U_Na_Og_e$T1FO`$j}TYQ@NJI*`-hhQYP+b0_+JZ}t=AnME&cnN4Ih z*B8JPc{9US&p6n{u&QB>_M^**{nUj8kEs!6)H{>jHKThqzKTGL&phTjzm}ILoN2sS zwa6^zXy<%_2NY3dWf3nUM2jmcGb6Hs$OQCW-r9@zP)ur?F%C2~kc1IWz(Sr}(8T;S zRiadp@KcSx?a`-5Uj1bor3V=|ac&jaK%Z=muZX5#zEtvT)TBnZL#Z7m1{6)!yUb zw99AUC8Njc@vX6e#xI`v?Sh2K$pPTq1OKUir1vUvLb}N@Fpn!&qc4wE4jKX(l8cNG zTS-q(wSxu0tc@J3LiwP?^AHYgw9Ji6EIX67nN9sZj>W0JfoKgT;Of#-^G?#)@~|Q( zQ?RdcuZv!>-Tt1iPqI&)8+(N`?pID$I3AAdVSliLH+ZODUX1tqkcOxE8Pspni;)CA}cOiDsK?StUl z1~T4{`V?qO1(J4&LBsk*KGnj-g@eVH7~-lFCAp|O8;doD5NpLUWeW$V$M^BbhV7NV zpgKm-IXay0*cqLHmY0m=wxZy$f!@Adl#Mu`1>_U8e8pOO&CGzq|Jtl#!*S zGj60v$S=SLc@e$8&sM*6u&ZX+@tWOp>#!we5G$QVk`{uQIlUoYzx|H%qGAsSJf5x9 zTqMf0zWeHTd90-l2pU7~-OJjrIq(Xb&}3VHVW$beEWiP{5(+Tm6Ykqhhn7JIacBSg ze5K!ycVat|PGE9&BI7q22S~=i|Dbe|8mwGtx|AW|l?KE^4p{B-TuX@%9m83^pE(7DGQVAt_6CeMb ziqpQ6L{3~3KRr3AvBsE@Z@AI%9Nenh!*G@;L$7GdlEn!Ir~S~e`~Io(RTmG@WHGa| z;}+B*1oJu@2WF7#q|vU5Gc!?A!@gM@5sI&0ne@a@?jBL?EFdk8lPK}d_>L`9Ploa) zgTCiGu%t2bTb9OJI14TaUmhb(Q$>)M2O~SzJYX> zylg>Ka^^Q4p;c&x&p=ZWG#3SIf9^`eKE8ag-Dz9DeLoEfOThjn9#?|giU4v7%+j%j z+?K&kXS@L&X=lu5K!;J|m$M-+vu7VXM77YbTv1&ktXA4rt@VYa8Z)1&WM)okgGCM4 z0aMAX4fg2BYvf}A=qkEpN5qP8LKEJBx!?&p^B6H`Gy@yOsIOl+SL`)<=K^NCPZ_Q; zh|+WC=N9lHEC@@4Qx~L0h<(Jv$$^KHMK3I5#ripv^<#2(P?<(U;e-S;txpSjFq~ng zoh2nMN{ItfUcjWlq36i++aOiLS`H?_>EP$*CEpTGHI|D~if+JI%d@hU2|u1D{rFM5 zKbuU#efk<#Am&f*Zp;igXwC-Q0}#FfLvBZF>w5=3VuO}LOE?a;+^#|EU=H+cVACQy zp-kUjC%SW=Ju_+NSl zZ9hcfAF?T_&Fjjpw`M zWE>c-t+j-^u4QvpjSI|aYnnD|SMDy%TVW%t@-N=!Ozp+{Y%DZ>?*|W)q+9UOwq;WExFL zNpE0!MVbz`?d6F6kGXy}&XOQpHD!sdgA+}X&8DG1=%?g|@;qQW6avQM`MzgFDd7yr zMOtGNM8kns-w$y+P3W3?38Qg9+l%{TR{`>w;2L4aTaU2C{g=x|?`K%WUF8sm;)d_D z#BCa=5<+=I)APoHU*#(=lo^j5P*kp_E8EM%l=Vn4W{ojOIFPfn#KIurUi}G-Z_Ope z%u~Eff1yO;JRZBN08%88P+pl|P=NnrKxW<3hLf|>WR$W?HA?(v#Fw6~`9#k0tJAH<5V07K%E^K!*BJ1FX@ zL65@eWViWE9h~pM*GS6)q6{JzZ6Hr)Puvt)Ik{!p(--ch{~P2A=DScF-s-AYxvOT~ z?zT;6#us0``q|2GlYR804i@B*k8VG(s;F_pTxUKndLD`R`vs1gAWd5|86`4>xp_;c zxz|!$CtksdouK}BILMZ}x0NiG0s9n=hPxnw-l!|nmobSm(7GB?BJsHr40!LPW)FHS z`0?BDb=g{`QS>U2>sWw8)+GTVy6T)Cx8dA~9*;9~9KV;4jEq_I>JuYSn3qS7_mqA# z_Ng0J;?^i*bl@ofXA%xw)EK8?pIvRrom6x5E8bYxT8;i0n5wy0!~1qtQY*vBq_ZH` zk98m?Tq|SXv5$@0o|s+t?kq}2A^~2-G$UN`F(5?6FJX_nB!CwQYGi&$C(J`^sc6Je z_K7PpzgEL>WHrELz0gvu5h$3fzG_t))h)5B zrj^nMI`DEQha!{fUr=Y!l4DIbPB9m*NpD5TG2E-=0>E*hHTS^fJC;46XKk%?;%0l{ zIbyY_YTng`Fi6iwG6)h=3)ThsHage!rU)Qhqgg%$_*7_k$6O>|4Ub^u-1}LYBw1*X zxjlBUr62w>-0~ziD}3B{9Js z?yiKc-g0~lX;m_;L0+iz#tq0tufEJFbL-+SEskj(u76TzidLn+5MHJ~V4&xipAO67 z!_X~~rvwewG$U@rsOD@x7jk!C*{fW=+)+A9#(;WpaD4oFV`!iK0n2Kj*9%;w2It3R|7y2kk&^%nppa6TJBv-EhWAe~;<-gN%4m^@FSDgrck zOSLm5_*mXX30*A0!FzT@%pr~?>T8*vJ>li83fJ@NjfOx4{6RNX!q6|!@GK*D?gW$( zNUQOsI z?2+o3^=anu#3W052X|WF1&2x8fXM^tw4J5m`H$Con>WL(* zmJab#Bz^SzE+Uihxv-z;>aul(8w=ADR5NLdhrmaA5Kf!){^Ns%$$EnoXpGCz&Js#G z{ir?H$E0S}KygRdLU^ftwj2cMXiknK6~G&M9Jx{orpS{I9kT)6d6nZC0|o5FO%16$ zj-&<|;+K`2^t`-(_oQu1=*d14!W9^~+SPS-WXI<9kmx6u?ed7qhm*$hT_a%cscO6Fa9M}3+Pa_n~TtNUYMt0t^{%1&1_Af}2-E++q; zs)`y{M4QeHR*^Iq8+dTLa_FhkGCCRIz0zHFm^9W+rZI5sIqcXhw*GCCyv2bt{#dBuDTlD3v%0m*sG` z>ClJApg0;_6lCD)=MKfFYmyIDS(5G)dV+}ANYSbyz0>T;4Xa0ex&0Z+G2CxX8UF>_ zA?eW|o4w}et0LRBB}0Q35Cu-wGrXM{&}hbT2CxONX^XNpcUfQm)mrF(^2HqjDSv;Z z4|=}GiK9LpL{BWkiK7Gfo`Scbr#)|NlJ?1;EN)JC4twVVnkvCoIQ3q|%d`0cj;brN?I6ll!$OLM(}zN25}qdKF}gLIXJVE^JX zYBb=?%b7oZ*1tfXW2Gc|fPoBd&@Mq|hAJT;qf%bhu7swd>#~h~wuluHpQB;RvQAA| zpkG}Z>^+|s{5-!X3k)fWd{&X2+{R++ssiE~&sNTOXyl-NayvcBYT2K(70Cv4(de&B z1@L0}KJdD%1)UfrFUt-~rf{dQD}#f*nrDqwbRd!-(+`F=E3X)pJOiI3PHpZ;IdF&p zgQZ+tjAn90iZxG;lgb$Ce_DVyYioJgDJOFEjiiRcDX*+phsQF28{WLn9|1UF;oUS- zABA{FX8$$j;~pI6>4S$G!yo1WSVp&{ckrUuh7F$`?8a7@1#N#au4AS(7dFTLJia45 zjLWYTw<(maNdhy1=k=;P;_8$D7R8%m+#C_EqGNWR0fnhl=J6^XcM?{M+_2ZR%u@YV5Py#PU&g{ zK&<*zNylFR)$(oeIDet@NyC08ZXU5B`>L?-{XS$?hX78Ope|{r` zk*%*)n)GU_g}-hsCsgV%LfXtVBd5|(e}CHH`U#A+JBs>rIYK(8hF1CT(^pAQ!6mR%|o2BAJA2SYw${;VG26c!hcFc_q+#V3M_+sI6 zdHN=>U7Mt-qSO0dpBOFi1l*HT9xpoG@K|PqaKft{L z0-A?}KZ2zqNUcE1GYW=nkPiYqtCZ1qd2H^}%xYW>=K?)qD-%^I6B;7ycTBJtNIjKf zM~`kaiJqr4tDE)8r7bbUE*Pk*G*V0109Pr>>|%AnPUME+@Gq)JgE1tQ3W=_?>WM)A z?}imMTGJ()%OhzqlJZLl5aG00 ze^C~#$I^$Vhu6I9+`bio#`IEh#5V!*hadS9kFO|p(mtRL0n7!`wh2woSKSK{V^)t` z)a#|u-QL(4$&gEO?A|0EA$ONXq|400vJo&$_+n5j#?HZ&dOrlzKT%vpg(2whV564u71qOP*uBKv~$ zMi(RDOV1&QN!YaIY`Fg^-C90w22<6In95VT)6VOX+V!a$Fq5W4IKB4HPx;t~d}?A;iZEZgnEL<>OQqRYvw zfmO;tCIBPmEhc=+5|Wf`<5^hlaFzhSzoESt7eLdrY_DoR6b9 z2j5%rkL~#mZ@0`dsOjq`T2d`LK{OUvjw-Ay+=>z8QS%_I}W=jBRNJ!`t@el8z>pNJ1TzX0UoykLwmJy z2r&Lx77sZLU*3-fSS!OzL)%sQp2-Nr4ggUZH_qiiMG_(P^>{CMf7@E0uPnvBdecit zo5N%08J&QG8ICd!^r}<@%-fpJU{|9C{yX5xB8p1>BE)IjgbF)gX<6IMM4 zv}rtJbw`fAdtVNAGt;f~uX4bcei8!!EFY#-+~0ydCQ~V*l(p&6g%~-s*W%;KiA=XX zfWHUZZo5nAWe{wEA_h8I{SttvI`Q|0IXO|iWe`=s`K>3So+!`d!F>Sl`?k8e%9#c> z(5E>7XakOf8h~AVDIwu#Y@+J5zo?8|+v<&KFt-d`2;!jc-n3pcN~__CkYWOW6m#HI zX;x7i=jTth7U4tMN6%vl63SM2pfu#5UrD}%>xlMW^rJkzxi$|VmJmRURKPtz0r))t z1j_>8bmk=}w-cSoma4+9551bV!v7-q6_NnPO7Cdo9R6(%U=2b65aJ$C@L;c%Pom}C z@p%A90DQMl`C@EQDq4gu_d4YqrZh|~-$(=xClT}icz|Ea!W9NP;x#3PR4Ffm6l9xc zm4U#?1x46_cL=0%Jx{*X8s?uCCes59@UX_|u+~SA)k^9IGy*iRU)10cilE>OpalW$ zCL*72Ceb`xBIU@k*@*7g3gS8i#$|PF!xy8U1i1VBX0}~GC=yCnZWn}1<0#;;07pEO z<y#xA< z-~-b*^;Xf~#=F(y9N-6ylWqpOrgs5|6oaE>VrbZceFXUNVKwdR0}!4EmT}<*gieFy z6>6X%_2T6C{}B6eGOjabJ zodV!4W}vJJ@cW6kD)e)y6cD@WMxA9>)+^BlSO-uIHw3qYfsiaPZ_dXLTML$8%auVWzuG|226l?&Zn||}= z8K7(iEju-yf${wS)3@RNy{+|^uQvDM^=IQ013t#^C!6WX&N+H--d(R z0kRQpp8H^7ldrq>jf1Kw58ndUpkk&9KzZg15_#(yfCouYw#S;XWrtz*B5#t6eM_L0 zg@d%jWQB~gfa@Ljw9dCfT`>U^Ksj`y{y5%D$?m-#VEJ8+=K{zEovK?U!zl*0(*ZlS zT?~h}Kj2~MAK1#SDxF9?Wf z_V7?fa{miEV$LxjxhnEuW}&r$rSLvE+^lP1A!9Tj*82!-0ec(99;R#+vv$*QnD^Rs z%+*kmaTJ6Og*nj!4_^%r9UWauYQN)~-yhi%1=GgH^vdtPxem7Sv$e-#Q7kw8nd94r zV*YrVQx348?52k;akG0jpG0k&o8LtlacV~Wf|^=>SPUwO4KzF=hD^9LrB5r*XfyKj z6`!Qbv(mythL^ggmP@bYv^h1NRY(E;d*BviM;aQ_?YZ* zsQJqP?F4870CmCemp|i`SHp#&TP&YH-PP`aI5-^41~^jPKFyowXN(jUn^j|};crZ2 zpoSMpKYM&q9RPKzCWZDbOLc&w5jfoQ z57eD!XQn0n?_(tN4l1EyCz7$(Jy-H5j{O36)HWVyYviwr%OT|Rm~ztImx3sF#ZTrh_-{^U&;;x$H^{@2irqG*;V^fNkg|-htQgG(>jNOxSFccVh5dqa+1s0@mXb(I z+@*5-s7p;N#1iZmECw$14*&HTGBry{K{TaOHiSOMtPHT?wDL`@f&g^7wpE#UzI1eN zb#X2$fAzfrJ>#}U4kh$vL=kU@ItrZxOcVfs9|(a0JWtC`w`0MND>c?6)uO6~9BE0uC+<-g4zQsUXTC-8jram?DqUpP zdL5u&0?rxW8(^bv;i0$s38;Ff ze<@ggrSY|k}cfCz4hAkH9-+8*y?7=8zyyN1+vyKYSi ze4F6Ad-rafj^yGy6^(kD8#FlkTM=DjA%N(cw0U~6Dw@W1A=%GCgr>J(akzV0dxU?5 zjNDjiNdYJg=fvO(>XV5biXyFHnTr z!a~4tGkCi+b-tJeTiK@os`LV6ItwLa=a*A}-3jJ>4kDQ1bClB8&Kcf0GZ3~F@k+|i z?HaHxjBn;Rfy8lhu+axFRnJLzrm=P074;HR8c6+CB!iH#MT$a|t*GN*n0eZaQzEXW=IP!=AVX_%=L#UmA&kcqAPKKrupVrrYu$$; zkvq0$_&$J~eefJ2fDU=Kfjf~3!^Iih`}3#F-X$=kHMziR5ABxVrg5mJwfRb0X21uv zJbl*27r-*{+C9j*?#TG*lzH&hWivG%s#yWRDr}khBqC1=^drohsAEf=E-s@ulflLd z3WvaQq)_YM3R-NT@6vdb#v!W#6$}xdhnB(W)6<&)3ydk?T=toWj^DpatGE;gfkLv@ zp{=E>t)8he6uJlQpiq9Yz0o#I|7tQ^{^|1yhUqAH2sY)VnW=VRv{2q_b0;UK#yK$i zo?1eB9?T{N1d?LP51d8ZbOHIgGt`?=U})S^>Xz$A?s8}V7vxYa73haTz-imasw%z9 z*2&A$Jg)u#mt@R9dF-~0OH{^p%|!H!K>dNEi8%$0c_5iyb)!tz0J89jF?u2=eBO)Rq)ygxnKyzqL8+Yz(EBL+l zp5>W!c0gVId557Unc7r$9CYfb^i{}qbGaSgh6GRw_$vi-)G+?4Lh?Z8Qy)LSl4r)S zilBOf(7tnCR0ItsRNv4M6v>z*e2R zONFQ;FTJ7=vny6^c+A+KprWF(?*JaryN3c|*sL*UdsHNGL-+qI;{*j1wk`5 z&@=|=x}hF=a$lFAjSV}?!2J|L{Eh6HEkFRgS4`{EoLEf2w9t*~mRZ*b> z(is#bHGOy}!2wBqeLb^?$e6v)$<#e1HH4V~ zTHH=x8mek&Wcd3N0pf$$u)xIK;T0#Jj~^efut+_7_AKEcN`Eq z|3R_*e=qnyW|ThvGmCCPMC6*9nk+0Vpg7aU=BBca&X?z6V#2ymLY~|+U-LL*dI0!Z z(;WHrYYtdhK*F)!x>eIN3#+jux^e~Mwre%`A;stlar7IFnQV4-RojFE4@>-DVS2hW zn6Wj?zNg})#C~3`ParKRKkb;Z=7^tR-ncqGE1U9Cj_Z`llW`VxTqy2jfH={7LGdDC#FOmOl)irUjp zaLLX_tGHnUm7jY`V!r$2ZkLNVqyOI3pr=T9`dyLX?tdj&uAlWyzrt5v>MH!7 zL-e0d+{tO^_F`$(R<{prpRvpn;5p)@f#w)Sk>Qqi9nA03{YP8Rs|C>kBGvX8GO-AQ zF&^3Dhhk;*k#Vkvuu412YM#aEoyW~Xd%b&>-D=u~r&A8yNnRJ4PcJy>Di#+=&lQeWq#ZR{RdvIyFPYJQr)1@BP=%2kY zy$?v_h|rWaUwVY4$mEuDtG+&0RnkNa(%5Y+EVHrX*l`UsMmv+?nyi2}QpX|Rn>Fe~ z=-Xsvk~|Vo6Xs%8jVAF|&!`UWE|lKdiXJX{boZF?i)G!v1GCO)sFbqp)wp%Xs zSgYMY4ChdY@dQi~1|w8-mm(Ms9i`+wszfB5E32C^$K|>{TKnrab$K#UM2;qZi#HdN zsy?VLk|OQt{d_JhcjY?ahlNVX0}{6l8q2oK;VKt+)q%%D#8h{Y(-l4qBgiWO9o{`P z`uBEKj;>tSIkrSNWHT~zHsyML726)mO2pGumn7-1I+L5d?Jz4L@S{zO`d3-)d}t&p zc0Hn$49-UWIXRBKZhr6Oe{JRy;$M&v&ZSb4if6Wqk2m9m<0?h8!?xnPB&ow_*(Lpn z3Z%md0$79L@Z)4D+|isr(T1vA*Dp(BO4U+A>%(jtR9n>F0t)J#o=J1DyDZgBj_HL! zl@BA`|OSgW%Zj_}AtbMrQWjwNj_4=Z7lmn@4j38d_Q(UK%*!6_vHpAFsTEUVZAEV%rPa zndV}OPxFk(b6I*uB1NpcJCKAJMpF%GmT$Bcp(kd7$3p&NMWYLy^h4>tR*DDfb}QFm zUzoKw(5k8ka#g@TB1xCBIe6#shiip8jq+fbB#w!w7^{-DjfJEvye?m57}upKB!%~5 zmtXD=x+bYyeJ4Wb`M9c8z^#vRV(5;Jx5wD2CyP`XW+WWhl@|oCa97D^=ZXqG!~N9h zp-<Bq3b)pqML=e95vqWP^y}39X||b+}qdK zUauE+7_4#exi}C}RsBNV3!{51*#ZAmcB6_L;t&T)LbVP+YMer{wW%t z&iX3@e5^7g-l6i>UN!1OSly&;VFDzYQG4-?WdnG@G|nBCWM-~WczvrRMZsHy8We8Ra9Fi9~sBV zNm_}l?6M$<>+YcXLaNca&yGBawss?f+gR#?BBp=(fB5HmK5@X-6yKNi)JuPF$P&Cp zP{l-k?d|j*$^}|%1)AU|r$BS`3NPcovQenP;PH&1hA~FVh?gmLj_SHi(<6`|TK{N- z^uL`3cu_R9*S(Kr8t+X1bG!dp3zrLr+X%VzJ%~jP-uv37Td5nrGl!AXKE1Fe_S@>7 z&gj8`Ob=PjFkKMsPYO16&lgY5ehy``I?&M19?QKisx=0z?$h&4!}^6_=e?ENx`K=P zJ&G}{H&j7$=0kyHD_tkd2VncxGDB&q!fI%WW}d92UZSm^RmHw0l(Vp-1qOBhZUz`^ zKO{-`{`hC2@AA{;#7ENuGaXd7;~Sx=w^cg#nKhB{Q5>?|po16G&QO+-zcBIBu*CtM zl@(55BjaYC8BeVbx>#DS}<*8xAE&;6mQPKcswX|{}y)dpT&jKkh;ZX(W72FGxG@Dcn0PZ#Z# zr%-Y{d3%<=$vWJVDYGbB$9Z%=<7i_hAosZH$i#=yoM6~doY7{?s)oX1b9eTxL|Ytv zPisTyx+Uy#u<2oyf`xv)M~d!b>v=7Ssj8?IOO~U5x@!wMuffBM85ip`?YFx{#|5nR zTh?%R&|xQ3PRIO_1RPg>OkDoBF<~ixw`K|y!gN_|xnAWo+Tb9(@#D(&*najQKjPP# zb;7!1*`ojzXtHX@lJ(#77S0!_h|$9hOAI#GSsf zR0!j_VM0%3NoxnLq>T_O=#~GyPHg+P*ltGuWapB#M+Wy2k9kv)XUhOqOt+koC09%a zwV#=p(^tZ(w)7=bOm`TlwHPZytS{**s8P1DkI7j@-B!FhiJ0kdF41%w&(%pebLiG; zwcE9DRcxlcXJ|)g?QVC;TU?Z-;CAPsGr)|QPfpFoal2I^^&BNp!&P%F(l@BD^N+v54Whog{w^3^5+rD!ks#PWYK0j6+YO5u*+?pZSN{R3}HQ@5$TT3 zgDKGYu@!G=}(cbO7iqT;M22dLGJMa zo|RqAT_?MxrDePQQX+G8l7drtaPAko(6DfUr9^afdMWASmgY9)XKzAttELPbCGpwa z-FA+FOSdF}q`~g_q8NL6!E4YenUpYoJDMC8rsOCq*LlBizO}9uju5IRJ(Iw>x(orh8kr``$CPh-xHNceqPL>vQV4 zJt<_AH0kZl)8D3RK33tBNMPNiFK3v-BYhWl@ z$knzEQK959nx9+TkZxG2)6_Sc@-ghoH2k7=oTs=+$rc$LEL-X3giF0VOvQzWAxVO zL+~#rg?OjA$;qS5z{vt$%T3em@Xi{MAOy0nCCQkWtogF@nz-?KI486mJyPS21XO4|Nv3QH9!_tJ}LKj5wM zdn~AITe`#>G{~ifok(=B>|VVw!m?H%7HO1!6T90zDfJhrs+7)0|5jGaa-&S!muk5_ zjH8&vYN=UXwpxIN1s!%FdzA&9)m0+8+S6)}du5?VlS9rhm+lITfOL4_aOz348p!Oj zmsNp!T59Ydn4n&2`vN!f!g9P!BFv#QRRzcpik9cMRllm}e@|P60bl4X*{TKim7(y=GJTJn@lmG~Pj0ISJ-5wqNE}u6DD|P` z$$D| z=T%q#N;&n2=O&5zVi^^Bacums-%*6lz_7@4TwWl=OCi5Yk?`2C94sMJM(Kc~jMdn) zQIBC+NNmQhxo77LJ2Nx#y%Ka@=}93w(o4QwO8~-^U{`xhm&esTVBEi=;_UX?uykEJ z9$j&8wsL#yK6Y)l#H>9!K<9Q>7B`KW0Zmgcb3n1xrmu8ZPfsZ8UZ=>;1^7cTyW zW}IxCjou+VMnCEJp&oMIQ&iTostU0?o(T79KVy7UR9)+KN9bWu*Y0%ov%{^v_tp+w zK87H%LoHhi6EYZ23MX>Nw{;HIU0mGfrkSzn#ZO)Ww=1v{ z-dkqHher*~xJoIl6|Kfq+ho2BzWFU@*s0lSSMjL!EI+NBxVUdCZr7}|yHhwMT0cp2 z#I-OB1>cHJ*Xzx=YD&jD)MB9FFp5rJ*e((6KDRqEltUv_eW@< z0Y%fe-9s+#aYvF`lux|6@^zBPzE!|RK&x&en=77xKrapQYk9d?zI*7KEP>{i2#avb zkO2!QZcBf(uUh)jp-BT}SgPQC5=8@4wVO5_#UXbXM#II6||Cj@(u!@vA^X8T|&* z^sPFWw1+WzyVnal5Y-OskE-nG5ir>>X1 z+87w~N@gJ=Zb*wwr$R?_ke`JO<;Fgk;->DgH7G>Fz^QPE*i|tzvq|xop>I;x(D+NB zp!md;I%!$hQx`#XbgcOt{%)1loU z6&rGIAx4ozVwFmgy0bc($Esc7Sj1dFMJ4-(enq-=@k(#fQKjx(wcK)qKjR|mf;4r- zeYqPpzxVf;ne)S;F}2vumFgJCmA;p-M#Oja+&QicFKdr-0}Sa5or(ts{s^ zc*Ic#tSjBMwomQKVb=ueS;3%y2-hHg88xrT$6~8-Hov;A?8RgR64_*s-_+5e4swE7 zM0D;{FHAG6Ha6B(aYhBPGs8kwy9qi`_cs-3q47aerBLr5+$)nWF4w7!PA=Zg> zdA_9s^(QAzYu?r}^>~`+t7sA&)$wbFer1iTqM_Jj~Dz2{Lm5Q|GJInUM zH4Xw_MLBxmrJg**%?@#(Fj~B>@>ROljm>;t;@Ebqt1=N8IkV71l~L@yOl=&a2Th?C zkh|#t_X7pr1R+yfIjGR|qf4Tq+QoLVABJ05sr5P$V0;i8gX#|LO>e-sDXk^D8XHF* z?fS%0yS3YpEZ|U9XJ!)9-E8^3=1$X+&U3^I%PGTc9%^jm?3kFC|H!MwahJhv?Y zF3wOQOCGI~W<8iZukWeLD_5!0{OK#T<@}G<28O=wO{--O>#X*$ZqC6K5&WMtIltx; zQ_gtwY_0kwE)B8XXU!E0JaYNj6xTpZ)hl0U;sqE8xRUhICW&5#yHd(8Ng1-y(Ye5- zfI*}=qMYT|hAQQc6S&nWKhxw`)m?(?n?%VdgVdb4$Gg7f^f?)sn_R?V&COpiPKUw% z{(%Y|y;cN=9rY}rH>ZVjye-fRm^FJ~{wjAo-&cpvswiP^80GITM9s+KM3(SZO6Z`U zoC6shDw&&4OR4eds?Is*B820r`I9+|o-rXwC9|i)4-L4oCGJ<+?VpcSQ(Ib9pXYTw zc=wG{vU_7ohE2L70v)VMcUMejUr^k~%m{rx2p^dgr;%0@s{L+iFA!D2~1Otuj+2M6|4dOkdNP1gCN7#JoK(;T*@r# z64Dm!$fu+HR5EOIHKpKXmH5~bZgJ^oFAfl$1E~mY?wjW#qt&^vqm3r2#>T3Zm)a!= zuU7iSfud_o!l2~Ek}f7*4>JZK$-HjdRA_dCY(v>}X;RR;uN z=W8#p%QlSdHsFP(%GYpxIV2mKb;nz5!{pa!QwL%mK&o9w71q4C-&zLujfs^yyG-nD zk(6{R2IML`^e0s{uE>K9!*SMX;>O0K4!Uce+1bk@FV{XH9ah3J4i3(;9Ux*~64U5d z>#I5CBDR;2&(0A*^p;ideurwEeD{E!Rb}fNJ|NygI>QiQ9ygT7pHjOc z??A?#mM=iv9S5KItQIs8v(v5Pk&h^6G{?t?Am*wY7Ner)%8D_)mySI$8O_BTgY&x(#`BwmD6j~z`j zQ4e!8=kUZC&Xu^4(@dc_)mWkMJdc%g4m@)}hIaUpl8*gs zJ>ocpUn|cgmX_4-;?_!76F0hViyxRGOY-iRL8%gkw=7UxP0F?3&_mqrJP;U zGKzf>0DcqGvKtGkTnp-R{V`zQ(JxUrw>WwK=$>C)ay#9$<4R<>alXb@JPkTa+r7L$ zxuym->at=S)Gq{dKVYAh)m}QHGf3gy`7()^vqrZxHX`+e%Tb0p*4c-Tn; zRM-7u8~8clJ}Mp~?|w@ebsfJL#Bc|7ue^1|VJN*D!dKwOOV)e$l$1s@f;~i6aQX=d z#mP6x`0#$QusDwd^Gs$$%EDcoS@=h}U%6eJ)xDj<6QAXN(K)PUyn!qoFcV)#&zx`5 zFI53<5ea9Rayd$UE;)1sq4@oW4-wr=itQI)N`6_O7wFGdR*~#xSUa*zapX#^$JMmG zqw_tExG(=ZY(xIIcVY)}Z4^c0`;!*pZ=|Spj4u!IuB4SAoSRK{SwgqQJe0YNUC3lT z55$BWw#`W7KK1;T7{hh9Io_q!tC&4|xbyxU^}#+aBGk3h(Y?2?Bda3oW!Tn^aP-1P zs4(~MU9@?3h>EaXn^|S?>#wVB9-&!qI>JsYP2Xcq4P9$b$*`i?#1dAaCV4uf$XeF4 zAEhVivtq4(JmBIgHTXzhg(xy<*H1t8Ovw#0?ik4^{p)qw!5mXbM;FETe5oawtV|!d zsblX)$|^YL4Bx@VC(6LTJz~44%*@DO`PQmxc z+kKJ5eFmocPHNR6!omuJ&CUXqYgF3K4?J2)bsrnQPnUisXun&C7FSj|g-3b;T09fK=@XS+`Ax@g>JeF*9$pi2H<{&jkV~oB6Z+o`t?%KJWwVHPfWe3$>y7|XOxy@K})cbj`9?B%>Zxk`6&P5 z!wZB#?mYOYfi&;urF1X0p#dYham%*u(QrSD2DWKOT-BQ^!kNV^1o@V*huGZrJLfRZ5bJYp9 zZskkd9Fk3eT6T(Gc48QO8--o5GUJ^1y~aziILZ{y)ECs?vYr)cMJb4NeowepU-}$E z7eYtS6XIqIOiKqYq|G8S5v?rOv$r`_p51z;rkqYwVZ=bgC||>||2=|W z3{A|{YHFJxME=QP!;6}juiyVK DdS|ua From 6e444964d0d90107dce76b34551541671dae0e88 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 11 Feb 2021 15:27:24 -0800 Subject: [PATCH 003/175] [Security Solution][Exceptions Table] - Fix exceptions table search by name (#88701) Addresses #88450 Issue Search was not working as expected was because the exception list property name is mapped as a keyword - this means it does not get tokenized which is why one word searches were working but if the name included multiple words and was partial, it was not filtering properly. --- .../hooks/use_exception_lists.test.ts | 2 +- .../plugins/lists/public/exceptions/types.ts | 2 + .../lists/public/exceptions/utils.test.ts | 16 +- .../plugins/lists/public/exceptions/utils.ts | 5 +- x-pack/plugins/lists/public/shared_exports.ts | 1 + .../server/saved_objects/exception_list.ts | 10 ++ .../alerts_detection_exceptions_table.spec.ts | 147 ++++++++++++++++++ .../cypress/objects/exception.ts | 24 +++ .../cypress/screens/exceptions.ts | 20 +++ .../cypress/screens/rule_details.ts | 2 + .../cypress/tasks/api_calls/exceptions.ts | 25 +++ .../cypress/tasks/exceptions_table.ts | 50 ++++++ .../cypress/tasks/rule_details.ts | 5 + .../reference_error_modal.tsx | 1 + .../rules/all/exceptions/columns.tsx | 14 ++ .../all/exceptions/exceptions_search_bar.tsx | 53 +++++++ .../rules/all/exceptions/exceptions_table.tsx | 47 +++++- .../rules/all/exceptions/translations.ts | 14 ++ .../exceptions/use_all_exception_lists.tsx | 2 + .../rules/all/helpers.test.tsx | 62 +++++++- .../detection_engine/rules/all/helpers.ts | 30 ++++ .../detection_engine/rules/details/index.tsx | 1 + .../public/shared_imports.ts | 1 + 23 files changed, 514 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/exceptions/alerts_detection_exceptions_table.spec.ts create mode 100644 x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts create mode 100644 x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_search_bar.tsx diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts index 6ac15fa990b6de..7fdf861543117d 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts @@ -201,7 +201,7 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(exception-list.attributes.created_by:Moi* OR exception-list-agnostic.attributes.created_by:Moi*) AND (exception-list.attributes.name:Sample Endpoint* OR exception-list-agnostic.attributes.name:Sample Endpoint*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)', + '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index 0758b5babfc0ac..e37c03978c9f6d 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -128,6 +128,8 @@ export interface ExceptionListFilter { name?: string | null; list_id?: string | null; created_by?: string | null; + type?: string | null; + tags?: string | null; } export interface UseExceptionListsProps { diff --git a/x-pack/plugins/lists/public/exceptions/utils.test.ts b/x-pack/plugins/lists/public/exceptions/utils.test.ts index cb13b1aef97ea4..47279de0a84c8e 100644 --- a/x-pack/plugins/lists/public/exceptions/utils.test.ts +++ b/x-pack/plugins/lists/public/exceptions/utils.test.ts @@ -115,7 +115,7 @@ describe('Exceptions utils', () => { const filters = getGeneralFilters({ created_by: 'moi', name: 'Sample' }, ['exception-list']); expect(filters).toEqual( - '(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample)' ); }); @@ -126,7 +126,7 @@ describe('Exceptions utils', () => { ]); expect(filters).toEqual( - '(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample)' ); }); }); @@ -179,7 +179,7 @@ describe('Exceptions utils', () => { const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], false); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*)' ); }); @@ -187,7 +187,7 @@ describe('Exceptions utils', () => { const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], true); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*) AND (exception-list.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*)' ); }); }); @@ -213,7 +213,7 @@ describe('Exceptions utils', () => { const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], false); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi*) AND (exception-list-agnostic.attributes.name:Sample*) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' ); }); @@ -221,7 +221,7 @@ describe('Exceptions utils', () => { const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], true); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi*) AND (exception-list-agnostic.attributes.name:Sample*) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' ); }); }); @@ -251,7 +251,7 @@ describe('Exceptions utils', () => { ); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' ); }); @@ -263,7 +263,7 @@ describe('Exceptions utils', () => { ); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' ); }); }); diff --git a/x-pack/plugins/lists/public/exceptions/utils.ts b/x-pack/plugins/lists/public/exceptions/utils.ts index 51dec8bb49007b..009d6e56dc022e 100644 --- a/x-pack/plugins/lists/public/exceptions/utils.ts +++ b/x-pack/plugins/lists/public/exceptions/utils.ts @@ -74,10 +74,11 @@ export const getGeneralFilters = ( return Object.keys(filters) .map((filterKey) => { const value = get(filterKey, filters); - if (value != null) { + if (value != null && value.trim() !== '') { const filtersByNamespace = namespaceTypes .map((namespace) => { - return `${namespace}.attributes.${filterKey}:${value}*`; + const fieldToSearch = filterKey === 'name' ? 'name.text' : filterKey; + return `${namespace}.attributes.${fieldToSearch}:${value}`; }) .join(' OR '); return `(${filtersByNamespace})`; diff --git a/x-pack/plugins/lists/public/shared_exports.ts b/x-pack/plugins/lists/public/shared_exports.ts index d91910ad5ed28f..c9938897b50932 100644 --- a/x-pack/plugins/lists/public/shared_exports.ts +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -32,6 +32,7 @@ export { } from './exceptions/api'; export { ExceptionList, + ExceptionListFilter, ExceptionListIdentifiers, Pagination, UseExceptionListItemsSuccess, diff --git a/x-pack/plugins/lists/server/saved_objects/exception_list.ts b/x-pack/plugins/lists/server/saved_objects/exception_list.ts index 9766c0bcb98724..d380e821034e91 100644 --- a/x-pack/plugins/lists/server/saved_objects/exception_list.ts +++ b/x-pack/plugins/lists/server/saved_objects/exception_list.ts @@ -47,9 +47,19 @@ export const commonMapping: SavedObjectsType['mappings'] = { type: 'keyword', }, name: { + fields: { + text: { + type: 'text', + }, + }, type: 'keyword', }, tags: { + fields: { + text: { + type: 'text', + }, + }, type: 'keyword', }, tie_breaker_id: { diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/alerts_detection_exceptions_table.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/alerts_detection_exceptions_table.spec.ts new file mode 100644 index 00000000000000..aa469a0cb25311 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/alerts_detection_exceptions_table.spec.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 { exception, exceptionList, expectedExportedExceptionList } from '../../objects/exception'; +import { newRule } from '../../objects/rule'; + +import { RULE_STATUS } from '../../screens/create_new_rule'; + +import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; +import { createCustomRule } from '../../tasks/api_calls/rules'; +import { goToRuleDetails, waitForRulesToBeLoaded } from '../../tasks/alerts_detection_rules'; +import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { + addsExceptionFromRuleSettings, + goBackToAllRulesTable, + goToExceptionsTab, + waitForTheRuleToBeExecuted, +} from '../../tasks/rule_details'; + +import { DETECTIONS_URL } from '../../urls/navigation'; +import { cleanKibana } from '../../tasks/common'; +import { + deleteExceptionListWithRuleReference, + deleteExceptionListWithoutRuleReference, + exportExceptionList, + goToExceptionsTable, + searchForExceptionList, + waitForExceptionsTableToBeLoaded, + clearSearchSelection, +} from '../../tasks/exceptions_table'; +import { + EXCEPTIONS_TABLE_LIST_NAME, + EXCEPTIONS_TABLE_SHOWING_LISTS, +} from '../../screens/exceptions'; +import { createExceptionList } from '../../tasks/api_calls/exceptions'; + +describe('Exceptions Table', () => { + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsIndexToBeCreated(); + createCustomRule(newRule); + goToManageAlertsDetectionRules(); + goToRuleDetails(); + + cy.get(RULE_STATUS).should('have.text', '—'); + + esArchiverLoad('auditbeat_for_exceptions'); + + // Add a detections exception list + goToExceptionsTab(); + addsExceptionFromRuleSettings(exception); + waitForTheRuleToBeExecuted(); + + // Create exception list not used by any rules + createExceptionList(exceptionList).as('exceptionListResponse'); + + goBackToAllRulesTable(); + waitForRulesToBeLoaded(); + }); + + after(() => { + esArchiverUnload('auditbeat_for_exceptions'); + }); + + it('Filters exception lists on search', () => { + goToExceptionsTable(); + waitForExceptionsTableToBeLoaded(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 3 lists`); + + // Single word search + searchForExceptionList('Endpoint'); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); + + // Multi word search + clearSearchSelection(); + searchForExceptionList('New Rule Test'); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 2 lists`); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(0).should('have.text', 'Test exception list'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(1).should('have.text', 'New Rule Test'); + + // Exact phrase search + clearSearchSelection(); + searchForExceptionList('"New Rule Test"'); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'New Rule Test'); + + // Field search + clearSearchSelection(); + searchForExceptionList('list_id:endpoint_list'); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); + + clearSearchSelection(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 3 lists`); + }); + + it('Exports exception list', async function () { + cy.intercept(/(\/api\/exception_lists\/_export)/).as('export'); + + goToExceptionsTable(); + waitForExceptionsTableToBeLoaded(); + + exportExceptionList(); + + cy.wait('@export').then(({ response }) => { + cy.wrap(response!.body).should( + 'eql', + expectedExportedExceptionList(this.exceptionListResponse) + ); + }); + }); + + it('Deletes exception list without rule reference', () => { + goToExceptionsTable(); + waitForExceptionsTableToBeLoaded(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 3 lists`); + + deleteExceptionListWithoutRuleReference(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 2 lists`); + }); + + it('Deletes exception list with rule reference', () => { + goToExceptionsTable(); + waitForExceptionsTableToBeLoaded(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 2 lists`); + + deleteExceptionListWithRuleReference(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/objects/exception.ts b/x-pack/plugins/security_solution/cypress/objects/exception.ts index 8e22784087dd65..73457f10ccec6f 100644 --- a/x-pack/plugins/security_solution/cypress/objects/exception.ts +++ b/x-pack/plugins/security_solution/cypress/objects/exception.ts @@ -11,8 +11,32 @@ export interface Exception { values: string[]; } +export interface ExceptionList { + description: string; + list_id: string; + name: string; + namespace_type: 'single' | 'agnostic'; + tags: string[]; + type: 'detection' | 'endpoint'; +} + +export const exceptionList: ExceptionList = { + description: 'Test exception list description', + list_id: 'test_exception_list', + name: 'Test exception list', + namespace_type: 'single', + tags: ['test tag'], + type: 'detection', +}; + export const exception: Exception = { field: 'host.name', operator: 'is', values: ['suricata-iowa'], }; + +export const expectedExportedExceptionList = (exceptionListResponse: Cypress.Response) => { + const jsonrule = exceptionListResponse.body; + + return `{"_version":"${jsonrule._version}","created_at":"${jsonrule.created_at}","created_by":"elastic","description":"${jsonrule.description}","id":"${jsonrule.id}","immutable":false,"list_id":"test_exception_list","name":"Test exception list","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"${jsonrule.tie_breaker_id}","type":"detection","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","version":1}\n"\n""\n{"exception_list_items_details":"{"exported_count":0}\n"}`; +}; diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index dbd55a293f6a0a..7cd273b1db746f 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -23,3 +23,23 @@ export const OPERATOR_INPUT = '[data-test-subj="operatorAutocompleteComboBox"]'; export const VALUES_INPUT = '[data-test-subj="valuesAutocompleteMatch"] [data-test-subj="comboBoxInput"]'; + +export const EXCEPTIONS_TABLE_TAB = '[data-test-subj="allRulesTableTab-exceptions"]'; + +export const EXCEPTIONS_TABLE = '[data-test-subj="exceptions-table"]'; + +export const EXCEPTIONS_TABLE_SEARCH = '[data-test-subj="header-section-supplements"] input'; + +export const EXCEPTIONS_TABLE_SHOWING_LISTS = '[data-test-subj="showingExceptionLists"]'; + +export const EXCEPTIONS_TABLE_DELETE_BTN = '[data-test-subj="exceptionsTableDeleteButton"]'; + +export const EXCEPTIONS_TABLE_EXPORT_BTN = '[data-test-subj="exceptionsTableExportButton"]'; + +export const EXCEPTIONS_TABLE_SEARCH_CLEAR = '[data-test-subj="header-section-supplements"] button'; + +export const EXCEPTIONS_TABLE_LIST_NAME = '[data-test-subj="exceptionsTableName"]'; + +export const EXCEPTIONS_TABLE_MODAL = '[data-test-subj="referenceErrorModal"]'; + +export const EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN = '[data-test-subj="confirmModalConfirmButton"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index a45b3f67457b98..f9590b34a0a11c 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -97,3 +97,5 @@ export const getDetails = (title: string) => export const removeExternalLinkText = (str: string) => str.replace(/\(opens in a new tab or window\)/g, ''); + +export const BACK_TO_RULES = '[data-test-subj="ruleDetailsBackToAllRules"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts new file mode 100644 index 00000000000000..7363bd5991b1c2 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExceptionList } from '../../objects/exception'; + +export const createExceptionList = ( + exceptionList: ExceptionList, + exceptionListId = 'exception_list_testing' +) => + cy.request({ + method: 'POST', + url: 'api/exception_lists', + body: { + list_id: exceptionListId != null ? exceptionListId : exceptionList.list_id, + description: exceptionList.description, + name: exceptionList.name, + type: exceptionList.type, + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts b/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts new file mode 100644 index 00000000000000..5b9cff5ec158e7 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EXCEPTIONS_TABLE_TAB, + EXCEPTIONS_TABLE, + EXCEPTIONS_TABLE_SEARCH, + EXCEPTIONS_TABLE_DELETE_BTN, + EXCEPTIONS_TABLE_SEARCH_CLEAR, + EXCEPTIONS_TABLE_MODAL, + EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN, + EXCEPTIONS_TABLE_EXPORT_BTN, +} from '../screens/exceptions'; + +export const goToExceptionsTable = () => { + cy.get(EXCEPTIONS_TABLE_TAB).should('exist').click({ force: true }); +}; + +export const waitForExceptionsTableToBeLoaded = () => { + cy.get(EXCEPTIONS_TABLE).should('exist'); + cy.get(EXCEPTIONS_TABLE_SEARCH).should('exist'); +}; + +export const searchForExceptionList = (searchText: string) => { + cy.get(EXCEPTIONS_TABLE_SEARCH).type(searchText, { force: true }).trigger('search'); +}; + +export const deleteExceptionListWithoutRuleReference = () => { + cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click(); + cy.get(EXCEPTIONS_TABLE_MODAL).should('not.exist'); +}; + +export const deleteExceptionListWithRuleReference = () => { + cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click(); + cy.get(EXCEPTIONS_TABLE_MODAL).should('exist'); + cy.get(EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN).first().click(); + cy.get(EXCEPTIONS_TABLE_MODAL).should('not.exist'); +}; + +export const exportExceptionList = () => { + cy.get(EXCEPTIONS_TABLE_EXPORT_BTN).first().click(); +}; + +export const clearSearchSelection = () => { + cy.get(EXCEPTIONS_TABLE_SEARCH_CLEAR).first().click(); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 06c4fb572650b9..57037e9f269b47 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -18,6 +18,7 @@ import { } from '../screens/exceptions'; import { ALERTS_TAB, + BACK_TO_RULES, EXCEPTIONS_TAB, REFRESH_BUTTON, REMOVE_EXCEPTION_BTN, @@ -90,3 +91,7 @@ export const waitForTheRuleToBeExecuted = async () => { status = await cy.get(RULE_STATUS).invoke('text').promisify(); } }; + +export const goBackToAllRulesTable = () => { + cy.get(BACK_TO_RULES).click(); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/reference_error_modal/reference_error_modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/reference_error_modal/reference_error_modal.tsx index f69743b7bb7b1e..20744c3a22515f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/reference_error_modal/reference_error_modal.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/reference_error_modal/reference_error_modal.tsx @@ -69,6 +69,7 @@ export const ReferenceErrorModalComponent: React.FC = confirmButtonText={confirmText} buttonColor="danger" defaultFocusedButton="confirm" + data-test-subj="referenceErrorModal" >

{contentText}

diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx index 98f0a3d87bc5dc..d11ceee7f59783 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx @@ -40,6 +40,19 @@ export const getAllExceptionListsColumns = ( ), }, + { + align: 'left', + field: 'name', + name: i18n.EXCEPTION_LIST_NAME, + truncateText: true, + dataType: 'string', + width: '10%', + render: (value: ExceptionListInfo['name']) => ( + +

{value}

+
+ ), + }, { align: 'center', field: 'rules', @@ -109,6 +122,7 @@ export const getAllExceptionListsColumns = ( })} aria-label="Export exception list" iconType="exportAction" + data-test-subj="exceptionsTableExportButton" /> ), }, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_search_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_search_bar.tsx new file mode 100644 index 00000000000000..9c2b427948fd8f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_search_bar.tsx @@ -0,0 +1,53 @@ +/* + * 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 { EuiSearchBar, EuiSearchBarProps } from '@elastic/eui'; + +import * as i18n from './translations'; + +interface ExceptionListsTableSearchProps { + onSearch: (args: Parameters>[0]) => void; +} + +export const EXCEPTIONS_SEARCH_SCHEMA = { + strict: true, + fields: { + created_by: { + type: 'string', + }, + name: { + type: 'string', + }, + type: { + type: 'string', + }, + list_id: { + type: 'string', + }, + tags: { + type: 'string', + }, + }, +}; + +export const ExceptionsSearchBar = React.memo(({ onSearch }) => { + return ( + + ); +}); + +ExceptionsSearchBar.displayName = 'ExceptionsSearchBar'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 350a05bad2a1aa..d5acf0e1de3cf8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -6,14 +6,20 @@ */ import React, { useMemo, useEffect, useCallback, useState } from 'react'; -import { EuiBasicTable, EuiEmptyPrompt, EuiLoadingContent, EuiProgress } from '@elastic/eui'; +import { + EuiBasicTable, + EuiEmptyPrompt, + EuiLoadingContent, + EuiProgress, + EuiSearchBarProps, +} from '@elastic/eui'; import styled from 'styled-components'; import { History } from 'history'; import { AutoDownload } from '../../../../../../common/components/auto_download/auto_download'; import { NamespaceType } from '../../../../../../../../lists/common'; import { useKibana } from '../../../../../../common/lib/kibana'; -import { useApi, useExceptionLists } from '../../../../../../shared_imports'; +import { ExceptionListFilter, useApi, useExceptionLists } from '../../../../../../shared_imports'; import { FormatUrl } from '../../../../../../common/components/link_to'; import { HeaderSection } from '../../../../../../common/components/header_section'; import { Loader } from '../../../../../../common/components/loader'; @@ -25,17 +31,14 @@ import { AllExceptionListsColumns, getAllExceptionListsColumns } from './columns import { useAllExceptionLists } from './use_all_exception_lists'; import { ReferenceErrorModal } from '../../../../../components/value_lists_management_modal/reference_error_modal'; import { patchRule } from '../../../../../containers/detection_engine/rules/api'; +import { ExceptionsSearchBar } from './exceptions_search_bar'; +import { getSearchFilters } from '../helpers'; // Known lost battle with Eui :( // eslint-disable-next-line @typescript-eslint/no-explicit-any const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any; export type Func = () => Promise; -export interface ExceptionListFilter { - name?: string | null; - list_id?: string | null; - created_by?: string | null; -} interface ExceptionListsTableProps { history: History; @@ -71,8 +74,10 @@ export const ExceptionListsTable = React.memo( const [referenceModalState, setReferenceModalState] = useState( exceptionReferenceModalInitialState ); + const [filters, setFilters] = useState(undefined); const [loadingExceptions, exceptions, pagination, refreshExceptions] = useExceptionLists({ errorMessage: i18n.ERROR_EXCEPTION_LISTS, + filterOptions: filters, http, namespaceTypes: ['single', 'agnostic'], notifications, @@ -224,6 +229,29 @@ export const ExceptionListsTable = React.memo( ); }, []); + const handleSearch = useCallback( + async ({ + query, + queryText, + }: Parameters>[0]): Promise => { + const filterOptions = { + name: null, + list_id: null, + created_by: null, + type: null, + tags: null, + }; + const searchTerms = getSearchFilters({ + defaultSearchTerm: 'name', + filterOptions, + query, + searchValue: queryText, + }); + setFilters(searchTerms); + }, + [] + ); + const handleCloseReferenceErrorModal = useCallback((): void => { setDeletingListIds([]); setShowReferenceErrorModal(false); @@ -321,11 +349,14 @@ export const ExceptionListsTable = React.memo( split title={i18n.ALL_EXCEPTIONS} subtitle={} - /> + > + {!initLoading && } + {loadingTableInfo && !initLoading && !showReferenceErrorModal && ( )} + {initLoading ? ( ) : ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts index 2c0281ccb89776..0dd016425f4e65 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts @@ -14,6 +14,13 @@ export const EXCEPTION_LIST_ID_TITLE = i18n.translate( } ); +export const EXCEPTION_LIST_NAME = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.all.exceptions.listName', + { + defaultMessage: 'Name', + } +); + export const NUMBER_RULES_ASSIGNED_TO_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.all.exceptions.numberRulesAssignedTitle', { @@ -131,3 +138,10 @@ export const referenceErrorMessage = (referenceCount: number) => 'This exception list is associated with ({referenceCount}) {referenceCount, plural, =1 {rule} other {rules}}. Removing this exception list will also remove its reference from the associated rules.', values: { referenceCount }, }); + +export const EXCEPTION_LIST_SEARCH_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.searchPlaceholder', + { + defaultMessage: 'e.g. Example List Name', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx index 15fd9b0f36bd23..d104026c79bfca 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/use_all_exception_lists.tsx @@ -83,6 +83,8 @@ export const useAllExceptionLists = ({ const fetchData = async (): Promise => { if (exceptionLists.length === 0 && isSubscribed) { setLoading(false); + setExceptions([]); + setExceptionsListInfo({}); return; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.tsx index fc4a5a167af2b9..ebd059971b1401 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.tsx @@ -5,10 +5,17 @@ * 2.0. */ -import { bucketRulesResponse, caseInsensitiveSort, showRulesTable } from './helpers'; +import { + bucketRulesResponse, + caseInsensitiveSort, + showRulesTable, + getSearchFilters, +} from './helpers'; import { mockRule, mockRuleError } from './__mocks__/mock'; import uuid from 'uuid'; import { Rule, RuleError } from '../../../../containers/detection_engine/rules'; +import { Query } from '@elastic/eui'; +import { EXCEPTIONS_SEARCH_SCHEMA } from './exceptions/exceptions_search_bar'; describe('AllRulesTable Helpers', () => { const mockRule1: Readonly = mockRule(uuid.v4()); @@ -98,4 +105,57 @@ describe('AllRulesTable Helpers', () => { }); }); }); + + describe('getSearchFilters', () => { + const filterOptions = { + name: null, + list_id: null, + created_by: null, + type: null, + tags: null, + }; + + test('it does not modify filter options if no query clauses match', () => { + const searchValues = getSearchFilters({ + query: null, + searchValue: 'bar', + filterOptions, + defaultSearchTerm: 'name', + }); + + expect(searchValues).toEqual({ name: 'bar' }); + }); + + test('it properly formats search options', () => { + const query = Query.parse('name:bar list_id:some_id', { schema: EXCEPTIONS_SEARCH_SCHEMA }); + + const searchValues = getSearchFilters({ + query, + searchValue: 'bar', + filterOptions, + defaultSearchTerm: 'name', + }); + + expect(searchValues).toEqual({ + created_by: null, + list_id: 'some_id', + name: 'bar', + tags: null, + type: null, + }); + }); + + test('it properly formats search options when no query clauses used', () => { + const query = Query.parse('some list name', { schema: EXCEPTIONS_SEARCH_SCHEMA }); + + const searchValues = getSearchFilters({ + query, + searchValue: 'some list name', + filterOptions, + defaultSearchTerm: 'name', + }); + + expect(searchValues).toEqual({ name: 'some list name' }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts index 8add47a70f6543..7ae4be08ef0aca 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Query } from '@elastic/eui'; import { BulkRuleResponse, RuleResponseBuckets, @@ -38,3 +39,32 @@ export const showRulesTable = ({ export const caseInsensitiveSort = (tags: string[]): string[] => { return tags.sort((a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase())); // Case insensitive }; + +export const getSearchFilters = ({ + query, + searchValue, + filterOptions, + defaultSearchTerm, +}: { + query: Query | null; + searchValue: string; + filterOptions: Record; + defaultSearchTerm: string; +}): Record => { + const fieldClauses = query?.ast.getFieldClauses(); + + if (fieldClauses != null && fieldClauses.length > 0) { + const filtersReduced = fieldClauses.reduce>( + (acc, { field, value }) => { + acc[field] = `${value}`; + + return acc; + }, + filterOptions + ); + + return filtersReduced; + } + + return { [defaultSearchTerm]: searchValue }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 4619177bb2158f..5836cac09e9b81 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -484,6 +484,7 @@ const RuleDetailsPageComponent = () => { href: getRulesUrl(), text: i18n.BACK_TO_RULES, pageId: SecurityPageName.detections, + dataTestSubj: 'ruleDetailsBackToAllRules', }} border subtitle={subTitle} diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 4f9a31fb2a1a3e..4fdb65bc53ea39 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -51,6 +51,7 @@ export { updateExceptionListItem, fetchExceptionListById, addExceptionList, + ExceptionListFilter, ExceptionListIdentifiers, ExceptionList, Pagination, From 0fb83bbcd422ebf566648d38aacedee5679f94c2 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Thu, 11 Feb 2021 15:44:59 -0800 Subject: [PATCH 004/175] Add size param to search for previous threshold signals (#90810) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../detection_engine/signals/threshold_find_previous_signals.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold_find_previous_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold_find_previous_signals.ts index a623608ef60064..dbad1d12d2be63 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold_find_previous_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold_find_previous_signals.ts @@ -48,6 +48,7 @@ export const findPreviousThresholdSignals = async ({ threshold: { terms: { field: 'signal.threshold_result.value', + size: 10000, }, aggs: { lastSignalTimestamp: { From c64cc352f9343c701f85918ebfcd22c4f1ddb7dd Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 11 Feb 2021 16:19:49 -0800 Subject: [PATCH 005/175] [ML] Edit hyperparameter descriptions in job wizard (#91072) Co-authored-by: Tom Veasey --- .../advanced_step/hyper_parameters.tsx | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx index 03dfc09d97b0e0..cd4dd44edfa507 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx @@ -45,15 +45,14 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.lambdaHelpText', { defaultMessage: - 'Regularization parameter to prevent overfitting on the training data set. Must be a non negative value.', + 'A multiplier of the leaf weights in loss calculations. Must be a nonnegative value.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.LAMBDA] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.LAMBDA]} > @@ -71,7 +70,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors defaultMessage: 'Max trees', })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.maxTreesText', { - defaultMessage: 'The maximum number of trees the forest is allowed to contain.', + defaultMessage: 'The maximum number of decision trees in the forest.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.MAX_TREES] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.MAX_TREES]} @@ -80,7 +79,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors aria-label={i18n.translate( 'xpack.ml.dataframe.analytics.create.maxTreesInputAriaLabel', { - defaultMessage: 'The maximum number of trees the forest is allowed to contain.', + defaultMessage: 'The maximum number of decision trees in the forest.', } )} data-test-subj="mlAnalyticsCreateJobFlyoutMaxTreesInput" @@ -102,15 +101,14 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.gammaText', { defaultMessage: - 'Multiplies a linear penalty associated with the size of individual trees in the forest. Must be non-negative value.', + 'A multiplier of the tree size in loss calcuations. Must be nonnegative value.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.GAMMA] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.GAMMA]} > @@ -135,7 +133,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors > @@ -192,8 +190,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors defaultMessage: 'Randomize seed', })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.randomizeSeedText', { - defaultMessage: - 'The seed to the random generator that is used to pick which documents will be used for training.', + defaultMessage: 'The seed for the random generator used to pick training data.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.RANDOMIZE_SEED] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.RANDOMIZE_SEED]} @@ -202,8 +199,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors aria-label={i18n.translate( 'xpack.ml.dataframe.analytics.create.randomizeSeedInputAriaLabel', { - defaultMessage: - 'The seed to the random generator that is used to pick which documents will be used for training', + defaultMessage: 'The seed for the random generator used to pick training data.', } )} data-test-subj="mlAnalyticsCreateJobWizardRandomizeSeedInput" @@ -223,14 +219,14 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.alphaText', { defaultMessage: - 'Multiplies a term based on tree depth in the regularized loss. Higher values result in shallower trees and faster training times. Must be greater than or equal to 0. ', + 'A multiplier of the tree depth in loss calculations. Must be greater than or equal to 0.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.ALPHA] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.ALPHA]} > @@ -249,7 +245,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.downsampleFactorText', { defaultMessage: - 'Controls the fraction of data that is used to compute the derivatives of the loss function for tree training. Must be between 0 and 1.', + 'The fraction of data used to compute derivatives of the loss function for tree training. Must be between 0 and 1.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.DOWNSAMPLE_FACTOR] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.DOWNSAMPLE_FACTOR]} @@ -259,7 +255,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors 'xpack.ml.dataframe.analytics.create.downsampleFactorInputAriaLabel', { defaultMessage: - 'Controls the fraction of data that is used to compute the derivatives of the loss function for tree training', + 'The fraction of data used to compute derivatives of the loss function for tree training.', } )} data-test-subj="mlAnalyticsCreateJobWizardDownsampleFactorInput" @@ -282,7 +278,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.etaGrowthRatePerTreeText', { defaultMessage: - 'Specifies the rate at which eta increases for each new tree that is added to the forest. Must be between 0.5 and 2.', + 'The rate at which eta increases for each new tree that is added to the forest. Must be between 0.5 and 2.', })} isInvalid={ advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.ETA_GROWTH_RATE_PER_TREE] !== undefined @@ -294,7 +290,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors 'xpack.ml.dataframe.analytics.create.etaGrowthRatePerTreeInputAriaLabel', { defaultMessage: - 'Specifies the rate at which eta increases for each new tree that is added to the forest.', + 'The rate at which eta increases for each new tree that is added to the forest.', } )} data-test-subj="mlAnalyticsCreateJobWizardEtaGrowthRatePerTreeInput" @@ -322,7 +318,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors 'xpack.ml.dataframe.analytics.create.maxOptimizationRoundsPerHyperparameterText', { defaultMessage: - 'Multiplier responsible for determining the maximum number of hyperparameter optimization steps in the Bayesian optimization procedure.', + 'The maximum number of optimization rounds for each undefined hyperparameter.', } )} isInvalid={ @@ -339,7 +335,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors 'xpack.ml.dataframe.analytics.create.maxOptimizationRoundsPerHyperparameterInputAriaLabel', { defaultMessage: - 'Multiplier responsible for determining the maximum number of hyperparameter optimization steps in the Bayesian optimization procedure. Must be an integer between 0 and 20.', + 'The maximum number of optimization rounds for each undefined hyperparameter. Must be an integer between 0 and 20.', } )} data-test-subj="mlAnalyticsCreateJobWizardMaxOptimizationRoundsPerHyperparameterInput" @@ -363,7 +359,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.softTreeDepthLimitText', { defaultMessage: - 'Tree depth limit that increases regularized loss when exceeded. Must be greater than or equal to 0. ', + 'Decision trees that exceed this depth are penalized in loss calculations. Must be greater than or equal to 0. ', })} isInvalid={ advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.SOFT_TREE_DEPTH_LIMIT] !== undefined @@ -374,7 +370,8 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors aria-label={i18n.translate( 'xpack.ml.dataframe.analytics.create.softTreeDepthLimitInputAriaLabel', { - defaultMessage: 'Tree depth limit that increases regularized loss when exceeded', + defaultMessage: + 'Decision trees that exceed this depth are penalized in loss calculations.', } )} data-test-subj="mlAnalyticsCreateJobWizardSoftTreeDepthLimitInput" @@ -398,7 +395,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors 'xpack.ml.dataframe.analytics.create.softTreeDepthToleranceText', { defaultMessage: - 'Controls how quickly the regularized loss increases when the tree depth exceeds soft_tree_depth_limit. Must be greater than or equal to 0.01. ', + 'Controls how quickly the loss increases when tree depths exceed soft limits. The smaller the value, the faster the loss increases. Must be greater than or equal to 0.01. ', } )} isInvalid={ @@ -410,7 +407,8 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors aria-label={i18n.translate( 'xpack.ml.dataframe.analytics.create.softTreeDepthToleranceInputAriaLabel', { - defaultMessage: 'Tree depth limit that increases regularized loss when exceeded', + defaultMessage: + 'Decision trees that exceed this depth are penalized in loss calculations.', } )} data-test-subj="mlAnalyticsCreateJobWizardSoftTreeDepthToleranceInput" From 7a5526768c455e57ac51810f7973902eabf7f960 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Thu, 11 Feb 2021 18:43:09 -0600 Subject: [PATCH 006/175] [Security Solution][Detections]Indicator Match Enrichment (#89899) * Adds basic integration test for threat enrichment * Update signals mappings with indicator fields * Simplify some ternaries with Math.min * Remove outdated comments * Add notes from walkthrough with devin * Add an enrichment hook to the current signal creation pipeline When this moves to individual rule-specific data transformations this will be a little more explicit/configurable; for now to keep changes minimal, we're using dependency injection to pass a function, which will default to the identity function (e.g. a no-op). * Add utility functions for encoding/decoding our threat query This is what allows us to enrich the threat match signals using only the signal search response. * Add a name to each threat match filter clause This gives us the information we need to enrich our signals after they've been queried without having to perform a complicated reverse query. * Adds functions for signal enrichment of threat indicators * Wire up threat enrichment to threat match rules * Fleshes out threat match integration tests Adds assertions to the existing test, and fleshes out another test for a multi-match signal. * Add more test cases to indicator match integration tests * single indicator matching multiple events * multiple indicators matching a single event * multiple indicators, multiple events * placeholder for deduplication logic This also adds some descriptions to our threat intel documents, to give a little context around how they're meant to function within the tests, particularly as relates to the auditbeat/hosts data on which it is meant to function. * Implement signal deduplification This handles the situation where the indicator match search has returned the same signal multiple times due to the source event matching different indicators in different query batches. In this case, we want to generate a single signal with all matched indicators. * Move default indicator path to constant * Testing some edge cases with signal enrichment * Cover and test edge cases with threat enrichment generation * Fix logical error in TI enrichment We were previously adding the indicator's field to matched.field, instead of the corresponding event field that matched the indicator. In the normal case, the expectation is that the indicator field is self-evident, and thus we want to know the other side of the match on the event itself. Updates tests accordingly. * Document behavior when an indicator matched but is absent on enrichment This could occur if the indicator index is updated while a rule is being run. * Add followup note * Add basic unit test for our enrichment function This just verifies that the enrichment function gets invoked with search results. * Update license headers for new files * Remove unused threatintel archive I made both of these before we were clear on the direction we were taking here. * Bump signals version to allows some updates in patch releases * Fix typings of threat list item We were conflating the type of the underlying document with the type of the search response for that document. This is now addressed with two types: ThreatListDoc and ThreatListItem, respectively. ThreatListDoc isn't the most distinguishing name but it avoids a lot of unnecessary renaming for the existing concept of ThreatListItem. * Update test mock to be aware of (but not care about) named queries * Remove/update outdated comments This code was modified to perform two searches instead of one; at that time, a lot of this code was duplicated and modified slightly, and these misleading comments were a result. I removed the ones that were no longer relevant, but left a TODO for one that could be a bug. * Remove outdated comment Documents will always have _id. * Update enriched signals' total to account for deduplication If a given signal matched on multiple indicators in different loops of our indicator query, it may appear multiple times. Our enrichment performs the merging of those duplicated results, but did not previously update the response's total field to account for this. I don't believe that anything downstream is actually using this field and that we are instead operating on the length of hits and the response from the bulk create request, but this keeps things consistent in case that changes. * Remove development comments * Add JSDoc for our special template version constant * Remove outdated comments * Add an additional test permutation for error cases Ensure that we throw an error if the indicator field is either a primitive or an array of primitives. * Remove unnecessary coalescing These values are already defaulted in the parent, and the types are correct in that these cannot be undefined. * Move logic to build threat enrichment function into helper * Refactor code to allow typescript to infer our type narrowing existingSignalHit could not be undefined on line 30 here, but typescript could not infer this from the !acc.has() call. * Use a POJO over a Map We were using a map previously in order to use .has() for a predicate, but code has since been refactored to make that unnecessary. * Explicitly type our enriched signals These are being typed implicitly and verified against SignalSourceHit[] on the assignment below, but this makes the types explicit and surfaces a type error here instead of the subsequent assignment. * Add an explanatory note about these test results * Remove unused imports These references were moved into buildThreatEnrichment * Remove threat mappings accidentally brought in with indicator work I copied the entirety of the `threat` mappings in order to get the `threat.indicator` ones, but it looks like these were added at some point too. I'd rather these not be added incidentally. If we need them, we should do so explicitly. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../routes/index/ecs_mapping.json | 138 +++++ .../routes/index/get_signals_template.ts | 15 +- .../signals/search_after_bulk_create.test.ts | 90 ++++ .../signals/search_after_bulk_create.ts | 25 +- .../threat_mapping/build_threat_enrichment.ts | 55 ++ .../build_threat_mapping_filter.mock.ts | 73 +-- .../build_threat_mapping_filter.test.ts | 13 +- .../build_threat_mapping_filter.ts | 10 +- .../threat_mapping/create_threat_signal.ts | 3 + .../threat_mapping/create_threat_signals.ts | 14 + .../enrich_signal_threat_matches.mock.ts | 36 ++ .../enrich_signal_threat_matches.test.ts | 484 ++++++++++++++++++ .../enrich_signal_threat_matches.ts | 114 +++++ .../signals/threat_mapping/types.ts | 33 +- .../signals/threat_mapping/utils.test.ts | 55 ++ .../signals/threat_mapping/utils.ts | 28 +- .../lib/detection_engine/signals/types.ts | 3 + .../tests/create_threat_matching.ts | 376 ++++++++++++++ .../filebeat/threat_intel/data.json | 276 ++++++++++ .../filebeat/threat_intel/mappings.json | 243 +++++++++ 20 files changed, 2025 insertions(+), 59 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.mock.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts create mode 100644 x-pack/test/functional/es_archives/filebeat/threat_intel/data.json create mode 100644 x-pack/test/functional/es_archives/filebeat/threat_intel/mappings.json diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/ecs_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/ecs_mapping.json index 6126ee462ec202..70b62d569b9d37 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/ecs_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/ecs_mapping.json @@ -2457,6 +2457,144 @@ "ignore_above": 1024, "type": "keyword" }, + "indicator": { + "type": "nested", + "properties": { + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "confidence": { + "ignore_above": 1024, + "type": "keyword" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "type": "wildcard" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "first_seen": { + "type": "date" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "last_seen": { + "type": "date" + }, + "marking": { + "properties": { + "tlp": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "matched": { + "properties": { + "atomic": { + "ignore_above": 1024, + "type": "keyword" + }, + "field": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner_stats": { + "type": "long" + }, + "sightings": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, "tactic": { "properties": { "id": { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts index c9a4f168224d43..48036ec73511b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts @@ -8,7 +8,20 @@ import signalsMapping from './signals_mapping.json'; import ecsMapping from './ecs_mapping.json'; -export const SIGNALS_TEMPLATE_VERSION = 14; +/** + @constant + @type {number} + @description This value represents the template version assumed by app code. + If this number is greater than the user's signals index version, the + detections UI will attempt to update the signals template and roll over to + a new signals index. + + If making mappings changes in a patch release, this number should be incremented by 1. + If making mappings changes in a minor release, this number should be + incremented by 10 in order to add "room" for the aforementioned patch + release +*/ +export const SIGNALS_TEMPLATE_VERSION = 24; export const MIN_EQL_RULE_INDEX_VERSION = 2; export const getSignalsTemplate = (index: string) => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index b506a2463a3113..55d128225c5553 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -14,6 +14,7 @@ import { repeatedSearchResultsWithSortId, repeatedSearchResultsWithNoSortId, sampleDocSearchResultsNoSortIdNoHits, + sampleDocWithSortId, } from './__mocks__/es_results'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { buildRuleMessageFactory } from './rule_messages'; @@ -870,4 +871,93 @@ describe('searchAfterAndBulkCreate', () => { expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); + + it('invokes the enrichment callback with signal search results', async () => { + const sampleParams = sampleRuleAlertParams(30); + mockService.callCluster + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + + const mockEnrichment = jest.fn((a) => a); + const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ + enrichment: mockEnrichment, + ruleParams: sampleParams, + gap: moment.duration(2, 'm'), + previousStartedAt: moment().subtract(10, 'm').toDate(), + listClient, + exceptionsList: [], + services: mockService, + logger: mockLogger, + eventsTelemetry: undefined, + id: sampleRuleGuid, + inputIndexPattern, + signalsIndex: DEFAULT_SIGNALS_INDEX, + name: 'rule-name', + actions: [], + createdAt: '2020-01-28T15:58:34.810Z', + updatedAt: '2020-01-28T15:59:14.004Z', + createdBy: 'elastic', + updatedBy: 'elastic', + interval: '5m', + enabled: true, + pageSize: 1, + filter: undefined, + refresh: false, + tags: ['some fake tag 1', 'some fake tag 2'], + throttle: 'no_actions', + buildRuleMessage, + }); + + expect(mockEnrichment).toHaveBeenCalledWith( + expect.objectContaining({ + hits: expect.objectContaining({ + hits: expect.arrayContaining([ + expect.objectContaining({ + ...sampleDocWithSortId(), + _id: expect.any(String), + }), + ]), + }), + }) + ); + expect(success).toEqual(true); + expect(mockService.callCluster).toHaveBeenCalledTimes(7); + expect(createdSignalsCount).toEqual(3); + expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index b821909ca907c7..061aa4bba5a412 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -7,6 +7,7 @@ /* eslint-disable complexity */ +import { identity } from 'lodash'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; import { filterEventsAgainstList } from './filters/filter_events_against_list'; @@ -49,6 +50,7 @@ export const searchAfterAndBulkCreate = async ({ tags, throttle, buildRuleMessage, + enrichment = identity, }: SearchAfterAndBulkCreateParams): Promise => { let toReturn = createSearchAfterReturnType(); @@ -106,7 +108,7 @@ export const searchAfterAndBulkCreate = async ({ services, logger, filter, - pageSize: tuple.maxSignals < pageSize ? Math.ceil(tuple.maxSignals) : pageSize, // maximum number of docs to receive per search result. + pageSize: Math.ceil(Math.min(tuple.maxSignals, pageSize)), timestampOverride: ruleParams.timestampOverride, excludeDocsWithTimestampOverride: true, }); @@ -117,14 +119,12 @@ export const searchAfterAndBulkCreate = async ({ backupSortId = lastSortId[0]; hasBackupSortId = true; } else { - // if no sort id on backup search and the initial search result was also empty logger.debug(buildRuleMessage('backupSortIds was empty on searchResultB')); hasBackupSortId = false; } mergedSearchResults = mergeSearchResults([mergedSearchResults, searchResultB]); - // merge the search result from the secondary search with the first toReturn = mergeReturns([ toReturn, createSearchAfterReturnTypeFromResponse({ @@ -139,7 +139,6 @@ export const searchAfterAndBulkCreate = async ({ } if (hasSortId) { - // only execute search if we have something to sort on or if it is the first search const { searchResult, searchDuration, searchErrors } = await singleSearchAfter({ buildRuleMessage, searchAfterSortId: sortId, @@ -149,7 +148,7 @@ export const searchAfterAndBulkCreate = async ({ services, logger, filter, - pageSize: tuple.maxSignals < pageSize ? Math.ceil(tuple.maxSignals) : pageSize, // maximum number of docs to receive per search result. + pageSize: Math.ceil(Math.min(tuple.maxSignals, pageSize)), timestampOverride: ruleParams.timestampOverride, excludeDocsWithTimestampOverride: false, }); @@ -166,10 +165,6 @@ export const searchAfterAndBulkCreate = async ({ }), ]); - // we are guaranteed to have searchResult hits at this point - // because we check before if the totalHits or - // searchResult.hits.hits.length is 0 - // call this function setSortIdOrExit() const lastSortId = searchResult.hits.hits[searchResult.hits.hits.length - 1]?.sort; if (lastSortId != null && lastSortId.length !== 0) { sortId = lastSortId[0]; @@ -186,14 +181,6 @@ export const searchAfterAndBulkCreate = async ({ buildRuleMessage(`searchResult.hit.hits.length: ${mergedSearchResults.hits.hits.length}`) ); - // search results yielded zero hits so exit - // with search_after, these two values can be different when - // searching with the last sortId of a consecutive search_after - // yields zero hits, but there were hits using the previous - // sortIds. - // e.g. totalHits was 156, index 50 of 100 results, do another search-after - // this time with a new sortId, index 22 of the remaining 56, get another sortId - // search with that sortId, total is still 156 but the hits.hits array is empty. if (totalHits === 0 || mergedSearchResults.hits.hits.length === 0) { logger.debug( buildRuleMessage( @@ -228,6 +215,8 @@ export const searchAfterAndBulkCreate = async ({ tuple.maxSignals - signalsCreatedCount ); } + const enrichedEvents = await enrichment(filteredEvents); + const { bulkCreateDuration: bulkDuration, createdItemsCount: createdCount, @@ -236,7 +225,7 @@ export const searchAfterAndBulkCreate = async ({ errors: bulkErrors, } = await singleBulkCreate({ buildRuleMessage, - filteredEvents, + filteredEvents: enrichedEvents, ruleParams, services, logger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts new file mode 100644 index 00000000000000..b14d1482189388 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts @@ -0,0 +1,55 @@ +/* + * 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 { SignalSearchResponse, SignalsEnrichment } from '../types'; +import { enrichSignalThreatMatches } from './enrich_signal_threat_matches'; +import { getThreatList } from './get_threat_list'; +import { BuildThreatEnrichmentOptions, GetMatchedThreats } from './types'; + +export const buildThreatEnrichment = ({ + buildRuleMessage, + exceptionItems, + listClient, + logger, + services, + threatFilters, + threatIndex, + threatLanguage, + threatQuery, +}: BuildThreatEnrichmentOptions): SignalsEnrichment => { + const getMatchedThreats: GetMatchedThreats = async (ids) => { + const matchedThreatsFilter = { + query: { + bool: { + filter: { + ids: { values: ids }, + }, + }, + }, + }; + const threatResponse = await getThreatList({ + callCluster: services.callCluster, + exceptionItems, + threatFilters: [...threatFilters, matchedThreatsFilter], + query: threatQuery, + language: threatLanguage, + index: threatIndex, + listClient, + searchAfter: undefined, + sortField: undefined, + sortOrder: undefined, + logger, + buildRuleMessage, + perPage: undefined, + }); + + return threatResponse.hits.hits; + }; + + return (signals: SignalSearchResponse): Promise => + enrichSignalThreatMatches(signals, getMatchedThreats); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts index 12865e4dd47a90..266903f568792c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts @@ -9,7 +9,7 @@ import { ThreatMapping } from '../../../../../common/detection_engine/schemas/ty import { Filter } from 'src/plugins/data/common'; import { SearchResponse } from 'elasticsearch'; -import { ThreatListItem } from './types'; +import { ThreatListDoc, ThreatListItem } from './types'; export const getThreatMappingMock = (): ThreatMapping => { return [ @@ -62,7 +62,7 @@ export const getThreatMappingMock = (): ThreatMapping => { ]; }; -export const getThreatListSearchResponseMock = (): SearchResponse => ({ +export const getThreatListSearchResponseMock = (): SearchResponse => ({ took: 0, timed_out: false, _shards: { @@ -74,33 +74,32 @@ export const getThreatListSearchResponseMock = (): SearchResponse ({ - '@timestamp': '2020-09-09T21:59:13Z', - host: { - name: 'host-1', - ip: '192.168.0.0.1', - }, - source: { - ip: '127.0.0.1', - port: 1, - }, - destination: { - ip: '127.0.0.1', - port: 1, +export const getThreatListItemMock = (overrides: Partial = {}): ThreatListItem => ({ + _id: '123', + _index: 'threat_index', + _type: '_doc', + _score: 0, + _source: { + '@timestamp': '2020-09-09T21:59:13Z', + host: { + name: 'host-1', + ip: '192.168.0.0.1', + }, + source: { + ip: '127.0.0.1', + port: 1, + }, + destination: { + ip: '127.0.0.1', + port: 1, + }, }, + fields: getThreatListItemFieldsMock(), + ...overrides, }); export const getThreatListItemFieldsMock = () => ({ @@ -188,13 +187,17 @@ export const getThreatMappingFilterShouldMock = (port = 1) => ({ filter: [ { bool: { - should: [{ match: { 'host.name': 'host-1' } }], + should: [ + { match: { 'host.name': { query: 'host-1', _name: expect.any(String) } } }, + ], minimum_should_match: 1, }, }, { bool: { - should: [{ match: { 'host.ip': '192.168.0.0.1' } }], + should: [ + { match: { 'host.ip': { query: '192.168.0.0.1', _name: expect.any(String) } } }, + ], minimum_should_match: 1, }, }, @@ -206,13 +209,19 @@ export const getThreatMappingFilterShouldMock = (port = 1) => ({ filter: [ { bool: { - should: [{ match: { 'destination.ip': '127.0.0.1' } }], + should: [ + { + match: { 'destination.ip': { query: '127.0.0.1', _name: expect.any(String) } }, + }, + ], minimum_should_match: 1, }, }, { bool: { - should: [{ match: { 'destination.port': port } }], + should: [ + { match: { 'destination.port': { query: port, _name: expect.any(String) } } }, + ], minimum_should_match: 1, }, }, @@ -224,7 +233,7 @@ export const getThreatMappingFilterShouldMock = (port = 1) => ({ filter: [ { bool: { - should: [{ match: { 'source.port': port } }], + should: [{ match: { 'source.port': { query: port, _name: expect.any(String) } } }], minimum_should_match: 1, }, }, @@ -236,7 +245,9 @@ export const getThreatMappingFilterShouldMock = (port = 1) => ({ filter: [ { bool: { - should: [{ match: { 'source.ip': '127.0.0.1' } }], + should: [ + { match: { 'source.ip': { query: '127.0.0.1', _name: expect.any(String) } } }, + ], minimum_should_match: 1, }, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts index 7a9c4b43b8f7af..1c0300ee0cc74e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts @@ -132,7 +132,7 @@ describe('build_threat_mapping_filter', () => { ], }, ], - threatListItem: { + threatListItem: getThreatListItemMock({ _source: { '@timestamp': '2020-09-09T21:59:13Z', host: { @@ -144,7 +144,7 @@ describe('build_threat_mapping_filter', () => { '@timestamp': ['2020-09-09T21:59:13Z'], 'host.name': ['host-1'], }, - }, + }), }); expect(item).toEqual([]); }); @@ -176,7 +176,7 @@ describe('build_threat_mapping_filter', () => { ], }, ], - threatListItem: { + threatListItem: getThreatListItemMock({ _source: { '@timestamp': '2020-09-09T21:59:13Z', host: { @@ -187,7 +187,7 @@ describe('build_threat_mapping_filter', () => { '@timestamp': ['2020-09-09T21:59:13Z'], 'host.name': ['host-1'], }, - }, + }), }); expect(item).toEqual([ { @@ -325,7 +325,10 @@ describe('build_threat_mapping_filter', () => { test('it should return an empty boolean clause given an empty object for a threat list item', () => { const threatMapping = getThreatMappingMock(); - const innerClause = createAndOrClauses({ threatMapping, threatListItem: { _source: {} } }); + const innerClause = createAndOrClauses({ + threatMapping, + threatListItem: getThreatListItemMock({ _source: {}, fields: {} }), + }); expect(innerClause).toEqual({ bool: { minimum_should_match: 1, should: [] } }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts index cab01a602b8a95..0a2789ec2f1d0d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts @@ -17,6 +17,7 @@ import { FilterThreatMappingOptions, SplitShouldClausesOptions, } from './types'; +import { encodeThreatMatchNamedQuery } from './utils'; export const MAX_CHUNK_SIZE = 1024; @@ -79,7 +80,14 @@ export const createInnerAndClauses = ({ should: [ { match: { - [threatMappingEntry.field]: value[0], + [threatMappingEntry.field]: { + query: value[0], + _name: encodeThreatMatchNamedQuery({ + id: threatListItem._id, + field: threatMappingEntry.field, + value: threatMappingEntry.value, + }), + }, }, }, ], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index a076ab46aae2a6..ba428bc0771250 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -14,6 +14,7 @@ import { SearchAfterAndBulkCreateReturnType } from '../types'; export const createThreatSignal = async ({ threatMapping, + threatEnrichment, query, inputIndex, type, @@ -77,6 +78,7 @@ export const createThreatSignal = async ({ `${threatFilter.query.bool.should.length} indicator items are being checked for existence of matches` ) ); + const result = await searchAfterAndBulkCreate({ gap, previousStartedAt, @@ -103,6 +105,7 @@ export const createThreatSignal = async ({ tags, throttle, buildRuleMessage, + enrichment: threatEnrichment, }); logger.debug( buildRuleMessage( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index 1e486e58aa0730..7690eb5eb1d554 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -12,6 +12,7 @@ import { CreateThreatSignalsOptions } from './types'; import { createThreatSignal } from './create_threat_signal'; import { SearchAfterAndBulkCreateReturnType } from '../types'; import { combineConcurrentResults } from './utils'; +import { buildThreatEnrichment } from './build_threat_enrichment'; export const createThreatSignals = async ({ threatMapping, @@ -90,12 +91,25 @@ export const createThreatSignals = async ({ perPage, }); + const threatEnrichment = buildThreatEnrichment({ + buildRuleMessage, + exceptionItems, + listClient, + logger, + services, + threatFilters, + threatIndex, + threatLanguage, + threatQuery, + }); + while (threatList.hits.hits.length !== 0) { const chunks = chunk(itemsPerSearch, threatList.hits.hits); logger.debug(buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`)); const concurrentSearchesPerformed = chunks.map>( (slicedChunk) => createThreatSignal({ + threatEnrichment, threatMapping, query, inputIndex, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.mock.ts new file mode 100644 index 00000000000000..a3ff932e97886e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.mock.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SignalSearchResponse, SignalSourceHit } from '../types'; +import { ThreatMatchNamedQuery } from './types'; + +export const getNamedQueryMock = ( + overrides: Partial = {} +): ThreatMatchNamedQuery => ({ + id: 'id', + field: 'field', + value: 'value', + ...overrides, +}); + +export const getSignalHitMock = (overrides: Partial = {}): SignalSourceHit => ({ + _id: '_id', + _index: '_index', + _source: { + '@timestamp': '2020-11-20T15:35:28.373Z', + }, + _type: '_type', + _score: 0, + ...overrides, +}); + +export const getSignalsResponseMock = (signals: SignalSourceHit[] = []): SignalSearchResponse => ({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: signals.length, relation: 'eq' }, max_score: 0, hits: signals }, +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts new file mode 100644 index 00000000000000..3c0765b56ae20b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts @@ -0,0 +1,484 @@ +/* + * 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 { get } from 'lodash'; + +import { getThreatListItemMock } from './build_threat_mapping_filter.mock'; +import { + buildMatchedIndicator, + enrichSignalThreatMatches, + groupAndMergeSignalMatches, +} from './enrich_signal_threat_matches'; +import { + getNamedQueryMock, + getSignalHitMock, + getSignalsResponseMock, +} from './enrich_signal_threat_matches.mock'; +import { GetMatchedThreats, ThreatListItem, ThreatMatchNamedQuery } from './types'; +import { encodeThreatMatchNamedQuery } from './utils'; + +describe('groupAndMergeSignalMatches', () => { + it('returns an empty array if there are no signals', () => { + expect(groupAndMergeSignalMatches([])).toEqual([]); + }); + + it('returns the same list if there are no duplicates', () => { + const signals = [getSignalHitMock({ _id: '1' }), getSignalHitMock({ _id: '2' })]; + const expectedSignals = [...signals]; + expect(groupAndMergeSignalMatches(signals)).toEqual(expectedSignals); + }); + + it('deduplicates signals with the same ID', () => { + const signals = [getSignalHitMock({ _id: '1' }), getSignalHitMock({ _id: '1' })]; + const expectedSignals = [signals[0]]; + expect(groupAndMergeSignalMatches(signals)).toEqual(expectedSignals); + }); + + it('merges the matched_queries of duplicate signals', () => { + const signals = [ + getSignalHitMock({ _id: '1', matched_queries: ['query1'] }), + getSignalHitMock({ _id: '1', matched_queries: ['query3', 'query4'] }), + ]; + const [mergedSignal] = groupAndMergeSignalMatches(signals); + expect(mergedSignal.matched_queries).toEqual(['query1', 'query3', 'query4']); + }); + + it('does not deduplicate identical named queries on duplicate signals', () => { + const signals = [ + getSignalHitMock({ _id: '1', matched_queries: ['query1'] }), + getSignalHitMock({ _id: '1', matched_queries: ['query1', 'query2'] }), + ]; + const [mergedSignal] = groupAndMergeSignalMatches(signals); + expect(mergedSignal.matched_queries).toEqual(['query1', 'query1', 'query2']); + }); + + it('merges the matched_queries of multiple signals', () => { + const signals = [ + getSignalHitMock({ _id: '1', matched_queries: ['query1'] }), + getSignalHitMock({ _id: '1', matched_queries: ['query3', 'query4'] }), + getSignalHitMock({ _id: '2', matched_queries: ['query1', 'query2'] }), + getSignalHitMock({ _id: '2', matched_queries: ['query5', 'query6'] }), + ]; + const mergedSignals = groupAndMergeSignalMatches(signals); + expect(mergedSignals.map((signal) => signal.matched_queries)).toEqual([ + ['query1', 'query3', 'query4'], + ['query1', 'query2', 'query5', 'query6'], + ]); + }); +}); + +describe('buildMatchedIndicator', () => { + let threats: ThreatListItem[]; + let queries: ThreatMatchNamedQuery[]; + + beforeEach(() => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { indicator: { domain: 'domain_1', other: 'other_1', type: 'type_1' } }, + }, + }), + ]; + queries = [ + getNamedQueryMock({ id: '123', field: 'event.field', value: 'threat.indicator.domain' }), + ]; + }); + + it('returns an empty list if queries is empty', () => { + const indicators = buildMatchedIndicator({ + queries: [], + threats, + }); + + expect(indicators).toEqual([]); + }); + + it('returns the value of the matched indicator as matched.atomic', () => { + const [indicator] = buildMatchedIndicator({ + queries, + threats, + }); + + expect(get(indicator, 'matched.atomic')).toEqual('domain_1'); + }); + + it('returns the field of the matched indicator as matched.field', () => { + const [indicator] = buildMatchedIndicator({ + queries, + threats, + }); + + expect(get(indicator, 'matched.field')).toEqual('event.field'); + }); + + it('returns the type of the matched indicator as matched.type', () => { + const [indicator] = buildMatchedIndicator({ + queries, + threats, + }); + + expect(get(indicator, 'matched.type')).toEqual('type_1'); + }); + + it('returns indicators for each provided query', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { indicator: { domain: 'domain_1', other: 'other_1', type: 'type_1' } }, + }, + }), + getThreatListItemMock({ + _id: '456', + _source: { + threat: { indicator: { domain: 'domain_1', other: 'other_1', type: 'type_1' } }, + }, + }), + ]; + queries = [ + getNamedQueryMock({ id: '123', value: 'threat.indicator.domain' }), + getNamedQueryMock({ id: '456', value: 'threat.indicator.other' }), + getNamedQueryMock({ id: '456', value: 'threat.indicator.domain' }), + ]; + const indicators = buildMatchedIndicator({ + queries, + threats, + }); + + expect(indicators).toHaveLength(queries.length); + }); + + it('returns the indicator data specified at threat.indicator by default', () => { + const indicators = buildMatchedIndicator({ + queries, + threats, + }); + + expect(indicators).toEqual([ + { + domain: 'domain_1', + matched: { + atomic: 'domain_1', + field: 'event.field', + type: 'type_1', + }, + other: 'other_1', + type: 'type_1', + }, + ]); + }); + + it('returns the indicator data specified at the custom path', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + 'threat.indicator.domain': 'domain_1', + custom: { + indicator: { + path: { + indicator_field: 'indicator_field_1', + type: 'indicator_type', + }, + }, + }, + }, + }), + ]; + + const indicators = buildMatchedIndicator({ + indicatorPath: 'custom.indicator.path', + queries, + threats, + }); + + expect(indicators).toEqual([ + { + indicator_field: 'indicator_field_1', + matched: { + atomic: 'domain_1', + field: 'event.field', + type: 'indicator_type', + }, + type: 'indicator_type', + }, + ]); + }); + + it('returns only the match data if indicator field is absent', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: {}, + }), + ]; + + const indicators = buildMatchedIndicator({ + queries, + threats, + }); + + expect(indicators).toEqual([ + { + matched: { + atomic: undefined, + field: 'event.field', + type: undefined, + }, + }, + ]); + }); + + it('returns only the match data if indicator field is an empty array', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { threat: { indicator: [] } }, + }), + ]; + + const indicators = buildMatchedIndicator({ + queries, + threats, + }); + + expect(indicators).toEqual([ + { + matched: { + atomic: undefined, + field: 'event.field', + type: undefined, + }, + }, + ]); + }); + + it('returns data sans atomic from first indicator if indicator field is an array of objects', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { + indicator: [ + { domain: 'foo', type: 'first' }, + { domain: 'bar', type: 'second' }, + ], + }, + }, + }), + ]; + + const indicators = buildMatchedIndicator({ + queries, + threats, + }); + + expect(indicators).toEqual([ + { + domain: 'foo', + matched: { + atomic: undefined, + field: 'event.field', + type: 'first', + }, + type: 'first', + }, + ]); + }); + + it('throws an error if indicator field is a not an object', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { + indicator: 'not an object', + }, + }, + }), + ]; + + expect(() => + buildMatchedIndicator({ + queries, + threats, + }) + ).toThrowError('Expected indicator field to be an object, but found: not an object'); + }); + + it('throws an error if indicator field is not an array of objects', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { + indicator: ['not an object'], + }, + }, + }), + ]; + + expect(() => + buildMatchedIndicator({ + queries, + threats, + }) + ).toThrowError('Expected indicator field to be an object, but found: not an object'); + }); +}); + +describe('enrichSignalThreatMatches', () => { + let getMatchedThreats: GetMatchedThreats; + let matchedQuery: string; + + beforeEach(() => { + getMatchedThreats = async () => [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { indicator: { domain: 'domain_1', other: 'other_1', type: 'type_1' } }, + }, + }), + ]; + matchedQuery = encodeThreatMatchNamedQuery( + getNamedQueryMock({ id: '123', field: 'event.field', value: 'threat.indicator.domain' }) + ); + }); + + it('performs no enrichment if there are no signals', async () => { + const signals = getSignalsResponseMock([]); + const enrichedSignals = await enrichSignalThreatMatches(signals, getMatchedThreats); + + expect(enrichedSignals.hits.hits).toEqual([]); + }); + + it('preserves existing threat.indicator objects on signals', async () => { + const signalHit = getSignalHitMock({ + _source: { '@timestamp': 'mocked', threat: { indicator: [{ existing: 'indicator' }] } }, + matched_queries: [matchedQuery], + }); + const signals = getSignalsResponseMock([signalHit]); + const enrichedSignals = await enrichSignalThreatMatches(signals, getMatchedThreats); + const [enrichedHit] = enrichedSignals.hits.hits; + const indicators = get(enrichedHit._source, 'threat.indicator'); + + expect(indicators).toEqual([ + { existing: 'indicator' }, + { + domain: 'domain_1', + matched: { atomic: 'domain_1', field: 'event.field', type: 'type_1' }, + other: 'other_1', + type: 'type_1', + }, + ]); + }); + + it('provides only match data if the matched threat cannot be found', async () => { + getMatchedThreats = async () => []; + const signalHit = getSignalHitMock({ + matched_queries: [matchedQuery], + }); + const signals = getSignalsResponseMock([signalHit]); + const enrichedSignals = await enrichSignalThreatMatches(signals, getMatchedThreats); + const [enrichedHit] = enrichedSignals.hits.hits; + const indicators = get(enrichedHit._source, 'threat.indicator'); + + expect(indicators).toEqual([ + { + matched: { atomic: undefined, field: 'event.field', type: undefined }, + }, + ]); + }); + + it('preserves an existing threat.indicator object on signals', async () => { + const signalHit = getSignalHitMock({ + _source: { '@timestamp': 'mocked', threat: { indicator: { existing: 'indicator' } } }, + matched_queries: [matchedQuery], + }); + const signals = getSignalsResponseMock([signalHit]); + const enrichedSignals = await enrichSignalThreatMatches(signals, getMatchedThreats); + const [enrichedHit] = enrichedSignals.hits.hits; + const indicators = get(enrichedHit._source, 'threat.indicator'); + + expect(indicators).toEqual([ + { existing: 'indicator' }, + { + domain: 'domain_1', + matched: { atomic: 'domain_1', field: 'event.field', type: 'type_1' }, + other: 'other_1', + type: 'type_1', + }, + ]); + }); + + it('throws an error if threat is neither an object nor undefined', async () => { + const signalHit = getSignalHitMock({ + _source: { '@timestamp': 'mocked', threat: 'whoops' }, + matched_queries: [matchedQuery], + }); + const signals = getSignalsResponseMock([signalHit]); + await expect(() => enrichSignalThreatMatches(signals, getMatchedThreats)).rejects.toThrowError( + 'Expected threat field to be an object, but found: whoops' + ); + }); + + it('merges duplicate matched signals into a single signal with multiple indicators', async () => { + getMatchedThreats = async () => [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { indicator: { domain: 'domain_1', other: 'other_1', type: 'type_1' } }, + }, + }), + getThreatListItemMock({ + _id: '456', + _source: { + threat: { indicator: { domain: 'domain_2', other: 'other_2', type: 'type_2' } }, + }, + }), + ]; + const signalHit = getSignalHitMock({ + _id: 'signal123', + matched_queries: [matchedQuery], + }); + const otherSignalHit = getSignalHitMock({ + _id: 'signal123', + matched_queries: [ + encodeThreatMatchNamedQuery( + getNamedQueryMock({ id: '456', field: 'event.other', value: 'threat.indicator.domain' }) + ), + ], + }); + const signals = getSignalsResponseMock([signalHit, otherSignalHit]); + const enrichedSignals = await enrichSignalThreatMatches(signals, getMatchedThreats); + expect(enrichedSignals.hits.total).toEqual(expect.objectContaining({ value: 1 })); + expect(enrichedSignals.hits.hits).toHaveLength(1); + + const [enrichedHit] = enrichedSignals.hits.hits; + const indicators = get(enrichedHit._source, 'threat.indicator'); + + expect(indicators).toEqual([ + { + domain: 'domain_1', + matched: { atomic: 'domain_1', field: 'event.field', type: 'type_1' }, + other: 'other_1', + type: 'type_1', + }, + { + domain: 'domain_2', + matched: { + atomic: 'domain_2', + field: 'event.other', + type: 'type_2', + }, + other: 'other_2', + type: 'type_2', + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts new file mode 100644 index 00000000000000..c298ef98ebcd53 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get, isObject } from 'lodash'; + +import type { SignalSearchResponse, SignalSourceHit } from '../types'; +import type { + GetMatchedThreats, + ThreatIndicator, + ThreatListItem, + ThreatMatchNamedQuery, +} from './types'; +import { extractNamedQueries } from './utils'; + +const DEFAULT_INDICATOR_PATH = 'threat.indicator'; +const getSignalId = (signal: SignalSourceHit): string => signal._id; + +export const groupAndMergeSignalMatches = (signalHits: SignalSourceHit[]): SignalSourceHit[] => { + const dedupedHitsMap = signalHits.reduce>((acc, signalHit) => { + const signalId = getSignalId(signalHit); + const existingSignalHit = acc[signalId]; + + if (existingSignalHit == null) { + acc[signalId] = signalHit; + } else { + const existingQueries = existingSignalHit?.matched_queries ?? []; + const newQueries = signalHit.matched_queries ?? []; + existingSignalHit.matched_queries = [...existingQueries, ...newQueries]; + + acc[signalId] = existingSignalHit; + } + + return acc; + }, {}); + const dedupedHits = Object.values(dedupedHitsMap); + return dedupedHits; +}; + +export const buildMatchedIndicator = ({ + queries, + threats, + indicatorPath = DEFAULT_INDICATOR_PATH, +}: { + queries: ThreatMatchNamedQuery[]; + threats: ThreatListItem[]; + indicatorPath?: string; +}): ThreatIndicator[] => + queries.map((query) => { + const matchedThreat = threats.find((threat) => threat._id === query.id); + const indicatorValue = get(matchedThreat?._source, indicatorPath) as unknown; + const indicator = [indicatorValue].flat()[0] ?? {}; + if (!isObject(indicator)) { + throw new Error(`Expected indicator field to be an object, but found: ${indicator}`); + } + const atomic = get(matchedThreat?._source, query.value) as unknown; + const type = get(indicator, 'type') as unknown; + + return { + ...indicator, + matched: { atomic, field: query.field, type }, + }; + }); + +export const enrichSignalThreatMatches = async ( + signals: SignalSearchResponse, + getMatchedThreats: GetMatchedThreats +): Promise => { + const signalHits = signals.hits.hits; + if (signalHits.length === 0) { + return signals; + } + + const uniqueHits = groupAndMergeSignalMatches(signalHits); + const signalMatches = uniqueHits.map((signalHit) => extractNamedQueries(signalHit)); + const matchedThreatIds = [...new Set(signalMatches.flat().map(({ id }) => id))]; + const matchedThreats = await getMatchedThreats(matchedThreatIds); + const matchedIndicators = signalMatches.map((queries) => + buildMatchedIndicator({ queries, threats: matchedThreats }) + ); + + const enrichedSignals: SignalSourceHit[] = uniqueHits.map((signalHit, i) => { + const threat = get(signalHit._source, 'threat') ?? {}; + if (!isObject(threat)) { + throw new Error(`Expected threat field to be an object, but found: ${threat}`); + } + const existingIndicatorValue = get(signalHit._source, 'threat.indicator') ?? []; + const existingIndicators = [existingIndicatorValue].flat(); // ensure indicators is an array + + return { + ...signalHit, + _source: { + ...signalHit._source, + threat: { + ...threat, + indicator: [...existingIndicators, ...matchedIndicators[i]], + }, + }, + }; + }); + /* eslint-disable require-atomic-updates */ + signals.hits.hits = enrichedSignals; + if (isObject(signals.hits.total)) { + signals.hits.total.value = enrichedSignals.length; + } else { + signals.hits.total = enrichedSignals.length; + } + /* eslint-enable require-atomic-updates */ + + return signals; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index 26e42b795be3ea..b80d3faf9b61c7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -5,7 +5,9 @@ * 2.0. */ +import { SearchResponse } from 'elasticsearch'; import { Duration } from 'moment'; + import { ListClient } from '../../../../../../lists/server'; import { Type, @@ -31,7 +33,7 @@ import { ILegacyScopedClusterClient, Logger } from '../../../../../../../../src/ import { RuleAlertAction } from '../../../../../common/detection_engine/types'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; -import { SearchAfterAndBulkCreateReturnType } from '../types'; +import { SearchAfterAndBulkCreateReturnType, SignalsEnrichment } from '../types'; export type SortOrderOrUndefined = 'asc' | 'desc' | undefined; @@ -76,6 +78,7 @@ export interface CreateThreatSignalsOptions { export interface CreateThreatSignalOptions { threatMapping: ThreatMapping; + threatEnrichment: SignalsEnrichment; query: string; inputIndex: string[]; type: Type; @@ -177,14 +180,40 @@ export interface GetSortWithTieBreakerOptions { listItemIndex: string; } +export interface ThreatListDoc { + [key: string]: unknown; +} + /** * This is an ECS document being returned, but the user could return or use non-ecs based * documents potentially. */ -export interface ThreatListItem { +export type ThreatListItem = SearchResponse['hits']['hits'][number]; + +export interface ThreatIndicator { [key: string]: unknown; } export interface SortWithTieBreaker { [key: string]: string; } + +export interface ThreatMatchNamedQuery { + id: string; + field: string; + value: string; +} + +export type GetMatchedThreats = (ids: string[]) => Promise; + +export interface BuildThreatEnrichmentOptions { + buildRuleMessage: BuildRuleMessage; + exceptionItems: ExceptionListItemSchema[]; + listClient: ListClient; + logger: Logger; + services: AlertServices; + threatFilters: PartialFilter[]; + threatIndex: ThreatIndex; + threatLanguage: ThreatLanguageOrUndefined; + threatQuery: ThreatQuery; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts index a738c8a864a1c6..897143f9ae5744 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts @@ -7,6 +7,7 @@ import { SearchAfterAndBulkCreateReturnType } from '../types'; import { sampleSignalHit } from '../__mocks__/es_results'; +import { ThreatMatchNamedQuery } from './types'; import { calculateAdditiveMax, @@ -14,6 +15,8 @@ import { calculateMaxLookBack, combineConcurrentResults, combineResults, + decodeThreatMatchNamedQuery, + encodeThreatMatchNamedQuery, } from './utils'; describe('utils', () => { @@ -580,4 +583,56 @@ describe('utils', () => { ); }); }); + + describe('threat match queries', () => { + describe('encodeThreatMatchNamedQuery()', () => { + it('generates a string that can be later decoded', () => { + const encoded = encodeThreatMatchNamedQuery({ + id: 'id', + field: 'field', + value: 'value', + }); + + expect(typeof encoded).toEqual('string'); + }); + }); + + describe('decodeThreatMatchNamedQuery()', () => { + it('can decode an encoded query', () => { + const query: ThreatMatchNamedQuery = { + id: 'my_id', + field: 'threat.indicator.domain', + value: 'host.name', + }; + + const encoded = encodeThreatMatchNamedQuery(query); + const decoded = decodeThreatMatchNamedQuery(encoded); + + expect(decoded).not.toBe(query); + expect(decoded).toEqual(query); + }); + + it('raises an error if the input is invalid', () => { + const badInput = 'nope'; + + expect(() => decodeThreatMatchNamedQuery(badInput)).toThrowError( + 'Decoded query is invalid. Decoded value: {"id":"nope"}' + ); + }); + + it('raises an error if the query is missing a value', () => { + const badQuery: ThreatMatchNamedQuery = { + id: 'my_id', + // @ts-expect-error field intentionally undefined + field: undefined, + value: 'host.name', + }; + const badInput = encodeThreatMatchNamedQuery(badQuery); + + expect(() => decodeThreatMatchNamedQuery(badInput)).toThrowError( + 'Decoded query is invalid. Decoded value: {"id":"my_id","field":"","value":"host.name"}' + ); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts index 87bcb657a53a52..72d9257798e1c9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { SearchAfterAndBulkCreateReturnType } from '../types'; +import { SearchAfterAndBulkCreateReturnType, SignalSourceHit } from '../types'; +import { ThreatMatchNamedQuery } from './types'; /** * Given two timers this will take the max of each and add them to each other and return that addition. @@ -113,3 +114,28 @@ export const combineConcurrentResults = ( return combineResults(currentResult, maxedNewResult); }; + +const separator = '___SEPARATOR___'; +export const encodeThreatMatchNamedQuery = ({ + id, + field, + value, +}: ThreatMatchNamedQuery): string => { + return [id, field, value].join(separator); +}; + +export const decodeThreatMatchNamedQuery = (encoded: string): ThreatMatchNamedQuery => { + const queryValues = encoded.split(separator); + const [id, field, value] = queryValues; + const query = { id, field, value }; + + if (queryValues.length !== 3 || !queryValues.every(Boolean)) { + const queryString = JSON.stringify(query); + throw new Error(`Decoded query is invalid. Decoded value: ${queryString}`); + } + + return query; +}; + +export const extractNamedQueries = (hit: SignalSourceHit): ThreatMatchNamedQuery[] => + hit.matched_queries?.map((match) => decodeThreatMatchNamedQuery(match)) ?? []; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 8031b81f70eb05..f7ac0425b2f2e4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -228,6 +228,8 @@ export interface QueryFilter { }; } +export type SignalsEnrichment = (signals: SignalSearchResponse) => Promise; + export interface SearchAfterAndBulkCreateParams { gap: moment.Duration | null; previousStartedAt: Date | null | undefined; @@ -254,6 +256,7 @@ export interface SearchAfterAndBulkCreateParams { tags: string[]; throttle: string; buildRuleMessage: BuildRuleMessage; + enrichment?: SignalsEnrichment; } export interface SearchAfterAndBulkCreateReturnType { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index 9b29548cbe19ef..9e1c290d160590 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -251,6 +251,382 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [ruleResponse.id]); expect(signalsOpen.hits.hits.length).equal(0); }); + + describe('indicator enrichment', () => { + beforeEach(async () => { + await esArchiver.load('filebeat/threat_intel'); + }); + + afterEach(async () => { + await esArchiver.unload('filebeat/threat_intel'); + }); + + it('enriches signals with the single indicator that matched', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: '*:*', + threat_query: 'threat.indicator.domain: *', // narrow things down to indicators with a domain + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.domain', + field: 'destination.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); + expect(signalsOpen.hits.hits.length).equal(2); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source.threat); + expect(threats).to.eql([ + { + indicator: [ + { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + matched: { + atomic: '159.89.119.67', + field: 'destination.ip', + type: 'url', + }, + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + ], + }, + { + indicator: [ + { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + matched: { + atomic: '159.89.119.67', + field: 'destination.ip', + type: 'url', + }, + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + ], + }, + ]); + }); + + it('enriches signals with multiple indicators if several matched', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'source.port: 57324', // narrow our query to a single record that matches two indicators + threat_query: 'threat.indicator.ip: *', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); + expect(signalsOpen.hits.hits.length).equal(1); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source.threat); + expect(threats).to.eql([ + { + indicator: [ + { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: '45.115.45.3', + field: 'source.ip', + type: 'url', + }, + port: 57324, + provider: 'geenensp', + type: 'url', + }, + { + description: 'this should match auditbeat/hosts on ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: '45.115.45.3', + field: 'source.ip', + type: 'ip', + }, + provider: 'other_provider', + type: 'ip', + }, + ], + }, + ]); + }); + + it('adds a single indicator that matched multiple fields', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'source.port: 57324', // narrow our query to a single record that matches two indicators + threat_query: 'threat.indicator.ip: *', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.port', + field: 'source.port', + type: 'mapping', + }, + ], + }, + { + entries: [ + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); + expect(signalsOpen.hits.hits.length).equal(1); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source.threat); + + expect(threats).to.eql([ + { + indicator: [ + { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: '45.115.45.3', + field: 'source.ip', + type: 'url', + }, + port: 57324, + provider: 'geenensp', + type: 'url', + }, + { + description: 'this should match auditbeat/hosts on ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: '45.115.45.3', + field: 'source.ip', + type: 'ip', + }, + provider: 'other_provider', + type: 'ip', + }, + // We do not merge matched indicators during enrichment, so in + // certain circumstances a given indicator document could appear + // multiple times in an enriched alert (albeit with different + // threat.indicator.matched data). That's the case with the + // first and third indicators matched, here. + { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: 57324, + field: 'source.port', + type: 'url', + }, + port: 57324, + provider: 'geenensp', + type: 'url', + }, + ], + }, + ]); + }); + + it('generates multiple signals with multiple matches', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: '*:*', // narrow our query to a single record that matches two indicators + threat_query: '', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.port', + field: 'source.port', + type: 'mapping', + }, + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + { + entries: [ + { + value: 'threat.indicator.domain', + field: 'destination.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); + expect(signalsOpen.hits.hits.length).equal(2); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source.threat); + expect(threats).to.eql([ + { + indicator: [ + { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + matched: { + atomic: '159.89.119.67', + field: 'destination.ip', + type: 'url', + }, + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + ], + }, + { + indicator: [ + { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + matched: { + atomic: '159.89.119.67', + field: 'destination.ip', + type: 'url', + }, + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: '45.115.45.3', + field: 'source.ip', + type: 'url', + }, + port: 57324, + provider: 'geenensp', + type: 'url', + }, + { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: 57324, + field: 'source.port', + type: 'url', + }, + port: 57324, + provider: 'geenensp', + type: 'url', + }, + ], + }, + ]); + }); + }); }); }); }; diff --git a/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json b/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json new file mode 100644 index 00000000000000..0cbc7f37bd519a --- /dev/null +++ b/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json @@ -0,0 +1,276 @@ +{ + "type": "doc", + "value": { + "id": "978783", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "threatintel.abuseurl", + "ingested": "2021-01-26T11:09:06.595350Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978783/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "domain should match the auditbeat hosts' data's source.ip", + "domain": "159.89.119.67", + "first_seen": "2021-01-26T11:09:04.000Z", + "provider": "geenensp", + "type": "url", + "url": { + "full": "http://159.89.119.67:59600/bin.sh", + "scheme": "http" + } + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": false, + "tags": null, + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "978784", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "threatintel.abuseurl", + "ingested": "2021-01-26T11:09:06.616763Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978782/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "this should not match the auditbeat hosts data", + "ip": "125.46.136.106", + "first_seen": "2021-01-26T11:06:03.000Z", + "provider": "geenensp", + "type": "ip" + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": true, + "tags": [ + "32-bit", + "elf", + "mips" + ], + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "978785", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "threatintel.abuseurl", + "ingested": "2021-01-26T11:09:06.616763Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978782/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "this should match auditbeat/hosts on both port and ip", + "ip": "45.115.45.3", + "port": 57324, + "first_seen": "2021-01-26T11:06:03.000Z", + "provider": "geenensp", + "type": "url" + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": true, + "tags": [ + "32-bit", + "elf", + "mips" + ], + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "978787", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "threatintel.abuseurl", + "ingested": "2021-01-26T11:09:06.616763Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978782/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "this should match auditbeat/hosts on ip", + "ip": "45.115.45.3", + "first_seen": "2021-01-26T11:06:03.000Z", + "provider": "other_provider", + "type": "ip" + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": true, + "tags": [ + "32-bit", + "elf", + "mips" + ], + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} diff --git a/x-pack/test/functional/es_archives/filebeat/threat_intel/mappings.json b/x-pack/test/functional/es_archives/filebeat/threat_intel/mappings.json new file mode 100644 index 00000000000000..26d8e29eaecf77 --- /dev/null +++ b/x-pack/test/functional/es_archives/filebeat/threat_intel/mappings.json @@ -0,0 +1,243 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": "filebeat-8.0.0-2021.01.26-000001", + "mappings": { + "_meta": { + "beat": "filebeat", + "version": "7.0.0" + }, + "properties": { + "@timestamp": { + "type": "date" + }, + "@version": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "indicator": { + "properties": { + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "confidence": { + "ignore_above": 1024, + "type": "keyword" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "type": "wildcard" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "first_seen": { + "type": "date" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "last_seen": { + "type": "date" + }, + "marking": { + "properties": { + "tlp": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "matched": { + "properties": { + "atomic": { + "ignore_above": 1024, + "type": "keyword" + }, + "field": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner_stats": { + "type": "long" + }, + "sightings": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "subtechnique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + } + } + }, + "settings": { + "index": { + "lifecycle": { + "name": "filebeat-8.0.0", + "rollover_alias": "filebeat-filebeat-8.0.0" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "0", + "number_of_shards": "1", + "refresh_interval": "5s" + } + } + } +} From 7537326aca6cd14acf7a280e832663c911c7adad Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Thu, 11 Feb 2021 18:54:28 -0600 Subject: [PATCH 007/175] [Metrics UI] Fix passing space ID into anomaly alerts (#91222) * [Metrics UI] Fix passing space ID into anomaly alerts * Fix jest --- .../plugins/infra/common/alerting/metrics/types.ts | 2 ++ .../metric_anomaly/components/expression.test.tsx | 6 ++++++ .../metric_anomaly/components/expression.tsx | 12 ++++++++++-- .../metric_anomaly/metric_anomaly_executor.ts | 3 ++- .../register_metric_anomaly_alert_type.ts | 1 + 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts index 70515bde4b3fa7..94ec40dd2847e8 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/types.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -51,6 +51,7 @@ export interface MetricAnomalyParams { metric: rt.TypeOf; alertInterval?: string; sourceId?: string; + spaceId?: string; threshold: Exclude; influencerFilter: rt.TypeOf | undefined; } @@ -112,6 +113,7 @@ const metricAnomalyAlertPreviewRequestParamsRT = rt.intersection([ metric: metricAnomalyMetricRT, threshold: rt.number, alertType: rt.literal(METRIC_ANOMALY_ALERT_TYPE_ID), + spaceId: rt.string, }), rt.partial({ influencerFilter: metricAnomalyInfluencerFilterRT, diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx index 3b3bece47e53f8..dd4cbe10b74eea 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx @@ -25,6 +25,12 @@ jest.mock('../../../hooks/use_kibana', () => ({ }), })); +jest.mock('../../../hooks/use_kibana_space', () => ({ + useActiveKibanaSpace: () => ({ + space: { id: 'default' }, + }), +})); + jest.mock('../../../containers/ml/infra_ml_capabilities', () => ({ useInfraMLCapabilities: () => ({ isLoading: false, diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx index 5f034a600ecc68..12cc2bf9fb3a9f 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx @@ -38,6 +38,7 @@ import { ANOMALY_THRESHOLD } from '../../../../common/infra_ml'; import { validateMetricAnomaly } from './validation'; import { InfluencerFilter } from './influencer_filter'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { useActiveKibanaSpace } from '../../../hooks/use_kibana_space'; export interface AlertContextMeta { metric?: InfraWaffleMapOptions['metric']; @@ -45,7 +46,7 @@ export interface AlertContextMeta { } type AlertParams = AlertTypeParams & - MetricAnomalyParams & { sourceId: string; hasInfraMLCapabilities: boolean }; + MetricAnomalyParams & { sourceId: string; spaceId: string; hasInfraMLCapabilities: boolean }; type Props = Omit< AlertTypeParamsExpressionProps, @@ -62,6 +63,8 @@ export const defaultExpression = { export const Expression: React.FC = (props) => { const { hasInfraMLCapabilities, isLoading: isLoadingMLCapabilities } = useInfraMLCapabilities(); const { http, notifications } = useKibanaContextForPlugin().services; + const { space } = useActiveKibanaSpace(); + const { setAlertParams, alertParams, @@ -176,7 +179,11 @@ export const Expression: React.FC = (props) => { if (!alertParams.sourceId) { setAlertParams('sourceId', source?.id || 'default'); } - }, [metadata, derivedIndexPattern, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!alertParams.spaceId) { + setAlertParams('spaceId', space?.id || 'default'); + } + }, [metadata, derivedIndexPattern, defaultExpression, source, space]); // eslint-disable-line react-hooks/exhaustive-deps if (isLoadingMLCapabilities) return ; if (!hasInfraMLCapabilities) return ; @@ -263,6 +270,7 @@ export const Expression: React.FC = (props) => { 'threshold', 'nodeType', 'sourceId', + 'spaceId', 'influencerFilter' )} validate={validateMetricAnomaly} diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts index a5297f81bbacac..7a4c93438027ad 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts @@ -51,6 +51,7 @@ export const createMetricAnomalyExecutor = (libs: InfraBackendLibs, ml?: MlPlugi alertInterval, influencerFilter, sourceId, + spaceId, nodeType, threshold, } = params as MetricAnomalyParams; @@ -67,7 +68,7 @@ export const createMetricAnomalyExecutor = (libs: InfraBackendLibs, ml?: MlPlugi const { data } = await evaluateCondition({ sourceId: sourceId ?? 'default', - spaceId: 'default', + spaceId: spaceId ?? 'default', mlSystem, mlAnomalyDetectors, startTime, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts index 8ac62c125515af..d5333f155b5c32 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts @@ -51,6 +51,7 @@ export const registerMetricAnomalyAlertType = ( schema.string({ validate: validateIsStringElasticsearchJSONFilter }) ), sourceId: schema.string(), + spaceId: schema.string(), }, { unknowns: 'allow' } ), From 7994e87cd77a169f00adaec85a0f59c5b7357218 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 11 Feb 2021 23:23:14 -0500 Subject: [PATCH 008/175] [Time to Visualize] Clear All Editor State when Visualize Listing Page Loads (#91005) * Changed the embeddable state transfer service so that it is possible to clear all editor state at once. Used that method in the visualize listing page --- ...mbeddablestatetransfer.cleareditorstate.md | 2 +- .../embeddable_state_transfer.test.ts | 104 +++++++++++------- .../embeddable_state_transfer.ts | 36 +++--- src/plugins/embeddable/public/public.api.md | 2 +- .../components/visualize_listing.tsx | 4 +- .../apps/dashboard/dashboard_lens_by_value.ts | 27 ++++- 6 files changed, 111 insertions(+), 64 deletions(-) diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md index 034f9c70e389fe..d5a8ec311df31e 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md @@ -9,7 +9,7 @@ Clears the [editor state](./kibana-plugin-plugins-embeddable-public.embeddableed Signature: ```typescript -clearEditorState(appId: string): void; +clearEditorState(appId?: string): void; ``` ## Parameters diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts index a8ecb384f782b4..2dda0df1a85c5c 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts @@ -44,8 +44,6 @@ describe('embeddable state transfer', () => { const testAppId = 'testApp'; - const buildKey = (appId: string, key: string) => `${appId}-${key}`; - beforeEach(() => { currentAppId$ = new Subject(); currentAppId$.next(originatingApp); @@ -86,8 +84,10 @@ describe('embeddable state transfer', () => { it('can send an outgoing editor state', async () => { await stateTransfer.navigateToEditor(destinationApp, { state: { originatingApp } }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(destinationApp, EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'superUltraTestDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + [destinationApp]: { + originatingApp: 'superUltraTestDashboard', + }, }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { @@ -104,8 +104,10 @@ describe('embeddable state transfer', () => { }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { kibanaIsNowForSports: 'extremeSportsKibana', - [buildKey(destinationApp, EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'superUltraTestDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + [destinationApp]: { + originatingApp: 'superUltraTestDashboard', + }, }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { @@ -125,9 +127,11 @@ describe('embeddable state transfer', () => { state: { type: 'coolestType', input: { savedObjectId: '150' } }, }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(destinationApp, EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'coolestType', - input: { savedObjectId: '150' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [destinationApp]: { + type: 'coolestType', + input: { savedObjectId: '150' }, + }, }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { @@ -144,9 +148,11 @@ describe('embeddable state transfer', () => { }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { kibanaIsNowForSports: 'extremeSportsKibana', - [buildKey(destinationApp, EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'coolestType', - input: { savedObjectId: '150' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [destinationApp]: { + type: 'coolestType', + input: { savedObjectId: '150' }, + }, }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { @@ -165,8 +171,10 @@ describe('embeddable state transfer', () => { it('can fetch an incoming editor state', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'superUltraTestDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + [testAppId]: { + originatingApp: 'superUltraTestDashboard', + }, }, }); const fetchedState = stateTransfer.getIncomingEditorState(testAppId); @@ -175,14 +183,16 @@ describe('embeddable state transfer', () => { it('can fetch an incoming editor state and ignore state for other apps', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey('otherApp1', EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'whoops not me', - }, - [buildKey('otherApp2', EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'otherTestDashboard', - }, - [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'superUltraTestDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + otherApp1: { + originatingApp: 'whoops not me', + }, + otherApp2: { + originatingApp: 'otherTestDashboard', + }, + [testAppId]: { + originatingApp: 'superUltraTestDashboard', + }, }, }); const fetchedState = stateTransfer.getIncomingEditorState(testAppId); @@ -194,8 +204,10 @@ describe('embeddable state transfer', () => { it('incoming editor state returns undefined when state is not in the right shape', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { - helloSportsKibana: 'superUltraTestDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + [testAppId]: { + helloSportsKibana: 'superUltraTestDashboard', + }, }, }); const fetchedState = stateTransfer.getIncomingEditorState(testAppId); @@ -204,9 +216,11 @@ describe('embeddable state transfer', () => { it('can fetch an incoming embeddable package state', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'skisEmbeddable', - input: { savedObjectId: '123' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [testAppId]: { + type: 'skisEmbeddable', + input: { savedObjectId: '123' }, + }, }, }); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(testAppId); @@ -215,13 +229,15 @@ describe('embeddable state transfer', () => { it('can fetch an incoming embeddable package state and ignore state for other apps', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'skisEmbeddable', - input: { savedObjectId: '123' }, - }, - [buildKey('testApp2', EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'crossCountryEmbeddable', - input: { savedObjectId: '456' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [testAppId]: { + type: 'skisEmbeddable', + input: { savedObjectId: '123' }, + }, + testApp2: { + type: 'crossCountryEmbeddable', + input: { savedObjectId: '456' }, + }, }, }); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(testAppId); @@ -236,7 +252,11 @@ describe('embeddable state transfer', () => { it('embeddable package state returns undefined when state is not in the right shape', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { kibanaIsFor: 'sports' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [testAppId]: { + kibanaIsFor: 'sports', + }, + }, }); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(testAppId); expect(fetchedState).toBeUndefined(); @@ -244,9 +264,11 @@ describe('embeddable state transfer', () => { it('removes embeddable package key when removeAfterFetch is true', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'coolestType', - input: { savedObjectId: '150' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [testAppId]: { + type: 'coolestType', + input: { savedObjectId: '150' }, + }, }, iSHouldStillbeHere: 'doing the sports thing', }); @@ -258,8 +280,10 @@ describe('embeddable state transfer', () => { it('removes editor state key when removeAfterFetch is true', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'superCoolFootballDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + [testAppId]: { + originatingApp: 'superCoolFootballDashboard', + }, }, iSHouldStillbeHere: 'doing the sports thing', }); diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts index 8664a5aae7345f..52a5eccac99105 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts @@ -75,10 +75,14 @@ export class EmbeddableStateTransfer { * @param appId - The app to fetch incomingEditorState for * @param removeAfterFetch - Whether to remove the package state after fetch to prevent duplicates. */ - public clearEditorState(appId: string) { + public clearEditorState(appId?: string) { const currentState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY); if (currentState) { - delete currentState[this.buildKey(appId, EMBEDDABLE_EDITOR_STATE_KEY)]; + if (appId) { + delete currentState[EMBEDDABLE_EDITOR_STATE_KEY]?.[appId]; + } else { + delete currentState[EMBEDDABLE_EDITOR_STATE_KEY]; + } this.storage.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, currentState); } } @@ -117,7 +121,6 @@ export class EmbeddableStateTransfer { this.isTransferInProgress = true; await this.navigateToWithState(appId, EMBEDDABLE_EDITOR_STATE_KEY, { ...options, - appendToExistingState: true, }); } @@ -132,14 +135,9 @@ export class EmbeddableStateTransfer { this.isTransferInProgress = true; await this.navigateToWithState(appId, EMBEDDABLE_PACKAGE_STATE_KEY, { ...options, - appendToExistingState: true, }); } - private buildKey(appId: string, key: string) { - return `${appId}-${key}`; - } - private getIncomingState( guard: (state: unknown) => state is IncomingStateType, appId: string, @@ -148,15 +146,13 @@ export class EmbeddableStateTransfer { keysToRemoveAfterFetch?: string[]; } ): IncomingStateType | undefined { - const incomingState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY)?.[ - this.buildKey(appId, key) - ]; + const incomingState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY)?.[key]?.[appId]; const castState = !guard || guard(incomingState) ? (cloneDeep(incomingState) as IncomingStateType) : undefined; if (castState && options?.keysToRemoveAfterFetch) { const stateReplace = { ...this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY) }; options.keysToRemoveAfterFetch.forEach((keyToRemove: string) => { - delete stateReplace[this.buildKey(appId, keyToRemove)]; + delete stateReplace[keyToRemove]; }); this.storage.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, stateReplace); } @@ -166,14 +162,16 @@ export class EmbeddableStateTransfer { private async navigateToWithState( appId: string, key: string, - options?: { path?: string; state?: OutgoingStateType; appendToExistingState?: boolean } + options?: { path?: string; state?: OutgoingStateType } ): Promise { - const stateObject = options?.appendToExistingState - ? { - ...this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY), - [this.buildKey(appId, key)]: options.state, - } - : { [this.buildKey(appId, key)]: options?.state }; + const existingAppState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY)?.[key] || {}; + const stateObject = { + ...this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY), + [key]: { + ...existingAppState, + [appId]: options?.state, + }, + }; this.storage.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, stateObject); await this.navigateToApp(appId, { path: options?.path }); } diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 3e7014d54958de..189f71b85206bc 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -590,7 +590,7 @@ export class EmbeddableStateTransfer { // Warning: (ae-forgotten-export) The symbol "ApplicationStart" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "PublicAppInfo" needs to be exported by the entry point index.d.ts constructor(navigateToApp: ApplicationStart['navigateToApp'], currentAppId$: ApplicationStart['currentAppId$'], appList?: ReadonlyMap | undefined, customStorage?: Storage); - clearEditorState(appId: string): void; + clearEditorState(appId?: string): void; getAppNameFromId: (appId: string) => string | undefined; getIncomingEditorState(appId: string, removeAfterFetch?: boolean): EmbeddableEditorState | undefined; getIncomingEmbeddablePackage(appId: string, removeAfterFetch?: boolean): EmbeddablePackageState | undefined; diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 87660b64bab611..024752188a88b9 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -64,8 +64,8 @@ export const VisualizeListing = () => { }, [history, pathname, visualizations]); useMount(() => { - // Reset editor state if the visualize listing page is loaded. - stateTransferService.clearEditorState(VisualizeConstants.APP_ID); + // Reset editor state for all apps if the visualize listing page is loaded. + stateTransferService.clearEditorState(); chrome.setBreadcrumbs([ { text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { diff --git a/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts b/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts index a962d22e16551c..f270142b441e28 100644 --- a/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts +++ b/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts @@ -9,10 +9,11 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - const PageObjects = getPageObjects(['common', 'dashboard', 'visualize', 'lens']); + const PageObjects = getPageObjects(['common', 'dashboard', 'visualize', 'lens', 'header']); const find = getService('find'); const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardVisualizations = getService('dashboardVisualizations'); @@ -69,5 +70,29 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const titles = await PageObjects.dashboard.getPanelTitles(); expect(titles.indexOf(newTitle)).to.not.be(-1); }); + + it('is no longer linked to a dashboard after visiting the visuali1ze listing page', async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickLensWidget(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + await PageObjects.lens.notLinkedToOriginatingApp(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // return to origin should not be present in save modal + await testSubjects.click('lnsApp_saveButton'); + const redirectToOriginCheckboxExists = await testSubjects.exists('returnToOriginModeSwitch'); + expect(redirectToOriginCheckboxExists).to.be(false); + }); }); } From 644bcbccd41aa7a226a48a3ac154a5a360c4d002 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Fri, 12 Feb 2021 10:55:38 +0300 Subject: [PATCH 009/175] [Vega] Restores signal values on refresh (#90774) * Vega kibanaAddFilter() Resets Signal Values Back to Default Closes: #88976 * fix ci * introduce restoreSignalValuesOnRefresh option * update docs --- docs/user/dashboard/vega-reference.asciidoc | 6 +- .../public/data_model/vega_parser.ts | 3 + .../public/lib/vega_state_restorer.test.ts | 93 +++++++++++++++++++ .../public/lib/vega_state_restorer.ts | 69 ++++++++++++++ .../public/vega_view/vega_base_view.d.ts | 5 +- .../public/vega_view/vega_base_view.js | 13 ++- .../vega_view/vega_map_view/view.test.ts | 9 +- .../public/vega_view/vega_map_view/view.ts | 13 ++- .../public/vega_visualization.ts | 6 ++ 9 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/plugins/vis_type_vega/public/lib/vega_state_restorer.test.ts create mode 100644 src/plugins/vis_type_vega/public/lib/vega_state_restorer.ts diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index 88fd870fefa74d..cc384ec041a9da 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -401,7 +401,9 @@ Vega-Lite compilation. [[vega-expression-functions]] ===== (Vega only) Expression functions which can update the time range and dashboard filters -{kib} has extended the Vega expression language with these functions: +{kib} has extended the Vega expression language with these functions. +These functions will trigger new data to be fetched, which by default will reset Vega signals. +To keep signal values set `restoreSignalValuesOnRefresh: true` in the Vega config. ```js /** @@ -444,6 +446,8 @@ kibanaSetTimeFilter(start, end) hideWarnings: true // Vega renderer to use: `svg` or `canvas` (default) renderer: canvas + // Defaults to 'false', restores Vega signal values on refresh + restoreSignalValuesOnRefresh: false } } } diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts index d3647b35a5b94b..5a36390dda0a7f 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -53,6 +53,7 @@ const DEFAULT_PARSER: string = 'elasticsearch'; export class VegaParser { spec: VegaSpec; hideWarnings: boolean; + restoreSignalValuesOnRefresh: boolean; error?: string; warnings: string[]; _urlParsers: UrlParserConfig | undefined; @@ -137,6 +138,8 @@ The URL is an identifier only. Kibana and your browser will never access this UR this._config = this._parseConfig(); this.hideWarnings = !!this._config.hideWarnings; + this._parseBool('restoreSignalValuesOnRefresh', this._config, false); + this.restoreSignalValuesOnRefresh = this._config.restoreSignalValuesOnRefresh; this.useMap = this._config.type === 'map'; this.renderer = this._config.renderer === 'svg' ? 'svg' : 'canvas'; this.tooltips = this._parseTooltips(); diff --git a/src/plugins/vis_type_vega/public/lib/vega_state_restorer.test.ts b/src/plugins/vis_type_vega/public/lib/vega_state_restorer.test.ts new file mode 100644 index 00000000000000..57b352b30dd205 --- /dev/null +++ b/src/plugins/vis_type_vega/public/lib/vega_state_restorer.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { createVegaStateRestorer } from './vega_state_restorer'; + +describe('extractIndexPatternsFromSpec', () => { + test('should create vega state restorer ', async () => { + expect(createVegaStateRestorer()).toMatchInlineSnapshot(` + Object { + "clear": [Function], + "restore": [Function], + "save": [Function], + } + `); + }); + + test('should save state', async () => { + const vegaStateRestorer = createVegaStateRestorer(); + + vegaStateRestorer.save({ + signals: { foo: 'foo' }, + data: { test: 'test' }, + }); + + expect(vegaStateRestorer.restore()).toMatchInlineSnapshot(` + Object { + "signals": Object { + "foo": "foo", + }, + } + `); + }); + + test('should restore of "data" if "restoreData" is true', () => { + const vegaStateRestorer = createVegaStateRestorer(); + + vegaStateRestorer.save({ + signals: { foo: 'foo' }, + data: { test: 'test' }, + }); + + expect(vegaStateRestorer.restore(true)).toMatchInlineSnapshot(` + Object { + "data": Object { + "test": "test", + }, + "signals": Object { + "foo": "foo", + }, + } + `); + }); + + test('should clear saved state', () => { + const vegaStateRestorer = createVegaStateRestorer(); + + vegaStateRestorer.save({ + signals: { foo: 'foo' }, + data: { test: 'test' }, + }); + vegaStateRestorer.clear(); + + expect(vegaStateRestorer.restore(true)).toMatchInlineSnapshot(`null`); + }); + + test('should omit signals', () => { + const vegaStateRestorer = createVegaStateRestorer({ omitSignals: ['foo'] }); + + vegaStateRestorer.save({ + signals: { foo: 'foo' }, + }); + + expect(vegaStateRestorer.restore()).toMatchInlineSnapshot(` + Object { + "signals": Object {}, + } + `); + }); + test('should not save state if isActive is false', () => { + const vegaStateRestorer = createVegaStateRestorer({ isActive: () => false }); + + vegaStateRestorer.save({ + signals: { foo: 'foo' }, + }); + + expect(vegaStateRestorer.restore()).toMatchInlineSnapshot(`null`); + }); +}); diff --git a/src/plugins/vis_type_vega/public/lib/vega_state_restorer.ts b/src/plugins/vis_type_vega/public/lib/vega_state_restorer.ts new file mode 100644 index 00000000000000..b39f2495656a39 --- /dev/null +++ b/src/plugins/vis_type_vega/public/lib/vega_state_restorer.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { omit } from 'lodash'; + +interface VegaStateRestorerOptions { + /** + * List of excluded signals + * + * By default, all Build-in signals (width,height,padding,autosize,background) were excluded + * @see https://vega.github.io/vega/docs/signals/ + */ + omitSignals?: string[]; + /** + * Gets a value that indicates whether the VegaStateRestorer is active. + */ + isActive?: () => boolean; +} + +type State = Partial<{ + signals: Record; + data: Record; +}>; + +export const createVegaStateRestorer = ({ + omitSignals = ['width', 'height', 'padding', 'autosize', 'background'], + isActive = () => true, +}: VegaStateRestorerOptions = {}) => { + let state: State | null; + + return { + /** + * Save Vega state + * @public + * @param newState - new state value + */ + save: (newState: State) => { + if (newState && isActive()) { + state = { + signals: omit(newState.signals, omitSignals || []), + data: newState.data, + }; + } + }, + + /** + * Restore Vega state + * @public + * @param restoreData - by default, we only recover signals, + * but if the data also needs to be recovered, this option should be set to true + */ + restore: (restoreData = false) => + isActive() && state ? omit(state, restoreData ? undefined : 'data') : null, + + /** + * Clear saved Vega state + * + * @public + */ + clear: () => { + state = null; + }, + }; +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts index 73620d85100a3b..a3376705305480 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts @@ -10,6 +10,7 @@ import { DataPublicPluginStart } from 'src/plugins/data/public'; import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; import { IServiceSettings } from 'src/plugins/maps_legacy/public'; import { VegaParser } from '../data_model/vega_parser'; +import { createVegaStateRestorer } from '../lib/vega_state_restorer'; interface VegaViewParams { parentEl: HTMLDivElement; @@ -18,6 +19,7 @@ interface VegaViewParams { serviceSettings: IServiceSettings; filterManager: DataPublicPluginStart['query']['filterManager']; timefilter: DataPublicPluginStart['query']['timefilter']['timefilter']; + vegaStateRestorer: ReturnType; } export class VegaBaseView { @@ -34,5 +36,6 @@ export class VegaBaseView { _$container: any; _parser: any; _vegaViewConfig: any; - _serviceSettings: any; + _serviceSettings: VegaViewParams['serviceSettings']; + _vegaStateRestorer: VegaViewParams['vegaStateRestorer']; } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index d9b1b536a6d171..14e4d6034c1c22 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -62,6 +62,7 @@ export class VegaBaseView { this._destroyHandlers = []; this._initialized = false; this._enableExternalUrls = getEnableExternalUrls(); + this._vegaStateRestorer = opts.vegaStateRestorer; } async init() { @@ -103,6 +104,10 @@ export class VegaBaseView { this._$messages = null; } if (this._view) { + const state = this._view.getState(); + if (state) { + this._vegaStateRestorer.save(state); + } this._view.finalize(); } this._view = null; @@ -262,7 +267,13 @@ export class VegaBaseView { this._addDestroyHandler(() => tthandler.hideTooltip()); } - return view.runAsync(); // Allows callers to await rendering + const state = this._vegaStateRestorer.restore(); + + if (state) { + return view.setState(state); + } else { + return view.runAsync(); + } } } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts index 21c18e15c242ca..6aac6891ae0e82 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts @@ -100,13 +100,18 @@ describe('vega_map_view/view', () => { async function createVegaMapView() { await vegaParser.parseAsync(); - return new VegaMapView({ + return new VegaMapView(({ vegaParser, filterManager: dataPluginStart.query.filterManager, timefilter: dataPluginStart.query.timefilter.timefilter, fireEvent: (event: any) => {}, parentEl: document.createElement('div'), - } as VegaViewParams); + vegaStateRestorer: { + save: jest.fn(), + restore: jest.fn(), + clear: jest.fn(), + }, + } as unknown) as VegaViewParams); } beforeEach(() => { diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts index c2112659a50ae7..ca936cb49c7e0b 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts @@ -68,11 +68,18 @@ export class VegaMapView extends VegaBaseView { private getMapParams(defaults: { maxZoom: number; minZoom: number }): Partial { const { longitude, latitude, scrollWheelZoom } = this._parser.mapConfig; - const zoomSettings = validateZoomSettings(this._parser.mapConfig, defaults, this.onWarn); + const { zoom, maxZoom, minZoom } = validateZoomSettings( + this._parser.mapConfig, + defaults, + this.onWarn + ); + const { signals } = this._vegaStateRestorer.restore() || {}; return { - ...zoomSettings, - center: [longitude, latitude], + maxZoom, + minZoom, + zoom: signals?.zoom ?? zoom, + center: [signals?.longitude ?? longitude, signals?.latitude ?? latitude], scrollZoom: scrollWheelZoom, }; } diff --git a/src/plugins/vis_type_vega/public/vega_visualization.ts b/src/plugins/vis_type_vega/public/vega_visualization.ts index 63eec674af9844..d207aadec656a6 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.ts +++ b/src/plugins/vis_type_vega/public/vega_visualization.ts @@ -12,6 +12,7 @@ import { VegaParser } from './data_model/vega_parser'; import { VegaVisualizationDependencies } from './plugin'; import { getNotifications, getData } from './services'; import type { VegaView } from './vega_view/vega_view'; +import { createVegaStateRestorer } from './lib/vega_state_restorer'; type VegaVisType = new (el: HTMLDivElement, fireEvent: IInterpreterRenderHandlers['event']) => { render(visData: VegaParser): Promise; @@ -24,6 +25,9 @@ export const createVegaVisualization = ({ class VegaVisualization { private readonly dataPlugin = getData(); private vegaView: InstanceType | null = null; + private vegaStateRestorer = createVegaStateRestorer({ + isActive: () => Boolean(this.vegaView?._parser?.restoreSignalValuesOnRefresh), + }); constructor( private el: HTMLDivElement, @@ -71,6 +75,7 @@ export const createVegaVisualization = ({ const vegaViewParams = { parentEl: this.el, fireEvent: this.fireEvent, + vegaStateRestorer: this.vegaStateRestorer, vegaParser, serviceSettings, filterManager, @@ -89,6 +94,7 @@ export const createVegaVisualization = ({ } destroy() { + this.vegaStateRestorer.clear(); this.vegaView?.destroy(); } }; From afed310b82d538cbe44a16318acaf656bdafee91 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 12 Feb 2021 00:56:21 -0800 Subject: [PATCH 010/175] [ts/build-refs] implement experimental remote cache (#91012) * [ts/build-refs] implement experimental remote cache * delete old tests * add some more tests * add some docs and a readme * fix kibanaPackageJson usage Co-authored-by: spalger Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .ci/Jenkinsfile_baseline_capture | 12 +- .../kbn-utils/src/package_json/index.test.ts | 8 +- packages/kbn-utils/src/package_json/index.ts | 2 +- .../integration_tests/version_info.test.ts | 2 +- src/dev/ci_setup/setup.sh | 11 +- src/dev/typescript/build_ts_refs_cli.ts | 36 ++- src/dev/typescript/ref_output_cache/README.md | 17 ++ .../typescript/ref_output_cache/archives.ts | 186 ++++++++++++++ src/dev/typescript/ref_output_cache/index.ts | 9 + .../__fixtures__/archives/1234.zip | Bin 0 -> 845 bytes .../__fixtures__/archives/5678.zip | Bin 0 -> 694 bytes .../integration_tests/archives.test.ts | 240 ++++++++++++++++++ .../ref_output_cache.test.ts | 156 ++++++++++++ .../ref_output_cache/ref_output_cache.ts | 185 ++++++++++++++ .../typescript/ref_output_cache/repo_info.ts | 55 ++++ src/dev/typescript/ref_output_cache/zip.ts | 52 ++++ .../fleet/server/services/app_context.ts | 6 +- 17 files changed, 963 insertions(+), 14 deletions(-) create mode 100644 src/dev/typescript/ref_output_cache/README.md create mode 100644 src/dev/typescript/ref_output_cache/archives.ts create mode 100644 src/dev/typescript/ref_output_cache/index.ts create mode 100644 src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/1234.zip create mode 100644 src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/5678.zip create mode 100644 src/dev/typescript/ref_output_cache/integration_tests/archives.test.ts create mode 100644 src/dev/typescript/ref_output_cache/integration_tests/ref_output_cache.test.ts create mode 100644 src/dev/typescript/ref_output_cache/ref_output_cache.ts create mode 100644 src/dev/typescript/ref_output_cache/repo_info.ts create mode 100644 src/dev/typescript/ref_output_cache/zip.ts diff --git a/.ci/Jenkinsfile_baseline_capture b/.ci/Jenkinsfile_baseline_capture index 791cacf7abb4c6..6993dc9e087f97 100644 --- a/.ci/Jenkinsfile_baseline_capture +++ b/.ci/Jenkinsfile_baseline_capture @@ -12,7 +12,17 @@ kibanaPipeline(timeoutMinutes: 120) { ]) { parallel([ 'oss-baseline': { - workers.ci(name: 'oss-baseline', size: 'l', ramDisk: true, runErrorReporter: false) { + workers.ci(name: 'oss-baseline', size: 'l', ramDisk: true, runErrorReporter: false, bootstrapped: false) { + // bootstrap ourselves, but with the env needed to upload the ts refs cache + withGcpServiceAccount.fromVaultSecret('secret/kibana-issues/dev/ci-artifacts-key', 'value') { + withEnv([ + 'BUILD_TS_REFS_CACHE_ENABLE=true', + 'BUILD_TS_REFS_CACHE_CAPTURE=true' + ]) { + kibanaPipeline.doSetup() + } + } + kibanaPipeline.functionalTestProcess('oss-baseline', './test/scripts/jenkins_baseline.sh')() } }, diff --git a/packages/kbn-utils/src/package_json/index.test.ts b/packages/kbn-utils/src/package_json/index.test.ts index 49aace6b4ff93e..f6d7e1f2f611b6 100644 --- a/packages/kbn-utils/src/package_json/index.test.ts +++ b/packages/kbn-utils/src/package_json/index.test.ts @@ -7,14 +7,14 @@ */ import path from 'path'; -import { kibanaPackageJSON } from './'; +import { kibanaPackageJson } from './'; it('parses package.json', () => { - expect(kibanaPackageJSON.name).toEqual('kibana'); + expect(kibanaPackageJson.name).toEqual('kibana'); }); it('includes __dirname and __filename', () => { const root = path.resolve(__dirname, '../../../../'); - expect(kibanaPackageJSON.__filename).toEqual(path.resolve(root, 'package.json')); - expect(kibanaPackageJSON.__dirname).toEqual(root); + expect(kibanaPackageJson.__filename).toEqual(path.resolve(root, 'package.json')); + expect(kibanaPackageJson.__dirname).toEqual(root); }); diff --git a/packages/kbn-utils/src/package_json/index.ts b/packages/kbn-utils/src/package_json/index.ts index 0368d883896e92..40ce353780749a 100644 --- a/packages/kbn-utils/src/package_json/index.ts +++ b/packages/kbn-utils/src/package_json/index.ts @@ -9,7 +9,7 @@ import { dirname, resolve } from 'path'; import { REPO_ROOT } from '../repo_root'; -export const kibanaPackageJSON = { +export const kibanaPackageJson = { __filename: resolve(REPO_ROOT, 'package.json'), __dirname: dirname(resolve(REPO_ROOT, 'package.json')), ...require(resolve(REPO_ROOT, 'package.json')), diff --git a/src/dev/build/lib/integration_tests/version_info.test.ts b/src/dev/build/lib/integration_tests/version_info.test.ts index 34d537611e0c6f..e7a3a04c047345 100644 --- a/src/dev/build/lib/integration_tests/version_info.test.ts +++ b/src/dev/build/lib/integration_tests/version_info.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { kibanaPackageJSON as pkg } from '@kbn/dev-utils'; +import { kibanaPackageJson as pkg } from '@kbn/dev-utils'; import { getVersionInfo } from '../version_info'; diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index db7110d2d0875d..c4559029e5607a 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -25,7 +25,16 @@ echo "build --remote_header=x-buildbuddy-api-key=$KIBANA_BUILDBUDDY_CI_API_KEY" ### install dependencies ### echo " -- installing node.js dependencies" -yarn kbn bootstrap +yarn kbn bootstrap --verbose + +### +### upload ts-refs-cache artifacts as quickly as possible so they are available for download +### +if [[ "$BUILD_TS_REFS_CACHE_CAPTURE" == "true" ]]; then + cd "$KIBANA_DIR/target/ts_refs_cache" + gsutil cp "*.zip" 'gs://kibana-ci-ts-refs-cache/' + cd "$KIBANA_DIR" +fi ### ### Download es snapshots diff --git a/src/dev/typescript/build_ts_refs_cli.ts b/src/dev/typescript/build_ts_refs_cli.ts index 1f7bf18b5012d9..0c4f312126762f 100644 --- a/src/dev/typescript/build_ts_refs_cli.ts +++ b/src/dev/typescript/build_ts_refs_cli.ts @@ -6,28 +6,58 @@ * Side Public License, v 1. */ -import { run } from '@kbn/dev-utils'; +import Path from 'path'; + +import { run, REPO_ROOT } from '@kbn/dev-utils'; import del from 'del'; +import { RefOutputCache } from './ref_output_cache'; import { buildAllTsRefs, REF_CONFIG_PATHS } from './build_ts_refs'; import { getOutputsDeep } from './ts_configfile'; import { concurrentMap } from './concurrent_map'; +const CACHE_WORKING_DIR = Path.resolve(REPO_ROOT, 'data/ts_refs_output_cache'); + export async function runBuildRefsCli() { run( async ({ log, flags }) => { + const outDirs = getOutputsDeep(REF_CONFIG_PATHS); + if (flags.clean) { - const outDirs = getOutputsDeep(REF_CONFIG_PATHS); log.info('deleting', outDirs.length, 'ts output directories'); await concurrentMap(100, outDirs, (outDir) => del(outDir)); } + let outputCache; + if (flags.cache) { + outputCache = await RefOutputCache.create({ + log, + outDirs, + repoRoot: REPO_ROOT, + workingDir: CACHE_WORKING_DIR, + upstreamUrl: 'https://github.com/elastic/kibana.git', + }); + + await outputCache.initCaches(); + } + await buildAllTsRefs(log); + + if (outputCache) { + if (process.env.BUILD_TS_REFS_CACHE_CAPTURE === 'true') { + await outputCache.captureCache(Path.resolve(REPO_ROOT, 'target/ts_refs_cache')); + } + + await outputCache.cleanup(); + } }, { description: 'Build TypeScript projects', flags: { - boolean: ['clean'], + boolean: ['clean', 'cache'], + default: { + cache: process.env.BUILD_TS_REFS_CACHE_ENABLE === 'true' ? true : false, + }, }, log: { defaultLevel: 'debug', diff --git a/src/dev/typescript/ref_output_cache/README.md b/src/dev/typescript/ref_output_cache/README.md new file mode 100644 index 00000000000000..41506a118dcb9b --- /dev/null +++ b/src/dev/typescript/ref_output_cache/README.md @@ -0,0 +1,17 @@ +# `node scripts/build_ts_refs` output cache + +This module implements the logic for caching the output of building the ts refs and extracting those caches into the source repo to speed up the execution of this script. We've implemented this as a stop-gap solution while we migrate to Bazel which will handle caching the types produced by the +scripts independently and speed things up incredibly, but in the meantime we need something to fix the 10 minute bootstrap times we're seeing. + +How it works: + + 1. traverse the TS projects referenced from `tsconfig.refs.json` and collect their `compilerOptions.outDir` setting. + 2. determine the `upstreamBranch` by reading the `branch` property out of `package.json` + 3. fetch the latest changes from `https://github.com/elastic/kibana.git` for that branch + 4. determine the merge base between `HEAD` and the latest ref from the `upstreamBranch` + 5. check in the `data/ts_refs_output_cache/archives` dir (where we keep the 10 most recent downloads) and at `https://ts-refs-cache.kibana.dev/{sha}.zip` for the cache of the merge base commit, and up to 5 commits before that in the log, stopping once we find one that is available locally or was downloaded. + 6. check for the `.ts-ref-cache-merge-base` file in each `outDir`, which records the `mergeBase` that was used to initialize that `outDir`, if the file exists and matches the `sha` that we plan to use for our cache then exclude that `outDir` from getting initialized with the cache data + 7. for each `outDir` that either hasn't been initialized with cache data or was initialized with cache data from another merge base, delete the `outDir` and replace it with the copy stored in the downloaded cache + 1. if there isn't a cached version of that `outDir` replace it with an empty directory + 8. write the current `mergeBase` to the `.ts-ref-cache-merge-base` file in each `outDir` + 9. run `tsc`, which will only build things which have changed since the cache was created \ No newline at end of file diff --git a/src/dev/typescript/ref_output_cache/archives.ts b/src/dev/typescript/ref_output_cache/archives.ts new file mode 100644 index 00000000000000..4db40221809975 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/archives.ts @@ -0,0 +1,186 @@ +/* + * 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 Fs from 'fs/promises'; +import { createWriteStream } from 'fs'; +import Path from 'path'; +import { promisify } from 'util'; +import { pipeline } from 'stream'; + +import { ToolingLog } from '@kbn/dev-utils'; +import Axios from 'axios'; +import del from 'del'; + +// https://github.com/axios/axios/tree/ffea03453f77a8176c51554d5f6c3c6829294649/lib/adapters +// @ts-expect-error untyped internal module used to prevent axios from using xhr adapter in tests +import AxiosHttpAdapter from 'axios/lib/adapters/http'; + +interface Archive { + sha: string; + path: string; + time: number; +} + +const asyncPipeline = promisify(pipeline); + +async function getCacheNames(cacheDir: string) { + try { + return await Fs.readdir(cacheDir); + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } + + throw error; + } +} + +export class Archives { + static async create(log: ToolingLog, workingDir: string) { + const dir = Path.resolve(workingDir, 'archives'); + const bySha = new Map(); + + for (const name of await getCacheNames(dir)) { + const path = Path.resolve(dir, name); + + if (!name.endsWith('.zip')) { + log.debug('deleting unexpected file in archives dir', path); + await Fs.unlink(path); + continue; + } + + const sha = name.replace('.zip', ''); + log.verbose('identified archive for', sha); + const s = await Fs.stat(path); + const time = Math.max(s.atimeMs, s.mtimeMs); + bySha.set(sha, { + path, + time, + sha, + }); + } + + return new Archives(log, workingDir, bySha); + } + + protected constructor( + private readonly log: ToolingLog, + private readonly workDir: string, + private readonly bySha: Map + ) {} + + size() { + return this.bySha.size; + } + + get(sha: string) { + return this.bySha.get(sha); + } + + async delete(sha: string) { + const archive = this.get(sha); + if (archive) { + await Fs.unlink(archive.path); + this.bySha.delete(sha); + } + } + + *[Symbol.iterator]() { + yield* this.bySha.values(); + } + + /** + * Attempt to download the cache for a given sha, adding it to this.bySha + * and returning true if successful, logging and returning false otherwise. + * + * @param sha the commit sha we should try to download the cache for + */ + async attemptToDownload(sha: string) { + if (this.bySha.has(sha)) { + return true; + } + + const url = `https://ts-refs-cache.kibana.dev/${sha}.zip`; + this.log.debug('attempting to download cache for', sha, 'from', url); + + const filename = `${sha}.zip`; + const target = Path.resolve(this.workDir, 'archives', `${filename}`); + const tmpTarget = `${target}.tmp`; + + try { + const resp = await Axios.request({ + url, + responseType: 'stream', + adapter: AxiosHttpAdapter, + }); + + await Fs.mkdir(Path.dirname(target), { recursive: true }); + await asyncPipeline(resp.data, createWriteStream(tmpTarget)); + this.log.debug('download complete, renaming tmp'); + + await Fs.rename(tmpTarget, target); + this.bySha.set(sha, { + sha, + path: target, + time: Date.now(), + }); + + this.log.debug('download of cache for', sha, 'complete'); + return true; + } catch (error) { + await del(tmpTarget, { force: true }); + + if (!error.response) { + this.log.debug(`failed to download cache, ignoring error:`, error.message); + return false; + } + + if (error.response.status === 404) { + return false; + } + + this.log.debug(`failed to download cache,`, error.response.status, 'response'); + } + } + + /** + * Iterate through a list of shas, which represent commits + * on our upstreamBranch, and look for caches which are + * already downloaded, or try to download them. If the cache + * for that commit is not available for any reason the next + * sha will be tried. + * + * If we reach the end of the list without any caches being + * available undefined is returned. + * + * @param shas shas for commits to try and find caches for + */ + async getFirstAvailable(shas: string[]): Promise { + if (!shas.length) { + throw new Error('no possible shas to pick archive from'); + } + + for (const sha of shas) { + let archive = this.bySha.get(sha); + + // if we don't have one locally try to download one + if (!archive && (await this.attemptToDownload(sha))) { + archive = this.bySha.get(sha); + } + + // if we found the archive return it + if (archive) { + return archive; + } + + this.log.debug('no archive available for', sha); + } + + return undefined; + } +} diff --git a/src/dev/typescript/ref_output_cache/index.ts b/src/dev/typescript/ref_output_cache/index.ts new file mode 100644 index 00000000000000..8d55a31a1771c5 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * from './ref_output_cache'; diff --git a/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/1234.zip b/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/1234.zip new file mode 100644 index 0000000000000000000000000000000000000000..07c14c13488b5f58d0406cb2feaccf0a8f314634 GIT binary patch literal 845 zcmWIWW@Zs#00FK!o`XC4E#bdMZ5YSRI3zHK>lhYHD5j-FI znjpcfE+B1aVDO7EQ(^wb#zu_?5<)^OiH#)&MGTUn8D29MKKjGM^XTNyomHKztd=XQ z6jzF>uB$xxQ*&nJOM$Uy%Q^4xOjTj@tnAFs2W=DLQuW~g0>-cx%#rEpukHfl^PVQ^pzecFuV&|Ki-^%a3`EfNj zcG>rPucxt`Huk9RJGn07eDs-rxowlAcboM;`26AhJ=rkh!RCPY zAa|nM@)#&92DBwU-q+FDKR7}kJAPL67mk3%G~7G<>h^;M0X8uuWt{}SzTS9 zbGx+E8(PxWUN>R82==IjpxCWepjSZN3GilQ5@E(2B|s%$@YWGT5f?!a6Jdde9IT)y zf`KKCPEZrEg&!#P(ZUbrRG7iY-T=i43@m9}0c4UFM*-ffY#?)(fsmh(fng#LGcW)E DTd typeof v === 'object' && v && typeof v.time === 'number', + (v) => ({ ...v, time: '' }) + ) +); + +jest.mock('axios', () => { + return { + request: jest.fn(), + }; +}); +const mockRequest: jest.Mock = jest.requireMock('axios').request; + +import { Archives } from '../archives'; + +const FIXTURE = Path.resolve(__dirname, '__fixtures__'); +const TMP = Path.resolve(__dirname, '__tmp__'); + +beforeAll(() => del(TMP, { force: true })); +beforeEach(() => cpy('.', TMP, { cwd: FIXTURE, parents: true })); +afterEach(async () => { + await del(TMP, { force: true }); + jest.resetAllMocks(); +}); + +const readArchiveDir = () => + Fs.readdirSync(Path.resolve(TMP, 'archives')).sort((a, b) => a.localeCompare(b)); + +const log = new ToolingLog(); +const logWriter = new ToolingLogCollectingWriter(); +log.setWriters([logWriter]); +afterEach(() => (logWriter.messages.length = 0)); + +it('deletes invalid files', async () => { + const path = Path.resolve(TMP, 'archives/foo.txt'); + Fs.writeFileSync(path, 'hello'); + const archives = await Archives.create(log, TMP); + + expect(archives.size()).toBe(2); + expect(Fs.existsSync(path)).toBe(false); +}); + +it('exposes archives by sha', async () => { + const archives = await Archives.create(log, TMP); + expect(archives.get('1234')).toMatchInlineSnapshot(` + Object { + "path": /src/dev/typescript/ref_output_cache/integration_tests/__tmp__/archives/1234.zip, + "sha": "1234", + "time": "", + } + `); + expect(archives.get('5678')).toMatchInlineSnapshot(` + Object { + "path": /src/dev/typescript/ref_output_cache/integration_tests/__tmp__/archives/5678.zip, + "sha": "5678", + "time": "", + } + `); + expect(archives.get('foo')).toMatchInlineSnapshot(`undefined`); +}); + +it('deletes archives', async () => { + const archives = await Archives.create(log, TMP); + expect(archives.size()).toBe(2); + await archives.delete('1234'); + expect(archives.size()).toBe(1); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "5678.zip", + ] + `); +}); + +it('returns false when attempting to download for sha without cache', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + throw new Error('404!'); + }); + + await expect(archives.attemptToDownload('foobar')).resolves.toBe(false); +}); + +it('returns true when able to download an archive for a sha', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + return { + data: Readable.from('foobar zip contents'), + }; + }); + + expect(archives.size()).toBe(2); + await expect(archives.attemptToDownload('foobar')).resolves.toBe(true); + expect(archives.size()).toBe(3); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + "foobar.zip", + ] + `); + expect(Fs.readFileSync(Path.resolve(TMP, 'archives/foobar.zip'), 'utf-8')).toBe( + 'foobar zip contents' + ); +}); + +it('returns true if attempting to download a cache which is already downloaded', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + throw new Error(`it shouldn't try to download anything`); + }); + + expect(archives.size()).toBe(2); + await expect(archives.attemptToDownload('1234')).resolves.toBe(true); + expect(archives.size()).toBe(2); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + ] + `); +}); + +it('returns false and deletes the zip if the download fails part way', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + let readCounter = 0; + return { + data: new Readable({ + read() { + readCounter++; + if (readCounter === 1) { + this.push('foo'); + } else { + this.emit('error', new Error('something went wrong')); + } + }, + }), + }; + }); + + await expect(archives.attemptToDownload('foo')).resolves.toBe(false); + expect(archives.size()).toBe(2); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + ] + `); +}); + +it('resolves to first sha if it is available locally', async () => { + const archives = await Archives.create(log, TMP); + + expect(await archives.getFirstAvailable(['1234', '5678'])).toHaveProperty('sha', '1234'); + expect(await archives.getFirstAvailable(['5678', '1234'])).toHaveProperty('sha', '5678'); +}); + +it('resolves to first local sha when it tried to reach network and gets errors', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + throw new Error('no network available'); + }); + + expect(await archives.getFirstAvailable(['foo', 'bar', '1234'])).toHaveProperty('sha', '1234'); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(logWriter.messages).toMatchInlineSnapshot(` + Array [ + " sill identified archive for 1234", + " sill identified archive for 5678", + " debg attempting to download cache for foo from https://ts-refs-cache.kibana.dev/foo.zip", + " debg failed to download cache, ignoring error: no network available", + " debg no archive available for foo", + " debg attempting to download cache for bar from https://ts-refs-cache.kibana.dev/bar.zip", + " debg failed to download cache, ignoring error: no network available", + " debg no archive available for bar", + ] + `); +}); + +it('resolves to first remote that downloads successfully', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation((params) => { + if (params.url === `https://ts-refs-cache.kibana.dev/bar.zip`) { + return { + data: Readable.from('bar cache data'), + }; + } + + throw new Error('no network available'); + }); + + const archive = await archives.getFirstAvailable(['foo', 'bar', '1234']); + expect(archive).toHaveProperty('sha', 'bar'); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(logWriter.messages).toMatchInlineSnapshot(` + Array [ + " sill identified archive for 1234", + " sill identified archive for 5678", + " debg attempting to download cache for foo from https://ts-refs-cache.kibana.dev/foo.zip", + " debg failed to download cache, ignoring error: no network available", + " debg no archive available for foo", + " debg attempting to download cache for bar from https://ts-refs-cache.kibana.dev/bar.zip", + " debg download complete, renaming tmp", + " debg download of cache for bar complete", + ] + `); + + expect(Fs.readFileSync(archive!.path, 'utf-8')).toBe('bar cache data'); +}); diff --git a/src/dev/typescript/ref_output_cache/integration_tests/ref_output_cache.test.ts b/src/dev/typescript/ref_output_cache/integration_tests/ref_output_cache.test.ts new file mode 100644 index 00000000000000..2bc75785ee6a73 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/integration_tests/ref_output_cache.test.ts @@ -0,0 +1,156 @@ +/* + * 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 Path from 'path'; +import Fs from 'fs'; + +import del from 'del'; +import cpy from 'cpy'; +import globby from 'globby'; +import { + ToolingLog, + createAbsolutePathSerializer, + createStripAnsiSerializer, + ToolingLogCollectingWriter, +} from '@kbn/dev-utils'; + +import { RefOutputCache, OUTDIR_MERGE_BASE_FILENAME } from '../ref_output_cache'; +import { Archives } from '../archives'; +import type { RepoInfo } from '../repo_info'; + +jest.mock('../repo_info'); +const { RepoInfo: MockRepoInfo } = jest.requireMock('../repo_info'); + +jest.mock('axios'); +const { request: mockRequest } = jest.requireMock('axios'); + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); +expect.addSnapshotSerializer(createStripAnsiSerializer()); + +const FIXTURE = Path.resolve(__dirname, '__fixtures__'); +const TMP = Path.resolve(__dirname, '__tmp__'); +const repo: jest.Mocked = new MockRepoInfo(); +const log = new ToolingLog(); +const logWriter = new ToolingLogCollectingWriter(); +log.setWriters([logWriter]); + +beforeAll(() => del(TMP, { force: true })); +beforeEach(() => cpy('.', TMP, { cwd: FIXTURE, parents: true })); +afterEach(async () => { + await del(TMP, { force: true }); + jest.resetAllMocks(); + logWriter.messages.length = 0; +}); + +it('creates and extracts caches, ingoring dirs with matching merge-base file and placing merge-base files', async () => { + // setup repo mock + const HEAD = 'abcdefg'; + repo.getHeadSha.mockResolvedValue(HEAD); + repo.getRelative.mockImplementation((path) => Path.relative(TMP, path)); + repo.getRecentShasFrom.mockResolvedValue(['5678', '1234']); + + // create two fake outDirs + const outDirs = [Path.resolve(TMP, 'out/foo'), Path.resolve(TMP, 'out/bar')]; + for (const dir of outDirs) { + Fs.mkdirSync(dir, { recursive: true }); + Fs.writeFileSync(Path.resolve(dir, 'test'), 'hello world'); + } + + // init an archives instance using tmp + const archives = await Archives.create(log, TMP); + + // init the RefOutputCache with our mock data + const refOutputCache = new RefOutputCache(log, repo, archives, outDirs, HEAD); + + // create the new cache right in the archives dir + await refOutputCache.captureCache(Path.resolve(TMP)); + const cachePath = Path.resolve(TMP, `${HEAD}.zip`); + + // check that the cache was created and stored in the archives + if (!Fs.existsSync(cachePath)) { + throw new Error('zip was not created as expected'); + } + + mockRequest.mockImplementation((params: any) => { + if (params.url.endsWith(`${HEAD}.zip`)) { + return { + data: Fs.createReadStream(cachePath), + }; + } + + throw new Error(`unexpected url: ${params.url}`); + }); + + // modify the files in the outDirs so we can see which ones are restored from the cache + for (const dir of outDirs) { + Fs.writeFileSync(Path.resolve(dir, 'test'), 'not cleared by cache init'); + } + // add the mergeBase to the first outDir so that it is ignored + Fs.writeFileSync(Path.resolve(outDirs[0], OUTDIR_MERGE_BASE_FILENAME), HEAD); + + // rebuild the outDir from the refOutputCache + await refOutputCache.initCaches(); + + const files = Object.fromEntries( + globby + .sync(outDirs, { dot: true }) + .map((path) => [Path.relative(TMP, path), Fs.readFileSync(path, 'utf-8')]) + ); + + expect(files).toMatchInlineSnapshot(` + Object { + "out/bar/.ts-ref-cache-merge-base": "abcdefg", + "out/bar/test": "hello world", + "out/foo/.ts-ref-cache-merge-base": "abcdefg", + "out/foo/test": "not cleared by cache init", + } + `); + expect(logWriter.messages).toMatchInlineSnapshot(` + Array [ + " sill identified archive for 1234", + " sill identified archive for 5678", + " debg writing ts-ref cache to abcdefg.zip", + " succ wrote archive to abcdefg.zip", + " debg attempting to download cache for abcdefg from https://ts-refs-cache.kibana.dev/abcdefg.zip", + " debg download complete, renaming tmp", + " debg download of cache for abcdefg complete", + " debg extracting archives/abcdefg.zip to rebuild caches in 1 outDirs", + " debg [out/bar] clearing outDir and replacing with cache", + ] + `); +}); + +it('cleans up oldest archives when there are more than 10', async () => { + for (let i = 0; i < 100; i++) { + const time = i * 10_000; + const path = Path.resolve(TMP, `archives/${time}.zip`); + Fs.writeFileSync(path, ''); + Fs.utimesSync(path, time, time); + } + + const archives = await Archives.create(log, TMP); + const cache = new RefOutputCache(log, repo, archives, [], '1234'); + expect(cache.archives.size()).toBe(102); + await cache.cleanup(); + expect(cache.archives.size()).toBe(10); + expect(Fs.readdirSync(Path.resolve(TMP, 'archives')).sort((a, b) => a.localeCompare(b))) + .toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + "920000.zip", + "930000.zip", + "940000.zip", + "950000.zip", + "960000.zip", + "970000.zip", + "980000.zip", + "990000.zip", + ] + `); +}); diff --git a/src/dev/typescript/ref_output_cache/ref_output_cache.ts b/src/dev/typescript/ref_output_cache/ref_output_cache.ts new file mode 100644 index 00000000000000..342470ce0c6e36 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/ref_output_cache.ts @@ -0,0 +1,185 @@ +/* + * 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 Path from 'path'; +import Fs from 'fs/promises'; + +import { ToolingLog, kibanaPackageJson } from '@kbn/dev-utils'; +import del from 'del'; +import tempy from 'tempy'; + +import { Archives } from './archives'; +import { unzip, zip } from './zip'; +import { concurrentMap } from '../concurrent_map'; +import { RepoInfo } from './repo_info'; + +export const OUTDIR_MERGE_BASE_FILENAME = '.ts-ref-cache-merge-base'; + +export async function matchMergeBase(outDir: string, sha: string) { + try { + const existing = await Fs.readFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), 'utf8'); + return existing === sha; + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + + throw error; + } +} + +export async function isDir(path: string) { + try { + return (await Fs.stat(path)).isDirectory(); + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + + throw error; + } +} + +export class RefOutputCache { + static async create(options: { + log: ToolingLog; + workingDir: string; + outDirs: string[]; + repoRoot: string; + upstreamUrl: string; + }) { + const repoInfo = new RepoInfo(options.log, options.repoRoot, options.upstreamUrl); + const archives = await Archives.create(options.log, options.workingDir); + + const upstreamBranch: string = kibanaPackageJson.branch; + const mergeBase = await repoInfo.getMergeBase('HEAD', upstreamBranch); + + return new RefOutputCache(options.log, repoInfo, archives, options.outDirs, mergeBase); + } + + constructor( + private readonly log: ToolingLog, + private readonly repo: RepoInfo, + public readonly archives: Archives, + private readonly outDirs: string[], + private readonly mergeBase: string + ) {} + + /** + * Find the most recent cache/archive of the outDirs and replace the outDirs + * on disk with the files in the cache if the outDir has an outdated merge-base + * written to the directory. + */ + async initCaches() { + const archive = + this.archives.get(this.mergeBase) ?? + (await this.archives.getFirstAvailable([ + this.mergeBase, + ...(await this.repo.getRecentShasFrom(this.mergeBase, 5)), + ])); + + if (!archive) { + return; + } + + const outdatedOutDirs = ( + await concurrentMap(100, this.outDirs, async (outDir) => ({ + path: outDir, + outdated: !(await matchMergeBase(outDir, archive.sha)), + })) + ) + .filter((o) => o.outdated) + .map((o) => o.path); + + if (!outdatedOutDirs.length) { + this.log.debug('all outDirs have the most recent cache'); + return; + } + + const tmpDir = tempy.directory(); + this.log.debug( + 'extracting', + this.repo.getRelative(archive.path), + 'to rebuild caches in', + outdatedOutDirs.length, + 'outDirs' + ); + await unzip(archive.path, tmpDir); + + const cacheNames = await Fs.readdir(tmpDir); + + await concurrentMap(50, outdatedOutDirs, async (outDir) => { + const relative = this.repo.getRelative(outDir); + const cacheName = `${relative.split(Path.sep).join('__')}.zip`; + + if (!cacheNames.includes(cacheName)) { + this.log.debug(`[${relative}] not in cache`); + await Fs.mkdir(outDir, { recursive: true }); + await Fs.writeFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), archive.sha); + return; + } + + if (await matchMergeBase(outDir, archive.sha)) { + this.log.debug(`[${relative}] keeping outdir, created from selected sha`); + return; + } + + this.log.debug(`[${relative}] clearing outDir and replacing with cache`); + await del(outDir); + await unzip(Path.resolve(tmpDir, cacheName), outDir); + await Fs.writeFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), archive.sha); + }); + } + + /** + * Iterate through the outDirs, zip them up, and then zip up those zips + * into a single file which we can upload/download/extract just a portion + * of the archive. + * + * @param outputDir directory that the {HEAD}.zip file should be written to + */ + async captureCache(outputDir: string) { + const tmpDir = tempy.directory(); + const currentSha = await this.repo.getHeadSha(); + const outputPath = Path.resolve(outputDir, `${currentSha}.zip`); + const relativeOutputPath = this.repo.getRelative(outputPath); + + this.log.debug('writing ts-ref cache to', relativeOutputPath); + + const subZips: Array<[string, string]> = []; + + await Promise.all( + this.outDirs.map(async (absolute) => { + const relative = this.repo.getRelative(absolute); + const subZipName = `${relative.split(Path.sep).join('__')}.zip`; + const subZipPath = Path.resolve(tmpDir, subZipName); + await zip([[absolute, '/']], [], subZipPath); + subZips.push([subZipPath, subZipName]); + }) + ); + + await zip([], subZips, outputPath); + await del(tmpDir, { force: true }); + this.log.success('wrote archive to', relativeOutputPath); + } + + /** + * Cleanup the downloaded cache files, keeping the 10 newest files. Each file + * is about 25-30MB, so 10 downloads is a a decent amount of disk space for + * caches but we could potentially increase this number in the future if we like + */ + async cleanup() { + // sort archives by time desc + const archives = [...this.archives].sort((a, b) => b.time - a.time); + + // delete the 11th+ archive + for (const { sha } of archives.slice(10)) { + await this.archives.delete(sha); + } + } +} diff --git a/src/dev/typescript/ref_output_cache/repo_info.ts b/src/dev/typescript/ref_output_cache/repo_info.ts new file mode 100644 index 00000000000000..5ca792332bafa9 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/repo_info.ts @@ -0,0 +1,55 @@ +/* + * 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 Path from 'path'; + +import execa from 'execa'; +import { ToolingLog } from '@kbn/dev-utils'; + +export class RepoInfo { + constructor( + private readonly log: ToolingLog, + private readonly dir: string, + private readonly upstreamUrl: string + ) {} + + async getRecentShasFrom(sha: string, size: number) { + return (await this.git(['log', '--pretty=%P', `-n`, `${size}`, sha])) + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + } + + async getMergeBase(ref: string, upstreamBranch: string) { + this.log.info('ensuring we have the latest changelog from upstream', upstreamBranch); + await this.git(['fetch', this.upstreamUrl, upstreamBranch]); + + this.log.info('determining merge base with upstream'); + + const mergeBase = this.git(['merge-base', ref, 'FETCH_HEAD']); + this.log.info('merge base with', upstreamBranch, 'is', mergeBase); + + return mergeBase; + } + + async getHeadSha() { + return await this.git(['rev-parse', 'HEAD']); + } + + getRelative(path: string) { + return Path.relative(this.dir, path); + } + + private async git(args: string[]) { + const proc = await execa('git', args, { + cwd: this.dir, + }); + + return proc.stdout.trim(); + } +} diff --git a/src/dev/typescript/ref_output_cache/zip.ts b/src/dev/typescript/ref_output_cache/zip.ts new file mode 100644 index 00000000000000..b1bd8f514bb95c --- /dev/null +++ b/src/dev/typescript/ref_output_cache/zip.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 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 Fs from 'fs/promises'; +import { createWriteStream } from 'fs'; +import Path from 'path'; +import { pipeline } from 'stream'; +import { promisify } from 'util'; + +import extractZip from 'extract-zip'; +import archiver from 'archiver'; + +const asyncPipeline = promisify(pipeline); + +export async function zip( + dirs: Array<[string, string]>, + files: Array<[string, string]>, + outputPath: string +) { + const archive = archiver('zip', { + zlib: { + level: 9, + }, + }); + + for (const [absolute, relative] of dirs) { + archive.directory(absolute, relative); + } + + for (const [absolute, relative] of files) { + archive.file(absolute, { + name: relative, + }); + } + + // ensure output dir exists + await Fs.mkdir(Path.dirname(outputPath), { recursive: true }); + + // await the promise from the pipeline and archive.finalize() + await Promise.all([asyncPipeline(archive, createWriteStream(outputPath)), archive.finalize()]); +} + +export async function unzip(path: string, outputDir: string) { + await extractZip(path, { + dir: outputDir, + }); +} diff --git a/x-pack/plugins/fleet/server/services/app_context.ts b/x-pack/plugins/fleet/server/services/app_context.ts index 02e4fceea54f9e..cc4be6b31734a3 100644 --- a/x-pack/plugins/fleet/server/services/app_context.ts +++ b/x-pack/plugins/fleet/server/services/app_context.ts @@ -7,7 +7,7 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { kibanaPackageJSON } from '@kbn/utils'; +import { kibanaPackageJson } from '@kbn/utils'; import { ElasticsearchClient, @@ -34,8 +34,8 @@ class AppContextService { private configSubject$?: BehaviorSubject; private savedObjects: SavedObjectsServiceStart | undefined; private isProductionMode: FleetAppContext['isProductionMode'] = false; - private kibanaVersion: FleetAppContext['kibanaVersion'] = kibanaPackageJSON.version; - private kibanaBranch: FleetAppContext['kibanaBranch'] = kibanaPackageJSON.branch; + private kibanaVersion: FleetAppContext['kibanaVersion'] = kibanaPackageJson.version; + private kibanaBranch: FleetAppContext['kibanaBranch'] = kibanaPackageJson.branch; private cloud?: CloudSetup; private logger: Logger | undefined; private httpSetup?: HttpServiceSetup; From 6438cb0431252727757550fe4b5c756ee87be01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20H=C3=BCbertz?= Date: Fri, 12 Feb 2021 10:19:55 +0100 Subject: [PATCH 011/175] [APM] Remove UX app promo callout (#91118) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/app/service_overview/index.tsx | 6 --- .../app/transaction_overview/index.tsx | 9 ---- .../user_experience_callout.tsx | 48 ------------------- .../translations/translations/ja-JP.json | 3 -- .../translations/translations/zh-CN.json | 3 -- 5 files changed, 69 deletions(-) delete mode 100644 x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 2214dc2a5e034f..8141ecfebb1ff0 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -18,7 +18,6 @@ import { LatencyChart } from '../../shared/charts/latency_chart'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; import { SearchBar } from '../../shared/search_bar'; -import { UserExperienceCallout } from '../transaction_overview/user_experience_callout'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; import { ServiceOverviewInstancesChartAndTable } from './service_overview_instances_chart_and_table'; @@ -61,11 +60,6 @@ export function ServiceOverview({ - {isRumAgent && ( - - - - )} diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 1f8b431d072b79..1b8c41344efc57 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -21,7 +21,6 @@ import { Location } from 'history'; import React from 'react'; import { useLocation } from 'react-router-dom'; import { useTrackPageview } from '../../../../../observability/public'; -import { TRANSACTION_PAGE_LOAD } from '../../../../common/transaction_types'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { IUrlParams } from '../../../context/url_params_context/types'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -33,7 +32,6 @@ import { TransactionTypeSelect } from '../../shared/transaction_type_select'; import { Correlations } from '../Correlations'; import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; -import { UserExperienceCallout } from './user_experience_callout'; import { useTransactionListFetcher } from './use_transaction_list'; function getRedirectLocation({ @@ -116,13 +114,6 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { - - {transactionType === TRANSACTION_PAGE_LOAD && ( - <> - - - - )} diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx deleted file mode 100644 index 5b1ffceb8a213a..00000000000000 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiButton, EuiCallOut, EuiSpacer, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; - -interface Props { - serviceName: string; -} -export function UserExperienceCallout({ serviceName }: Props) { - const { core } = useApmPluginContext(); - const userExperienceHref = core.http.basePath.prepend( - `/app/ux?serviceName=${serviceName}` - ); - - return ( - - - {i18n.translate( - 'xpack.apm.transactionOverview.userExperience.calloutText', - { - defaultMessage: - 'We are beyond excited to introduce a new experience for analyzing the user experience metrics specifically for your RUM services. It provides insights into the core vitals and visitor breakdown by browser and location. The app is always available in the left sidebar among the other Observability views.', - } - )} - - - - {i18n.translate( - 'xpack.apm.transactionOverview.userExperience.linkLabel', - { defaultMessage: 'Take me there' } - )} - - - ); -} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7936f8455e5345..801c2141c7d1c6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5451,9 +5451,6 @@ "xpack.apm.transactionDurationLabel": "期間", "xpack.apm.transactionErrorRateAlert.name": "トランザクションエラー率しきい値", "xpack.apm.transactionErrorRateAlertTrigger.isAbove": "より大きい", - "xpack.apm.transactionOverview.userExperience.calloutText": "特にRUMサービスのユーザーエクスペリエンスメトリックを分析するための新しい経験が導入されます。ブラウザーと位置情報別に細分化されたコアバイタルとアクセスユーザーに関するインサイトを得ることができます。このアプリは、他のオブザーバビリティビューで左側のサイドバーで常に使用できます。", - "xpack.apm.transactionOverview.userExperience.calloutTitle": "導入:Elasticユーザーエクスペリエンス", - "xpack.apm.transactionOverview.userExperience.linkLabel": "移動", "xpack.apm.transactionRateLabel": "{value} tpm", "xpack.apm.transactions.latency.chart.95thPercentileLabel": "95 パーセンタイル", "xpack.apm.transactions.latency.chart.99thPercentileLabel": "99 パーセンタイル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 22935dc716c0e8..c7218ddaae2394 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5461,9 +5461,6 @@ "xpack.apm.transactionDurationLabel": "持续时间", "xpack.apm.transactionErrorRateAlert.name": "事务错误率阈值", "xpack.apm.transactionErrorRateAlertTrigger.isAbove": "高于", - "xpack.apm.transactionOverview.userExperience.calloutText": "我们非常高兴地推出一种用于分析用户体验指标的新体验,专门用于您的 RUM 服务。通过该体验,可深入了解按浏览器和位置细分的核心指标和访客。该应用始终位于其他“可观测性”视图的左侧边栏中。", - "xpack.apm.transactionOverview.userExperience.calloutTitle": "即将引入:Elastic 用户体验", - "xpack.apm.transactionOverview.userExperience.linkLabel": "带我前往此处", "xpack.apm.transactionRateLabel": "{value} tpm", "xpack.apm.transactions.latency.chart.95thPercentileLabel": "第 95 个百分位", "xpack.apm.transactions.latency.chart.99thPercentileLabel": "第 99 个百分位", From b2104ae2bac41f87bab0b0dea9f6bd9c7d8f3bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20H=C3=BCbertz?= Date: Fri, 12 Feb 2021 10:20:31 +0100 Subject: [PATCH 012/175] [APM] Polish: Update header styles and layout (#91093) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx | 2 +- x-pack/plugins/apm/public/components/shared/main_tabs.tsx | 2 ++ x-pack/plugins/apm/public/components/shared/search_bar.tsx | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx index 414011df7f9efb..20a589f3126c45 100644 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx @@ -15,7 +15,7 @@ import { EnvironmentFilter } from '../EnvironmentFilter'; const HeaderFlexGroup = euiStyled(EuiFlexGroup)` padding: ${({ theme }) => theme.eui.gutterTypes.gutterMedium}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; + background: ${({ theme }) => theme.eui.euiColorEmptyShade}; `; export function ApmHeader({ children }: { children: ReactNode }) { diff --git a/x-pack/plugins/apm/public/components/shared/main_tabs.tsx b/x-pack/plugins/apm/public/components/shared/main_tabs.tsx index 941ce924cff079..f60da7c3087115 100644 --- a/x-pack/plugins/apm/public/components/shared/main_tabs.tsx +++ b/x-pack/plugins/apm/public/components/shared/main_tabs.tsx @@ -15,6 +15,8 @@ import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; const StyledTabs = euiStyled(EuiTabs)` padding: ${({ theme }) => `${theme.eui.gutterTypes.gutterMedium}`}; border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; + border-top: ${({ theme }) => theme.eui.euiBorderThin}; + background: ${({ theme }) => theme.eui.euiColorEmptyShade}; `; export function MainTabs({ children }: { children: ReactNode }) { diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index 3285db1f49191c..260306abe2c342 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -32,7 +32,7 @@ export function SearchBar({ prepend, showTimeComparison = false }: Props) { const { isMedium, isLarge } = useBreakPoints(); const itemsStyle = { marginBottom: isLarge ? px(unit) : 0 }; return ( - + From 01451af68b315b9f518aa969a9960676612ad73f Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Fri, 12 Feb 2021 10:56:05 +0100 Subject: [PATCH 013/175] [ML] Skip module setup tests to unblock ES promotion. --- x-pack/test/api_integration/apis/ml/modules/setup_module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts index 8064d498774a39..c4b3a4ed0adcf8 100644 --- a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts @@ -713,7 +713,8 @@ export default ({ getService }: FtrProviderContext) => { return successObjects; } - describe('module setup', function () { + // blocks ES snapshot promotion: https://github.com/elastic/kibana/issues/91224 + describe.skip('module setup', function () { before(async () => { await ml.testResources.setKibanaTimeZoneToUTC(); }); From 6406e996424ff19690818808761d66b68be956a0 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Fri, 12 Feb 2021 09:59:16 +0000 Subject: [PATCH 014/175] [Alerting] Skips Alert Instances that were instantiated but have no scheduled actions (#91179) Skips Alert Instances that were instantiated but have no scheduled actions to prevent them from being identified as having recovered. --- .../server/task_runner/task_runner.test.ts | 81 +++++++++++++++++++ .../alerts/server/task_runner/task_runner.ts | 5 +- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index b227cd2fe53530..08b288f293bd77 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -37,6 +37,7 @@ import { Alert, RecoveredActionGroup } from '../../common'; import { omit } from 'lodash'; import { UntypedNormalizedAlertType } from '../alert_type_registry'; import { alertTypeRegistryMock } from '../alert_type_registry.mock'; +import uuid from 'uuid'; const alertType: jest.Mocked = { id: 'test', name: 'My test alert', @@ -1068,6 +1069,86 @@ describe('Task Runner', () => { `); }); + test('should skip alertInstances which werent active on the previous execution', async () => { + const alertId = uuid.v4(); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + + alertType.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + + // create an instance, but don't schedule any actions, so it doesn't go active + executorServices.alertInstanceFactory('3'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { meta: {}, state: { bar: false } }, + '2': { meta: {}, state: { bar: false } }, + }, + }, + params: { + alertId, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: alertId, + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` + Object { + "1": Object { + "meta": Object { + "lastScheduledActions": Object { + "date": 1970-01-01T00:00:00.000Z, + "group": "default", + "subgroup": undefined, + }, + }, + "state": Object { + "bar": false, + }, + }, + } + `); + + const logger = taskRunnerFactoryInitializerParams.logger; + expect(logger.debug).toHaveBeenCalledWith( + `alert test:${alertId}: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + ); + expect(logger.debug).toHaveBeenCalledWith( + `alert test:${alertId}: 'alert-name' has 1 recovered alert instances: [\"2\"]` + ); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(2); + expect(actionsClient.enqueueExecution.mock.calls[1][0].id).toEqual('1'); + expect(actionsClient.enqueueExecution.mock.calls[0][0].id).toEqual('2'); + }); + test('fire actions under a custom recovery group when specified on an alert type for alertInstances which are in the recovered state', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 42708c9b7eb54b..7e96cf03e06106 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -234,6 +234,7 @@ export class TaskRunner< (rawAlertInstance) => new AlertInstance(rawAlertInstance) ); const originalAlertInstances = cloneDeep(alertInstances); + const originalAlertInstanceIds = new Set(Object.keys(originalAlertInstances)); const eventLogger = this.context.eventLogger; const alertLabel = `${this.alertType.id}:${alertId}: '${name}'`; @@ -282,8 +283,8 @@ export class TaskRunner< ); const recoveredAlertInstances = pickBy( alertInstances, - (alertInstance: AlertInstance) => - !alertInstance.hasScheduledActions() + (alertInstance: AlertInstance, id) => + !alertInstance.hasScheduledActions() && originalAlertInstanceIds.has(id) ); logActiveAndRecoveredInstances({ From 2fcf2a91cc870c4dfe52d1107885cefb40e0100e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Fri, 12 Feb 2021 11:57:01 +0100 Subject: [PATCH 015/175] [APM] Break down transaction table api removing the sparklines (#88946) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dario Gieselaar --- .../service_overview.test.tsx | 6 +- .../get_columns.tsx | 163 ++++++ .../index.tsx | 295 ++++------ .../shared/charts/spark_plot/index.tsx | 17 +- .../url_params_context/resolve_url_params.ts | 2 +- .../context/url_params_context/types.ts | 3 +- .../use_transaction_latency_chart_fetcher.ts | 3 +- ...transaction_group_comparison_statistics.ts | 167 ++++++ ...e.ts => get_service_transaction_groups.ts} | 109 ++-- ..._timeseries_data_for_transaction_groups.ts | 119 ---- .../get_service_transaction_groups/index.ts | 89 --- .../merge_transaction_group_data.ts | 90 --- .../apm/server/routes/create_apm_api.ts | 6 +- .../plugins/apm/server/routes/transactions.ts | 89 +-- .../test/apm_api_integration/tests/index.ts | 3 +- .../service_overview/dependencies/index.ts | 43 +- ...sactions_groups_comparison_statistics.snap | 517 ++++++++++++++++++ ...ansactions_groups_comparison_statistics.ts | 158 ++++++ .../transactions_groups_overview.ts | 273 --------- .../transactions_groups_primary_statistics.ts | 150 +++++ x-pack/test/apm_api_integration/utils.ts | 18 + .../typings/elasticsearch/aggregations.d.ts | 12 + 22 files changed, 1422 insertions(+), 910 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/get_columns.tsx create mode 100644 x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts rename x-pack/plugins/apm/server/lib/services/{get_service_transaction_groups/get_transaction_groups_for_page.ts => get_service_transaction_groups.ts} (50%) delete mode 100644 x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts delete mode 100644 x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts delete mode 100644 x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts create mode 100644 x-pack/test/apm_api_integration/tests/transactions/__snapshots__/transactions_groups_comparison_statistics.snap create mode 100644 x-pack/test/apm_api_integration/tests/transactions/transactions_groups_comparison_statistics.ts delete mode 100644 x-pack/test/apm_api_integration/tests/transactions/transactions_groups_overview.ts create mode 100644 x-pack/test/apm_api_integration/tests/transactions/transactions_groups_primary_statistics.ts create mode 100644 x-pack/test/apm_api_integration/utils.ts diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index cb77c0fa1346fb..999718e754c619 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -80,21 +80,21 @@ describe('ServiceOverview', () => { status: FETCH_STATUS.SUCCESS, }); + /* eslint-disable @typescript-eslint/naming-convention */ const calls = { - // eslint-disable-next-line @typescript-eslint/naming-convention 'GET /api/apm/services/{serviceName}/error_groups': { error_groups: [], total_error_groups: 0, }, - 'GET /api/apm/services/{serviceName}/transactions/groups/overview': { + 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics': { transactionGroups: [], totalTransactionGroups: 0, isAggregationAccurate: true, }, 'GET /api/apm/services/{serviceName}/dependencies': [], - // eslint-disable-next-line @typescript-eslint/naming-convention 'GET /api/apm/services/{serviceName}/service_overview_instances': [], }; + /* eslint-enable @typescript-eslint/naming-convention */ jest .spyOn(callApmApiModule, 'createCallApmApi') diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/get_columns.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/get_columns.tsx new file mode 100644 index 00000000000000..2ffc0fc9c93a3a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/get_columns.tsx @@ -0,0 +1,163 @@ +/* + * 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 { EuiBasicTableColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ValuesType } from 'utility-types'; +import { + asMillisecondDuration, + asPercent, + asTransactionRate, +} from '../../../../../common/utils/formatters'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { px, unit } from '../../../../style/variables'; +import { SparkPlot } from '../../../shared/charts/spark_plot'; +import { ImpactBar } from '../../../shared/ImpactBar'; +import { TransactionDetailLink } from '../../../shared/Links/apm/transaction_detail_link'; +import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; + +type TransactionGroupPrimaryStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics'>; + +type ServiceTransactionGroupItem = ValuesType< + TransactionGroupPrimaryStatistics['transactionGroups'] +>; +type TransactionGroupComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics'>; + +function getLatencyAggregationTypeLabel(latencyAggregationType?: string) { + switch (latencyAggregationType) { + case 'avg': + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.avg', + { defaultMessage: 'Latency (avg.)' } + ); + + case 'p95': + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p95', + { defaultMessage: 'Latency (95th)' } + ); + + case 'p99': + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p99', + { defaultMessage: 'Latency (99th)' } + ); + } +} + +export function getColumns({ + serviceName, + latencyAggregationType, + transactionGroupComparisonStatistics, +}: { + serviceName: string; + latencyAggregationType?: string; + transactionGroupComparisonStatistics?: TransactionGroupComparisonStatistics; +}): Array> { + return [ + { + field: 'name', + sortable: true, + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnName', + { defaultMessage: 'Name' } + ), + render: (_, { name, transactionType: type }) => { + return ( + + {name} + + } + /> + ); + }, + }, + { + field: 'latency', + sortable: true, + name: getLatencyAggregationTypeLabel(latencyAggregationType), + width: px(unit * 10), + render: (_, { latency, name }) => { + const timeseries = + transactionGroupComparisonStatistics?.[name]?.latency; + return ( + + ); + }, + }, + { + field: 'throughput', + sortable: true, + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnThroughput', + { defaultMessage: 'Throughput' } + ), + width: px(unit * 10), + render: (_, { throughput, name }) => { + const timeseries = + transactionGroupComparisonStatistics?.[name]?.throughput; + return ( + + ); + }, + }, + { + field: 'errorRate', + sortable: true, + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnErrorRate', + { defaultMessage: 'Error rate' } + ), + width: px(unit * 8), + render: (_, { errorRate, name }) => { + const timeseries = + transactionGroupComparisonStatistics?.[name]?.errorRate; + return ( + + ); + }, + }, + { + field: 'impact', + sortable: true, + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnImpact', + { defaultMessage: 'Impact' } + ), + width: px(unit * 5), + render: (_, { name }) => { + const impact = + transactionGroupComparisonStatistics?.[name]?.impact ?? 0; + return ; + }, + }, + ]; +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx index 6ee82f47b9e4aa..a0facb2ddbedf6 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -7,86 +7,41 @@ import { EuiBasicTable, - EuiBasicTableColumn, EuiFlexGroup, EuiFlexItem, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { orderBy } from 'lodash'; import React, { useState } from 'react'; -import { ValuesType } from 'utility-types'; -import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; -import { - asMillisecondDuration, - asPercent, - asTransactionRate, -} from '../../../../../common/utils/formatters'; +import uuid from 'uuid'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; -import { px, unit } from '../../../../style/variables'; -import { SparkPlot } from '../../../shared/charts/spark_plot'; -import { ImpactBar } from '../../../shared/ImpactBar'; -import { TransactionDetailLink } from '../../../shared/Links/apm/transaction_detail_link'; import { TransactionOverviewLink } from '../../../shared/Links/apm/transaction_overview_link'; import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper'; -import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; import { ServiceOverviewTableContainer } from '../service_overview_table_container'; - -type ServiceTransactionGroupItem = ValuesType< - APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/overview'>['transactionGroups'] ->; +import { getColumns } from './get_columns'; interface Props { serviceName: string; } +const INITIAL_STATE = { + transactionGroups: [], + isAggregationAccurate: true, + requestId: '', +}; + type SortField = 'name' | 'latency' | 'throughput' | 'errorRate' | 'impact'; type SortDirection = 'asc' | 'desc'; - const PAGE_SIZE = 5; const DEFAULT_SORT = { direction: 'desc' as const, field: 'impact' as const, }; -function getLatencyAggregationTypeLabel(latencyAggregationType?: string) { - switch (latencyAggregationType) { - case 'avg': - return i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnLatency.avg', - { - defaultMessage: 'Latency (avg.)', - } - ); - - case 'p95': - return i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p95', - { - defaultMessage: 'Latency (95th)', - } - ); - - case 'p99': - return i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p99', - { - defaultMessage: 'Latency (99th)', - } - ); - } -} - -export function ServiceOverviewTransactionsTable(props: Props) { - const { serviceName } = props; - const { transactionType } = useApmServiceContext(); - const { - uiFilters, - urlParams: { start, end, latencyAggregationType }, - } = useUrlParams(); - +export function ServiceOverviewTransactionsTable({ serviceName }: Props) { const [tableOptions, setTableOptions] = useState<{ pageIndex: number; sort: { @@ -98,51 +53,36 @@ export function ServiceOverviewTransactionsTable(props: Props) { sort: DEFAULT_SORT, }); + const { pageIndex, sort } = tableOptions; + + const { transactionType } = useApmServiceContext(); const { - data = { - totalItemCount: 0, - items: [], - tableOptions: { - pageIndex: 0, - sort: DEFAULT_SORT, - }, - }, - status, - } = useFetcher( + uiFilters, + urlParams: { start, end, latencyAggregationType }, + } = useUrlParams(); + + const { data = INITIAL_STATE, status } = useFetcher( (callApmApi) => { if (!start || !end || !latencyAggregationType || !transactionType) { return; } - return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/transactions/groups/overview', + 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics', params: { path: { serviceName }, query: { start, end, uiFilters: JSON.stringify(uiFilters), - size: PAGE_SIZE, - numBuckets: 20, - pageIndex: tableOptions.pageIndex, - sortField: tableOptions.sort.field, - sortDirection: tableOptions.sort.direction, transactionType, - latencyAggregationType: latencyAggregationType as LatencyAggregationType, + latencyAggregationType, }, }, }).then((response) => { return { - items: response.transactionGroups, - totalItemCount: response.totalTransactionGroups, - tableOptions: { - pageIndex: tableOptions.pageIndex, - sort: { - field: tableOptions.sort.field, - direction: tableOptions.sort.direction, - }, - }, + requestId: uuid(), + ...response, }; }); }, @@ -151,114 +91,81 @@ export function ServiceOverviewTransactionsTable(props: Props) { start, end, uiFilters, - tableOptions.pageIndex, - tableOptions.sort.field, - tableOptions.sort.direction, transactionType, latencyAggregationType, ] ); - const { - items, - totalItemCount, - tableOptions: { pageIndex, sort }, - } = data; + const { transactionGroups, requestId } = data; + const currentPageTransactionGroups = orderBy( + transactionGroups, + sort.field, + sort.direction + ).slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE); - const columns: Array> = [ - { - field: 'name', - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnName', - { - defaultMessage: 'Name', - } - ), - render: (_, { name, transactionType: type }) => { - return ( - - {name} - - } - /> - ); - }, - }, - { - field: 'latency', - name: getLatencyAggregationTypeLabel(latencyAggregationType), - width: px(unit * 10), - render: (_, { latency }) => { - return ( - - ); - }, - }, - { - field: 'throughput', - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnThroughput', - { defaultMessage: 'Throughput' } - ), - width: px(unit * 10), - render: (_, { throughput }) => { - return ( - - ); - }, - }, - { - field: 'errorRate', - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnErrorRate', - { - defaultMessage: 'Error rate', - } - ), - width: px(unit * 8), - render: (_, { errorRate }) => { - return ( - - ); - }, + const transactionNames = JSON.stringify( + currentPageTransactionGroups.map(({ name }) => name).sort() + ); + + const { + data: transactionGroupComparisonStatistics, + status: transactionGroupComparisonStatisticsStatus, + } = useFetcher( + (callApmApi) => { + if ( + currentPageTransactionGroups.length && + start && + end && + transactionType && + latencyAggregationType + ) { + return callApmApi({ + endpoint: + 'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics', + params: { + path: { serviceName }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters), + numBuckets: 20, + transactionType, + latencyAggregationType, + transactionNames, + }, + }, + }); + } }, - { - field: 'impact', - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnImpact', - { - defaultMessage: 'Impact', - } - ), - width: px(unit * 5), - render: (_, { impact }) => { - return ; - }, + // only fetches statistics when requestId changes or transaction names changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [requestId, transactionNames], + { preservePreviousData: false } + ); + + const columns = getColumns({ + serviceName, + latencyAggregationType, + transactionGroupComparisonStatistics, + }); + + const isLoading = + status === FETCH_STATUS.LOADING || + transactionGroupComparisonStatisticsStatus === FETCH_STATUS.LOADING; + + const pagination = { + pageIndex, + pageSize: PAGE_SIZE, + totalItemCount: transactionGroups.length, + hidePerPageOptions: true, + }; + + const sorting = { + sort: { + field: sort.field, + direction: sort.direction, }, - ]; + }; return ( @@ -295,21 +202,14 @@ export function ServiceOverviewTransactionsTable(props: Props) { diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index 1da6bbaad7358c..36c499f9e5ee40 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -16,6 +16,7 @@ import { Settings, } from '@elastic/charts'; import { merge } from 'lodash'; +import { Coordinate } from '../../../../../typings/timeseries'; import { useChartTheme } from '../../../../../../observability/public'; import { px, unit } from '../../../../style/variables'; import { useTheme } from '../../../../hooks/use_theme'; @@ -39,7 +40,7 @@ export function SparkPlot({ compact, }: { color: Color; - series?: Array<{ x: number; y: number | null }> | null; + series?: Coordinate[] | null; valueLabel: React.ReactNode; compact?: boolean; }) { @@ -58,18 +59,18 @@ export function SparkPlot({ const colorValue = theme.eui[color]; + const chartSize = { + height: px(24), + width: compact ? px(unit * 3) : px(unit * 4), + }; + return ( {!series || series.every((point) => point.y === null) ? ( - + ) : ( - + >; diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts index 5522562fbeab79..d5974ee3543a7e 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts @@ -12,7 +12,6 @@ import { useUrlParams } from '../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; import { getLatencyChartSelector } from '../selectors/latency_chart_selectors'; import { useTheme } from './use_theme'; -import { LatencyAggregationType } from '../../common/latency_aggregation_types'; export function useTransactionLatencyChartsFetcher() { const { serviceName } = useParams<{ serviceName?: string }>(); @@ -43,7 +42,7 @@ export function useTransactionLatencyChartsFetcher() { transactionType, transactionName, uiFilters: JSON.stringify(uiFilters), - latencyAggregationType: latencyAggregationType as LatencyAggregationType, + latencyAggregationType, }, }, }); diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts new file mode 100644 index 00000000000000..8c21fb65a37e51 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts @@ -0,0 +1,167 @@ +/* + * 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 { keyBy } from 'lodash'; +import { + EVENT_OUTCOME, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { EventOutcome } from '../../../common/event_outcome'; +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { Coordinate } from '../../../typings/timeseries'; +import { + getDocumentTypeFilterForAggregatedTransactions, + getProcessorEventForAggregatedTransactions, + getTransactionDurationFieldForAggregatedTransactions, +} from '../helpers/aggregated_transactions'; +import { getBucketSize } from '../helpers/get_bucket_size'; +import { + getLatencyAggregation, + getLatencyValue, +} from '../helpers/latency_aggregation_type'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { calculateTransactionErrorPercentage } from '../helpers/transaction_error_rate'; + +export async function getServiceTransactionGroupComparisonStatistics({ + serviceName, + transactionNames, + setup, + numBuckets, + searchAggregatedTransactions, + transactionType, + latencyAggregationType, +}: { + serviceName: string; + transactionNames: string[]; + setup: Setup & SetupTimeRange; + numBuckets: number; + searchAggregatedTransactions: boolean; + transactionType: string; + latencyAggregationType: LatencyAggregationType; +}): Promise< + Record< + string, + { + latency: Coordinate[]; + throughput: Coordinate[]; + errorRate: Coordinate[]; + impact: number; + } + > +> { + const { apmEventClient, start, end, esFilter } = setup; + const { intervalString } = getBucketSize({ start, end, numBuckets }); + + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); + + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { range: rangeFilter(start, end) }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...esFilter, + ], + }, + }, + aggs: { + total_duration: { sum: { field } }, + transaction_groups: { + terms: { + field: TRANSACTION_NAME, + include: transactionNames, + size: transactionNames.length, + }, + aggs: { + transaction_group_total_duration: { + sum: { field }, + }, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + throughput_rate: { + rate: { + unit: 'minute', + }, + }, + ...getLatencyAggregation(latencyAggregationType, field), + [EVENT_OUTCOME]: { + terms: { + field: EVENT_OUTCOME, + include: [EventOutcome.failure, EventOutcome.success], + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const buckets = response.aggregations?.transaction_groups.buckets ?? []; + + const totalDuration = response.aggregations?.total_duration.value; + return keyBy( + buckets.map((bucket) => { + const transactionName = bucket.key; + const latency = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: getLatencyValue({ + latencyAggregationType, + aggregation: timeseriesBucket.latency, + }), + })); + const throughput = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: timeseriesBucket.throughput_rate.value, + })); + const errorRate = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: calculateTransactionErrorPercentage(timeseriesBucket[EVENT_OUTCOME]), + })); + const transactionGroupTotalDuration = + bucket.transaction_group_total_duration.value || 0; + return { + transactionName, + latency, + throughput, + errorRate, + impact: totalDuration + ? (transactionGroupTotalDuration * 100) / totalDuration + : 0, + }; + }), + 'transactionName' + ); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups.ts similarity index 50% rename from x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts rename to x-pack/plugins/apm/server/lib/services/get_service_transaction_groups.ts index 9038ddff04e3c3..67ae37f93606e8 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups.ts @@ -5,29 +5,27 @@ * 2.0. */ -import { orderBy } from 'lodash'; -import { ValuesType } from 'utility-types'; -import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; -import { PromiseReturnType } from '../../../../../observability/typings/common'; -import { EventOutcome } from '../../../../common/event_outcome'; -import { ESFilter } from '../../../../../../typings/elasticsearch'; -import { rangeFilter } from '../../../../common/utils/range_filter'; import { EVENT_OUTCOME, SERVICE_NAME, TRANSACTION_NAME, TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; +} from '../../../common/elasticsearch_fieldnames'; +import { EventOutcome } from '../../../common/event_outcome'; +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { rangeFilter } from '../../../common/utils/range_filter'; import { + getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, -} from '../../helpers/aggregated_transactions'; -import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; +} from '../helpers/aggregated_transactions'; +import { calculateThroughput } from '../helpers/calculate_throughput'; import { getLatencyAggregation, getLatencyValue, -} from '../../helpers/latency_aggregation_type'; -import { calculateThroughput } from '../../helpers/calculate_throughput'; +} from '../helpers/latency_aggregation_type'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { calculateTransactionErrorPercentage } from '../helpers/transaction_error_rate'; export type ServiceOverviewTransactionGroupSortField = | 'name' @@ -36,37 +34,21 @@ export type ServiceOverviewTransactionGroupSortField = | 'errorRate' | 'impact'; -export type TransactionGroupWithoutTimeseriesData = ValuesType< - PromiseReturnType['transactionGroups'] ->; - -export async function getTransactionGroupsForPage({ - apmEventClient, - searchAggregatedTransactions, +export async function getServiceTransactionGroups({ serviceName, - start, - end, - esFilter, - sortField, - sortDirection, - pageIndex, - size, + setup, + searchAggregatedTransactions, transactionType, latencyAggregationType, }: { - apmEventClient: APMEventClient; - searchAggregatedTransactions: boolean; serviceName: string; - start: number; - end: number; - esFilter: ESFilter[]; - sortField: ServiceOverviewTransactionGroupSortField; - sortDirection: 'asc' | 'desc'; - pageIndex: number; - size: number; + setup: Setup & SetupTimeRange; + searchAggregatedTransactions: boolean; transactionType: string; latencyAggregationType: LatencyAggregationType; }) { + const { apmEventClient, start, end, esFilter } = setup; + const field = getTransactionDurationFieldForAggregatedTransactions( searchAggregatedTransactions ); @@ -87,11 +69,15 @@ export async function getTransactionGroupsForPage({ { term: { [SERVICE_NAME]: serviceName } }, { term: { [TRANSACTION_TYPE]: transactionType } }, { range: rangeFilter(start, end) }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), ...esFilter, ], }, }, aggs: { + total_duration: { sum: { field } }, transaction_groups: { terms: { field: TRANSACTION_NAME, @@ -99,9 +85,15 @@ export async function getTransactionGroupsForPage({ order: { _count: 'desc' }, }, aggs: { + transaction_group_total_duration: { + sum: { field }, + }, ...getLatencyAggregation(latencyAggregationType, field), [EVENT_OUTCOME]: { - filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + terms: { + field: EVENT_OUTCOME, + include: [EventOutcome.failure, EventOutcome.success], + }, }, }, }, @@ -109,12 +101,16 @@ export async function getTransactionGroupsForPage({ }, }); + const totalDuration = response.aggregations?.total_duration.value; + const transactionGroups = response.aggregations?.transaction_groups.buckets.map((bucket) => { - const errorRate = - bucket.doc_count > 0 - ? bucket[EVENT_OUTCOME].doc_count / bucket.doc_count - : null; + const errorRate = calculateTransactionErrorPercentage( + bucket[EVENT_OUTCOME] + ); + + const transactionGroupTotalDuration = + bucket.transaction_group_total_duration.value || 0; return { name: bucket.key as string, @@ -128,36 +124,17 @@ export async function getTransactionGroupsForPage({ value: bucket.doc_count, }), errorRate, + impact: totalDuration + ? (transactionGroupTotalDuration * 100) / totalDuration + : 0, }; }) ?? []; - const totalDurationValues = transactionGroups.map( - (group) => (group.latency ?? 0) * group.throughput - ); - - const minTotalDuration = Math.min(...totalDurationValues); - const maxTotalDuration = Math.max(...totalDurationValues); - - const transactionGroupsWithImpact = transactionGroups.map((group) => ({ - ...group, - impact: - (((group.latency ?? 0) * group.throughput - minTotalDuration) / - (maxTotalDuration - minTotalDuration)) * - 100, - })); - - // Sort transaction groups first, and only get timeseries for data in view. - // This is to limit the possibility of creating too many buckets. - - const sortedAndSlicedTransactionGroups = orderBy( - transactionGroupsWithImpact, - sortField, - [sortDirection] - ).slice(pageIndex * size, pageIndex * size + size); - return { - transactionGroups: sortedAndSlicedTransactionGroups, - totalTransactionGroups: transactionGroups.length, + transactionGroups: transactionGroups.map((transactionGroup) => ({ + ...transactionGroup, + transactionType, + })), isAggregationAccurate: (response.aggregations?.transaction_groups.sum_other_doc_count ?? 0) === 0, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts deleted file mode 100644 index 21db304c4dfe83..00000000000000 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; -import { PromiseReturnType } from '../../../../../observability/typings/common'; -import { EventOutcome } from '../../../../common/event_outcome'; -import { rangeFilter } from '../../../../common/utils/range_filter'; -import { - EVENT_OUTCOME, - SERVICE_NAME, - TRANSACTION_NAME, - TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; - -import { ESFilter } from '../../../../../../typings/elasticsearch'; -import { - getDocumentTypeFilterForAggregatedTransactions, - getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, -} from '../../helpers/aggregated_transactions'; -import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { getLatencyAggregation } from '../../helpers/latency_aggregation_type'; - -export type TransactionGroupTimeseriesData = PromiseReturnType< - typeof getTimeseriesDataForTransactionGroups ->; - -export async function getTimeseriesDataForTransactionGroups({ - apmEventClient, - start, - end, - serviceName, - transactionNames, - esFilter, - searchAggregatedTransactions, - size, - numBuckets, - transactionType, - latencyAggregationType, -}: { - apmEventClient: APMEventClient; - start: number; - end: number; - serviceName: string; - transactionNames: string[]; - esFilter: ESFilter[]; - searchAggregatedTransactions: boolean; - size: number; - numBuckets: number; - transactionType: string; - latencyAggregationType: LatencyAggregationType; -}) { - const { intervalString } = getBucketSize({ start, end, numBuckets }); - - const field = getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ); - - const timeseriesResponse = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { terms: { [TRANSACTION_NAME]: transactionNames } }, - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ...esFilter, - ], - }, - }, - aggs: { - transaction_groups: { - terms: { - field: TRANSACTION_NAME, - size, - }, - aggs: { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, - }, - }, - aggs: { - ...getLatencyAggregation(latencyAggregationType, field), - [EVENT_OUTCOME]: { - filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - }, - }, - }, - }, - }, - }, - }, - }); - - return timeseriesResponse.aggregations?.transaction_groups.buckets ?? []; -} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts deleted file mode 100644 index 3b5426a3c1764a..00000000000000 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; -import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { getTimeseriesDataForTransactionGroups } from './get_timeseries_data_for_transaction_groups'; -import { - getTransactionGroupsForPage, - ServiceOverviewTransactionGroupSortField, -} from './get_transaction_groups_for_page'; -import { mergeTransactionGroupData } from './merge_transaction_group_data'; - -export async function getServiceTransactionGroups({ - serviceName, - setup, - size, - numBuckets, - pageIndex, - sortDirection, - sortField, - searchAggregatedTransactions, - transactionType, - latencyAggregationType, -}: { - serviceName: string; - setup: Setup & SetupTimeRange; - size: number; - pageIndex: number; - numBuckets: number; - sortDirection: 'asc' | 'desc'; - sortField: ServiceOverviewTransactionGroupSortField; - searchAggregatedTransactions: boolean; - transactionType: string; - latencyAggregationType: LatencyAggregationType; -}) { - const { apmEventClient, start, end, esFilter } = setup; - - const { - transactionGroups, - totalTransactionGroups, - isAggregationAccurate, - } = await getTransactionGroupsForPage({ - apmEventClient, - start, - end, - serviceName, - esFilter, - pageIndex, - sortField, - sortDirection, - size, - searchAggregatedTransactions, - transactionType, - latencyAggregationType, - }); - - const transactionNames = transactionGroups.map((group) => group.name); - - const timeseriesData = await getTimeseriesDataForTransactionGroups({ - apmEventClient, - start, - end, - esFilter, - numBuckets, - searchAggregatedTransactions, - serviceName, - size, - transactionNames, - transactionType, - latencyAggregationType, - }); - - return { - transactionGroups: mergeTransactionGroupData({ - transactionGroups, - timeseriesData, - start, - end, - latencyAggregationType, - transactionType, - }), - totalTransactionGroups, - isAggregationAccurate, - }; -} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts deleted file mode 100644 index 6d6ad3bf830840..00000000000000 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EVENT_OUTCOME } from '../../../../common/elasticsearch_fieldnames'; -import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; -import { calculateThroughput } from '../../helpers/calculate_throughput'; -import { getLatencyValue } from '../../helpers/latency_aggregation_type'; -import { TransactionGroupTimeseriesData } from './get_timeseries_data_for_transaction_groups'; -import { TransactionGroupWithoutTimeseriesData } from './get_transaction_groups_for_page'; - -export function mergeTransactionGroupData({ - start, - end, - transactionGroups, - timeseriesData, - latencyAggregationType, - transactionType, -}: { - start: number; - end: number; - transactionGroups: TransactionGroupWithoutTimeseriesData[]; - timeseriesData: TransactionGroupTimeseriesData; - latencyAggregationType: LatencyAggregationType; - transactionType: string; -}) { - return transactionGroups.map((transactionGroup) => { - const groupBucket = timeseriesData.find( - ({ key }) => key === transactionGroup.name - ); - - const timeseriesBuckets = groupBucket?.timeseries.buckets ?? []; - - return timeseriesBuckets.reduce( - (acc, point) => { - return { - ...acc, - latency: { - ...acc.latency, - timeseries: acc.latency.timeseries.concat({ - x: point.key, - y: getLatencyValue({ - latencyAggregationType, - aggregation: point.latency, - }), - }), - }, - throughput: { - ...acc.throughput, - timeseries: acc.throughput.timeseries.concat({ - x: point.key, - y: calculateThroughput({ - start, - end, - value: point.doc_count, - }), - }), - }, - errorRate: { - ...acc.errorRate, - timeseries: acc.errorRate.timeseries.concat({ - x: point.key, - y: point[EVENT_OUTCOME].doc_count / point.doc_count, - }), - }, - }; - }, - { - name: transactionGroup.name, - transactionType, - latency: { - value: transactionGroup.latency, - timeseries: [] as Array<{ x: number; y: number | null }>, - }, - throughput: { - value: transactionGroup.throughput, - timeseries: [] as Array<{ x: number; y: number }>, - }, - errorRate: { - value: transactionGroup.errorRate, - timeseries: [] as Array<{ x: number; y: number | null }>, - }, - impact: transactionGroup.impact, - } - ); - }); -} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 5d580fc0e253a5..d22bcb1c501e0a 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -61,9 +61,10 @@ import { transactionChartsDistributionRoute, transactionChartsErrorRateRoute, transactionGroupsRoute, - transactionGroupsOverviewRoute, + transactionGroupsPrimaryStatisticsRoute, transactionLatencyChatsRoute, transactionThroughputChatsRoute, + transactionGroupsComparisonStatisticsRoute, } from './transactions'; import { rumOverviewLocalFiltersRoute, @@ -164,9 +165,10 @@ const createApmApi = () => { .add(transactionChartsDistributionRoute) .add(transactionChartsErrorRateRoute) .add(transactionGroupsRoute) - .add(transactionGroupsOverviewRoute) + .add(transactionGroupsPrimaryStatisticsRoute) .add(transactionLatencyChatsRoute) .add(transactionThroughputChatsRoute) + .add(transactionGroupsComparisonStatisticsRoute) // UI filters .add(uiFiltersEnvironmentsRoute) diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index 912820975cad19..bef96cb7f0767e 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -7,27 +7,29 @@ import Boom from '@hapi/boom'; import * as t from 'io-ts'; -import { createRoute } from './create_route'; -import { rangeRt, uiFiltersRt } from './default_api_types'; +import { + LatencyAggregationType, + latencyAggregationTypeRt, +} from '../../common/latency_aggregation_types'; +import { jsonRt } from '../../common/runtime_types/json_rt'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceTransactionGroups } from '../lib/services/get_service_transaction_groups'; +import { getServiceTransactionGroupComparisonStatistics } from '../lib/services/get_service_transaction_group_comparison_statistics'; import { getTransactionBreakdown } from '../lib/transactions/breakdown'; -import { getAnomalySeries } from '../lib/transactions/get_anomaly_data'; import { getTransactionDistribution } from '../lib/transactions/distribution'; -import { getTransactionGroupList } from '../lib/transaction_groups'; -import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; +import { getAnomalySeries } from '../lib/transactions/get_anomaly_data'; import { getLatencyTimeseries } from '../lib/transactions/get_latency_charts'; import { getThroughputCharts } from '../lib/transactions/get_throughput_charts'; -import { - LatencyAggregationType, - latencyAggregationTypeRt, -} from '../../common/latency_aggregation_types'; +import { getTransactionGroupList } from '../lib/transaction_groups'; +import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; +import { createRoute } from './create_route'; +import { rangeRt, uiFiltersRt } from './default_api_types'; /** * Returns a list of transactions grouped by name - * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/overview/ + * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/primary_statistics/ */ export const transactionGroupsRoute = createRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups', @@ -63,25 +65,56 @@ export const transactionGroupsRoute = createRoute({ }, }); -export const transactionGroupsOverviewRoute = createRoute({ - endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups/overview', +export const transactionGroupsPrimaryStatisticsRoute = createRoute({ + endpoint: + 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics', + params: t.type({ + path: t.type({ serviceName: t.string }), + query: t.intersection([ + rangeRt, + uiFiltersRt, + t.type({ + transactionType: t.string, + latencyAggregationType: latencyAggregationTypeRt, + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const searchAggregatedTransactions = await getSearchAggregatedTransactions( + setup + ); + + const { + path: { serviceName }, + query: { latencyAggregationType, transactionType }, + } = context.params; + + return getServiceTransactionGroups({ + setup, + serviceName, + searchAggregatedTransactions, + transactionType, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, + }); + }, +}); + +export const transactionGroupsComparisonStatisticsRoute = createRoute({ + endpoint: + 'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics', params: t.type({ path: t.type({ serviceName: t.string }), query: t.intersection([ rangeRt, uiFiltersRt, t.type({ - size: toNumberRt, + transactionNames: jsonRt, numBuckets: toNumberRt, - pageIndex: toNumberRt, - sortDirection: t.union([t.literal('asc'), t.literal('desc')]), - sortField: t.union([ - t.literal('name'), - t.literal('latency'), - t.literal('throughput'), - t.literal('errorRate'), - t.literal('impact'), - ]), transactionType: t.string, latencyAggregationType: latencyAggregationTypeRt, }), @@ -100,24 +133,18 @@ export const transactionGroupsOverviewRoute = createRoute({ const { path: { serviceName }, query: { + transactionNames, latencyAggregationType, numBuckets, - pageIndex, - size, - sortDirection, - sortField, transactionType, }, } = context.params; - return getServiceTransactionGroups({ + return getServiceTransactionGroupComparisonStatistics({ setup, serviceName, - pageIndex, + transactionNames, searchAggregatedTransactions, - size, - sortDirection, - sortField, transactionType, numBuckets, latencyAggregationType: latencyAggregationType as LatencyAggregationType, diff --git a/x-pack/test/apm_api_integration/tests/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index a59cfab2ba33bf..72ca22ae749ca7 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -62,7 +62,8 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte loadTestFile(require.resolve('./transactions/latency')); loadTestFile(require.resolve('./transactions/throughput')); loadTestFile(require.resolve('./transactions/top_transaction_groups')); - loadTestFile(require.resolve('./transactions/transactions_groups_overview')); + loadTestFile(require.resolve('./transactions/transactions_groups_primary_statistics')); + loadTestFile(require.resolve('./transactions/transactions_groups_comparison_statistics')); loadTestFile(require.resolve('./feature_controls')); diff --git a/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts b/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts index 321ef0c4a76380..fde12105518162 100644 --- a/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts +++ b/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts @@ -6,20 +6,17 @@ */ import expect from '@kbn/expect'; +import { last, omit, pick, sortBy } from 'lodash'; import url from 'url'; -import { sortBy, pick, last, omit } from 'lodash'; import { ValuesType } from 'utility-types'; -import { registry } from '../../../common/registry'; -import { Maybe } from '../../../../../plugins/apm/typings/common'; -import { isFiniteNumber } from '../../../../../plugins/apm/common/utils/is_finite_number'; -import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi'; +import { roundNumber } from '../../../utils'; import { ENVIRONMENT_ALL } from '../../../../../plugins/apm/common/environment_filter_values'; -import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi'; import archives from '../../../common/fixtures/es_archiver/archives_metadata'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { registry } from '../../../common/registry'; import { apmDependenciesMapping, createServiceDependencyDocs } from './es_utils'; -const round = (num: Maybe): string => (isFiniteNumber(num) ? num.toPrecision(4) : ''); - export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const es = getService('es'); @@ -235,9 +232,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(opbeansNode !== undefined).to.be(true); const values = { - latency: round(opbeansNode?.latency.value), - throughput: round(opbeansNode?.throughput.value), - errorRate: round(opbeansNode?.errorRate.value), + latency: roundNumber(opbeansNode?.latency.value), + throughput: roundNumber(opbeansNode?.throughput.value), + errorRate: roundNumber(opbeansNode?.errorRate.value), ...pick(opbeansNode, 'serviceName', 'type', 'agentName', 'environment', 'impact'), }; @@ -250,16 +247,16 @@ export default function ApiTest({ getService }: FtrProviderContext) { environment: '', serviceName: 'opbeans-node', type: 'service', - errorRate: round(errors / count), - latency: round(sum / count), - throughput: round(count / ((endTime - startTime) / 1000 / 60)), + errorRate: roundNumber(errors / count), + latency: roundNumber(sum / count), + throughput: roundNumber(count / ((endTime - startTime) / 1000 / 60)), impact: 100, }); - const firstValue = round(opbeansNode?.latency.timeseries[0].y); - const lastValue = round(last(opbeansNode?.latency.timeseries)?.y); + const firstValue = roundNumber(opbeansNode?.latency.timeseries[0].y); + const lastValue = roundNumber(last(opbeansNode?.latency.timeseries)?.y); - expect(firstValue).to.be(round(20 / 3)); + expect(firstValue).to.be(roundNumber(20 / 3)); expect(lastValue).to.be('1.000'); }); @@ -271,9 +268,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(postgres !== undefined).to.be(true); const values = { - latency: round(postgres?.latency.value), - throughput: round(postgres?.throughput.value), - errorRate: round(postgres?.errorRate.value), + latency: roundNumber(postgres?.latency.value), + throughput: roundNumber(postgres?.throughput.value), + errorRate: roundNumber(postgres?.errorRate.value), ...pick(postgres, 'spanType', 'spanSubtype', 'name', 'impact', 'type'), }; @@ -286,9 +283,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { spanSubtype: 'http', name: 'postgres', type: 'external', - errorRate: round(errors / count), - latency: round(sum / count), - throughput: round(count / ((endTime - startTime) / 1000 / 60)), + errorRate: roundNumber(errors / count), + latency: roundNumber(sum / count), + throughput: roundNumber(count / ((endTime - startTime) / 1000 / 60)), impact: 0, }); }); diff --git a/x-pack/test/apm_api_integration/tests/transactions/__snapshots__/transactions_groups_comparison_statistics.snap b/x-pack/test/apm_api_integration/tests/transactions/__snapshots__/transactions_groups_comparison_statistics.snap new file mode 100644 index 00000000000000..739ff5a080d76c --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/transactions/__snapshots__/transactions_groups_comparison_statistics.snap @@ -0,0 +1,517 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`APM API tests basic apm_8.0.0 Transaction groups comparison statistics when data is loaded returns the correct data 1`] = ` +Array [ + Object { + "x": 1607435820000, + "y": null, + }, + Object { + "x": 1607435880000, + "y": 69429, + }, + Object { + "x": 1607435940000, + "y": 8071285, + }, + Object { + "x": 1607436000000, + "y": 31949, + }, + Object { + "x": 1607436060000, + "y": null, + }, + Object { + "x": 1607436120000, + "y": 47755, + }, + Object { + "x": 1607436180000, + "y": null, + }, + Object { + "x": 1607436240000, + "y": 35403, + }, + Object { + "x": 1607436300000, + "y": null, + }, + Object { + "x": 1607436360000, + "y": null, + }, + Object { + "x": 1607436420000, + "y": null, + }, + Object { + "x": 1607436480000, + "y": 48137, + }, + Object { + "x": 1607436540000, + "y": null, + }, + Object { + "x": 1607436600000, + "y": 35457, + }, + Object { + "x": 1607436660000, + "y": null, + }, + Object { + "x": 1607436720000, + "y": null, + }, + Object { + "x": 1607436780000, + "y": null, + }, + Object { + "x": 1607436840000, + "y": null, + }, + Object { + "x": 1607436900000, + "y": null, + }, + Object { + "x": 1607436960000, + "y": 30501, + }, + Object { + "x": 1607437020000, + "y": null, + }, + Object { + "x": 1607437080000, + "y": null, + }, + Object { + "x": 1607437140000, + "y": null, + }, + Object { + "x": 1607437200000, + "y": 46937.5, + }, + Object { + "x": 1607437260000, + "y": null, + }, + Object { + "x": 1607437320000, + "y": null, + }, + Object { + "x": 1607437380000, + "y": null, + }, + Object { + "x": 1607437440000, + "y": null, + }, + Object { + "x": 1607437500000, + "y": null, + }, + Object { + "x": 1607437560000, + "y": null, + }, + Object { + "x": 1607437620000, + "y": null, + }, +] +`; + +exports[`APM API tests basic apm_8.0.0 Transaction groups comparison statistics when data is loaded returns the correct data 2`] = ` +Array [ + Object { + "x": 1607435820000, + "y": 0, + }, + Object { + "x": 1607435880000, + "y": 1, + }, + Object { + "x": 1607435940000, + "y": 2, + }, + Object { + "x": 1607436000000, + "y": 1, + }, + Object { + "x": 1607436060000, + "y": 0, + }, + Object { + "x": 1607436120000, + "y": 1, + }, + Object { + "x": 1607436180000, + "y": 0, + }, + Object { + "x": 1607436240000, + "y": 4, + }, + Object { + "x": 1607436300000, + "y": 0, + }, + Object { + "x": 1607436360000, + "y": 0, + }, + Object { + "x": 1607436420000, + "y": 0, + }, + Object { + "x": 1607436480000, + "y": 2, + }, + Object { + "x": 1607436540000, + "y": 0, + }, + Object { + "x": 1607436600000, + "y": 1, + }, + Object { + "x": 1607436660000, + "y": 0, + }, + Object { + "x": 1607436720000, + "y": 0, + }, + Object { + "x": 1607436780000, + "y": 0, + }, + Object { + "x": 1607436840000, + "y": 0, + }, + Object { + "x": 1607436900000, + "y": 0, + }, + Object { + "x": 1607436960000, + "y": 2, + }, + Object { + "x": 1607437020000, + "y": 0, + }, + Object { + "x": 1607437080000, + "y": 0, + }, + Object { + "x": 1607437140000, + "y": 0, + }, + Object { + "x": 1607437200000, + "y": 2, + }, + Object { + "x": 1607437260000, + "y": 0, + }, + Object { + "x": 1607437320000, + "y": 0, + }, + Object { + "x": 1607437380000, + "y": 0, + }, + Object { + "x": 1607437440000, + "y": 0, + }, + Object { + "x": 1607437500000, + "y": 0, + }, + Object { + "x": 1607437560000, + "y": 0, + }, + Object { + "x": 1607437620000, + "y": 0, + }, +] +`; + +exports[`APM API tests basic apm_8.0.0 Transaction groups comparison statistics when data is loaded returns the correct data 3`] = ` +Array [ + Object { + "x": 1607435820000, + "y": null, + }, + Object { + "x": 1607435880000, + "y": 0, + }, + Object { + "x": 1607435940000, + "y": 0, + }, + Object { + "x": 1607436000000, + "y": 0, + }, + Object { + "x": 1607436060000, + "y": null, + }, + Object { + "x": 1607436120000, + "y": 0, + }, + Object { + "x": 1607436180000, + "y": null, + }, + Object { + "x": 1607436240000, + "y": 0, + }, + Object { + "x": 1607436300000, + "y": null, + }, + Object { + "x": 1607436360000, + "y": null, + }, + Object { + "x": 1607436420000, + "y": null, + }, + Object { + "x": 1607436480000, + "y": 0, + }, + Object { + "x": 1607436540000, + "y": null, + }, + Object { + "x": 1607436600000, + "y": 0, + }, + Object { + "x": 1607436660000, + "y": null, + }, + Object { + "x": 1607436720000, + "y": null, + }, + Object { + "x": 1607436780000, + "y": null, + }, + Object { + "x": 1607436840000, + "y": null, + }, + Object { + "x": 1607436900000, + "y": null, + }, + Object { + "x": 1607436960000, + "y": 0, + }, + Object { + "x": 1607437020000, + "y": null, + }, + Object { + "x": 1607437080000, + "y": null, + }, + Object { + "x": 1607437140000, + "y": null, + }, + Object { + "x": 1607437200000, + "y": 0.5, + }, + Object { + "x": 1607437260000, + "y": null, + }, + Object { + "x": 1607437320000, + "y": null, + }, + Object { + "x": 1607437380000, + "y": null, + }, + Object { + "x": 1607437440000, + "y": null, + }, + Object { + "x": 1607437500000, + "y": null, + }, + Object { + "x": 1607437560000, + "y": null, + }, + Object { + "x": 1607437620000, + "y": null, + }, +] +`; + +exports[`APM API tests basic apm_8.0.0 Transaction groups comparison statistics when data is loaded returns the correct for latency aggregation 99th percentile 1`] = ` +Array [ + Object { + "x": 1607435820000, + "y": null, + }, + Object { + "x": 1607435880000, + "y": 69429, + }, + Object { + "x": 1607435940000, + "y": 8198285, + }, + Object { + "x": 1607436000000, + "y": 31949, + }, + Object { + "x": 1607436060000, + "y": null, + }, + Object { + "x": 1607436120000, + "y": 47755, + }, + Object { + "x": 1607436180000, + "y": null, + }, + Object { + "x": 1607436240000, + "y": 73411, + }, + Object { + "x": 1607436300000, + "y": null, + }, + Object { + "x": 1607436360000, + "y": null, + }, + Object { + "x": 1607436420000, + "y": null, + }, + Object { + "x": 1607436480000, + "y": 55116, + }, + Object { + "x": 1607436540000, + "y": null, + }, + Object { + "x": 1607436600000, + "y": 35457, + }, + Object { + "x": 1607436660000, + "y": null, + }, + Object { + "x": 1607436720000, + "y": null, + }, + Object { + "x": 1607436780000, + "y": null, + }, + Object { + "x": 1607436840000, + "y": null, + }, + Object { + "x": 1607436900000, + "y": null, + }, + Object { + "x": 1607436960000, + "y": 46040, + }, + Object { + "x": 1607437020000, + "y": null, + }, + Object { + "x": 1607437080000, + "y": null, + }, + Object { + "x": 1607437140000, + "y": null, + }, + Object { + "x": 1607437200000, + "y": 82486, + }, + Object { + "x": 1607437260000, + "y": null, + }, + Object { + "x": 1607437320000, + "y": null, + }, + Object { + "x": 1607437380000, + "y": null, + }, + Object { + "x": 1607437440000, + "y": null, + }, + Object { + "x": 1607437500000, + "y": null, + }, + Object { + "x": 1607437560000, + "y": null, + }, + Object { + "x": 1607437620000, + "y": null, + }, +] +`; diff --git a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_comparison_statistics.ts b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_comparison_statistics.ts new file mode 100644 index 00000000000000..414e2189a63fe4 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_comparison_statistics.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import url from 'url'; +import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi'; +import archives from '../../common/fixtures/es_archiver/archives_metadata'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { registry } from '../../common/registry'; +import { removeEmptyCoordinates, roundNumber } from '../../utils'; + +type TransactionsGroupsComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const archiveName = 'apm_8.0.0'; + const { start, end } = archives[archiveName]; + const transactionNames = ['DispatcherServlet#doGet', 'APIRestController#customers']; + + registry.when( + 'Transaction groups comparison statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles the empty state', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/comparison_statistics`, + query: { + start, + end, + uiFilters: '{}', + numBuckets: 20, + latencyAggregationType: 'avg', + transactionType: 'request', + transactionNames: JSON.stringify(transactionNames), + }, + }) + ); + + expect(response.status).to.be(200); + expect(response.body).to.empty(); + }); + } + ); + + registry.when( + 'Transaction groups comparison statistics when data is loaded', + { config: 'basic', archives: [archiveName] }, + () => { + it('returns the correct data', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/comparison_statistics`, + query: { + start, + end, + uiFilters: '{}', + numBuckets: 20, + transactionType: 'request', + latencyAggregationType: 'avg', + transactionNames: JSON.stringify(transactionNames), + }, + }) + ); + + expect(response.status).to.be(200); + + const transactionsGroupsComparisonStatistics = response.body as TransactionsGroupsComparisonStatistics; + + expect(Object.keys(transactionsGroupsComparisonStatistics).length).to.be.eql( + transactionNames.length + ); + + transactionNames.map((transactionName) => { + expect(transactionsGroupsComparisonStatistics[transactionName]).not.to.be.empty(); + }); + + const { latency, throughput, errorRate, impact } = transactionsGroupsComparisonStatistics[ + transactionNames[0] + ]; + + expect(removeEmptyCoordinates(latency).length).to.be.greaterThan(0); + expectSnapshot(latency).toMatch(); + + expect(removeEmptyCoordinates(throughput).length).to.be.greaterThan(0); + expectSnapshot(throughput).toMatch(); + + expect(removeEmptyCoordinates(errorRate).length).to.be.greaterThan(0); + expectSnapshot(errorRate).toMatch(); + + expectSnapshot(roundNumber(impact)).toMatchInline(`"93.93"`); + }); + + it('returns the correct for latency aggregation 99th percentile', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/comparison_statistics`, + query: { + start, + end, + uiFilters: '{}', + numBuckets: 20, + transactionType: 'request', + latencyAggregationType: 'p99', + transactionNames: JSON.stringify(transactionNames), + }, + }) + ); + + expect(response.status).to.be(200); + + const transactionsGroupsComparisonStatistics = response.body as TransactionsGroupsComparisonStatistics; + + expect(Object.keys(transactionsGroupsComparisonStatistics).length).to.be.eql( + transactionNames.length + ); + + transactionNames.map((transactionName) => { + expect(transactionsGroupsComparisonStatistics[transactionName]).not.to.be.empty(); + }); + + const { latency, throughput, errorRate } = transactionsGroupsComparisonStatistics[ + transactionNames[0] + ]; + expect(removeEmptyCoordinates(latency).length).to.be.greaterThan(0); + expectSnapshot(latency).toMatch(); + + expect(removeEmptyCoordinates(throughput).length).to.be.greaterThan(0); + expect(removeEmptyCoordinates(errorRate).length).to.be.greaterThan(0); + }); + + it('returns empty when transaction name is not found', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/comparison_statistics`, + query: { + start, + end, + uiFilters: '{}', + numBuckets: 20, + transactionType: 'request', + latencyAggregationType: 'avg', + transactionNames: JSON.stringify(['foo']), + }, + }) + ); + + expect(response.status).to.be(200); + expect(response.body).to.empty(); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_overview.ts b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_overview.ts deleted file mode 100644 index 807373f5828640..00000000000000 --- a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_overview.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import { pick, uniqBy, sortBy } from 'lodash'; -import url from 'url'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; -import archives from '../../common/fixtures/es_archiver/archives_metadata'; -import { registry } from '../../common/registry'; - -export default function ApiTest({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - - const archiveName = 'apm_8.0.0'; - const { start, end } = archives[archiveName]; - - registry.when( - 'Transaction groups overview when data is not loaded', - { config: 'basic', archives: [] }, - () => { - it('handles the empty state', async () => { - const response = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size: 5, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'impact', - latencyAggregationType: 'avg', - transactionType: 'request', - }, - }) - ); - - expect(response.status).to.be(200); - expect(response.body).to.eql({ - totalTransactionGroups: 0, - transactionGroups: [], - isAggregationAccurate: true, - }); - }); - } - ); - - registry.when( - 'Top transaction groups when data is loaded', - { config: 'basic', archives: [archiveName] }, - () => { - it('returns the correct data', async () => { - const response = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size: 5, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'impact', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - expect(response.status).to.be(200); - - expectSnapshot(response.body.totalTransactionGroups).toMatchInline(`12`); - - expectSnapshot(response.body.transactionGroups.map((group: any) => group.name)) - .toMatchInline(` - Array [ - "DispatcherServlet#doGet", - "APIRestController#customers", - "APIRestController#order", - "APIRestController#stats", - "APIRestController#customerWhoBought", - ] - `); - - expectSnapshot(response.body.transactionGroups.map((group: any) => group.impact)) - .toMatchInline(` - Array [ - 100, - 1.43059146953109, - 0.953769516915408, - 0.905498741191481, - 0.894989230293471, - ] - `); - - const firstItem = response.body.transactionGroups[0]; - - expectSnapshot( - pick(firstItem, 'name', 'latency.value', 'throughput.value', 'errorRate.value', 'impact') - ).toMatchInline(` - Object { - "errorRate": Object { - "value": 0.0625, - }, - "impact": 100, - "latency": Object { - "value": 1044995.1875, - }, - "name": "DispatcherServlet#doGet", - "throughput": Object { - "value": 0.533333333333333, - }, - } - `); - - expectSnapshot( - firstItem.latency.timeseries.filter(({ y }: any) => y > 0).length - ).toMatchInline(`9`); - - expectSnapshot( - firstItem.throughput.timeseries.filter(({ y }: any) => y > 0).length - ).toMatchInline(`9`); - - expectSnapshot( - firstItem.errorRate.timeseries.filter(({ y }: any) => y > 0).length - ).toMatchInline(`1`); - }); - - it('sorts items in the correct order', async () => { - const descendingResponse = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size: 5, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'impact', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - expect(descendingResponse.status).to.be(200); - - const descendingOccurrences = descendingResponse.body.transactionGroups.map( - (item: any) => item.impact - ); - - expect(descendingOccurrences).to.eql(sortBy(descendingOccurrences.concat()).reverse()); - - const ascendingResponse = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size: 5, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'impact', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - const ascendingOccurrences = ascendingResponse.body.transactionGroups.map( - (item: any) => item.impact - ); - - expect(ascendingOccurrences).to.eql(sortBy(ascendingOccurrences.concat()).reverse()); - }); - - it('sorts items by the correct field', async () => { - const response = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size: 5, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'latency', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - expect(response.status).to.be(200); - - const latencies = response.body.transactionGroups.map((group: any) => group.latency.value); - - expect(latencies).to.eql(sortBy(latencies.concat()).reverse()); - }); - - it('paginates through the items', async () => { - const size = 1; - - const firstPage = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'impact', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - expect(firstPage.status).to.eql(200); - - const totalItems = firstPage.body.totalTransactionGroups; - - const pages = Math.floor(totalItems / size); - - const items = await new Array(pages) - .fill(undefined) - .reduce(async (prevItemsPromise, _, pageIndex) => { - const prevItems = await prevItemsPromise; - - const thisPage = await supertest.get( - url.format({ - pathname: '/api/apm/services/opbeans-java/transactions/groups/overview', - query: { - start, - end, - uiFilters: '{}', - size, - numBuckets: 20, - pageIndex, - sortDirection: 'desc', - sortField: 'impact', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - return prevItems.concat(thisPage.body.transactionGroups); - }, Promise.resolve([])); - - expect(items.length).to.eql(totalItems); - - expect(uniqBy(items, 'name').length).to.eql(totalItems); - }); - } - ); -} diff --git a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_primary_statistics.ts b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_primary_statistics.ts new file mode 100644 index 00000000000000..7d8417bc5bf63a --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_primary_statistics.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { pick, sum } from 'lodash'; +import url from 'url'; +import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import archives from '../../common/fixtures/es_archiver/archives_metadata'; +import { registry } from '../../common/registry'; + +type TransactionsGroupsPrimaryStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const archiveName = 'apm_8.0.0'; + const { start, end } = archives[archiveName]; + + registry.when( + 'Transaction groups primary statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles the empty state', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/primary_statistics`, + query: { + start, + end, + uiFilters: '{}', + latencyAggregationType: 'avg', + transactionType: 'request', + }, + }) + ); + + expect(response.status).to.be(200); + const transctionsGroupsPrimaryStatistics = response.body as TransactionsGroupsPrimaryStatistics; + expect(transctionsGroupsPrimaryStatistics.transactionGroups).to.empty(); + expect(transctionsGroupsPrimaryStatistics.isAggregationAccurate).to.be(true); + }); + } + ); + + registry.when( + 'Transaction groups primary statistics when data is loaded', + { config: 'basic', archives: [archiveName] }, + () => { + it('returns the correct data', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/primary_statistics`, + query: { + start, + end, + uiFilters: '{}', + transactionType: 'request', + latencyAggregationType: 'avg', + }, + }) + ); + + expect(response.status).to.be(200); + + const transctionsGroupsPrimaryStatistics = response.body as TransactionsGroupsPrimaryStatistics; + + expectSnapshot( + transctionsGroupsPrimaryStatistics.transactionGroups.map((group: any) => group.name) + ).toMatchInline(` + Array [ + "DispatcherServlet#doGet", + "APIRestController#customerWhoBought", + "APIRestController#order", + "APIRestController#customer", + "ResourceHttpRequestHandler", + "APIRestController#customers", + "APIRestController#stats", + "APIRestController#topProducts", + "APIRestController#orders", + "APIRestController#product", + "APIRestController#products", + "DispatcherServlet#doPost", + ] + `); + + const impacts = transctionsGroupsPrimaryStatistics.transactionGroups.map( + (group: any) => group.impact + ); + expectSnapshot(impacts).toMatchInline(` + Array [ + 93.9295870910491, + 0.850308244392878, + 0.905514602241759, + 0.699947181217412, + 0.143906183235671, + 1.35334507158962, + 0.860178761411346, + 0.476138685202191, + 0.446650726277923, + 0.262571482598846, + 0.062116281544223, + 0.00973568923904662, + ] + `); + + expect(Math.round(sum(impacts))).to.eql(100); + + const firstItem = transctionsGroupsPrimaryStatistics.transactionGroups[0]; + + expectSnapshot(pick(firstItem, 'name', 'latency', 'throughput', 'errorRate', 'impact')) + .toMatchInline(` + Object { + "errorRate": 0.0625, + "impact": 93.9295870910491, + "latency": 1044995.1875, + "name": "DispatcherServlet#doGet", + "throughput": 0.533333333333333, + } + `); + }); + + it('returns the correct data for latency aggregation 99th percentile', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/primary_statistics`, + query: { + start, + end, + uiFilters: '{}', + transactionType: 'request', + latencyAggregationType: 'p99', + }, + }) + ); + + expect(response.status).to.be(200); + + const transctionsGroupsPrimaryStatistics = response.body as TransactionsGroupsPrimaryStatistics; + + const firstItem = transctionsGroupsPrimaryStatistics.transactionGroups[0]; + expectSnapshot(firstItem.latency).toMatchInline(`8198285`); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/utils.ts b/x-pack/test/apm_api_integration/utils.ts new file mode 100644 index 00000000000000..0fb99e2aa3c7cb --- /dev/null +++ b/x-pack/test/apm_api_integration/utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Coordinate } from '../../plugins/apm/typings/timeseries'; +import { isFiniteNumber } from '../../plugins/apm/common/utils/is_finite_number'; +import { Maybe } from '../../plugins/apm/typings/common'; + +export function roundNumber(num: Maybe) { + return isFiniteNumber(num) ? num.toPrecision(4) : ''; +} + +export function removeEmptyCoordinates(coordinates: Coordinate[]) { + return coordinates.filter(({ y }) => isFiniteNumber(y)); +} diff --git a/x-pack/typings/elasticsearch/aggregations.d.ts b/x-pack/typings/elasticsearch/aggregations.d.ts index 795205e82aa6bf..077399c596d543 100644 --- a/x-pack/typings/elasticsearch/aggregations.d.ts +++ b/x-pack/typings/elasticsearch/aggregations.d.ts @@ -190,6 +190,15 @@ export interface AggregationOptionsByType { gap_policy?: 'skip' | 'insert_zeros'; format?: string; }; + rate: { + unit: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'; + } & ( + | { + field: string; + mode: 'sum' | 'value_count'; + } + | {} + ); } type AggregationType = keyof AggregationOptionsByType; @@ -409,6 +418,9 @@ interface AggregationResponsePart = TFieldName extends string From f8b8d5b33d1862cfaf6eb7e8a62475059ff6e820 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Fri, 12 Feb 2021 09:03:53 -0500 Subject: [PATCH 016/175] [Case] Detection rules for case (#88726) * Adding type field to client * Removing context and adding association type * Handle alerts from multiple indices * Adding flow for adding a sub case * Making progress on creating alerts from rules * Refactored add comment to handle case and sub case * Starting sub case API and refactoring of case client * Fleshing out find cases * Finished the find cases api * Filtering comments by association type * Fixing tests and types * Updating snapshots * Cleaning up comment references * Working unit tests * Fixing integration tests and got ES to work * Unit tests and api integration test working * Refactoring find and get_status * Starting patch, and update * script for sub cases * Removing converted_by and fixing type errors * Adding docs for script * Removing converted_by and fixing integration test * Adding sub case id to comment routes * Removing stringify comparison * Adding delete api and tests * Updating license * missed license files * Integration tests passing * Adding more tests for sub cases * Find int tests, scoped client, patch sub user actions * fixing types and call cluster * fixing get sub case param issue * Adding user actions for sub cases * Preventing alerts on collections and refactoring user * Allowing type to be updated for ind cases * Refactoring and writing tests * Fixing sub case status filtering * Adding more tests not allowing gen alerts patch * Working unit tests * Push to connector gets all sub case comments * Writing more tests and cleaning up * Updating push functionality for generated alerts and sub cases * Adding comment about updating collection sync * Refactoring update alert status for sub cases and removing request and cleaning up * Addressing alert service feedback * Fixing sub case sync bug and cleaning up comment types * Addressing more feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/case/common/api/cases/case.ts | 54 +- .../plugins/case/common/api/cases/comment.ts | 51 +- .../case/common/api/cases/commentable_case.ts | 35 + x-pack/plugins/case/common/api/cases/index.ts | 2 + .../plugins/case/common/api/cases/status.ts | 14 + .../plugins/case/common/api/cases/sub_case.ts | 80 ++ .../case/common/api/cases/user_actions.ts | 25 +- x-pack/plugins/case/common/api/helpers.ts | 10 + .../plugins/case/common/api/saved_object.ts | 5 +- x-pack/plugins/case/common/constants.ts | 5 + x-pack/plugins/case/package.json | 10 + .../plugins/case/server/client/alerts/get.ts | 28 +- .../client/alerts/update_status.test.ts | 48 +- .../server/client/alerts/update_status.ts | 30 +- .../case/server/client/cases/create.test.ts | 267 ++-- .../case/server/client/cases/create.ts | 35 +- .../plugins/case/server/client/cases/get.ts | 25 +- .../plugins/case/server/client/cases/mock.ts | 3 + .../plugins/case/server/client/cases/push.ts | 68 +- .../case/server/client/cases/update.test.ts | 492 ++++--- .../case/server/client/cases/update.ts | 537 ++++++-- .../case/server/client/cases/utils.test.ts | 4 +- .../plugins/case/server/client/cases/utils.ts | 9 +- x-pack/plugins/case/server/client/client.ts | 154 +++ .../case/server/client/comments/add.test.ts | 120 +- .../case/server/client/comments/add.ts | 346 +++-- .../server/client/configure/get_fields.ts | 2 +- .../client/configure/get_mappings.test.ts | 2 - .../server/client/configure/get_mappings.ts | 19 +- .../plugins/case/server/client/index.test.ts | 83 +- x-pack/plugins/case/server/client/index.ts | 43 +- x-pack/plugins/case/server/client/mocks.ts | 49 +- x-pack/plugins/case/server/client/types.ts | 63 +- .../case/server/client/user_actions/get.ts | 14 +- x-pack/plugins/case/server/common/index.ts | 9 + .../server/common/models/commentable_case.ts | 300 +++++ .../case/server/common/models/index.ts | 8 + .../plugins/case/server/common/utils.test.ts | 235 ++++ x-pack/plugins/case/server/common/utils.ts | 127 ++ .../case/server/connectors/case/index.test.ts | 108 +- .../case/server/connectors/case/index.ts | 64 +- .../case/server/connectors/case/schema.ts | 55 +- .../case/server/connectors/case/types.ts | 4 +- .../plugins/case/server/connectors/index.ts | 26 +- .../plugins/case/server/connectors/types.ts | 6 + x-pack/plugins/case/server/plugin.ts | 39 +- .../routes/api/__fixtures__/authc_mock.ts | 7 +- .../__fixtures__/create_mock_so_repository.ts | 62 +- .../routes/api/__fixtures__/mock_router.ts | 8 +- .../api/__fixtures__/mock_saved_objects.ts | 11 + .../routes/api/__fixtures__/route_contexts.ts | 30 +- .../api/cases/comments/delete_all_comments.ts | 18 +- .../api/cases/comments/delete_comment.ts | 21 +- .../api/cases/comments/find_comments.ts | 35 +- .../api/cases/comments/get_all_comment.ts | 35 +- .../api/cases/comments/patch_comment.test.ts | 16 +- .../api/cases/comments/patch_comment.ts | 170 +-- .../api/cases/comments/post_comment.test.ts | 35 +- .../routes/api/cases/comments/post_comment.ts | 9 +- .../api/cases/configure/get_configure.ts | 1 - .../api/cases/configure/patch_configure.ts | 3 +- .../api/cases/configure/post_configure.ts | 3 +- .../routes/api/cases/delete_cases.test.ts | 47 +- .../server/routes/api/cases/delete_cases.ts | 88 +- .../server/routes/api/cases/find_cases.ts | 123 +- .../case/server/routes/api/cases/get_case.ts | 14 +- .../case/server/routes/api/cases/helpers.ts | 178 ++- .../routes/api/cases/patch_cases.test.ts | 217 +-- .../server/routes/api/cases/patch_cases.ts | 2 +- .../server/routes/api/cases/post_case.test.ts | 67 +- .../case/server/routes/api/cases/post_case.ts | 2 +- .../server/routes/api/cases/push_case.test.ts | 5 +- .../case/server/routes/api/cases/push_case.ts | 1 - .../api/cases/status/get_status.test.ts | 8 +- .../routes/api/cases/status/get_status.ts | 30 +- .../api/cases/sub_case/delete_sub_cases.ts | 87 ++ .../api/cases/sub_case/find_sub_cases.ts | 95 ++ .../routes/api/cases/sub_case/get_sub_case.ts | 77 ++ .../api/cases/sub_case/patch_sub_cases.ts | 418 ++++++ .../plugins/case/server/routes/api/index.ts | 18 + .../case/server/routes/api/utils.test.ts | 890 ++++++++----- .../plugins/case/server/routes/api/utils.ts | 250 +++- .../case/server/saved_object_types/cases.ts | 5 +- .../server/saved_object_types/comments.ts | 3 + .../case/server/saved_object_types/index.ts | 1 + .../server/saved_object_types/migrations.ts | 34 +- .../server/saved_object_types/sub_case.ts | 71 + .../server/scripts/mock/case/post_case.json | 11 +- .../scripts/mock/case/post_case_v2.json | 11 +- .../case/server/scripts/sub_cases/README.md | 80 ++ .../server/scripts/sub_cases/generator.js | 9 + .../case/server/scripts/sub_cases/index.ts | 217 +++ .../case/server/services/alerts/index.test.ts | 30 +- .../case/server/services/alerts/index.ts | 65 +- x-pack/plugins/case/server/services/index.ts | 1184 ++++++++++++++--- x-pack/plugins/case/server/services/mocks.ts | 17 +- .../server/services/user_actions/helpers.ts | 158 ++- .../cases/components/all_cases/index.test.tsx | 4 +- .../components/case_view/helpers.test.tsx | 4 +- .../cases/components/case_view/helpers.ts | 3 +- .../connectors/case/alert_fields.tsx | 5 +- .../components/user_action_tree/index.tsx | 9 +- .../public/cases/containers/mock.ts | 7 + .../public/cases/containers/types.ts | 5 + .../public/cases/containers/use_get_case.tsx | 4 +- .../security_solution/server/plugin.ts | 18 +- .../tests/cases/comments/delete_comment.ts | 73 +- .../tests/cases/comments/find_comments.ts | 42 +- .../tests/cases/comments/get_all_comments.ts | 109 ++ .../basic/tests/cases/comments/get_comment.ts | 28 +- .../tests/cases/comments/patch_comment.ts | 88 +- .../tests/cases/comments/post_comment.ts | 74 +- .../basic/tests/cases/find_cases.ts | 140 +- .../basic/tests/cases/patch_cases.ts | 110 +- .../tests/cases/sub_cases/delete_sub_cases.ts | 102 ++ .../tests/cases/sub_cases/find_sub_cases.ts | 254 ++++ .../tests/cases/sub_cases/get_sub_case.ts | 115 ++ .../tests/cases/sub_cases/patch_sub_cases.ts | 93 ++ .../user_actions/get_all_user_actions.ts | 39 +- .../basic/tests/connectors/case.ts | 121 +- .../case_api_integration/basic/tests/index.ts | 5 + .../case_api_integration/common/lib/mock.ts | 151 ++- .../case_api_integration/common/lib/utils.ts | 207 +++ 123 files changed, 8319 insertions(+), 2181 deletions(-) create mode 100644 x-pack/plugins/case/common/api/cases/commentable_case.ts create mode 100644 x-pack/plugins/case/common/api/cases/sub_case.ts create mode 100644 x-pack/plugins/case/package.json create mode 100644 x-pack/plugins/case/server/client/client.ts create mode 100644 x-pack/plugins/case/server/common/index.ts create mode 100644 x-pack/plugins/case/server/common/models/commentable_case.ts create mode 100644 x-pack/plugins/case/server/common/models/index.ts create mode 100644 x-pack/plugins/case/server/common/utils.test.ts create mode 100644 x-pack/plugins/case/server/common/utils.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts create mode 100644 x-pack/plugins/case/server/saved_object_types/sub_case.ts create mode 100644 x-pack/plugins/case/server/scripts/sub_cases/README.md create mode 100644 x-pack/plugins/case/server/scripts/sub_cases/generator.js create mode 100644 x-pack/plugins/case/server/scripts/sub_cases/index.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index bebd261fb7b9b3..49643ca1f4d0c7 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -10,22 +10,21 @@ import * as rt from 'io-ts'; import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; -import { CasesStatusResponseRt } from './status'; +import { CasesStatusResponseRt, CaseStatusRt } from './status'; import { CaseConnectorRt, ESCaseConnector } from '../connectors'; +import { SubCaseResponseRt } from './sub_case'; -export enum CaseStatuses { - open = 'open', - 'in-progress' = 'in-progress', - closed = 'closed', +export enum CaseType { + collection = 'collection', + individual = 'individual', } -const CaseStatusRt = rt.union([ - rt.literal(CaseStatuses.open), - rt.literal(CaseStatuses['in-progress']), - rt.literal(CaseStatuses.closed), -]); +/** + * Exposing the field used to define the case type so that it can be used for filtering in saved object find queries. + */ +export const caseTypeField = 'type'; -export const caseStatuses = Object.values(CaseStatuses); +const CaseTypeRt = rt.union([rt.literal(CaseType.collection), rt.literal(CaseType.individual)]); const SettingsRt = rt.type({ syncAlerts: rt.boolean, @@ -36,6 +35,7 @@ const CaseBasicRt = rt.type({ status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, + [caseTypeField]: CaseTypeRt, connector: CaseConnectorRt, settings: SettingsRt, }); @@ -72,7 +72,7 @@ export const CaseAttributesRt = rt.intersection([ }), ]); -export const CasePostRequestRt = rt.type({ +const CasePostRequestNoTypeRt = rt.type({ description: rt.string, tags: rt.array(rt.string), title: rt.string, @@ -80,7 +80,27 @@ export const CasePostRequestRt = rt.type({ settings: SettingsRt, }); +/** + * This type is used for validating a create case request. It requires that the type field be defined. + */ +export const CaseClientPostRequestRt = rt.type({ + ...CasePostRequestNoTypeRt.props, + [caseTypeField]: CaseTypeRt, +}); + +/** + * This type is not used for validation when decoding a request because intersection does not have props defined which + * required for the excess function. Instead we use this as the type used by the UI. This allows the type field to be + * optional and the server will handle setting it to a default value before validating that the request + * has all the necessary fields. CaseClientPostRequestRt is used for validation. + */ +export const CasePostRequestRt = rt.intersection([ + rt.partial({ type: CaseTypeRt }), + CasePostRequestNoTypeRt, +]); + export const CasesFindRequestRt = rt.partial({ + type: CaseTypeRt, tags: rt.union([rt.array(rt.string), rt.string]), status: CaseStatusRt, reporters: rt.union([rt.array(rt.string), rt.string]), @@ -99,9 +119,11 @@ export const CaseResponseRt = rt.intersection([ rt.type({ id: rt.string, totalComment: rt.number, + totalAlerts: rt.number, version: rt.string, }), rt.partial({ + subCases: rt.array(SubCaseResponseRt), comments: rt.array(CommentResponseRt), }), ]); @@ -150,13 +172,21 @@ export const ExternalServiceResponseRt = rt.intersection([ ]); export type CaseAttributes = rt.TypeOf; +/** + * This field differs from the CasePostRequest in that the post request's type field can be optional. This type requires + * that the type field be defined. The CasePostRequest should be used in most places (the UI etc). This type is really + * only necessary for validation. + */ +export type CaseClientPostRequest = rt.TypeOf; export type CasePostRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; export type CasesResponse = rt.TypeOf; +export type CasesFindRequest = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; export type CaseFullExternalService = rt.TypeOf; +export type CaseSettings = rt.TypeOf; export type ExternalServiceResponse = rt.TypeOf; export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 7c9b31f496e54d..cfc6099fa4bb57 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -9,7 +9,22 @@ import * as rt from 'io-ts'; import { UserRT } from '../user'; +/** + * this is used to differentiate between an alert attached to a top-level case and a group of alerts that should only + * be attached to a sub case. The reason we need this is because an alert group comment will have references to both a case and + * sub case when it is created. For us to be able to filter out alert groups in a top-level case we need a field to + * use as a filter. + */ +export enum AssociationType { + case = 'case', + subCase = 'sub_case', +} + export const CommentAttributesBasicRt = rt.type({ + associationType: rt.union([ + rt.literal(AssociationType.case), + rt.literal(AssociationType.subCase), + ]), created_at: rt.string, created_by: UserRT, pushed_at: rt.union([rt.string, rt.null]), @@ -18,24 +33,33 @@ export const CommentAttributesBasicRt = rt.type({ updated_by: rt.union([UserRT, rt.null]), }); +export enum CommentType { + user = 'user', + alert = 'alert', + generatedAlert = 'generated_alert', +} + export const ContextTypeUserRt = rt.type({ comment: rt.string, - type: rt.literal('user'), + type: rt.literal(CommentType.user), }); -export const ContextTypeAlertRt = rt.type({ - type: rt.literal('alert'), - alertId: rt.string, +/** + * This defines the structure of how alerts (generated or user attached) are stored in saved objects documents. It also + * represents of an alert after it has been transformed. A generated alert will be transformed by the connector so that + * it matches this structure. User attached alerts do not need to be transformed. + */ +export const AlertCommentRequestRt = rt.type({ + type: rt.union([rt.literal(CommentType.generatedAlert), rt.literal(CommentType.alert)]), + alertId: rt.union([rt.array(rt.string), rt.string]), index: rt.string, }); const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); -const AttributesTypeAlertsRt = rt.intersection([ContextTypeAlertRt, CommentAttributesBasicRt]); +const AttributesTypeAlertsRt = rt.intersection([AlertCommentRequestRt, CommentAttributesBasicRt]); const CommentAttributesRt = rt.union([AttributesTypeUserRt, AttributesTypeAlertsRt]); -const ContextBasicRt = rt.union([ContextTypeUserRt, ContextTypeAlertRt]); - -export const CommentRequestRt = ContextBasicRt; +export const CommentRequestRt = rt.union([ContextTypeUserRt, AlertCommentRequestRt]); export const CommentResponseRt = rt.intersection([ CommentAttributesRt, @@ -60,7 +84,7 @@ export const CommentPatchRequestRt = rt.intersection([ * Partial updates are not allowed. * We want to prevent the user for changing the type without removing invalid fields. */ - ContextBasicRt, + CommentRequestRt, rt.type({ id: rt.string, version: rt.string }), ]); @@ -71,7 +95,7 @@ export const CommentPatchRequestRt = rt.intersection([ * We ensure that partial updates of CommentContext is not going to happen inside the patch comment route. */ export const CommentPatchAttributesRt = rt.intersection([ - rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(ContextTypeAlertRt.props)]), + rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(AlertCommentRequestRt.props)]), rt.partial(CommentAttributesBasicRt.props), ]); @@ -82,11 +106,6 @@ export const CommentsResponseRt = rt.type({ total: rt.number, }); -export enum CommentType { - user = 'user', - alert = 'alert', -} - export const AllCommentsResponseRt = rt.array(CommentResponseRt); export type CommentAttributes = rt.TypeOf; @@ -98,4 +117,4 @@ export type CommentsResponse = rt.TypeOf; export type CommentPatchRequest = rt.TypeOf; export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; -export type CommentRequestAlertType = rt.TypeOf; +export type CommentRequestAlertType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/commentable_case.ts b/x-pack/plugins/case/common/api/cases/commentable_case.ts new file mode 100644 index 00000000000000..023229a90d352d --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/commentable_case.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { CaseAttributesRt } from './case'; +import { CommentResponseRt } from './comment'; +import { SubCaseAttributesRt, SubCaseResponseRt } from './sub_case'; + +export const CollectionSubCaseAttributesRt = rt.intersection([ + rt.partial({ subCase: SubCaseAttributesRt }), + rt.type({ + case: CaseAttributesRt, + }), +]); + +export const CollectWithSubCaseResponseRt = rt.intersection([ + CaseAttributesRt, + rt.type({ + id: rt.string, + totalComment: rt.number, + version: rt.string, + }), + rt.partial({ + subCase: SubCaseResponseRt, + totalAlerts: rt.number, + comments: rt.array(CommentResponseRt), + }), +]); + +export type CollectionWithSubCaseResponse = rt.TypeOf; +export type CollectionWithSubCaseAttributes = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index a3ad4100b7ce37..4d1fc68109ddb7 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -10,3 +10,5 @@ export * from './configure'; export * from './comment'; export * from './status'; export * from './user_actions'; +export * from './sub_case'; +export * from './commentable_case'; diff --git a/x-pack/plugins/case/common/api/cases/status.ts b/x-pack/plugins/case/common/api/cases/status.ts index 2e05930c37f5da..7286e19da91592 100644 --- a/x-pack/plugins/case/common/api/cases/status.ts +++ b/x-pack/plugins/case/common/api/cases/status.ts @@ -7,6 +7,20 @@ import * as rt from 'io-ts'; +export enum CaseStatuses { + open = 'open', + 'in-progress' = 'in-progress', + closed = 'closed', +} + +export const CaseStatusRt = rt.union([ + rt.literal(CaseStatuses.open), + rt.literal(CaseStatuses['in-progress']), + rt.literal(CaseStatuses.closed), +]); + +export const caseStatuses = Object.values(CaseStatuses); + export const CasesStatusResponseRt = rt.type({ count_open_cases: rt.number, count_in_progress_cases: rt.number, diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts new file mode 100644 index 00000000000000..c46f87c547d50b --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +import { NumberFromString } from '../saved_object'; +import { UserRT } from '../user'; +import { CommentResponseRt } from './comment'; +import { CasesStatusResponseRt } from './status'; +import { CaseStatusRt } from './status'; + +const SubCaseBasicRt = rt.type({ + status: CaseStatusRt, +}); + +export const SubCaseAttributesRt = rt.intersection([ + SubCaseBasicRt, + rt.type({ + closed_at: rt.union([rt.string, rt.null]), + closed_by: rt.union([UserRT, rt.null]), + created_at: rt.string, + created_by: rt.union([UserRT, rt.null]), + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRT, rt.null]), + }), +]); + +export const SubCasesFindRequestRt = rt.partial({ + status: CaseStatusRt, + defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + fields: rt.array(rt.string), + page: NumberFromString, + perPage: NumberFromString, + search: rt.string, + searchFields: rt.array(rt.string), + sortField: rt.string, + sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), +}); + +export const SubCaseResponseRt = rt.intersection([ + SubCaseAttributesRt, + rt.type({ + id: rt.string, + totalComment: rt.number, + totalAlerts: rt.number, + version: rt.string, + }), + rt.partial({ + comments: rt.array(CommentResponseRt), + }), +]); + +export const SubCasesFindResponseRt = rt.intersection([ + rt.type({ + subCases: rt.array(SubCaseResponseRt), + page: rt.number, + per_page: rt.number, + total: rt.number, + }), + CasesStatusResponseRt, +]); + +export const SubCasePatchRequestRt = rt.intersection([ + rt.partial(SubCaseBasicRt.props), + rt.type({ id: rt.string, version: rt.string }), +]); + +export const SubCasesPatchRequestRt = rt.type({ subCases: rt.array(SubCasePatchRequestRt) }); +export const SubCasesResponseRt = rt.array(SubCaseResponseRt); + +export type SubCaseAttributes = rt.TypeOf; +export type SubCaseResponse = rt.TypeOf; +export type SubCasesResponse = rt.TypeOf; +export type SubCasesFindResponse = rt.TypeOf; +export type SubCasePatchRequest = rt.TypeOf; +export type SubCasesPatchRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index a83b8e46ae04eb..de9e88993df9a2 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -12,18 +12,18 @@ import { UserRT } from '../user'; /* To the next developer, if you add/removed fields here * make sure to check this file (x-pack/plugins/case/server/services/user_actions/helpers.ts) too */ -const UserActionFieldRt = rt.array( - rt.union([ - rt.literal('comment'), - rt.literal('connector'), - rt.literal('description'), - rt.literal('pushed'), - rt.literal('tags'), - rt.literal('title'), - rt.literal('status'), - rt.literal('settings'), - ]) -); +const UserActionFieldTypeRt = rt.union([ + rt.literal('comment'), + rt.literal('connector'), + rt.literal('description'), + rt.literal('pushed'), + rt.literal('tags'), + rt.literal('title'), + rt.literal('status'), + rt.literal('settings'), + rt.literal('sub_case'), +]); +const UserActionFieldRt = rt.array(UserActionFieldTypeRt); const UserActionRt = rt.union([ rt.literal('add'), rt.literal('create'), @@ -60,3 +60,4 @@ export type CaseUserActionsResponse = rt.TypeOf; export type UserActionField = rt.TypeOf; +export type UserActionFieldType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/helpers.ts b/x-pack/plugins/case/common/api/helpers.ts index 24c4756a1596b4..9c290c0a4d6128 100644 --- a/x-pack/plugins/case/common/api/helpers.ts +++ b/x-pack/plugins/case/common/api/helpers.ts @@ -10,6 +10,8 @@ import { CASE_COMMENTS_URL, CASE_USER_ACTIONS_URL, CASE_COMMENT_DETAILS_URL, + SUB_CASE_DETAILS_URL, + SUB_CASES_URL, CASE_PUSH_URL, } from '../constants'; @@ -17,6 +19,14 @@ export const getCaseDetailsUrl = (id: string): string => { return CASE_DETAILS_URL.replace('{case_id}', id); }; +export const getSubCasesUrl = (caseID: string): string => { + return SUB_CASES_URL.replace('{case_id}', caseID); +}; + +export const getSubCaseDetailsUrl = (caseID: string, subCaseID: string): string => { + return SUB_CASE_DETAILS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID); +}; + export const getCaseCommentsUrl = (id: string): string => { return CASE_COMMENTS_URL.replace('{case_id}', id); }; diff --git a/x-pack/plugins/case/common/api/saved_object.ts b/x-pack/plugins/case/common/api/saved_object.ts index 91eace4e3655e8..e0ae4ee82c490f 100644 --- a/x-pack/plugins/case/common/api/saved_object.ts +++ b/x-pack/plugins/case/common/api/saved_object.ts @@ -20,9 +20,12 @@ export const NumberFromString = new rt.Type( String ); +const ReferenceRt = rt.type({ id: rt.string, type: rt.string }); + export const SavedObjectFindOptionsRt = rt.partial({ defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), - hasReference: rt.type({ id: rt.string, type: rt.string }), + hasReferenceOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + hasReference: rt.union([rt.array(ReferenceRt), ReferenceRt]), fields: rt.array(rt.string), filter: rt.string, page: NumberFromString, diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index 92dd2312f1ecf3..5d34ed120ff6f8 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -15,6 +15,11 @@ export const CASES_URL = '/api/cases'; export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; + +export const SUB_CASES_PATCH_DEL_URL = `${CASES_URL}/sub_cases`; +export const SUB_CASES_URL = `${CASE_DETAILS_URL}/sub_cases`; +export const SUB_CASE_DETAILS_URL = `${CASE_DETAILS_URL}/sub_cases/{sub_case_id}`; + export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push`; diff --git a/x-pack/plugins/case/package.json b/x-pack/plugins/case/package.json new file mode 100644 index 00000000000000..5a254142969460 --- /dev/null +++ b/x-pack/plugins/case/package.json @@ -0,0 +1,10 @@ +{ + "author": "Elastic", + "name": "case", + "version": "8.0.0", + "private": true, + "license": "Elastic-License", + "scripts": { + "test:sub-cases": "node server/scripts/sub_cases/generator" + } +} diff --git a/x-pack/plugins/case/server/client/alerts/get.ts b/x-pack/plugins/case/server/client/alerts/get.ts index 718dd327aa08c8..a7ca5d9742c6bb 100644 --- a/x-pack/plugins/case/server/client/alerts/get.ts +++ b/x-pack/plugins/case/server/client/alerts/get.ts @@ -5,24 +5,32 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { CaseClientGetAlerts, CaseClientFactoryArguments } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; +import { AlertServiceContract } from '../../services'; import { CaseClientGetAlertsResponse } from './types'; -export const get = ({ alertsService, request, context }: CaseClientFactoryArguments) => async ({ +interface GetParams { + alertsService: AlertServiceContract; + ids: string[]; + indices: Set; + scopedClusterClient: ElasticsearchClient; +} + +export const get = async ({ + alertsService, ids, -}: CaseClientGetAlerts): Promise => { - const securitySolutionClient = context?.securitySolution?.getAppClient(); - if (securitySolutionClient == null) { - throw Boom.notFound('securitySolutionClient client have not been found'); + indices, + scopedClusterClient, +}: GetParams): Promise => { + if (ids.length === 0 || indices.size <= 0) { + return []; } - if (ids.length === 0) { + const alerts = await alertsService.getAlerts({ ids, indices, scopedClusterClient }); + if (!alerts) { return []; } - const index = securitySolutionClient.getSignalsIndex(); - const alerts = await alertsService.getAlerts({ ids, index, request }); return alerts.hits.hits.map((alert) => ({ id: alert._id, index: alert._index, diff --git a/x-pack/plugins/case/server/client/alerts/update_status.test.ts b/x-pack/plugins/case/server/client/alerts/update_status.test.ts index f7b028fd98cd6a..c8df1c8ab74f36 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.test.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.test.ts @@ -10,45 +10,21 @@ import { createMockSavedObjectsRepository } from '../../routes/api/__fixtures__' import { createCaseClientWithMockSavedObjectsClient } from '../mocks'; describe('updateAlertsStatus', () => { - describe('happy path', () => { - test('it update the status of the alert correctly', async () => { - const savedObjectsClient = createMockSavedObjectsRepository(); + it('updates the status of the alert correctly', async () => { + const savedObjectsClient = createMockSavedObjectsRepository(); - const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - await caseClient.client.updateAlertsStatus({ - ids: ['alert-id-1'], - status: CaseStatuses.closed, - }); - - expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ - ids: ['alert-id-1'], - index: '.siem-signals', - request: {}, - status: CaseStatuses.closed, - }); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + await caseClient.client.updateAlertsStatus({ + ids: ['alert-id-1'], + status: CaseStatuses.closed, + indices: new Set(['.siem-signals']), }); - describe('unhappy path', () => { - test('it throws when missing securitySolutionClient', async () => { - expect.assertions(3); - - const savedObjectsClient = createMockSavedObjectsRepository(); - - const caseClient = await createCaseClientWithMockSavedObjectsClient({ - savedObjectsClient, - omitFromContext: ['securitySolution'], - }); - caseClient.client - .updateAlertsStatus({ - ids: ['alert-id-1'], - status: CaseStatuses.closed, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(404); - }); - }); + expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ + scopedClusterClient: expect.anything(), + ids: ['alert-id-1'], + indices: new Set(['.siem-signals']), + status: CaseStatuses.closed, }); }); }); diff --git a/x-pack/plugins/case/server/client/alerts/update_status.ts b/x-pack/plugins/case/server/client/alerts/update_status.ts index daaa6d7233f027..cb18bd4fc16e3e 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -5,22 +5,24 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { CaseClientUpdateAlertsStatus, CaseClientFactoryArguments } from '../types'; +import { ElasticsearchClient } from 'src/core/server'; +import { CaseStatuses } from '../../../common/api'; +import { AlertServiceContract } from '../../services'; -export const updateAlertsStatus = ({ +interface UpdateAlertsStatusArgs { + alertsService: AlertServiceContract; + ids: string[]; + status: CaseStatuses; + indices: Set; + scopedClusterClient: ElasticsearchClient; +} + +export const updateAlertsStatus = async ({ alertsService, - request, - context, -}: CaseClientFactoryArguments) => async ({ ids, status, -}: CaseClientUpdateAlertsStatus): Promise => { - const securitySolutionClient = context?.securitySolution?.getAppClient(); - if (securitySolutionClient == null) { - throw Boom.notFound('securitySolutionClient client have not been found'); - } - - const index = securitySolutionClient.getSignalsIndex(); - await alertsService.updateAlertsStatus({ ids, status, index, request }); + indices, + scopedClusterClient, +}: UpdateAlertsStatusArgs): Promise => { + await alertsService.updateAlertsStatus({ ids, status, indices, scopedClusterClient }); }; diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index 919128a2cfbc59..065825472954b8 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypes, CasePostRequest, CaseStatuses } from '../../../common/api'; +import { ConnectorTypes, CaseStatuses, CaseType, CaseClientPostRequest } from '../../../common/api'; import { createMockSavedObjectsRepository, @@ -25,10 +25,11 @@ describe('create', () => { describe('happy path', () => { test('it creates the case correctly', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: '123', name: 'Jira', @@ -38,75 +39,100 @@ describe('create', () => { settings: { syncAlerts: true, }, - } as CasePostRequest; + }; const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "Jira", + "type": ".jira", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions - ).toEqual([ - { - attributes: { - action: 'create', - action_at: '2019-11-25T21:54:48.952Z', - action_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', + // using a snapshot here so we don't have to update the text field manually each time it changes + ).toMatchInlineSnapshot(` + Array [ + Object { + "attributes": Object { + "action": "create", + "action_at": "2019-11-25T21:54:48.952Z", + "action_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "action_field": Array [ + "description", + "status", + "tags", + "title", + "connector", + "settings", + ], + "new_value": "{\\"type\\":\\"individual\\",\\"description\\":\\"This is a brand new case of a bad meanie defacing data\\",\\"title\\":\\"Super Bad Security Issue\\",\\"tags\\":[\\"defacement\\"],\\"connector\\":{\\"id\\":\\"123\\",\\"name\\":\\"Jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"Task\\",\\"priority\\":\\"High\\",\\"parent\\":null}},\\"settings\\":{\\"syncAlerts\\":true}}", + "old_value": null, }, - action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], - new_value: - '{"description":"This is a brand new case of a bad meanie defacing data","title":"Super Bad Security Issue","tags":["defacement"],"connector":{"id":"123","name":"Jira","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}},"settings":{"syncAlerts":true}}', - old_value: null, + "references": Array [ + Object { + "id": "mock-it", + "name": "associated-cases", + "type": "cases", + }, + ], }, - references: [ - { - id: 'mock-it', - name: 'associated-cases', - type: 'cases', - }, - ], - }, - ]); + ] + `); }); test('it creates the case without connector in the configuration', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -122,36 +148,53 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('Allow user to create case without authentication', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -170,33 +213,45 @@ describe('create', () => { savedObjectsClient, badAuth: true, }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); @@ -338,6 +393,7 @@ describe('create', () => { title: 'a title', description: 'This is a brand new case of a bad meanie defacing data', tags: ['defacement'], + type: CaseType.individual, status: CaseStatuses.closed, connector: { id: 'none', @@ -354,7 +410,7 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create({ theCase: postCase }).catch((e) => { + caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(400); @@ -362,10 +418,11 @@ describe('create', () => { }); it(`Returns an error if postNewCase throws`, async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'Throw an error', title: 'Super Bad Security Issue', tags: ['error'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -381,7 +438,7 @@ describe('create', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create({ theCase: postCase }).catch((e) => { + caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(400); diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index b9c2c1991537af..ee47c59072fdd9 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -10,14 +10,18 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; +import { SavedObjectsClientContract } from 'src/core/server'; import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; import { - CasePostRequestRt, throwErrors, excess, CaseResponseRt, CaseResponse, + CaseClientPostRequestRt, + CasePostRequest, + CaseType, + User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { @@ -25,22 +29,39 @@ import { transformCaseConnectorToEsConnector, } from '../../routes/api/cases/helpers'; -import { CaseClientCreate, CaseClientFactoryArguments } from '../types'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; + +interface CreateCaseArgs { + caseConfigureService: CaseConfigureServiceSetup; + caseService: CaseServiceSetup; + user: User; + savedObjectsClient: SavedObjectsClientContract; + userActionService: CaseUserActionServiceSetup; + theCase: CasePostRequest; +} -export const create = ({ +export const create = async ({ savedObjectsClient, caseService, caseConfigureService, userActionService, - request, -}: CaseClientFactoryArguments) => async ({ theCase }: CaseClientCreate): Promise => { + user, + theCase, +}: CreateCaseArgs): Promise => { + // default to an individual case if the type is not defined. + const { type = CaseType.individual, ...nonTypeCaseFields } = theCase; const query = pipe( - excess(CasePostRequestRt).decode(theCase), + // decode with the defaulted type field + excess(CaseClientPostRequestRt).decode({ type, ...nonTypeCaseFields }), fold(throwErrors(Boom.badRequest), identity) ); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); + const { username, full_name, email } = user; const createdDate = new Date().toISOString(); const myCaseConfigure = await caseConfigureService.find({ client: savedObjectsClient }); const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); diff --git a/x-pack/plugins/case/server/client/cases/get.ts b/x-pack/plugins/case/server/client/cases/get.ts index c1901ccaae511c..eab43a0c4d4536 100644 --- a/x-pack/plugins/case/server/client/cases/get.ts +++ b/x-pack/plugins/case/server/client/cases/get.ts @@ -5,17 +5,30 @@ * 2.0. */ +import { SavedObjectsClientContract } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; import { CaseResponseRt, CaseResponse } from '../../../common/api'; -import { CaseClientGet, CaseClientFactoryArguments } from '../types'; +import { CaseServiceSetup } from '../../services'; +import { countAlertsForID } from '../../common'; -export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArguments) => async ({ +interface GetParams { + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + id: string; + includeComments?: boolean; + includeSubCaseComments?: boolean; +} + +export const get = async ({ + savedObjectsClient, + caseService, id, includeComments = false, -}: CaseClientGet): Promise => { + includeSubCaseComments = false, +}: GetParams): Promise => { const theCase = await caseService.getCase({ client: savedObjectsClient, - caseId: id, + id, }); if (!includeComments) { @@ -28,11 +41,12 @@ export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArgume const theComments = await caseService.getAllCaseComments({ client: savedObjectsClient, - caseId: id, + id, options: { sortField: 'created_at', sortOrder: 'asc', }, + includeSubCaseComments, }); return CaseResponseRt.encode( @@ -40,6 +54,7 @@ export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArgume savedObject: theCase, comments: theComments.saved_objects, totalComment: theComments.total, + totalAlerts: countAlertsForID({ comments: theComments, id }), }) ); }; diff --git a/x-pack/plugins/case/server/client/cases/mock.ts b/x-pack/plugins/case/server/client/cases/mock.ts index 57e2d4373a52b8..2be9f410598312 100644 --- a/x-pack/plugins/case/server/client/cases/mock.ts +++ b/x-pack/plugins/case/server/client/cases/mock.ts @@ -10,6 +10,7 @@ import { CommentType, ConnectorMappingsAttributes, CaseUserActionsResponse, + AssociationType, } from '../../../common/api'; import { BasicParams } from './types'; @@ -27,6 +28,7 @@ const entity = { }; export const comment: CommentResponse = { + associationType: AssociationType.case, id: 'mock-comment-1', comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user as const, @@ -48,6 +50,7 @@ export const comment: CommentResponse = { }; export const commentAlert: CommentResponse = { + associationType: AssociationType.case, id: 'mock-comment-1', alertId: 'alert-id-1', index: 'alert-index-1', diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts index f329fb4d00d077..1e0c246855d88b 100644 --- a/x-pack/plugins/case/server/client/cases/push.ts +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -6,9 +6,13 @@ */ import Boom, { isBoom, Boom as BoomType } from '@hapi/boom'; - -import { SavedObjectsBulkUpdateResponse, SavedObjectsUpdateResponse } from 'kibana/server'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { + SavedObjectsBulkUpdateResponse, + SavedObjectsClientContract, + SavedObjectsUpdateResponse, +} from 'kibana/server'; +import { ActionResult, ActionsClient } from '../../../../actions/server'; +import { flattenCaseSavedObject, getAlertIndicesAndIDs } from '../../routes/api/utils'; import { ActionConnector, @@ -18,11 +22,18 @@ import { ExternalServiceResponse, ESCaseAttributes, CommentAttributes, + CaseUserActionsResponse, + User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; -import { CaseClientPush, CaseClientFactoryArguments } from '../types'; -import { createIncident, getCommentContextFromAttributes, isCommentAlertType } from './utils'; +import { createIncident, getCommentContextFromAttributes } from './utils'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; +import { CaseClientHandler } from '../client'; const createError = (e: Error | BoomType, message: string): Error | BoomType => { if (isBoom(e)) { @@ -34,30 +45,40 @@ const createError = (e: Error | BoomType, message: string): Error | BoomType => return Error(message); }; -export const push = ({ +interface PushParams { + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + caseConfigureService: CaseConfigureServiceSetup; + userActionService: CaseUserActionServiceSetup; + user: User; + caseId: string; + connectorId: string; + caseClient: CaseClientHandler; + actionsClient: ActionsClient; +} + +export const push = async ({ savedObjectsClient, caseService, caseConfigureService, userActionService, - request, - response, -}: CaseClientFactoryArguments) => async ({ - actionsClient, caseClient, - caseId, + actionsClient, connectorId, -}: CaseClientPush): Promise => { + caseId, + user, +}: PushParams): Promise => { /* Start of push to external service */ - let theCase; - let connector; - let userActions; + let theCase: CaseResponse; + let connector: ActionResult; + let userActions: CaseUserActionsResponse; let alerts; let connectorMappings; let externalServiceIncident; try { [theCase, connector, userActions] = await Promise.all([ - caseClient.get({ id: caseId, includeComments: true }), + caseClient.get({ id: caseId, includeComments: true, includeSubCaseComments: true }), actionsClient.get({ id: connectorId }), caseClient.getUserActions({ caseId }), ]); @@ -73,9 +94,12 @@ export const push = ({ ); } + const { ids, indices } = getAlertIndicesAndIDs(theCase?.comments); + try { alerts = await caseClient.getAlerts({ - ids: theCase?.comments?.filter(isCommentAlertType).map((comment) => comment.alertId) ?? [], + ids, + indices, }); } catch (e) { throw new Error(`Error getting alerts for case with id ${theCase.id}: ${e.message}`); @@ -84,7 +108,6 @@ export const push = ({ try { connectorMappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.actionTypeId, }); @@ -124,27 +147,26 @@ export const push = ({ /* End of push to external service */ /* Start of update case with push information */ - let user; let myCase; let myCaseConfigure; let comments; try { - [user, myCase, myCaseConfigure, comments] = await Promise.all([ - caseService.getUser({ request, response }), + [myCase, myCaseConfigure, comments] = await Promise.all([ caseService.getCase({ client: savedObjectsClient, - caseId, + id: caseId, }), caseConfigureService.find({ client: savedObjectsClient }), caseService.getAllCaseComments({ client: savedObjectsClient, - caseId, + id: caseId, options: { fields: [], page: 1, perPage: theCase?.totalComment ?? 0, }, + includeSubCaseComments: true, }), ]); } catch (e) { diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index 0fd72c86a50ba5..53e233c74deb4e 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -40,39 +40,55 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.closed, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions @@ -123,39 +139,51 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.open, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); test('it change the status of case to in-progress correctly', async () => { @@ -174,43 +202,55 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: { - issueType: 'Task', - parent: null, - priority: 'High', + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "in-progress", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - id: 'mock-id-4', - external_service: null, - status: CaseStatuses['in-progress'], - tags: ['LOLBins'], - title: 'Another bad one', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + ] + `); }); test('it updates a case without a connector.id', async () => { @@ -229,39 +269,54 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - id: 'mock-no-connector_id', - comments: [], - totalComment: 0, - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'elastic', email: 'testemail@elastic.co', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.closed, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); test('it updates the connector correctly', async () => { @@ -285,47 +340,55 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - id: 'mock-id-3', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { - id: '456', - name: 'My connector 2', - type: ConnectorTypes.jira, - fields: { issueType: 'Bug', priority: 'Low', parent: null }, - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - external_service: null, - title: 'Another bad one', - status: CaseStatuses.open, - tags: ['LOLBins'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'Awesome D00d', - email: 'd00d@awesome.com', - username: 'awesome', - }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Bug", + "parent": null, + "priority": "Low", + }, + "id": "456", + "name": "My connector 2", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); test('it updates alert status when the status is updated and syncAlerts=true', async () => { @@ -341,20 +404,29 @@ describe('update', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, - caseCommentSavedObject: [{ ...mockCaseComments[3] }], + caseCommentSavedObject: [ + { + ...mockCaseComments[3], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-1', + }, + ], + }, + ], }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-id'], status: 'closed', + indices: new Set(['test-index']), }); }); @@ -382,10 +454,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -414,14 +483,12 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-id'], status: 'open', + indices: new Set(['test-index']), }); }); @@ -444,10 +511,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -478,25 +542,50 @@ describe('update', () => { ...mockCases[1], }, ], - caseCommentSavedObject: [{ ...mockCaseComments[3] }, { ...mockCaseComments[4] }], + caseCommentSavedObject: [ + { + ...mockCaseComments[3], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-1', + }, + ], + }, + { + ...mockCaseComments[4], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-2', + }, + ], + }, + ], }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - + await caseClient.client.update(patchCases); + /** + * the update code will put each comment into a status bucket and then make at most 1 call + * to ES for each status bucket + * Now instead of doing a call per case to get the comments, it will do a single call with all the cases + * and sub cases and get all the comments in one go + */ expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(1, { - ids: ['test-id', 'test-id-2'], + ids: ['test-id'], status: 'open', + indices: new Set(['test-index']), }); expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(2, { - ids: ['test-id', 'test-id-2'], + ids: ['test-id-2'], status: 'closed', + indices: new Set(['test-index-2']), }); }); @@ -518,10 +607,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -607,7 +693,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(406); @@ -637,7 +723,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(404); @@ -664,7 +750,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(409); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 99802ba47c8396..a4ca2b4cbdef98 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -10,18 +10,34 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObjectsFindResponse } from 'kibana/server'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsFindResult, +} from 'kibana/server'; +import { + AlertInfo, + flattenCaseSavedObject, + isCommentRequestTypeAlertOrGenAlert, +} from '../../routes/api/utils'; import { throwErrors, excess, CasesResponseRt, - CasesPatchRequestRt, ESCasePatchRequest, CasePatchRequest, CasesResponse, CaseStatuses, + CasesPatchRequestRt, + CommentType, + ESCaseAttributes, + CaseType, + CasesPatchRequest, + AssociationType, + CommentAttributes, + User, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { @@ -29,17 +45,296 @@ import { transformCaseConnectorToEsConnector, } from '../../routes/api/cases/helpers'; -import { CaseClientUpdate, CaseClientFactoryArguments } from '../types'; +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; +import { + CASE_COMMENT_SAVED_OBJECT, + CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; +import { CaseClientHandler } from '..'; +import { addAlertInfoToStatusMap } from '../../common'; + +/** + * Throws an error if any of the requests attempt to update a collection style cases' status field. + */ +function throwIfUpdateStatusOfCollection( + requests: ESCasePatchRequest[], + casesMap: Map> +) { + const requestsUpdatingStatusOfCollection = requests.filter( + (req) => + req.status !== undefined && casesMap.get(req.id)?.attributes.type === CaseType.collection + ); + + if (requestsUpdatingStatusOfCollection.length > 0) { + const ids = requestsUpdatingStatusOfCollection.map((req) => req.id); + throw Boom.badRequest( + `Updating the status of a collection is not allowed ids: [${ids.join(', ')}]` + ); + } +} + +/** + * Throws an error if any of the requests attempt to update a collection style case to an individual one. + */ +function throwIfUpdateTypeCollectionToIndividual( + requests: ESCasePatchRequest[], + casesMap: Map> +) { + const requestsUpdatingTypeCollectionToInd = requests.filter( + (req) => + req.type === CaseType.individual && + casesMap.get(req.id)?.attributes.type === CaseType.collection + ); + + if (requestsUpdatingTypeCollectionToInd.length > 0) { + const ids = requestsUpdatingTypeCollectionToInd.map((req) => req.id); + throw Boom.badRequest( + `Converting a collection to an individual case is not allowed ids: [${ids.join(', ')}]` + ); + } +} + +/** + * Throws an error if any of the requests attempt to update an individual style cases' type field to a collection + * when alerts are attached to the case. + */ +async function throwIfInvalidUpdateOfTypeWithAlerts({ + requests, + caseService, + client, +}: { + requests: ESCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}) { + const getAlertsForID = async (caseToUpdate: ESCasePatchRequest) => { + const alerts = await caseService.getAllCaseComments({ + client, + id: caseToUpdate.id, + options: { + fields: [], + // there should never be generated alerts attached to an individual case but we'll check anyway + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + page: 1, + perPage: 1, + }, + }); + + return { id: caseToUpdate.id, alerts }; + }; + + const requestsUpdatingTypeField = requests.filter((req) => req.type === CaseType.collection); + const casesAlertTotals = await Promise.all( + requestsUpdatingTypeField.map((caseToUpdate) => getAlertsForID(caseToUpdate)) + ); + + // grab the cases that have at least one alert comment attached to them + const typeUpdateWithAlerts = casesAlertTotals.filter((caseInfo) => caseInfo.alerts.total > 0); + + if (typeUpdateWithAlerts.length > 0) { + const ids = typeUpdateWithAlerts.map((req) => req.id); + throw Boom.badRequest( + `Converting a case to a collection is not allowed when it has alert comments, ids: [${ids.join( + ', ' + )}]` + ); + } +} + +/** + * Get the id from a reference in a comment for a specific type. + */ +function getID( + comment: SavedObject, + type: typeof CASE_SAVED_OBJECT | typeof SUB_CASE_SAVED_OBJECT +): string | undefined { + return comment.references.find((ref) => ref.type === type)?.id; +} + +/** + * Gets all the alert comments (generated or user alerts) for the requested cases. + */ +async function getAlertComments({ + casesToSync, + caseService, + client, +}: { + casesToSync: ESCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const idsOfCasesToSync = casesToSync.map((casePatchReq) => casePatchReq.id); + + // getAllCaseComments will by default get all the comments, unless page or perPage fields are set + return caseService.getAllCaseComments({ + client, + id: idsOfCasesToSync, + includeSubCaseComments: true, + options: { + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + }, + }); +} + +/** + * Returns a map of sub case IDs to their status. This uses a group of alert comments to determine which sub cases should + * be retrieved. This is based on whether the comment is associated to a sub case. + */ +async function getSubCasesToStatus({ + totalAlerts, + caseService, + client, +}: { + totalAlerts: SavedObjectsFindResponse; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const subCasesToRetrieve = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if ( + isCommentRequestTypeAlertOrGenAlert(alertComment.attributes) && + alertComment.attributes.associationType === AssociationType.subCase + ) { + const id = getID(alertComment, SUB_CASE_SAVED_OBJECT); + if (id !== undefined) { + acc.add(id); + } + } + return acc; + }, new Set()); + + const subCases = await caseService.getSubCases({ + ids: Array.from(subCasesToRetrieve.values()), + client, + }); + + return subCases.saved_objects.reduce((acc, subCase) => { + // log about the sub cases that we couldn't find + if (!subCase.error) { + acc.set(subCase.id, subCase.attributes.status); + } + return acc; + }, new Map()); +} + +/** + * Returns what status the alert comment should have based on whether it is associated to a case or sub case. + */ +function getSyncStatusForComment({ + alertComment, + casesToSyncToStatus, + subCasesToStatus, +}: { + alertComment: SavedObjectsFindResult; + casesToSyncToStatus: Map; + subCasesToStatus: Map; +}): CaseStatuses { + let status: CaseStatuses = CaseStatuses.open; + if (alertComment.attributes.associationType === AssociationType.case) { + const id = getID(alertComment, CASE_SAVED_OBJECT); + // We should log if we can't find the status + // attempt to get the case status from our cases to sync map if we found the ID otherwise default to open + status = + id !== undefined ? casesToSyncToStatus.get(id) ?? CaseStatuses.open : CaseStatuses.open; + } else if (alertComment.attributes.associationType === AssociationType.subCase) { + const id = getID(alertComment, SUB_CASE_SAVED_OBJECT); + status = id !== undefined ? subCasesToStatus.get(id) ?? CaseStatuses.open : CaseStatuses.open; + } + return status; +} + +/** + * Updates the alert ID's status field based on the patch requests + */ +async function updateAlerts({ + casesWithSyncSettingChangedToOn, + casesWithStatusChangedAndSynced, + casesMap, + caseService, + client, + caseClient, +}: { + casesWithSyncSettingChangedToOn: ESCasePatchRequest[]; + casesWithStatusChangedAndSynced: ESCasePatchRequest[]; + casesMap: Map>; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseClient: CaseClientHandler; +}) { + /** + * It's possible that a case ID can appear multiple times in each array. I'm intentionally placing the status changes + * last so when the map is built we will use the last status change as the source of truth. + */ + const casesToSync = [...casesWithSyncSettingChangedToOn, ...casesWithStatusChangedAndSynced]; + + // build a map of case id to the status it has + // this will have collections in it but the alerts should be associated to sub cases and not collections so it shouldn't + // matter. + const casesToSyncToStatus = casesToSync.reduce((acc, caseInfo) => { + acc.set( + caseInfo.id, + caseInfo.status ?? casesMap.get(caseInfo.id)?.attributes.status ?? CaseStatuses.open + ); + return acc; + }, new Map()); + + // get all the alerts for all the alert comments for all cases and collections. Collections themselves won't have any + // but their sub cases could + const totalAlerts = await getAlertComments({ + casesToSync, + caseService, + client, + }); + + // get a map of sub case id to the sub case status + const subCasesToStatus = await getSubCasesToStatus({ totalAlerts, client, caseService }); + + // create a map of the case statuses to the alert information that we need to update for that status + // This allows us to make at most 3 calls to ES, one for each status type that we need to update + // One potential improvement here is to do a tick (set timeout) to reduce the memory footprint if that becomes an issue + const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { + const status = getSyncStatusForComment({ + alertComment, + casesToSyncToStatus, + subCasesToStatus, + }); + + addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); + } -export const update = ({ + return acc; + }, new Map()); + + // This does at most 3 calls to Elasticsearch to update the status of the alerts to either open, closed, or in-progress + for (const [status, alertInfo] of alertsToUpdate.entries()) { + if (alertInfo.ids.length > 0 && alertInfo.indices.size > 0) { + caseClient.updateAlertsStatus({ + ids: alertInfo.ids, + status, + indices: alertInfo.indices, + }); + } + } +} + +interface UpdateArgs { + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + user: User; + caseClient: CaseClientHandler; + cases: CasesPatchRequest; +} + +export const update = async ({ savedObjectsClient, caseService, userActionService, - request, -}: CaseClientFactoryArguments) => async ({ + user, caseClient, cases, -}: CaseClientUpdate): Promise => { +}: UpdateArgs): Promise => { const query = pipe( excess(CasesPatchRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) @@ -95,133 +390,119 @@ export const update = ({ return Object.keys(updateCaseAttributes).length > 0; }); - if (updateFilterCases.length > 0) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const updatedDt = new Date().toISOString(); - const updatedCases = await caseService.patchCases({ - client: savedObjectsClient, - cases: updateFilterCases.map((thisCase) => { - const { id: caseId, version, ...updateCaseAttributes } = thisCase; - let closedInfo = {}; - if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { - closedInfo = { - closed_at: updatedDt, - closed_by: { email, full_name, username }, - }; - } else if ( - updateCaseAttributes.status && - (updateCaseAttributes.status === CaseStatuses.open || - updateCaseAttributes.status === CaseStatuses['in-progress']) - ) { - closedInfo = { - closed_at: null, - closed_by: null, - }; - } - return { - caseId, - updatedAttributes: { - ...updateCaseAttributes, - ...closedInfo, - updated_at: updatedDt, - updated_by: { email, full_name, username }, - }, - version, - }; - }), - }); + if (updateFilterCases.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } - // If a status update occurred and the case is synced then we need to update all alerts' status - // attached to the case to the new status. - const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); - return ( - currentCase != null && - caseToUpdate.status != null && - currentCase.attributes.status !== caseToUpdate.status && - currentCase.attributes.settings.syncAlerts - ); - }); + const casesMap = myCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); - // If syncAlerts setting turned on we need to update all alerts' status - // attached to the case to the current status. - const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); - return ( - currentCase != null && - caseToUpdate.settings?.syncAlerts != null && - currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && - caseToUpdate.settings.syncAlerts - ); - }); + throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); + throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); + await throwIfInvalidUpdateOfTypeWithAlerts({ + requests: updateFilterCases, + caseService, + client: savedObjectsClient, + }); - for (const theCase of [ - ...casesWithSyncSettingChangedToOn, - ...casesWithStatusChangedAndSynced, - ]) { - const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); - const totalComments = await caseService.getAllCaseComments({ - client: savedObjectsClient, - caseId: theCase.id, - options: { - fields: [], - filter: 'cases-comments.attributes.type: alert', - page: 1, - perPage: 1, + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const updatedDt = new Date().toISOString(); + const updatedCases = await caseService.patchCases({ + client: savedObjectsClient, + cases: updateFilterCases.map((thisCase) => { + const { id: caseId, version, ...updateCaseAttributes } = thisCase; + let closedInfo = {}; + if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { + closedInfo = { + closed_at: updatedDt, + closed_by: { email, full_name, username }, + }; + } else if ( + updateCaseAttributes.status && + (updateCaseAttributes.status === CaseStatuses.open || + updateCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + caseId, + updatedAttributes: { + ...updateCaseAttributes, + ...closedInfo, + updated_at: updatedDt, + updated_by: { email, full_name, username }, }, - }); + version, + }; + }), + }); - const caseComments = (await caseService.getAllCaseComments({ - client: savedObjectsClient, - caseId: theCase.id, - options: { - fields: [], - filter: 'cases-comments.attributes.type: alert', - page: 1, - perPage: totalComments.total, - }, - // The filter guarantees that the comments will be of type alert - })) as SavedObjectsFindResponse<{ alertId: string }>; - - const commentIds = caseComments.saved_objects.map(({ attributes: { alertId } }) => alertId); - if (commentIds.length > 0) { - caseClient.updateAlertsStatus({ - ids: commentIds, - // Either there is a status update or the syncAlerts got turned on. - status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, - }); - } - } + // If a status update occurred and the case is synced then we need to update all alerts' status + // attached to the case to the new status. + const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.status != null && + currentCase.attributes.status !== caseToUpdate.status && + currentCase.attributes.settings.syncAlerts + ); + }); - const returnUpdatedCase = myCases.saved_objects - .filter((myCase) => - updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) - ) - .map((myCase) => { - const updatedCase = updatedCases.saved_objects.find((c) => c.id === myCase.id); - return flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - version: updatedCase?.version ?? myCase.version, - }, - }); - }); + // If syncAlerts setting turned on we need to update all alerts' status + // attached to the case to the current status. + const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.settings?.syncAlerts != null && + currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && + caseToUpdate.settings.syncAlerts + ); + }); + + // Update the alert's status to match any case status or sync settings changes + await updateAlerts({ + casesWithStatusChangedAndSynced, + casesWithSyncSettingChangedToOn, + caseService, + client: savedObjectsClient, + caseClient, + casesMap, + }); - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: buildCaseUserActions({ - originalCases: myCases.saved_objects, - updatedCases: updatedCases.saved_objects, - actionDate: updatedDt, - actionBy: { email, full_name, username }, - }), + const returnUpdatedCase = myCases.saved_objects + .filter((myCase) => + updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) + ) + .map((myCase) => { + const updatedCase = updatedCases.saved_objects.find((c) => c.id === myCase.id); + return flattenCaseSavedObject({ + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + version: updatedCase?.version ?? myCase.version, + }, + }); }); - return CasesResponseRt.encode(returnUpdatedCase); - } - throw Boom.notAcceptable('All update fields are identical to current version.'); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: buildCaseUserActions({ + originalCases: myCases.saved_objects, + updatedCases: updatedCases.saved_objects, + actionDate: updatedDt, + actionBy: { email, full_name, username }, + }), + }); + + return CasesResponseRt.encode(returnUpdatedCase); }; diff --git a/x-pack/plugins/case/server/client/cases/utils.test.ts b/x-pack/plugins/case/server/client/cases/utils.test.ts index dca2c34602678b..361d0fb561afd7 100644 --- a/x-pack/plugins/case/server/client/cases/utils.test.ts +++ b/x-pack/plugins/case/server/client/cases/utils.test.ts @@ -537,12 +537,12 @@ describe('utils', () => { }, { comment: - 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', commentId: 'comment-alert-1', }, { comment: - 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', commentId: 'comment-alert-2', }, ]); diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index 6974fd4ffa2883..78bdc6d282c698 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -38,6 +38,7 @@ import { TransformerArgs, TransformFieldsArgs, } from './types'; +import { getAlertIds } from '../../routes/api/utils'; export const getLatestPushInfo = ( connectorId: string, @@ -66,8 +67,9 @@ const isConnectorSupported = (connectorId: string): connectorId is FormatterConn const getCommentContent = (comment: CommentResponse): string => { if (comment.type === CommentType.user) { return comment.comment; - } else if (comment.type === CommentType.alert) { - return `Alert with id ${comment.alertId} added to case`; + } else if (comment.type === CommentType.alert || comment.type === CommentType.generatedAlert) { + const ids = getAlertIds(comment); + return `Alert with ids ${ids.join(', ')} added to case`; } return ''; @@ -306,9 +308,10 @@ export const getCommentContextFromAttributes = ( type: CommentType.user, comment: attributes.comment, }; + case CommentType.generatedAlert: case CommentType.alert: return { - type: CommentType.alert, + type: attributes.type, alertId: attributes.alertId, index: attributes.index, }; diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts new file mode 100644 index 00000000000000..c684548decbe6f --- /dev/null +++ b/x-pack/plugins/case/server/client/client.ts @@ -0,0 +1,154 @@ +/* + * 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 { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { + CaseClientFactoryArguments, + CaseClient, + ConfigureFields, + MappingsClient, + CaseClientUpdateAlertsStatus, + CaseClientAddComment, + CaseClientGet, + CaseClientGetUserActions, + CaseClientGetAlerts, + CaseClientPush, +} from './types'; +import { create } from './cases/create'; +import { update } from './cases/update'; +import { addComment } from './comments/add'; +import { getFields } from './configure/get_fields'; +import { getMappings } from './configure/get_mappings'; +import { updateAlertsStatus } from './alerts/update_status'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + ConnectorMappingsServiceSetup, + CaseUserActionServiceSetup, + AlertServiceContract, +} from '../services'; +import { CasesPatchRequest, CasePostRequest, User } from '../../common/api'; +import { get } from './cases/get'; +import { get as getUserActions } from './user_actions/get'; +import { get as getAlerts } from './alerts/get'; +import { push } from './cases/push'; + +/** + * This class is a pass through for common case functionality (like creating, get a case). + */ +export class CaseClientHandler implements CaseClient { + private readonly _scopedClusterClient: ElasticsearchClient; + private readonly _caseConfigureService: CaseConfigureServiceSetup; + private readonly _caseService: CaseServiceSetup; + private readonly _connectorMappingsService: ConnectorMappingsServiceSetup; + private readonly user: User; + private readonly _savedObjectsClient: SavedObjectsClientContract; + private readonly _userActionService: CaseUserActionServiceSetup; + private readonly _alertsService: AlertServiceContract; + + constructor(clientArgs: CaseClientFactoryArguments) { + this._scopedClusterClient = clientArgs.scopedClusterClient; + this._caseConfigureService = clientArgs.caseConfigureService; + this._caseService = clientArgs.caseService; + this._connectorMappingsService = clientArgs.connectorMappingsService; + this.user = clientArgs.user; + this._savedObjectsClient = clientArgs.savedObjectsClient; + this._userActionService = clientArgs.userActionService; + this._alertsService = clientArgs.alertsService; + } + + public async create(caseInfo: CasePostRequest) { + return create({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + caseConfigureService: this._caseConfigureService, + userActionService: this._userActionService, + user: this.user, + theCase: caseInfo, + }); + } + + public async update(cases: CasesPatchRequest) { + return update({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + user: this.user, + cases, + caseClient: this, + }); + } + + public async addComment({ caseId, comment }: CaseClientAddComment) { + return addComment({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + caseClient: this, + caseId, + comment, + user: this.user, + }); + } + + public async getFields(fields: ConfigureFields) { + return getFields(fields); + } + + public async getMappings(args: MappingsClient) { + return getMappings({ + ...args, + savedObjectsClient: this._savedObjectsClient, + connectorMappingsService: this._connectorMappingsService, + caseClient: this, + }); + } + + public async updateAlertsStatus(args: CaseClientUpdateAlertsStatus) { + return updateAlertsStatus({ + ...args, + alertsService: this._alertsService, + scopedClusterClient: this._scopedClusterClient, + }); + } + + public async get(args: CaseClientGet) { + return get({ + ...args, + caseService: this._caseService, + savedObjectsClient: this._savedObjectsClient, + }); + } + + public async getUserActions(args: CaseClientGetUserActions) { + return getUserActions({ + ...args, + savedObjectsClient: this._savedObjectsClient, + userActionService: this._userActionService, + }); + } + + public async getAlerts(args: CaseClientGetAlerts) { + return getAlerts({ + ...args, + alertsService: this._alertsService, + scopedClusterClient: this._scopedClusterClient, + }); + } + + public async push(args: CaseClientPush) { + return push({ + ...args, + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + user: this.user, + caseClient: this, + caseConfigureService: this._caseConfigureService, + }); + } +} diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index 746176284a2928..315203a1f5e1d9 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -32,7 +32,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -42,22 +41,25 @@ describe('addComment', () => { expect(res.id).toEqual('mock-id-1'); expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it adds a comment of type alert correctly', async () => { @@ -68,7 +70,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -79,23 +80,26 @@ describe('addComment', () => { expect(res.id).toEqual('mock-id-1'); expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toEqual({ - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "alertId": "test-id", + "associationType": "case", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "id": "mock-comment", + "index": "test-index", + "pushed_at": null, + "pushed_by": null, + "type": "alert", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it updates the case correctly after adding a comment', async () => { @@ -106,7 +110,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -130,7 +133,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -181,7 +183,6 @@ describe('addComment', () => { badAuth: true, }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -190,22 +191,25 @@ describe('addComment', () => { }); expect(res.id).toEqual('mock-id-1'); - expect(res.comments![res.comments!.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it update the status of the alert if the case is synced with alerts', async () => { @@ -222,7 +226,6 @@ describe('addComment', () => { caseClient.client.updateAlertsStatus = jest.fn(); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -234,6 +237,7 @@ describe('addComment', () => { expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-alert'], status: 'open', + indices: new Set(['test-index']), }); }); @@ -256,7 +260,6 @@ describe('addComment', () => { caseClient.client.updateAlertsStatus = jest.fn(); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -336,7 +339,6 @@ describe('addComment', () => { ['alertId', 'index'].forEach((attribute) => { caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -398,7 +400,6 @@ describe('addComment', () => { ['comment'].forEach((attribute) => { caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -425,7 +426,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'not-exists', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -449,7 +449,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Throw an error', @@ -474,7 +473,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-4', comment: { type: CommentType.alert, diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 58d7c9abcbfd3a..7dd1b4a8f6c5cd 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -10,135 +10,297 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { decodeComment, flattenCaseSavedObject, transformNewComment } from '../../routes/api/utils'; +import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; +import { + decodeCommentRequest, + getAlertIds, + isCommentRequestTypeGenAlert, +} from '../../routes/api/utils'; import { throwErrors, - CaseResponseRt, CommentRequestRt, - CaseResponse, CommentType, CaseStatuses, + CaseType, + SubCaseAttributes, + CommentRequest, + CollectionWithSubCaseResponse, + User, + CommentRequestAlertType, + AlertCommentRequestRt, } from '../../../common/api'; -import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; +import { + buildCaseUserActionItem, + buildCommentUserActionItem, +} from '../../services/user_actions/helpers'; + +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; +import { CommentableCase } from '../../common'; +import { CaseClientHandler } from '..'; + +async function getSubCase({ + caseService, + savedObjectsClient, + caseId, + createdAt, + userActionService, + user, +}: { + caseService: CaseServiceSetup; + savedObjectsClient: SavedObjectsClientContract; + caseId: string; + createdAt: string; + userActionService: CaseUserActionServiceSetup; + user: User; +}): Promise> { + const mostRecentSubCase = await caseService.getMostRecentSubCase(savedObjectsClient, caseId); + if (mostRecentSubCase && mostRecentSubCase.attributes.status !== CaseStatuses.closed) { + return mostRecentSubCase; + } -import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; -import { CASE_SAVED_OBJECT } from '../../saved_object_types'; + const newSubCase = await caseService.createSubCase({ + client: savedObjectsClient, + createdAt, + caseId, + createdBy: user, + }); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCaseUserActionItem({ + action: 'create', + actionAt: createdAt, + actionBy: user, + caseId, + subCaseId: newSubCase.id, + fields: ['status', 'sub_case'], + newValue: JSON.stringify({ status: newSubCase.attributes.status }), + }), + ], + }); + return newSubCase; +} + +interface AddCommentFromRuleArgs { + caseClient: CaseClientHandler; + caseId: string; + comment: CommentRequestAlertType; + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; +} -export const addComment = ({ +const addGeneratedAlerts = async ({ savedObjectsClient, caseService, userActionService, - request, -}: CaseClientFactoryArguments) => async ({ caseClient, caseId, comment, -}: CaseClientAddComment): Promise => { +}: AddCommentFromRuleArgs): Promise => { const query = pipe( - CommentRequestRt.decode(comment), + AlertCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); - decodeComment(comment); + decodeCommentRequest(comment); + + // This function only supports adding generated alerts + if (comment.type !== CommentType.generatedAlert) { + throw Boom.internal('Attempting to add a non generated alert in the wrong context'); + } + const createdDate = new Date().toISOString(); - const myCase = await caseService.getCase({ + const caseInfo = await caseService.getCase({ client: savedObjectsClient, - caseId, + id: caseId, }); - // An alert cannot be attach to a closed case. - if (query.type === CommentType.alert && myCase.attributes.status === CaseStatuses.closed) { - throw Boom.badRequest('Alert cannot be attached to a closed case'); + if ( + query.type === CommentType.generatedAlert && + caseInfo.attributes.type !== CaseType.collection + ) { + throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); } - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const createdDate = new Date().toISOString(); + const userDetails: User = { + username: caseInfo.attributes.created_by?.username, + full_name: caseInfo.attributes.created_by?.full_name, + email: caseInfo.attributes.created_by?.email, + }; - const [newComment, updatedCase] = await Promise.all([ - caseService.postNewComment({ - client: savedObjectsClient, - attributes: transformNewComment({ - createdDate, - ...query, - username, - full_name, - email, - }), - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: myCase.id, - }, - ], - }), - caseService.patchCase({ - client: savedObjectsClient, - caseId, - updatedAttributes: { - updated_at: createdDate, - updated_by: { username, full_name, email }, - }, - version: myCase.version, - }), - ]); + const subCase = await getSubCase({ + caseService, + savedObjectsClient, + caseId, + createdAt: createdDate, + userActionService, + user: userDetails, + }); + + const commentableCase = new CommentableCase({ + collection: caseInfo, + subCase, + soClient: savedObjectsClient, + service: caseService, + }); - // If the case is synced with alerts the newly attached alert must match the status of the case. - if (newComment.attributes.type === CommentType.alert && myCase.attributes.settings.syncAlerts) { + const { + comment: newComment, + commentableCase: updatedCase, + } = await commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }); + + if ( + (newComment.attributes.type === CommentType.alert || + newComment.attributes.type === CommentType.generatedAlert) && + caseInfo.attributes.settings.syncAlerts + ) { + const ids = getAlertIds(query); await caseClient.updateAlertsStatus({ - ids: [newComment.attributes.alertId], - status: myCase.attributes.status, + ids, + status: subCase.attributes.status, + indices: new Set([newComment.attributes.index]), }); } - const totalCommentsFindByCases = await caseService.getAllCaseComments({ + await userActionService.postUserActions({ client: savedObjectsClient, - caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { ...userDetails }, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], }); - const [comments] = await Promise.all([ - caseService.getAllCaseComments({ - client: savedObjectsClient, - caseId, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindByCases.total, - }, + return updatedCase.encode(); +}; + +async function getCombinedCase( + service: CaseServiceSetup, + client: SavedObjectsClientContract, + id: string +): Promise { + const [casePromise, subCasePromise] = await Promise.allSettled([ + service.getCase({ + client, + id, }), - userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCommentUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { username, full_name, email }, - caseId: myCase.id, - commentId: newComment.id, - fields: ['comment'], - newValue: JSON.stringify(query), - }), - ], + service.getSubCase({ + client, + id, }), ]); - return CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase.attributes }, - version: updatedCase.version ?? myCase.version, - references: myCase.references, - }, - comments: comments.saved_objects, - }) + if (subCasePromise.status === 'fulfilled') { + if (subCasePromise.value.references.length > 0) { + const caseValue = await service.getCase({ + client, + id: subCasePromise.value.references[0].id, + }); + return new CommentableCase({ + collection: caseValue, + subCase: subCasePromise.value, + service, + soClient: client, + }); + } else { + throw Boom.badRequest('Sub case found without reference to collection'); + } + } + + if (casePromise.status === 'rejected') { + throw casePromise.reason; + } else { + return new CommentableCase({ collection: casePromise.value, service, soClient: client }); + } +} + +interface AddCommentArgs { + caseClient: CaseClientHandler; + caseId: string; + comment: CommentRequest; + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + user: User; +} + +export const addComment = async ({ + savedObjectsClient, + caseService, + userActionService, + caseClient, + caseId, + comment, + user, +}: AddCommentArgs): Promise => { + const query = pipe( + CommentRequestRt.decode(comment), + fold(throwErrors(Boom.badRequest), identity) ); + + if (isCommentRequestTypeGenAlert(comment)) { + return addGeneratedAlerts({ + caseId, + comment, + caseClient, + savedObjectsClient, + userActionService, + caseService, + }); + } + + decodeCommentRequest(comment); + const createdDate = new Date().toISOString(); + + const combinedCase = await getCombinedCase(caseService, savedObjectsClient, caseId); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const userInfo: User = { + username, + full_name, + email, + }; + + const { comment: newComment, commentableCase: updatedCase } = await combinedCase.createComment({ + createdDate, + user: userInfo, + commentReq: query, + }); + + if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { + const ids = getAlertIds(query); + await caseClient.updateAlertsStatus({ + ids, + status: updatedCase.status, + indices: new Set([newComment.attributes.index]), + }); + } + + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], + }); + + return updatedCase.encode(); }; diff --git a/x-pack/plugins/case/server/client/configure/get_fields.ts b/x-pack/plugins/case/server/client/configure/get_fields.ts index a797e120b971bd..deabae33810b2e 100644 --- a/x-pack/plugins/case/server/client/configure/get_fields.ts +++ b/x-pack/plugins/case/server/client/configure/get_fields.ts @@ -11,7 +11,7 @@ import { GetFieldsResponse } from '../../../common/api'; import { ConfigureFields } from '../types'; import { createDefaultMapping, formatFields } from './utils'; -export const getFields = () => async ({ +export const getFields = async ({ actionsClient, connectorType, connectorId, diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.test.ts b/x-pack/plugins/case/server/client/configure/get_mappings.test.ts index 4ec9fa7e8e8c2d..d4dad182d815e5 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.test.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.test.ts @@ -31,7 +31,6 @@ describe('get_mappings', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getMappings({ actionsClient: actionsMock, - caseClient: caseClient.client, connectorType: ConnectorTypes.jira, connectorId: '123', }); @@ -45,7 +44,6 @@ describe('get_mappings', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getMappings({ actionsClient: actionsMock, - caseClient: caseClient.client, connectorType: ConnectorTypes.jira, connectorId: '123', }); diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.ts b/x-pack/plugins/case/server/client/configure/get_mappings.ts index a2d2711264b137..5dd90efd8a2d74 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.ts @@ -5,20 +5,31 @@ * 2.0. */ +import { SavedObjectsClientContract } from 'src/core/server'; +import { ActionsClient } from '../../../../actions/server'; import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; -import { CaseClientFactoryArguments, MappingsClient } from '../types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects'; +import { ConnectorMappingsServiceSetup } from '../../services'; +import { CaseClientHandler } from '..'; -export const getMappings = ({ +interface GetMappingsArgs { + savedObjectsClient: SavedObjectsClientContract; + connectorMappingsService: ConnectorMappingsServiceSetup; + actionsClient: ActionsClient; + caseClient: CaseClientHandler; + connectorType: string; + connectorId: string; +} + +export const getMappings = async ({ savedObjectsClient, connectorMappingsService, -}: CaseClientFactoryArguments) => async ({ actionsClient, caseClient, connectorType, connectorId, -}: MappingsClient): Promise => { +}: GetMappingsArgs): Promise => { if (connectorType === ConnectorTypes.none) { return []; } diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 4daa4d1c0bd8b4..8a085bf29f2147 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -5,9 +5,11 @@ * 2.0. */ -import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { createCaseClient } from '.'; +import { + elasticsearchServiceMock, + savedObjectsClientMock, +} from '../../../../../src/core/server/mocks'; +import { nullUser } from '../common'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -16,87 +18,30 @@ import { createAlertServiceMock, } from '../services/mocks'; -import { create } from './cases/create'; -import { get } from './cases/get'; -import { update } from './cases/update'; -import { push } from './cases/push'; -import { addComment } from './comments/add'; -import { getFields } from './configure/get_fields'; -import { getMappings } from './configure/get_mappings'; -import { updateAlertsStatus } from './alerts/update_status'; -import { get as getUserActions } from './user_actions/get'; -import { get as getAlerts } from './alerts/get'; -import type { CasesRequestHandlerContext } from '../types'; - -jest.mock('./cases/create'); -jest.mock('./cases/update'); -jest.mock('./cases/get'); -jest.mock('./cases/push'); -jest.mock('./comments/add'); -jest.mock('./alerts/update_status'); -jest.mock('./alerts/get'); -jest.mock('./user_actions/get'); -jest.mock('./configure/get_fields'); -jest.mock('./configure/get_mappings'); +jest.mock('./client'); +import { CaseClientHandler } from './client'; +import { createExternalCaseClient } from './index'; +const esClient = elasticsearchServiceMock.createElasticsearchClient(); const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); const caseService = createCaseServiceMock(); const connectorMappingsService = connectorMappingsServiceMock(); -const request = {} as KibanaRequest; -const response = kibanaResponseFactory; const savedObjectsClient = savedObjectsClientMock.create(); const userActionService = createUserActionServiceMock(); -const context = {} as CasesRequestHandlerContext; -const createMock = create as jest.Mock; -const getMock = get as jest.Mock; -const updateMock = update as jest.Mock; -const pushMock = push as jest.Mock; -const addCommentMock = addComment as jest.Mock; -const updateAlertsStatusMock = updateAlertsStatus as jest.Mock; -const getAlertsStatusMock = getAlerts as jest.Mock; -const getFieldsMock = getFields as jest.Mock; -const getMappingsMock = getMappings as jest.Mock; -const getUserActionsMock = getUserActions as jest.Mock; - -describe('createCaseClient()', () => { +describe('createExternalCaseClient()', () => { test('it creates the client correctly', async () => { - createCaseClient({ + createExternalCaseClient({ + scopedClusterClient: esClient, alertsService, caseConfigureService, caseService, connectorMappingsService, - context, - request, - response, + user: nullUser, savedObjectsClient, userActionService, }); - - [ - createMock, - getMock, - updateMock, - pushMock, - addCommentMock, - updateAlertsStatusMock, - getAlertsStatusMock, - getFieldsMock, - getMappingsMock, - getUserActionsMock, - ].forEach((method) => - expect(method).toHaveBeenCalledWith({ - caseConfigureService, - caseService, - connectorMappingsService, - request, - response, - savedObjectsClient, - userActionService, - alertsService, - context, - }) - ); + expect(CaseClientHandler).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index e15b9fc766562b..900b5a92ebf924 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -5,41 +5,16 @@ * 2.0. */ -import { - CaseClientFactoryArguments, - CaseClient, - CaseClientFactoryMethods, - CaseClientMethods, -} from './types'; -import { create } from './cases/create'; -import { get } from './cases/get'; -import { update } from './cases/update'; -import { push } from './cases/push'; -import { addComment } from './comments/add'; -import { getFields } from './configure/get_fields'; -import { getMappings } from './configure/get_mappings'; -import { updateAlertsStatus } from './alerts/update_status'; -import { get as getUserActions } from './user_actions/get'; -import { get as getAlerts } from './alerts/get'; +import { CaseClientFactoryArguments, CaseClient } from './types'; +import { CaseClientHandler } from './client'; +export { CaseClientHandler } from './client'; export { CaseClient } from './types'; -export const createCaseClient = (args: CaseClientFactoryArguments): CaseClient => { - const methods: CaseClientFactoryMethods = { - create, - get, - update, - push, - addComment, - getAlerts, - getFields, - getMappings, - getUserActions, - updateAlertsStatus, - }; - - return (Object.keys(methods) as CaseClientMethods[]).reduce((client, method) => { - client[method] = methods[method](args); - return client; - }, {} as CaseClient); +/** + * Create a CaseClientHandler to external services (other plugins). + */ +export const createExternalCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { + const client = new CaseClientHandler(clientArgs); + return client; }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index b2a07e36b3aed7..302745913babbd 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -5,10 +5,7 @@ * 2.0. */ -import { omit } from 'lodash/fp'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server/http'; -import { loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { AlertServiceContract, CaseConfigureService, @@ -17,12 +14,11 @@ import { ConnectorMappingsService, } from '../services'; import { CaseClient } from './types'; -import { authenticationMock, createActionsClient } from '../routes/api/__fixtures__'; -import { createCaseClient } from '.'; -import type { CasesRequestHandlerContext } from '../types'; +import { authenticationMock } from '../routes/api/__fixtures__'; +import { createExternalCaseClient } from '.'; -export type CaseClientMock = jest.Mocked; -export const createCaseClientMock = (): CaseClientMock => ({ +export type CaseClientPluginContractMock = jest.Mocked; +export const createExternalCaseClientMock = (): CaseClientPluginContractMock => ({ addComment: jest.fn(), create: jest.fn(), get: jest.fn(), @@ -50,18 +46,15 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ alertsService: jest.Mocked; }; }> => { - const actionsMock = createActionsClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + // const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); - const request = {} as KibanaRequest; - const response = kibanaResponseFactory; - const caseServicePlugin = new CaseService(log); + const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); + const caseService = new CaseService(log, auth); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const connectorMappingsService = await connectorMappingsServicePlugin.setup(); @@ -76,33 +69,15 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ getAlerts: jest.fn(), }; - const context = { - core: { - savedObjects: { - client: savedObjectsClient, - }, - }, - actions: { getActionsClient: () => actionsMock }, - case: { - getCaseClient: () => caseClient, - }, - securitySolution: { - getAppClient: () => ({ - getSignalsIndex: () => '.siem-signals', - }), - }, - }; - - const caseClient = createCaseClient({ + const caseClient = createExternalCaseClient({ savedObjectsClient, - request, - response, + user: auth.getCurrentUser(), caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - context: (omit(omitFromContext, context) as unknown) as CasesRequestHandlerContext, + scopedClusterClient: esClient, }); return { client: caseClient, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 8778aa46a2d244..a8f64227daf83f 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { KibanaRequest, KibanaResponseFactory, SavedObjectsClientContract } from 'kibana/server'; +import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CasePostRequest, @@ -13,10 +13,12 @@ import { CasesPatchRequest, CasesResponse, CaseStatuses, + CollectionWithSubCaseResponse, CommentRequest, ConnectorMappingsAttributes, GetFieldsResponse, CaseUserActionsResponse, + User, } from '../../common/api'; import { CaseConfigureServiceSetup, @@ -25,32 +27,21 @@ import { AlertServiceContract, } from '../services'; import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; -import type { CasesRequestHandlerContext } from '../types'; import { CaseClientGetAlertsResponse } from './alerts/types'; -export interface CaseClientCreate { - theCase: CasePostRequest; -} - -export interface CaseClientUpdate { - caseClient: CaseClient; - cases: CasesPatchRequest; -} - export interface CaseClientGet { id: string; includeComments?: boolean; + includeSubCaseComments?: boolean; } export interface CaseClientPush { actionsClient: ActionsClient; - caseClient: CaseClient; caseId: string; connectorId: string; } export interface CaseClientAddComment { - caseClient: CaseClient; caseId: string; comment: CommentRequest; } @@ -58,10 +49,12 @@ export interface CaseClientAddComment { export interface CaseClientUpdateAlertsStatus { ids: string[]; status: CaseStatuses; + indices: Set; } export interface CaseClientGetAlerts { ids: string[]; + indices: Set; } export interface CaseClientGetUserActions { @@ -70,21 +63,19 @@ export interface CaseClientGetUserActions { export interface MappingsClient { actionsClient: ActionsClient; - caseClient: CaseClient; connectorId: string; connectorType: string; } export interface CaseClientFactoryArguments { + scopedClusterClient: ElasticsearchClient; caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; connectorMappingsService: ConnectorMappingsServiceSetup; - request: KibanaRequest; - response: KibanaResponseFactory; + user: User; savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; - context?: Omit; } export interface ConfigureFields { @@ -92,25 +83,25 @@ export interface ConfigureFields { connectorId: string; connectorType: string; } + +/** + * This represents the interface that other plugins can access. + */ export interface CaseClient { - addComment: (args: CaseClientAddComment) => Promise; - create: (args: CaseClientCreate) => Promise; - get: (args: CaseClientGet) => Promise; - getAlerts: (args: CaseClientGetAlerts) => Promise; - getFields: (args: ConfigureFields) => Promise; - getMappings: (args: MappingsClient) => Promise; - getUserActions: (args: CaseClientGetUserActions) => Promise; - push: (args: CaseClientPush) => Promise; - update: (args: CaseClientUpdate) => Promise; - updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise; + addComment(args: CaseClientAddComment): Promise; + create(theCase: CasePostRequest): Promise; + get(args: CaseClientGet): Promise; + getAlerts(args: CaseClientGetAlerts): Promise; + getFields(args: ConfigureFields): Promise; + getMappings(args: MappingsClient): Promise; + getUserActions(args: CaseClientGetUserActions): Promise; + push(args: CaseClientPush): Promise; + update(args: CasesPatchRequest): Promise; + updateAlertsStatus(args: CaseClientUpdateAlertsStatus): Promise; } -export type CaseClientFactoryMethod = ( - factoryArgs: CaseClientFactoryArguments -) => (methodArgs: any) => Promise; - -export type CaseClientMethods = keyof CaseClient; - -export type CaseClientFactoryMethods = { - [K in CaseClientMethods]: CaseClientFactoryMethod; -}; +export interface MappingsClient { + actionsClient: ActionsClient; + connectorId: string; + connectorType: string; +} diff --git a/x-pack/plugins/case/server/client/user_actions/get.ts b/x-pack/plugins/case/server/client/user_actions/get.ts index e83a9e34842625..8a4e45f71b9ca3 100644 --- a/x-pack/plugins/case/server/client/user_actions/get.ts +++ b/x-pack/plugins/case/server/client/user_actions/get.ts @@ -5,16 +5,22 @@ * 2.0. */ +import { SavedObjectsClientContract } from 'kibana/server'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api'; -import { CaseClientGetUserActions, CaseClientFactoryArguments } from '../types'; +import { CaseUserActionServiceSetup } from '../../services'; -export const get = ({ +interface GetParams { + savedObjectsClient: SavedObjectsClientContract; + userActionService: CaseUserActionServiceSetup; + caseId: string; +} + +export const get = async ({ savedObjectsClient, userActionService, -}: CaseClientFactoryArguments) => async ({ caseId, -}: CaseClientGetUserActions): Promise => { +}: GetParams): Promise => { const userActions = await userActionService.getUserActions({ client: savedObjectsClient, caseId, diff --git a/x-pack/plugins/case/server/common/index.ts b/x-pack/plugins/case/server/common/index.ts new file mode 100644 index 00000000000000..0960b28b3d25ac --- /dev/null +++ b/x-pack/plugins/case/server/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './models'; +export * from './utils'; diff --git a/x-pack/plugins/case/server/common/models/commentable_case.ts b/x-pack/plugins/case/server/common/models/commentable_case.ts new file mode 100644 index 00000000000000..9827118ee8e298 --- /dev/null +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -0,0 +1,300 @@ +/* + * 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 Boom from '@hapi/boom'; + +import { + SavedObject, + SavedObjectReference, + SavedObjectsClientContract, + SavedObjectsUpdateResponse, +} from 'src/core/server'; +import { + AssociationType, + CaseSettings, + CaseStatuses, + CaseType, + CollectionWithSubCaseResponse, + CollectWithSubCaseResponseRt, + CommentAttributes, + CommentPatchRequest, + CommentRequest, + CommentType, + ESCaseAttributes, + SubCaseAttributes, + User, +} from '../../../common/api'; +import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; +import { + flattenCommentSavedObjects, + flattenSubCaseSavedObject, + transformNewComment, +} from '../../routes/api/utils'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { CaseServiceSetup } from '../../services'; +import { countAlertsForID } from '../index'; + +interface UpdateCommentResp { + comment: SavedObjectsUpdateResponse; + commentableCase: CommentableCase; +} + +interface NewCommentResp { + comment: SavedObject; + commentableCase: CommentableCase; +} + +interface CommentableCaseParams { + collection: SavedObject; + subCase?: SavedObject; + soClient: SavedObjectsClientContract; + service: CaseServiceSetup; +} + +/** + * This class represents a case that can have a comment attached to it. This includes + * a Sub Case, Case, and Collection. + */ +export class CommentableCase { + private readonly collection: SavedObject; + private readonly subCase?: SavedObject; + private readonly soClient: SavedObjectsClientContract; + private readonly service: CaseServiceSetup; + constructor({ collection, subCase, soClient, service }: CommentableCaseParams) { + this.collection = collection; + this.subCase = subCase; + this.soClient = soClient; + this.service = service; + } + + public get status(): CaseStatuses { + return this.subCase?.attributes.status ?? this.collection.attributes.status; + } + + /** + * This property is used to abstract away which element is actually being acted upon in this class. + * If the sub case was initialized then it will be the focus of creating comments. So if you want the id + * of the saved object that the comment is primarily being attached to use this property. + * + * This is a little confusing because the created comment will have references to both the sub case and the + * collection but from the UI's perspective only the sub case really has the comment attached to it. + */ + public get id(): string { + return this.subCase?.id ?? this.collection.id; + } + + public get settings(): CaseSettings { + return this.collection.attributes.settings; + } + + /** + * These functions break the abstraction of this class but they are needed to build the comment user action item. + * Another potential solution would be to implement another function that handles creating the user action in this + * class so that we don't need to expose these properties. + */ + public get caseId(): string { + return this.collection.id; + } + + public get subCaseId(): string | undefined { + return this.subCase?.id; + } + + private buildRefsToCase(): SavedObjectReference[] { + const subCaseSOType = SUB_CASE_SAVED_OBJECT; + const caseSOType = CASE_SAVED_OBJECT; + return [ + { + type: caseSOType, + name: `associated-${caseSOType}`, + id: this.collection.id, + }, + ...(this.subCase + ? [{ type: subCaseSOType, name: `associated-${subCaseSOType}`, id: this.subCase.id }] + : []), + ]; + } + + private async update({ date, user }: { date: string; user: User }): Promise { + let updatedSubCaseAttributes: SavedObject | undefined; + + if (this.subCase) { + const updatedSubCase = await this.service.patchSubCase({ + client: this.soClient, + subCaseId: this.subCase.id, + updatedAttributes: { + updated_at: date, + updated_by: { + ...user, + }, + }, + version: this.subCase.version, + }); + + updatedSubCaseAttributes = { + ...this.subCase, + attributes: { + ...this.subCase.attributes, + ...updatedSubCase.attributes, + }, + version: updatedSubCase.version ?? this.subCase.version, + }; + } + + const updatedCase = await this.service.patchCase({ + client: this.soClient, + caseId: this.collection.id, + updatedAttributes: { + updated_at: date, + updated_by: { ...user }, + }, + version: this.collection.version, + }); + + // this will contain the updated sub case information if the sub case was defined initially + return new CommentableCase({ + collection: { + ...this.collection, + attributes: { + ...this.collection.attributes, + ...updatedCase.attributes, + }, + version: updatedCase.version ?? this.collection.version, + }, + subCase: updatedSubCaseAttributes, + soClient: this.soClient, + service: this.service, + }); + } + + /** + * Update a comment and update the corresponding case's update_at and updated_by fields. + */ + public async updateComment({ + updateRequest, + updatedAt, + user, + }: { + updateRequest: CommentPatchRequest; + updatedAt: string; + user: User; + }): Promise { + const { id, version, ...queryRestAttributes } = updateRequest; + + const [comment, commentableCase] = await Promise.all([ + this.service.patchComment({ + client: this.soClient, + commentId: id, + updatedAttributes: { + ...queryRestAttributes, + updated_at: updatedAt, + updated_by: user, + }, + version, + }), + this.update({ date: updatedAt, user }), + ]); + return { + comment, + commentableCase, + }; + } + + /** + * Create a new comment on the appropriate case. This updates the case's updated_at and updated_by fields. + */ + public async createComment({ + createdDate, + user, + commentReq, + }: { + createdDate: string; + user: User; + commentReq: CommentRequest; + }): Promise { + if (commentReq.type === CommentType.alert) { + if (this.status === CaseStatuses.closed) { + throw Boom.badRequest('Alert cannot be attached to a closed case'); + } + + if (!this.subCase && this.collection.attributes.type === CaseType.collection) { + throw Boom.badRequest('Alert cannot be attached to a collection case'); + } + } + + const [comment, commentableCase] = await Promise.all([ + this.service.postNewComment({ + client: this.soClient, + attributes: transformNewComment({ + associationType: this.subCase ? AssociationType.subCase : AssociationType.case, + createdDate, + ...commentReq, + ...user, + }), + references: this.buildRefsToCase(), + }), + this.update({ date: createdDate, user }), + ]); + return { + comment, + commentableCase, + }; + } + + private formatCollectionForEncoding(totalComment: number) { + return { + id: this.collection.id, + version: this.collection.version ?? '0', + totalComment, + ...this.collection.attributes, + connector: transformESConnectorToCaseConnector(this.collection.attributes.connector), + }; + } + + public async encode(): Promise { + const collectionCommentStats = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }); + + if (this.subCase) { + const subCaseComments = await this.service.getAllSubCaseComments({ + client: this.soClient, + id: this.subCase.id, + }); + + return CollectWithSubCaseResponseRt.encode({ + subCase: flattenSubCaseSavedObject({ + savedObject: this.subCase, + comments: subCaseComments.saved_objects, + totalAlerts: countAlertsForID({ comments: subCaseComments, id: this.subCase.id }), + }), + ...this.formatCollectionForEncoding(collectionCommentStats.total), + }); + } + + const collectionComments = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: collectionCommentStats.total, + }, + }); + + return CollectWithSubCaseResponseRt.encode({ + comments: flattenCommentSavedObjects(collectionComments.saved_objects), + totalAlerts: countAlertsForID({ comments: collectionComments, id: this.collection.id }), + ...this.formatCollectionForEncoding(collectionCommentStats.total), + }); + } +} diff --git a/x-pack/plugins/case/server/common/models/index.ts b/x-pack/plugins/case/server/common/models/index.ts new file mode 100644 index 00000000000000..189090c91c81cd --- /dev/null +++ b/x-pack/plugins/case/server/common/models/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './commentable_case'; diff --git a/x-pack/plugins/case/server/common/utils.test.ts b/x-pack/plugins/case/server/common/utils.test.ts new file mode 100644 index 00000000000000..d89feb009f8069 --- /dev/null +++ b/x-pack/plugins/case/server/common/utils.test.ts @@ -0,0 +1,235 @@ +/* + * 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 { SavedObjectsFindResponse } from 'kibana/server'; +import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common/api'; +import { transformNewComment } from '../routes/api/utils'; +import { combineFilters, countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; + +interface CommentReference { + ids: string[]; + comments: CommentRequest[]; +} + +function createCommentFindResponse( + commentRequests: CommentReference[] +): SavedObjectsFindResponse { + const resp: SavedObjectsFindResponse = { + page: 0, + per_page: 0, + total: 0, + saved_objects: [], + }; + + for (const { ids, comments } of commentRequests) { + for (const id of ids) { + for (const comment of comments) { + resp.saved_objects.push({ + id: '', + references: [{ id, type: '', name: '' }], + score: 0, + type: '', + attributes: transformNewComment({ + ...comment, + associationType: AssociationType.case, + createdDate: '', + }), + }); + } + } + } + + return resp; +} + +describe('common utils', () => { + describe('combineFilters', () => { + it("creates a filter string with two values and'd together", () => { + expect(combineFilters(['a', 'b'], 'AND')).toBe('(a AND b)'); + }); + + it('creates a filter string with three values or together', () => { + expect(combineFilters(['a', 'b', 'c'], 'OR')).toBe('(a OR b OR c)'); + }); + + it('ignores empty strings', () => { + expect(combineFilters(['', 'a', '', 'b'], 'AND')).toBe('(a AND b)'); + }); + + it('returns an empty string if all filters are empty strings', () => { + expect(combineFilters(['', ''], 'OR')).toBe(''); + }); + + it('returns an empty string if the filters are undefined', () => { + expect(combineFilters(undefined, 'OR')).toBe(''); + }); + + it('returns a value without parenthesis when only a single filter is provided', () => { + expect(combineFilters(['a'], 'OR')).toBe('a'); + }); + + it('returns a string without parenthesis when only a single non empty filter is provided', () => { + expect(combineFilters(['', ''], 'AND')).toBe(''); + }); + }); + + describe('countAlerts', () => { + it('returns 0 when no alerts are found', () => { + expect( + countAlerts( + createCommentFindResponse([ + { ids: ['1'], comments: [{ comment: '', type: CommentType.user }] }, + ]).saved_objects[0] + ) + ).toBe(0); + }); + + it('returns 3 alerts for a single generated alert comment', () => { + expect( + countAlerts( + createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b', 'c'], + index: '', + type: CommentType.generatedAlert, + }, + ], + }, + ]).saved_objects[0] + ) + ).toBe(3); + }); + + it('returns 3 alerts for a single alert comment', () => { + expect( + countAlerts( + createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b', 'c'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]).saved_objects[0] + ) + ).toBe(3); + }); + }); + + describe('groupTotalAlertsByID', () => { + it('returns a map with one entry and 2 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + { + comment: '', + type: CommentType.user, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([['1', 2]]) + ); + }); + + it('returns a map with two entry, 2 alerts, and 0 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + { + ids: ['2'], + comments: [ + { + comment: '', + type: CommentType.user, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([ + ['1', 2], + ['2', 0], + ]) + ); + }); + + it('returns a map with two entry, 2 alerts, and 2 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1', '2'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([ + ['1', 2], + ['2', 2], + ]) + ); + }); + }); + + describe('countAlertsForID', () => { + it('returns 2 alerts for id 1 when the map has multiple entries', () => { + expect( + countAlertsForID({ + id: '1', + comments: createCommentFindResponse([ + { + ids: ['1', '2'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]), + }) + ).toEqual(2); + }); + }); +}); diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts new file mode 100644 index 00000000000000..a3ac0361569d53 --- /dev/null +++ b/x-pack/plugins/case/server/common/utils.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; +import { CaseStatuses, CommentAttributes, CommentType, User } from '../../common/api'; +import { AlertInfo, getAlertIndicesAndIDs } from '../routes/api/utils'; + +/** + * Default sort field for querying saved objects. + */ +export const defaultSortField = 'created_at'; + +/** + * Default unknown user + */ +export const nullUser: User = { username: null, full_name: null, email: null }; + +/** + * Adds the ids and indices to a map of statuses + */ +export function addAlertInfoToStatusMap({ + comment, + statusMap, + status, +}: { + comment: CommentAttributes; + statusMap: Map; + status: CaseStatuses; +}) { + const newAlertInfo = getAlertIndicesAndIDs([comment]); + + // combine the already accumulated ids and indices with the new ones from this alert comment + if (newAlertInfo.ids.length > 0 && newAlertInfo.indices.size > 0) { + const accAlertInfo = statusMap.get(status) ?? { ids: [], indices: new Set() }; + accAlertInfo.ids.push(...newAlertInfo.ids); + accAlertInfo.indices = new Set([ + ...accAlertInfo.indices.values(), + ...newAlertInfo.indices.values(), + ]); + statusMap.set(status, accAlertInfo); + } +} + +/** + * Combines multiple filter expressions using the specified operator and parenthesis if multiple expressions exist. + * This will ignore empty string filters. If a single valid filter is found it will not wrap in parenthesis. + * + * @param filters an array of filters to combine using the specified operator + * @param operator AND or OR + */ +export const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { + const noEmptyStrings = filters?.filter((value) => value !== ''); + const joinedExp = noEmptyStrings?.join(` ${operator} `); + // if undefined or an empty string + if (!joinedExp) { + return ''; + } else if ((noEmptyStrings?.length ?? 0) > 1) { + // if there were multiple filters, wrap them in () + return `(${joinedExp})`; + } else { + // return a single value not wrapped in () + return joinedExp; + } +}; + +/** + * Counts the total alert IDs within a single comment. + */ +export const countAlerts = (comment: SavedObjectsFindResult) => { + let totalAlerts = 0; + if ( + comment.attributes.type === CommentType.alert || + comment.attributes.type === CommentType.generatedAlert + ) { + if (Array.isArray(comment.attributes.alertId)) { + totalAlerts += comment.attributes.alertId.length; + } else { + totalAlerts++; + } + } + return totalAlerts; +}; + +/** + * Count the number of alerts for each id in the alert's references. This will result + * in a map with entries for both the collection and the individual sub cases. So the resulting + * size of the map will not equal the total number of sub cases. + */ +export const groupTotalAlertsByID = ({ + comments, +}: { + comments: SavedObjectsFindResponse; +}): Map => { + return comments.saved_objects.reduce((acc, alertsInfo) => { + const alertTotalForComment = countAlerts(alertsInfo); + for (const alert of alertsInfo.references) { + if (alert.id) { + const totalAlerts = acc.get(alert.id); + + if (totalAlerts !== undefined) { + acc.set(alert.id, totalAlerts + alertTotalForComment); + } else { + acc.set(alert.id, alertTotalForComment); + } + } + } + + return acc; + }, new Map()); +}; + +/** + * Counts the total alert IDs for a single case or sub case ID. + */ +export const countAlertsForID = ({ + comments, + id, +}: { + comments: SavedObjectsFindResponse; + id: string; +}): number | undefined => { + return groupTotalAlertsByID({ comments }).get(id); +}; diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index 4a025fd980fe20..6b7e395bae4dc1 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -10,7 +10,16 @@ import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsMock } from '../../../../actions/server/mocks'; import { validateParams } from '../../../../actions/server/lib'; -import { ConnectorTypes, CommentType, CaseStatuses } from '../../../common/api'; +import { + ConnectorTypes, + CommentType, + CaseStatuses, + CaseType, + AssociationType, + CaseResponse, + CasesResponse, + CollectionWithSubCaseResponse, +} from '../../../common/api'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -20,12 +29,12 @@ import { } from '../../services/mocks'; import { CaseActionType, CaseActionTypeExecutorOptions, CaseExecutorParams } from './types'; import { getActionType } from '.'; -import { createCaseClientMock } from '../../client/mocks'; +import { createExternalCaseClientMock } from '../../client/mocks'; -const mockCaseClient = createCaseClientMock(); +const mockCaseClient = createExternalCaseClientMock(); jest.mock('../../client', () => ({ - createCaseClient: () => mockCaseClient, + createExternalCaseClient: () => mockCaseClient, })); const services = actionsMock.createServices(); @@ -699,9 +708,7 @@ describe('case connector', () => { expect(validateParams(caseActionType, params)).toEqual(params); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('succeeds when type is an alert', () => { + it('succeeds when type is an alert', () => { const params: Record = { subAction: 'addComment', subActionParams: { @@ -727,26 +734,6 @@ describe('case connector', () => { }).toThrow(); }); - // TODO: Remove it when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it('fails when type is an alert', () => { - const params: Record = { - subAction: 'addComment', - subActionParams: { - caseId: 'case-id', - comment: { - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - }, - }, - }; - - expect(() => { - validateParams(caseActionType, params); - }).toThrow(); - }); - it('fails when missing attributes: type user', () => { const allParams = { type: CommentType.user, @@ -769,9 +756,7 @@ describe('case connector', () => { }); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('fails when missing attributes: type alert', () => { + it('fails when missing attributes: type alert', () => { const allParams = { type: CommentType.alert, comment: 'a comment', @@ -813,9 +798,7 @@ describe('case connector', () => { }); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('fails when excess attributes are provided: type alert', () => { + it('fails when excess attributes are provided: type alert', () => { ['comment'].forEach((attribute) => { const params: Record = { subAction: 'addComment', @@ -863,10 +846,11 @@ describe('case connector', () => { describe('create', () => { it('executes correctly', async () => { - const createReturn = { + const createReturn: CaseResponse = { id: 'mock-it', comments: [], totalComment: 0, + totalAlerts: 0, closed_at: null, closed_by: null, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, @@ -878,6 +862,7 @@ describe('case connector', () => { }, title: 'Case from case connector!!', tags: ['case', 'connector'], + type: CaseType.collection, description: 'Yo fields!!', external_service: null, status: CaseStatuses.open, @@ -926,17 +911,15 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: createReturn }); expect(mockCaseClient.create).toHaveBeenCalledWith({ - theCase: { - ...params.subActionParams, - connector: { - id: 'jira', - name: 'Jira', - type: '.jira', - fields: { - issueType: '10006', - priority: 'High', - parent: null, - }, + ...params.subActionParams, + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, }, }, }); @@ -945,7 +928,7 @@ describe('case connector', () => { describe('update', () => { it('executes correctly', async () => { - const updateReturn = [ + const updateReturn: CasesResponse = [ { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { @@ -973,6 +956,8 @@ describe('case connector', () => { tags: ['defacement'], title: 'Update title', totalComment: 0, + totalAlerts: 0, + type: CaseType.collection, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', @@ -1015,41 +1000,45 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: updateReturn }); expect(mockCaseClient.update).toHaveBeenCalledWith({ - caseClient: mockCaseClient, // Null values have been striped out. - cases: { - cases: [ - { - id: 'case-id', - version: '123', - title: 'Update title', - }, - ], - }, + cases: [ + { + id: 'case-id', + version: '123', + title: 'Update title', + }, + ], }); }); }); describe('addComment', () => { it('executes correctly', async () => { - const commentReturn = { + const commentReturn: CollectionWithSubCaseResponse = { id: 'mock-it', totalComment: 0, + version: 'WzksMV0=', + closed_at: null, closed_by: null, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, + created_by: { + full_name: 'Awesome D00d', + email: 'd00d@awesome.com', + username: 'awesome', + }, description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.collection, updated_at: null, updated_by: null, - version: 'WzksMV0=', comments: [ { + associationType: AssociationType.case, comment: 'a comment', type: CommentType.user as const, created_at: '2020-10-23T21:54:48.952Z', @@ -1097,7 +1086,6 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: commentReturn }); expect(mockCaseClient.addComment).toHaveBeenCalledWith({ - caseClient: mockCaseClient, caseId: 'case-id', comment: { comment: 'a comment', diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 9907aa5b3cd3a8..34b407616cfe48 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -7,11 +7,15 @@ import { curry } from 'lodash'; -import { KibanaRequest, kibanaResponseFactory } from '../../../../../../src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; -import { CasePatchRequest, CasePostRequest } from '../../../common/api'; -import { createCaseClient } from '../../client'; -import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema'; +import { + CasePatchRequest, + CasePostRequest, + CommentRequest, + CommentType, +} from '../../../common/api'; +import { createExternalCaseClient } from '../../client'; +import { CaseExecutorParamsSchema, CaseConfigurationSchema, CommentSchemaType } from './schema'; import { CaseExecutorResponse, ExecutorSubActionAddCommentParams, @@ -19,9 +23,9 @@ import { CaseActionTypeExecutorOptions, } from './types'; import * as i18n from './translations'; -import type { CasesRequestHandlerContext } from '../../types'; -import { GetActionTypeParams } from '..'; +import { GetActionTypeParams, isCommentGeneratedAlert } from '..'; +import { nullUser } from '../../common'; const supportedSubActions: string[] = ['create', 'update', 'addComment']; @@ -69,18 +73,17 @@ async function executor( const { subAction, subActionParams } = params; let data: CaseExecutorResponse | null = null; - const { savedObjectsClient } = services; - const caseClient = createCaseClient({ + const { savedObjectsClient, scopedClusterClient } = services; + const caseClient = createExternalCaseClient({ savedObjectsClient, - request: {} as KibanaRequest, - response: kibanaResponseFactory, + scopedClusterClient, + // we might want the user information to be passed as part of the action request + user: nullUser, caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - // TODO: When case connector is enabled we should figure out how to pass the context. - context: {} as CasesRequestHandlerContext, }); if (!supportedSubActions.includes(subAction)) { @@ -90,7 +93,9 @@ async function executor( } if (subAction === 'create') { - data = await caseClient.create({ theCase: subActionParams as CasePostRequest }); + data = await caseClient.create({ + ...(subActionParams as CasePostRequest), + }); } if (subAction === 'update') { @@ -102,16 +107,39 @@ async function executor( {} as CasePatchRequest ); - data = await caseClient.update({ - caseClient, - cases: { cases: [updateParamsWithoutNullValues] }, - }); + data = await caseClient.update({ cases: [updateParamsWithoutNullValues] }); } if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - data = await caseClient.addComment({ caseClient, caseId, comment }); + const formattedComment = transformConnectorComment(comment); + data = await caseClient.addComment({ caseId, comment: formattedComment }); } return { status: 'ok', data: data ?? {}, actionId }; } + +/** + * This converts a connector style generated alert ({_id: string} | {_id: string}[]) to the expected format of addComment. + */ +export const transformConnectorComment = (comment: CommentSchemaType): CommentRequest => { + if (isCommentGeneratedAlert(comment)) { + const alertId: string[] = []; + if (Array.isArray(comment.alerts)) { + alertId.push( + ...comment.alerts.map((alert: { _id: string }) => { + return alert._id; + }) + ); + } else { + alertId.push(comment.alerts._id); + } + return { + type: CommentType.generatedAlert, + alertId, + index: comment.index, + }; + } else { + return comment; + } +}; diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 8d52a344308e1a..cdeb00209f846b 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -6,37 +6,48 @@ */ import { schema } from '@kbn/config-schema'; +import { CommentType } from '../../../common/api'; import { validateConnector } from './validators'; // Reserved for future implementation export const CaseConfigurationSchema = schema.object({}); const ContextTypeUserSchema = schema.object({ - type: schema.literal('user'), + type: schema.literal(CommentType.user), comment: schema.string(), }); -/** - * ContextTypeAlertSchema has been deleted. - * Comments of type alert need the siem signal index. - * Case connector is not being passed the context which contains the - * security solution app client which in turn provides the siem signal index. - * For that reason, we disable comments of type alert for the case connector until - * we figure out how to pass the security solution app client to the connector. - * See: x-pack/plugins/case/server/connectors/case/index.ts L76. - * - * The schema: - * - * const ContextTypeAlertSchema = schema.object({ - * type: schema.literal('alert'), - * alertId: schema.string(), - * index: schema.string(), - * }); - * - * Issue: https://github.com/elastic/kibana/issues/85750 - * */ - -export const CommentSchema = schema.oneOf([ContextTypeUserSchema]); +const AlertIDSchema = schema.object( + { + _id: schema.string(), + }, + { unknowns: 'ignore' } +); + +const ContextTypeAlertGroupSchema = schema.object({ + type: schema.literal(CommentType.generatedAlert), + alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]), + index: schema.string(), +}); + +export type ContextTypeGeneratedAlertType = typeof ContextTypeAlertGroupSchema.type; + +const ContextTypeAlertSchema = schema.object({ + type: schema.literal(CommentType.alert), + // allowing either an array or a single value to preserve the previous API of attaching a single alert ID + alertId: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), + index: schema.string(), +}); + +export type ContextTypeAlertSchemaType = typeof ContextTypeAlertSchema.type; + +export const CommentSchema = schema.oneOf([ + ContextTypeUserSchema, + ContextTypeAlertSchema, + ContextTypeAlertGroupSchema, +]); + +export type CommentSchemaType = typeof CommentSchema.type; const JiraFieldsSchema = schema.object({ issueType: schema.string(), diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts index 6a7dfd9c2e6876..50ff104d7bad02 100644 --- a/x-pack/plugins/case/server/connectors/case/types.ts +++ b/x-pack/plugins/case/server/connectors/case/types.ts @@ -16,7 +16,7 @@ import { ConnectorSchema, CommentSchema, } from './schema'; -import { CaseResponse, CasesResponse } from '../../../common/api'; +import { CaseResponse, CasesResponse, CollectionWithSubCaseResponse } from '../../../common/api'; export type CaseConfiguration = TypeOf; export type Connector = TypeOf; @@ -29,7 +29,7 @@ export type ExecutorSubActionAddCommentParams = TypeOf< >; export type CaseExecutorParams = TypeOf; -export type CaseExecutorResponse = CaseResponse | CasesResponse; +export type CaseExecutorResponse = CaseResponse | CasesResponse | CollectionWithSubCaseResponse; export type CaseActionType = ActionType< CaseConfiguration, diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index 00809d81ca5f25..056ccff2733a76 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -5,14 +5,22 @@ * 2.0. */ -import { RegisterConnectorsArgs, ExternalServiceFormatterMapper } from './types'; +import { + RegisterConnectorsArgs, + ExternalServiceFormatterMapper, + CommentSchemaType, + ContextTypeGeneratedAlertType, + ContextTypeAlertSchemaType, +} from './types'; import { getActionType as getCaseConnector } from './case'; import { serviceNowITSMExternalServiceFormatter } from './servicenow/itsm_formatter'; import { serviceNowSIRExternalServiceFormatter } from './servicenow/sir_formatter'; import { jiraExternalServiceFormatter } from './jira/external_service_formatter'; import { resilientExternalServiceFormatter } from './resilient/external_service_formatter'; +import { CommentRequest, CommentType } from '../../common/api'; export * from './types'; +export { transformConnectorComment } from './case'; export const registerConnectors = ({ actionsRegisterType, @@ -41,3 +49,19 @@ export const externalServiceFormatters: ExternalServiceFormatterMapper = { '.jira': jiraExternalServiceFormatter, '.resilient': resilientExternalServiceFormatter, }; + +export const isCommentGeneratedAlert = ( + comment: CommentSchemaType | CommentRequest +): comment is ContextTypeGeneratedAlertType => { + return ( + comment.type === CommentType.generatedAlert && + 'alerts' in comment && + comment.alerts !== undefined + ); +}; + +export const isCommentAlert = ( + comment: CommentSchemaType +): comment is ContextTypeAlertSchemaType => { + return comment.type === CommentType.alert; +}; diff --git a/x-pack/plugins/case/server/connectors/types.ts b/x-pack/plugins/case/server/connectors/types.ts index 8e7eb91ad2dc66..ffda6f96ae3ba0 100644 --- a/x-pack/plugins/case/server/connectors/types.ts +++ b/x-pack/plugins/case/server/connectors/types.ts @@ -23,6 +23,12 @@ import { AlertServiceContract, } from '../services'; +export { + ContextTypeGeneratedAlertType, + CommentSchemaType, + ContextTypeAlertSchemaType, +} from './case/schema'; + export interface GetActionTypeParams { logger: Logger; caseService: CaseServiceSetup; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 5d05db165f6373..1c00c26a7c0b09 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -5,13 +5,7 @@ * 2.0. */ -import { - IContextProvider, - KibanaRequest, - KibanaResponseFactory, - Logger, - PluginInitializerContext, -} from 'kibana/server'; +import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; import { SecurityPluginSetup } from '../../security/server'; @@ -26,6 +20,7 @@ import { caseConnectorMappingsSavedObjectType, caseSavedObjectType, caseUserActionSavedObjectType, + subCaseSavedObjectType, } from './saved_object_types'; import { CaseConfigureService, @@ -39,7 +34,7 @@ import { AlertService, AlertServiceContract, } from './services'; -import { createCaseClient } from './client'; +import { CaseClientHandler, createExternalCaseClient } from './client'; import { registerConnectors } from './connectors'; import type { CasesRequestHandlerContext } from './types'; @@ -75,6 +70,7 @@ export class CasePlugin { core.savedObjects.registerType(caseConfigureSavedObjectType); core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); core.savedObjects.registerType(caseSavedObjectType); + core.savedObjects.registerType(subCaseSavedObjectType); core.savedObjects.registerType(caseUserActionSavedObjectType); this.log.debug( @@ -83,9 +79,10 @@ export class CasePlugin { )}] and plugins [${Object.keys(plugins)}]` ); - this.caseService = await new CaseService(this.log).setup({ - authentication: plugins.security != null ? plugins.security.authc : null, - }); + this.caseService = new CaseService( + this.log, + plugins.security != null ? plugins.security.authc : undefined + ); this.caseConfigureService = await new CaseConfigureService(this.log).setup(); this.connectorMappingsService = await new ConnectorMappingsService(this.log).setup(); this.userActionService = await new CaseUserActionService(this.log).setup(); @@ -125,23 +122,21 @@ export class CasePlugin { public start(core: CoreStart) { this.log.debug(`Starting Case Workflow`); - this.alertsService!.initialize(core.elasticsearch.client); const getCaseClientWithRequestAndContext = async ( context: CasesRequestHandlerContext, - request: KibanaRequest, - response: KibanaResponseFactory + request: KibanaRequest ) => { - return createCaseClient({ + const user = await this.caseService!.getUser({ request }); + return createExternalCaseClient({ + scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: core.savedObjects.getScopedClient(request), - request, - response, + user, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, connectorMappingsService: this.connectorMappingsService!, userActionService: this.userActionService!, alertsService: this.alertsService!, - context, }); }; @@ -171,18 +166,18 @@ export class CasePlugin { }): IContextProvider => { return async (context, request, response) => { const [{ savedObjects }] = await core.getStartServices(); + const user = await caseService.getUser({ request }); return { getCaseClient: () => { - return createCaseClient({ + return new CaseClientHandler({ + scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: savedObjects.getScopedClient(request), caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - context, - request, - response, + user, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts index 51ba684bf7a7b3..66d3ffe5f23d16 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -7,6 +7,7 @@ import { AuthenticatedUser } from '../../../../../security/server'; import { securityMock } from '../../../../../security/server/mocks'; +import { nullUser } from '../../../common'; function createAuthenticationMock({ currentUser, @@ -14,7 +15,11 @@ function createAuthenticationMock({ const { authc } = securityMock.createSetup(); authc.getCurrentUser.mockReturnValue( currentUser !== undefined - ? currentUser + ? // if we pass in null then use the null user (has null for each field) this is the default behavior + // for the CaseService getUser method + currentUser !== null + ? currentUser + : nullUser : ({ email: 'd00d@awesome.com', username: 'awesome', diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 18730effdf55a4..a33226bcde8998 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -10,6 +10,7 @@ import { SavedObjectsErrorHelpers, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, + SavedObjectsFindOptions, } from 'src/core/server'; import { @@ -17,6 +18,7 @@ import { CASE_SAVED_OBJECT, CASE_CONFIGURE_SAVED_OBJECT, CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, CASE_USER_ACTION_SAVED_OBJECT, } from '../../../saved_object_types'; @@ -91,16 +93,29 @@ export const createMockSavedObjectsRepository = ({ throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } return result[0]; - } - - const result = caseSavedObject.filter((s) => s.id === id); - if (!result.length) { + } else if (type === CASE_SAVED_OBJECT) { + const result = caseSavedObject.filter((s) => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + } else { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return result[0]; }), - find: jest.fn((findArgs) => { - if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { + find: jest.fn((findArgs: SavedObjectsFindOptions) => { + // References can be an array so we need to loop through it looking for the bad-guy + const hasReferenceIncludeBadGuy = (args: SavedObjectsFindOptions) => { + const references = args.hasReference; + if (references) { + return Array.isArray(references) + ? references.some((ref) => ref.id === 'bad-guy') + : references.id === 'bad-guy'; + } else { + return false; + } + }; + if (hasReferenceIncludeBadGuy(findArgs)) { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } @@ -141,6 +156,16 @@ export const createMockSavedObjectsRepository = ({ }; } + // Currently not supporting sub cases in this mock library + if (findArgs.type === SUB_CASE_SAVED_OBJECT) { + return { + page: 1, + per_page: 0, + total: 0, + saved_objects: [], + }; + } + if (findArgs.type === CASE_USER_ACTION_SAVED_OBJECT) { return { page: 1, @@ -206,19 +231,22 @@ export const createMockSavedObjectsRepository = ({ }), update: jest.fn((type, id, attributes) => { if (type === CASE_COMMENT_SAVED_OBJECT) { - if (!caseCommentSavedObject.find((s) => s.id === id)) { + const foundComment = caseCommentSavedObject.findIndex((s: { id: string }) => s.id === id); + if (foundComment === -1) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - caseCommentSavedObject = [ - ...caseCommentSavedObject, - { - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - version: 'WzE3LDFd', - attributes, + const comment = caseCommentSavedObject[foundComment]; + caseCommentSavedObject.splice(foundComment, 1, { + ...comment, + id, + type, + updated_at: '2019-11-22T22:50:55.191Z', + version: 'WzE3LDFd', + attributes: { + ...comment.attributes, + ...attributes, }, - ]; + }); } else if (type === CASE_SAVED_OBJECT) { if (!caseSavedObject.find((s) => s.id === id)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index ed4d22046c5819..b4230a05749a15 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -19,14 +19,10 @@ export const createRoute = async ( const router = httpService.createRouter(); const log = loggingSystemMock.create().get('case'); - - const caseServicePlugin = new CaseService(log); + const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); + const caseService = new CaseService(log, auth); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const connectorMappingsService = await connectorMappingsServicePlugin.setup(); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 514f77a8f953dc..2fe0be3e08ede6 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -7,7 +7,9 @@ import { SavedObject } from 'kibana/server'; import { + AssociationType, CaseStatuses, + CaseType, CaseUserActionAttributes, CommentAttributes, CommentType, @@ -46,6 +48,7 @@ export const mockCases: Array> = [ title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.individual, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { full_name: 'elastic', @@ -83,6 +86,7 @@ export const mockCases: Array> = [ title: 'Damaging Data Destruction Detected', status: CaseStatuses.open, tags: ['Data Destruction'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:00.900Z', updated_by: { full_name: 'elastic', @@ -124,6 +128,7 @@ export const mockCases: Array> = [ title: 'Another bad one', status: CaseStatuses.open, tags: ['LOLBins'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', @@ -169,6 +174,7 @@ export const mockCases: Array> = [ status: CaseStatuses.closed, title: 'Another bad one', tags: ['LOLBins'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', @@ -231,6 +237,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-1', attributes: { + associationType: AssociationType.case, comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user, created_at: '2019-11-25T21:55:00.177Z', @@ -262,6 +269,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-2', attributes: { + associationType: AssociationType.case, comment: 'Well I decided to update my comment. So what? Deal with it.', type: CommentType.user, created_at: '2019-11-25T21:55:14.633Z', @@ -294,6 +302,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-3', attributes: { + associationType: AssociationType.case, comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user, created_at: '2019-11-25T22:32:30.608Z', @@ -325,6 +334,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-4', attributes: { + associationType: AssociationType.case, type: CommentType.alert, index: 'test-index', alertId: 'test-id', @@ -357,6 +367,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-5', attributes: { + associationType: AssociationType.case, type: CommentType.alert, index: 'test-index-2', alertId: 'test-id-2', diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 74665ffdc5b16a..492be96fb4aa92 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -5,12 +5,8 @@ * 2.0. */ -import { KibanaRequest, kibanaResponseFactory } from '../../../../../../../src/core/server'; -import { - loggingSystemMock, - elasticsearchServiceMock, -} from '../../../../../../../src/core/server/mocks'; -import { createCaseClient } from '../../../client'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { createExternalCaseClient } from '../../../client'; import { AlertService, CaseService, @@ -26,20 +22,18 @@ export const createRouteContext = async (client: any, badAuth = false) => { const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); - const esClientMock = elasticsearchServiceMock.createClusterClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + + const authc = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); - const caseServicePlugin = new CaseService(log); + const caseService = new CaseService(log, authc); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); const caseUserActionsServicePlugin = new CaseUserActionService(log); - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const userActionService = await caseUserActionsServicePlugin.setup(); const alertsService = new AlertService(); - alertsService.initialize(esClientMock); const context = ({ core: { @@ -51,24 +45,18 @@ export const createRouteContext = async (client: any, badAuth = false) => { case: { getCaseClient: () => caseClient, }, - securitySolution: { - getAppClient: () => ({ - getSignalsIndex: () => '.siem-signals', - }), - }, } as unknown) as CasesRequestHandlerContext; const connectorMappingsService = await connectorMappingsServicePlugin.setup(); - const caseClient = createCaseClient({ + const caseClient = createExternalCaseClient({ savedObjectsClient: client, - request: {} as KibanaRequest, - response: kibanaResponseFactory, + user: authc.getCurrentUser(), caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - context, + scopedClusterClient: esClient, }); return { context, services: { userActionService } }; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index 898e0a14d0e2d9..bcbf1828e1fde4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -11,6 +11,7 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { AssociationType } from '../../../../../common/api'; export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { router.delete( @@ -20,19 +21,29 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); - const comments = await caseService.getAllCaseComments({ + const id = request.query?.subCaseID ?? request.params.case_id; + const comments = await caseService.getCommentsByAssociation({ client, - caseId: request.params.case_id, + id, + associationType: request.query?.subCaseID + ? AssociationType.subCase + : AssociationType.case, }); + await Promise.all( comments.saved_objects.map((comment) => caseService.deleteComment({ @@ -50,6 +61,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: request.params.case_id, + subCaseId: request.query?.subCaseID, commentId: comment.id, fields: ['comment'], }) diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 78c7623861b85e..73307753a550de 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -8,7 +8,7 @@ import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; @@ -23,13 +23,18 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: case_id: schema.string(), comment_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); const myComment = await caseService.getComment({ @@ -41,10 +46,13 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); } - const caseRef = myComment.references.find((c) => c.type === CASE_SAVED_OBJECT); - if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { + const type = request.query?.subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const id = request.query?.subCaseID ?? request.params.case_id; + + const caseRef = myComment.references.find((c) => c.type === type); + if (caseRef == null || (caseRef != null && caseRef.id !== id)) { throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${request.params.case_id}).` + `This comment ${request.params.comment_id} does not exist in ${id}).` ); } @@ -60,7 +68,8 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: action: 'delete', actionAt: deleteDate, actionBy: { username, full_name, email }, - caseId: request.params.case_id, + caseId: id, + subCaseId: request.query?.subCaseID, commentId: request.params.comment_id, fields: ['comment'], }), diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index 91ac9259d25686..3431c340c791e3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -5,6 +5,8 @@ * 2.0. */ +import * as rt from 'io-ts'; + import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; @@ -13,6 +15,7 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { + AssociationType, CommentsResponseRt, SavedObjectFindOptionsRt, throwErrors, @@ -20,6 +23,12 @@ import { import { RouteDeps } from '../../types'; import { escapeHatch, transformComments, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { defaultPage, defaultPerPage } from '../..'; + +const FindQueryParamsRt = rt.partial({ + ...SavedObjectFindOptionsRt.props, + subCaseID: rt.string, +}); export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { router.get( @@ -36,25 +45,41 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { try { const client = context.core.savedObjects.client; const query = pipe( - SavedObjectFindOptionsRt.decode(request.query), + FindQueryParamsRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) ); + const id = query.subCaseID ?? request.params.case_id; + const associationType = query.subCaseID ? AssociationType.subCase : AssociationType.case; const args = query ? { + caseService, client, - caseId: request.params.case_id, + id, options: { - ...query, + // We need this because the default behavior of getAllCaseComments is to return all the comments + // unless the page and/or perPage is specified. Since we're spreading the query after the request can + // still override this behavior. + page: defaultPage, + perPage: defaultPerPage, sortField: 'created_at', + ...query, }, + associationType, } : { + caseService, client, - caseId: request.params.case_id, + id, + options: { + page: defaultPage, + perPage: defaultPerPage, + sortField: 'created_at', + }, + associationType, }; - const theComments = await caseService.getAllCaseComments(args); + const theComments = await caseService.getCommentsByAssociation(args); return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 72105662dafb57..730b1b92a8a076 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -7,10 +7,12 @@ import { schema } from '@kbn/config-schema'; -import { AllCommentsResponseRt } from '../../../../../common/api'; +import { SavedObjectsFindResponse } from 'kibana/server'; +import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { flattenCommentSavedObjects, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { defaultSortField } from '../../../../common'; export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { router.get( @@ -20,15 +22,38 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + includeSubCaseComments: schema.maybe(schema.boolean()), + subCaseID: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; - const comments = await caseService.getAllCaseComments({ - client, - caseId: request.params.case_id, - }); + let comments: SavedObjectsFindResponse; + + if (request.query?.subCaseID) { + comments = await caseService.getAllSubCaseComments({ + client, + id: request.query.subCaseID, + options: { + sortField: defaultSortField, + }, + }); + } else { + comments = await caseService.getAllCaseComments({ + client, + id: request.params.case_id, + includeSubCaseComments: request.query?.includeSubCaseComments, + options: { + sortField: defaultSortField, + }, + }); + } + return response.ok({ body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts index 33dc24d776c701..9dec910f9fc46f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts @@ -27,6 +27,7 @@ describe('PATCH comment', () => { }); it(`Patch a comment`, async () => { + const commentID = 'mock-comment-1'; const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, method: 'patch', @@ -36,7 +37,7 @@ describe('PATCH comment', () => { body: { type: CommentType.user, comment: 'Update my comment', - id: 'mock-comment-1', + id: commentID, version: 'WzEsMV0=', }, }); @@ -50,12 +51,14 @@ describe('PATCH comment', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1].comment).toEqual( - 'Update my comment' + const updatedComment = response.payload.comments.find( + (comment: { id: string }) => comment.id === commentID ); + expect(updatedComment.comment).toEqual('Update my comment'); }); it(`Patch an alert`, async () => { + const commentID = 'mock-comment-4'; const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, method: 'patch', @@ -66,7 +69,7 @@ describe('PATCH comment', () => { type: CommentType.alert, alertId: 'new-id', index: 'test-index', - id: 'mock-comment-4', + id: commentID, version: 'WzYsMV0=', }, }); @@ -80,9 +83,10 @@ describe('PATCH comment', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1].alertId).toEqual( - 'new-id' + const updatedComment = response.payload.comments.find( + (comment: { id: string }) => comment.id === commentID ); + expect(updatedComment.alertId).toEqual('new-id'); }); it(`it throws when missing attributes: type user`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index ac037ce3ead1ff..e8b6f7bc957eb3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -12,12 +12,44 @@ import { identity } from 'fp-ts/lib/function'; import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; -import { CommentPatchRequestRt, CaseResponseRt, throwErrors } from '../../../../../common/api'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { SavedObjectsClientContract } from 'kibana/server'; +import { CommentableCase } from '../../../../common'; +import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/api'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError, flattenCaseSavedObject, decodeComment } from '../../utils'; +import { escapeHatch, wrapError, decodeCommentRequest } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CaseServiceSetup } from '../../../../services'; + +interface CombinedCaseParams { + service: CaseServiceSetup; + client: SavedObjectsClientContract; + caseID: string; + subCaseID?: string; +} + +async function getCommentableCase({ service, client, caseID, subCaseID }: CombinedCaseParams) { + if (subCaseID) { + const [caseInfo, subCase] = await Promise.all([ + service.getCase({ + client, + id: caseID, + }), + service.getSubCase({ + client, + id: subCaseID, + }), + ]); + return new CommentableCase({ collection: caseInfo, service, subCase, soClient: client }); + } else { + const caseInfo = await service.getCase({ + client, + id: caseID, + }); + return new CommentableCase({ collection: caseInfo, service, soClient: client }); + } +} export function initPatchCommentApi({ caseConfigureService, @@ -32,24 +64,30 @@ export function initPatchCommentApi({ params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), body: escapeHatch, }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; - const caseId = request.params.case_id; const query = pipe( CommentPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const { id: queryCommentId, version: queryCommentVersion, ...queryRestAttributes } = query; - decodeComment(queryRestAttributes); + decodeCommentRequest(queryRestAttributes); - const myCase = await caseService.getCase({ + const commentableCase = await getCommentableCase({ + service: caseService, client, - caseId, + caseID: request.params.case_id, + subCaseID: request.query?.subCaseID, }); const myComment = await caseService.getComment({ @@ -65,9 +103,13 @@ export function initPatchCommentApi({ throw Boom.badRequest(`You cannot change the type of the comment.`); } - const caseRef = myComment.references.find((c) => c.type === CASE_SAVED_OBJECT); - if (caseRef == null || (caseRef != null && caseRef.id !== caseId)) { - throw Boom.notFound(`This comment ${queryCommentId} does not exist in ${caseId}).`); + const saveObjType = request.query?.subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + + const caseRef = myComment.references.find((c) => c.type === saveObjType); + if (caseRef == null || (caseRef != null && caseRef.id !== commentableCase.id)) { + throw Boom.notFound( + `This comment ${queryCommentId} does not exist in ${commentableCase.id}).` + ); } if (queryCommentVersion !== myComment.version) { @@ -77,84 +119,46 @@ export function initPatchCommentApi({ } // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); + const userInfo: User = { + username, + full_name, + email, + }; + const updatedDate = new Date().toISOString(); - const [updatedComment, updatedCase] = await Promise.all([ - caseService.patchComment({ - client, - commentId: queryCommentId, - updatedAttributes: { - ...queryRestAttributes, - updated_at: updatedDate, - updated_by: { email, full_name, username }, - }, - version: queryCommentVersion, - }), - caseService.patchCase({ - client, - caseId, - updatedAttributes: { - updated_at: updatedDate, - updated_by: { username, full_name, email }, - }, - version: myCase.version, - }), - ]); - - const totalCommentsFindByCases = await caseService.getAllCaseComments({ - client, - caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, + const { + comment: updatedComment, + commentableCase: updatedCase, + } = await commentableCase.updateComment({ + updateRequest: query, + updatedAt: updatedDate, + user: userInfo, }); - const [comments] = await Promise.all([ - caseService.getAllCaseComments({ - client, - caseId: request.params.case_id, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindByCases.total, - }, - }), - userActionService.postUserActions({ - client, - actions: [ - buildCommentUserActionItem({ - action: 'update', - actionAt: updatedDate, - actionBy: { username, full_name, email }, - caseId: request.params.case_id, - commentId: updatedComment.id, - fields: ['comment'], - newValue: JSON.stringify(queryRestAttributes), - oldValue: JSON.stringify( - // We are interested only in ContextBasicRt attributes - // myComment.attribute contains also CommentAttributesBasicRt attributes - pick(Object.keys(queryRestAttributes), myComment.attributes) - ), - }), - ], - }), - ]); + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'update', + actionAt: updatedDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + subCaseId: request.query?.subCaseID, + commentId: updatedComment.id, + fields: ['comment'], + newValue: JSON.stringify(queryRestAttributes), + oldValue: JSON.stringify( + // We are interested only in ContextBasicRt attributes + // myComment.attribute contains also CommentAttributesBasicRt attributes + pick(Object.keys(queryRestAttributes), myComment.attributes) + ), + }), + ], + }); return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase.attributes }, - version: updatedCase.version ?? myCase.version, - references: myCase.references, - }, - comments: comments.saved_objects, - }) - ), + body: await updatedCase.encode(), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index 0ab038a62ac771..fb51b8f76d0ef4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -299,21 +299,24 @@ describe('POST comment', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(response.payload.comments[response.payload.comments.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 761beb964823a3..95b611950bd411 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -19,6 +19,11 @@ export function initPostCommentApi({ router }: RouteDeps) { params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), body: escapeHatch, }, }, @@ -28,12 +33,12 @@ export function initPostCommentApi({ router }: RouteDeps) { } const caseClient = context.case.getCaseClient(); - const caseId = request.params.case_id; + const caseId = request.query?.subCaseID ?? request.params.case_id; const comment = request.body as CommentRequest; try { return response.ok({ - body: await caseClient.addComment({ caseClient, caseId, comment }), + body: await caseClient.addComment({ caseId, comment }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts index 17972e129a8257..33226d39a25957 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts @@ -40,7 +40,6 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.type, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 6925f116136b34..02d39465373f98 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -56,7 +56,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout } // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const updateDate = new Date().toISOString(); @@ -73,7 +73,6 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.type, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index 0bcf2ac18740fc..db3d5cd6a2e56e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -58,14 +58,13 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ); } // eslint-disable-next-line @typescript-eslint/naming-convention - const { email, full_name, username } = await caseService.getUser({ request, response }); + const { email, full_name, username } = await caseService.getUser({ request }); const creationDate = new Date().toISOString(); let mappings: ConnectorMappingsAttributes[] = []; try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: query.connector.id, connectorType: query.connector.type, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts index d588950bec9aa3..a441a027769bfc 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts @@ -52,12 +52,15 @@ describe('DELETE case', () => { }, }); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); @@ -71,12 +74,16 @@ describe('DELETE case', () => { }, }); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCaseComments, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCaseComments, + }); + + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); @@ -90,12 +97,16 @@ describe('DELETE case', () => { }, }); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCasesErrorTriggerData, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCasesErrorTriggerData, + }); + + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index c72dde9f77bf11..263b814df4146e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -7,10 +7,75 @@ import { schema } from '@kbn/config-schema'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { CaseType } from '../../../../common/api'; import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; import { CASES_URL } from '../../../../common/constants'; +import { CaseServiceSetup } from '../../../services'; + +async function unremovableCases({ + caseService, + client, + ids, + force, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + ids: string[]; + force: boolean | undefined; +}): Promise { + // if the force flag was included then we can skip checking whether the cases are collections and go ahead + // and delete them + if (force) { + return []; + } + + const cases = await caseService.getCases({ caseIds: ids, client }); + const parentCases = cases.saved_objects.filter( + /** + * getCases will return an array of saved_objects and some can be successful cases where as others + * might have failed to find the ID. If it fails to find it, it will set the error field but not + * the attributes so check that we didn't receive an error. + */ + (caseObj) => !caseObj.error && caseObj.attributes.type === CaseType.collection + ); + + return parentCases.map((parentCase) => parentCase.id); +} + +async function deleteSubCases({ + caseService, + client, + caseIds, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseIds: string[]; +}) { + const subCasesForCaseIds = await caseService.findSubCasesByCaseId({ client, ids: caseIds }); + + const subCaseIDs = subCasesForCaseIds.saved_objects.map((subCase) => subCase.id); + const commentsForSubCases = await caseService.getAllSubCaseComments({ + client, + id: subCaseIDs, + }); + + // This shouldn't actually delete anything because all the comments should be deleted when comments are deleted + // per case ID + await Promise.all( + commentsForSubCases.saved_objects.map((commentSO) => + caseService.deleteComment({ client, commentId: commentSO.id }) + ) + ); + + await Promise.all( + subCasesForCaseIds.saved_objects.map((subCaseSO) => + caseService.deleteSubCase(client, subCaseSO.id) + ) + ); +} export function initDeleteCasesApi({ caseService, router, userActionService }: RouteDeps) { router.delete( @@ -19,17 +84,30 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R validate: { query: schema.object({ ids: schema.arrayOf(schema.string()), + force: schema.maybe(schema.boolean()), }), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; + const unremovable = await unremovableCases({ + caseService, + client, + ids: request.query.ids, + force: request.query.force, + }); + + if (unremovable.length > 0) { + return response.badRequest({ + body: `Case IDs: [${unremovable.join(' ,')}] are not removable`, + }); + } await Promise.all( request.query.ids.map((id) => caseService.deleteCase({ client, - caseId: id, + id, }) ) ); @@ -37,7 +115,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R request.query.ids.map((id) => caseService.getAllCaseComments({ client, - caseId: id, + id, }) ) ); @@ -56,8 +134,10 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R ) ); } + + await deleteSubCases({ caseService, client, caseIds: request.query.ids }); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); await userActionService.postUserActions({ @@ -68,7 +148,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: id, - fields: ['comment', 'description', 'status', 'tags', 'title'], + fields: ['comment', 'description', 'status', 'tags', 'title', 'sub_case'], }) ), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 2bfce8b9088032..8ba83b42c06d70 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -11,40 +11,16 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { isEmpty } from 'lodash'; import { CasesFindResponseRt, CasesFindRequestRt, throwErrors, - CaseStatuses, caseStatuses, } from '../../../../common/api'; -import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; -import { RouteDeps, TotalCommentByCase } from '../types'; -import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { transformCases, wrapError, escapeHatch } from '../utils'; +import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; - -const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => - filters?.filter((i) => i !== '').join(` ${operator} `); - -const getStatusFilter = (status: CaseStatuses, appendFilter?: string) => - `${CASE_SAVED_OBJECT}.attributes.status: ${status}${ - !isEmpty(appendFilter) ? ` AND ${appendFilter}` : '' - }`; - -const buildFilter = ( - filters: string | string[] | undefined, - field: string, - operator: 'OR' | 'AND' -): string => - filters != null && filters.length > 0 - ? Array.isArray(filters) - ? // Be aware of the surrounding parenthesis (as string inside literal) around filters. - `(${filters - .map((filter) => `${CASE_SAVED_OBJECT}.attributes.${field}: ${filter}`) - ?.join(` ${operator} `)})` - : `${CASE_SAVED_OBJECT}.attributes.${field}: ${filters}` - : ''; +import { constructQueryOptions } from './helpers'; export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { router.get( @@ -62,79 +38,42 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: fold(throwErrors(Boom.badRequest), identity) ); - const { tags, reporters, status, ...query } = queryParams; - const tagsFilter = buildFilter(tags, 'tags', 'OR'); - const reportersFilters = buildFilter(reporters, 'created_by.username', 'OR'); - - const myFilters = combineFilters([tagsFilter, reportersFilters], 'AND'); - const filter = status != null ? getStatusFilter(status, myFilters) : myFilters; + const queryArgs = { + tags: queryParams.tags, + reporters: queryParams.reporters, + sortByField: queryParams.sortField, + status: queryParams.status, + caseType: queryParams.type, + }; - const args = queryParams - ? { - client, - options: { - ...query, - filter, - sortField: sortToSnake(query.sortField ?? ''), - }, - } - : { - client, - }; + const caseQueries = constructQueryOptions(queryArgs); - const statusArgs = caseStatuses.map((caseStatus) => ({ + const cases = await caseService.findCasesGroupedByID({ client, - options: { - fields: [], - page: 1, - perPage: 1, - filter: getStatusFilter(caseStatus, myFilters), - }, - })); - - const [cases, openCases, inProgressCases, closedCases] = await Promise.all([ - caseService.findCases(args), - ...statusArgs.map((arg) => caseService.findCases(arg)), - ]); + caseOptions: { ...queryParams, ...caseQueries.case }, + subCaseOptions: caseQueries.subCase, + }); - const totalCommentsFindByCases = await Promise.all( - cases.saved_objects.map((c) => - caseService.getAllCaseComments({ + const [openCases, inProgressCases, closedCases] = await Promise.all([ + ...caseStatuses.map((status) => { + const statusQuery = constructQueryOptions({ ...queryArgs, status }); + return caseService.findCaseStatusStats({ client, - caseId: c.id, - options: { - fields: [], - page: 1, - perPage: 1, - }, - }) - ) - ); - - const totalCommentsByCases = totalCommentsFindByCases.reduce( - (acc, itemFind) => { - if (itemFind.saved_objects.length > 0) { - const caseId = - itemFind.saved_objects[0].references.find((r) => r.type === CASE_SAVED_OBJECT) - ?.id ?? null; - if (caseId != null) { - return [...acc, { caseId, totalComments: itemFind.total }]; - } - } - return [...acc]; - }, - [] - ); + caseOptions: statusQuery.case, + subCaseOptions: statusQuery.subCase, + }); + }), + ]); return response.ok({ body: CasesFindResponseRt.encode( - transformCases( - cases, - openCases.total ?? 0, - inProgressCases.total ?? 0, - closedCases.total ?? 0, - totalCommentsByCases - ) + transformCases({ + ...cases, + countOpenCases: openCases, + countInProgressCases: inProgressCases, + countClosedCases: closedCases, + total: cases.casesMap.size, + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index 55377d93e528d0..a3311796fa5cd9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -20,22 +20,22 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro case_id: schema.string(), }), query: schema.object({ - includeComments: schema.string({ defaultValue: 'true' }), + includeComments: schema.boolean({ defaultValue: true }), + includeSubCaseComments: schema.maybe(schema.boolean({ defaultValue: false })), }), }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } - const caseClient = context.case.getCaseClient(); - const includeComments = JSON.parse(request.query.includeComments); const id = request.params.case_id; try { return response.ok({ - body: await caseClient.get({ id, includeComments }), + body: await caseClient.get({ + id, + includeComments: request.query.includeComments, + includeSubCaseComments: request.query.includeSubCaseComments, + }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index d888eb21a4946d..a1a7f4f9da8f5b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -12,12 +12,174 @@ import { SavedObjectsFindResponse } from 'kibana/server'; import { CaseConnector, ESCaseConnector, - ESCaseAttributes, - ESCasePatchRequest, ESCasesConfigureAttributes, ConnectorTypes, + CaseStatuses, + CaseType, + SavedObjectFindOptions, } from '../../../../common/api'; import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { sortToSnake } from '../utils'; +import { combineFilters } from '../../../common'; + +export const addStatusFilter = ({ + status, + appendFilter, + type = CASE_SAVED_OBJECT, +}: { + status: CaseStatuses | undefined; + appendFilter?: string; + type?: string; +}) => { + const filters: string[] = []; + if (status) { + filters.push(`${type}.attributes.status: ${status}`); + } + + if (appendFilter) { + filters.push(appendFilter); + } + return combineFilters(filters, 'AND'); +}; + +export const buildFilter = ({ + filters, + field, + operator, + type = CASE_SAVED_OBJECT, +}: { + filters: string | string[] | undefined; + field: string; + operator: 'OR' | 'AND'; + type?: string; +}): string => { + // if it is an empty string, empty array of strings, or undefined just return + if (!filters || filters.length <= 0) { + return ''; + } + + const arrayFilters = !Array.isArray(filters) ? [filters] : filters; + + return combineFilters( + arrayFilters.map((filter) => `${type}.attributes.${field}: ${filter}`), + operator + ); +}; + +/** + * Constructs the filters used for finding cases and sub cases. + * There are a few scenarios that this function tries to handle when constructing the filters used for finding cases + * and sub cases. + * + * Scenario 1: + * Type == Individual + * If the API request specifies that it wants only individual cases (aka not collections) then we need to add that + * specific filter when call the saved objects find api. This will filter out any collection cases. + * + * Scenario 2: + * Type == collection + * If the API request specifies that it only wants collection cases (cases that have sub cases) then we need to add + * the filter for collections AND we need to ignore any status filter for the case find call. This is because a + * collection's status is no longer relevant when it has sub cases. The user cannot change the status for a collection + * only for its sub cases. The status filter will be applied to the find request when looking for sub cases. + * + * Scenario 3: + * No Type is specified + * If the API request does not want to filter on type but instead get both collections and regular individual cases then + * we need to find all cases that match the other filter criteria and sub cases. To do this we construct the following query: + * + * ((status == some_status and type === individual) or type == collection) and (tags == blah) and (reporter == yo) + * This forces us to honor the status request for individual cases but gets us ALL collection cases that match the other + * filter criteria. When we search for sub cases we will use that status filter in that find call as well. + */ +export const constructQueryOptions = ({ + tags, + reporters, + status, + sortByField, + caseType, +}: { + tags?: string | string[]; + reporters?: string | string[]; + status?: CaseStatuses; + sortByField?: string; + caseType?: CaseType; +}): { case: SavedObjectFindOptions; subCase?: SavedObjectFindOptions } => { + const tagsFilter = buildFilter({ filters: tags, field: 'tags', operator: 'OR' }); + const reportersFilter = buildFilter({ + filters: reporters, + field: 'created_by.username', + operator: 'OR', + }); + const sortField = sortToSnake(sortByField); + + switch (caseType) { + case CaseType.individual: { + // The cases filter will result in this structure "status === oh and (type === individual) and (tags === blah) and (reporter === yo)" + // The subCase filter will be undefined because we don't need to find sub cases if type === individual + + // We do not want to support multiple type's being used, so force it to be a single filter value + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const caseFilters = addStatusFilter({ + status, + appendFilter: combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'), + }); + return { + case: { + filter: caseFilters, + sortField, + }, + }; + } + case CaseType.collection: { + // The cases filter will result in this structure "(type == parent) and (tags == blah) and (reporter == yo)" + // The sub case filter will use the query.status if it exists + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.collection}`; + const caseFilters = combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'); + + return { + case: { + filter: caseFilters, + sortField, + }, + subCase: { + filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), + sortField, + }, + }; + } + default: { + /** + * In this scenario no type filter was sent, so we want to honor the status filter if one exists. + * To construct the filter and honor the status portion we need to find all individual cases that + * have that particular status. We also need to find cases that have sub cases but we want to ignore the + * case collection's status because it is not relevant. We only care about the status of the sub cases if the + * case is a collection. + * + * The cases filter will result in this structure "((status == open and type === individual) or type == parent) and (tags == blah) and (reporter == yo)" + * The sub case filter will use the query.status if it exists + */ + const typeIndividual = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const typeParent = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.collection}`; + + const statusFilter = combineFilters([addStatusFilter({ status }), typeIndividual], 'AND'); + const statusAndType = combineFilters([statusFilter, typeParent], 'OR'); + const caseFilters = combineFilters([statusAndType, tagsFilter, reportersFilter], 'AND'); + + return { + case: { + filter: caseFilters, + sortField, + }, + subCase: { + filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), + sortField, + }, + }; + } + } +}; interface CompareArrays { addedItems: string[]; @@ -66,10 +228,16 @@ export const isTwoArraysDifference = ( return null; }; +interface CaseWithIDVersion { + id: string; + version: string; + [key: string]: unknown; +} + export const getCaseToUpdate = ( - currentCase: ESCaseAttributes, - queryCase: ESCasePatchRequest -): ESCasePatchRequest => + currentCase: unknown, + queryCase: CaseWithIDVersion +): CaseWithIDVersion => Object.entries(queryCase).reduce( (acc, [key, value]) => { const currentValue = get(currentCase, key); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 6d1134b15b65e3..e50d14e5c66c4f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -17,7 +17,7 @@ import { } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -import { ConnectorTypes, CaseStatuses } from '../../../../common/api'; +import { CaseStatuses } from '../../../../common/api'; describe('PATCH cases', () => { let routeHandler: RequestHandler; @@ -52,34 +52,53 @@ describe('PATCH cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.closed, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + expect(response.payload).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); it(`Open a case`, async () => { @@ -106,34 +125,53 @@ describe('PATCH cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: '123', - name: 'My connector', - type: '.jira', - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - id: 'mock-id-4', - external_service: null, - status: CaseStatuses.open, - tags: ['LOLBins'], - title: 'Another bad one', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + expect(response.payload).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); it(`Change case to in-progress`, async () => { @@ -159,34 +197,49 @@ describe('PATCH cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses['in-progress'], - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + expect(response.payload).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "in-progress", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); it(`Patches a case without a connector.id`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index a320fafe4e5b4e..67d4d21a576340 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -28,7 +28,7 @@ export function initPatchCasesApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.update({ caseClient, cases }), + body: await caseClient.update(cases), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 292e2c6775a801..53829157c5b049 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -189,35 +189,42 @@ describe('POST cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual({ - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - id: 'mock-it', - status: CaseStatuses.open, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(response.payload).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 09f746a62d58ac..349ed6c3e5af93 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -28,7 +28,7 @@ export function initPostCaseApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.create({ theCase }), + body: await caseClient.create({ ...theCase }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts index 49801ea4e2f3eb..bf398d1ffcf407 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts @@ -131,7 +131,10 @@ describe('Push case', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(caseClient.getAlerts).toHaveBeenCalledWith({ ids: ['test-id'] }); + expect(caseClient.getAlerts).toHaveBeenCalledWith({ + ids: ['test-id'], + indices: new Set(['test-index']), + }); }); it(`Calls execute with correct arguments`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 6d670c38bbf85c..c1f0a2cb59cb1d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -45,7 +45,6 @@ export function initPushCaseApi({ router }: RouteDeps) { return response.ok({ body: await caseClient.push({ - caseClient, actionsClient, caseId: params.case_id, connectorId: params.connector_id, diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts index 9644162629f24b..1c399a415e4704 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts @@ -16,6 +16,7 @@ import { } from '../../__fixtures__'; import { initGetCasesStatusApi } from './get_status'; import { CASE_STATUS_URL } from '../../../../../common/constants'; +import { CaseType } from '../../../../../common/api'; describe('GET status', () => { let routeHandler: RequestHandler; @@ -24,6 +25,7 @@ describe('GET status', () => { page: 1, perPage: 1, type: 'cases', + sortField: 'created_at', }; beforeAll(async () => { @@ -45,17 +47,17 @@ describe('GET status', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { ...findArgs, - filter: 'cases.attributes.status: open', + filter: `((cases.attributes.status: open AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { ...findArgs, - filter: 'cases.attributes.status: in-progress', + filter: `((cases.attributes.status: in-progress AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { ...findArgs, - filter: 'cases.attributes.status: closed', + filter: `((cases.attributes.status: closed AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(response.payload).toEqual({ diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts index 8300e38a568962..f3cd0e2bdda5c2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -9,8 +9,8 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { CASE_STATUS_URL } from '../../../../../common/constants'; +import { constructQueryOptions } from '../helpers'; export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { router.get( @@ -21,25 +21,23 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const args = caseStatuses.map((status) => ({ - client, - options: { - fields: [], - page: 1, - perPage: 1, - filter: `${CASE_SAVED_OBJECT}.attributes.status: ${status}`, - }, - })); - const [openCases, inProgressCases, closesCases] = await Promise.all( - args.map((arg) => caseService.findCases(arg)) - ); + const [openCases, inProgressCases, closedCases] = await Promise.all([ + ...caseStatuses.map((status) => { + const statusQuery = constructQueryOptions({ status }); + return caseService.findCaseStatusStats({ + client, + caseOptions: statusQuery.case, + subCaseOptions: statusQuery.subCase, + }); + }), + ]); return response.ok({ body: CasesStatusResponseRt.encode({ - count_open_cases: openCases.total, - count_in_progress_cases: inProgressCases.total, - count_closed_cases: closesCases.total, + count_open_cases: openCases, + count_in_progress_cases: inProgressCases, + count_closed_cases: closedCases, }), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts new file mode 100644 index 00000000000000..db701dd0fc82b5 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { schema } from '@kbn/config-schema'; +import { buildCaseUserActionItem } from '../../../../services/user_actions/helpers'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; + +export function initDeleteSubCasesApi({ caseService, router, userActionService }: RouteDeps) { + router.delete( + { + path: SUB_CASES_PATCH_DEL_URL, + validate: { + query: schema.object({ + ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + + const [comments, subCases] = await Promise.all([ + caseService.getAllSubCaseComments({ client, id: request.query.ids }), + caseService.getSubCases({ client, ids: request.query.ids }), + ]); + + const subCaseErrors = subCases.saved_objects.filter( + (subCase) => subCase.error !== undefined + ); + + if (subCaseErrors.length > 0) { + throw Boom.notFound( + `These sub cases ${subCaseErrors + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + const subCaseIDToParentID = subCases.saved_objects.reduce((acc, subCase) => { + const parentID = subCase.references.find((ref) => ref.type === CASE_SAVED_OBJECT); + acc.set(subCase.id, parentID?.id); + return acc; + }, new Map()); + + await Promise.all( + comments.saved_objects.map((comment) => + caseService.deleteComment({ client, commentId: comment.id }) + ) + ); + + await Promise.all(request.query.ids.map((id) => caseService.deleteSubCase(client, id))); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = await caseService.getUser({ request }); + const deleteDate = new Date().toISOString(); + + await userActionService.postUserActions({ + client, + actions: request.query.ids.map((id) => + buildCaseUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + // if for some reason the sub case didn't have a reference to its parent, we'll still log a user action + // but we won't have the case ID + caseId: subCaseIDToParentID.get(id) ?? '', + subCaseId: id, + fields: ['sub_case', 'comment', 'status'], + }) + ), + }); + + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts new file mode 100644 index 00000000000000..98052ccaeaba8e --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from '@hapi/boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + caseStatuses, + SubCasesFindRequestRt, + SubCasesFindResponseRt, + throwErrors, +} from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { escapeHatch, transformSubCases, wrapError } from '../../utils'; +import { SUB_CASES_URL } from '../../../../../common/constants'; +import { constructQueryOptions } from '../helpers'; +import { defaultPage, defaultPerPage } from '../..'; + +export function initFindSubCasesApi({ caseService, router }: RouteDeps) { + router.get( + { + path: `${SUB_CASES_URL}/_find`, + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const queryParams = pipe( + SubCasesFindRequestRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const ids = [request.params.case_id]; + const { subCase: subCaseQueryOptions } = constructQueryOptions({ + status: queryParams.status, + sortByField: queryParams.sortField, + }); + + const subCases = await caseService.findSubCasesGroupByCase({ + client, + ids, + options: { + sortField: 'created_at', + page: defaultPage, + perPage: defaultPerPage, + ...queryParams, + ...subCaseQueryOptions, + }, + }); + + const [open, inProgress, closed] = await Promise.all([ + ...caseStatuses.map((status) => { + const { subCase: statusQueryOptions } = constructQueryOptions({ + status, + sortByField: queryParams.sortField, + }); + return caseService.findSubCaseStatusStats({ + client, + options: statusQueryOptions ?? {}, + ids, + }); + }), + ]); + + return response.ok({ + body: SubCasesFindResponseRt.encode( + transformSubCases({ + ...subCases, + open, + inProgress, + closed, + // there should only be one entry in the map for the requested case ID + total: subCases.subCasesMap.get(request.params.case_id)?.length ?? 0, + }) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts new file mode 100644 index 00000000000000..b6d9a7345dbdd1 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { SubCaseResponseRt } from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { flattenSubCaseSavedObject, wrapError } from '../../utils'; +import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; +import { countAlertsForID } from '../../../../common'; + +export function initGetSubCaseApi({ caseService, router }: RouteDeps) { + router.get( + { + path: SUB_CASE_DETAILS_URL, + validate: { + params: schema.object({ + case_id: schema.string(), + sub_case_id: schema.string(), + }), + query: schema.object({ + includeComments: schema.boolean({ defaultValue: true }), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const includeComments = request.query.includeComments; + + const subCase = await caseService.getSubCase({ + client, + id: request.params.sub_case_id, + }); + + if (!includeComments) { + return response.ok({ + body: SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + }) + ), + }); + } + + const theComments = await caseService.getAllSubCaseComments({ + client, + id: request.params.sub_case_id, + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, + }); + + return response.ok({ + body: SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + totalAlerts: countAlertsForID({ + comments: theComments, + id: request.params.sub_case_id, + }), + }) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts new file mode 100644 index 00000000000000..ca5cd657a39f32 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -0,0 +1,418 @@ +/* + * 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 Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { + SavedObjectsClientContract, + KibanaRequest, + SavedObject, + SavedObjectsFindResponse, +} from 'kibana/server'; + +import { CaseClient } from '../../../../client'; +import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../../../services'; +import { + CaseStatuses, + SubCasesPatchRequest, + SubCasesPatchRequestRt, + CommentType, + excess, + throwErrors, + SubCasesResponse, + SubCasePatchRequest, + SubCaseAttributes, + ESCaseAttributes, + SubCaseResponse, + SubCasesResponseRt, + User, + CommentAttributes, +} from '../../../../../common/api'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; +import { RouteDeps } from '../../types'; +import { + AlertInfo, + escapeHatch, + flattenSubCaseSavedObject, + isCommentRequestTypeAlertOrGenAlert, + wrapError, +} from '../../utils'; +import { getCaseToUpdate } from '../helpers'; +import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; +import { addAlertInfoToStatusMap } from '../../../../common'; + +interface UpdateArgs { + client: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + request: KibanaRequest; + caseClient: CaseClient; + subCases: SubCasesPatchRequest; +} + +function checkNonExistingOrConflict( + toUpdate: SubCasePatchRequest[], + fromStorage: Map> +) { + const nonExistingSubCases: SubCasePatchRequest[] = []; + const conflictedSubCases: SubCasePatchRequest[] = []; + for (const subCaseToUpdate of toUpdate) { + const bulkEntry = fromStorage.get(subCaseToUpdate.id); + + if (bulkEntry && bulkEntry.error) { + nonExistingSubCases.push(subCaseToUpdate); + } + + if (!bulkEntry || bulkEntry.version !== subCaseToUpdate.version) { + conflictedSubCases.push(subCaseToUpdate); + } + } + + if (nonExistingSubCases.length > 0) { + throw Boom.notFound( + `These sub cases ${nonExistingSubCases + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + if (conflictedSubCases.length > 0) { + throw Boom.conflict( + `These sub cases ${conflictedSubCases + .map((c) => c.id) + .join(', ')} has been updated. Please refresh before saving additional updates.` + ); + } +} + +interface GetParentIDsResult { + ids: string[]; + parentIDToSubID: Map; +} + +function getParentIDs({ + subCasesMap, + subCaseIDs, +}: { + subCasesMap: Map>; + subCaseIDs: string[]; +}): GetParentIDsResult { + return subCaseIDs.reduce( + (acc, id) => { + const subCase = subCasesMap.get(id); + if (subCase && subCase.references.length > 0) { + const parentID = subCase.references[0].id; + acc.ids.push(parentID); + let subIDs = acc.parentIDToSubID.get(parentID); + if (subIDs === undefined) { + subIDs = []; + } + subIDs.push(id); + acc.parentIDToSubID.set(parentID, subIDs); + } + return acc; + }, + { ids: [], parentIDToSubID: new Map() } + ); +} + +async function getParentCases({ + caseService, + client, + subCaseIDs, + subCasesMap, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + subCaseIDs: string[]; + subCasesMap: Map>; +}): Promise>> { + const parentIDInfo = getParentIDs({ subCaseIDs, subCasesMap }); + + const parentCases = await caseService.getCases({ + client, + caseIds: parentIDInfo.ids, + }); + + const parentCaseErrors = parentCases.saved_objects.filter((so) => so.error !== undefined); + + if (parentCaseErrors.length > 0) { + throw Boom.badRequest( + `Unable to find parent cases: ${parentCaseErrors + .map((c) => c.id) + .join(', ')} for sub cases: ${subCaseIDs.join(', ')}` + ); + } + + return parentCases.saved_objects.reduce((acc, so) => { + const subCaseIDsWithParent = parentIDInfo.parentIDToSubID.get(so.id); + subCaseIDsWithParent?.forEach((subCaseID) => { + acc.set(subCaseID, so); + }); + return acc; + }, new Map>()); +} + +function getValidUpdateRequests( + toUpdate: SubCasePatchRequest[], + subCasesMap: Map> +): SubCasePatchRequest[] { + const validatedSubCaseAttributes: SubCasePatchRequest[] = toUpdate.map((updateCase) => { + const currentCase = subCasesMap.get(updateCase.id); + return currentCase != null + ? getCaseToUpdate(currentCase.attributes, { + ...updateCase, + }) + : { id: updateCase.id, version: updateCase.version }; + }); + + return validatedSubCaseAttributes.filter((updateCase: SubCasePatchRequest) => { + const { id, version, ...updateCaseAttributes } = updateCase; + return Object.keys(updateCaseAttributes).length > 0; + }); +} + +/** + * Get the id from a reference in a comment for a sub case + */ +function getID(comment: SavedObject): string | undefined { + return comment.references.find((ref) => ref.type === SUB_CASE_SAVED_OBJECT)?.id; +} + +/** + * Get all the alert comments for a set of sub cases + */ +async function getAlertComments({ + subCasesToSync, + caseService, + client, +}: { + subCasesToSync: SubCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const ids = subCasesToSync.map((subCase) => subCase.id); + return caseService.getAllSubCaseComments({ + client, + id: ids, + options: { + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + }, + }); +} + +/** + * Updates the status of alerts for the specified sub cases. + */ +async function updateAlerts({ + subCasesToSync, + caseService, + client, + caseClient, +}: { + subCasesToSync: SubCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseClient: CaseClient; +}) { + const subCasesToSyncMap = subCasesToSync.reduce((acc, subCase) => { + acc.set(subCase.id, subCase); + return acc; + }, new Map()); + // get all the alerts for all sub cases that need to be synced + const totalAlerts = await getAlertComments({ caseService, client, subCasesToSync }); + // create a map of the status (open, closed, etc) to alert info that needs to be updated + const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { + const id = getID(alertComment); + const status = + id !== undefined + ? subCasesToSyncMap.get(id)?.status ?? CaseStatuses.open + : CaseStatuses.open; + + addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); + } + return acc; + }, new Map()); + + // This does at most 3 calls to Elasticsearch to update the status of the alerts to either open, closed, or in-progress + for (const [status, alertInfo] of alertsToUpdate.entries()) { + if (alertInfo.ids.length > 0 && alertInfo.indices.size > 0) { + caseClient.updateAlertsStatus({ + ids: alertInfo.ids, + status, + indices: alertInfo.indices, + }); + } + } +} + +async function update({ + client, + caseService, + userActionService, + request, + caseClient, + subCases, +}: UpdateArgs): Promise { + const query = pipe( + excess(SubCasesPatchRequestRt).decode(subCases), + fold(throwErrors(Boom.badRequest), identity) + ); + + const bulkSubCases = await caseService.getSubCases({ + client, + ids: query.subCases.map((q) => q.id), + }); + + const subCasesMap = bulkSubCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); + + checkNonExistingOrConflict(query.subCases, subCasesMap); + + const nonEmptySubCaseRequests = getValidUpdateRequests(query.subCases, subCasesMap); + + if (nonEmptySubCaseRequests.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } + + const subIDToParentCase = await getParentCases({ + client, + caseService, + subCaseIDs: nonEmptySubCaseRequests.map((subCase) => subCase.id), + subCasesMap, + }); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = await caseService.getUser({ request }); + const updatedAt = new Date().toISOString(); + const updatedCases = await caseService.patchSubCases({ + client, + subCases: nonEmptySubCaseRequests.map((thisCase) => { + const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; + let closedInfo: { closed_at: string | null; closed_by: User | null } = { + closed_at: null, + closed_by: null, + }; + + if ( + updateSubCaseAttributes.status && + updateSubCaseAttributes.status === CaseStatuses.closed + ) { + closedInfo = { + closed_at: updatedAt, + closed_by: { email, full_name, username }, + }; + } else if ( + updateSubCaseAttributes.status && + (updateSubCaseAttributes.status === CaseStatuses.open || + updateSubCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + subCaseId, + updatedAttributes: { + ...updateSubCaseAttributes, + ...closedInfo, + updated_at: updatedAt, + updated_by: { email, full_name, username }, + }, + version, + }; + }), + }); + + const subCasesToSyncAlertsFor = nonEmptySubCaseRequests.filter((subCaseToUpdate) => { + const storedSubCase = subCasesMap.get(subCaseToUpdate.id); + const parentCase = subIDToParentCase.get(subCaseToUpdate.id); + return ( + storedSubCase !== undefined && + subCaseToUpdate.status !== undefined && + storedSubCase.attributes.status !== subCaseToUpdate.status && + parentCase?.attributes.settings.syncAlerts + ); + }); + + await updateAlerts({ + caseService, + client, + caseClient, + subCasesToSync: subCasesToSyncAlertsFor, + }); + + const returnUpdatedSubCases = updatedCases.saved_objects.reduce( + (acc, updatedSO) => { + const originalSubCase = subCasesMap.get(updatedSO.id); + if (originalSubCase) { + acc.push( + flattenSubCaseSavedObject({ + savedObject: { + ...originalSubCase, + ...updatedSO, + attributes: { ...originalSubCase.attributes, ...updatedSO.attributes }, + references: originalSubCase.references, + version: updatedSO.version ?? originalSubCase.version, + }, + }) + ); + } + return acc; + }, + [] + ); + + await userActionService.postUserActions({ + client, + actions: buildSubCaseUserActions({ + originalSubCases: bulkSubCases.saved_objects, + updatedSubCases: updatedCases.saved_objects, + actionDate: updatedAt, + actionBy: { email, full_name, username }, + }), + }); + + return SubCasesResponseRt.encode(returnUpdatedSubCases); +} + +export function initPatchSubCasesApi({ router, caseService, userActionService }: RouteDeps) { + router.patch( + { + path: SUB_CASES_PATCH_DEL_URL, + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + const caseClient = context.case.getCaseClient(); + const subCases = request.body as SubCasesPatchRequest; + + try { + return response.ok({ + body: await update({ + request, + subCases, + caseClient, + client: context.core.savedObjects.client, + caseService, + userActionService, + }), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 00660e08bbd83a..f2fd986dd8a3ae 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -30,6 +30,19 @@ import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; import { RouteDeps } from './types'; +import { initGetSubCaseApi } from './cases/sub_case/get_sub_case'; +import { initPatchSubCasesApi } from './cases/sub_case/patch_sub_cases'; +import { initFindSubCasesApi } from './cases/sub_case/find_sub_cases'; +import { initDeleteSubCasesApi } from './cases/sub_case/delete_sub_cases'; + +/** + * Default page number when interacting with the saved objects API. + */ +export const defaultPage = 1; +/** + * Default number of results when interacting with the saved objects API. + */ +export const defaultPerPage = 20; export function initCaseApi(deps: RouteDeps) { // Cases @@ -40,6 +53,11 @@ export function initCaseApi(deps: RouteDeps) { initPostCaseApi(deps); initPushCaseApi(deps); initGetAllUserActionsApi(deps); + // Sub cases + initGetSubCaseApi(deps); + initPatchSubCasesApi(deps); + initFindSubCasesApi(deps); + initDeleteSubCasesApi(deps); // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 163116e1316f12..1efec927efb62f 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -10,7 +10,6 @@ import { transformNewComment, wrapError, transformCases, - flattenCaseSavedObjects, flattenCaseSavedObject, flattenCommentSavedObjects, transformComments, @@ -24,7 +23,14 @@ import { mockCaseComments, mockCaseNoConnectorId, } from './__fixtures__/mock_saved_objects'; -import { ConnectorTypes, ESCaseConnector, CommentType, CaseStatuses } from '../../../common/api'; +import { + ConnectorTypes, + ESCaseConnector, + CommentType, + AssociationType, + CaseType, + CaseResponse, +} from '../../../common/api'; describe('Utils', () => { describe('transformNewCase', () => { @@ -40,7 +46,7 @@ describe('Utils', () => { }; it('transform correctly', () => { const myCase = { - newCase, + newCase: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', email: 'elastic@elastic.co', @@ -50,46 +56,112 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly without optional fields', () => { const myCase = { - newCase, + newCase: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', }; const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: undefined, full_name: undefined, username: undefined }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly with optional fields as null', () => { const myCase = { - newCase, + newCase: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', email: null, @@ -99,18 +171,51 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: null, full_name: null, username: null }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); }); }); @@ -123,19 +228,27 @@ describe('Utils', () => { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic', + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly without optional fields', () => { @@ -143,20 +256,28 @@ describe('Utils', () => { comment: 'A comment', type: CommentType.user as const, createdDate: '2020-04-09T09:43:51.778Z', + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: undefined, full_name: undefined, username: undefined }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly with optional fields as null', () => { @@ -167,20 +288,28 @@ describe('Utils', () => { email: null, full_name: null, username: null, + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: null, full_name: null, username: null }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); }); @@ -232,173 +361,200 @@ describe('Utils', () => { describe('transformCases', () => { it('transforms correctly', () => { - const extraCaseData = [ - { caseId: mockCases[0].id, totalComments: 2 }, - { caseId: mockCases[1].id, totalComments: 2 }, - { caseId: mockCases[2].id, totalComments: 2 }, - { caseId: mockCases[3].id, totalComments: 2 }, - ]; - - const res = transformCases( - { - saved_objects: mockCases.map((obj) => ({ ...obj, score: 1 })), - total: mockCases.length, - per_page: 10, - page: 1, - }, - 2, - 2, - 2, - extraCaseData + const casesMap = new Map( + mockCases.map((obj) => { + return [obj.id, flattenCaseSavedObject({ savedObject: obj, totalComment: 2 })]; + }) ); - expect(res).toEqual({ + const res = transformCases({ + casesMap, + countOpenCases: 2, + countInProgressCases: 2, + countClosedCases: 2, page: 1, - per_page: 10, - total: mockCases.length, - cases: flattenCaseSavedObjects( - mockCases.map((obj) => ({ ...obj, score: 1 })), - extraCaseData - ), - count_open_cases: 2, - count_closed_cases: 2, - count_in_progress_cases: 2, + perPage: 10, + total: casesMap.size, }); - }); - }); - - describe('flattenCaseSavedObjects', () => { - it('flattens correctly', () => { - const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 2 }]; - - const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - - expect(res).toEqual([ - { - id: 'mock-id-1', - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 2, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); - }); - - it('it handles total comments correctly when caseId is not in extraCaseData', () => { - const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }]; - const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - - expect(res).toEqual([ - { - id: 'mock-id-1', - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 0, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); - }); - - it('inserts missing connector', () => { - const extraCaseData = [ - { - caseId: mockCaseNoConnectorId.id, - totalComment: 0, - }, - ]; - - // @ts-ignore this is to update old case saved objects to include connector - const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData); - - expect(res).toEqual([ - { - id: mockCaseNoConnectorId.id, - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 0, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(res).toMatchInlineSnapshot(` + Object { + "cases": Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T22:32:00.900Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie destroying data!", + "external_service": null, + "id": "mock-id-2", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "Data Destruction", + ], + "title": "Damaging Data Destruction Detected", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:00.900Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzQsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + Object { + "closed_at": "2019-11-25T22:32:17.947Z", + "closed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + ], + "count_closed_cases": 2, + "count_in_progress_cases": 2, + "count_open_cases": 2, + "page": 1, + "per_page": 10, + "total": 4, + } + `); }); }); @@ -410,17 +566,51 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: myCase.version, - comments: [], - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + } + `); }); it('flattens correctly without version', () => { @@ -431,17 +621,51 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: '0', - comments: [], - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "0", + } + `); }); it('flattens correctly with comments', () => { @@ -453,17 +677,73 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: myCase.version, - comments: flattenCommentSavedObjects(comments), - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [ + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:55:00.177Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "id": "mock-comment-1", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": "2019-11-25T21:55:00.177Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzEsMV0=", + }, + ], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + } + `); }); it('inserts missing connector', () => { @@ -477,40 +757,46 @@ describe('Utils', () => { ...extraCaseData, }); - expect(res).toEqual({ - id: mockCaseNoConnectorId.id, - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 2, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index e2751c05d880ac..bc82f656f477b3 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -18,7 +18,6 @@ import { } from 'kibana/server'; import { - CasePostRequest, CaseResponse, CasesFindResponse, CommentResponse, @@ -28,17 +27,41 @@ import { ESCaseAttributes, CommentRequest, ContextTypeUserRt, - ContextTypeAlertRt, CommentRequestUserType, CommentRequestAlertType, CommentType, excess, throwErrors, CaseStatuses, + CaseClientPostRequest, + AssociationType, + SubCaseAttributes, + SubCaseResponse, + SubCasesFindResponse, + User, + AlertCommentRequestRt, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; -import { SortFieldCase, TotalCommentByCase } from './types'; +import { SortFieldCase } from './types'; + +export const transformNewSubCase = ({ + createdAt, + createdBy, +}: { + createdAt: string; + createdBy: User; +}): SubCaseAttributes => { + return { + closed_at: null, + closed_by: null, + created_at: createdAt, + created_by: createdBy, + status: CaseStatuses.open, + updated_at: null, + updated_by: null, + }; +}; export const transformNewCase = ({ connector, @@ -53,7 +76,7 @@ export const transformNewCase = ({ createdDate: string; email?: string | null; full_name?: string | null; - newCase: CasePostRequest; + newCase: CaseClientPostRequest; username?: string | null; }): ESCaseAttributes => ({ ...newCase, @@ -69,28 +92,93 @@ export const transformNewCase = ({ }); type NewCommentArgs = CommentRequest & { + associationType: AssociationType; createdDate: string; email?: string | null; full_name?: string | null; username?: string | null; }; +/** + * Return the alert IDs from the comment if it is an alert style comment. Otherwise return an empty array. + */ +export const getAlertIds = (comment: CommentRequest): string[] => { + if (isCommentRequestTypeAlertOrGenAlert(comment)) { + return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + } + return []; +}; + +/** + * This structure holds the alert IDs and indices found from multiple alert comments + */ +export interface AlertInfo { + ids: string[]; + indices: Set; +} + +const accumulateIndicesAndIDs = (comment: CommentAttributes, acc: AlertInfo): AlertInfo => { + if (isCommentRequestTypeAlertOrGenAlert(comment)) { + acc.ids.push(...getAlertIds(comment)); + acc.indices.add(comment.index); + } + return acc; +}; + +/** + * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alerts. + */ +export const getAlertIndicesAndIDs = (comments: CommentAttributes[] | undefined): AlertInfo => { + if (comments === undefined) { + return { ids: [], indices: new Set() }; + } + + return comments.reduce( + (acc: AlertInfo, comment) => { + return accumulateIndicesAndIDs(comment, acc); + }, + { ids: [], indices: new Set() } + ); +}; + +/** + * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alert saved objects. + */ +export const getAlertIndicesAndIDsFromSO = ( + comments: SavedObjectsFindResponse | undefined +): AlertInfo => { + if (comments === undefined) { + return { ids: [], indices: new Set() }; + } + + return comments.saved_objects.reduce( + (acc: AlertInfo, comment) => { + return accumulateIndicesAndIDs(comment.attributes, acc); + }, + { ids: [], indices: new Set() } + ); +}; + export const transformNewComment = ({ + associationType, createdDate, email, // eslint-disable-next-line @typescript-eslint/naming-convention full_name, username, ...comment -}: NewCommentArgs): CommentAttributes => ({ - ...comment, - created_at: createdDate, - created_by: { email, full_name, username }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, -}); +}: NewCommentArgs): CommentAttributes => { + return { + associationType, + ...comment, + created_at: createdDate, + created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; +}; export function wrapError(error: any): CustomHttpResponseOptions { const options = { statusCode: error.statusCode ?? 500 }; @@ -102,52 +190,99 @@ export function wrapError(error: any): CustomHttpResponseOptions }; } -export const transformCases = ( - cases: SavedObjectsFindResponse, - countOpenCases: number, - countInProgressCases: number, - countClosedCases: number, - totalCommentByCase: TotalCommentByCase[] -): CasesFindResponse => ({ - page: cases.page, - per_page: cases.per_page, - total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), +export const transformCases = ({ + casesMap, + countOpenCases, + countInProgressCases, + countClosedCases, + page, + perPage, + total, +}: { + casesMap: Map; + countOpenCases: number; + countInProgressCases: number; + countClosedCases: number; + page: number; + perPage: number; + total: number; +}): CasesFindResponse => ({ + page, + per_page: perPage, + total, + cases: Array.from(casesMap.values()), count_open_cases: countOpenCases, count_in_progress_cases: countInProgressCases, count_closed_cases: countClosedCases, }); -export const flattenCaseSavedObjects = ( - savedObjects: Array>, - totalCommentByCase: TotalCommentByCase[] -): CaseResponse[] => - savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { - return [ - ...acc, - flattenCaseSavedObject({ - savedObject, - totalComment: - totalCommentByCase.find((tc) => tc.caseId === savedObject.id)?.totalComments ?? 0, - }), - ]; - }, []); +export const transformSubCases = ({ + subCasesMap, + open, + inProgress, + closed, + page, + perPage, + total, +}: { + subCasesMap: Map; + open: number; + inProgress: number; + closed: number; + page: number; + perPage: number; + total: number; +}): SubCasesFindResponse => ({ + page, + per_page: perPage, + total, + // Squish all the entries in the map together as one array + subCases: Array.from(subCasesMap.values()).flat(), + count_open_cases: open, + count_in_progress_cases: inProgress, + count_closed_cases: closed, +}); export const flattenCaseSavedObject = ({ savedObject, comments = [], totalComment = comments.length, + totalAlerts = 0, + subCases, }: { savedObject: SavedObject; comments?: Array>; totalComment?: number; + totalAlerts?: number; + subCases?: SubCaseResponse[]; }): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), totalComment, + totalAlerts, ...savedObject.attributes, connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), + subCases, +}); + +export const flattenSubCaseSavedObject = ({ + savedObject, + comments = [], + totalComment = comments.length, + totalAlerts = 0, +}: { + savedObject: SavedObject; + comments?: Array>; + totalComment?: number; + totalAlerts?: number; +}): SubCaseResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', + comments: flattenCommentSavedObjects(comments), + totalComment, + totalAlerts, + ...savedObject.attributes, }); export const transformComments = ( @@ -174,7 +309,7 @@ export const flattenCommentSavedObject = ( ...savedObject.attributes, }); -export const sortToSnake = (sortField: string): SortFieldCase => { +export const sortToSnake = (sortField: string | undefined): SortFieldCase => { switch (sortField) { case 'status': return SortFieldCase.status; @@ -191,18 +326,41 @@ export const sortToSnake = (sortField: string): SortFieldCase => { export const escapeHatch = schema.object({}, { unknowns: 'allow' }); -export const isUserContext = (context: CommentRequest): context is CommentRequestUserType => { +/** + * A type narrowing function for user comments. Exporting so integration tests can use it. + */ +export const isCommentRequestTypeUser = ( + context: CommentRequest +): context is CommentRequestUserType => { return context.type === CommentType.user; }; -export const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => { - return context.type === CommentType.alert; +/** + * A type narrowing function for alert comments. Exporting so integration tests can use it. + */ +export const isCommentRequestTypeAlertOrGenAlert = ( + context: CommentRequest +): context is CommentRequestAlertType => { + return context.type === CommentType.alert || context.type === CommentType.generatedAlert; +}; + +/** + * This is used to test if the posted comment is an generated alert. A generated alert will have one or many alerts. + * An alert is essentially an object with a _id field. This differs from a regular attached alert because the _id is + * passed directly in the request, it won't be in an object. Internally case will strip off the outer object and store + * both a generated and user attached alert in the same structure but this function is useful to determine which + * structure the new alert in the request has. + */ +export const isCommentRequestTypeGenAlert = ( + context: CommentRequest +): context is CommentRequestAlertType => { + return context.type === CommentType.generatedAlert; }; -export const decodeComment = (comment: CommentRequest) => { - if (isUserContext(comment)) { +export const decodeCommentRequest = (comment: CommentRequest) => { + if (isCommentRequestTypeUser(comment)) { pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isAlertContext(comment)) { - pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isCommentRequestTypeAlertOrGenAlert(comment)) { + pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); } }; diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 64ee692d447986..5f413ea27c4a75 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -118,7 +118,10 @@ export const caseSavedObjectType: SavedObjectsType = { tags: { type: 'keyword', }, - + // collection or individual + type: { + type: 'keyword', + }, updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 336a1bdd172c60..9eabf744f2e132 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -16,6 +16,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { namespaceType: 'single', mappings: { properties: { + associationType: { + type: 'keyword', + }, comment: { type: 'text', }, diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts index 9599cbef9709d7..91f104335df8b8 100644 --- a/x-pack/plugins/case/server/saved_object_types/index.ts +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -6,6 +6,7 @@ */ export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; +export { subCaseSavedObjectType, SUB_CASE_SAVED_OBJECT } from './sub_case'; export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure'; export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions'; diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index fc7ffb34776ae9..a0b22c49d0bc6d 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -8,7 +8,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; -import { ConnectorTypes, CommentType } from '../../common/api'; +import { ConnectorTypes, CommentType, CaseType, AssociationType } from '../../common/api'; interface UnsanitizedCaseConnector { connector_id: string; @@ -49,6 +49,10 @@ interface SanitizedCaseSettings { }; } +interface SanitizedCaseType { + type: string; +} + export const caseMigrations = { '7.10.0': ( doc: SavedObjectUnsanitizedDoc @@ -83,6 +87,18 @@ export const caseMigrations = { references: doc.references || [], }; }, + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + type: CaseType.individual, + }, + references: doc.references || [], + }; + }, }; export const configureMigrations = { @@ -157,6 +173,10 @@ interface SanitizedComment { type: CommentType; } +interface SanitizedCommentAssociationType { + associationType: AssociationType; +} + export const commentsMigrations = { '7.11.0': ( doc: SavedObjectUnsanitizedDoc @@ -170,4 +190,16 @@ export const commentsMigrations = { references: doc.references || [], }; }, + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + associationType: AssociationType.case, + }, + references: doc.references || [], + }; + }, }; diff --git a/x-pack/plugins/case/server/saved_object_types/sub_case.ts b/x-pack/plugins/case/server/saved_object_types/sub_case.ts new file mode 100644 index 00000000000000..da89b19346e4e1 --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/sub_case.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsType } from 'src/core/server'; + +export const SUB_CASE_SAVED_OBJECT = 'cases-sub-case'; + +export const subCaseSavedObjectType: SavedObjectsType = { + name: SUB_CASE_SAVED_OBJECT, + hidden: false, + namespaceType: 'single', + mappings: { + properties: { + closed_at: { + type: 'date', + }, + closed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + status: { + type: 'keyword', + }, + updated_at: { + type: 'date', + }, + updated_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/scripts/mock/case/post_case.json b/x-pack/plugins/case/server/scripts/mock/case/post_case.json index 743fa396295ca7..bed342dd69fe9d 100644 --- a/x-pack/plugins/case/server/scripts/mock/case/post_case.json +++ b/x-pack/plugins/case/server/scripts/mock/case/post_case.json @@ -3,5 +3,14 @@ "title": "Bad meanie defacing data", "tags": [ "defacement" - ] + ], + "connector": { + "id": "none", + "name": "none", + "type": ".none", + "fields": null + }, + "settings": { + "syncAlerts": true + } } diff --git a/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json b/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json index 13efe436a640d5..58fee92859bf97 100644 --- a/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json +++ b/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json @@ -3,5 +3,14 @@ "title": "Another bad dude", "tags": [ "phishing" - ] + ], + "connector": { + "id": "none", + "name": "none", + "type": ".none", + "fields": null + }, + "settings": { + "syncAlerts": true + } } diff --git a/x-pack/plugins/case/server/scripts/sub_cases/README.md b/x-pack/plugins/case/server/scripts/sub_cases/README.md new file mode 100644 index 00000000000000..92873b8f037f3f --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/README.md @@ -0,0 +1,80 @@ +# Sub Cases Helper Script + +This script makes interacting with sub cases easier (creating, deleting, retrieving, etc). + +To run the script, first `cd x-pack/plugins/case/server/scripts` + +## Showing the help + +```bash +yarn test:sub-cases help +``` + +Sub command help + +```bash +yarn test:sub-cases help +``` + +## Generating alerts + +This will generate a new case and sub case if one does not exist and then attach a group +of alerts to it. + +```bash +yarn test:sub-cases alerts --ids id1 id2 id3 +``` + +## Deleting a collection + +This will delete a case that has sub cases. + +```bash +yarn test:sub-cases delete +``` + +## Find sub cases + +This will find sub cases attached to a collection. + +```bash +yarn test:sub-cases find [status] +``` + +Example: + +```bash +yarn test:sub-cases find 6c9e0490-64dc-11eb-92be-09d246866276 +``` + +Response: + +```bash +{ + "page": 1, + "per_page": 1, + "total": 1, + "subCases": [ + { + "id": "6dd6d2b0-64dc-11eb-92be-09d246866276", + "version": "WzUzNDMsMV0=", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "closed_at": null, + "closed_by": null, + "created_at": "2021-02-01T22:25:46.323Z", + "status": "open", + "updated_at": "2021-02-01T22:25:46.323Z", + "updated_by": { + "full_name": null, + "email": null, + "username": "elastic" + } + } + ], + "count_open_cases": 0, + "count_in_progress_cases": 0, + "count_closed_cases": 0 +} +``` diff --git a/x-pack/plugins/case/server/scripts/sub_cases/generator.js b/x-pack/plugins/case/server/scripts/sub_cases/generator.js new file mode 100644 index 00000000000000..0c5b8bfc8550b1 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/generator.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +require('../../../../../../src/setup_node_env'); +require('./index'); diff --git a/x-pack/plugins/case/server/scripts/sub_cases/index.ts b/x-pack/plugins/case/server/scripts/sub_cases/index.ts new file mode 100644 index 00000000000000..2ea9718d18487e --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -0,0 +1,217 @@ +/* + * 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. + */ +/* eslint-disable no-console */ +import yargs from 'yargs'; +import { KbnClient, ToolingLog } from '@kbn/dev-utils'; +import { + CaseResponse, + CaseType, + CollectionWithSubCaseResponse, + ConnectorTypes, +} from '../../../common/api'; +import { CommentType } from '../../../common/api/cases/comment'; +import { CASES_URL } from '../../../common/constants'; +import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common'; + +main(); + +function createClient(argv: any): KbnClient { + return new KbnClient({ + log: new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }), + url: argv.kibana, + }); +} + +async function handleFind(argv: any) { + const client = createClient(argv); + + try { + const res = await client.request({ + path: `${CASES_URL}/${argv.caseID}/sub_cases/_find`, + method: 'GET', + query: { + status: argv.status, + }, + }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (e) { + console.error(e); + throw e; + } +} + +async function handleDelete(argv: any) { + const client = createClient(argv); + + try { + await client.request({ + path: `${CASES_URL}?ids=["${argv.id}"]`, + method: 'DELETE', + query: { + force: true, + }, + }); + } catch (e) { + console.error(e); + throw e; + } +} + +async function handleGenGroupAlerts(argv: any) { + const client = createClient(argv); + + try { + const createdAction = await client.request({ + path: '/api/actions/action', + method: 'POST', + body: { + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }, + }); + + let caseID: string | undefined = argv.caseID as string | undefined; + + if (!caseID) { + console.log('Creating new case'); + const newCase = await client.request({ + path: CASES_URL, + method: 'POST', + body: { + description: 'This is a brand new case from generator script', + type: CaseType.collection, + title: 'Super Bad Security Issue', + tags: ['defacement'], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + }, + }); + caseID = newCase.data.id; + } + + console.log('Case id: ', caseID); + const executeResp = await client.request< + ActionTypeExecutorResult + >({ + path: `/api/actions/action/${createdAction.data.id}/_execute`, + method: 'POST', + body: { + params: { + subAction: 'addComment', + subActionParams: { + caseId: caseID, + comment: { + type: CommentType.generatedAlert, + alerts: argv.ids.map((id: string) => ({ _id: id })), + index: argv.signalsIndex, + }, + }, + }, + }, + }); + + if (executeResp.data.status !== 'ok') { + console.log( + 'Error received from actions api during execute: ', + JSON.stringify(executeResp.data, null, 2) + ); + process.exit(1); + } + + console.log('Execution response ', JSON.stringify(executeResp.data, null, 2)); + } catch (e) { + console.error(e); + throw e; + } +} + +async function main() { + // This returns before the async handlers do + // We need to convert this to commander instead I think + yargs(process.argv.slice(2)) + .help() + .options({ + kibana: { + alias: 'k', + describe: 'kibana url', + default: 'http://elastic:changeme@localhost:5601', + type: 'string', + }, + }) + .command({ + command: 'alerts', + aliases: ['gen', 'genAlerts'], + describe: 'generate a group of alerts', + builder: (args) => { + return args + .options({ + caseID: { + alias: 'c', + describe: 'case ID', + }, + ids: { + alias: 'a', + describe: 'alert ids', + type: 'array', + }, + signalsIndex: { + alias: 'i', + describe: 'siem signals index', + type: 'string', + default: '.siem-signals-default', + }, + }) + .demandOption(['ids']); + }, + handler: async (args) => { + return handleGenGroupAlerts(args); + }, + }) + .command({ + command: 'delete ', + describe: 'deletes a case', + builder: (args) => { + return args.positional('id', { + describe: 'case id', + type: 'string', + }); + }, + handler: async (args) => { + return handleDelete(args); + }, + }) + .command({ + command: 'find [status]', + describe: 'gets all sub cases', + builder: (args) => { + return args + .positional('caseID', { describe: 'case id', type: 'string' }) + .positional('status', { + describe: 'filter by status', + type: 'string', + }); + }, + handler: async (args) => { + return handleFind(args); + }, + }) + .demandCommand() + .parse(); + + console.log('completed'); +} diff --git a/x-pack/plugins/case/server/services/alerts/index.test.ts b/x-pack/plugins/case/server/services/alerts/index.test.ts index 2787d855a4c0de..35aa3ff80efc1e 100644 --- a/x-pack/plugins/case/server/services/alerts/index.test.ts +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -6,20 +6,21 @@ */ import { KibanaRequest } from 'kibana/server'; -import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; import { CaseStatuses } from '../../../common/api'; import { AlertService, AlertServiceContract } from '.'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; describe('updateAlertsStatus', () => { - const esClientMock = elasticsearchServiceMock.createClusterClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); describe('happy path', () => { let alertService: AlertServiceContract; const args = { ids: ['alert-id-1'], - index: '.siem-signals', + indices: new Set(['.siem-signals']), request: {} as KibanaRequest, status: CaseStatuses.closed, + scopedClusterClient: esClient, }; beforeEach(async () => { @@ -28,30 +29,29 @@ describe('updateAlertsStatus', () => { }); test('it update the status of the alert correctly', async () => { - alertService.initialize(esClientMock); await alertService.updateAlertsStatus(args); - expect(esClientMock.asScoped().asCurrentUser.updateByQuery).toHaveBeenCalledWith({ + expect(esClient.updateByQuery).toHaveBeenCalledWith({ body: { query: { ids: { values: args.ids } }, script: { lang: 'painless', source: `ctx._source.signal.status = '${args.status}'` }, }, conflicts: 'abort', ignore_unavailable: true, - index: args.index, + index: [...args.indices], }); }); describe('unhappy path', () => { - test('it throws when service is already initialized', async () => { - alertService.initialize(esClientMock); - expect(() => { - alertService.initialize(esClientMock); - }).toThrow(); - }); - - test('it throws when service is not initialized and try to update the status', async () => { - await expect(alertService.updateAlertsStatus(args)).rejects.toThrow(); + it('ignores empty indices', async () => { + expect( + await alertService.updateAlertsStatus({ + ids: ['alert-id-1'], + status: CaseStatuses.closed, + indices: new Set(['']), + scopedClusterClient: esClient, + }) + ).toBeUndefined(); }); }); }); diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index 2776d6b40761e1..320d32ac0d7887 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -5,24 +5,26 @@ * 2.0. */ +import _ from 'lodash'; + import type { PublicMethodsOf } from '@kbn/utility-types'; -import { IClusterClient, KibanaRequest } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; export type AlertServiceContract = PublicMethodsOf; interface UpdateAlertsStatusArgs { - request: KibanaRequest; ids: string[]; status: CaseStatuses; - index: string; + indices: Set; + scopedClusterClient: ElasticsearchClient; } interface GetAlertsArgs { - request: KibanaRequest; ids: string[]; - index: string; + indices: Set; + scopedClusterClient: ElasticsearchClient; } interface Alert { @@ -37,29 +39,32 @@ interface AlertsResponse { }; } -export class AlertService { - private isInitialized = false; - private esClient?: IClusterClient; +/** + * remove empty strings from the indices, I'm not sure how likely this is but in the case that + * the document doesn't have _index set the security_solution code sets the value to an empty string + * instead + */ +function getValidIndices(indices: Set): string[] { + return [...indices].filter((index) => !_.isEmpty(index)); +} +export class AlertService { constructor() {} - public initialize(esClient: IClusterClient) { - if (this.isInitialized) { - throw new Error('AlertService already initialized'); + public async updateAlertsStatus({ + ids, + status, + indices, + scopedClusterClient, + }: UpdateAlertsStatusArgs) { + const sanitizedIndices = getValidIndices(indices); + if (sanitizedIndices.length <= 0) { + // log that we only had invalid indices + return; } - this.isInitialized = true; - this.esClient = esClient; - } - - public async updateAlertsStatus({ request, ids, status, index }: UpdateAlertsStatusArgs) { - if (!this.isInitialized) { - throw new Error('AlertService not initialized'); - } - - // The above check makes sure that esClient is defined. - const result = await this.esClient!.asScoped(request).asCurrentUser.updateByQuery({ - index, + const result = await scopedClusterClient.updateByQuery({ + index: sanitizedIndices, conflicts: 'abort', body: { script: { @@ -74,13 +79,17 @@ export class AlertService { return result; } - public async getAlerts({ request, ids, index }: GetAlertsArgs): Promise { - if (!this.isInitialized) { - throw new Error('AlertService not initialized'); + public async getAlerts({ + scopedClusterClient, + ids, + indices, + }: GetAlertsArgs): Promise { + const index = getValidIndices(indices); + if (index.length <= 0) { + return; } - // The above check makes sure that esClient is defined. - const result = await this.esClient!.asScoped(request).asCurrentUser.search({ + const result = await scopedClusterClient.search({ index, body: { query: { diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index a2ad98b6035525..a9e5c269608308 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -7,7 +7,6 @@ import { KibanaRequest, - KibanaResponseFactory, Logger, SavedObject, SavedObjectsClientContract, @@ -16,6 +15,7 @@ import { SavedObjectReference, SavedObjectsBulkUpdateResponse, SavedObjectsBulkResponse, + SavedObjectsFindResult, } from 'kibana/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; @@ -25,8 +25,26 @@ import { SavedObjectFindOptions, User, CommentPatchAttributes, + SubCaseAttributes, + AssociationType, + SubCaseResponse, + CommentType, + CaseType, + CaseResponse, + caseTypeField, } from '../../common/api'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; +import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; +import { defaultPage, defaultPerPage } from '../routes/api'; +import { + flattenCaseSavedObject, + flattenSubCaseSavedObject, + transformNewSubCase, +} from '../routes/api/utils'; +import { + CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../saved_object_types'; import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; @@ -45,20 +63,50 @@ interface PushedArgs { } interface GetCaseArgs extends ClientArgs { - caseId: string; + id: string; } interface GetCasesArgs extends ClientArgs { caseIds: string[]; } -interface FindCommentsArgs extends GetCaseArgs { +interface GetSubCasesArgs extends ClientArgs { + ids: string[]; +} + +interface FindCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptions; +} + +interface FindCaseCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptions; + includeSubCaseComments?: boolean; +} + +interface FindSubCaseCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; options?: SavedObjectFindOptions; } interface FindCasesArgs extends ClientArgs { options?: SavedObjectFindOptions; } + +interface FindSubCasesByIDArgs extends FindCasesArgs { + ids: string[]; +} + +interface FindSubCasesStatusStats { + client: SavedObjectsClientContract; + options: SavedObjectFindOptions; + ids: string[]; +} + interface GetCommentArgs extends ClientArgs { commentId: string; } @@ -67,6 +115,12 @@ interface PostCaseArgs extends ClientArgs { attributes: ESCaseAttributes; } +interface CreateSubCaseArgs extends ClientArgs { + createdAt: string; + caseId: string; + createdBy: User; +} + interface PostCommentArgs extends ClientArgs { attributes: CommentAttributes; references: SavedObjectReference[]; @@ -95,20 +149,69 @@ interface PatchComments extends ClientArgs { comments: PatchComment[]; } +interface PatchSubCase { + client: SavedObjectsClientContract; + subCaseId: string; + updatedAttributes: Partial; + version?: string; +} + +interface PatchSubCases { + client: SavedObjectsClientContract; + subCases: Array>; +} + interface GetUserArgs { request: KibanaRequest; - response?: KibanaResponseFactory; } -interface CaseServiceDeps { - authentication: SecurityPluginSetup['authc'] | null; +interface SubCasesMapWithPageInfo { + subCasesMap: Map; + page: number; + perPage: number; +} + +interface CaseCommentStats { + commentTotals: Map; + alertTotals: Map; +} + +interface FindCommentsByAssociationArgs { + client: SavedObjectsClientContract; + id: string | string[]; + associationType: AssociationType; + options?: SavedObjectFindOptions; +} + +interface Collection { + case: SavedObjectsFindResult; + subCases?: SubCaseResponse[]; } + +interface CasesMapWithPageInfo { + casesMap: Map; + page: number; + perPage: number; +} + export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; + deleteSubCase(client: SavedObjectsClientContract, id: string): Promise<{}>; findCases(args: FindCasesArgs): Promise>; - getAllCaseComments(args: FindCommentsArgs): Promise>; + findSubCases(args: FindCasesArgs): Promise>; + findSubCasesByCaseId( + args: FindSubCasesByIDArgs + ): Promise>; + getAllCaseComments( + args: FindCaseCommentsArgs + ): Promise>; + getAllSubCaseComments( + args: FindSubCaseCommentsArgs + ): Promise>; getCase(args: GetCaseArgs): Promise>; + getSubCase(args: GetCaseArgs): Promise>; + getSubCases(args: GetSubCasesArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; getTags(args: ClientArgs): Promise; @@ -120,205 +223,902 @@ export interface CaseServiceSetup { patchCases(args: PatchCasesArgs): Promise>; patchComment(args: UpdateCommentArgs): Promise>; patchComments(args: PatchComments): Promise>; + getMostRecentSubCase( + client: SavedObjectsClientContract, + caseId: string + ): Promise | undefined>; + createSubCase(args: CreateSubCaseArgs): Promise>; + patchSubCase(args: PatchSubCase): Promise>; + patchSubCases(args: PatchSubCases): Promise>; + findSubCaseStatusStats(args: FindSubCasesStatusStats): Promise; + getCommentsByAssociation( + args: FindCommentsByAssociationArgs + ): Promise>; + getCaseCommentStats(args: { + client: SavedObjectsClientContract; + ids: string[]; + associationType: AssociationType; + }): Promise; + findSubCasesGroupByCase(args: { + client: SavedObjectsClientContract; + options?: SavedObjectFindOptions; + ids: string[]; + }): Promise; + findCaseStatusStats(args: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise; + findCasesGroupedByID(args: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise; } -export class CaseService { - constructor(private readonly log: Logger) {} - public setup = async ({ authentication }: CaseServiceDeps): Promise => ({ - deleteCase: async ({ client, caseId }: GetCaseArgs) => { - try { - this.log.debug(`Attempting to GET case ${caseId}`); - return await client.delete(CASE_SAVED_OBJECT, caseId); - } catch (error) { - this.log.debug(`Error on GET case ${caseId}: ${error}`); - throw error; - } - }, - deleteComment: async ({ client, commentId }: GetCommentArgs) => { - try { - this.log.debug(`Attempting to GET comment ${commentId}`); - return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); - } catch (error) { - this.log.debug(`Error on GET comment ${commentId}: ${error}`); - throw error; - } - }, - getCase: async ({ client, caseId }: GetCaseArgs) => { - try { - this.log.debug(`Attempting to GET case ${caseId}`); - return await client.get(CASE_SAVED_OBJECT, caseId); - } catch (error) { - this.log.debug(`Error on GET case ${caseId}: ${error}`); - throw error; +export class CaseService implements CaseServiceSetup { + constructor( + private readonly log: Logger, + private readonly authentication?: SecurityPluginSetup['authc'] + ) {} + + /** + * Returns a map of all cases combined with their sub cases if they are collections. + */ + public async findCasesGroupedByID({ + client, + caseOptions, + subCaseOptions, + }: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise { + const cases = await this.findCases({ + client, + options: caseOptions, + }); + + const subCasesResp = await this.findSubCasesGroupByCase({ + client, + options: subCaseOptions, + ids: cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) + .map((caseInfo) => caseInfo.id), + }); + + const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { + const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); + + /** + * This will include empty collections unless the query explicitly requested type === CaseType.individual, in which + * case we'd not have any collections anyway. + */ + accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); + return accMap; + }, new Map()); + + /** + * One potential optimization here is to get all comment stats for individual cases, parent cases, and sub cases + * in a single request. This can be done because comments that are for sub cases have a reference to both the sub case + * and the parent. The associationType field allows us to determine which type of case the comment is attached to. + * + * So we could use the ids for all the valid cases (individual cases and parents with sub cases) to grab everything. + * Once we have it we can build the maps. + * + * Currently we get all comment stats for all sub cases in one go and we get all comment stats for cases (individual and parent) + * in another request (the one below this comment). + */ + const totalCommentsForCases = await this.getCaseCommentStats({ + client, + ids: Array.from(casesMap.keys()), + associationType: AssociationType.case, + }); + + const casesWithComments = new Map(); + for (const [id, caseInfo] of casesMap.entries()) { + casesWithComments.set( + id, + flattenCaseSavedObject({ + savedObject: caseInfo.case, + totalComment: totalCommentsForCases.commentTotals.get(id) ?? 0, + totalAlerts: totalCommentsForCases.alertTotals.get(id) ?? 0, + subCases: caseInfo.subCases, + }) + ); + } + + return { + casesMap: casesWithComments, + page: cases.page, + perPage: cases.per_page, + }; + } + + /** + * Retrieves the number of cases that exist with a given status (open, closed, etc). + * This also counts sub cases. Parent cases are excluded from the statistics. + */ + public async findCaseStatusStats({ + client, + caseOptions, + subCaseOptions, + }: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise { + const casesStats = await this.findCases({ + client, + options: { + ...caseOptions, + fields: [], + page: 1, + perPage: 1, + }, + }); + + /** + * This could be made more performant. What we're doing here is retrieving all cases + * that match the API request's filters instead of just counts. This is because we need to grab + * the ids for the parent cases that match those filters. Then we use those IDS to count how many + * sub cases those parents have to calculate the total amount of cases that are open, closed, or in-progress. + * + * Another solution would be to store ALL filterable fields on both a case and sub case. That we could do a single + * query for each type to calculate the totals using the filters. This has drawbacks though: + * + * We'd have to sync up the parent case's editable attributes with the sub case any time they were change to avoid + * them getting out of sync and causing issues when we do these types of stats aggregations. This would result in a lot + * of update requests if the user is editing their case details often. Which could potentially cause conflict failures. + * + * Another option is to prevent the ability from update the parent case's details all together once it's created. A user + * could instead modify the sub case details directly. This could be weird though because individual sub cases for the same + * parent would have different titles, tags, etc. + * + * Another potential issue with this approach is when you push a case and all its sub case information. If the sub cases + * don't have the same title and tags, we'd need to account for that as well. + */ + const cases = await this.findCases({ + client, + options: { + ...caseOptions, + fields: [caseTypeField], + page: 1, + perPage: casesStats.total, + }, + }); + + const caseIds = cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) + .map((caseInfo) => caseInfo.id); + + let subCasesTotal = 0; + + if (subCaseOptions) { + subCasesTotal = await this.findSubCaseStatusStats({ + client, + options: subCaseOptions, + ids: caseIds, + }); + } + + const total = + cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.collection) + .length + subCasesTotal; + + return total; + } + + /** + * Retrieves the comments attached to a case or sub case. + */ + public async getCommentsByAssociation({ + client, + id, + associationType, + options, + }: FindCommentsByAssociationArgs): Promise> { + if (associationType === AssociationType.subCase) { + return this.getAllSubCaseComments({ + client, + id, + options, + }); + } else { + return this.getAllCaseComments({ + client, + id, + options, + }); + } + } + + /** + * Returns the number of total comments and alerts for a case (or sub case) + */ + public async getCaseCommentStats({ + client, + ids, + associationType, + }: { + client: SavedObjectsClientContract; + ids: string[]; + associationType: AssociationType; + }): Promise { + if (ids.length <= 0) { + return { + commentTotals: new Map(), + alertTotals: new Map(), + }; + } + + const refType = + associationType === AssociationType.case ? CASE_SAVED_OBJECT : SUB_CASE_SAVED_OBJECT; + + const allComments = await Promise.all( + ids.map((id) => + this.getCommentsByAssociation({ + client, + associationType, + id, + options: { page: 1, perPage: 1 }, + }) + ) + ); + + const alerts = await this.getCommentsByAssociation({ + client, + associationType, + id: ids, + options: { + filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert})`, + }, + }); + + const getID = (comments: SavedObjectsFindResponse) => { + return comments.saved_objects.length > 0 + ? comments.saved_objects[0].references.find((ref) => ref.type === refType)?.id + : undefined; + }; + + const groupedComments = allComments.reduce((acc, comments) => { + const id = getID(comments); + if (id) { + acc.set(id, comments.total); } - }, - getCases: async ({ client, caseIds }: GetCasesArgs) => { - try { - this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); - return await client.bulkGet( - caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) - ); - } catch (error) { - this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); - throw error; + return acc; + }, new Map()); + + const groupedAlerts = groupTotalAlertsByID({ comments: alerts }); + return { commentTotals: groupedComments, alertTotals: groupedAlerts }; + } + + /** + * Returns all the sub cases for a set of case IDs. Comment statistics are also returned. + */ + public async findSubCasesGroupByCase({ + client, + options, + ids, + }: { + client: SavedObjectsClientContract; + options?: SavedObjectFindOptions; + ids: string[]; + }): Promise { + const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { + return subCase.references.length > 0 ? subCase.references[0].id : undefined; + }; + + const emptyResponse = { + subCasesMap: new Map(), + page: 0, + perPage: 0, + }; + + if (!options) { + return emptyResponse; + } + + if (ids.length <= 0) { + return emptyResponse; + } + + const subCases = await this.findSubCases({ + client, + options: { + ...options, + hasReference: ids.map((id) => { + return { + id, + type: CASE_SAVED_OBJECT, + }; + }), + }, + }); + + const subCaseComments = await this.getCaseCommentStats({ + client, + ids: subCases.saved_objects.map((subCase) => subCase.id), + associationType: AssociationType.subCase, + }); + + const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { + const parentCaseID = getCaseID(subCase); + if (parentCaseID) { + const subCaseFromMap = accMap.get(parentCaseID); + + if (subCaseFromMap === undefined) { + const subCasesForID = [ + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, + }), + ]; + accMap.set(parentCaseID, subCasesForID); + } else { + subCaseFromMap.push( + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, + }) + ); + } } - }, - getComment: async ({ client, commentId }: GetCommentArgs) => { - try { - this.log.debug(`Attempting to GET comment ${commentId}`); - return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); - } catch (error) { - this.log.debug(`Error on GET comment ${commentId}: ${error}`); - throw error; + return accMap; + }, new Map()); + + return { subCasesMap, page: subCases.page, perPage: subCases.per_page }; + } + + /** + * Calculates the number of sub cases for a given set of options for a set of case IDs. + */ + public async findSubCaseStatusStats({ + client, + options, + ids, + }: FindSubCasesStatusStats): Promise { + if (ids.length <= 0) { + return 0; + } + + const subCases = await this.findSubCases({ + client, + options: { + ...options, + page: 1, + perPage: 1, + fields: [], + hasReference: ids.map((id) => { + return { + id, + type: CASE_SAVED_OBJECT, + }; + }), + }, + }); + + return subCases.total; + } + + public async createSubCase({ + client, + createdAt, + caseId, + createdBy, + }: CreateSubCaseArgs): Promise> { + try { + this.log.debug(`Attempting to POST a new sub case`); + return client.create(SUB_CASE_SAVED_OBJECT, transformNewSubCase({ createdAt, createdBy }), { + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + ], + }); + } catch (error) { + this.log.debug(`Error on POST a new sub case: ${error}`); + throw error; + } + } + + public async getMostRecentSubCase(client: SavedObjectsClientContract, caseId: string) { + try { + this.log.debug(`Attempting to find most recent sub case for caseID: ${caseId}`); + const subCases: SavedObjectsFindResponse = await client.find({ + perPage: 1, + sortField: 'created_at', + sortOrder: 'desc', + type: SUB_CASE_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + }); + if (subCases.saved_objects.length <= 0) { + return; } - }, - findCases: async ({ client, options }: FindCasesArgs) => { - try { - this.log.debug(`Attempting to GET all cases`); - return await client.find({ ...options, type: CASE_SAVED_OBJECT }); - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; + + return subCases.saved_objects[0]; + } catch (error) { + this.log.debug(`Error finding the most recent sub case for case: ${caseId}`); + throw error; + } + } + + public async deleteSubCase(client: SavedObjectsClientContract, id: string) { + try { + this.log.debug(`Attempting to DELETE sub case ${id}`); + return await client.delete(SUB_CASE_SAVED_OBJECT, id); + } catch (error) { + this.log.debug(`Error on DELETE sub case ${id}: ${error}`); + throw error; + } + } + + public async deleteCase({ client, id: caseId }: GetCaseArgs) { + try { + this.log.debug(`Attempting to DELETE case ${caseId}`); + return await client.delete(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on DELETE case ${caseId}: ${error}`); + throw error; + } + } + public async deleteComment({ client, commentId }: GetCommentArgs) { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + } + public async getCase({ + client, + id: caseId, + }: GetCaseArgs): Promise> { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.get(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + } + public async getSubCase({ client, id }: GetCaseArgs): Promise> { + try { + this.log.debug(`Attempting to GET sub case ${id}`); + return await client.get(SUB_CASE_SAVED_OBJECT, id); + } catch (error) { + this.log.debug(`Error on GET sub case ${id}: ${error}`); + throw error; + } + } + + public async getSubCases({ + client, + ids, + }: GetSubCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET sub cases ${ids.join(', ')}`); + return await client.bulkGet(ids.map((id) => ({ type: SUB_CASE_SAVED_OBJECT, id }))); + } catch (error) { + this.log.debug(`Error on GET cases ${ids.join(', ')}: ${error}`); + throw error; + } + } + + public async getCases({ + client, + caseIds, + }: GetCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); + return await client.bulkGet( + caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) + ); + } catch (error) { + this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); + throw error; + } + } + public async getComment({ + client, + commentId, + }: GetCommentArgs): Promise> { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + } + + public async findCases({ + client, + options, + }: FindCasesArgs): Promise> { + try { + this.log.debug(`Attempting to find cases`); + return await client.find({ + sortField: defaultSortField, + ...options, + type: CASE_SAVED_OBJECT, + }); + } catch (error) { + this.log.debug(`Error on find cases: ${error}`); + throw error; + } + } + + public async findSubCases({ + client, + options, + }: FindCasesArgs): Promise> { + try { + this.log.debug(`Attempting to find sub cases`); + // if the page or perPage options are set then respect those instead of trying to + // grab all sub cases + if (options?.page !== undefined || options?.perPage !== undefined) { + return client.find({ + sortField: defaultSortField, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); } - }, - getAllCaseComments: async ({ client, caseId, options }: FindCommentsArgs) => { - try { - this.log.debug(`Attempting to GET all comments for case ${caseId}`); - return await client.find({ + + const stats = await client.find({ + fields: [], + page: 1, + perPage: 1, + sortField: defaultSortField, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); + return client.find({ + page: 1, + perPage: stats.total, + sortField: defaultSortField, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); + } catch (error) { + this.log.debug(`Error on find sub cases: ${error}`); + throw error; + } + } + + /** + * Find sub cases using a collection's ID. This would try to retrieve the maximum amount of sub cases + * by default. + * + * @param id the saved object ID of the parent collection to find sub cases for. + */ + public async findSubCasesByCaseId({ + client, + ids, + options, + }: FindSubCasesByIDArgs): Promise> { + if (ids.length <= 0) { + return { + total: 0, + saved_objects: [], + page: options?.page ?? defaultPage, + per_page: options?.perPage ?? defaultPerPage, + }; + } + + try { + this.log.debug(`Attempting to GET sub cases for case collection id ${ids.join(', ')}`); + return this.findSubCases({ + client, + options: { ...options, + hasReference: ids.map((id) => ({ + type: CASE_SAVED_OBJECT, + id, + })), + }, + }); + } catch (error) { + this.log.debug( + `Error on GET all sub cases for case collection id ${ids.join(', ')}: ${error}` + ); + throw error; + } + } + + private asArray(id: string | string[] | undefined): string[] { + if (id === undefined) { + return []; + } else if (Array.isArray(id)) { + return id; + } else { + return [id]; + } + } + + private async getAllComments({ + client, + id, + options, + }: FindCommentsArgs): Promise> { + try { + this.log.debug(`Attempting to GET all comments for id ${id}`); + if (options?.page !== undefined || options?.perPage !== undefined) { + return client.find({ type: CASE_COMMENT_SAVED_OBJECT, - hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + sortField: defaultSortField, + ...options, }); - } catch (error) { - this.log.debug(`Error on GET all comments for case ${caseId}: ${error}`); - throw error; - } - }, - getReporters: async ({ client }: ClientArgs) => { - try { - this.log.debug(`Attempting to GET all reporters`); - return await readReporters({ client }); - } catch (error) { - this.log.debug(`Error on GET all reporters: ${error}`); - throw error; } - }, - getTags: async ({ client }: ClientArgs) => { - try { - this.log.debug(`Attempting to GET all cases`); - return await readTags({ client }); - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; - } - }, - getUser: async ({ request, response }: GetUserArgs) => { - try { - this.log.debug(`Attempting to authenticate a user`); - if (authentication != null) { - const user = authentication.getCurrentUser(request); - if (!user) { - return { - username: null, - full_name: null, - email: null, - }; - } - return user; - } + // get the total number of comments that are in ES then we'll grab them all in one go + const stats = await client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + fields: [], + page: 1, + perPage: 1, + sortField: defaultSortField, + // spread the options after so the caller can override the default behavior if they want + ...options, + }); + + return client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + page: 1, + perPage: stats.total, + sortField: defaultSortField, + ...options, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for ${id}: ${error}`); + throw error; + } + } + + /** + * Default behavior is to retrieve all comments that adhere to a given filter (if one is included). + * to override this pass in the either the page or perPage options. + * + * @param includeSubCaseComments is a flag to indicate that sub case comments should be included as well, by default + * sub case comments are excluded. If the `filter` field is included in the options, it will override this behavior + */ + public async getAllCaseComments({ + client, + id, + options, + includeSubCaseComments = false, + }: FindCaseCommentsArgs): Promise> { + try { + const refs = this.asArray(id).map((caseID) => ({ type: CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { return { - username: null, - full_name: null, - email: null, + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, }; - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; } - }, - postNewCase: async ({ client, attributes }: PostCaseArgs) => { - try { - this.log.debug(`Attempting to POST a new case`); - return await client.create(CASE_SAVED_OBJECT, { ...attributes }); - } catch (error) { - this.log.debug(`Error on POST a new case: ${error}`); - throw error; - } - }, - postNewComment: async ({ client, attributes, references }: PostCommentArgs) => { - try { - this.log.debug(`Attempting to POST a new comment`); - return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); - } catch (error) { - this.log.debug(`Error on POST a new comment: ${error}`); - throw error; - } - }, - patchCase: async ({ client, caseId, updatedAttributes, version }: PatchCaseArgs) => { - try { - this.log.debug(`Attempting to UPDATE case ${caseId}`); - return await client.update( - CASE_SAVED_OBJECT, - caseId, - { ...updatedAttributes }, - { version } - ); - } catch (error) { - this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); - throw error; - } - }, - patchCases: async ({ client, cases }: PatchCasesArgs) => { - try { - this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); - return await client.bulkUpdate( - cases.map((c) => ({ - type: CASE_SAVED_OBJECT, - id: c.caseId, - attributes: c.updatedAttributes, - version: c.version, - })) + + let filter: string | undefined; + if (!includeSubCaseComments) { + // if other filters were passed in then combine them to filter out sub case comments + filter = combineFilters( + [ + options?.filter ?? '', + `${CASE_COMMENT_SAVED_OBJECT}.attributes.associationType: ${AssociationType.case}`, + ], + 'AND' ); - } catch (error) { - this.log.debug(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); - throw error; } - }, - patchComment: async ({ client, commentId, updatedAttributes, version }: UpdateCommentArgs) => { - try { - this.log.debug(`Attempting to UPDATE comment ${commentId}`); - return await client.update( - CASE_COMMENT_SAVED_OBJECT, - commentId, - { - ...updatedAttributes, - }, - { version } - ); - } catch (error) { - this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); - throw error; + + this.log.debug(`Attempting to GET all comments for case caseID ${id}`); + return this.getAllComments({ + client, + id, + options: { + hasReferenceOperator: 'OR', + hasReference: refs, + filter, + ...options, + }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for case ${id}: ${error}`); + throw error; + } + } + + public async getAllSubCaseComments({ + client, + id, + options, + }: FindSubCaseCommentsArgs): Promise> { + try { + const refs = this.asArray(id).map((caseID) => ({ type: SUB_CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { + return { + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, + }; } - }, - patchComments: async ({ client, comments }: PatchComments) => { - try { - this.log.debug( - `Attempting to UPDATE comments ${comments.map((c) => c.commentId).join(', ')}` - ); - return await client.bulkUpdate( - comments.map((c) => ({ - type: CASE_COMMENT_SAVED_OBJECT, - id: c.commentId, - attributes: c.updatedAttributes, - version: c.version, - })) - ); - } catch (error) { - this.log.debug( - `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` - ); - throw error; + + this.log.debug(`Attempting to GET all comments for sub case caseID ${id}`); + return this.getAllComments({ + client, + id, + options: { + hasReferenceOperator: 'OR', + hasReference: refs, + ...options, + }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for sub case ${id}: ${error}`); + throw error; + } + } + + public async getReporters({ client }: ClientArgs) { + try { + this.log.debug(`Attempting to GET all reporters`); + return await readReporters({ client }); + } catch (error) { + this.log.debug(`Error on GET all reporters: ${error}`); + throw error; + } + } + public async getTags({ client }: ClientArgs) { + try { + this.log.debug(`Attempting to GET all cases`); + return await readTags({ client }); + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + } + + public async getUser({ request }: GetUserArgs) { + try { + this.log.debug(`Attempting to authenticate a user`); + if (this.authentication != null) { + const user = this.authentication.getCurrentUser(request); + if (!user) { + return { + username: null, + full_name: null, + email: null, + }; + } + return user; } - }, - }); + return { + username: null, + full_name: null, + email: null, + }; + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + } + public async postNewCase({ client, attributes }: PostCaseArgs) { + try { + this.log.debug(`Attempting to POST a new case`); + return await client.create(CASE_SAVED_OBJECT, { ...attributes }); + } catch (error) { + this.log.debug(`Error on POST a new case: ${error}`); + throw error; + } + } + public async postNewComment({ client, attributes, references }: PostCommentArgs) { + try { + this.log.debug(`Attempting to POST a new comment`); + return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); + } catch (error) { + this.log.debug(`Error on POST a new comment: ${error}`); + throw error; + } + } + public async patchCase({ client, caseId, updatedAttributes, version }: PatchCaseArgs) { + try { + this.log.debug(`Attempting to UPDATE case ${caseId}`); + return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }, { version }); + } catch (error) { + this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); + throw error; + } + } + public async patchCases({ client, cases }: PatchCasesArgs) { + try { + this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); + return await client.bulkUpdate( + cases.map((c) => ({ + type: CASE_SAVED_OBJECT, + id: c.caseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); + throw error; + } + } + public async patchComment({ client, commentId, updatedAttributes, version }: UpdateCommentArgs) { + try { + this.log.debug(`Attempting to UPDATE comment ${commentId}`); + return await client.update( + CASE_COMMENT_SAVED_OBJECT, + commentId, + { + ...updatedAttributes, + }, + { version } + ); + } catch (error) { + this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); + throw error; + } + } + public async patchComments({ client, comments }: PatchComments) { + try { + this.log.debug( + `Attempting to UPDATE comments ${comments.map((c) => c.commentId).join(', ')}` + ); + return await client.bulkUpdate( + comments.map((c) => ({ + type: CASE_COMMENT_SAVED_OBJECT, + id: c.commentId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` + ); + throw error; + } + } + public async patchSubCase({ client, subCaseId, updatedAttributes, version }: PatchSubCase) { + try { + this.log.debug(`Attempting to UPDATE sub case ${subCaseId}`); + return await client.update( + SUB_CASE_SAVED_OBJECT, + subCaseId, + { ...updatedAttributes }, + { version } + ); + } catch (error) { + this.log.debug(`Error on UPDATE sub case ${subCaseId}: ${error}`); + throw error; + } + } + + public async patchSubCases({ client, subCases }: PatchSubCases) { + try { + this.log.debug( + `Attempting to UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}` + ); + return await client.bulkUpdate( + subCases.map((c) => ({ + type: SUB_CASE_SAVED_OBJECT, + id: c.subCaseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}: ${error}` + ); + throw error; + } + } } diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 0b3615793ef85e..51eb0bbb1a7e43 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -20,13 +20,21 @@ export type CaseUserActionServiceMock = jest.Mocked; export type AlertServiceMock = jest.Mocked; export const createCaseServiceMock = (): CaseServiceMock => ({ + createSubCase: jest.fn(), deleteCase: jest.fn(), deleteComment: jest.fn(), + deleteSubCase: jest.fn(), findCases: jest.fn(), + findSubCases: jest.fn(), + findSubCasesByCaseId: jest.fn(), getAllCaseComments: jest.fn(), + getAllSubCaseComments: jest.fn(), getCase: jest.fn(), getCases: jest.fn(), getComment: jest.fn(), + getMostRecentSubCase: jest.fn(), + getSubCase: jest.fn(), + getSubCases: jest.fn(), getTags: jest.fn(), getReporters: jest.fn(), getUser: jest.fn(), @@ -36,6 +44,14 @@ export const createCaseServiceMock = (): CaseServiceMock => ({ patchCases: jest.fn(), patchComment: jest.fn(), patchComments: jest.fn(), + patchSubCase: jest.fn(), + patchSubCases: jest.fn(), + findSubCaseStatusStats: jest.fn(), + getCommentsByAssociation: jest.fn(), + getCaseCommentStats: jest.fn(), + findSubCasesGroupByCase: jest.fn(), + findCaseStatusStats: jest.fn(), + findCasesGroupedByID: jest.fn(), }); export const createConfigureServiceMock = (): CaseConfigureServiceMock => ({ @@ -57,7 +73,6 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ }); export const createAlertServiceMock = (): AlertServiceMock => ({ - initialize: jest.fn(), updateAlertsStatus: jest.fn(), getAlerts: jest.fn(), }); diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index 9a32a04d62300e..c600a96234b3d7 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -15,13 +15,19 @@ import { UserActionField, ESCaseAttributes, User, + UserActionFieldType, + SubCaseAttributes, } from '../../../common/api'; import { isTwoArraysDifference, transformESConnectorToCaseConnector, } from '../../routes/api/cases/helpers'; import { UserActionItem } from '.'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; +import { + CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; export const transformNewUserAction = ({ actionField, @@ -59,6 +65,7 @@ interface BuildCaseUserAction { fields: UserActionField | unknown[]; newValue?: string | unknown; oldValue?: string | unknown; + subCaseId?: string; } interface BuildCommentUserActionItem extends BuildCaseUserAction { @@ -74,6 +81,7 @@ export const buildCommentUserActionItem = ({ fields, newValue, oldValue, + subCaseId, }: BuildCommentUserActionItem): UserActionItem => ({ attributes: transformNewUserAction({ actionField: fields as UserActionField, @@ -94,6 +102,15 @@ export const buildCommentUserActionItem = ({ name: `associated-${CASE_COMMENT_SAVED_OBJECT}`, id: commentId, }, + ...(subCaseId + ? [ + { + type: SUB_CASE_SAVED_OBJECT, + id: subCaseId, + name: `associated-${SUB_CASE_SAVED_OBJECT}`, + }, + ] + : []), ], }); @@ -105,6 +122,7 @@ export const buildCaseUserActionItem = ({ fields, newValue, oldValue, + subCaseId, }: BuildCaseUserAction): UserActionItem => ({ attributes: transformNewUserAction({ actionField: fields as UserActionField, @@ -120,6 +138,15 @@ export const buildCaseUserActionItem = ({ name: `associated-${CASE_SAVED_OBJECT}`, id: caseId, }, + ...(subCaseId + ? [ + { + type: SUB_CASE_SAVED_OBJECT, + name: `associated-${SUB_CASE_SAVED_OBJECT}`, + id: subCaseId, + }, + ] + : []), ], }); @@ -131,35 +158,57 @@ const userActionFieldsAllowed: UserActionField = [ 'title', 'status', 'settings', + 'sub_case', ]; -export const buildCaseUserActions = ({ +interface CaseSubIDs { + caseId: string; + subCaseId?: string; +} + +type GetCaseAndSubID = (so: SavedObjectsUpdateResponse) => CaseSubIDs; +type GetField = ( + attributes: Pick, 'attributes'>, + field: UserActionFieldType +) => unknown; + +/** + * Abstraction functions to retrieve a given field and the caseId and subCaseId depending on + * whether we're interacting with a case or a sub case. + */ +interface Getters { + getField: GetField; + getCaseAndSubID: GetCaseAndSubID; +} + +const buildGenericCaseUserActions = ({ actionDate, actionBy, originalCases, updatedCases, + allowedFields, + getters, }: { actionDate: string; actionBy: User; - originalCases: Array>; - updatedCases: Array>; -}): UserActionItem[] => - updatedCases.reduce((acc, updatedItem) => { + originalCases: Array>; + updatedCases: Array>; + allowedFields: UserActionField; + getters: Getters; +}): UserActionItem[] => { + const { getCaseAndSubID, getField } = getters; + return updatedCases.reduce((acc, updatedItem) => { + const { caseId, subCaseId } = getCaseAndSubID(updatedItem); + // regardless of whether we're looking at a sub case or case, the id field will always be used to match between + // the original and the updated saved object const originalItem = originalCases.find((oItem) => oItem.id === updatedItem.id); if (originalItem != null) { let userActions: UserActionItem[] = []; const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; updatedFields.forEach((field) => { - if (userActionFieldsAllowed.includes(field)) { - const origValue = - field === 'connector' && originalItem.attributes.connector - ? transformESConnectorToCaseConnector(originalItem.attributes.connector) - : get(originalItem, ['attributes', field]); - - const updatedValue = - field === 'connector' && updatedItem.attributes.connector - ? transformESConnectorToCaseConnector(updatedItem.attributes.connector) - : get(updatedItem, ['attributes', field]); + if (allowedFields.includes(field)) { + const origValue = getField(originalItem, field); + const updatedValue = getField(updatedItem, field); if (isString(origValue) && isString(updatedValue) && origValue !== updatedValue) { userActions = [ @@ -168,7 +217,8 @@ export const buildCaseUserActions = ({ action: 'update', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: updatedValue, oldValue: origValue, @@ -183,7 +233,8 @@ export const buildCaseUserActions = ({ action: 'add', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: compareValues.addedItems.join(', '), }), @@ -197,7 +248,8 @@ export const buildCaseUserActions = ({ action: 'delete', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: compareValues.deletedItems.join(', '), }), @@ -214,7 +266,8 @@ export const buildCaseUserActions = ({ action: 'update', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: JSON.stringify(updatedValue), oldValue: JSON.stringify(origValue), @@ -227,3 +280,68 @@ export const buildCaseUserActions = ({ } return acc; }, []); +}; + +/** + * Create a user action for an updated sub case. + */ +export const buildSubCaseUserActions = (args: { + actionDate: string; + actionBy: User; + originalSubCases: Array>; + updatedSubCases: Array>; +}): UserActionItem[] => { + const getField = ( + so: Pick, 'attributes'>, + field: UserActionFieldType + ) => get(so, ['attributes', field]); + + const getCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { + const caseId = so.references?.find((ref) => ref.type === CASE_SAVED_OBJECT)?.id ?? ''; + return { caseId, subCaseId: so.id }; + }; + + const getters: Getters = { + getField, + getCaseAndSubID, + }; + + return buildGenericCaseUserActions({ + actionDate: args.actionDate, + actionBy: args.actionBy, + originalCases: args.originalSubCases, + updatedCases: args.updatedSubCases, + allowedFields: ['status'], + getters, + }); +}; + +/** + * Create a user action for an updated case. + */ +export const buildCaseUserActions = (args: { + actionDate: string; + actionBy: User; + originalCases: Array>; + updatedCases: Array>; +}): UserActionItem[] => { + const getField = ( + so: Pick, 'attributes'>, + field: UserActionFieldType + ) => { + return field === 'connector' && so.attributes.connector + ? transformESConnectorToCaseConnector(so.attributes.connector) + : get(so, ['attributes', field]); + }; + + const caseGetIds: GetCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { + return { caseId: so.id }; + }; + + const getters: Getters = { + getField, + getCaseAndSubID: caseGetIds, + }; + + return buildGenericCaseUserActions({ ...args, allowedFields: userActionFieldsAllowed, getters }); +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index e1e9ac77a547ae..318143426af584 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -14,7 +14,7 @@ import { TestProviders } from '../../../common/mock'; import { casesStatus, useGetCasesMockState } from '../../containers/mock'; import * as i18n from './translations'; -import { CaseStatuses } from '../../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../../case/common/api'; import { useKibana } from '../../../common/lib/kibana'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; @@ -544,7 +544,9 @@ describe('AllCases', () => { status: 'open', tags: ['coke', 'pepsi'], title: 'Another horrible breach!!', + totalAlerts: 0, totalComment: 0, + type: CaseType.individual, updatedAt: '2020-02-20T15:02:57.995Z', updatedBy: { email: 'leslie.knope@elastic.co', diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx index be43704fcbba18..a1ee825aa5337e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx @@ -5,13 +5,14 @@ * 2.0. */ -import { CommentType } from '../../../../../case/common/api'; +import { AssociationType, CommentType } from '../../../../../case/common/api'; import { Comment } from '../../containers/types'; import { getRuleIdsFromComments, buildAlertsQuery } from './helpers'; const comments: Comment[] = [ { + associationType: AssociationType.case, type: CommentType.alert, alertId: 'alert-id-1', index: 'alert-index-1', @@ -25,6 +26,7 @@ const comments: Comment[] = [ version: 'WzQ3LDFc', }, { + associationType: AssociationType.case, type: CommentType.alert, alertId: 'alert-id-2', index: 'alert-index-2', diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts index ac0dc96eda526b..6b92e414675e2c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts @@ -11,7 +11,8 @@ import { Comment } from '../../containers/types'; export const getRuleIdsFromComments = (comments: Comment[]) => comments.reduce((ruleIds, comment: Comment) => { if (comment.type === CommentType.alert) { - return [...ruleIds, comment.alertId]; + const ids = Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + return [...ruleIds, ...ids]; } return ruleIds; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx index 1e2b34ddf38eae..656257f2b36c43 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx @@ -26,9 +26,10 @@ const Container = styled.div` `; const defaultAlertComment = { - type: CommentType.alert, - alertId: '{{context.rule.id}}', + type: CommentType.generatedAlert, + alerts: '{{context.alerts}}', index: '{{context.rule.output_index}}', + ruleId: '{{context.rule.id}}', }; const CaseParamsFields: React.FunctionComponent> = ({ diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index 4a567a38dc9f21..3b81fc0afccf3b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -335,8 +335,15 @@ export const UserActionTree = React.memo( ), }, ]; + // TODO: need to handle CommentType.generatedAlert here to } else if (comment != null && comment.type === CommentType.alert) { - const alert = alerts[comment.alertId]; + // TODO: clean this up + const alertId = Array.isArray(comment.alertId) + ? comment.alertId.length > 0 + ? comment.alertId[0] + : '' + : comment.alertId; + const alert = alerts[alertId]; return [...comments, getAlertComment({ action, alert, onShowAlertDetails })]; } } diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 444a87a57d2513..80d4816bedd53d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -18,6 +18,8 @@ import { CasesResponse, CasesFindResponse, CommentType, + AssociationType, + CaseType, } from '../../../../case/common/api'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import { ConnectorTypes } from '../../../../case/common/api/connectors'; @@ -38,6 +40,7 @@ export const elasticUser = { export const tags: string[] = ['coke', 'pepsi']; export const basicComment: Comment = { + associationType: AssociationType.case, comment: 'Solve this fast!', type: CommentType.user, id: basicCommentId, @@ -52,6 +55,7 @@ export const basicComment: Comment = { export const alertComment: Comment = { alertId: 'alert-id-1', + associationType: AssociationType.case, index: 'alert-index-1', type: CommentType.alert, id: 'alert-comment-id', @@ -65,6 +69,7 @@ export const alertComment: Comment = { }; export const basicCase: Case = { + type: CaseType.individual, closedAt: null, closedBy: null, id: basicCaseId, @@ -83,6 +88,7 @@ export const basicCase: Case = { tags, title: 'Another horrible breach!!', totalComment: 1, + totalAlerts: 0, updatedAt: basicUpdatedAt, updatedBy: elasticUser, version: 'WzQ3LDFd', @@ -181,6 +187,7 @@ export const elasticUserSnake = { }; export const basicCommentSnake: CommentResponse = { + associationType: AssociationType.case, comment: 'Solve this fast!', type: CommentType.user, id: basicCommentId, diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index e5477cbd951ae5..30ea8344434686 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -14,11 +14,14 @@ import { CaseStatuses, CaseAttributes, CasePatchRequest, + CaseType, + AssociationType, } from '../../../../case/common/api'; export { CaseConnector, ActionConnector } from '../../../../case/common/api'; export type Comment = CommentRequest & { + associationType: AssociationType; id: string; createdAt: string; createdBy: ElasticUser; @@ -62,7 +65,9 @@ export interface Case { status: CaseStatuses; tags: string[]; title: string; + totalAlerts: number; totalComment: number; + type: CaseType; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 8f2e9a4f1d7cd0..45827a4bebff80 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -6,7 +6,7 @@ */ import { useEffect, useReducer, useCallback } from 'react'; -import { CaseStatuses } from '../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../case/common/api'; import { Case } from './types'; import * as i18n from './translations'; @@ -71,7 +71,9 @@ export const initialData: Case = { status: CaseStatuses.open, tags: [], title: '', + totalAlerts: 0, totalComment: 0, + type: CaseType.individual, updatedAt: null, updatedBy: null, version: '', diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 164ccfd7389194..5003d49136b7ca 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -116,6 +116,14 @@ const securitySubPlugins = [ `${APP_ID}:${SecurityPageName.administration}`, ]; +const caseSavedObjects = [ + 'cases', + 'cases-comments', + 'cases-sub-case', + 'cases-configure', + 'cases-user-actions', +]; + export class Plugin implements IPlugin { private readonly logger: Logger; private readonly config: ConfigType; @@ -215,10 +223,7 @@ export class Plugin implements IPlugin { @@ -42,7 +50,6 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(204) .send(); - expect(comment).to.eql({}); }); @@ -77,5 +84,67 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(404); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('deletes a comment from a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .delete( + `${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}?subCaseID=${ + caseInfo.subCase!.id + }` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + const { body } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + ); + expect(body.length).to.eql(0); + }); + + it('deletes all comments from a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + let { body: allComments } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + ); + expect(allComments.length).to.eql(2); + + await supertest + .delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + ({ body: allComments } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + )); + + // no comments for the sub case + expect(allComments.length).to.eql(0); + + ({ body: allComments } = await supertest.get(`${CASES_URL}/${caseInfo.id}/comments`)); + + // no comments for the collection + expect(allComments.length).to.eql(0); + }); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts index 824ea40d38ace7..585333291111eb 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts @@ -9,9 +9,17 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { CommentsResponse, CommentType } from '../../../../../../plugins/case/common/api'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -102,5 +110,35 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(400); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('finds comments for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: subCaseComments }: { body: CommentsResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`) + .send() + .expect(200); + expect(subCaseComments.total).to.be(2); + expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); + expect(subCaseComments.comments[1].type).to.be(CommentType.user); + }); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts new file mode 100644 index 00000000000000..1af16f9e54563c --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { CommentType } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_all_comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should get multiple comments for a single case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(comments.length).to.eql(2); + }); + + it('should get comments from a case and its sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments?includeSubCaseComments=true`) + .expect(200); + + expect(comments.length).to.eql(2); + expect(comments[0].type).to.eql(CommentType.generatedAlert); + expect(comments[1].type).to.eql(CommentType.user); + }); + + it('should get comments from a sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.subCase!.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .expect(200); + + expect(comments.length).to.eql(2); + expect(comments[0].type).to.eql(CommentType.generatedAlert); + expect(comments[1].type).to.eql(CommentType.user); + }); + + it('should not find any comments for an invalid case id', async () => { + const { body } = await supertest + .get(`${CASES_URL}/fake-id/comments`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(0); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts index 89efc927de5e3d..389ec3f088f95b 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts @@ -10,7 +10,13 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { CommentResponse, CommentType } from '../../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -18,10 +24,15 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('get_comment', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should get a comment', async () => { @@ -45,6 +56,15 @@ export default ({ getService }: FtrProviderContext): void => { expect(comment).to.eql(patchedCase.comments[0]); }); + + it('should get a sub case comment', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const { body: comment }: { body: CommentResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}`) + .expect(200); + expect(comment.type).to.be(CommentType.generatedAlert); + }); + it('unhappy path - 404s when comment is not there', async () => { await supertest .get(`${CASES_URL}/fake-id/comments/fake-comment`) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts index 73cce973eef94a..2250b481c37291 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts @@ -10,14 +10,25 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { + CollectionWithSubCaseResponse, + CommentType, +} from '../../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCommentUserReq, postCommentAlertReq, } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -31,6 +42,79 @@ export default ({ getService }: FtrProviderContext): void => { await deleteCasesUserActions(es); }); + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('patches a comment for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const { + body: patchedSubCase, + }: { body: CollectionWithSubCaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + const { body: patchedSubCaseUpdatedComment } = await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedSubCase.subCase!.comments![1].id, + version: patchedSubCase.subCase!.comments![1].version, + comment: newComment, + type: CommentType.user, + }) + .expect(200); + + expect(patchedSubCaseUpdatedComment.subCase.comments.length).to.be(2); + expect(patchedSubCaseUpdatedComment.subCase.comments[0].type).to.be( + CommentType.generatedAlert + ); + expect(patchedSubCaseUpdatedComment.subCase.comments[1].type).to.be(CommentType.user); + expect(patchedSubCaseUpdatedComment.subCase.comments[1].comment).to.be(newComment); + }); + + it('fails to update the generated alert comment type', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send({ + id: caseInfo.subCase!.comments![0].id, + version: caseInfo.subCase!.comments![0].version, + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }) + .expect(400); + }); + + it('fails to update the generated alert comment by using another generated alert comment', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send({ + id: caseInfo.subCase!.comments![0].id, + version: caseInfo.subCase!.comments![0].version, + type: CommentType.generatedAlert, + alerts: [{ _id: 'id1' }], + index: 'test-index', + }) + .expect(400); + }); + }); + it('should patch a comment', async () => { const { body: postedCase } = await supertest .post(CASES_URL) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts index 087eb79dde7d2a..1ce011985d9e63 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts @@ -11,14 +11,24 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../../plugins/security_solution/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { CommentsResponse, CommentType } from '../../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCommentUserReq, postCommentAlertReq, + postCollectionReq, + postCommentGenAlertReq, } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; import { createSignalsIndex, deleteSignalsIndex, @@ -209,6 +219,34 @@ export default ({ getService }: FtrProviderContext): void => { .expect(400); }); + it('400s when adding an alert to a collection case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(400); + }); + + it('400s when adding a generated alert to an individual case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentGenAlertReq) + .expect(400); + }); + describe('alerts', () => { beforeEach(async () => { await esArchiver.load('auditbeat/hosts'); @@ -321,5 +359,37 @@ export default ({ getService }: FtrProviderContext): void => { expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); }); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('posts a new comment for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // create another sub case just to make sure we get the right comments + await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: subCaseComments }: { body: CommentsResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`) + .send() + .expect(200); + expect(subCaseComments.total).to.be(2); + expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); + expect(subCaseComments.comments[1].type).to.be(CommentType.user); + }); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts index 41a9c822efda73..a2bc0acbcf17cc 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts @@ -8,9 +8,17 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; -import { CASES_URL } from '../../../../../plugins/case/common/constants'; +import { CASES_URL, SUB_CASES_PATCH_DEL_URL } from '../../../../../plugins/case/common/constants'; import { postCaseReq, postCommentUserReq, findCasesResp } from '../../../common/lib/mock'; -import { deleteCases, deleteComments, deleteCasesUserActions } from '../../../common/lib/utils'; +import { + deleteAllCaseItems, + createSubCase, + setStatus, + CreateSubCaseResp, + createCaseAction, + deleteCaseAction, +} from '../../../common/lib/utils'; +import { CasesFindResponse, CaseStatuses, CaseType } from '../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -18,9 +26,7 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('find_cases', () => { afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should return empty response', async () => { @@ -242,6 +248,130 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.count_in_progress_cases).to.eql(1); }); + describe('stats with sub cases', () => { + let collection: CreateSubCaseResp; + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + beforeEach(async () => { + collection = await createSubCase({ supertest, actionID }); + + const [, , { body: toCloseCase }] = await Promise.all([ + setStatus({ + supertest, + cases: [ + { + id: collection.newSubCaseInfo.subCase!.id, + version: collection.newSubCaseInfo.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }), + supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), + supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), + ]); + + await setStatus({ + supertest, + cases: [ + { + id: toCloseCase.id, + version: toCloseCase.version, + status: CaseStatuses.closed, + }, + ], + type: 'case', + }); + }); + it('correctly counts stats without using a filter', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .expect(200); + + expect(body.total).to.eql(3); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(1); + }); + + it('correctly counts stats with a filter for open cases', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&status=open`) + .expect(200); + + expect(body.total).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(1); + }); + + it('correctly counts stats with a filter for individual cases', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.individual}`) + .expect(200); + + expect(body.total).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats with a filter for collection cases with multiple sub cases', async () => { + // this will force the first sub case attached to the collection to be closed + // so we'll have one closed sub case and one open sub case + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}`) + .expect(200); + + expect(body.total).to.eql(1); + expect(body.cases[0].subCases?.length).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats with a filter for collection and open cases with multiple sub cases', async () => { + // this will force the first sub case attached to the collection to be closed + // so we'll have one closed sub case and one open sub case + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); + const { body }: { body: CasesFindResponse } = await supertest + .get( + `${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}&status=${CaseStatuses.open}` + ) + .expect(200); + + expect(body.total).to.eql(1); + expect(body.cases[0].subCases?.length).to.eql(1); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats including a collection without sub cases', async () => { + // delete the sub case on the collection so that it doesn't have any sub cases + await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCase!.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .expect(200); + + expect(body.total).to.eql(3); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + }); + it('unhappy path - 400s when bad query supplied', async () => { await supertest .get(`${CASES_URL}/_find?perPage=true`) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts index 1b51ec9ba1171f..dcc49152e4db8c 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts @@ -10,14 +10,17 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../plugins/case/common/constants'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../plugins/security_solution/common/constants'; -import { CommentType } from '../../../../../plugins/case/common/api'; +import { CaseType, CommentType } from '../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCaseResp, + postCollectionReq, + postCommentAlertReq, + postCommentUserReq, removeServerGeneratedPropertiesFromCase, } from '../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions } from '../../../common/lib/utils'; +import { deleteAllCaseItems } from '../../../common/lib/utils'; import { createSignalsIndex, deleteSignalsIndex, @@ -38,8 +41,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('patch_cases', () => { afterEach(async () => { - await deleteCases(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should patch a case', async () => { @@ -127,6 +129,106 @@ export default ({ getService }: FtrProviderContext): void => { .expect(404); }); + it('should 400 and not allow converting a collection back to an individual case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + type: CaseType.individual, + }, + ], + }) + .expect(400); + }); + + it('should allow converting an individual case to a collection when it does not have alerts', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: patchedCase.id, + version: patchedCase.version, + type: CaseType.collection, + }, + ], + }) + .expect(200); + }); + + it('should 400 when attempting to update an individual case to a collection when it has alerts attached to it', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: patchedCase.id, + version: patchedCase.version, + type: CaseType.collection, + }, + ], + }) + .expect(400); + }); + + it("should 400 when attempting to update a collection case's status", async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(400); + }); + it('unhappy path - 406s when excess data sent', async () => { const { body: postedCase } = await supertest .post(CASES_URL) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts new file mode 100644 index 00000000000000..537afbe8250682 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts @@ -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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + CASES_URL, + SUB_CASES_PATCH_DEL_URL, +} from '../../../../../../plugins/case/common/constants'; +import { postCommentUserReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { CollectionWithSubCaseResponse } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('delete_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should delete a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + expect(caseInfo.subCase?.id).to.not.eql(undefined); + + const { body: subCase } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .send() + .expect(200); + + expect(subCase.id).to.not.eql(undefined); + + const { body } = await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${subCase.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + expect(body).to.eql({}); + await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .send() + .expect(404); + }); + + it(`should delete a sub case's comments when that case gets deleted`, async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + expect(caseInfo.subCase?.id).to.not.eql(undefined); + + // there should be two comments on the sub case now + const { + body: patchedCaseWithSubCase, + }: { body: CollectionWithSubCaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments`) + .set('kbn-xsrf', 'true') + .query({ subCaseID: caseInfo.subCase!.id }) + .send(postCommentUserReq) + .expect(200); + + const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${ + patchedCaseWithSubCase.subCase!.comments![1].id + }`; + // make sure we can get the second comment + await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200); + + await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${patchedCaseWithSubCase.subCase!.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(404); + }); + + it('unhappy path - 404s when sub case id is invalid', async () => { + await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["fake-id"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts new file mode 100644 index 00000000000000..3463b372509809 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts @@ -0,0 +1,254 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { findSubCasesResp, postCollectionReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + setStatus, +} from '../../../../common/lib/utils'; +import { getSubCasesUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { + CaseResponse, + CaseStatuses, + SubCasesFindResponse, +} from '../../../../../../plugins/case/common/api'; +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('find_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should not find any sub cases when none exist', async () => { + const { body: caseResp }: { body: CaseResponse } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + const { body: findSubCases } = await supertest + .get(`${getSubCasesUrl(caseResp.id)}/_find`) + .expect(200); + + expect(findSubCases).to.eql({ + page: 1, + per_page: 20, + total: 0, + subCases: [], + count_open_cases: 0, + count_closed_cases: 0, + count_in_progress_cases: 0, + }); + }); + + it('should return a sub cases with comment stats', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find`) + .expect(200); + + expect(body).to.eql({ + ...findSubCasesResp, + total: 1, + // find should not return the comments themselves only the stats + subCases: [{ ...caseInfo.subCase!, comments: [], totalComment: 1, totalAlerts: 2 }], + count_open_cases: 1, + }); + }); + + it('should return multiple sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const subCase2Resp = await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find`) + .expect(200); + + expect(body).to.eql({ + ...findSubCasesResp, + total: 2, + // find should not return the comments themselves only the stats + subCases: [ + { + // there should only be 1 closed sub case + ...subCase2Resp.modifiedSubCases![0], + comments: [], + totalComment: 1, + totalAlerts: 2, + status: CaseStatuses.closed, + }, + { + ...subCase2Resp.newSubCaseInfo.subCase, + comments: [], + totalComment: 1, + totalAlerts: 2, + }, + ], + count_open_cases: 1, + count_closed_cases: 1, + }); + }); + + it('should only return open when filtering for open', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses.open}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses.open); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should only return closed when filtering for closed', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses.closed}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should only return in progress when filtering for in progress', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + const { newSubCaseInfo: secondSub } = await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + await setStatus({ + supertest, + cases: [ + { + id: secondSub.subCase!.id, + version: secondSub.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses['in-progress']}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses['in-progress']); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(0); + expect(body.count_in_progress_cases).to.be(1); + }); + + it('should sort on createdAt field in descending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=createdAt&sortOrder=desc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.open); + expect(body.subCases[1].status).to.be(CaseStatuses.closed); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should sort on createdAt field in ascending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=createdAt&sortOrder=asc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.subCases[1].status).to.be(CaseStatuses.open); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should sort on updatedAt field in ascending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + const { newSubCaseInfo: secondSub } = await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + await setStatus({ + supertest, + cases: [ + { + id: secondSub.subCase!.id, + version: secondSub.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=updatedAt&sortOrder=asc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.subCases[1].status).to.be(CaseStatuses['in-progress']); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(0); + expect(body.count_in_progress_cases).to.be(1); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts new file mode 100644 index 00000000000000..cd5a1ed85742f1 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + commentsResp, + postCommentAlertReq, + removeServerGeneratedPropertiesFromComments, + removeServerGeneratedPropertiesFromSubCase, + subCaseResp, +} from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + defaultCreateSubComment, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { + getCaseCommentsUrl, + getSubCaseDetailsUrl, +} from '../../../../../../plugins/case/common/api/helpers'; +import { + AssociationType, + CollectionWithSubCaseResponse, + SubCaseResponse, +} from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_sub_case', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return a case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + const { body }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( + commentsResp({ + comments: [{ comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }], + associationType: AssociationType.subCase, + }) + ); + + expect(removeServerGeneratedPropertiesFromSubCase(body)).to.eql( + subCaseResp({ id: body.id, totalComment: 1, totalAlerts: 2 }) + ); + }); + + it('should return the correct number of alerts with multiple types of alerts', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + const { body: singleAlert }: { body: CollectionWithSubCaseResponse } = await supertest + .post(getCaseCommentsUrl(caseInfo.id)) + .query({ subCaseID: caseInfo.subCase!.id }) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + + const { body }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( + commentsResp({ + comments: [ + { comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }, + { + comment: postCommentAlertReq, + id: singleAlert.subCase!.comments![1].id, + }, + ], + associationType: AssociationType.subCase, + }) + ); + + expect(removeServerGeneratedPropertiesFromSubCase(body)).to.eql( + subCaseResp({ id: body.id, totalComment: 2, totalAlerts: 3 }) + ); + }); + + it('unhappy path - 404s when case is not there', async () => { + await supertest + .get(getSubCaseDetailsUrl('fake-case-id', 'fake-sub-case-id')) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts new file mode 100644 index 00000000000000..66422724b5677e --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../../plugins/case/common/constants'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + setStatus, +} from '../../../../common/lib/utils'; +import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { CaseStatuses, SubCaseResponse } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('patch_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should update the status of a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + await setStatus({ + supertest, + cases: [ + { + id: caseInfo.subCase!.id, + version: caseInfo.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + const { body: subCase }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .expect(200); + + expect(subCase.status).to.eql(CaseStatuses['in-progress']); + }); + + it('404s when sub case id is invalid', async () => { + await supertest + .patch(`${SUB_CASES_PATCH_DEL_URL}`) + .set('kbn-xsrf', 'true') + .send({ + subCases: [ + { + id: 'fake-id', + version: 'blah', + status: CaseStatuses.open, + }, + ], + }) + .expect(404); + }); + + it('406s when updating invalid fields for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + await supertest + .patch(`${SUB_CASES_PATCH_DEL_URL}`) + .set('kbn-xsrf', 'true') + .send({ + subCases: [ + { + id: caseInfo.subCase!.id, + version: caseInfo.subCase!.version, + type: 'blah', + }, + ], + }) + .expect(406); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index 1cbf79cb3326ce..b771da84d4360c 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -10,7 +10,12 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../../plugins/case/common/constants'; import { CommentType } from '../../../../../../plugins/case/common/api'; -import { defaultUser, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + userActionPostResp, + defaultUser, + postCaseReq, + postCommentUserReq, +} from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, @@ -73,7 +78,7 @@ export default ({ getService }: FtrProviderContext): void => { ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null); - expect(body[0].new_value).to.eql(JSON.stringify(postCaseReq)); + expect(JSON.parse(body[0].new_value)).to.eql(userActionPostResp); }); it(`on close case, user action: 'update' should be called with actionFields: ['status']`, async () => { @@ -147,10 +152,18 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['connector']); expect(body[1].action).to.eql('update'); - expect(body[1].old_value).to.eql(`{"id":"none","name":"none","type":".none","fields":null}`); - expect(body[1].new_value).to.eql( - `{"id":"123","name":"Connector","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}}` - ); + expect(JSON.parse(body[1].old_value)).to.eql({ + id: 'none', + name: 'none', + type: '.none', + fields: null, + }); + expect(JSON.parse(body[1].new_value)).to.eql({ + id: '123', + name: 'Connector', + type: '.jira', + fields: { issueType: 'Task', priority: 'High', parent: null }, + }); }); it(`on update tags, user action: 'add' and 'delete' should be called with actionFields: ['tags']`, async () => { @@ -284,7 +297,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(body[1].action_field).to.eql(['comment']); expect(body[1].action).to.eql('create'); expect(body[1].old_value).to.eql(null); - expect(body[1].new_value).to.eql(JSON.stringify(postCommentUserReq)); + expect(JSON.parse(body[1].new_value)).to.eql(postCommentUserReq); }); it(`on update comment, user action: 'update' should be called with actionFields: ['comments']`, async () => { @@ -317,13 +330,11 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(3); expect(body[2].action_field).to.eql(['comment']); expect(body[2].action).to.eql('update'); - expect(body[2].old_value).to.eql(JSON.stringify(postCommentUserReq)); - expect(body[2].new_value).to.eql( - JSON.stringify({ - comment: newComment, - type: CommentType.user, - }) - ); + expect(JSON.parse(body[2].old_value)).to.eql(postCommentUserReq); + expect(JSON.parse(body[2].new_value)).to.eql({ + comment: newComment, + type: CommentType.user, + }); }); it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index 302c3a0423bed6..01dd6ed5404c2e 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -17,10 +17,21 @@ import { removeServerGeneratedPropertiesFromCase, removeServerGeneratedPropertiesFromComments, } from '../../../common/lib/mock'; +import { + createRule, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsByIds, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, +} from '../../../../detection_engine_api_integration/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('case_connector', () => { let createdActionId = ''; @@ -682,47 +693,80 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - // TODO: Remove it when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it('should fail adding a comment of type alert', async () => { - const { body: createdAction } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A case connector', - actionTypeId: '.case', - config: {}, - }) - .expect(200); + describe('adding alerts using a connector', () => { + beforeEach(async () => { + await esArchiver.load('auditbeat/hosts'); + await createSignalsIndex(supertest); + }); - createdActionId = createdAction.id; + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('auditbeat/hosts'); + }); - const caseRes = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); + it('should add a comment of type alert', async () => { + // TODO: don't do all this stuff + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + const alert = signals.hits.hits[0]; + + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); - const params = { - subAction: 'addComment', - subActionParams: { - caseId: caseRes.body.id, - comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, - }, - }; + createdActionId = createdAction.id; - const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ params }) - .expect(200); + const caseRes = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); - expect(caseConnector.body).to.eql({ - status: 'error', - actionId: createdActionId, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.type]: expected value to equal [user]', - retry: false, + const params = { + subAction: 'addComment', + subActionParams: { + caseId: caseRes.body.id, + comment: { alertId: alert._id, index: alert._index, type: CommentType.alert }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body.status).to.eql('ok'); + + const { body } = await supertest + .get(`${CASES_URL}/${caseRes.body.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(body); + const comments = removeServerGeneratedPropertiesFromComments(data.comments ?? []); + expect({ ...data, comments }).to.eql({ + ...postCaseResp(caseRes.body.id), + comments, + totalAlerts: 1, + totalComment: 1, + updated_by: { + email: null, + full_name: null, + username: null, + }, + }); }); }); @@ -791,7 +835,7 @@ export default ({ getService }: FtrProviderContext): void => { }, }; - for (const attribute of ['alertId', 'index']) { + for (const attribute of ['blah', 'bogus']) { const caseConnector = await supertest .post(`/api/actions/action/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') @@ -805,11 +849,10 @@ export default ({ getService }: FtrProviderContext): void => { }, }) .expect(200); - expect(caseConnector.body).to.eql({ status: 'error', actionId: createdActionId, - message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing`, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]\n - [subActionParams.comment.2.type]: expected value to equal [generated_alert]`, retry: false, }); } diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts index f547b781026587..837e6503084a70 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -16,6 +16,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./cases/comments/delete_comment')); loadTestFile(require.resolve('./cases/comments/find_comments')); loadTestFile(require.resolve('./cases/comments/get_comment')); + loadTestFile(require.resolve('./cases/comments/get_all_comments')); loadTestFile(require.resolve('./cases/comments/patch_comment')); loadTestFile(require.resolve('./cases/comments/post_comment')); loadTestFile(require.resolve('./cases/delete_cases')); @@ -33,6 +34,10 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./configure/patch_configure')); loadTestFile(require.resolve('./configure/post_configure')); loadTestFile(require.resolve('./connectors/case')); + loadTestFile(require.resolve('./cases/sub_cases/patch_sub_cases')); + loadTestFile(require.resolve('./cases/sub_cases/delete_sub_cases')); + loadTestFile(require.resolve('./cases/sub_cases/get_sub_case')); + loadTestFile(require.resolve('./cases/sub_cases/find_sub_cases')); // Migrations loadTestFile(require.resolve('./cases/migrations')); diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index 04f08125534562..2f4fa1b30f5649 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -5,6 +5,12 @@ * 2.0. */ +import { + CommentSchemaType, + ContextTypeGeneratedAlertType, + isCommentGeneratedAlert, + transformConnectorComment, +} from '../../../../plugins/case/server/connectors'; import { CasePostRequest, CaseResponse, @@ -15,7 +21,15 @@ import { CommentRequestAlertType, CommentType, CaseStatuses, + CaseType, + CaseClientPostRequest, + SubCaseResponse, + AssociationType, + CollectionWithSubCaseResponse, + SubCasesFindResponse, + CommentRequest, } from '../../../../plugins/case/common/api'; + export const defaultUser = { email: null, full_name: null, username: 'elastic' }; export const postCaseReq: CasePostRequest = { description: 'This is a brand new case of a bad meanie defacing data', @@ -32,6 +46,22 @@ export const postCaseReq: CasePostRequest = { }, }; +/** + * The fields for creating a collection style case. + */ +export const postCollectionReq: CasePostRequest = { + ...postCaseReq, + type: CaseType.collection, +}; + +/** + * This is needed because the post api does not allow specifying the case type. But the response will include the type. + */ +export const userActionPostResp: CaseClientPostRequest = { + ...postCaseReq, + type: CaseType.individual, +}; + export const postCommentUserReq: CommentRequestUserType = { comment: 'This is a cool comment', type: CommentType.user, @@ -43,6 +73,12 @@ export const postCommentAlertReq: CommentRequestAlertType = { type: CommentType.alert, }; +export const postCommentGenAlertReq: ContextTypeGeneratedAlertType = { + alerts: [{ _id: 'test-id' }, { _id: 'test-id2' }], + index: 'test-index', + type: CommentType.generatedAlert, +}; + export const postCaseResp = ( id: string, req: CasePostRequest = postCaseReq @@ -50,7 +86,9 @@ export const postCaseResp = ( ...req, id, comments: [], + totalAlerts: 0, totalComment: 0, + type: req.type ?? CaseType.individual, closed_by: null, created_by: defaultUser, external_service: null, @@ -58,6 +96,100 @@ export const postCaseResp = ( updated_by: null, }); +interface CommentRequestWithID { + id: string; + comment: CommentSchemaType | CommentRequest; +} + +export const commentsResp = ({ + comments, + associationType, +}: { + comments: CommentRequestWithID[]; + associationType: AssociationType; +}): Array> => { + return comments.map(({ comment, id }) => { + const baseFields = { + id, + created_by: defaultUser, + pushed_at: null, + pushed_by: null, + updated_by: null, + }; + + if (isCommentGeneratedAlert(comment)) { + return { + associationType, + ...transformConnectorComment(comment), + ...baseFields, + }; + } else { + return { + associationType, + ...comment, + ...baseFields, + }; + } + }); +}; + +export const subCaseResp = ({ + id, + totalAlerts, + totalComment, + status = CaseStatuses.open, +}: { + id: string; + status?: CaseStatuses; + totalAlerts: number; + totalComment: number; +}): Partial => ({ + status, + id, + totalAlerts, + totalComment, + closed_by: null, + created_by: defaultUser, + updated_by: defaultUser, +}); + +interface FormattedCollectionResponse { + caseInfo: Partial; + subCase?: Partial; + comments?: Array>; +} + +export const formatCollectionResponse = ( + caseInfo: CollectionWithSubCaseResponse +): FormattedCollectionResponse => { + return { + caseInfo: removeServerGeneratedPropertiesFromCaseCollection(caseInfo), + subCase: removeServerGeneratedPropertiesFromSubCase(caseInfo.subCase), + comments: removeServerGeneratedPropertiesFromComments( + caseInfo.subCase?.comments ?? caseInfo.comments + ), + }; +}; + +export const removeServerGeneratedPropertiesFromSubCase = ( + subCase: Partial | undefined +): Partial | undefined => { + if (!subCase) { + return; + } + // eslint-disable-next-line @typescript-eslint/naming-convention + const { closed_at, created_at, updated_at, version, comments, ...rest } = subCase; + return rest; +}; + +export const removeServerGeneratedPropertiesFromCaseCollection = ( + config: Partial +): Partial => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { closed_at, created_at, updated_at, version, subCase, ...rest } = config; + return rest; +}; + export const removeServerGeneratedPropertiesFromCase = ( config: Partial ): Partial => { @@ -67,21 +199,30 @@ export const removeServerGeneratedPropertiesFromCase = ( }; export const removeServerGeneratedPropertiesFromComments = ( - comments: CommentResponse[] -): Array> => { - return comments.map((comment) => { + comments: CommentResponse[] | undefined +): Array> | undefined => { + return comments?.map((comment) => { // eslint-disable-next-line @typescript-eslint/naming-convention const { created_at, updated_at, version, ...rest } = comment; return rest; }); }; -export const findCasesResp: CasesFindResponse = { +const findCommon = { page: 1, per_page: 20, total: 0, - cases: [], count_open_cases: 0, count_closed_cases: 0, count_in_progress_cases: 0, }; + +export const findCasesResp: CasesFindResponse = { + ...findCommon, + cases: [], +}; + +export const findSubCasesResp: SubCasesFindResponse = { + ...findCommon, + subCases: [], +}; diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 27a49c3f058695..048c5c5d84098c 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -4,14 +4,197 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import expect from '@kbn/expect'; import { Client } from '@elastic/elasticsearch'; +import * as st from 'supertest'; +import supertestAsPromised from 'supertest-as-promised'; +import { CASES_URL, SUB_CASES_PATCH_DEL_URL } from '../../../../plugins/case/common/constants'; import { CasesConfigureRequest, CasesConfigureResponse, CaseConnector, ConnectorTypes, + CasePostRequest, + CollectionWithSubCaseResponse, + SubCasesFindResponse, + CaseStatuses, + SubCasesResponse, + CasesResponse, } from '../../../../plugins/case/common/api'; +import { postCollectionReq, postCommentGenAlertReq } from './mock'; +import { getSubCasesUrl } from '../../../../plugins/case/common/api/helpers'; +import { ContextTypeGeneratedAlertType } from '../../../../plugins/case/server/connectors'; + +interface SetStatusCasesParams { + id: string; + version: string; + status: CaseStatuses; +} + +/** + * Sets the status of some cases or sub cases. The cases field must be all of one type. + */ +export const setStatus = async ({ + supertest, + cases, + type, +}: { + supertest: st.SuperTest; + cases: SetStatusCasesParams[]; + type: 'case' | 'sub_case'; +}): Promise => { + const url = type === 'case' ? CASES_URL : SUB_CASES_PATCH_DEL_URL; + const patchFields = type === 'case' ? { cases } : { subCases: cases }; + const { body }: { body: CasesResponse | SubCasesResponse } = await supertest + .patch(url) + .set('kbn-xsrf', 'true') + .send(patchFields) + .expect(200); + return body; +}; + +/** + * Variable to easily access the default comment for the createSubCase function. + */ +export const defaultCreateSubComment = postCommentGenAlertReq; + +/** + * Variable to easily access the default comment for the createSubCase function. + */ +export const defaultCreateSubPost = postCollectionReq; + +/** + * Response structure for the createSubCase and createSubCaseComment functions. + */ +export interface CreateSubCaseResp { + newSubCaseInfo: CollectionWithSubCaseResponse; + modifiedSubCases?: SubCasesResponse; +} + +/** + * Creates a sub case using the actions API. If a caseID isn't passed in then it will create + * the collection as well. To create a sub case a comment must be created so it uses a default + * generated alert style comment which can be overridden. + */ +export const createSubCase = async (args: { + supertest: st.SuperTest; + comment?: ContextTypeGeneratedAlertType; + caseID?: string; + caseInfo?: CasePostRequest; + actionID?: string; +}): Promise => { + return createSubCaseComment({ ...args, forceNewSubCase: true }); +}; + +/** + * Add case as a connector + */ +export const createCaseAction = async (supertest: st.SuperTest) => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + return createdAction.id; +}; + +/** + * Remove a connector + */ +export const deleteCaseAction = async ( + supertest: st.SuperTest, + id: string +) => { + await supertest.delete(`/api/actions/action/${id}`).set('kbn-xsrf', 'foo'); +}; + +/** + * Creates a sub case using the actions APIs. This will handle forcing a creation of a new sub case even if one exists + * if the forceNewSubCase parameter is set to true. + */ +export const createSubCaseComment = async ({ + supertest, + caseID, + comment = defaultCreateSubComment, + caseInfo = defaultCreateSubPost, + // if true it will close any open sub cases and force a new sub case to be opened + forceNewSubCase = false, + actionID, +}: { + supertest: st.SuperTest; + comment?: ContextTypeGeneratedAlertType; + caseID?: string; + caseInfo?: CasePostRequest; + forceNewSubCase?: boolean; + actionID?: string; +}): Promise => { + let actionIDToUse: string; + + if (actionID === undefined) { + actionIDToUse = await createCaseAction(supertest); + } else { + actionIDToUse = actionID; + } + + let collectionID: string; + + if (!caseID) { + collectionID = ( + await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(caseInfo).expect(200) + ).body.id; + } else { + collectionID = caseID; + } + + let closedSubCases: SubCasesResponse | undefined; + if (forceNewSubCase) { + const { body: subCasesResp }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(collectionID)}/_find`) + .expect(200); + + const nonClosed = subCasesResp.subCases.filter( + (subCase) => subCase.status !== CaseStatuses.closed + ); + if (nonClosed.length > 0) { + // mark the sub case as closed so a new sub case will be created on the next comment + closedSubCases = ( + await supertest + .patch(SUB_CASES_PATCH_DEL_URL) + .set('kbn-xsrf', 'true') + .send({ + subCases: nonClosed.map((subCase) => ({ + id: subCase.id, + version: subCase.version, + status: CaseStatuses.closed, + })), + }) + .expect(200) + ).body; + } + } + + const caseConnector = await supertest + .post(`/api/actions/action/${actionIDToUse}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'addComment', + subActionParams: { + caseId: collectionID, + comment, + }, + }, + }) + .expect(200); + + expect(caseConnector.body.status).to.eql('ok'); + return { newSubCaseInfo: caseConnector.body.data, modifiedSubCases: closedSubCases }; +}; export const getConfiguration = ({ id = 'none', @@ -104,6 +287,16 @@ export const removeServerGeneratedPropertiesFromConfigure = ( return rest; }; +export const deleteAllCaseItems = async (es: Client) => { + await Promise.all([ + deleteCases(es), + deleteSubCases(es), + deleteCasesUserActions(es), + deleteComments(es), + deleteConfiguration(es), + ]); +}; + export const deleteCasesUserActions = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana', @@ -124,6 +317,20 @@ export const deleteCases = async (es: Client): Promise => { }); }; +/** + * Deletes all sub cases in the .kibana index. This uses ES to perform the delete and does + * not go through the case API. + */ +export const deleteSubCases = async (es: Client): Promise => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:cases-sub-case', + wait_for_completion: true, + refresh: true, + body: {}, + }); +}; + export const deleteComments = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana', From a28318e516d60aff1457b6c7be244b336e413039 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Fri, 12 Feb 2021 19:34:44 +0100 Subject: [PATCH 017/175] [APM] Add custom spans around async operations (#90403) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 7 +- packages/kbn-apm-utils/package.json | 13 + packages/kbn-apm-utils/src/index.ts | 87 +++++ packages/kbn-apm-utils/tsconfig.json | 18 + .../chart_preview/get_transaction_duration.ts | 117 +++--- .../get_transaction_error_count.ts | 69 ++-- .../create_anomaly_detection_jobs.ts | 95 ++--- .../get_anomaly_detection_jobs.ts | 38 +- .../get_ml_jobs_with_apm_group.ts | 21 +- .../lib/anomaly_detection/has_legacy_jobs.ts | 27 +- .../index.ts | 265 +++++++------- .../get_duration_for_percentile.ts | 37 +- .../get_latency_distribution.ts | 195 +++++----- .../get_max_latency.ts | 53 +-- .../index.ts | 125 +++---- .../lib/environments/get_all_environments.ts | 88 ++--- .../lib/errors/distribution/get_buckets.ts | 85 ++--- .../lib/errors/get_error_group_sample.ts | 75 ++-- .../apm/server/lib/errors/get_error_groups.ts | 129 +++---- .../helpers/aggregated_transactions/index.ts | 39 +- .../get_internal_saved_objects_client.ts | 9 +- .../apm/server/lib/helpers/setup_request.ts | 83 +++-- .../create_static_index_pattern.ts | 61 ++-- .../get_dynamic_index_pattern.ts | 75 ++-- .../by_agent/java/gc/get_gc_rate_chart.ts | 17 +- .../by_agent/java/gc/get_gc_time_chart.ts | 17 +- .../by_agent/java/heap_memory/index.ts | 31 +- .../server/lib/metrics/by_agent/java/index.ts | 25 +- .../by_agent/java/non_heap_memory/index.ts | 33 +- .../by_agent/java/thread_count/index.ts | 25 +- .../lib/metrics/by_agent/shared/cpu/index.ts | 31 +- .../metrics/by_agent/shared/memory/index.ts | 69 ++-- .../get_service_count.ts | 47 +-- .../get_transaction_coordinates.ts | 66 ++-- .../lib/observability_overview/has_data.ts | 45 +-- .../fetch_service_paths_from_trace_ids.ts | 119 ++++--- .../lib/service_map/get_service_anomalies.ts | 185 +++++----- .../server/lib/service_map/get_service_map.ts | 221 ++++++------ .../get_service_map_service_node_info.ts | 299 ++++++++-------- .../lib/service_map/get_trace_sample_ids.ts | 194 +++++----- .../apm/server/lib/service_nodes/index.ts | 101 +++--- .../get_derived_service_annotations.ts | 155 ++++---- .../annotations/get_stored_annotations.ts | 99 ++--- .../lib/services/get_service_agent_name.ts | 65 ++-- .../get_destination_map.ts | 315 ++++++++-------- .../get_service_dependencies/get_metrics.ts | 183 +++++----- .../get_service_dependencies/index.ts | 313 ++++++++-------- .../get_service_error_groups/index.ts | 238 +++++++------ ...et_service_instance_system_metric_stats.ts | 201 ++++++----- .../get_service_instance_transaction_stats.ts | 199 ++++++----- .../services/get_service_instances/index.ts | 29 +- .../services/get_service_metadata_details.ts | 169 ++++----- .../services/get_service_metadata_icons.ts | 89 ++--- .../lib/services/get_service_node_metadata.ts | 64 ++-- ...transaction_group_comparison_statistics.ts | 186 +++++----- .../get_service_transaction_groups.ts | 157 ++++---- .../services/get_service_transaction_types.ts | 65 ++-- .../get_services/get_legacy_data_status.ts | 37 +- .../get_service_transaction_stats.ts | 247 ++++++------- .../get_services/get_services_items.ts | 55 +-- .../get_services/has_historical_agent_data.ts | 35 +- .../server/lib/services/get_services/index.ts | 37 +- .../apm/server/lib/services/get_throughput.ts | 11 +- .../create_or_update_configuration.ts | 45 +-- .../delete_configuration.ts | 17 +- .../find_exact_configuration.ts | 57 +-- .../get_agent_name_by_service.ts | 51 +-- .../get_existing_environments_for_service.ts | 51 +-- .../get_environments/index.ts | 21 +- .../agent_configuration/get_service_names.ts | 63 ++-- .../list_configurations.ts | 6 +- .../mark_applied_by_agent.ts | 1 + .../search_configurations.ts | 99 ++--- .../settings/apm_indices/get_apm_indices.ts | 9 +- .../settings/apm_indices/save_apm_indices.ts | 19 +- .../create_or_update_custom_link.ts | 33 +- .../custom_link/delete_custom_link.ts | 19 +- .../settings/custom_link/get_transaction.ts | 53 +-- .../settings/custom_link/list_custom_links.ts | 75 ++-- .../apm/server/lib/traces/get_trace_items.ts | 166 +++++---- .../server/lib/transaction_groups/fetcher.ts | 203 ++++++----- .../lib/transaction_groups/get_error_rate.ts | 119 ++++--- .../get_transaction_group_stats.ts | 173 ++++----- .../lib/transactions/breakdown/index.ts | 337 +++++++++--------- .../distribution/get_buckets/index.ts | 252 ++++++------- .../distribution/get_distribution_max.ts | 77 ++-- .../lib/transactions/distribution/index.ts | 57 +-- .../transactions/get_anomaly_data/fetcher.ts | 99 ++--- .../transactions/get_anomaly_data/index.ts | 144 ++++---- .../transactions/get_latency_charts/index.ts | 59 +-- .../get_throughput_charts/index.ts | 37 +- .../lib/transactions/get_transaction/index.ts | 39 +- .../get_transaction_by_trace/index.ts | 56 +-- .../server/lib/ui_filters/get_environments.ts | 85 +++-- .../lib/ui_filters/local_ui_filters/index.ts | 67 ++-- .../apm/server/routes/create_api/index.ts | 7 + .../apm/server/routes/index_pattern.ts | 6 +- .../server/routes/observability_overview.ts | 28 +- x-pack/plugins/apm/server/routes/services.ts | 49 +-- .../routes/settings/anomaly_detection.ts | 12 +- .../plugins/apm/server/utils/with_apm_span.ts | 25 ++ yarn.lock | 4 + 102 files changed, 4792 insertions(+), 4213 deletions(-) create mode 100644 packages/kbn-apm-utils/package.json create mode 100644 packages/kbn-apm-utils/src/index.ts create mode 100644 packages/kbn-apm-utils/tsconfig.json create mode 100644 x-pack/plugins/apm/server/utils/with_apm_span.ts diff --git a/package.json b/package.json index 9ddb37b60021d6..ed21cb7052c1cb 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "@kbn/ace": "link:packages/kbn-ace", "@kbn/analytics": "link:packages/kbn-analytics", "@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader", + "@kbn/apm-utils": "link:packages/kbn-apm-utils", "@kbn/config": "link:packages/kbn-config", "@kbn/config-schema": "link:packages/kbn-config-schema", "@kbn/i18n": "link:packages/kbn-i18n", @@ -129,6 +130,7 @@ "@kbn/logging": "link:packages/kbn-logging", "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/std": "link:packages/kbn-std", + "@kbn/tinymath": "link:packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", "@kbn/utils": "link:packages/kbn-utils", @@ -312,7 +314,6 @@ "tabbable": "1.1.3", "tar": "4.4.13", "tinygradient": "0.4.3", - "@kbn/tinymath": "link:packages/kbn-tinymath", "tree-kill": "^1.2.2", "ts-easing": "^0.2.0", "tslib": "^2.0.0", @@ -390,10 +391,10 @@ "@scant/router": "^0.1.0", "@storybook/addon-a11y": "^6.0.26", "@storybook/addon-actions": "^6.0.26", + "@storybook/addon-docs": "^6.0.26", "@storybook/addon-essentials": "^6.0.26", "@storybook/addon-knobs": "^6.0.26", "@storybook/addon-storyshots": "^6.0.26", - "@storybook/addon-docs": "^6.0.26", "@storybook/components": "^6.0.26", "@storybook/core": "^6.0.26", "@storybook/core-events": "^6.0.26", @@ -851,4 +852,4 @@ "yargs": "^15.4.1", "zlib": "^1.0.5" } -} \ No newline at end of file +} diff --git a/packages/kbn-apm-utils/package.json b/packages/kbn-apm-utils/package.json new file mode 100644 index 00000000000000..d414b94cb39789 --- /dev/null +++ b/packages/kbn-apm-utils/package.json @@ -0,0 +1,13 @@ +{ + "name": "@kbn/apm-utils", + "main": "./target/index.js", + "types": "./target/index.d.ts", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true, + "scripts": { + "build": "../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + } +} diff --git a/packages/kbn-apm-utils/src/index.ts b/packages/kbn-apm-utils/src/index.ts new file mode 100644 index 00000000000000..f2f537138dad07 --- /dev/null +++ b/packages/kbn-apm-utils/src/index.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 agent from 'elastic-apm-node'; +import asyncHooks from 'async_hooks'; + +export interface SpanOptions { + name: string; + type?: string; + subtype?: string; + labels?: Record; +} + +export function parseSpanOptions(optionsOrName: SpanOptions | string) { + const options = typeof optionsOrName === 'string' ? { name: optionsOrName } : optionsOrName; + + return options; +} + +const runInNewContext = any>(cb: T): ReturnType => { + const resource = new asyncHooks.AsyncResource('fake_async'); + + return resource.runInAsyncScope(cb); +}; + +export async function withSpan( + optionsOrName: SpanOptions | string, + cb: () => Promise +): Promise { + const options = parseSpanOptions(optionsOrName); + + const { name, type, subtype, labels } = options; + + if (!agent.isStarted()) { + return cb(); + } + + // When a span starts, it's marked as the active span in its context. + // When it ends, it's not untracked, which means that if a span + // starts directly after this one ends, the newly started span is a + // child of this span, even though it should be a sibling. + // To mitigate this, we queue a microtask by awaiting a promise. + await Promise.resolve(); + + const span = agent.startSpan(name); + + if (!span) { + return cb(); + } + + // If a span is created in the same context as the span that we just + // started, it will be a sibling, not a child. E.g., the Elasticsearch span + // that is created when calling search() happens in the same context. To + // mitigate this we create a new context. + + return runInNewContext(() => { + // @ts-ignore + if (type) { + span.type = type; + } + if (subtype) { + span.subtype = subtype; + } + + if (labels) { + span.addLabels(labels); + } + + return cb() + .then((res) => { + span.outcome = 'success'; + return res; + }) + .catch((err) => { + span.outcome = 'failure'; + throw err; + }) + .finally(() => { + span.end(); + }); + }); +} diff --git a/packages/kbn-apm-utils/tsconfig.json b/packages/kbn-apm-utils/tsconfig.json new file mode 100644 index 00000000000000..e1f79b5ef394da --- /dev/null +++ b/packages/kbn-apm-utils/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "./target", + "stripInternal": false, + "declarationMap": true, + "types": [ + "node" + ] + }, + "include": [ + "./src/**/*.ts" + ], + "exclude": [ + "target" + ] +} diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts index 67cd33415f28f2..e487684909633e 100644 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts @@ -15,80 +15,83 @@ import { import { ProcessorEvent } from '../../../../common/processor_event'; import { rangeFilter } from '../../../../common/utils/range_filter'; import { AlertParams } from '../../../routes/alerts/chart_preview'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -export async function getTransactionDurationChartPreview({ +export function getTransactionDurationChartPreview({ alertParams, setup, }: { alertParams: AlertParams; setup: Setup & SetupTimeRange; }) { - const { apmEventClient, start, end } = setup; - const { - aggregationType, - environment, - serviceName, - transactionType, - } = alertParams; + return withApmSpan('get_transaction_duration_chart_preview', async () => { + const { apmEventClient, start, end } = setup; + const { + aggregationType, + environment, + serviceName, + transactionType, + } = alertParams; - const query = { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), - ...(transactionType - ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] - : []), - ...getEnvironmentUiFilterES(environment), - ], - }, - }; + const query = { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), + ...(transactionType + ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] + : []), + ...getEnvironmentUiFilterES(environment), + ], + }, + }; - const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); + const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); - const aggs = { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - }, - aggs: { - agg: - aggregationType === 'avg' - ? { avg: { field: TRANSACTION_DURATION } } - : { - percentiles: { - field: TRANSACTION_DURATION, - percents: [aggregationType === '95th' ? 95 : 99], + const aggs = { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + }, + aggs: { + agg: + aggregationType === 'avg' + ? { avg: { field: TRANSACTION_DURATION } } + : { + percentiles: { + field: TRANSACTION_DURATION, + percents: [aggregationType === '95th' ? 95 : 99], + }, }, - }, + }, }, - }, - }; - const params = { - apm: { events: [ProcessorEvent.transaction] }, - body: { size: 0, query, aggs }, - }; - const resp = await apmEventClient.search(params); + }; + const params = { + apm: { events: [ProcessorEvent.transaction] }, + body: { size: 0, query, aggs }, + }; + const resp = await apmEventClient.search(params); - if (!resp.aggregations) { - return []; - } + if (!resp.aggregations) { + return []; + } - return resp.aggregations.timeseries.buckets.map((bucket) => { - const percentilesKey = aggregationType === '95th' ? '95.0' : '99.0'; - const x = bucket.key; - const y = - aggregationType === 'avg' - ? (bucket.agg as MetricsAggregationResponsePart).value - : (bucket.agg as { values: Record }).values[ - percentilesKey - ]; + return resp.aggregations.timeseries.buckets.map((bucket) => { + const percentilesKey = aggregationType === '95th' ? '95.0' : '99.0'; + const x = bucket.key; + const y = + aggregationType === 'avg' + ? (bucket.agg as MetricsAggregationResponsePart).value + : (bucket.agg as { values: Record }).values[ + percentilesKey + ]; - return { x, y }; + return { x, y }; + }); }); } diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts index ae1d634928ed98..05ad743af0997b 100644 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts @@ -9,56 +9,59 @@ import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; import { rangeFilter } from '../../../../common/utils/range_filter'; import { AlertParams } from '../../../routes/alerts/chart_preview'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -export async function getTransactionErrorCountChartPreview({ +export function getTransactionErrorCountChartPreview({ setup, alertParams, }: { setup: Setup & SetupTimeRange; alertParams: AlertParams; }) { - const { apmEventClient, start, end } = setup; - const { serviceName, environment } = alertParams; + return withApmSpan('get_transaction_error_count_chart_preview', async () => { + const { apmEventClient, start, end } = setup; + const { serviceName, environment } = alertParams; - const query = { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), - ...getEnvironmentUiFilterES(environment), - ], - }, - }; + const query = { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), + ...getEnvironmentUiFilterES(environment), + ], + }, + }; - const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); + const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); - const aggs = { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, + const aggs = { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + }, }, - }, - }; + }; - const params = { - apm: { events: [ProcessorEvent.error] }, - body: { size: 0, query, aggs }, - }; + const params = { + apm: { events: [ProcessorEvent.error] }, + body: { size: 0, query, aggs }, + }; - const resp = await apmEventClient.search(params); + const resp = await apmEventClient.search(params); - if (!resp.aggregations) { - return []; - } + if (!resp.aggregations) { + return []; + } - return resp.aggregations.timeseries.buckets.map((bucket) => { - return { - x: bucket.key, - y: bucket.doc_count, - }; + return resp.aggregations.timeseries.buckets.map((bucket) => { + return { + x: bucket.key, + y: bucket.doc_count, + }; + }); }); } diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts index 3b8e104fbf81d7..a560f011b186bb 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts @@ -18,6 +18,7 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { APM_ML_JOB_GROUP, ML_MODULE_ID_APM_TRANSACTION } from './constants'; import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { withApmSpan } from '../../utils/with_apm_span'; export async function createAnomalyDetectionJobs( setup: Setup, @@ -30,32 +31,36 @@ export async function createAnomalyDetectionJobs( throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); } - const mlCapabilities = await ml.mlSystem.mlCapabilities(); + const mlCapabilities = await withApmSpan('get_ml_capabilities', () => + ml.mlSystem.mlCapabilities() + ); if (!mlCapabilities.mlFeatureEnabledInSpace) { throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); } - logger.info( - `Creating ML anomaly detection jobs for environments: [${environments}].` - ); - - const indexPatternName = indices['apm_oss.transactionIndices']; - const responses = await Promise.all( - environments.map((environment) => - createAnomalyDetectionJob({ ml, environment, indexPatternName }) - ) - ); - const jobResponses = responses.flatMap((response) => response.jobs); - const failedJobs = jobResponses.filter(({ success }) => !success); + return withApmSpan('create_anomaly_detection_jobs', async () => { + logger.info( + `Creating ML anomaly detection jobs for environments: [${environments}].` + ); - if (failedJobs.length > 0) { - const errors = failedJobs.map(({ id, error }) => ({ id, error })); - throw new Error( - `An error occurred while creating ML jobs: ${JSON.stringify(errors)}` + const indexPatternName = indices['apm_oss.transactionIndices']; + const responses = await Promise.all( + environments.map((environment) => + createAnomalyDetectionJob({ ml, environment, indexPatternName }) + ) ); - } + const jobResponses = responses.flatMap((response) => response.jobs); + const failedJobs = jobResponses.filter(({ success }) => !success); + + if (failedJobs.length > 0) { + const errors = failedJobs.map(({ id, error }) => ({ id, error })); + throw new Error( + `An error occurred while creating ML jobs: ${JSON.stringify(errors)}` + ); + } - return jobResponses; + return jobResponses; + }); } async function createAnomalyDetectionJob({ @@ -67,34 +72,36 @@ async function createAnomalyDetectionJob({ environment: string; indexPatternName: string; }) { - const randomToken = uuid().substr(-4); + return withApmSpan('create_anomaly_detection_job', async () => { + const randomToken = uuid().substr(-4); - return ml.modules.setup({ - moduleId: ML_MODULE_ID_APM_TRANSACTION, - prefix: `${APM_ML_JOB_GROUP}-${snakeCase(environment)}-${randomToken}-`, - groups: [APM_ML_JOB_GROUP], - indexPatternName, - applyToAllSpaces: true, - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - { exists: { field: TRANSACTION_DURATION } }, - ...getEnvironmentUiFilterES(environment), - ], + return ml.modules.setup({ + moduleId: ML_MODULE_ID_APM_TRANSACTION, + prefix: `${APM_ML_JOB_GROUP}-${snakeCase(environment)}-${randomToken}-`, + groups: [APM_ML_JOB_GROUP], + indexPatternName, + applyToAllSpaces: true, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { exists: { field: TRANSACTION_DURATION } }, + ...getEnvironmentUiFilterES(environment), + ], + }, }, - }, - startDatafeed: true, - jobOverrides: [ - { - custom_settings: { - job_tags: { - environment, - // identifies this as an APM ML job & facilitates future migrations - apm_ml_version: 2, + startDatafeed: true, + jobOverrides: [ + { + custom_settings: { + job_tags: { + environment, + // identifies this as an APM ML job & facilitates future migrations + apm_ml_version: 2, + }, }, }, - }, - ], + ], + }); }); } diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts index 632a9398ff6acb..75b2e8289c7a85 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts @@ -10,27 +10,35 @@ import Boom from '@hapi/boom'; import { ML_ERRORS } from '../../../common/anomaly_detection'; import { Setup } from '../helpers/setup_request'; import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { +export function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { const { ml } = setup; if (!ml) { throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); } - const mlCapabilities = await ml.mlSystem.mlCapabilities(); - if (!mlCapabilities.mlFeatureEnabledInSpace) { - throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); - } + return withApmSpan('get_anomaly_detection_jobs', async () => { + const mlCapabilities = await withApmSpan('get_ml_capabilities', () => + ml.mlSystem.mlCapabilities() + ); + + if (!mlCapabilities.mlFeatureEnabledInSpace) { + throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); + } - const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors); - return response.jobs - .filter((job) => (job.custom_settings?.job_tags?.apm_ml_version ?? 0) >= 2) - .map((job) => { - const environment = job.custom_settings?.job_tags?.environment ?? ''; - return { - job_id: job.job_id, - environment, - }; - }); + const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors); + return response.jobs + .filter( + (job) => (job.custom_settings?.job_tags?.apm_ml_version ?? 0) >= 2 + ) + .map((job) => { + const environment = job.custom_settings?.job_tags?.environment ?? ''; + return { + job_id: job.job_id, + environment, + }; + }); + }); } diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts index 6ef8ee291a815b..bcea8f1ed6b26e 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts @@ -6,20 +6,23 @@ */ import { MlPluginSetup } from '../../../../ml/server'; +import { withApmSpan } from '../../utils/with_apm_span'; import { APM_ML_JOB_GROUP } from './constants'; // returns ml jobs containing "apm" group // workaround: the ML api returns 404 when no jobs are found. This is handled so instead of throwing an empty response is returned -export async function getMlJobsWithAPMGroup( +export function getMlJobsWithAPMGroup( anomalyDetectors: ReturnType ) { - try { - return await anomalyDetectors.jobs(APM_ML_JOB_GROUP); - } catch (e) { - if (e.statusCode === 404) { - return { count: 0, jobs: [] }; - } + return withApmSpan('get_ml_jobs_with_apm_group', async () => { + try { + return await anomalyDetectors.jobs(APM_ML_JOB_GROUP); + } catch (e) { + if (e.statusCode === 404) { + return { count: 0, jobs: [] }; + } - throw e; - } + throw e; + } + }); } diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts index 48f40e387ffae9..c189d24efc23a3 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts @@ -7,27 +7,32 @@ import Boom from '@hapi/boom'; import { ML_ERRORS } from '../../../common/anomaly_detection'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup } from '../helpers/setup_request'; import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group'; // Determine whether there are any legacy ml jobs. // A legacy ML job has a job id that ends with "high_mean_response_time" and created_by=ml-module-apm-transaction -export async function hasLegacyJobs(setup: Setup) { +export function hasLegacyJobs(setup: Setup) { const { ml } = setup; if (!ml) { throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); } - const mlCapabilities = await ml.mlSystem.mlCapabilities(); - if (!mlCapabilities.mlFeatureEnabledInSpace) { - throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); - } + return withApmSpan('has_legacy_jobs', async () => { + const mlCapabilities = await withApmSpan('get_ml_capabilities', () => + ml.mlSystem.mlCapabilities() + ); + if (!mlCapabilities.mlFeatureEnabledInSpace) { + throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); + } - const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors); - return response.jobs.some( - (job) => - job.job_id.endsWith('high_mean_response_time') && - job.custom_settings?.created_by === 'ml-module-apm-transaction' - ); + const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors); + return response.jobs.some( + (job) => + job.job_id.endsWith('high_mean_response_time') && + job.custom_settings?.created_by === 'ml-module-apm-transaction' + ); + }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts index 686629b0d7d180..ecebf5b5715a18 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts @@ -27,6 +27,7 @@ import { getOutcomeAggregation, getTransactionErrorRateTimeSeries, } from '../../helpers/transaction_error_rate'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getCorrelationsForFailedTransactions({ serviceName, @@ -41,74 +42,76 @@ export async function getCorrelationsForFailedTransactions({ fieldNames: string[]; setup: Setup & SetupTimeRange; }) { - const { start, end, esFilter, apmEventClient } = setup; - - const backgroundFilters: ESFilter[] = [ - ...esFilter, - { range: rangeFilter(start, end) }, - ]; - - if (serviceName) { - backgroundFilters.push({ term: { [SERVICE_NAME]: serviceName } }); - } - - if (transactionType) { - backgroundFilters.push({ term: { [TRANSACTION_TYPE]: transactionType } }); - } - - if (transactionName) { - backgroundFilters.push({ term: { [TRANSACTION_NAME]: transactionName } }); - } - - const params = { - apm: { events: [ProcessorEvent.transaction] }, - track_total_hits: true, - body: { - size: 0, - query: { - bool: { filter: backgroundFilters }, - }, - aggs: { - failed_transactions: { - filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - - // significant term aggs - aggs: fieldNames.reduce((acc, fieldName) => { - return { - ...acc, - [fieldName]: { - significant_terms: { - size: 10, - field: fieldName, - background_filter: { bool: { filter: backgroundFilters } }, + return withApmSpan('get_correlations_for_failed_transactions', async () => { + const { start, end, esFilter, apmEventClient } = setup; + + const backgroundFilters: ESFilter[] = [ + ...esFilter, + { range: rangeFilter(start, end) }, + ]; + + if (serviceName) { + backgroundFilters.push({ term: { [SERVICE_NAME]: serviceName } }); + } + + if (transactionType) { + backgroundFilters.push({ term: { [TRANSACTION_TYPE]: transactionType } }); + } + + if (transactionName) { + backgroundFilters.push({ term: { [TRANSACTION_NAME]: transactionName } }); + } + + const params = { + apm: { events: [ProcessorEvent.transaction] }, + track_total_hits: true, + body: { + size: 0, + query: { + bool: { filter: backgroundFilters }, + }, + aggs: { + failed_transactions: { + filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + + // significant term aggs + aggs: fieldNames.reduce((acc, fieldName) => { + return { + ...acc, + [fieldName]: { + significant_terms: { + size: 10, + field: fieldName, + background_filter: { bool: { filter: backgroundFilters } }, + }, }, - }, - }; - }, {} as Record), + }; + }, {} as Record), + }, }, }, - }, - }; - - const response = await apmEventClient.search(params); - if (!response.aggregations) { - return {}; - } - - const failedTransactionCount = - response.aggregations?.failed_transactions.doc_count; - const totalTransactionCount = response.hits.total.value; - const avgErrorRate = (failedTransactionCount / totalTransactionCount) * 100; - const sigTermAggs = omit( - response.aggregations?.failed_transactions, - 'doc_count' - ); - - const topSigTerms = processSignificantTermAggs({ - sigTermAggs, - thresholdPercentage: avgErrorRate, + }; + + const response = await apmEventClient.search(params); + if (!response.aggregations) { + return {}; + } + + const failedTransactionCount = + response.aggregations?.failed_transactions.doc_count; + const totalTransactionCount = response.hits.total.value; + const avgErrorRate = (failedTransactionCount / totalTransactionCount) * 100; + const sigTermAggs = omit( + response.aggregations?.failed_transactions, + 'doc_count' + ); + + const topSigTerms = processSignificantTermAggs({ + sigTermAggs, + thresholdPercentage: avgErrorRate, + }); + return getErrorRateTimeSeries({ setup, backgroundFilters, topSigTerms }); }); - return getErrorRateTimeSeries({ setup, backgroundFilters, topSigTerms }); } export async function getErrorRateTimeSeries({ @@ -120,71 +123,73 @@ export async function getErrorRateTimeSeries({ backgroundFilters: ESFilter[]; topSigTerms: TopSigTerm[]; }) { - const { start, end, apmEventClient } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets: 30 }); - - if (isEmpty(topSigTerms)) { - return {}; - } - - const timeseriesAgg = { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { min: start, max: end }, - }, - aggs: { - outcomes: getOutcomeAggregation(), - }, - }; - - const perTermAggs = topSigTerms.reduce( - (acc, term, index) => { - acc[`term_${index}`] = { - filter: { term: { [term.fieldName]: term.fieldValue } }, - aggs: { timeseries: timeseriesAgg }, - }; - return acc; - }, - {} as { - [key: string]: { - filter: AggregationOptionsByType['filter']; - aggs: { timeseries: typeof timeseriesAgg }; - }; + return withApmSpan('get_error_rate_timeseries', async () => { + const { start, end, apmEventClient } = setup; + const { intervalString } = getBucketSize({ start, end, numBuckets: 30 }); + + if (isEmpty(topSigTerms)) { + return {}; } - ); - - const params = { - // TODO: add support for metrics - apm: { events: [ProcessorEvent.transaction] }, - body: { - size: 0, - query: { bool: { filter: backgroundFilters } }, - aggs: merge({ timeseries: timeseriesAgg }, perTermAggs), - }, - }; - - const response = await apmEventClient.search(params); - const { aggregations } = response; - - if (!aggregations) { - return {}; - } - - return { - overall: { - timeseries: getTransactionErrorRateTimeSeries( - aggregations.timeseries.buckets - ), - }, - significantTerms: topSigTerms.map((topSig, index) => { - const agg = aggregations[`term_${index}`]!; - - return { - ...topSig, - timeseries: getTransactionErrorRateTimeSeries(agg.timeseries.buckets), - }; - }), - }; + + const timeseriesAgg = { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: { + outcomes: getOutcomeAggregation(), + }, + }; + + const perTermAggs = topSigTerms.reduce( + (acc, term, index) => { + acc[`term_${index}`] = { + filter: { term: { [term.fieldName]: term.fieldValue } }, + aggs: { timeseries: timeseriesAgg }, + }; + return acc; + }, + {} as { + [key: string]: { + filter: AggregationOptionsByType['filter']; + aggs: { timeseries: typeof timeseriesAgg }; + }; + } + ); + + const params = { + // TODO: add support for metrics + apm: { events: [ProcessorEvent.transaction] }, + body: { + size: 0, + query: { bool: { filter: backgroundFilters } }, + aggs: merge({ timeseries: timeseriesAgg }, perTermAggs), + }, + }; + + const response = await apmEventClient.search(params); + const { aggregations } = response; + + if (!aggregations) { + return {}; + } + + return { + overall: { + timeseries: getTransactionErrorRateTimeSeries( + aggregations.timeseries.buckets + ), + }, + significantTerms: topSigTerms.map((topSig, index) => { + const agg = aggregations[`term_${index}`]!; + + return { + ...topSig, + timeseries: getTransactionErrorRateTimeSeries(agg.timeseries.buckets), + }; + }), + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_duration_for_percentile.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_duration_for_percentile.ts index 096d601b2f9091..27f69c3ca7d56d 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_duration_for_percentile.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_duration_for_percentile.ts @@ -8,6 +8,7 @@ import { ESFilter } from '../../../../../../typings/elasticsearch'; import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; export async function getDurationForPercentile({ @@ -19,26 +20,28 @@ export async function getDurationForPercentile({ backgroundFilters: ESFilter[]; setup: Setup & SetupTimeRange; }) { - const { apmEventClient } = setup; - const res = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - size: 0, - query: { - bool: { filter: backgroundFilters }, + return withApmSpan('get_duration_for_percentiles', async () => { + const { apmEventClient } = setup; + const res = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.transaction], }, - aggs: { - percentile: { - percentiles: { - field: TRANSACTION_DURATION, - percents: [durationPercentile], + body: { + size: 0, + query: { + bool: { filter: backgroundFilters }, + }, + aggs: { + percentile: { + percentiles: { + field: TRANSACTION_DURATION, + percents: [durationPercentile], + }, }, }, }, - }, - }); + }); - return Object.values(res.aggregations?.percentile.values || {})[0]; + return Object.values(res.aggregations?.percentile.values || {})[0]; + }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts index 9601700df4a5a7..6f7bd9537aa73b 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts @@ -13,6 +13,7 @@ import { ProcessorEvent } from '../../../../common/processor_event'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { TopSigTerm } from '../process_significant_term_aggs'; import { getMaxLatency } from './get_max_latency'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getLatencyDistribution({ setup, @@ -23,111 +24,115 @@ export async function getLatencyDistribution({ backgroundFilters: ESFilter[]; topSigTerms: TopSigTerm[]; }) { - const { apmEventClient } = setup; + return withApmSpan('get_latency_distribution', async () => { + const { apmEventClient } = setup; - if (isEmpty(topSigTerms)) { - return {}; - } + if (isEmpty(topSigTerms)) { + return {}; + } - const maxLatency = await getMaxLatency({ - setup, - backgroundFilters, - topSigTerms, - }); + const maxLatency = await getMaxLatency({ + setup, + backgroundFilters, + topSigTerms, + }); + + if (!maxLatency) { + return {}; + } + + const intervalBuckets = 20; + const distributionInterval = roundtoTenth(maxLatency / intervalBuckets); - if (!maxLatency) { - return {}; - } - - const intervalBuckets = 20; - const distributionInterval = roundtoTenth(maxLatency / intervalBuckets); - - const distributionAgg = { - // filter out outliers not included in the significant term docs - filter: { range: { [TRANSACTION_DURATION]: { lte: maxLatency } } }, - aggs: { - dist_filtered_by_latency: { - histogram: { - // TODO: add support for metrics - field: TRANSACTION_DURATION, - interval: distributionInterval, - min_doc_count: 0, - extended_bounds: { - min: 0, - max: maxLatency, + const distributionAgg = { + // filter out outliers not included in the significant term docs + filter: { range: { [TRANSACTION_DURATION]: { lte: maxLatency } } }, + aggs: { + dist_filtered_by_latency: { + histogram: { + // TODO: add support for metrics + field: TRANSACTION_DURATION, + interval: distributionInterval, + min_doc_count: 0, + extended_bounds: { + min: 0, + max: maxLatency, + }, }, }, }, - }, - }; - - const perTermAggs = topSigTerms.reduce( - (acc, term, index) => { - acc[`term_${index}`] = { - filter: { term: { [term.fieldName]: term.fieldValue } }, + }; + + const perTermAggs = topSigTerms.reduce( + (acc, term, index) => { + acc[`term_${index}`] = { + filter: { term: { [term.fieldName]: term.fieldValue } }, + aggs: { + distribution: distributionAgg, + }, + }; + return acc; + }, + {} as Record< + string, + { + filter: AggregationOptionsByType['filter']; + aggs: { + distribution: typeof distributionAgg; + }; + } + > + ); + + const params = { + // TODO: add support for metrics + apm: { events: [ProcessorEvent.transaction] }, + body: { + size: 0, + query: { bool: { filter: backgroundFilters } }, aggs: { + // overall aggs distribution: distributionAgg, - }, - }; - return acc; - }, - {} as Record< - string, - { - filter: AggregationOptionsByType['filter']; - aggs: { - distribution: typeof distributionAgg; - }; - } - > - ); - - const params = { - // TODO: add support for metrics - apm: { events: [ProcessorEvent.transaction] }, - body: { - size: 0, - query: { bool: { filter: backgroundFilters } }, - aggs: { - // overall aggs - distribution: distributionAgg, - // per term aggs - ...perTermAggs, + // per term aggs + ...perTermAggs, + }, }, - }, - }; - - const response = await apmEventClient.search(params); - type Agg = NonNullable; - - if (!response.aggregations) { - return; - } - - function formatDistribution(distribution: Agg['distribution']) { - const total = distribution.doc_count; - return distribution.dist_filtered_by_latency.buckets.map((bucket) => ({ - x: bucket.key, - y: (bucket.doc_count / total) * 100, - })); - } - - return { - distributionInterval, - overall: { - distribution: formatDistribution(response.aggregations.distribution), - }, - significantTerms: topSigTerms.map((topSig, index) => { - // @ts-expect-error - const agg = response.aggregations[`term_${index}`] as Agg; - - return { - ...topSig, - distribution: formatDistribution(agg.distribution), - }; - }), - }; + }; + + const response = await withApmSpan('get_terms_distribution', () => + apmEventClient.search(params) + ); + type Agg = NonNullable; + + if (!response.aggregations) { + return; + } + + function formatDistribution(distribution: Agg['distribution']) { + const total = distribution.doc_count; + return distribution.dist_filtered_by_latency.buckets.map((bucket) => ({ + x: bucket.key, + y: (bucket.doc_count / total) * 100, + })); + } + + return { + distributionInterval, + overall: { + distribution: formatDistribution(response.aggregations.distribution), + }, + significantTerms: topSigTerms.map((topSig, index) => { + // @ts-expect-error + const agg = response.aggregations[`term_${index}`] as Agg; + + return { + ...topSig, + distribution: formatDistribution(agg.distribution), + }; + }), + }; + }); } function roundtoTenth(v: number) { diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts index 2db764982c4325..2777c0944afd13 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts @@ -8,6 +8,7 @@ import { ESFilter } from '../../../../../../typings/elasticsearch'; import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { TopSigTerm } from '../process_significant_term_aggs'; @@ -20,35 +21,37 @@ export async function getMaxLatency({ backgroundFilters: ESFilter[]; topSigTerms: TopSigTerm[]; }) { - const { apmEventClient } = setup; + return withApmSpan('get_max_latency', async () => { + const { apmEventClient } = setup; - const params = { - // TODO: add support for metrics - apm: { events: [ProcessorEvent.transaction] }, - body: { - size: 0, - query: { - bool: { - filter: backgroundFilters, + const params = { + // TODO: add support for metrics + apm: { events: [ProcessorEvent.transaction] }, + body: { + size: 0, + query: { + bool: { + filter: backgroundFilters, - // only include docs containing the significant terms - should: topSigTerms.map((term) => ({ - term: { [term.fieldName]: term.fieldValue }, - })), - minimum_should_match: 1, + // only include docs containing the significant terms + should: topSigTerms.map((term) => ({ + term: { [term.fieldName]: term.fieldValue }, + })), + minimum_should_match: 1, + }, }, - }, - aggs: { - // TODO: add support for metrics - // max_latency: { max: { field: TRANSACTION_DURATION } }, - max_latency: { - percentiles: { field: TRANSACTION_DURATION, percents: [99] }, + aggs: { + // TODO: add support for metrics + // max_latency: { max: { field: TRANSACTION_DURATION } }, + max_latency: { + percentiles: { field: TRANSACTION_DURATION, percents: [99] }, + }, }, }, - }, - }; + }; - const response = await apmEventClient.search(params); - // return response.aggregations?.max_latency.value; - return Object.values(response.aggregations?.max_latency.values ?? {})[0]; + const response = await apmEventClient.search(params); + // return response.aggregations?.max_latency.value; + return Object.values(response.aggregations?.max_latency.values ?? {})[0]; + }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts index cbca2d1f41effe..0088b6ae7bb7bc 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts @@ -19,6 +19,7 @@ import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getDurationForPercentile } from './get_duration_for_percentile'; import { processSignificantTermAggs } from '../process_significant_term_aggs'; import { getLatencyDistribution } from './get_latency_distribution'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getCorrelationsForSlowTransactions({ serviceName, @@ -35,75 +36,79 @@ export async function getCorrelationsForSlowTransactions({ fieldNames: string[]; setup: Setup & SetupTimeRange; }) { - const { start, end, esFilter, apmEventClient } = setup; + return withApmSpan('get_correlations_for_slow_transactions', async () => { + const { start, end, esFilter, apmEventClient } = setup; - const backgroundFilters: ESFilter[] = [ - ...esFilter, - { range: rangeFilter(start, end) }, - ]; + const backgroundFilters: ESFilter[] = [ + ...esFilter, + { range: rangeFilter(start, end) }, + ]; - if (serviceName) { - backgroundFilters.push({ term: { [SERVICE_NAME]: serviceName } }); - } + if (serviceName) { + backgroundFilters.push({ term: { [SERVICE_NAME]: serviceName } }); + } - if (transactionType) { - backgroundFilters.push({ term: { [TRANSACTION_TYPE]: transactionType } }); - } + if (transactionType) { + backgroundFilters.push({ term: { [TRANSACTION_TYPE]: transactionType } }); + } - if (transactionName) { - backgroundFilters.push({ term: { [TRANSACTION_NAME]: transactionName } }); - } + if (transactionName) { + backgroundFilters.push({ term: { [TRANSACTION_NAME]: transactionName } }); + } - const durationForPercentile = await getDurationForPercentile({ - durationPercentile, - backgroundFilters, - setup, - }); + const durationForPercentile = await getDurationForPercentile({ + durationPercentile, + backgroundFilters, + setup, + }); - const params = { - apm: { events: [ProcessorEvent.transaction] }, - body: { - size: 0, - query: { - bool: { - // foreground filters - filter: [ - ...backgroundFilters, - { - range: { [TRANSACTION_DURATION]: { gte: durationForPercentile } }, - }, - ], - }, - }, - aggs: fieldNames.reduce((acc, fieldName) => { - return { - ...acc, - [fieldName]: { - significant_terms: { - size: 10, - field: fieldName, - background_filter: { bool: { filter: backgroundFilters } }, + const response = await withApmSpan('get_significant_terms', () => { + const params = { + apm: { events: [ProcessorEvent.transaction] }, + body: { + size: 0, + query: { + bool: { + // foreground filters + filter: [ + ...backgroundFilters, + { + range: { + [TRANSACTION_DURATION]: { gte: durationForPercentile }, + }, + }, + ], }, }, - }; - }, {} as Record), - }, - }; - - const response = await apmEventClient.search(params); - - if (!response.aggregations) { - return {}; - } + aggs: fieldNames.reduce((acc, fieldName) => { + return { + ...acc, + [fieldName]: { + significant_terms: { + size: 10, + field: fieldName, + background_filter: { bool: { filter: backgroundFilters } }, + }, + }, + }; + }, {} as Record), + }, + }; + return apmEventClient.search(params); + }); + if (!response.aggregations) { + return {}; + } - const topSigTerms = processSignificantTermAggs({ - sigTermAggs: response.aggregations, - thresholdPercentage: 100 - durationPercentile, - }); + const topSigTerms = processSignificantTermAggs({ + sigTermAggs: response.aggregations, + thresholdPercentage: 100 - durationPercentile, + }); - return getLatencyDistribution({ - setup, - backgroundFilters, - topSigTerms, + return getLatencyDistribution({ + setup, + backgroundFilters, + topSigTerms, + }); }); } diff --git a/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts b/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts index 44158433ce5a2d..112700d0b65833 100644 --- a/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts +++ b/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts @@ -13,6 +13,7 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { withApmSpan } from '../../utils/with_apm_span'; export async function getAllEnvironments({ serviceName, @@ -25,52 +26,59 @@ export async function getAllEnvironments({ searchAggregatedTransactions: boolean; includeMissing?: boolean; }) { - const { apmEventClient, config } = setup; - const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; + const spanName = serviceName + ? 'get_all_environments_for_all_services' + : 'get_all_environments_for_service'; + return withApmSpan(spanName, async () => { + const { apmEventClient, config } = setup; + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; - // omit filter for service.name if "All" option is selected - const serviceNameFilter = serviceName - ? [{ term: { [SERVICE_NAME]: serviceName } }] - : []; + // omit filter for service.name if "All" option is selected + const serviceNameFilter = serviceName + ? [{ term: { [SERVICE_NAME]: serviceName } }] + : []; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - // use timeout + min_doc_count to return as early as possible - // if filter is not defined to prevent timeouts - ...(!serviceName ? { timeout: '1ms' } : {}), - size: 0, - query: { - bool: { - filter: [...serviceNameFilter], - }, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.error, + ProcessorEvent.metric, + ], }, - aggs: { - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - size: maxServiceEnvironments, - ...(!serviceName ? { min_doc_count: 0 } : {}), - missing: includeMissing ? ENVIRONMENT_NOT_DEFINED.value : undefined, + body: { + // use timeout + min_doc_count to return as early as possible + // if filter is not defined to prevent timeouts + ...(!serviceName ? { timeout: '1ms' } : {}), + size: 0, + query: { + bool: { + filter: [...serviceNameFilter], + }, + }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + size: maxServiceEnvironments, + ...(!serviceName ? { min_doc_count: 0 } : {}), + missing: includeMissing + ? ENVIRONMENT_NOT_DEFINED.value + : undefined, + }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); + const resp = await apmEventClient.search(params); - const environments = - resp.aggregations?.environments.buckets.map( - (bucket) => bucket.key as string - ) || []; - return environments; + const environments = + resp.aggregations?.environments.buckets.map( + (bucket) => bucket.key as string + ) || []; + return environments; + }); } diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts index 2f409cc32ceb00..383fcbb2f5ce72 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts @@ -12,6 +12,7 @@ import { } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; import { rangeFilter } from '../../../../common/utils/range_filter'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; export async function getBuckets({ @@ -25,55 +26,57 @@ export async function getBuckets({ bucketSize: number; setup: Setup & SetupTimeRange; }) { - const { start, end, esFilter, apmEventClient } = setup; - const filter: ESFilter[] = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ]; + return withApmSpan('get_error_distribution_buckets', async () => { + const { start, end, esFilter, apmEventClient } = setup; + const filter: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + { range: rangeFilter(start, end) }, + ...esFilter, + ]; - if (groupId) { - filter.push({ term: { [ERROR_GROUP_ID]: groupId } }); - } + if (groupId) { + filter.push({ term: { [ERROR_GROUP_ID]: groupId } }); + } - const params = { - apm: { - events: [ProcessorEvent.error], - }, - body: { - size: 0, - query: { - bool: { - filter, - }, + const params = { + apm: { + events: [ProcessorEvent.error], }, - aggs: { - distribution: { - histogram: { - field: '@timestamp', - min_doc_count: 0, - interval: bucketSize, - extended_bounds: { - min: start, - max: end, + body: { + size: 0, + query: { + bool: { + filter, + }, + }, + aggs: { + distribution: { + histogram: { + field: '@timestamp', + min_doc_count: 0, + interval: bucketSize, + extended_bounds: { + min: start, + max: end, + }, }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); + const resp = await apmEventClient.search(params); - const buckets = (resp.aggregations?.distribution.buckets || []).map( - (bucket) => ({ - key: bucket.key, - count: bucket.doc_count, - }) - ); + const buckets = (resp.aggregations?.distribution.buckets || []).map( + (bucket) => ({ + key: bucket.key, + count: bucket.doc_count, + }) + ); - return { - noHits: resp.hits.total.value === 0, - buckets: resp.hits.total.value > 0 ? buckets : [], - }; + return { + noHits: resp.hits.total.value === 0, + buckets: resp.hits.total.value > 0 ? buckets : [], + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts b/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts index efaf02b10c6868..121b9b3d0c46fb 100644 --- a/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts +++ b/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts @@ -12,10 +12,11 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; import { rangeFilter } from '../../../common/utils/range_filter'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getTransaction } from '../transactions/get_transaction'; -export async function getErrorGroupSample({ +export function getErrorGroupSample({ serviceName, groupId, setup, @@ -24,45 +25,47 @@ export async function getErrorGroupSample({ groupId: string; setup: Setup & SetupTimeRange; }) { - const { start, end, esFilter, apmEventClient } = setup; + return withApmSpan('get_error_group_sample', async () => { + const { start, end, esFilter, apmEventClient } = setup; - const params = { - apm: { - events: [ProcessorEvent.error as const], - }, - body: { - size: 1, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [ERROR_GROUP_ID]: groupId } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ], - should: [{ term: { [TRANSACTION_SAMPLED]: true } }], + const params = { + apm: { + events: [ProcessorEvent.error as const], + }, + body: { + size: 1, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [ERROR_GROUP_ID]: groupId } }, + { range: rangeFilter(start, end) }, + ...esFilter, + ], + should: [{ term: { [TRANSACTION_SAMPLED]: true } }], + }, }, + sort: [ + { _score: 'desc' }, // sort by _score first to ensure that errors with transaction.sampled:true ends up on top + { '@timestamp': { order: 'desc' } }, // sort by timestamp to get the most recent error + ], }, - sort: [ - { _score: 'desc' }, // sort by _score first to ensure that errors with transaction.sampled:true ends up on top - { '@timestamp': { order: 'desc' } }, // sort by timestamp to get the most recent error - ], - }, - }; + }; - const resp = await apmEventClient.search(params); - const error = resp.hits.hits[0]?._source; - const transactionId = error?.transaction?.id; - const traceId = error?.trace?.id; + const resp = await apmEventClient.search(params); + const error = resp.hits.hits[0]?._source; + const transactionId = error?.transaction?.id; + const traceId = error?.trace?.id; - let transaction; - if (transactionId && traceId) { - transaction = await getTransaction({ transactionId, traceId, setup }); - } + let transaction; + if (transactionId && traceId) { + transaction = await getTransaction({ transactionId, traceId, setup }); + } - return { - transaction, - error, - occurrencesCount: resp.hits.total.value, - }; + return { + transaction, + error, + occurrencesCount: resp.hits.total.value, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts b/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts index 44b681d7ba2016..6e91f8fe7cdd28 100644 --- a/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts +++ b/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts @@ -16,10 +16,11 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { getErrorGroupsProjection } from '../../projections/errors'; import { mergeProjection } from '../../projections/util/merge_projection'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getErrorName } from '../helpers/get_error_name'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; -export async function getErrorGroups({ +export function getErrorGroups({ serviceName, sortField, sortDirection = 'desc', @@ -30,78 +31,82 @@ export async function getErrorGroups({ sortDirection?: 'asc' | 'desc'; setup: Setup & SetupTimeRange; }) { - const { apmEventClient } = setup; + return withApmSpan('get_error_groups', async () => { + const { apmEventClient } = setup; - // sort buckets by last occurrence of error - const sortByLatestOccurrence = sortField === 'latestOccurrenceAt'; + // sort buckets by last occurrence of error + const sortByLatestOccurrence = sortField === 'latestOccurrenceAt'; - const projection = getErrorGroupsProjection({ setup, serviceName }); + const projection = getErrorGroupsProjection({ setup, serviceName }); - const order: SortOptions = sortByLatestOccurrence - ? { - max_timestamp: sortDirection, - } - : { _count: sortDirection }; + const order: SortOptions = sortByLatestOccurrence + ? { + max_timestamp: sortDirection, + } + : { _count: sortDirection }; - const params = mergeProjection(projection, { - body: { - size: 0, - aggs: { - error_groups: { - terms: { - ...projection.body.aggs.error_groups.terms, - size: 500, - order, - }, - aggs: { - sample: { - top_hits: { - _source: [ - ERROR_LOG_MESSAGE, - ERROR_EXC_MESSAGE, - ERROR_EXC_HANDLED, - ERROR_EXC_TYPE, - ERROR_CULPRIT, - ERROR_GROUP_ID, - '@timestamp', - ], - sort: [{ '@timestamp': 'desc' as const }], - size: 1, - }, + const params = mergeProjection(projection, { + body: { + size: 0, + aggs: { + error_groups: { + terms: { + ...projection.body.aggs.error_groups.terms, + size: 500, + order, }, - ...(sortByLatestOccurrence - ? { - max_timestamp: { - max: { - field: '@timestamp', + aggs: { + sample: { + top_hits: { + _source: [ + ERROR_LOG_MESSAGE, + ERROR_EXC_MESSAGE, + ERROR_EXC_HANDLED, + ERROR_EXC_TYPE, + ERROR_CULPRIT, + ERROR_GROUP_ID, + '@timestamp', + ], + sort: [{ '@timestamp': 'desc' as const }], + size: 1, + }, + }, + ...(sortByLatestOccurrence + ? { + max_timestamp: { + max: { + field: '@timestamp', + }, }, - }, - } - : {}), + } + : {}), + }, }, }, }, - }, - }); + }); - const resp = await apmEventClient.search(params); + const resp = await apmEventClient.search(params); - // aggregations can be undefined when no matching indices are found. - // this is an exception rather than the rule so the ES type does not account for this. - const hits = (resp.aggregations?.error_groups.buckets || []).map((bucket) => { - const source = bucket.sample.hits.hits[0]._source; - const message = getErrorName(source); + // aggregations can be undefined when no matching indices are found. + // this is an exception rather than the rule so the ES type does not account for this. + const hits = (resp.aggregations?.error_groups.buckets || []).map( + (bucket) => { + const source = bucket.sample.hits.hits[0]._source; + const message = getErrorName(source); - return { - message, - occurrenceCount: bucket.doc_count, - culprit: source.error.culprit, - groupId: source.error.grouping_key, - latestOccurrenceAt: source['@timestamp'], - handled: source.error.exception?.[0].handled, - type: source.error.exception?.[0].type, - }; - }); + return { + message, + occurrenceCount: bucket.doc_count, + culprit: source.error.culprit, + groupId: source.error.grouping_key, + latestOccurrenceAt: source['@timestamp'], + handled: source.error.exception?.[0].handled, + type: source.error.exception?.[0].type, + }; + } + ); - return hits; + return hits; + }); } diff --git a/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts index 04f7edc3e6dd56..424c85d55a36e1 100644 --- a/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts @@ -14,6 +14,7 @@ import { } from '../../../../common/elasticsearch_fieldnames'; import { APMConfig } from '../../..'; import { APMEventClient } from '../create_es_client/create_apm_event_client'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getHasAggregatedTransactions({ start, @@ -24,28 +25,30 @@ export async function getHasAggregatedTransactions({ end?: number; apmEventClient: APMEventClient; }) { - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - query: { - bool: { - filter: [ - { exists: { field: TRANSACTION_DURATION_HISTOGRAM } }, - ...(start && end ? [{ range: rangeFilter(start, end) }] : []), - ], + return withApmSpan('get_has_aggregated_transactions', async () => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + query: { + bool: { + filter: [ + { exists: { field: TRANSACTION_DURATION_HISTOGRAM } }, + ...(start && end ? [{ range: rangeFilter(start, end) }] : []), + ], + }, }, }, - }, - terminateAfter: 1, - }); + terminateAfter: 1, + }); - if (response.hits.total.value > 0) { - return true; - } + if (response.hits.total.value > 0) { + return true; + } - return false; + return false; + }); } export async function getSearchAggregatedTransactions({ diff --git a/x-pack/plugins/apm/server/lib/helpers/get_internal_saved_objects_client.ts b/x-pack/plugins/apm/server/lib/helpers/get_internal_saved_objects_client.ts index 9ebb353e049806..6c4b3bad89ecf2 100644 --- a/x-pack/plugins/apm/server/lib/helpers/get_internal_saved_objects_client.ts +++ b/x-pack/plugins/apm/server/lib/helpers/get_internal_saved_objects_client.ts @@ -7,12 +7,15 @@ import { CoreSetup } from 'src/core/server'; import { PromiseReturnType } from '../../../../observability/typings/common'; +import { withApmSpan } from '../../utils/with_apm_span'; export type InternalSavedObjectsClient = PromiseReturnType< typeof getInternalSavedObjectsClient >; export async function getInternalSavedObjectsClient(core: CoreSetup) { - return core.getStartServices().then(async ([coreStart]) => { - return coreStart.savedObjects.createInternalRepository(); - }); + return withApmSpan('get_internal_saved_objects_client', () => + core.getStartServices().then(async ([coreStart]) => { + return coreStart.savedObjects.createInternalRepository(); + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 3783714b2d6a01..5de2abc312815e 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -27,6 +27,7 @@ import { APMInternalClient, createInternalESClient, } from './create_es_client/create_internal_es_client'; +import { withApmSpan } from '../../utils/with_apm_span'; // Explicitly type Setup to prevent TS initialization errors // https://github.com/microsoft/TypeScript/issues/34933 @@ -71,50 +72,54 @@ export async function setupRequest( context: APMRequestHandlerContext, request: KibanaRequest ): Promise> { - const { config, logger } = context; - const { query } = context.params; + return withApmSpan('setup_request', async () => { + const { config, logger } = context; + const { query } = context.params; - const [indices, includeFrozen] = await Promise.all([ - getApmIndices({ - savedObjectsClient: context.core.savedObjects.client, - config, - }), - context.core.uiSettings.client.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN), - ]); + const [indices, includeFrozen] = await Promise.all([ + getApmIndices({ + savedObjectsClient: context.core.savedObjects.client, + config, + }), + withApmSpan('get_ui_settings', () => + context.core.uiSettings.client.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN) + ), + ]); - const uiFilters = decodeUiFilters(logger, query.uiFilters); + const uiFilters = decodeUiFilters(logger, query.uiFilters); - const coreSetupRequest = { - indices, - apmEventClient: createApmEventClient({ - esClient: context.core.elasticsearch.client.asCurrentUser, - debug: context.params.query._debug, - request, + const coreSetupRequest = { indices, - options: { includeFrozen }, - }), - internalClient: createInternalESClient({ - context, - request, - }), - ml: - context.plugins.ml && isActivePlatinumLicense(context.licensing.license) - ? getMlSetup( - context.plugins.ml, - context.core.savedObjects.client, - request - ) - : undefined, - config, - uiFilters, - esFilter: getEsFilter(uiFilters), - }; + apmEventClient: createApmEventClient({ + esClient: context.core.elasticsearch.client.asCurrentUser, + debug: context.params.query._debug, + request, + indices, + options: { includeFrozen }, + }), + internalClient: createInternalESClient({ + context, + request, + }), + ml: + context.plugins.ml && isActivePlatinumLicense(context.licensing.license) + ? getMlSetup( + context.plugins.ml, + context.core.savedObjects.client, + request + ) + : undefined, + config, + uiFilters, + esFilter: getEsFilter(uiFilters), + }; - return { - ...('start' in query ? { start: moment.utc(query.start).valueOf() } : {}), - ...('end' in query ? { end: moment.utc(query.end).valueOf() } : {}), - ...coreSetupRequest, - } as InferSetup; + return { + ...('start' in query ? { start: moment.utc(query.start).valueOf() } : {}), + ...('end' in query ? { end: moment.utc(query.end).valueOf() } : {}), + ...coreSetupRequest, + } as InferSetup; + }); } function getMlSetup( diff --git a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts index d62386ed02835c..8d0acb7f85f5d7 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts @@ -14,6 +14,7 @@ import { hasHistoricalAgentData } from '../services/get_services/has_historical_ import { Setup } from '../helpers/setup_request'; import { APMRequestHandlerContext } from '../../routes/typings'; import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client.js'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getApmIndexPatternTitle } from './get_apm_index_pattern_title'; export async function createStaticIndexPattern( @@ -21,37 +22,41 @@ export async function createStaticIndexPattern( context: APMRequestHandlerContext, savedObjectsClient: InternalSavedObjectsClient ): Promise { - const { config } = context; + return withApmSpan('create_static_index_pattern', async () => { + const { config } = context; - // don't autocreate APM index pattern if it's been disabled via the config - if (!config['xpack.apm.autocreateApmIndexPattern']) { - return; - } + // don't autocreate APM index pattern if it's been disabled via the config + if (!config['xpack.apm.autocreateApmIndexPattern']) { + return; + } - // Discover and other apps will throw errors if an index pattern exists without having matching indices. - // The following ensures the index pattern is only created if APM data is found - const hasData = await hasHistoricalAgentData(setup); - if (!hasData) { - return; - } + // Discover and other apps will throw errors if an index pattern exists without having matching indices. + // The following ensures the index pattern is only created if APM data is found + const hasData = await hasHistoricalAgentData(setup); + if (!hasData) { + return; + } - try { - const apmIndexPatternTitle = getApmIndexPatternTitle(context); - await savedObjectsClient.create( - 'index-pattern', - { - ...apmIndexPattern.attributes, - title: apmIndexPatternTitle, - }, - { id: APM_STATIC_INDEX_PATTERN_ID, overwrite: false } - ); - return; - } catch (e) { - // if the index pattern (saved object) already exists a conflict error (code: 409) will be thrown - // that error should be silenced - if (SavedObjectsErrorHelpers.isConflictError(e)) { + try { + const apmIndexPatternTitle = getApmIndexPatternTitle(context); + await withApmSpan('create_index_pattern_saved_object', () => + savedObjectsClient.create( + 'index-pattern', + { + ...apmIndexPattern.attributes, + title: apmIndexPatternTitle, + }, + { id: APM_STATIC_INDEX_PATTERN_ID, overwrite: false } + ) + ); return; + } catch (e) { + // if the index pattern (saved object) already exists a conflict error (code: 409) will be thrown + // that error should be silenced + if (SavedObjectsErrorHelpers.isConflictError(e)) { + return; + } + throw e; } - throw e; - } + }); } diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts index c427588f8d860d..0d6d8b58b32f22 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts @@ -11,6 +11,7 @@ import { FieldDescriptor, } from '../../../../../../src/plugins/data/server'; import { APMRequestHandlerContext } from '../../routes/typings'; +import { withApmSpan } from '../../utils/with_apm_span'; interface IndexPatternTitleAndFields { title: string; @@ -23,50 +24,52 @@ const cache = new LRU({ }); // TODO: this is currently cached globally. In the future we might want to cache this per user -export const getDynamicIndexPattern = async ({ +export const getDynamicIndexPattern = ({ context, }: { context: APMRequestHandlerContext; }) => { - const indexPatternTitle = context.config['apm_oss.indexPattern']; + return withApmSpan('get_dynamic_index_pattern', async () => { + const indexPatternTitle = context.config['apm_oss.indexPattern']; - const CACHE_KEY = `apm_dynamic_index_pattern_${indexPatternTitle}`; - if (cache.has(CACHE_KEY)) { - return cache.get(CACHE_KEY); - } + const CACHE_KEY = `apm_dynamic_index_pattern_${indexPatternTitle}`; + if (cache.has(CACHE_KEY)) { + return cache.get(CACHE_KEY); + } - const indexPatternsFetcher = new IndexPatternsFetcher( - context.core.elasticsearch.client.asCurrentUser - ); + const indexPatternsFetcher = new IndexPatternsFetcher( + context.core.elasticsearch.client.asCurrentUser + ); - // Since `getDynamicIndexPattern` is called in setup_request (and thus by every endpoint) - // and since `getFieldsForWildcard` will throw if the specified indices don't exist, - // we have to catch errors here to avoid all endpoints returning 500 for users without APM data - // (would be a bad first time experience) - try { - const fields = await indexPatternsFetcher.getFieldsForWildcard({ - pattern: indexPatternTitle, - }); + // Since `getDynamicIndexPattern` is called in setup_request (and thus by every endpoint) + // and since `getFieldsForWildcard` will throw if the specified indices don't exist, + // we have to catch errors here to avoid all endpoints returning 500 for users without APM data + // (would be a bad first time experience) + try { + const fields = await indexPatternsFetcher.getFieldsForWildcard({ + pattern: indexPatternTitle, + }); - const indexPattern: IndexPatternTitleAndFields = { - fields, - title: indexPatternTitle, - }; + const indexPattern: IndexPatternTitleAndFields = { + fields, + title: indexPatternTitle, + }; - cache.set(CACHE_KEY, indexPattern); - return indexPattern; - } catch (e) { - // since `getDynamicIndexPattern` can be called multiple times per request it can be expensive not to cache failed lookups - cache.set(CACHE_KEY, undefined); - const notExists = e.output?.statusCode === 404; - if (notExists) { - context.logger.error( - `Could not get dynamic index pattern because indices "${indexPatternTitle}" don't exist` - ); - return; - } + cache.set(CACHE_KEY, indexPattern); + return indexPattern; + } catch (e) { + // since `getDynamicIndexPattern` can be called multiple times per request it can be expensive not to cache failed lookups + cache.set(CACHE_KEY, undefined); + const notExists = e.output?.statusCode === 404; + if (notExists) { + context.logger.error( + `Could not get dynamic index pattern because indices "${indexPatternTitle}" don't exist` + ); + return; + } - // re-throw - throw e; - } + // re-throw + throw e; + } + }); }; diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_rate_chart.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_rate_chart.ts index 730423d3269012..7989f57046ae78 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_rate_chart.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_rate_chart.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_JAVA_GC_COUNT } from '../../../../../../common/elasticsearch_fieldnames'; import { Setup, SetupTimeRange } from '../../../../helpers/setup_request'; import { fetchAndTransformGcMetrics } from './fetch_and_transform_gc_metrics'; @@ -40,13 +41,15 @@ function getGcRateChart({ serviceName: string; serviceNodeName?: string; }) { - return fetchAndTransformGcMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - fieldName: METRIC_JAVA_GC_COUNT, - }); + return withApmSpan('get_gc_rate_charts', () => + fetchAndTransformGcMetrics({ + setup, + serviceName, + serviceNodeName, + chartBase, + fieldName: METRIC_JAVA_GC_COUNT, + }) + ); } export { getGcRateChart }; diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_time_chart.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_time_chart.ts index ed9f135ea8b351..446894f82b75e3 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_time_chart.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_time_chart.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_JAVA_GC_TIME } from '../../../../../../common/elasticsearch_fieldnames'; import { Setup, SetupTimeRange } from '../../../../helpers/setup_request'; import { fetchAndTransformGcMetrics } from './fetch_and_transform_gc_metrics'; @@ -40,13 +41,15 @@ function getGcTimeChart({ serviceName: string; serviceNodeName?: string; }) { - return fetchAndTransformGcMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - fieldName: METRIC_JAVA_GC_TIME, - }); + return withApmSpan('get_gc_time_charts', () => + fetchAndTransformGcMetrics({ + setup, + serviceName, + serviceNodeName, + chartBase, + fieldName: METRIC_JAVA_GC_TIME, + }) + ); } export { getGcTimeChart }; diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts index 3e34fd407dd429..2b7bb9ea8da6ed 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_JAVA_HEAP_MEMORY_MAX, METRIC_JAVA_HEAP_MEMORY_COMMITTED, @@ -51,7 +52,7 @@ const chartBase: ChartBase = { series, }; -export async function getHeapMemoryChart({ +export function getHeapMemoryChart({ setup, serviceName, serviceNodeName, @@ -60,18 +61,20 @@ export async function getHeapMemoryChart({ serviceName: string; serviceNodeName?: string; }) { - return fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - heapMemoryMax: { avg: { field: METRIC_JAVA_HEAP_MEMORY_MAX } }, - heapMemoryCommitted: { - avg: { field: METRIC_JAVA_HEAP_MEMORY_COMMITTED }, + return withApmSpan('get_heap_memory_charts', () => + fetchAndTransformMetrics({ + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + heapMemoryMax: { avg: { field: METRIC_JAVA_HEAP_MEMORY_MAX } }, + heapMemoryCommitted: { + avg: { field: METRIC_JAVA_HEAP_MEMORY_COMMITTED }, + }, + heapMemoryUsed: { avg: { field: METRIC_JAVA_HEAP_MEMORY_USED } }, }, - heapMemoryUsed: { avg: { field: METRIC_JAVA_HEAP_MEMORY_USED } }, - }, - additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], - }); + additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.ts index d5751085e26c0b..e137720000262a 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { withApmSpan } from '../../../../utils/with_apm_span'; import { getHeapMemoryChart } from './heap_memory'; import { Setup, SetupTimeRange } from '../../../helpers/setup_request'; import { getNonHeapMemoryChart } from './non_heap_memory'; @@ -14,7 +15,7 @@ import { getMemoryChartData } from '../shared/memory'; import { getGcRateChart } from './gc/get_gc_rate_chart'; import { getGcTimeChart } from './gc/get_gc_time_chart'; -export async function getJavaMetricsCharts({ +export function getJavaMetricsCharts({ setup, serviceName, serviceNodeName, @@ -23,15 +24,17 @@ export async function getJavaMetricsCharts({ serviceName: string; serviceNodeName?: string; }) { - const charts = await Promise.all([ - getCPUChartData({ setup, serviceName, serviceNodeName }), - getMemoryChartData({ setup, serviceName, serviceNodeName }), - getHeapMemoryChart({ setup, serviceName, serviceNodeName }), - getNonHeapMemoryChart({ setup, serviceName, serviceNodeName }), - getThreadCountChart({ setup, serviceName, serviceNodeName }), - getGcRateChart({ setup, serviceName, serviceNodeName }), - getGcTimeChart({ setup, serviceName, serviceNodeName }), - ]); + return withApmSpan('get_java_system_metric_charts', async () => { + const charts = await Promise.all([ + getCPUChartData({ setup, serviceName, serviceNodeName }), + getMemoryChartData({ setup, serviceName, serviceNodeName }), + getHeapMemoryChart({ setup, serviceName, serviceNodeName }), + getNonHeapMemoryChart({ setup, serviceName, serviceNodeName }), + getThreadCountChart({ setup, serviceName, serviceNodeName }), + getGcRateChart({ setup, serviceName, serviceNodeName }), + getGcTimeChart({ setup, serviceName, serviceNodeName }), + ]); - return { charts }; + return { charts }; + }); } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts index 0f38c39b00d214..a3e253d2c81d67 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_JAVA_NON_HEAP_MEMORY_MAX, METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED, @@ -57,20 +58,22 @@ export async function getNonHeapMemoryChart({ serviceName: string; serviceNodeName?: string; }) { - return fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - nonHeapMemoryMax: { avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_MAX } }, - nonHeapMemoryCommitted: { - avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED }, + return withApmSpan('get_non_heap_memory_charts', () => + fetchAndTransformMetrics({ + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + nonHeapMemoryMax: { avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_MAX } }, + nonHeapMemoryCommitted: { + avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED }, + }, + nonHeapMemoryUsed: { + avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_USED }, + }, }, - nonHeapMemoryUsed: { - avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_USED }, - }, - }, - additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], - }); + additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts index 670a94f53515aa..e176c156ad05a5 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_JAVA_THREAD_COUNT, AGENT_NAME, @@ -49,15 +50,17 @@ export async function getThreadCountChart({ serviceName: string; serviceNodeName?: string; }) { - return fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - threadCount: { avg: { field: METRIC_JAVA_THREAD_COUNT } }, - threadCountMax: { max: { field: METRIC_JAVA_THREAD_COUNT } }, - }, - additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], - }); + return withApmSpan('get_thread_count_charts', () => + fetchAndTransformMetrics({ + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + threadCount: { avg: { field: METRIC_JAVA_THREAD_COUNT } }, + threadCountMax: { max: { field: METRIC_JAVA_THREAD_COUNT } }, + }, + additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts index 6ea8d942aad43d..e7f576b73c5aef 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_SYSTEM_CPU_PERCENT, METRIC_PROCESS_CPU_PERCENT, @@ -52,7 +53,7 @@ const chartBase: ChartBase = { series, }; -export async function getCPUChartData({ +export function getCPUChartData({ setup, serviceName, serviceNodeName, @@ -61,18 +62,18 @@ export async function getCPUChartData({ serviceName: string; serviceNodeName?: string; }) { - const metricsChart = await fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - systemCPUAverage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } }, - systemCPUMax: { max: { field: METRIC_SYSTEM_CPU_PERCENT } }, - processCPUAverage: { avg: { field: METRIC_PROCESS_CPU_PERCENT } }, - processCPUMax: { max: { field: METRIC_PROCESS_CPU_PERCENT } }, - }, - }); - - return metricsChart; + return withApmSpan('get_cpu_metric_charts', () => + fetchAndTransformMetrics({ + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + systemCPUAverage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } }, + systemCPUMax: { max: { field: METRIC_SYSTEM_CPU_PERCENT } }, + processCPUAverage: { avg: { field: METRIC_PROCESS_CPU_PERCENT } }, + processCPUMax: { max: { field: METRIC_PROCESS_CPU_PERCENT } }, + }, + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts index 5c31ffca509bc5..0f7954d86d3e2c 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_CGROUP_MEMORY_LIMIT_BYTES, METRIC_CGROUP_MEMORY_USAGE_BYTES, @@ -78,36 +79,44 @@ export async function getMemoryChartData({ serviceName: string; serviceNodeName?: string; }) { - const cgroupResponse = await fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - memoryUsedAvg: { avg: { script: percentCgroupMemoryUsedScript } }, - memoryUsedMax: { max: { script: percentCgroupMemoryUsedScript } }, - }, - additionalFilters: [ - { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, - ], - }); + return withApmSpan('get_memory_metrics_charts', async () => { + const cgroupResponse = await withApmSpan( + 'get_cgroup_memory_metrics_charts', + () => + fetchAndTransformMetrics({ + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + memoryUsedAvg: { avg: { script: percentCgroupMemoryUsedScript } }, + memoryUsedMax: { max: { script: percentCgroupMemoryUsedScript } }, + }, + additionalFilters: [ + { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, + ], + }) + ); - if (cgroupResponse.noHits) { - return await fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - memoryUsedAvg: { avg: { script: percentSystemMemoryUsedScript } }, - memoryUsedMax: { max: { script: percentSystemMemoryUsedScript } }, - }, - additionalFilters: [ - { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, - { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, - ], - }); - } + if (cgroupResponse.noHits) { + return await withApmSpan('get_system_memory_metrics_charts', () => + fetchAndTransformMetrics({ + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + memoryUsedAvg: { avg: { script: percentSystemMemoryUsedScript } }, + memoryUsedMax: { max: { script: percentSystemMemoryUsedScript } }, + }, + additionalFilters: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + }) + ); + } - return cgroupResponse; + return cgroupResponse; + }); } diff --git a/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts index 15c3ccfaec232f..4af57a685bf839 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts @@ -10,37 +10,40 @@ import { rangeFilter } from '../../../common/utils/range_filter'; import { SERVICE_NAME } from '../../../common/elasticsearch_fieldnames'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getServiceCount({ +export function getServiceCount({ setup, searchAggregatedTransactions, }: { setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { apmEventClient, start, end } = setup; + return withApmSpan('observability_overview_get_service_count', async () => { + const { apmEventClient, start, end } = setup; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [{ range: rangeFilter(start, end) }], + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [{ range: rangeFilter(start, end) }], + }, }, + aggs: { serviceCount: { cardinality: { field: SERVICE_NAME } } }, }, - aggs: { serviceCount: { cardinality: { field: SERVICE_NAME } } }, - }, - }; + }; - const { aggregations } = await apmEventClient.search(params); - return aggregations?.serviceCount.value || 0; + const { aggregations } = await apmEventClient.search(params); + return aggregations?.serviceCount.value || 0; + }); } diff --git a/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts index 6e6bd7ca6ef3d7..87394567afc50b 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts @@ -10,8 +10,9 @@ import { Coordinates } from '../../../../observability/typings/common'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; import { calculateThroughput } from '../helpers/calculate_throughput'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getTransactionCoordinates({ +export function getTransactionCoordinates({ setup, bucketSize, searchAggregatedTransactions, @@ -20,39 +21,44 @@ export async function getTransactionCoordinates({ bucketSize: string; searchAggregatedTransactions: boolean; }): Promise { - const { apmEventClient, start, end } = setup; + return withApmSpan( + 'observability_overview_get_transaction_distribution', + async () => { + const { apmEventClient, start, end } = setup; - const { aggregations } = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [{ range: rangeFilter(start, end) }], + const { aggregations } = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - }, - aggs: { - distribution: { - date_histogram: { - field: '@timestamp', - fixed_interval: bucketSize, - min_doc_count: 0, + body: { + size: 0, + query: { + bool: { + filter: [{ range: rangeFilter(start, end) }], + }, + }, + aggs: { + distribution: { + date_histogram: { + field: '@timestamp', + fixed_interval: bucketSize, + min_doc_count: 0, + }, + }, }, }, - }, - }, - }); + }); - return ( - aggregations?.distribution.buckets.map((bucket) => ({ - x: bucket.key, - y: calculateThroughput({ start, end, value: bucket.doc_count }), - })) || [] + return ( + aggregations?.distribution.buckets.map((bucket) => ({ + x: bucket.key, + y: calculateThroughput({ start, end, value: bucket.doc_count }), + })) || [] + ); + } ); } diff --git a/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts index dc9186fa2b354b..abdc8da78502c7 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts @@ -6,28 +6,31 @@ */ import { ProcessorEvent } from '../../../common/processor_event'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup } from '../helpers/setup_request'; -export async function hasData({ setup }: { setup: Setup }) { - const { apmEventClient } = setup; - try { - const params = { - apm: { - events: [ - ProcessorEvent.transaction, - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - terminateAfter: 1, - body: { - size: 0, - }, - }; +export function hasData({ setup }: { setup: Setup }) { + return withApmSpan('observability_overview_has_apm_data', async () => { + const { apmEventClient } = setup; + try { + const params = { + apm: { + events: [ + ProcessorEvent.transaction, + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + terminateAfter: 1, + body: { + size: 0, + }, + }; - const response = await apmEventClient.search(params); - return response.hits.total.value > 0; - } catch (e) { - return false; - } + const response = await apmEventClient.search(params); + return response.hits.total.value > 0; + } catch (e) { + return false; + } + }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts b/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts index 7aa019464754de..939ebbb1f79419 100644 --- a/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts @@ -14,42 +14,44 @@ import { ServiceConnectionNode, } from '../../../common/service_map'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { withApmSpan } from '../../utils/with_apm_span'; export async function fetchServicePathsFromTraceIds( setup: Setup & SetupTimeRange, traceIds: string[] ) { - const { apmEventClient } = setup; - - // make sure there's a range so ES can skip shards - const dayInMs = 24 * 60 * 60 * 1000; - const start = setup.start - dayInMs; - const end = setup.end + dayInMs; - - const serviceMapParams = { - apm: { - events: [ProcessorEvent.span, ProcessorEvent.transaction], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { - terms: { - [TRACE_ID]: traceIds, + return withApmSpan('get_service_paths_from_trace_ids', async () => { + const { apmEventClient } = setup; + + // make sure there's a range so ES can skip shards + const dayInMs = 24 * 60 * 60 * 1000; + const start = setup.start - dayInMs; + const end = setup.end + dayInMs; + + const serviceMapParams = { + apm: { + events: [ProcessorEvent.span, ProcessorEvent.transaction], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { + terms: { + [TRACE_ID]: traceIds, + }, }, - }, - { range: rangeFilter(start, end) }, - ], + { range: rangeFilter(start, end) }, + ], + }, }, - }, - aggs: { - service_map: { - scripted_metric: { - init_script: { - lang: 'painless', - source: `state.eventsById = new HashMap(); + aggs: { + service_map: { + scripted_metric: { + init_script: { + lang: 'painless', + source: `state.eventsById = new HashMap(); String[] fieldsToCopy = new String[] { 'parent.id', @@ -63,10 +65,10 @@ export async function fetchServicePathsFromTraceIds( 'agent.name' }; state.fieldsToCopy = fieldsToCopy;`, - }, - map_script: { - lang: 'painless', - source: `def id; + }, + map_script: { + lang: 'painless', + source: `def id; if (!doc['span.id'].empty) { id = doc['span.id'].value; } else { @@ -83,14 +85,14 @@ export async function fetchServicePathsFromTraceIds( } state.eventsById[id] = copy`, - }, - combine_script: { - lang: 'painless', - source: `return state.eventsById;`, - }, - reduce_script: { - lang: 'painless', - source: ` + }, + combine_script: { + lang: 'painless', + source: `return state.eventsById;`, + }, + reduce_script: { + lang: 'painless', + source: ` def getDestination ( def event ) { def destination = new HashMap(); destination['span.destination.service.resource'] = event['span.destination.service.resource']; @@ -206,28 +208,29 @@ export async function fetchServicePathsFromTraceIds( response.discoveredServices = discoveredServices; return response;`, + }, }, }, }, }, - }, - }; - - const serviceMapFromTraceIdsScriptResponse = await apmEventClient.search( - serviceMapParams - ); - - return serviceMapFromTraceIdsScriptResponse as { - aggregations?: { - service_map: { - value: { - paths: ConnectionNode[][]; - discoveredServices: Array<{ - from: ExternalConnectionNode; - to: ServiceConnectionNode; - }>; + }; + + const serviceMapFromTraceIdsScriptResponse = await apmEventClient.search( + serviceMapParams + ); + + return serviceMapFromTraceIdsScriptResponse as { + aggregations?: { + service_map: { + value: { + paths: ConnectionNode[][]; + discoveredServices: Array<{ + from: ExternalConnectionNode; + to: ServiceConnectionNode; + }>; + }; }; }; }; - }; + }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts index e9971114049869..2c64678eb082ec 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -17,6 +17,7 @@ import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, } from '../../../common/transaction_types'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getMlJobsWithAPMGroup } from '../anomaly_detection/get_ml_jobs_with_apm_group'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; @@ -36,115 +37,119 @@ export async function getServiceAnomalies({ setup: Setup & SetupTimeRange; environment?: string; }) { - const { ml, start, end } = setup; + return withApmSpan('get_service_anomalies', async () => { + const { ml, start, end } = setup; - if (!ml) { - throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); - } + if (!ml) { + throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); + } - const params = { - body: { - size: 0, - query: { - bool: { - filter: [ - { terms: { result_type: ['model_plot', 'record'] } }, - { - range: { - timestamp: { - // fetch data for at least 30 minutes - gte: Math.min(end - 30 * 60 * 1000, start), - lte: end, - format: 'epoch_millis', + const params = { + body: { + size: 0, + query: { + bool: { + filter: [ + { terms: { result_type: ['model_plot', 'record'] } }, + { + range: { + timestamp: { + // fetch data for at least 30 minutes + gte: Math.min(end - 30 * 60 * 1000, start), + lte: end, + format: 'epoch_millis', + }, }, }, - }, - { - terms: { - // Only retrieving anomalies for transaction types "request" and "page-load" - by_field_value: [TRANSACTION_REQUEST, TRANSACTION_PAGE_LOAD], + { + terms: { + // Only retrieving anomalies for transaction types "request" and "page-load" + by_field_value: [TRANSACTION_REQUEST, TRANSACTION_PAGE_LOAD], + }, }, - }, - ], - }, - }, - aggs: { - services: { - composite: { - size: 5000, - sources: [ - { serviceName: { terms: { field: 'partition_field_value' } } }, - { jobId: { terms: { field: 'job_id' } } }, ], }, - aggs: { - metrics: { - top_metrics: { - metrics: [ - { field: 'actual' }, - { field: 'by_field_value' }, - { field: 'result_type' }, - { field: 'record_score' }, - ] as const, - sort: { - record_score: 'desc' as const, + }, + aggs: { + services: { + composite: { + size: 5000, + sources: [ + { serviceName: { terms: { field: 'partition_field_value' } } }, + { jobId: { terms: { field: 'job_id' } } }, + ], + }, + aggs: { + metrics: { + top_metrics: { + metrics: [ + { field: 'actual' }, + { field: 'by_field_value' }, + { field: 'result_type' }, + { field: 'record_score' }, + ] as const, + sort: { + record_score: 'desc' as const, + }, }, }, }, }, }, }, - }, - }; + }; - const [anomalyResponse, jobIds] = await Promise.all([ - // pass an empty array of job ids to anomaly search - // so any validation is skipped - ml.mlSystem.mlAnomalySearch(params, []), - getMLJobIds(ml.anomalyDetectors, environment), - ]); + const [anomalyResponse, jobIds] = await Promise.all([ + // pass an empty array of job ids to anomaly search + // so any validation is skipped + withApmSpan('ml_anomaly_search', () => + ml.mlSystem.mlAnomalySearch(params, []) + ), + getMLJobIds(ml.anomalyDetectors, environment), + ]); - const typedAnomalyResponse: ESSearchResponse< - unknown, - typeof params - > = anomalyResponse as any; - const relevantBuckets = uniqBy( - sortBy( - // make sure we only return data for jobs that are available in this space - typedAnomalyResponse.aggregations?.services.buckets.filter((bucket) => - jobIds.includes(bucket.key.jobId as string) - ) ?? [], - // sort by job ID in case there are multiple jobs for one service to - // ensure consistent results - (bucket) => bucket.key.jobId - ), - // return one bucket per service - (bucket) => bucket.key.serviceName - ); + const typedAnomalyResponse: ESSearchResponse< + unknown, + typeof params + > = anomalyResponse as any; + const relevantBuckets = uniqBy( + sortBy( + // make sure we only return data for jobs that are available in this space + typedAnomalyResponse.aggregations?.services.buckets.filter((bucket) => + jobIds.includes(bucket.key.jobId as string) + ) ?? [], + // sort by job ID in case there are multiple jobs for one service to + // ensure consistent results + (bucket) => bucket.key.jobId + ), + // return one bucket per service + (bucket) => bucket.key.serviceName + ); - return { - mlJobIds: jobIds, - serviceAnomalies: relevantBuckets.map((bucket) => { - const metrics = bucket.metrics.top[0].metrics; + return { + mlJobIds: jobIds, + serviceAnomalies: relevantBuckets.map((bucket) => { + const metrics = bucket.metrics.top[0].metrics; - const anomalyScore = - metrics.result_type === 'record' && metrics.record_score - ? (metrics.record_score as number) - : 0; + const anomalyScore = + metrics.result_type === 'record' && metrics.record_score + ? (metrics.record_score as number) + : 0; - const severity = getSeverity(anomalyScore); - const healthStatus = getServiceHealthStatus({ severity }); + const severity = getSeverity(anomalyScore); + const healthStatus = getServiceHealthStatus({ severity }); - return { - serviceName: bucket.key.serviceName as string, - jobId: bucket.key.jobId as string, - transactionType: metrics.by_field_value as string, - actualValue: metrics.actual as number | null, - anomalyScore, - healthStatus, - }; - }), - }; + return { + serviceName: bucket.key.serviceName as string, + jobId: bucket.key.jobId as string, + transactionType: metrics.by_field_value as string, + actualValue: metrics.actual as number | null, + anomalyScore, + healthStatus, + }; + }), + }; + }); } export async function getMLJobs( diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 5b7589925d1103..951484308db195 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -15,6 +15,7 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { getServicesProjection } from '../../projections/services'; import { mergeProjection } from '../../projections/util/merge_projection'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { @@ -38,137 +39,147 @@ async function getConnectionData({ serviceName, environment, }: IEnvOptions) { - const { traceIds } = await getTraceSampleIds({ - setup, - serviceName, - environment, - }); + return withApmSpan('get_service_map_connections', async () => { + const { traceIds } = await getTraceSampleIds({ + setup, + serviceName, + environment, + }); + + const chunks = chunk( + traceIds, + setup.config['xpack.apm.serviceMapMaxTracesPerRequest'] + ); - const chunks = chunk( - traceIds, - setup.config['xpack.apm.serviceMapMaxTracesPerRequest'] - ); - - const init = { - connections: [], - discoveredServices: [], - }; - - if (!traceIds.length) { - return init; - } - - const chunkedResponses = await Promise.all( - chunks.map((traceIdsChunk) => - getServiceMapFromTraceIds({ - setup, - serviceName, - environment, - traceIds: traceIdsChunk, - }) - ) - ); - - return chunkedResponses.reduce((prev, current) => { - return { - connections: prev.connections.concat(current.connections), - discoveredServices: prev.discoveredServices.concat( - current.discoveredServices - ), + const init = { + connections: [], + discoveredServices: [], }; + + if (!traceIds.length) { + return init; + } + + const chunkedResponses = await withApmSpan( + 'get_service_paths_from_all_trace_ids', + () => + Promise.all( + chunks.map((traceIdsChunk) => + getServiceMapFromTraceIds({ + setup, + serviceName, + environment, + traceIds: traceIdsChunk, + }) + ) + ) + ); + + return chunkedResponses.reduce((prev, current) => { + return { + connections: prev.connections.concat(current.connections), + discoveredServices: prev.discoveredServices.concat( + current.discoveredServices + ), + }; + }); }); } async function getServicesData(options: IEnvOptions) { - const { setup, searchAggregatedTransactions } = options; + return withApmSpan('get_service_stats_for_service_map', async () => { + const { setup, searchAggregatedTransactions } = options; - const projection = getServicesProjection({ - setup: { ...setup, esFilter: [] }, - searchAggregatedTransactions, - }); + const projection = getServicesProjection({ + setup: { ...setup, esFilter: [] }, + searchAggregatedTransactions, + }); - let { filter } = projection.body.query.bool; + let { filter } = projection.body.query.bool; - if (options.serviceName) { - filter = filter.concat({ - term: { - [SERVICE_NAME]: options.serviceName, - }, - }); - } - - if (options.environment) { - filter = filter.concat(getEnvironmentUiFilterES(options.environment)); - } - - const params = mergeProjection(projection, { - body: { - size: 0, - query: { - bool: { - ...projection.body.query.bool, - filter, + if (options.serviceName) { + filter = filter.concat({ + term: { + [SERVICE_NAME]: options.serviceName, }, - }, - aggs: { - services: { - terms: { - field: projection.body.aggs.services.terms.field, - size: 500, + }); + } + + if (options.environment) { + filter = filter.concat(getEnvironmentUiFilterES(options.environment)); + } + + const params = mergeProjection(projection, { + body: { + size: 0, + query: { + bool: { + ...projection.body.query.bool, + filter, }, - aggs: { - agent_name: { - terms: { - field: AGENT_NAME, + }, + aggs: { + services: { + terms: { + field: projection.body.aggs.services.terms.field, + size: 500, + }, + aggs: { + agent_name: { + terms: { + field: AGENT_NAME, + }, }, }, }, }, }, - }, - }); + }); - const { apmEventClient } = setup; + const { apmEventClient } = setup; - const response = await apmEventClient.search(params); + const response = await apmEventClient.search(params); - return ( - response.aggregations?.services.buckets.map((bucket) => { - return { - [SERVICE_NAME]: bucket.key as string, - [AGENT_NAME]: - (bucket.agent_name.buckets[0]?.key as string | undefined) || '', - [SERVICE_ENVIRONMENT]: options.environment || null, - }; - }) || [] - ); + return ( + response.aggregations?.services.buckets.map((bucket) => { + return { + [SERVICE_NAME]: bucket.key as string, + [AGENT_NAME]: + (bucket.agent_name.buckets[0]?.key as string | undefined) || '', + [SERVICE_ENVIRONMENT]: options.environment || null, + }; + }) || [] + ); + }); } export type ConnectionsResponse = PromiseReturnType; export type ServicesResponse = PromiseReturnType; export type ServiceMapAPIResponse = PromiseReturnType; -export async function getServiceMap(options: IEnvOptions) { - const { logger } = options; - const anomaliesPromise = getServiceAnomalies( - options - - // always catch error to avoid breaking service maps if there is a problem with ML - ).catch((error) => { - logger.warn(`Unable to retrieve anomalies for service maps.`); - logger.error(error); - return DEFAULT_ANOMALIES; - }); +export function getServiceMap(options: IEnvOptions) { + return withApmSpan('get_service_map', async () => { + const { logger } = options; + const anomaliesPromise = getServiceAnomalies( + options + + // always catch error to avoid breaking service maps if there is a problem with ML + ).catch((error) => { + logger.warn(`Unable to retrieve anomalies for service maps.`); + logger.error(error); + return DEFAULT_ANOMALIES; + }); - const [connectionData, servicesData, anomalies] = await Promise.all([ - getConnectionData(options), - getServicesData(options), - anomaliesPromise, - ]); + const [connectionData, servicesData, anomalies] = await Promise.all([ + getConnectionData(options), + getServicesData(options), + anomaliesPromise, + ]); - return transformServiceMapResponses({ - ...connectionData, - services: servicesData, - anomalies, + return transformServiceMapResponses({ + ...connectionData, + services: servicesData, + anomalies, + }); }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts index 71be5f5392b6fb..213702bf06f4ce 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -20,6 +20,7 @@ import { TRANSACTION_REQUEST, } from '../../../common/transaction_types'; import { rangeFilter } from '../../../common/utils/range_filter'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, @@ -49,46 +50,48 @@ interface TaskParameters { setup: Setup; } -export async function getServiceMapServiceNodeInfo({ +export function getServiceMapServiceNodeInfo({ serviceName, setup, searchAggregatedTransactions, }: Options & { serviceName: string }) { - const { start, end, uiFilters } = setup; - - const filter: ESFilter[] = [ - { range: rangeFilter(start, end) }, - { term: { [SERVICE_NAME]: serviceName } }, - ...getEnvironmentUiFilterES(uiFilters.environment), - ]; - - const minutes = Math.abs((end - start) / (1000 * 60)); - const taskParams = { - environment: uiFilters.environment, - filter, - searchAggregatedTransactions, - minutes, - serviceName, - setup, - }; - - const [ - errorStats, - transactionStats, - cpuStats, - memoryStats, - ] = await Promise.all([ - getErrorStats(taskParams), - getTransactionStats(taskParams), - getCpuStats(taskParams), - getMemoryStats(taskParams), - ]); - return { - ...errorStats, - transactionStats, - ...cpuStats, - ...memoryStats, - }; + return withApmSpan('get_service_map_node_stats', async () => { + const { start, end, uiFilters } = setup; + + const filter: ESFilter[] = [ + { range: rangeFilter(start, end) }, + { term: { [SERVICE_NAME]: serviceName } }, + ...getEnvironmentUiFilterES(uiFilters.environment), + ]; + + const minutes = Math.abs((end - start) / (1000 * 60)); + const taskParams = { + environment: uiFilters.environment, + filter, + searchAggregatedTransactions, + minutes, + serviceName, + setup, + }; + + const [ + errorStats, + transactionStats, + cpuStats, + memoryStats, + ] = await Promise.all([ + getErrorStats(taskParams), + getTransactionStats(taskParams), + getCpuStats(taskParams), + getMemoryStats(taskParams), + ]); + return { + ...errorStats, + transactionStats, + ...cpuStats, + ...memoryStats, + }; + }); } async function getErrorStats({ @@ -102,20 +105,22 @@ async function getErrorStats({ environment?: string; searchAggregatedTransactions: boolean; }) { - const setupWithBlankUiFilters = { - ...setup, - uiFilters: { environment }, - esFilter: getEnvironmentUiFilterES(environment), - }; - const { noHits, average } = await getErrorRate({ - setup: setupWithBlankUiFilters, - serviceName, - searchAggregatedTransactions, + return withApmSpan('get_error_rate_for_service_map_node', async () => { + const setupWithBlankUiFilters = { + ...setup, + uiFilters: { environment }, + esFilter: getEnvironmentUiFilterES(environment), + }; + const { noHits, average } = await getErrorRate({ + setup: setupWithBlankUiFilters, + serviceName, + searchAggregatedTransactions, + }); + return { avgErrorRate: noHits ? null : average }; }); - return { avgErrorRate: noHits ? null : average }; } -async function getTransactionStats({ +function getTransactionStats({ setup, filter, minutes, @@ -124,95 +129,67 @@ async function getTransactionStats({ avgTransactionDuration: number | null; avgRequestsPerMinute: number | null; }> { - const { apmEventClient } = setup; - - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - ...filter, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - { - terms: { - [TRANSACTION_TYPE]: [ - TRANSACTION_REQUEST, - TRANSACTION_PAGE_LOAD, - ], + return withApmSpan('get_transaction_stats_for_service_map_node', async () => { + const { apmEventClient } = setup; + + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + ...filter, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + { + terms: { + [TRANSACTION_TYPE]: [ + TRANSACTION_REQUEST, + TRANSACTION_PAGE_LOAD, + ], + }, }, - }, - ], + ], + }, }, - }, - track_total_hits: true, - aggs: { - duration: { - avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), + track_total_hits: true, + aggs: { + duration: { + avg: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, }, }, }, - }, - }; - const response = await apmEventClient.search(params); + }; + const response = await apmEventClient.search(params); - const totalRequests = response.hits.total.value; + const totalRequests = response.hits.total.value; - return { - avgTransactionDuration: response.aggregations?.duration.value ?? null, - avgRequestsPerMinute: totalRequests > 0 ? totalRequests / minutes : null, - }; + return { + avgTransactionDuration: response.aggregations?.duration.value ?? null, + avgRequestsPerMinute: totalRequests > 0 ? totalRequests / minutes : null, + }; + }); } -async function getCpuStats({ +function getCpuStats({ setup, filter, }: TaskParameters): Promise<{ avgCpuUsage: number | null }> { - const { apmEventClient } = setup; - - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - size: 0, - query: { - bool: { - filter: [...filter, { exists: { field: METRIC_SYSTEM_CPU_PERCENT } }], - }, - }, - aggs: { avgCpuUsage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } } }, - }, - }); + return withApmSpan('get_avg_cpu_usage_for_service_map_node', async () => { + const { apmEventClient } = setup; - return { avgCpuUsage: response.aggregations?.avgCpuUsage.value ?? null }; -} - -async function getMemoryStats({ - setup, - filter, -}: TaskParameters): Promise<{ avgMemoryUsage: number | null }> { - const { apmEventClient } = setup; - - const getAvgMemoryUsage = async ({ - additionalFilters, - script, - }: { - additionalFilters: ESFilter[]; - script: typeof percentCgroupMemoryUsedScript; - }) => { const response = await apmEventClient.search({ apm: { events: [ProcessorEvent.metric], @@ -221,34 +198,72 @@ async function getMemoryStats({ size: 0, query: { bool: { - filter: [...filter, ...additionalFilters], + filter: [ + ...filter, + { exists: { field: METRIC_SYSTEM_CPU_PERCENT } }, + ], }, }, - aggs: { - avgMemoryUsage: { avg: { script } }, - }, + aggs: { avgCpuUsage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } } }, }, }); - return response.aggregations?.avgMemoryUsage.value ?? null; - }; - - let avgMemoryUsage = await getAvgMemoryUsage({ - additionalFilters: [ - { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, - ], - script: percentCgroupMemoryUsedScript, + return { avgCpuUsage: response.aggregations?.avgCpuUsage.value ?? null }; }); +} - if (!avgMemoryUsage) { - avgMemoryUsage = await getAvgMemoryUsage({ +function getMemoryStats({ + setup, + filter, +}: TaskParameters): Promise<{ avgMemoryUsage: number | null }> { + return withApmSpan('get_memory_stats_for_service_map_node', async () => { + const { apmEventClient } = setup; + + const getAvgMemoryUsage = ({ + additionalFilters, + script, + }: { + additionalFilters: ESFilter[]; + script: typeof percentCgroupMemoryUsedScript; + }) => { + return withApmSpan('get_avg_memory_for_service_map_node', async () => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter: [...filter, ...additionalFilters], + }, + }, + aggs: { + avgMemoryUsage: { avg: { script } }, + }, + }, + }); + return response.aggregations?.avgMemoryUsage.value ?? null; + }); + }; + + let avgMemoryUsage = await getAvgMemoryUsage({ additionalFilters: [ - { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, - { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, ], - script: percentSystemMemoryUsedScript, + script: percentCgroupMemoryUsedScript, }); - } - return { avgMemoryUsage }; + if (!avgMemoryUsage) { + avgMemoryUsage = await getAvgMemoryUsage({ + additionalFilters: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + script: percentSystemMemoryUsedScript, + }); + } + + return { avgMemoryUsage }; + }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts index aa9105d2edb30a..deb9104a839051 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -17,12 +17,13 @@ import { import { ProcessorEvent } from '../../../common/processor_event'; import { SERVICE_MAP_TIMEOUT_ERROR } from '../../../common/service_map'; import { rangeFilter } from '../../../common/utils/range_filter'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; const MAX_TRACES_TO_INSPECT = 1000; -export async function getTraceSampleIds({ +export function getTraceSampleIds({ serviceName, environment, setup, @@ -31,90 +32,92 @@ export async function getTraceSampleIds({ environment?: string; setup: Setup & SetupTimeRange; }) { - const { start, end, apmEventClient, config } = setup; + return withApmSpan('get_trace_sample_ids', async () => { + const { start, end, apmEventClient, config } = setup; - const rangeQuery = { range: rangeFilter(start, end) }; + const rangeQuery = { range: rangeFilter(start, end) }; - const query = { - bool: { - filter: [ - { - exists: { - field: SPAN_DESTINATION_SERVICE_RESOURCE, + const query = { + bool: { + filter: [ + { + exists: { + field: SPAN_DESTINATION_SERVICE_RESOURCE, + }, }, - }, - rangeQuery, - ] as ESFilter[], - }, - } as { bool: { filter: ESFilter[]; must_not?: ESFilter[] | ESFilter } }; + rangeQuery, + ] as ESFilter[], + }, + } as { bool: { filter: ESFilter[]; must_not?: ESFilter[] | ESFilter } }; - if (serviceName) { - query.bool.filter.push({ term: { [SERVICE_NAME]: serviceName } }); - } + if (serviceName) { + query.bool.filter.push({ term: { [SERVICE_NAME]: serviceName } }); + } - query.bool.filter.push(...getEnvironmentUiFilterES(environment)); + query.bool.filter.push(...getEnvironmentUiFilterES(environment)); - const fingerprintBucketSize = serviceName - ? config['xpack.apm.serviceMapFingerprintBucketSize'] - : config['xpack.apm.serviceMapFingerprintGlobalBucketSize']; + const fingerprintBucketSize = serviceName + ? config['xpack.apm.serviceMapFingerprintBucketSize'] + : config['xpack.apm.serviceMapFingerprintGlobalBucketSize']; - const traceIdBucketSize = serviceName - ? config['xpack.apm.serviceMapTraceIdBucketSize'] - : config['xpack.apm.serviceMapTraceIdGlobalBucketSize']; + const traceIdBucketSize = serviceName + ? config['xpack.apm.serviceMapTraceIdBucketSize'] + : config['xpack.apm.serviceMapTraceIdGlobalBucketSize']; - const samplerShardSize = traceIdBucketSize * 10; + const samplerShardSize = traceIdBucketSize * 10; - const params = { - apm: { - events: [ProcessorEvent.span], - }, - body: { - size: 0, - query, - aggs: { - connections: { - composite: { - sources: [ - { - [SPAN_DESTINATION_SERVICE_RESOURCE]: { - terms: { - field: SPAN_DESTINATION_SERVICE_RESOURCE, + const params = { + apm: { + events: [ProcessorEvent.span], + }, + body: { + size: 0, + query, + aggs: { + connections: { + composite: { + sources: [ + { + [SPAN_DESTINATION_SERVICE_RESOURCE]: { + terms: { + field: SPAN_DESTINATION_SERVICE_RESOURCE, + }, }, }, - }, - { - [SERVICE_NAME]: { - terms: { - field: SERVICE_NAME, + { + [SERVICE_NAME]: { + terms: { + field: SERVICE_NAME, + }, }, }, - }, - { - [SERVICE_ENVIRONMENT]: { - terms: { - field: SERVICE_ENVIRONMENT, - missing_bucket: true, + { + [SERVICE_ENVIRONMENT]: { + terms: { + field: SERVICE_ENVIRONMENT, + missing_bucket: true, + }, }, }, - }, - ], - size: fingerprintBucketSize, - }, - aggs: { - sample: { - sampler: { - shard_size: samplerShardSize, - }, - aggs: { - trace_ids: { - terms: { - field: TRACE_ID, - size: traceIdBucketSize, - execution_hint: 'map' as const, - // remove bias towards large traces by sorting on trace.id - // which will be random-esque - order: { - _key: 'desc' as const, + ], + size: fingerprintBucketSize, + }, + aggs: { + sample: { + sampler: { + shard_size: samplerShardSize, + }, + aggs: { + trace_ids: { + terms: { + field: TRACE_ID, + size: traceIdBucketSize, + execution_hint: 'map' as const, + // remove bias towards large traces by sorting on trace.id + // which will be random-esque + order: { + _key: 'desc' as const, + }, }, }, }, @@ -123,33 +126,34 @@ export async function getTraceSampleIds({ }, }, }, - }, - }; + }; - try { - const tracesSampleResponse = await apmEventClient.search(params); - // make sure at least one trace per composite/connection bucket - // is queried - const traceIdsWithPriority = - tracesSampleResponse.aggregations?.connections.buckets.flatMap((bucket) => - bucket.sample.trace_ids.buckets.map((sampleDocBucket, index) => ({ - traceId: sampleDocBucket.key as string, - priority: index, - })) - ) || []; + try { + const tracesSampleResponse = await apmEventClient.search(params); + // make sure at least one trace per composite/connection bucket + // is queried + const traceIdsWithPriority = + tracesSampleResponse.aggregations?.connections.buckets.flatMap( + (bucket) => + bucket.sample.trace_ids.buckets.map((sampleDocBucket, index) => ({ + traceId: sampleDocBucket.key as string, + priority: index, + })) + ) || []; - const traceIds = take( - uniq( - sortBy(traceIdsWithPriority, 'priority').map(({ traceId }) => traceId) - ), - MAX_TRACES_TO_INSPECT - ); + const traceIds = take( + uniq( + sortBy(traceIdsWithPriority, 'priority').map(({ traceId }) => traceId) + ), + MAX_TRACES_TO_INSPECT + ); - return { traceIds }; - } catch (error) { - if ('displayName' in error && error.displayName === 'RequestTimeout') { - throw Boom.internal(SERVICE_MAP_TIMEOUT_ERROR); + return { traceIds }; + } catch (error) { + if ('displayName' in error && error.displayName === 'RequestTimeout') { + throw Boom.internal(SERVICE_MAP_TIMEOUT_ERROR); + } + throw error; } - throw error; - } + }); } diff --git a/x-pack/plugins/apm/server/lib/service_nodes/index.ts b/x-pack/plugins/apm/server/lib/service_nodes/index.ts index 01a9f3fdac3ec8..a22c732a5e8ce3 100644 --- a/x-pack/plugins/apm/server/lib/service_nodes/index.ts +++ b/x-pack/plugins/apm/server/lib/service_nodes/index.ts @@ -14,76 +14,79 @@ import { import { SERVICE_NODE_NAME_MISSING } from '../../../common/service_nodes'; import { getServiceNodesProjection } from '../../projections/service_nodes'; import { mergeProjection } from '../../projections/util/merge_projection'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; -const getServiceNodes = async ({ +const getServiceNodes = ({ setup, serviceName, }: { setup: Setup & SetupTimeRange; serviceName: string; }) => { - const { apmEventClient } = setup; + return withApmSpan('get_service_nodes', async () => { + const { apmEventClient } = setup; - const projection = getServiceNodesProjection({ setup, serviceName }); + const projection = getServiceNodesProjection({ setup, serviceName }); - const params = mergeProjection(projection, { - body: { - aggs: { - nodes: { - terms: { - ...projection.body.aggs.nodes.terms, - size: 10000, - missing: SERVICE_NODE_NAME_MISSING, - }, - aggs: { - cpu: { - avg: { - field: METRIC_PROCESS_CPU_PERCENT, - }, + const params = mergeProjection(projection, { + body: { + aggs: { + nodes: { + terms: { + ...projection.body.aggs.nodes.terms, + size: 10000, + missing: SERVICE_NODE_NAME_MISSING, }, - heapMemory: { - avg: { - field: METRIC_JAVA_HEAP_MEMORY_USED, + aggs: { + cpu: { + avg: { + field: METRIC_PROCESS_CPU_PERCENT, + }, }, - }, - nonHeapMemory: { - avg: { - field: METRIC_JAVA_NON_HEAP_MEMORY_USED, + heapMemory: { + avg: { + field: METRIC_JAVA_HEAP_MEMORY_USED, + }, }, - }, - threadCount: { - max: { - field: METRIC_JAVA_THREAD_COUNT, + nonHeapMemory: { + avg: { + field: METRIC_JAVA_NON_HEAP_MEMORY_USED, + }, + }, + threadCount: { + max: { + field: METRIC_JAVA_THREAD_COUNT, + }, }, }, }, }, }, - }, - }); + }); - const response = await apmEventClient.search(params); + const response = await apmEventClient.search(params); - if (!response.aggregations) { - return []; - } + if (!response.aggregations) { + return []; + } - return response.aggregations.nodes.buckets - .map((bucket) => ({ - name: bucket.key as string, - cpu: bucket.cpu.value, - heapMemory: bucket.heapMemory.value, - nonHeapMemory: bucket.nonHeapMemory.value, - threadCount: bucket.threadCount.value, - })) - .filter( - (item) => - item.cpu !== null || - item.heapMemory !== null || - item.nonHeapMemory !== null || - item.threadCount != null - ); + return response.aggregations.nodes.buckets + .map((bucket) => ({ + name: bucket.key as string, + cpu: bucket.cpu.value, + heapMemory: bucket.heapMemory.value, + nonHeapMemory: bucket.nonHeapMemory.value, + threadCount: bucket.threadCount.value, + })) + .filter( + (item) => + item.cpu !== null || + item.heapMemory !== null || + item.nonHeapMemory !== null || + item.threadCount != null + ); + }); }; export { getServiceNodes }; diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts index d701c5380b2e45..7c746aac29af92 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts @@ -13,6 +13,7 @@ import { SERVICE_VERSION, } from '../../../../common/elasticsearch_fieldnames'; import { rangeFilter } from '../../../../common/utils/range_filter'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, @@ -31,93 +32,97 @@ export async function getDerivedServiceAnnotations({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_derived_service_annotations', async () => { + const { start, end, apmEventClient } = setup; - const filter: ESFilter[] = [ - { term: { [SERVICE_NAME]: serviceName } }, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ...getEnvironmentUiFilterES(environment), - ]; + const filter: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...getEnvironmentUiFilterES(environment), + ]; - const versions = - ( - await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [...filter, { range: rangeFilter(start, end) }], - }, + const versions = + ( + await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - versions: { - terms: { - field: SERVICE_VERSION, + body: { + size: 0, + query: { + bool: { + filter: [...filter, { range: rangeFilter(start, end) }], + }, + }, + aggs: { + versions: { + terms: { + field: SERVICE_VERSION, + }, }, }, }, - }, - }) - ).aggregations?.versions.buckets.map((bucket) => bucket.key) ?? []; + }) + ).aggregations?.versions.buckets.map((bucket) => bucket.key) ?? []; - if (versions.length <= 1) { - return []; - } - const annotations = await Promise.all( - versions.map(async (version) => { - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [...filter, { term: { [SERVICE_VERSION]: version } }], + if (versions.length <= 1) { + return []; + } + const annotations = await Promise.all( + versions.map(async (version) => { + return withApmSpan('get_first_seen_of_version', async () => { + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - }, - aggs: { - first_seen: { - min: { - field: '@timestamp', + body: { + size: 0, + query: { + bool: { + filter: [...filter, { term: { [SERVICE_VERSION]: version } }], + }, + }, + aggs: { + first_seen: { + min: { + field: '@timestamp', + }, + }, }, }, - }, - }, - }); + }); - const firstSeen = response.aggregations?.first_seen.value; + const firstSeen = response.aggregations?.first_seen.value; - if (!isNumber(firstSeen)) { - throw new Error( - 'First seen for version was unexpectedly undefined or null.' - ); - } + if (!isNumber(firstSeen)) { + throw new Error( + 'First seen for version was unexpectedly undefined or null.' + ); + } - if (firstSeen < start || firstSeen > end) { - return null; - } + if (firstSeen < start || firstSeen > end) { + return null; + } - return { - type: AnnotationType.VERSION, - id: version, - '@timestamp': firstSeen, - text: version, - }; - }) - ); - return annotations.filter(Boolean) as Annotation[]; + return { + type: AnnotationType.VERSION, + id: version, + '@timestamp': firstSeen, + text: version, + }; + }); + }) + ); + return annotations.filter(Boolean) as Annotation[]; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts index fafd0ce3d3cdcb..20629a4b4f5538 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts @@ -15,8 +15,9 @@ import { Annotation, AnnotationType } from '../../../../common/annotations'; import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function getStoredAnnotations({ +export function getStoredAnnotations({ setup, serviceName, environment, @@ -31,56 +32,58 @@ export async function getStoredAnnotations({ annotationsClient: ScopedAnnotationsClient; logger: Logger; }): Promise { - const body = { - size: 50, - query: { - bool: { - filter: [ - { - range: rangeFilter(setup.start, setup.end), - }, - { term: { 'annotation.type': 'deployment' } }, - { term: { tags: 'apm' } }, - { term: { [SERVICE_NAME]: serviceName } }, - ...getEnvironmentUiFilterES(environment), - ], + return withApmSpan('get_stored_annotations', async () => { + const body = { + size: 50, + query: { + bool: { + filter: [ + { + range: rangeFilter(setup.start, setup.end), + }, + { term: { 'annotation.type': 'deployment' } }, + { term: { tags: 'apm' } }, + { term: { [SERVICE_NAME]: serviceName } }, + ...getEnvironmentUiFilterES(environment), + ], + }, }, - }, - }; + }; - try { - const response: ESSearchResponse< - ESAnnotation, - { body: typeof body } - > = await unwrapEsResponse( - client.search({ - index: annotationsClient.index, - body, - }) - ); + try { + const response: ESSearchResponse< + ESAnnotation, + { body: typeof body } + > = await unwrapEsResponse( + client.search({ + index: annotationsClient.index, + body, + }) + ); - return response.hits.hits.map((hit) => { - return { - type: AnnotationType.VERSION, - id: hit._id, - '@timestamp': new Date(hit._source['@timestamp']).getTime(), - text: hit._source.message, - }; - }); - } catch (error) { - // index is only created when an annotation has been indexed, - // so we should handle this error gracefully - if (error.body?.error?.type === 'index_not_found_exception') { - return []; - } + return response.hits.hits.map((hit) => { + return { + type: AnnotationType.VERSION, + id: hit._id, + '@timestamp': new Date(hit._source['@timestamp']).getTime(), + text: hit._source.message, + }; + }); + } catch (error) { + // index is only created when an annotation has been indexed, + // so we should handle this error gracefully + if (error.body?.error?.type === 'index_not_found_exception') { + return []; + } - if (error.body?.error?.type === 'security_exception') { - logger.warn( - `Unable to get stored annotations due to a security exception. Please make sure that the user has 'indices:data/read/search' permissions for ${annotationsClient.index}` - ); - return []; - } + if (error.body?.error?.type === 'security_exception') { + logger.warn( + `Unable to get stored annotations due to a security exception. Please make sure that the user has 'indices:data/read/search' permissions for ${annotationsClient.index}` + ); + return []; + } - throw error; - } + throw error; + } + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts b/x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts index c2399cd4542e77..29c77da6e4075f 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts @@ -13,8 +13,9 @@ import { import { rangeFilter } from '../../../common/utils/range_filter'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getServiceAgentName({ +export function getServiceAgentName({ serviceName, setup, searchAggregatedTransactions, @@ -23,38 +24,42 @@ export async function getServiceAgentName({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_service_agent_name', async () => { + const { start, end, apmEventClient } = setup; - const params = { - terminateAfter: 1, - apm: { - events: [ - ProcessorEvent.error, - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.metric, - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ], - }, + const params = { + terminateAfter: 1, + apm: { + events: [ + ProcessorEvent.error, + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.metric, + ], }, - aggs: { - agents: { - terms: { field: AGENT_NAME, size: 1 }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { range: rangeFilter(start, end) }, + ], + }, + }, + aggs: { + agents: { + terms: { field: AGENT_NAME, size: 1 }, + }, }, }, - }, - }; + }; - const { aggregations } = await apmEventClient.search(params); - const agentName = aggregations?.agents.buckets[0]?.key as string | undefined; - return { agentName }; + const { aggregations } = await apmEventClient.search(params); + const agentName = aggregations?.agents.buckets[0]?.key as + | string + | undefined; + return { agentName }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts index f7a781fb7c075d..ef2b50cbdbedfe 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts @@ -24,8 +24,9 @@ import { ProcessorEvent } from '../../../../common/processor_event'; import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { joinByKey } from '../../../../common/utils/join_by_key'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export const getDestinationMap = async ({ +export const getDestinationMap = ({ setup, serviceName, environment, @@ -34,171 +35,183 @@ export const getDestinationMap = async ({ serviceName: string; environment: string; }) => { - const { start, end, apmEventClient } = setup; - - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.span], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { exists: { field: SPAN_DESTINATION_SERVICE_RESOURCE } }, - { range: rangeFilter(start, end) }, - ...getEnvironmentUiFilterES(environment), - ], + return withApmSpan('get_service_destination_map', async () => { + const { start, end, apmEventClient } = setup; + + const response = await withApmSpan('get_exit_span_samples', async () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.span], }, - }, - aggs: { - connections: { - composite: { - size: 1000, - sources: [ - { - [SPAN_DESTINATION_SERVICE_RESOURCE]: { - terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE }, - }, - }, - // make sure we get samples for both successful - // and failed calls - { [EVENT_OUTCOME]: { terms: { field: EVENT_OUTCOME } } }, - ], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { exists: { field: SPAN_DESTINATION_SERVICE_RESOURCE } }, + { range: rangeFilter(start, end) }, + ...getEnvironmentUiFilterES(environment), + ], + }, }, aggs: { - docs: { - top_hits: { - docvalue_fields: [SPAN_TYPE, SPAN_SUBTYPE, SPAN_ID] as const, - _source: false, - sort: { - '@timestamp': 'desc', + connections: { + composite: { + size: 1000, + sources: [ + { + [SPAN_DESTINATION_SERVICE_RESOURCE]: { + terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE }, + }, + }, + // make sure we get samples for both successful + // and failed calls + { [EVENT_OUTCOME]: { terms: { field: EVENT_OUTCOME } } }, + ], + }, + aggs: { + docs: { + top_hits: { + docvalue_fields: [ + SPAN_TYPE, + SPAN_SUBTYPE, + SPAN_ID, + ] as const, + _source: false, + sort: { + '@timestamp': 'desc', + }, + }, }, }, }, }, }, + }) + ); + + const outgoingConnections = + response.aggregations?.connections.buckets.map((bucket) => { + const doc = bucket.docs.hits.hits[0]; + + return { + [SPAN_DESTINATION_SERVICE_RESOURCE]: String( + bucket.key[SPAN_DESTINATION_SERVICE_RESOURCE] + ), + [SPAN_ID]: String(doc.fields[SPAN_ID]?.[0]), + [SPAN_TYPE]: String(doc.fields[SPAN_TYPE]?.[0] ?? ''), + [SPAN_SUBTYPE]: String(doc.fields[SPAN_SUBTYPE]?.[0] ?? ''), + }; + }) ?? []; + + const transactionResponse = await withApmSpan( + 'get_transactions_for_exit_spans', + () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + query: { + bool: { + filter: [ + { + terms: { + [PARENT_ID]: outgoingConnections.map( + (connection) => connection[SPAN_ID] + ), + }, + }, + { range: rangeFilter(start, end) }, + ], + }, + }, + size: outgoingConnections.length, + docvalue_fields: [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + AGENT_NAME, + PARENT_ID, + ] as const, + _source: false, + }, + }) + ); + + const incomingConnections = transactionResponse.hits.hits.map((hit) => ({ + [SPAN_ID]: String(hit.fields[PARENT_ID]![0]), + service: { + name: String(hit.fields[SERVICE_NAME]![0]), + environment: String(hit.fields[SERVICE_ENVIRONMENT]?.[0] ?? ''), + agentName: hit.fields[AGENT_NAME]![0] as AgentName, }, - }, - }); - - const outgoingConnections = - response.aggregations?.connections.buckets.map((bucket) => { - const doc = bucket.docs.hits.hits[0]; + })); + + // merge outgoing spans with transactions by span.id/parent.id + const joinedBySpanId = joinByKey( + [...outgoingConnections, ...incomingConnections], + SPAN_ID + ); + + // we could have multiple connections per address because + // of multiple event outcomes + const dedupedConnectionsByAddress = joinByKey( + joinedBySpanId, + SPAN_DESTINATION_SERVICE_RESOURCE + ); + + // identify a connection by either service.name, service.environment, agent.name + // OR span.destination.service.resource + + const connectionsWithId = dedupedConnectionsByAddress.map((connection) => { + const id = + 'service' in connection + ? { service: connection.service } + : pickKeys(connection, SPAN_DESTINATION_SERVICE_RESOURCE); return { - [SPAN_DESTINATION_SERVICE_RESOURCE]: String( - bucket.key[SPAN_DESTINATION_SERVICE_RESOURCE] - ), - [SPAN_ID]: String(doc.fields[SPAN_ID]?.[0]), - [SPAN_TYPE]: String(doc.fields[SPAN_TYPE]?.[0] ?? ''), - [SPAN_SUBTYPE]: String(doc.fields[SPAN_SUBTYPE]?.[0] ?? ''), + ...connection, + id, }; - }) ?? []; - - const transactionResponse = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - query: { - bool: { - filter: [ - { - terms: { - [PARENT_ID]: outgoingConnections.map( - (connection) => connection[SPAN_ID] - ), - }, - }, - { range: rangeFilter(start, end) }, - ], - }, - }, - size: outgoingConnections.length, - docvalue_fields: [ - SERVICE_NAME, - SERVICE_ENVIRONMENT, - AGENT_NAME, - PARENT_ID, - ] as const, - _source: false, - }, - }); + }); - const incomingConnections = transactionResponse.hits.hits.map((hit) => ({ - [SPAN_ID]: String(hit.fields[PARENT_ID]![0]), - service: { - name: String(hit.fields[SERVICE_NAME]![0]), - environment: String(hit.fields[SERVICE_ENVIRONMENT]?.[0] ?? ''), - agentName: hit.fields[AGENT_NAME]![0] as AgentName, - }, - })); - - // merge outgoing spans with transactions by span.id/parent.id - const joinedBySpanId = joinByKey( - [...outgoingConnections, ...incomingConnections], - SPAN_ID - ); - - // we could have multiple connections per address because - // of multiple event outcomes - const dedupedConnectionsByAddress = joinByKey( - joinedBySpanId, - SPAN_DESTINATION_SERVICE_RESOURCE - ); - - // identify a connection by either service.name, service.environment, agent.name - // OR span.destination.service.resource - - const connectionsWithId = dedupedConnectionsByAddress.map((connection) => { - const id = - 'service' in connection - ? { service: connection.service } - : pickKeys(connection, SPAN_DESTINATION_SERVICE_RESOURCE); - - return { - ...connection, - id, - }; - }); + const dedupedConnectionsById = joinByKey(connectionsWithId, 'id'); - const dedupedConnectionsById = joinByKey(connectionsWithId, 'id'); - - const connectionsByAddress = keyBy( - connectionsWithId, - SPAN_DESTINATION_SERVICE_RESOURCE - ); - - // per span.destination.service.resource, return merged/deduped item - return mapValues(connectionsByAddress, ({ id }) => { - const connection = dedupedConnectionsById.find((dedupedConnection) => - isEqual(id, dedupedConnection.id) - )!; - - return { - id, - span: { - type: connection[SPAN_TYPE], - subtype: connection[SPAN_SUBTYPE], - destination: { - service: { - resource: connection[SPAN_DESTINATION_SERVICE_RESOURCE], - }, - }, - }, - ...('service' in connection && connection.service - ? { + const connectionsByAddress = keyBy( + connectionsWithId, + SPAN_DESTINATION_SERVICE_RESOURCE + ); + + // per span.destination.service.resource, return merged/deduped item + return mapValues(connectionsByAddress, ({ id }) => { + const connection = dedupedConnectionsById.find((dedupedConnection) => + isEqual(id, dedupedConnection.id) + )!; + + return { + id, + span: { + type: connection[SPAN_TYPE], + subtype: connection[SPAN_SUBTYPE], + destination: { service: { - name: connection.service.name, - environment: connection.service.environment, - }, - agent: { - name: connection.service.agentName, + resource: connection[SPAN_DESTINATION_SERVICE_RESOURCE], }, - } - : {}), - }; + }, + }, + ...('service' in connection && connection.service + ? { + service: { + name: connection.service.name, + environment: connection.service.environment, + }, + agent: { + name: connection.service.agentName, + }, + } + : {}), + }; + }); }); }; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts index 5500b9c30b20e7..9a020daa7e095d 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts @@ -19,8 +19,9 @@ import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_e import { getBucketSize } from '../../helpers/get_bucket_size'; import { EventOutcome } from '../../../../common/event_outcome'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export const getMetrics = async ({ +export const getMetrics = ({ setup, serviceName, environment, @@ -31,61 +32,65 @@ export const getMetrics = async ({ environment: string; numBuckets: number; }) => { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_service_destination_metrics', async () => { + const { start, end, apmEventClient } = setup; - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - track_total_hits: true, - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { exists: { field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT } }, - { range: rangeFilter(start, end) }, - ...getEnvironmentUiFilterES(environment), - ], - }, + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], }, - aggs: { - connections: { - terms: { - field: SPAN_DESTINATION_SERVICE_RESOURCE, - size: 100, - }, - aggs: { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: getBucketSize({ start, end, numBuckets }) - .intervalString, - extended_bounds: { - min: start, - max: end, - }, + body: { + track_total_hits: true, + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { + exists: { field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT }, }, - aggs: { - latency_sum: { - sum: { - field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, + { range: rangeFilter(start, end) }, + ...getEnvironmentUiFilterES(environment), + ], + }, + }, + aggs: { + connections: { + terms: { + field: SPAN_DESTINATION_SERVICE_RESOURCE, + size: 100, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize({ start, end, numBuckets }) + .intervalString, + extended_bounds: { + min: start, + max: end, }, }, - count: { - sum: { - field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + aggs: { + latency_sum: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, + }, }, - }, - [EVENT_OUTCOME]: { - terms: { - field: EVENT_OUTCOME, + count: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + }, }, - aggs: { - count: { - sum: { - field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + [EVENT_OUTCOME]: { + terms: { + field: EVENT_OUTCOME, + }, + aggs: { + count: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + }, }, }, }, @@ -95,47 +100,47 @@ export const getMetrics = async ({ }, }, }, - }, - }); + }); - return ( - response.aggregations?.connections.buckets.map((bucket) => ({ - span: { - destination: { - service: { - resource: String(bucket.key), + return ( + response.aggregations?.connections.buckets.map((bucket) => ({ + span: { + destination: { + service: { + resource: String(bucket.key), + }, }, }, - }, - value: { - count: sum( - bucket.timeseries.buckets.map( - (dateBucket) => dateBucket.count.value ?? 0 - ) - ), - latency_sum: sum( - bucket.timeseries.buckets.map( - (dateBucket) => dateBucket.latency_sum.value ?? 0 - ) - ), - error_count: sum( - bucket.timeseries.buckets.flatMap( - (dateBucket) => - dateBucket[EVENT_OUTCOME].buckets.find( - (outcomeBucket) => outcomeBucket.key === EventOutcome.failure - )?.count.value ?? 0 - ) - ), - }, - timeseries: bucket.timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - count: dateBucket.count.value ?? 0, - latency_sum: dateBucket.latency_sum.value ?? 0, - error_count: - dateBucket[EVENT_OUTCOME].buckets.find( - (outcomeBucket) => outcomeBucket.key === EventOutcome.failure - )?.count.value ?? 0, - })), - })) ?? [] - ); + value: { + count: sum( + bucket.timeseries.buckets.map( + (dateBucket) => dateBucket.count.value ?? 0 + ) + ), + latency_sum: sum( + bucket.timeseries.buckets.map( + (dateBucket) => dateBucket.latency_sum.value ?? 0 + ) + ), + error_count: sum( + bucket.timeseries.buckets.flatMap( + (dateBucket) => + dateBucket[EVENT_OUTCOME].buckets.find( + (outcomeBucket) => outcomeBucket.key === EventOutcome.failure + )?.count.value ?? 0 + ) + ), + }, + timeseries: bucket.timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + count: dateBucket.count.value ?? 0, + latency_sum: dateBucket.latency_sum.value ?? 0, + error_count: + dateBucket[EVENT_OUTCOME].buckets.find( + (outcomeBucket) => outcomeBucket.key === EventOutcome.failure + )?.count.value ?? 0, + })), + })) ?? [] + ); + }); }; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts index 5ccb6fb19cbdad..19f306f5cb8035 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts @@ -16,6 +16,7 @@ import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getMetrics } from './get_metrics'; import { getDestinationMap } from './get_destination_map'; import { calculateThroughput } from '../../helpers/calculate_throughput'; +import { withApmSpan } from '../../../utils/with_apm_span'; export type ServiceDependencyItem = { name: string; @@ -42,7 +43,7 @@ export type ServiceDependencyItem = { | { type: 'external'; spanType?: string; spanSubtype?: string } ); -export async function getServiceDependencies({ +export function getServiceDependencies({ setup, serviceName, environment, @@ -53,171 +54,177 @@ export async function getServiceDependencies({ environment: string; numBuckets: number; }): Promise { - const { start, end } = setup; - const [allMetrics, destinationMap] = await Promise.all([ - getMetrics({ - setup, - serviceName, - environment, - numBuckets, - }), - getDestinationMap({ - setup, - serviceName, - environment, - }), - ]); - - const metricsWithDestinationIds = allMetrics.map((metricItem) => { - const spanDestination = metricItem.span.destination.service.resource; - - const destination = maybe(destinationMap[spanDestination]); - const id = destination?.id || { - [SPAN_DESTINATION_SERVICE_RESOURCE]: spanDestination, - }; - - return merge( - { - id, - metrics: [metricItem], - span: { - destination: { - service: { - resource: spanDestination, + return withApmSpan('get_service_dependencies', async () => { + const { start, end } = setup; + const [allMetrics, destinationMap] = await Promise.all([ + getMetrics({ + setup, + serviceName, + environment, + numBuckets, + }), + getDestinationMap({ + setup, + serviceName, + environment, + }), + ]); + + const metricsWithDestinationIds = allMetrics.map((metricItem) => { + const spanDestination = metricItem.span.destination.service.resource; + + const destination = maybe(destinationMap[spanDestination]); + const id = destination?.id || { + [SPAN_DESTINATION_SERVICE_RESOURCE]: spanDestination, + }; + + return merge( + { + id, + metrics: [metricItem], + span: { + destination: { + service: { + resource: spanDestination, + }, }, }, }, - }, - destination + destination + ); + }, []); + + const metricsJoinedByDestinationId = joinByKey( + metricsWithDestinationIds, + 'id', + (a, b) => { + const { metrics: metricsA, ...itemA } = a; + const { metrics: metricsB, ...itemB } = b; + + return merge({}, itemA, itemB, { metrics: metricsA.concat(metricsB) }); + } ); - }, []); - const metricsJoinedByDestinationId = joinByKey( - metricsWithDestinationIds, - 'id', - (a, b) => { - const { metrics: metricsA, ...itemA } = a; - const { metrics: metricsB, ...itemB } = b; + const metricsByResolvedAddress = metricsJoinedByDestinationId.map( + (item) => { + const mergedMetrics = item.metrics.reduce< + Omit, 'span'> + >( + (prev, current) => { + return { + value: { + count: prev.value.count + current.value.count, + latency_sum: prev.value.latency_sum + current.value.latency_sum, + error_count: prev.value.error_count + current.value.error_count, + }, + timeseries: joinByKey( + [...prev.timeseries, ...current.timeseries], + 'x', + (a, b) => ({ + x: a.x, + count: a.count + b.count, + latency_sum: a.latency_sum + b.latency_sum, + error_count: a.error_count + b.error_count, + }) + ), + }; + }, + { + value: { + count: 0, + latency_sum: 0, + error_count: 0, + }, + timeseries: [], + } + ); + + const destMetrics = { + latency: { + value: + mergedMetrics.value.count > 0 + ? mergedMetrics.value.latency_sum / mergedMetrics.value.count + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: point.count > 0 ? point.latency_sum / point.count : null, + })), + }, + throughput: { + value: + mergedMetrics.value.count > 0 + ? calculateThroughput({ + start, + end, + value: mergedMetrics.value.count, + }) + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: + point.count > 0 + ? calculateThroughput({ start, end, value: point.count }) + : null, + })), + }, + errorRate: { + value: + mergedMetrics.value.count > 0 + ? (mergedMetrics.value.error_count ?? 0) / + mergedMetrics.value.count + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: + point.count > 0 ? (point.error_count ?? 0) / point.count : null, + })), + }, + }; - return merge({}, itemA, itemB, { metrics: metricsA.concat(metricsB) }); - } - ); + if (item.service) { + return { + name: item.service.name, + type: 'service' as const, + serviceName: item.service.name, + environment: item.service.environment, + // agent.name should always be there, type returned from joinByKey is too pessimistic + agentName: item.agent!.name, + ...destMetrics, + }; + } - const metricsByResolvedAddress = metricsJoinedByDestinationId.map((item) => { - const mergedMetrics = item.metrics.reduce< - Omit, 'span'> - >( - (prev, current) => { return { - value: { - count: prev.value.count + current.value.count, - latency_sum: prev.value.latency_sum + current.value.latency_sum, - error_count: prev.value.error_count + current.value.error_count, - }, - timeseries: joinByKey( - [...prev.timeseries, ...current.timeseries], - 'x', - (a, b) => ({ - x: a.x, - count: a.count + b.count, - latency_sum: a.latency_sum + b.latency_sum, - error_count: a.error_count + b.error_count, - }) - ), + name: item.span.destination.service.resource, + type: 'external' as const, + spanType: item.span.type, + spanSubtype: item.span.subtype, + ...destMetrics, }; - }, - { - value: { - count: 0, - latency_sum: 0, - error_count: 0, - }, - timeseries: [], } ); - const destMetrics = { - latency: { - value: - mergedMetrics.value.count > 0 - ? mergedMetrics.value.latency_sum / mergedMetrics.value.count - : null, - timeseries: mergedMetrics.timeseries.map((point) => ({ - x: point.x, - y: point.count > 0 ? point.latency_sum / point.count : null, - })), - }, - throughput: { - value: - mergedMetrics.value.count > 0 - ? calculateThroughput({ - start, - end, - value: mergedMetrics.value.count, - }) - : null, - timeseries: mergedMetrics.timeseries.map((point) => ({ - x: point.x, - y: - point.count > 0 - ? calculateThroughput({ start, end, value: point.count }) - : null, - })), - }, - errorRate: { - value: - mergedMetrics.value.count > 0 - ? (mergedMetrics.value.error_count ?? 0) / mergedMetrics.value.count - : null, - timeseries: mergedMetrics.timeseries.map((point) => ({ - x: point.x, - y: point.count > 0 ? (point.error_count ?? 0) / point.count : null, - })), - }, - }; - - if (item.service) { - return { - name: item.service.name, - type: 'service' as const, - serviceName: item.service.name, - environment: item.service.environment, - // agent.name should always be there, type returned from joinByKey is too pessimistic - agentName: item.agent!.name, - ...destMetrics, - }; - } + const latencySums = metricsByResolvedAddress + .map( + (metric) => (metric.latency.value ?? 0) * (metric.throughput.value ?? 0) + ) + .filter(isFiniteNumber); - return { - name: item.span.destination.service.resource, - type: 'external' as const, - spanType: item.span.type, - spanSubtype: item.span.subtype, - ...destMetrics, - }; - }); + const minLatencySum = Math.min(...latencySums); + const maxLatencySum = Math.max(...latencySums); + + return metricsByResolvedAddress.map((metric) => { + const impact = + isFiniteNumber(metric.latency.value) && + isFiniteNumber(metric.throughput.value) + ? ((metric.latency.value * metric.throughput.value - minLatencySum) / + (maxLatencySum - minLatencySum)) * + 100 + : 0; - const latencySums = metricsByResolvedAddress - .map( - (metric) => (metric.latency.value ?? 0) * (metric.throughput.value ?? 0) - ) - .filter(isFiniteNumber); - - const minLatencySum = Math.min(...latencySums); - const maxLatencySum = Math.max(...latencySums); - - return metricsByResolvedAddress.map((metric) => { - const impact = - isFiniteNumber(metric.latency.value) && - isFiniteNumber(metric.throughput.value) - ? ((metric.latency.value * metric.throughput.value - minLatencySum) / - (maxLatencySum - minLatencySum)) * - 100 - : 0; - - return { - ...metric, - impact, - }; + return { + ...metric, + impact, + }; + }); }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts index f27125874f2b48..f5aa01e1dfa58a 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts @@ -21,6 +21,7 @@ import { import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { getErrorName } from '../../helpers/get_error_name'; +import { withApmSpan } from '../../../utils/with_apm_span'; export type ServiceErrorGroupItem = ValuesType< PromiseReturnType @@ -45,139 +46,152 @@ export async function getServiceErrorGroups({ sortField: 'name' | 'last_seen' | 'occurrences'; transactionType: string; }) { - const { apmEventClient, start, end, esFilter } = setup; + return withApmSpan('get_service_error_groups', async () => { + const { apmEventClient, start, end, esFilter } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets }); + const { intervalString } = getBucketSize({ start, end, numBuckets }); - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.error], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ], + const response = await withApmSpan('get_top_service_error_groups', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.error], }, - }, - aggs: { - error_groups: { - terms: { - field: ERROR_GROUP_ID, - size: 500, - order: { - _count: 'desc', + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { range: rangeFilter(start, end) }, + ...esFilter, + ], }, }, aggs: { - sample: { - top_hits: { - size: 1, - _source: [ERROR_LOG_MESSAGE, ERROR_EXC_MESSAGE, '@timestamp'], - sort: { - '@timestamp': 'desc', + error_groups: { + terms: { + field: ERROR_GROUP_ID, + size: 500, + order: { + _count: 'desc', + }, + }, + aggs: { + sample: { + top_hits: { + size: 1, + _source: [ + ERROR_LOG_MESSAGE, + ERROR_EXC_MESSAGE, + '@timestamp', + ], + sort: { + '@timestamp': 'desc', + }, + }, }, }, }, }, }, - }, - }, - }); + }) + ); - const errorGroups = - response.aggregations?.error_groups.buckets.map((bucket) => ({ - group_id: bucket.key as string, - name: - getErrorName(bucket.sample.hits.hits[0]._source) ?? NOT_AVAILABLE_LABEL, - last_seen: new Date( - bucket.sample.hits.hits[0]?._source['@timestamp'] - ).getTime(), - occurrences: { - value: bucket.doc_count, - }, - })) ?? []; + const errorGroups = + response.aggregations?.error_groups.buckets.map((bucket) => ({ + group_id: bucket.key as string, + name: + getErrorName(bucket.sample.hits.hits[0]._source) ?? + NOT_AVAILABLE_LABEL, + last_seen: new Date( + bucket.sample.hits.hits[0]?._source['@timestamp'] + ).getTime(), + occurrences: { + value: bucket.doc_count, + }, + })) ?? []; - // Sort error groups first, and only get timeseries for data in view. - // This is to limit the possibility of creating too many buckets. + // Sort error groups first, and only get timeseries for data in view. + // This is to limit the possibility of creating too many buckets. - const sortedAndSlicedErrorGroups = orderBy( - errorGroups, - (group) => { - if (sortField === 'occurrences') { - return group.occurrences.value; - } - return group[sortField]; - }, - [sortDirection] - ).slice(pageIndex * size, pageIndex * size + size); + const sortedAndSlicedErrorGroups = orderBy( + errorGroups, + (group) => { + if (sortField === 'occurrences') { + return group.occurrences.value; + } + return group[sortField]; + }, + [sortDirection] + ).slice(pageIndex * size, pageIndex * size + size); - const sortedErrorGroupIds = sortedAndSlicedErrorGroups.map( - (group) => group.group_id - ); + const sortedErrorGroupIds = sortedAndSlicedErrorGroups.map( + (group) => group.group_id + ); - const timeseriesResponse = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.error], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { terms: { [ERROR_GROUP_ID]: sortedErrorGroupIds } }, - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ], - }, - }, - aggs: { - error_groups: { - terms: { - field: ERROR_GROUP_ID, - size, + const timeseriesResponse = await withApmSpan( + 'get_service_error_groups_timeseries', + async () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.error], }, - aggs: { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, + body: { + size: 0, + query: { + bool: { + filter: [ + { terms: { [ERROR_GROUP_ID]: sortedErrorGroupIds } }, + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { range: rangeFilter(start, end) }, + ...esFilter, + ], + }, + }, + aggs: { + error_groups: { + terms: { + field: ERROR_GROUP_ID, + size, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + }, }, }, }, }, + }) + ); + + return { + total_error_groups: errorGroups.length, + is_aggregation_accurate: + (response.aggregations?.error_groups.sum_other_doc_count ?? 0) === 0, + error_groups: sortedAndSlicedErrorGroups.map((errorGroup) => ({ + ...errorGroup, + occurrences: { + ...errorGroup.occurrences, + timeseries: + timeseriesResponse.aggregations?.error_groups.buckets + .find((bucket) => bucket.key === errorGroup.group_id) + ?.timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.doc_count, + })) ?? null, }, - }, - }, + })), + }; }); - - return { - total_error_groups: errorGroups.length, - is_aggregation_accurate: - (response.aggregations?.error_groups.sum_other_doc_count ?? 0) === 0, - error_groups: sortedAndSlicedErrorGroups.map((errorGroup) => ({ - ...errorGroup, - occurrences: { - ...errorGroup.occurrences, - timeseries: - timeseriesResponse.aggregations?.error_groups.buckets - .find((bucket) => bucket.key === errorGroup.group_id) - ?.timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.doc_count, - })) ?? null, - }, - })), - }; } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts index 999ed62ed4ba6b..4f8088352d0ae2 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts @@ -23,6 +23,7 @@ import { percentCgroupMemoryUsedScript, percentSystemMemoryUsedScript, } from '../../metrics/by_agent/shared/memory'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getServiceInstanceSystemMetricStats({ setup, @@ -30,122 +31,124 @@ export async function getServiceInstanceSystemMetricStats({ size, numBuckets, }: ServiceInstanceParams) { - const { apmEventClient, start, end, esFilter } = setup; + return withApmSpan('get_service_instance_system_metric_stats', async () => { + const { apmEventClient, start, end, esFilter } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets }); + const { intervalString } = getBucketSize({ start, end, numBuckets }); - const systemMemoryFilter = { - bool: { - filter: [ - { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, - { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, - ], - }, - }; + const systemMemoryFilter = { + bool: { + filter: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + }, + }; - const cgroupMemoryFilter = { - exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES }, - }; + const cgroupMemoryFilter = { + exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES }, + }; - const cpuUsageFilter = { exists: { field: METRIC_PROCESS_CPU_PERCENT } }; + const cpuUsageFilter = { exists: { field: METRIC_PROCESS_CPU_PERCENT } }; - function withTimeseries(agg: T) { - return { - avg: { avg: agg }, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, + function withTimeseries(agg: T) { + return { + avg: { avg: agg }, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + avg: { avg: agg }, }, }, - aggs: { - avg: { avg: agg }, - }, + }; + } + + const subAggs = { + memory_usage_cgroup: { + filter: cgroupMemoryFilter, + aggs: withTimeseries({ script: percentCgroupMemoryUsedScript }), + }, + memory_usage_system: { + filter: systemMemoryFilter, + aggs: withTimeseries({ script: percentSystemMemoryUsedScript }), + }, + cpu_usage: { + filter: cpuUsageFilter, + aggs: withTimeseries({ field: METRIC_PROCESS_CPU_PERCENT }), }, }; - } - const subAggs = { - memory_usage_cgroup: { - filter: cgroupMemoryFilter, - aggs: withTimeseries({ script: percentCgroupMemoryUsedScript }), - }, - memory_usage_system: { - filter: systemMemoryFilter, - aggs: withTimeseries({ script: percentSystemMemoryUsedScript }), - }, - cpu_usage: { - filter: cpuUsageFilter, - aggs: withTimeseries({ field: METRIC_PROCESS_CPU_PERCENT }), - }, - }; - - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - { term: { [SERVICE_NAME]: serviceName } }, - ...esFilter, - ], - should: [cgroupMemoryFilter, systemMemoryFilter, cpuUsageFilter], - minimum_should_match: 1, - }, + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], }, - aggs: { - [SERVICE_NODE_NAME]: { - terms: { - field: SERVICE_NODE_NAME, - missing: SERVICE_NODE_NAME_MISSING, - size, + body: { + size: 0, + query: { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + { term: { [SERVICE_NAME]: serviceName } }, + ...esFilter, + ], + should: [cgroupMemoryFilter, systemMemoryFilter, cpuUsageFilter], + minimum_should_match: 1, + }, + }, + aggs: { + [SERVICE_NODE_NAME]: { + terms: { + field: SERVICE_NODE_NAME, + missing: SERVICE_NODE_NAME_MISSING, + size, + }, + aggs: subAggs, }, - aggs: subAggs, }, }, - }, - }); + }); - return ( - response.aggregations?.[SERVICE_NODE_NAME].buckets.map( - (serviceNodeBucket) => { - const hasCGroupData = - serviceNodeBucket.memory_usage_cgroup.avg.value !== null; + return ( + response.aggregations?.[SERVICE_NODE_NAME].buckets.map( + (serviceNodeBucket) => { + const hasCGroupData = + serviceNodeBucket.memory_usage_cgroup.avg.value !== null; - const memoryMetricsKey = hasCGroupData - ? 'memory_usage_cgroup' - : 'memory_usage_system'; + const memoryMetricsKey = hasCGroupData + ? 'memory_usage_cgroup' + : 'memory_usage_system'; - return { - serviceNodeName: String(serviceNodeBucket.key), - cpuUsage: { - value: serviceNodeBucket.cpu_usage.avg.value, - timeseries: serviceNodeBucket.cpu_usage.timeseries.buckets.map( - (dateBucket) => ({ + return { + serviceNodeName: String(serviceNodeBucket.key), + cpuUsage: { + value: serviceNodeBucket.cpu_usage.avg.value, + timeseries: serviceNodeBucket.cpu_usage.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg.value, + }) + ), + }, + memoryUsage: { + value: serviceNodeBucket[memoryMetricsKey].avg.value, + timeseries: serviceNodeBucket[ + memoryMetricsKey + ].timeseries.buckets.map((dateBucket) => ({ x: dateBucket.key, y: dateBucket.avg.value, - }) - ), - }, - memoryUsage: { - value: serviceNodeBucket[memoryMetricsKey].avg.value, - timeseries: serviceNodeBucket[ - memoryMetricsKey - ].timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.avg.value, - })), - }, - }; - } - ) ?? [] - ); + })), + }, + }; + } + ) ?? [] + ); + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts index 42c822fb4e133a..2cbe5a42206d18 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts @@ -21,6 +21,7 @@ import { getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; import { calculateThroughput } from '../../helpers/calculate_throughput'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getServiceInstanceTransactionStats({ setup, @@ -30,120 +31,122 @@ export async function getServiceInstanceTransactionStats({ searchAggregatedTransactions, numBuckets, }: ServiceInstanceParams) { - const { apmEventClient, start, end, esFilter } = setup; + return withApmSpan('get_service_instance_transaction_stats', async () => { + const { apmEventClient, start, end, esFilter } = setup; - const { intervalString, bucketSize } = getBucketSize({ - start, - end, - numBuckets, - }); + const { intervalString, bucketSize } = getBucketSize({ + start, + end, + numBuckets, + }); - const field = getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ); + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); - const subAggs = { - avg_transaction_duration: { - avg: { - field, + const subAggs = { + avg_transaction_duration: { + avg: { + field, + }, }, - }, - failures: { - filter: { - term: { - [EVENT_OUTCOME]: EventOutcome.failure, + failures: { + filter: { + term: { + [EVENT_OUTCOME]: EventOutcome.failure, + }, }, }, - }, - }; + }; - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - ...esFilter, - ], - }, + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - [SERVICE_NODE_NAME]: { - terms: { - field: SERVICE_NODE_NAME, - missing: SERVICE_NODE_NAME_MISSING, - size, + body: { + size: 0, + query: { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + ...esFilter, + ], }, - aggs: { - ...subAggs, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, + }, + aggs: { + [SERVICE_NODE_NAME]: { + terms: { + field: SERVICE_NODE_NAME, + missing: SERVICE_NODE_NAME_MISSING, + size, + }, + aggs: { + ...subAggs, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + ...subAggs, }, - }, - aggs: { - ...subAggs, }, }, }, }, }, - }, - }); + }); - const bucketSizeInMinutes = bucketSize / 60; + const bucketSizeInMinutes = bucketSize / 60; - return ( - response.aggregations?.[SERVICE_NODE_NAME].buckets.map( - (serviceNodeBucket) => { - const { - doc_count: count, - avg_transaction_duration: avgTransactionDuration, - key, - failures, - timeseries, - } = serviceNodeBucket; + return ( + response.aggregations?.[SERVICE_NODE_NAME].buckets.map( + (serviceNodeBucket) => { + const { + doc_count: count, + avg_transaction_duration: avgTransactionDuration, + key, + failures, + timeseries, + } = serviceNodeBucket; - return { - serviceNodeName: String(key), - errorRate: { - value: failures.doc_count / count, - timeseries: timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.failures.doc_count / dateBucket.doc_count, - })), - }, - throughput: { - value: calculateThroughput({ start, end, value: count }), - timeseries: timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.doc_count / bucketSizeInMinutes, - })), - }, - latency: { - value: avgTransactionDuration.value, - timeseries: timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.avg_transaction_duration.value, - })), - }, - }; - } - ) ?? [] - ); + return { + serviceNodeName: String(key), + errorRate: { + value: failures.doc_count / count, + timeseries: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.failures.doc_count / dateBucket.doc_count, + })), + }, + throughput: { + value: calculateThroughput({ start, end, value: count }), + timeseries: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.doc_count / bucketSizeInMinutes, + })), + }, + latency: { + value: avgTransactionDuration.value, + timeseries: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg_transaction_duration.value, + })), + }, + }; + } + ) ?? [] + ); + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts index 4dae5e0a33a908..021774f9522c1b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts @@ -6,6 +6,7 @@ */ import { joinByKey } from '../../../../common/utils/join_by_key'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getServiceInstanceSystemMetricStats } from './get_service_instance_system_metric_stats'; import { getServiceInstanceTransactionStats } from './get_service_instance_transaction_stats'; @@ -22,20 +23,22 @@ export interface ServiceInstanceParams { export async function getServiceInstances( params: Omit ) { - const paramsForSubQueries = { - ...params, - size: 50, - }; + return withApmSpan('get_service_instances', async () => { + const paramsForSubQueries = { + ...params, + size: 50, + }; - const [transactionStats, systemMetricStats] = await Promise.all([ - getServiceInstanceTransactionStats(paramsForSubQueries), - getServiceInstanceSystemMetricStats(paramsForSubQueries), - ]); + const [transactionStats, systemMetricStats] = await Promise.all([ + getServiceInstanceTransactionStats(paramsForSubQueries), + getServiceInstanceSystemMetricStats(paramsForSubQueries), + ]); - const stats = joinByKey( - [...transactionStats, ...systemMetricStats], - 'serviceNodeName' - ); + const stats = joinByKey( + [...transactionStats, ...systemMetricStats], + 'serviceNodeName' + ); - return stats; + return stats; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts index 246184f617b077..24ed72ea995109 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts @@ -26,6 +26,7 @@ import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw' import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { should } from './get_service_metadata_icons'; +import { withApmSpan } from '../../utils/with_apm_span'; type ServiceMetadataDetailsRaw = Pick< TransactionRaw, @@ -59,7 +60,7 @@ interface ServiceMetadataDetails { }; } -export async function getServiceMetadataDetails({ +export function getServiceMetadataDetails({ serviceName, setup, searchAggregatedTransactions, @@ -68,103 +69,105 @@ export async function getServiceMetadataDetails({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }): Promise { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_service_metadata_details', async () => { + const { start, end, apmEventClient } = setup; - const filter = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ]; + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + { range: rangeFilter(start, end) }, + ]; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - size: 1, - _source: [SERVICE, AGENT, HOST, CONTAINER_ID, KUBERNETES, CLOUD], - query: { bool: { filter, should } }, - aggs: { - serviceVersions: { - terms: { - field: SERVICE_VERSION, - size: 10, - order: { _key: 'desc' } as SortOptions, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + body: { + size: 1, + _source: [SERVICE, AGENT, HOST, CONTAINER_ID, KUBERNETES, CLOUD], + query: { bool: { filter, should } }, + aggs: { + serviceVersions: { + terms: { + field: SERVICE_VERSION, + size: 10, + order: { _key: 'desc' } as SortOptions, + }, }, - }, - availabilityZones: { - terms: { - field: CLOUD_AVAILABILITY_ZONE, - size: 10, + availabilityZones: { + terms: { + field: CLOUD_AVAILABILITY_ZONE, + size: 10, + }, }, - }, - machineTypes: { - terms: { - field: CLOUD_MACHINE_TYPE, - size: 10, + machineTypes: { + terms: { + field: CLOUD_MACHINE_TYPE, + size: 10, + }, }, + totalNumberInstances: { cardinality: { field: SERVICE_NODE_NAME } }, }, - totalNumberInstances: { cardinality: { field: SERVICE_NODE_NAME } }, }, - }, - }; + }; - const response = await apmEventClient.search(params); + const response = await apmEventClient.search(params); - if (response.hits.total.value === 0) { - return { - service: undefined, - container: undefined, - cloud: undefined, - }; - } + if (response.hits.total.value === 0) { + return { + service: undefined, + container: undefined, + cloud: undefined, + }; + } - const { service, agent, host, kubernetes, container, cloud } = response.hits - .hits[0]._source as ServiceMetadataDetailsRaw; + const { service, agent, host, kubernetes, container, cloud } = response.hits + .hits[0]._source as ServiceMetadataDetailsRaw; - const serviceMetadataDetails = { - versions: response.aggregations?.serviceVersions.buckets.map( - (bucket) => bucket.key as string - ), - runtime: service.runtime, - framework: service.framework?.name, - agent, - }; + const serviceMetadataDetails = { + versions: response.aggregations?.serviceVersions.buckets.map( + (bucket) => bucket.key as string + ), + runtime: service.runtime, + framework: service.framework?.name, + agent, + }; - const totalNumberInstances = - response.aggregations?.totalNumberInstances.value; + const totalNumberInstances = + response.aggregations?.totalNumberInstances.value; - const containerDetails = - host || container || totalNumberInstances || kubernetes + const containerDetails = + host || container || totalNumberInstances || kubernetes + ? { + os: host?.os?.platform, + type: (!!kubernetes ? 'Kubernetes' : 'Docker') as ContainerType, + isContainerized: !!container?.id, + totalNumberInstances, + } + : undefined; + + const cloudDetails = cloud ? { - os: host?.os?.platform, - type: (!!kubernetes ? 'Kubernetes' : 'Docker') as ContainerType, - isContainerized: !!container?.id, - totalNumberInstances, + provider: cloud.provider, + projectName: cloud.project?.name, + availabilityZones: response.aggregations?.availabilityZones.buckets.map( + (bucket) => bucket.key as string + ), + machineTypes: response.aggregations?.machineTypes.buckets.map( + (bucket) => bucket.key as string + ), } : undefined; - const cloudDetails = cloud - ? { - provider: cloud.provider, - projectName: cloud.project?.name, - availabilityZones: response.aggregations?.availabilityZones.buckets.map( - (bucket) => bucket.key as string - ), - machineTypes: response.aggregations?.machineTypes.buckets.map( - (bucket) => bucket.key as string - ), - } - : undefined; - - return { - service: serviceMetadataDetails, - container: containerDetails, - cloud: cloudDetails, - }; + return { + service: serviceMetadataDetails, + container: containerDetails, + cloud: cloudDetails, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts index b09b2305629ab9..6636820defdebe 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts @@ -20,6 +20,7 @@ import { rangeFilter } from '../../../common/utils/range_filter'; import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { withApmSpan } from '../../utils/with_apm_span'; type ServiceMetadataIconsRaw = Pick< TransactionRaw, @@ -40,7 +41,7 @@ export const should = [ { exists: { field: AGENT_NAME } }, ]; -export async function getServiceMetadataIcons({ +export function getServiceMetadataIcons({ serviceName, setup, searchAggregatedTransactions, @@ -49,53 +50,55 @@ export async function getServiceMetadataIcons({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }): Promise { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_service_metadata_icons', async () => { + const { start, end, apmEventClient } = setup; - const filter = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ]; + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + { range: rangeFilter(start, end) }, + ]; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - size: 1, - _source: [KUBERNETES, CLOUD_PROVIDER, CONTAINER_ID, AGENT_NAME], - query: { bool: { filter, should } }, - }, - }; + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + body: { + size: 1, + _source: [KUBERNETES, CLOUD_PROVIDER, CONTAINER_ID, AGENT_NAME], + query: { bool: { filter, should } }, + }, + }; - const response = await apmEventClient.search(params); + const response = await apmEventClient.search(params); - if (response.hits.total.value === 0) { - return { - agentName: undefined, - containerType: undefined, - cloudProvider: undefined, - }; - } + if (response.hits.total.value === 0) { + return { + agentName: undefined, + containerType: undefined, + cloudProvider: undefined, + }; + } - const { kubernetes, cloud, container, agent } = response.hits.hits[0] - ._source as ServiceMetadataIconsRaw; + const { kubernetes, cloud, container, agent } = response.hits.hits[0] + ._source as ServiceMetadataIconsRaw; - let containerType: ContainerType; - if (!!kubernetes) { - containerType = 'Kubernetes'; - } else if (!!container) { - containerType = 'Docker'; - } + let containerType: ContainerType; + if (!!kubernetes) { + containerType = 'Kubernetes'; + } else if (!!container) { + containerType = 'Docker'; + } - return { - agentName: agent?.name, - containerType, - cloudProvider: cloud?.provider, - }; + return { + agentName: agent?.name, + containerType, + cloudProvider: cloud?.provider, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_node_metadata.ts b/x-pack/plugins/apm/server/lib/services/get_service_node_metadata.ts index 07665c901e6cae..16753db416eddb 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_node_metadata.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_node_metadata.ts @@ -13,8 +13,9 @@ import { import { NOT_AVAILABLE_LABEL } from '../../../common/i18n'; import { mergeProjection } from '../../projections/util/merge_projection'; import { getServiceNodesProjection } from '../../projections/service_nodes'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getServiceNodeMetadata({ +export function getServiceNodeMetadata({ serviceName, serviceNodeName, setup, @@ -23,40 +24,43 @@ export async function getServiceNodeMetadata({ serviceNodeName: string; setup: Setup & SetupTimeRange; }) { - const { apmEventClient } = setup; + return withApmSpan('get_service_node_metadata', async () => { + const { apmEventClient } = setup; - const query = mergeProjection( - getServiceNodesProjection({ - setup, - serviceName, - serviceNodeName, - }), - { - body: { - size: 0, - aggs: { - host: { - terms: { - field: HOST_NAME, - size: 1, + const query = mergeProjection( + getServiceNodesProjection({ + setup, + serviceName, + serviceNodeName, + }), + { + body: { + size: 0, + aggs: { + host: { + terms: { + field: HOST_NAME, + size: 1, + }, }, - }, - containerId: { - terms: { - field: CONTAINER_ID, - size: 1, + containerId: { + terms: { + field: CONTAINER_ID, + size: 1, + }, }, }, }, - }, - } - ); + } + ); - const response = await apmEventClient.search(query); + const response = await apmEventClient.search(query); - return { - host: response.aggregations?.host.buckets[0]?.key || NOT_AVAILABLE_LABEL, - containerId: - response.aggregations?.containerId.buckets[0]?.key || NOT_AVAILABLE_LABEL, - }; + return { + host: response.aggregations?.host.buckets[0]?.key || NOT_AVAILABLE_LABEL, + containerId: + response.aggregations?.containerId.buckets[0]?.key || + NOT_AVAILABLE_LABEL, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts index 8c21fb65a37e51..54cf89d6125b6e 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts @@ -16,6 +16,7 @@ import { EventOutcome } from '../../../common/event_outcome'; import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; import { rangeFilter } from '../../../common/utils/range_filter'; import { Coordinate } from '../../../typings/timeseries'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, @@ -56,112 +57,123 @@ export async function getServiceTransactionGroupComparisonStatistics({ } > > { - const { apmEventClient, start, end, esFilter } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets }); + return withApmSpan( + 'get_service_transaction_group_comparison_statistics', + async () => { + const { apmEventClient, start, end, esFilter } = setup; + const { intervalString } = getBucketSize({ start, end, numBuckets }); - const field = getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ); + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...getDocumentTypeFilterForAggregatedTransactions( + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( searchAggregatedTransactions ), - ...esFilter, ], }, - }, - aggs: { - total_duration: { sum: { field } }, - transaction_groups: { - terms: { - field: TRANSACTION_NAME, - include: transactionNames, - size: transactionNames.length, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { range: rangeFilter(start, end) }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...esFilter, + ], + }, }, aggs: { - transaction_group_total_duration: { - sum: { field }, - }, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, - }, + total_duration: { sum: { field } }, + transaction_groups: { + terms: { + field: TRANSACTION_NAME, + include: transactionNames, + size: transactionNames.length, }, aggs: { - throughput_rate: { - rate: { - unit: 'minute', - }, + transaction_group_total_duration: { + sum: { field }, }, - ...getLatencyAggregation(latencyAggregationType, field), - [EVENT_OUTCOME]: { - terms: { - field: EVENT_OUTCOME, - include: [EventOutcome.failure, EventOutcome.success], + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + throughput_rate: { + rate: { + unit: 'minute', + }, + }, + ...getLatencyAggregation(latencyAggregationType, field), + [EVENT_OUTCOME]: { + terms: { + field: EVENT_OUTCOME, + include: [EventOutcome.failure, EventOutcome.success], + }, + }, }, }, }, }, }, }, - }, - }, - }); + }); - const buckets = response.aggregations?.transaction_groups.buckets ?? []; + const buckets = response.aggregations?.transaction_groups.buckets ?? []; - const totalDuration = response.aggregations?.total_duration.value; - return keyBy( - buckets.map((bucket) => { - const transactionName = bucket.key; - const latency = bucket.timeseries.buckets.map((timeseriesBucket) => ({ - x: timeseriesBucket.key, - y: getLatencyValue({ - latencyAggregationType, - aggregation: timeseriesBucket.latency, + const totalDuration = response.aggregations?.total_duration.value; + return keyBy( + buckets.map((bucket) => { + const transactionName = bucket.key; + const latency = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: getLatencyValue({ + latencyAggregationType, + aggregation: timeseriesBucket.latency, + }), + })); + const throughput = bucket.timeseries.buckets.map( + (timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: timeseriesBucket.throughput_rate.value, + }) + ); + const errorRate = bucket.timeseries.buckets.map( + (timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: calculateTransactionErrorPercentage( + timeseriesBucket[EVENT_OUTCOME] + ), + }) + ); + const transactionGroupTotalDuration = + bucket.transaction_group_total_duration.value || 0; + return { + transactionName, + latency, + throughput, + errorRate, + impact: totalDuration + ? (transactionGroupTotalDuration * 100) / totalDuration + : 0, + }; }), - })); - const throughput = bucket.timeseries.buckets.map((timeseriesBucket) => ({ - x: timeseriesBucket.key, - y: timeseriesBucket.throughput_rate.value, - })); - const errorRate = bucket.timeseries.buckets.map((timeseriesBucket) => ({ - x: timeseriesBucket.key, - y: calculateTransactionErrorPercentage(timeseriesBucket[EVENT_OUTCOME]), - })); - const transactionGroupTotalDuration = - bucket.transaction_group_total_duration.value || 0; - return { - transactionName, - latency, - throughput, - errorRate, - impact: totalDuration - ? (transactionGroupTotalDuration * 100) / totalDuration - : 0, - }; - }), - 'transactionName' + 'transactionName' + ); + } ); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups.ts index 67ae37f93606e8..168eed8e38374c 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups.ts @@ -14,6 +14,7 @@ import { import { EventOutcome } from '../../../common/event_outcome'; import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; import { rangeFilter } from '../../../common/utils/range_filter'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, @@ -47,96 +48,98 @@ export async function getServiceTransactionGroups({ transactionType: string; latencyAggregationType: LatencyAggregationType; }) { - const { apmEventClient, start, end, esFilter } = setup; + return withApmSpan('get_service_transaction_groups', async () => { + const { apmEventClient, start, end, esFilter } = setup; - const field = getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ); + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ...esFilter, - ], - }, + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - total_duration: { sum: { field } }, - transaction_groups: { - terms: { - field: TRANSACTION_NAME, - size: 500, - order: { _count: 'desc' }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { range: rangeFilter(start, end) }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...esFilter, + ], }, - aggs: { - transaction_group_total_duration: { - sum: { field }, + }, + aggs: { + total_duration: { sum: { field } }, + transaction_groups: { + terms: { + field: TRANSACTION_NAME, + size: 500, + order: { _count: 'desc' }, }, - ...getLatencyAggregation(latencyAggregationType, field), - [EVENT_OUTCOME]: { - terms: { - field: EVENT_OUTCOME, - include: [EventOutcome.failure, EventOutcome.success], + aggs: { + transaction_group_total_duration: { + sum: { field }, + }, + ...getLatencyAggregation(latencyAggregationType, field), + [EVENT_OUTCOME]: { + terms: { + field: EVENT_OUTCOME, + include: [EventOutcome.failure, EventOutcome.success], + }, }, }, }, }, }, - }, - }); + }); - const totalDuration = response.aggregations?.total_duration.value; + const totalDuration = response.aggregations?.total_duration.value; - const transactionGroups = - response.aggregations?.transaction_groups.buckets.map((bucket) => { - const errorRate = calculateTransactionErrorPercentage( - bucket[EVENT_OUTCOME] - ); + const transactionGroups = + response.aggregations?.transaction_groups.buckets.map((bucket) => { + const errorRate = calculateTransactionErrorPercentage( + bucket[EVENT_OUTCOME] + ); - const transactionGroupTotalDuration = - bucket.transaction_group_total_duration.value || 0; + const transactionGroupTotalDuration = + bucket.transaction_group_total_duration.value || 0; - return { - name: bucket.key as string, - latency: getLatencyValue({ - latencyAggregationType, - aggregation: bucket.latency, - }), - throughput: calculateThroughput({ - start, - end, - value: bucket.doc_count, - }), - errorRate, - impact: totalDuration - ? (transactionGroupTotalDuration * 100) / totalDuration - : 0, - }; - }) ?? []; + return { + name: bucket.key as string, + latency: getLatencyValue({ + latencyAggregationType, + aggregation: bucket.latency, + }), + throughput: calculateThroughput({ + start, + end, + value: bucket.doc_count, + }), + errorRate, + impact: totalDuration + ? (transactionGroupTotalDuration * 100) / totalDuration + : 0, + }; + }) ?? []; - return { - transactionGroups: transactionGroups.map((transactionGroup) => ({ - ...transactionGroup, - transactionType, - })), - isAggregationAccurate: - (response.aggregations?.transaction_groups.sum_other_doc_count ?? 0) === - 0, - }; + return { + transactionGroups: transactionGroups.map((transactionGroup) => ({ + ...transactionGroup, + transactionType, + })), + isAggregationAccurate: + (response.aggregations?.transaction_groups.sum_other_doc_count ?? 0) === + 0, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_types.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_types.ts index de0e991e219ccb..bc4660e2c01a5e 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_types.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_types.ts @@ -15,8 +15,9 @@ import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, } from '../helpers/aggregated_transactions'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getServiceTransactionTypes({ +export function getServiceTransactionTypes({ setup, serviceName, searchAggregatedTransactions, @@ -25,39 +26,41 @@ export async function getServiceTransactionTypes({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_service_transaction_types', async () => { + const { start, end, apmEventClient } = setup; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ], - }, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - types: { - terms: { field: TRANSACTION_TYPE, size: 100 }, + body: { + size: 0, + query: { + bool: { + filter: [ + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + { term: { [SERVICE_NAME]: serviceName } }, + { range: rangeFilter(start, end) }, + ], + }, + }, + aggs: { + types: { + terms: { field: TRANSACTION_TYPE, size: 100 }, + }, }, }, - }, - }; + }; - const { aggregations } = await apmEventClient.search(params); - const transactionTypes = - aggregations?.types.buckets.map((bucket) => bucket.key as string) || []; - return { transactionTypes }; + const { aggregations } = await apmEventClient.search(params); + const transactionTypes = + aggregations?.types.buckets.map((bucket) => bucket.key as string) || []; + return { transactionTypes }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts index d69c6682bc8074..87f3c0a5d1b389 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts @@ -8,27 +8,32 @@ import { ProcessorEvent } from '../../../../common/processor_event'; import { OBSERVER_VERSION_MAJOR } from '../../../../common/elasticsearch_fieldnames'; import { Setup } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; // returns true if 6.x data is found export async function getLegacyDataStatus(setup: Setup) { - const { apmEventClient } = setup; + return withApmSpan('get_legacy_data_status', async () => { + const { apmEventClient } = setup; - const params = { - terminateAfter: 1, - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - size: 0, - query: { - bool: { - filter: [{ range: { [OBSERVER_VERSION_MAJOR]: { lt: 7 } } }], + const params = { + terminateAfter: 1, + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + size: 0, + query: { + bool: { + filter: [{ range: { [OBSERVER_VERSION_MAJOR]: { lt: 7 } } }], + }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params, { includeLegacyData: true }); - const hasLegacyData = resp.hits.total.value > 0; - return hasLegacyData; + const resp = await apmEventClient.search(params, { + includeLegacyData: true, + }); + const hasLegacyData = resp.hits.total.value > 0; + return hasLegacyData; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts index 71cccfa61607fd..36540b01a07cc9 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts @@ -29,6 +29,7 @@ import { getOutcomeAggregation, } from '../../helpers/transaction_error_rate'; import { ServicesItemsSetup } from './get_services_items'; +import { withApmSpan } from '../../../utils/with_apm_span'; interface AggregationParams { setup: ServicesItemsSetup; @@ -41,143 +42,145 @@ export async function getServiceTransactionStats({ setup, searchAggregatedTransactions, }: AggregationParams) { - const { apmEventClient, start, end, esFilter } = setup; + return withApmSpan('get_service_transaction_stats', async () => { + const { apmEventClient, start, end, esFilter } = setup; - const outcomes = getOutcomeAggregation(); + const outcomes = getOutcomeAggregation(); - const metrics = { - avg_duration: { - avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), + const metrics = { + avg_duration: { + avg: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, }, - }, - outcomes, - }; + outcomes, + }; - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - ...esFilter, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - services: { - terms: { - field: SERVICE_NAME, - size: MAX_NUMBER_OF_SERVICES, + body: { + size: 0, + query: { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + ...esFilter, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - transactionType: { - terms: { - field: TRANSACTION_TYPE, - }, - aggs: { - ...metrics, - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - missing: '', - }, + }, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + size: MAX_NUMBER_OF_SERVICES, + }, + aggs: { + transactionType: { + terms: { + field: TRANSACTION_TYPE, }, - agentName: { - top_hits: { - docvalue_fields: [AGENT_NAME] as const, - size: 1, + aggs: { + ...metrics, + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + missing: '', + }, }, - }, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: getBucketSize({ - start, - end, - numBuckets: 20, - }).intervalString, - min_doc_count: 0, - extended_bounds: { min: start, max: end }, + agentName: { + top_hits: { + docvalue_fields: [AGENT_NAME] as const, + size: 1, + }, + }, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize({ + start, + end, + numBuckets: 20, + }).intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: metrics, }, - aggs: metrics, }, }, }, }, }, }, - }, - }); + }); - return ( - response.aggregations?.services.buckets.map((bucket) => { - const topTransactionTypeBucket = - bucket.transactionType.buckets.find( - ({ key }) => - key === TRANSACTION_REQUEST || key === TRANSACTION_PAGE_LOAD - ) ?? bucket.transactionType.buckets[0]; + return ( + response.aggregations?.services.buckets.map((bucket) => { + const topTransactionTypeBucket = + bucket.transactionType.buckets.find( + ({ key }) => + key === TRANSACTION_REQUEST || key === TRANSACTION_PAGE_LOAD + ) ?? bucket.transactionType.buckets[0]; - return { - serviceName: bucket.key as string, - transactionType: topTransactionTypeBucket.key as string, - environments: topTransactionTypeBucket.environments.buckets - .map((environmentBucket) => environmentBucket.key as string) - .filter(Boolean), - agentName: topTransactionTypeBucket.agentName.hits.hits[0].fields[ - 'agent.name' - ]?.[0] as AgentName, - avgResponseTime: { - value: topTransactionTypeBucket.avg_duration.value, - timeseries: topTransactionTypeBucket.timeseries.buckets.map( - (dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.avg_duration.value, - }) - ), - }, - transactionErrorRate: { - value: calculateTransactionErrorPercentage( - topTransactionTypeBucket.outcomes - ), - timeseries: topTransactionTypeBucket.timeseries.buckets.map( - (dateBucket) => ({ - x: dateBucket.key, - y: calculateTransactionErrorPercentage(dateBucket.outcomes), - }) - ), - }, - transactionsPerMinute: { - value: calculateThroughput({ - start, - end, - value: topTransactionTypeBucket.doc_count, - }), - timeseries: topTransactionTypeBucket.timeseries.buckets.map( - (dateBucket) => ({ - x: dateBucket.key, - y: calculateThroughput({ - start, - end, - value: dateBucket.doc_count, - }), - }) - ), - }, - }; - }) ?? [] - ); + return { + serviceName: bucket.key as string, + transactionType: topTransactionTypeBucket.key as string, + environments: topTransactionTypeBucket.environments.buckets + .map((environmentBucket) => environmentBucket.key as string) + .filter(Boolean), + agentName: topTransactionTypeBucket.agentName.hits.hits[0].fields[ + 'agent.name' + ]?.[0] as AgentName, + avgResponseTime: { + value: topTransactionTypeBucket.avg_duration.value, + timeseries: topTransactionTypeBucket.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg_duration.value, + }) + ), + }, + transactionErrorRate: { + value: calculateTransactionErrorPercentage( + topTransactionTypeBucket.outcomes + ), + timeseries: topTransactionTypeBucket.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key, + y: calculateTransactionErrorPercentage(dateBucket.outcomes), + }) + ), + }, + transactionsPerMinute: { + value: calculateThroughput({ + start, + end, + value: topTransactionTypeBucket.doc_count, + }), + timeseries: topTransactionTypeBucket.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key, + y: calculateThroughput({ + start, + end, + value: dateBucket.doc_count, + }), + }) + ), + }, + }; + }) ?? [] + ); + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts index a2310eebb639d0..1ba9aaf980201f 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts @@ -8,6 +8,7 @@ import { Logger } from '@kbn/logging'; import { joinByKey } from '../../../../common/utils/join_by_key'; import { getServicesProjection } from '../../../projections/services'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getHealthStatuses } from './get_health_statuses'; import { getServiceTransactionStats } from './get_service_transaction_stats'; @@ -24,32 +25,34 @@ export async function getServicesItems({ searchAggregatedTransactions: boolean; logger: Logger; }) { - const params = { - projection: getServicesProjection({ + return withApmSpan('get_services_items', async () => { + const params = { + projection: getServicesProjection({ + setup, + searchAggregatedTransactions, + }), setup, searchAggregatedTransactions, - }), - setup, - searchAggregatedTransactions, - }; - - const [transactionStats, healthStatuses] = await Promise.all([ - getServiceTransactionStats(params), - getHealthStatuses(params, setup.uiFilters.environment).catch((err) => { - logger.error(err); - return []; - }), - ]); - - const apmServices = transactionStats.map(({ serviceName }) => serviceName); - - // make sure to exclude health statuses from services - // that are not found in APM data - const matchedHealthStatuses = healthStatuses.filter(({ serviceName }) => - apmServices.includes(serviceName) - ); - - const allMetrics = [...transactionStats, ...matchedHealthStatuses]; - - return joinByKey(allMetrics, 'serviceName'); + }; + + const [transactionStats, healthStatuses] = await Promise.all([ + getServiceTransactionStats(params), + getHealthStatuses(params, setup.uiFilters.environment).catch((err) => { + logger.error(err); + return []; + }), + ]); + + const apmServices = transactionStats.map(({ serviceName }) => serviceName); + + // make sure to exclude health statuses from services + // that are not found in APM data + const matchedHealthStatuses = healthStatuses.filter(({ serviceName }) => + apmServices.includes(serviceName) + ); + + const allMetrics = [...transactionStats, ...matchedHealthStatuses]; + + return joinByKey(allMetrics, 'serviceName'); + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts b/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts index 8363e59f2522ed..28f6944fd24daf 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts @@ -6,26 +6,29 @@ */ import { ProcessorEvent } from '../../../../common/processor_event'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; // Note: this logic is duplicated in tutorials/apm/envs/on_prem export async function hasHistoricalAgentData(setup: Setup) { - const { apmEventClient } = setup; + return withApmSpan('has_historical_agent_data', async () => { + const { apmEventClient } = setup; - const params = { - terminateAfter: 1, - apm: { - events: [ - ProcessorEvent.error, - ProcessorEvent.metric, - ProcessorEvent.transaction, - ], - }, - body: { - size: 0, - }, - }; + const params = { + terminateAfter: 1, + apm: { + events: [ + ProcessorEvent.error, + ProcessorEvent.metric, + ProcessorEvent.transaction, + ], + }, + body: { + size: 0, + }, + }; - const resp = await apmEventClient.search(params); - return resp.hits.total.value > 0; + const resp = await apmEventClient.search(params); + return resp.hits.total.value > 0; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_services/index.ts b/x-pack/plugins/apm/server/lib/services/get_services/index.ts index 530f04c72b6c16..45efd80c45a980 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/index.ts @@ -7,6 +7,7 @@ import { Logger } from '@kbn/logging'; import { isEmpty } from 'lodash'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getLegacyDataStatus } from './get_legacy_data_status'; import { getServicesItems } from './get_services_items'; @@ -21,23 +22,25 @@ export async function getServices({ searchAggregatedTransactions: boolean; logger: Logger; }) { - const [items, hasLegacyData] = await Promise.all([ - getServicesItems({ - setup, - searchAggregatedTransactions, - logger, - }), - getLegacyDataStatus(setup), - ]); + return withApmSpan('get_services', async () => { + const [items, hasLegacyData] = await Promise.all([ + getServicesItems({ + setup, + searchAggregatedTransactions, + logger, + }), + getLegacyDataStatus(setup), + ]); - const noDataInCurrentTimeRange = isEmpty(items); - const hasHistoricalData = noDataInCurrentTimeRange - ? await hasHistoricalAgentData(setup) - : true; + const noDataInCurrentTimeRange = isEmpty(items); + const hasHistoricalData = noDataInCurrentTimeRange + ? await hasHistoricalAgentData(setup) + : true; - return { - items, - hasHistoricalData, - hasLegacyData, - }; + return { + items, + hasHistoricalData, + hasLegacyData, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_throughput.ts b/x-pack/plugins/apm/server/lib/services/get_throughput.ts index 492f41e153a89c..c4e217f95bcd1b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_throughput.ts +++ b/x-pack/plugins/apm/server/lib/services/get_throughput.ts @@ -19,6 +19,7 @@ import { import { getBucketSize } from '../helpers/get_bucket_size'; import { calculateThroughput } from '../helpers/calculate_throughput'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { withApmSpan } from '../../utils/with_apm_span'; interface Options { searchAggregatedTransactions: boolean; @@ -86,8 +87,10 @@ async function fetcher({ return apmEventClient.search(params); } -export async function getThroughput(options: Options) { - return { - throughput: transform(options, await fetcher(options)), - }; +export function getThroughput(options: Options) { + return withApmSpan('get_throughput_for_service', async () => { + return { + throughput: transform(options, await fetcher(options)), + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts index ff1315ed1e3f02..18853824355622 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts @@ -12,8 +12,9 @@ import { AgentConfigurationIntake, } from '../../../../common/agent_configuration/configuration_types'; import { APMIndexDocumentParams } from '../../helpers/create_es_client/create_internal_es_client'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function createOrUpdateConfiguration({ +export function createOrUpdateConfiguration({ configurationId, configurationIntake, setup, @@ -22,28 +23,30 @@ export async function createOrUpdateConfiguration({ configurationIntake: AgentConfigurationIntake; setup: Setup; }) { - const { internalClient, indices } = setup; + return withApmSpan('create_or_update_configuration', async () => { + const { internalClient, indices } = setup; - const params: APMIndexDocumentParams = { - refresh: true, - index: indices.apmAgentConfigurationIndex, - body: { - agent_name: configurationIntake.agent_name, - service: { - name: configurationIntake.service.name, - environment: configurationIntake.service.environment, + const params: APMIndexDocumentParams = { + refresh: true, + index: indices.apmAgentConfigurationIndex, + body: { + agent_name: configurationIntake.agent_name, + service: { + name: configurationIntake.service.name, + environment: configurationIntake.service.environment, + }, + settings: configurationIntake.settings, + '@timestamp': Date.now(), + applied_by_agent: false, + etag: hash(configurationIntake), }, - settings: configurationIntake.settings, - '@timestamp': Date.now(), - applied_by_agent: false, - etag: hash(configurationIntake), - }, - }; + }; - // by specifying an id elasticsearch will delete the previous doc and insert the updated doc - if (configurationId) { - params.id = configurationId; - } + // by specifying an id elasticsearch will delete the previous doc and insert the updated doc + if (configurationId) { + params.id = configurationId; + } - return internalClient.index(params); + return internalClient.index(params); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts index 51844115067692..6ed6f79979889b 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; export async function deleteConfiguration({ @@ -14,13 +15,15 @@ export async function deleteConfiguration({ configurationId: string; setup: Setup; }) { - const { internalClient, indices } = setup; + return withApmSpan('delete_agent_configuration', async () => { + const { internalClient, indices } = setup; - const params = { - refresh: 'wait_for' as const, - index: indices.apmAgentConfigurationIndex, - id: configurationId, - }; + const params = { + refresh: 'wait_for' as const, + index: indices.apmAgentConfigurationIndex, + id: configurationId, + }; - return internalClient.delete(params); + return internalClient.delete(params); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts index 1657177f7672e7..55d00b70b8c29c 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts @@ -11,44 +11,49 @@ import { SERVICE_ENVIRONMENT, SERVICE_NAME, } from '../../../../common/elasticsearch_fieldnames'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; import { convertConfigSettingsToString } from './convert_settings_to_string'; -export async function findExactConfiguration({ +export function findExactConfiguration({ service, setup, }: { service: AgentConfiguration['service']; setup: Setup; }) { - const { internalClient, indices } = setup; - - const serviceNameFilter = service.name - ? { term: { [SERVICE_NAME]: service.name } } - : { bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } }; - - const environmentFilter = service.environment - ? { term: { [SERVICE_ENVIRONMENT]: service.environment } } - : { bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] } }; - - const params = { - index: indices.apmAgentConfigurationIndex, - body: { - query: { - bool: { filter: [serviceNameFilter, environmentFilter] }, + return withApmSpan('find_exact_agent_configuration', async () => { + const { internalClient, indices } = setup; + + const serviceNameFilter = service.name + ? { term: { [SERVICE_NAME]: service.name } } + : { bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } }; + + const environmentFilter = service.environment + ? { term: { [SERVICE_ENVIRONMENT]: service.environment } } + : { bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] } }; + + const params = { + index: indices.apmAgentConfigurationIndex, + body: { + query: { + bool: { filter: [serviceNameFilter, environmentFilter] }, + }, }, - }, - }; + }; - const resp = await internalClient.search( - params - ); + const resp = await internalClient.search( + params + ); - const hit = resp.hits.hits[0] as ESSearchHit | undefined; + const hit = resp.hits.hits[0] as + | ESSearchHit + | undefined; - if (!hit) { - return; - } + if (!hit) { + return; + } - return convertConfigSettingsToString(hit); + return convertConfigSettingsToString(hit); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_agent_name_by_service.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_agent_name_by_service.ts index 322bb5fc3b1fe7..379ed12e373895 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_agent_name_by_service.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_agent_name_by_service.ts @@ -9,6 +9,7 @@ import { ProcessorEvent } from '../../../../common/processor_event'; import { Setup } from '../../helpers/setup_request'; import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; import { AGENT_NAME } from '../../../../common/elasticsearch_fieldnames'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getAgentNameByService({ serviceName, @@ -17,33 +18,35 @@ export async function getAgentNameByService({ serviceName: string; setup: Setup; }) { - const { apmEventClient } = setup; + return withApmSpan('get_agent_name_by_service', async () => { + const { apmEventClient } = setup; - const params = { - terminateAfter: 1, - apm: { - events: [ - ProcessorEvent.transaction, - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [{ term: { [SERVICE_NAME]: serviceName } }], - }, + const params = { + terminateAfter: 1, + apm: { + events: [ + ProcessorEvent.transaction, + ProcessorEvent.error, + ProcessorEvent.metric, + ], }, - aggs: { - agent_names: { - terms: { field: AGENT_NAME, size: 1 }, + body: { + size: 0, + query: { + bool: { + filter: [{ term: { [SERVICE_NAME]: serviceName } }], + }, + }, + aggs: { + agent_names: { + terms: { field: AGENT_NAME, size: 1 }, + }, }, }, - }, - }; + }; - const { aggregations } = await apmEventClient.search(params); - const agentName = aggregations?.agent_names.buckets[0]?.key; - return agentName as string | undefined; + const { aggregations } = await apmEventClient.search(params); + const agentName = aggregations?.agent_names.buckets[0]?.key; + return agentName as string | undefined; + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts index 01350db8ae4a40..4a32b3c3a370bd 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { withApmSpan } from '../../../../utils/with_apm_span'; import { Setup } from '../../../helpers/setup_request'; import { SERVICE_NAME, @@ -19,34 +20,36 @@ export async function getExistingEnvironmentsForService({ serviceName: string | undefined; setup: Setup; }) { - const { internalClient, indices, config } = setup; - const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; + return withApmSpan('get_existing_environments_for_service', async () => { + const { internalClient, indices, config } = setup; + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; - const bool = serviceName - ? { filter: [{ term: { [SERVICE_NAME]: serviceName } }] } - : { must_not: [{ exists: { field: SERVICE_NAME } }] }; + const bool = serviceName + ? { filter: [{ term: { [SERVICE_NAME]: serviceName } }] } + : { must_not: [{ exists: { field: SERVICE_NAME } }] }; - const params = { - index: indices.apmAgentConfigurationIndex, - body: { - size: 0, - query: { bool }, - aggs: { - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - missing: ALL_OPTION_VALUE, - size: maxServiceEnvironments, + const params = { + index: indices.apmAgentConfigurationIndex, + body: { + size: 0, + query: { bool }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + missing: ALL_OPTION_VALUE, + size: maxServiceEnvironments, + }, }, }, }, - }, - }; + }; - const resp = await internalClient.search(params); - const existingEnvironments = - resp.aggregations?.environments.buckets.map( - (bucket) => bucket.key as string - ) || []; - return existingEnvironments; + const resp = await internalClient.search(params); + const existingEnvironments = + resp.aggregations?.environments.buckets.map( + (bucket) => bucket.key as string + ) || []; + return existingEnvironments; + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts index 46fe4bbf9363fd..0ab56ac372706e 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { withApmSpan } from '../../../../utils/with_apm_span'; import { getAllEnvironments } from '../../../environments/get_all_environments'; import { Setup } from '../../../helpers/setup_request'; import { PromiseReturnType } from '../../../../../../observability/typings/common'; @@ -24,15 +25,17 @@ export async function getEnvironments({ setup: Setup; searchAggregatedTransactions: boolean; }) { - const [allEnvironments, existingEnvironments] = await Promise.all([ - getAllEnvironments({ serviceName, setup, searchAggregatedTransactions }), - getExistingEnvironmentsForService({ serviceName, setup }), - ]); + return withApmSpan('get_environments_for_agent_configuration', async () => { + const [allEnvironments, existingEnvironments] = await Promise.all([ + getAllEnvironments({ serviceName, setup, searchAggregatedTransactions }), + getExistingEnvironmentsForService({ serviceName, setup }), + ]); - return [ALL_OPTION_VALUE, ...allEnvironments].map((environment) => { - return { - name: environment, - alreadyConfigured: existingEnvironments.includes(environment), - }; + return [ALL_OPTION_VALUE, ...allEnvironments].map((environment) => { + return { + name: environment, + alreadyConfigured: existingEnvironments.includes(environment), + }; + }); }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts index 3f39268fc35318..9c56455f45902f 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts @@ -11,49 +11,52 @@ import { PromiseReturnType } from '../../../../../observability/typings/common'; import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; import { ALL_OPTION_VALUE } from '../../../../common/agent_configuration/all_option'; import { getProcessorEventForAggregatedTransactions } from '../../helpers/aggregated_transactions'; +import { withApmSpan } from '../../../utils/with_apm_span'; export type AgentConfigurationServicesAPIResponse = PromiseReturnType< typeof getServiceNames >; -export async function getServiceNames({ +export function getServiceNames({ setup, searchAggregatedTransactions, }: { setup: Setup; searchAggregatedTransactions: boolean; }) { - const { apmEventClient, config } = setup; - const maxServiceSelection = config['xpack.apm.maxServiceSelection']; + return withApmSpan('get_service_names_for_agent_config', async () => { + const { apmEventClient, config } = setup; + const maxServiceSelection = config['xpack.apm.maxServiceSelection']; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - timeout: '1ms', - size: 0, - aggs: { - services: { - terms: { - field: SERVICE_NAME, - size: maxServiceSelection, - min_doc_count: 0, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + body: { + timeout: '1ms', + size: 0, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + size: maxServiceSelection, + min_doc_count: 0, + }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); - const serviceNames = - resp.aggregations?.services.buckets - .map((bucket) => bucket.key as string) - .sort() || []; - return [ALL_OPTION_VALUE, ...serviceNames]; + const resp = await apmEventClient.search(params); + const serviceNames = + resp.aggregations?.services.buckets + .map((bucket) => bucket.key as string) + .sort() || []; + return [ALL_OPTION_VALUE, ...serviceNames]; + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts index 871a96f6f85f1e..adcfe88392dc8b 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts @@ -8,6 +8,7 @@ import { Setup } from '../../helpers/setup_request'; import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; import { convertConfigSettingsToString } from './convert_settings_to_string'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function listConfigurations({ setup }: { setup: Setup }) { const { internalClient, indices } = setup; @@ -17,7 +18,10 @@ export async function listConfigurations({ setup }: { setup: Setup }) { size: 200, }; - const resp = await internalClient.search(params); + const resp = await withApmSpan('list_agent_configurations', () => + internalClient.search(params) + ); + return resp.hits.hits .map(convertConfigSettingsToString) .map((hit) => hit._source); diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts index b5e35dde04a90f..2026742a936a49 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts @@ -8,6 +8,7 @@ import { Setup } from '../../helpers/setup_request'; import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; +// We're not wrapping this function with a span as it is not blocking the request export async function markAppliedByAgent({ id, body, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts index 546d1e562de1ca..0e7205c309e9fc 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts @@ -13,6 +13,7 @@ import { import { Setup } from '../../helpers/setup_request'; import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; import { convertConfigSettingsToString } from './convert_settings_to_string'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function searchConfigurations({ service, @@ -21,61 +22,67 @@ export async function searchConfigurations({ service: AgentConfiguration['service']; setup: Setup; }) { - const { internalClient, indices } = setup; + return withApmSpan('search_agent_configurations', async () => { + const { internalClient, indices } = setup; - // In the following `constant_score` is being used to disable IDF calculation (where frequency of a term influences scoring). - // Additionally a boost has been added to service.name to ensure it scores higher. - // If there is tie between a config with a matching service.name and a config with a matching environment, the config that matches service.name wins - const serviceNameFilter = service.name - ? [ - { - constant_score: { - filter: { term: { [SERVICE_NAME]: service.name } }, - boost: 2, + // In the following `constant_score` is being used to disable IDF calculation (where frequency of a term influences scoring). + // Additionally a boost has been added to service.name to ensure it scores higher. + // If there is tie between a config with a matching service.name and a config with a matching environment, the config that matches service.name wins + const serviceNameFilter = service.name + ? [ + { + constant_score: { + filter: { term: { [SERVICE_NAME]: service.name } }, + boost: 2, + }, }, - }, - ] - : []; + ] + : []; - const environmentFilter = service.environment - ? [ - { - constant_score: { - filter: { term: { [SERVICE_ENVIRONMENT]: service.environment } }, - boost: 1, + const environmentFilter = service.environment + ? [ + { + constant_score: { + filter: { term: { [SERVICE_ENVIRONMENT]: service.environment } }, + boost: 1, + }, }, - }, - ] - : []; + ] + : []; - const params = { - index: indices.apmAgentConfigurationIndex, - body: { - query: { - bool: { - minimum_should_match: 2, - should: [ - ...serviceNameFilter, - ...environmentFilter, - { bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } }, - { - bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] }, - }, - ], + const params = { + index: indices.apmAgentConfigurationIndex, + body: { + query: { + bool: { + minimum_should_match: 2, + should: [ + ...serviceNameFilter, + ...environmentFilter, + { bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } }, + { + bool: { + must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }], + }, + }, + ], + }, }, }, - }, - }; + }; - const resp = await internalClient.search( - params - ); + const resp = await internalClient.search( + params + ); - const hit = resp.hits.hits[0] as ESSearchHit | undefined; + const hit = resp.hits.hits[0] as + | ESSearchHit + | undefined; - if (!hit) { - return; - } + if (!hit) { + return; + } - return convertConfigSettingsToString(hit); + return convertConfigSettingsToString(hit); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts index 38075fb818f9e6..a1587611b0a2a9 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts @@ -15,6 +15,7 @@ import { } from '../../../../common/apm_saved_object_constants'; import { APMConfig } from '../../..'; import { APMRequestHandlerContext } from '../../../routes/typings'; +import { withApmSpan } from '../../../utils/with_apm_span'; type ISavedObjectsClient = Pick; @@ -36,9 +37,11 @@ export type ApmIndicesName = keyof ApmIndicesConfig; async function getApmIndicesSavedObject( savedObjectsClient: ISavedObjectsClient ) { - const apmIndices = await savedObjectsClient.get>( - APM_INDICES_SAVED_OBJECT_TYPE, - APM_INDICES_SAVED_OBJECT_ID + const apmIndices = await withApmSpan('get_apm_indices_saved_object', () => + savedObjectsClient.get>( + APM_INDICES_SAVED_OBJECT_TYPE, + APM_INDICES_SAVED_OBJECT_ID + ) ); return apmIndices.attributes; } diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/save_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/save_apm_indices.ts index 3f346891138c4b..14a5830d8246cc 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/save_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/save_apm_indices.ts @@ -10,19 +10,22 @@ import { APM_INDICES_SAVED_OBJECT_TYPE, APM_INDICES_SAVED_OBJECT_ID, } from '../../../../common/apm_saved_object_constants'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { ApmIndicesConfig } from './get_apm_indices'; -export async function saveApmIndices( +export function saveApmIndices( savedObjectsClient: SavedObjectsClientContract, apmIndices: Partial ) { - return await savedObjectsClient.create( - APM_INDICES_SAVED_OBJECT_TYPE, - removeEmpty(apmIndices), - { - id: APM_INDICES_SAVED_OBJECT_ID, - overwrite: true, - } + return withApmSpan('save_apm_indices', () => + savedObjectsClient.create( + APM_INDICES_SAVED_OBJECT_TYPE, + removeEmpty(apmIndices), + { + id: APM_INDICES_SAVED_OBJECT_ID, + overwrite: true, + } + ) ); } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts index b24124565b1811..7e546fb5550360 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts @@ -12,8 +12,9 @@ import { import { Setup } from '../../helpers/setup_request'; import { toESFormat } from './helper'; import { APMIndexDocumentParams } from '../../helpers/create_es_client/create_internal_es_client'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function createOrUpdateCustomLink({ +export function createOrUpdateCustomLink({ customLinkId, customLink, setup, @@ -22,21 +23,23 @@ export async function createOrUpdateCustomLink({ customLink: Omit; setup: Setup; }) { - const { internalClient, indices } = setup; + return withApmSpan('create_or_update_custom_link', () => { + const { internalClient, indices } = setup; - const params: APMIndexDocumentParams = { - refresh: true, - index: indices.apmCustomLinkIndex, - body: { - '@timestamp': Date.now(), - ...toESFormat(customLink), - }, - }; + const params: APMIndexDocumentParams = { + refresh: true, + index: indices.apmCustomLinkIndex, + body: { + '@timestamp': Date.now(), + ...toESFormat(customLink), + }, + }; - // by specifying an id elasticsearch will delete the previous doc and insert the updated doc - if (customLinkId) { - params.id = customLinkId; - } + // by specifying an id elasticsearch will delete the previous doc and insert the updated doc + if (customLinkId) { + params.id = customLinkId; + } - return internalClient.index(params); + return internalClient.index(params); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts index 64eba026770e59..7c88bcc43cc7f7 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts @@ -5,22 +5,25 @@ * 2.0. */ +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; -export async function deleteCustomLink({ +export function deleteCustomLink({ customLinkId, setup, }: { customLinkId: string; setup: Setup; }) { - const { internalClient, indices } = setup; + return withApmSpan('delete_custom_link', () => { + const { internalClient, indices } = setup; - const params = { - refresh: 'wait_for' as const, - index: indices.apmCustomLinkIndex, - id: customLinkId, - }; + const params = { + refresh: 'wait_for' as const, + index: indices.apmCustomLinkIndex, + id: customLinkId, + }; - return internalClient.delete(params); + return internalClient.delete(params); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts index 679b6b62776e03..8e343ecfe6a642 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts @@ -10,40 +10,43 @@ import { Setup } from '../../helpers/setup_request'; import { ProcessorEvent } from '../../../../common/processor_event'; import { filterOptionsRt } from './custom_link_types'; import { splitFilterValueByComma } from './helper'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function getTransaction({ +export function getTransaction({ setup, filters = {}, }: { setup: Setup; filters?: t.TypeOf; }) { - const { apmEventClient } = setup; + return withApmSpan('get_transaction_for_custom_link', async () => { + const { apmEventClient } = setup; - const esFilters = Object.entries(filters) - // loops through the filters splitting the value by comma and removing white spaces - .map(([key, value]) => { - if (value) { - return { terms: { [key]: splitFilterValueByComma(value) } }; - } - }) - // removes filters without value - .filter((value) => value); + const esFilters = Object.entries(filters) + // loops through the filters splitting the value by comma and removing white spaces + .map(([key, value]) => { + if (value) { + return { terms: { [key]: splitFilterValueByComma(value) } }; + } + }) + // removes filters without value + .filter((value) => value); - const params = { - terminateAfter: 1, - apm: { - events: [ProcessorEvent.transaction as const], - }, - size: 1, - body: { - query: { - bool: { - filter: esFilters, + const params = { + terminateAfter: 1, + apm: { + events: [ProcessorEvent.transaction as const], + }, + size: 1, + body: { + query: { + bool: { + filter: esFilters, + }, }, }, - }, - }; - const resp = await apmEventClient.search(params); - return resp.hits.hits[0]?._source; + }; + const resp = await apmEventClient.search(params); + return resp.hits.hits[0]?._source; + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts index 3d8a003155455d..7437b8328b876f 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts @@ -13,51 +13,54 @@ import { import { Setup } from '../../helpers/setup_request'; import { fromESFormat } from './helper'; import { filterOptionsRt } from './custom_link_types'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function listCustomLinks({ +export function listCustomLinks({ setup, filters = {}, }: { setup: Setup; filters?: t.TypeOf; }): Promise { - const { internalClient, indices } = setup; - const esFilters = Object.entries(filters).map(([key, value]) => { - return { - bool: { - minimum_should_match: 1, - should: [ - { term: { [key]: value } }, - { bool: { must_not: [{ exists: { field: key } }] } }, - ], - }, - }; - }); - - const params = { - index: indices.apmCustomLinkIndex, - size: 500, - body: { - query: { + return withApmSpan('list_custom_links', async () => { + const { internalClient, indices } = setup; + const esFilters = Object.entries(filters).map(([key, value]) => { + return { bool: { - filter: esFilters, + minimum_should_match: 1, + should: [ + { term: { [key]: value } }, + { bool: { must_not: [{ exists: { field: key } }] } }, + ], }, - }, - sort: [ - { - 'label.keyword': { - order: 'asc', + }; + }); + + const params = { + index: indices.apmCustomLinkIndex, + size: 500, + body: { + query: { + bool: { + filter: esFilters, }, }, - ], - }, - }; - const resp = await internalClient.search(params); - const customLinks = resp.hits.hits.map((item) => - fromESFormat({ - id: item._id, - ...item._source, - }) - ); - return customLinks; + sort: [ + { + 'label.keyword': { + order: 'asc', + }, + }, + ], + }, + }; + const resp = await internalClient.search(params); + const customLinks = resp.hits.hits.map((item) => + fromESFormat({ + id: item._id, + ...item._source, + }) + ); + return customLinks; + }); } diff --git a/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts b/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts index de2136b0c8b5af..c9769b6143c951 100644 --- a/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts +++ b/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts @@ -18,6 +18,7 @@ import { APMError } from '../../../typings/es_schemas/ui/apm_error'; import { rangeFilter } from '../../../common/utils/range_filter'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { PromiseValueType } from '../../../typings/common'; +import { withApmSpan } from '../../utils/with_apm_span'; interface ErrorsPerTransaction { [transactionId: string]: number; @@ -27,94 +28,103 @@ export async function getTraceItems( traceId: string, setup: Setup & SetupTimeRange ) { - const { start, end, apmEventClient, config } = setup; - const maxTraceItems = config['xpack.apm.ui.maxTraceItems']; - const excludedLogLevels = ['debug', 'info', 'warning']; + return withApmSpan('get_trace_items', async () => { + const { start, end, apmEventClient, config } = setup; + const maxTraceItems = config['xpack.apm.ui.maxTraceItems']; + const excludedLogLevels = ['debug', 'info', 'warning']; - const errorResponsePromise = apmEventClient.search({ - apm: { - events: [ProcessorEvent.error], - }, - body: { - size: maxTraceItems, - query: { - bool: { - filter: [ - { term: { [TRACE_ID]: traceId } }, - { range: rangeFilter(start, end) }, - ], - must_not: { terms: { [ERROR_LOG_LEVEL]: excludedLogLevels } }, + const errorResponsePromise = withApmSpan('get_trace_error_items', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.error], }, - }, - aggs: { - by_transaction_id: { - terms: { - field: TRANSACTION_ID, - size: maxTraceItems, - // high cardinality - execution_hint: 'map' as const, + body: { + size: maxTraceItems, + query: { + bool: { + filter: [ + { term: { [TRACE_ID]: traceId } }, + { range: rangeFilter(start, end) }, + ], + must_not: { terms: { [ERROR_LOG_LEVEL]: excludedLogLevels } }, + }, + }, + aggs: { + by_transaction_id: { + terms: { + field: TRANSACTION_ID, + size: maxTraceItems, + // high cardinality + execution_hint: 'map' as const, + }, + }, }, }, - }, - }, - }); + }) + ); - const traceResponsePromise = apmEventClient.search({ - apm: { - events: [ProcessorEvent.span, ProcessorEvent.transaction], - }, - body: { - size: maxTraceItems, - query: { - bool: { - filter: [ - { term: { [TRACE_ID]: traceId } }, - { range: rangeFilter(start, end) }, - ], - should: { - exists: { field: PARENT_ID }, + const traceResponsePromise = withApmSpan('get_trace_span_items', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.span, ProcessorEvent.transaction], + }, + body: { + size: maxTraceItems, + query: { + bool: { + filter: [ + { term: { [TRACE_ID]: traceId } }, + { range: rangeFilter(start, end) }, + ], + should: { + exists: { field: PARENT_ID }, + }, + }, }, + sort: [ + { _score: { order: 'asc' as const } }, + { [TRANSACTION_DURATION]: { order: 'desc' as const } }, + { [SPAN_DURATION]: { order: 'desc' as const } }, + ], + track_total_hits: true, }, - }, - sort: [ - { _score: { order: 'asc' as const } }, - { [TRANSACTION_DURATION]: { order: 'desc' as const } }, - { [SPAN_DURATION]: { order: 'desc' as const } }, - ], - track_total_hits: true, - }, - }); + }) + ); - const [errorResponse, traceResponse]: [ - // explicit intermediary types to avoid TS "excessively deep" error - PromiseValueType, - PromiseValueType - ] = (await Promise.all([errorResponsePromise, traceResponsePromise])) as any; + const [errorResponse, traceResponse]: [ + // explicit intermediary types to avoid TS "excessively deep" error + PromiseValueType, + PromiseValueType + ] = (await Promise.all([ + errorResponsePromise, + traceResponsePromise, + ])) as any; - const exceedsMax = traceResponse.hits.total.value > maxTraceItems; + const exceedsMax = traceResponse.hits.total.value > maxTraceItems; - const items = traceResponse.hits.hits.map((hit) => hit._source); + const items = traceResponse.hits.hits.map((hit) => hit._source); - const errorFrequencies: { - errorsPerTransaction: ErrorsPerTransaction; - errorDocs: APMError[]; - } = { - errorDocs: errorResponse.hits.hits.map(({ _source }) => _source), - errorsPerTransaction: - errorResponse.aggregations?.by_transaction_id.buckets.reduce( - (acc, current) => { - return { - ...acc, - [current.key]: current.doc_count, - }; - }, - {} as ErrorsPerTransaction - ) ?? {}, - }; + const errorFrequencies: { + errorsPerTransaction: ErrorsPerTransaction; + errorDocs: APMError[]; + } = { + errorDocs: errorResponse.hits.hits.map(({ _source }) => _source), + errorsPerTransaction: + errorResponse.aggregations?.by_transaction_id.buckets.reduce( + (acc, current) => { + return { + ...acc, + [current.key]: current.doc_count, + }; + }, + {} as ErrorsPerTransaction + ) ?? {}, + }; - return { - items, - exceedsMax, - ...errorFrequencies, - }; + return { + items, + exceedsMax, + ...errorFrequencies, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index 0d0f376f493533..46a55d9004aba0 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -17,6 +17,7 @@ import { import { joinByKey } from '../../../common/utils/join_by_key'; import { getTransactionGroupsProjection } from '../../projections/transaction_groups'; import { mergeProjection } from '../../projections/util/merge_projection'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getAverages, @@ -95,115 +96,121 @@ function getItemsWithRelativeImpact( return itemsWithRelativeImpact; } -export async function transactionGroupsFetcher( +export function transactionGroupsFetcher( options: Options, setup: TransactionGroupSetup, bucketSize: number ) { - const projection = getTransactionGroupsProjection({ - setup, - options, - }); - - const isTopTraces = options.type === 'top_traces'; - - // @ts-expect-error - delete projection.body.aggs; - - // traces overview is hardcoded to 10000 - // transactions overview: 1 extra bucket is added to check whether the total number of buckets exceed the specified bucket size. - const expectedBucketSize = isTopTraces ? 10000 : bucketSize; - const size = isTopTraces ? 10000 : expectedBucketSize + 1; - - const request = mergeProjection(projection, { - body: { - size: 0, - aggs: { - transaction_groups: { - ...(isTopTraces - ? { - composite: { - sources: [ - { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, - { - [TRANSACTION_NAME]: { - terms: { field: TRANSACTION_NAME }, + const spanName = + options.type === 'top_traces' ? 'get_top_traces' : 'get_top_transactions'; + + return withApmSpan(spanName, async () => { + const projection = getTransactionGroupsProjection({ + setup, + options, + }); + + const isTopTraces = options.type === 'top_traces'; + + // @ts-expect-error + delete projection.body.aggs; + + // traces overview is hardcoded to 10000 + // transactions overview: 1 extra bucket is added to check whether the total number of buckets exceed the specified bucket size. + const expectedBucketSize = isTopTraces ? 10000 : bucketSize; + const size = isTopTraces ? 10000 : expectedBucketSize + 1; + + const request = mergeProjection(projection, { + body: { + size: 0, + aggs: { + transaction_groups: { + ...(isTopTraces + ? { + composite: { + sources: [ + { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, + { + [TRANSACTION_NAME]: { + terms: { field: TRANSACTION_NAME }, + }, }, - }, - ], - size, - }, - } - : { - terms: { - field: TRANSACTION_NAME, - size, - }, - }), + ], + size, + }, + } + : { + terms: { + field: TRANSACTION_NAME, + size, + }, + }), + }, }, }, - }, - }); + }); - const params = { - request, - setup, - searchAggregatedTransactions: options.searchAggregatedTransactions, - }; + const params = { + request, + setup, + searchAggregatedTransactions: options.searchAggregatedTransactions, + }; - const [counts, averages, sums, percentiles] = await Promise.all([ - getCounts(params), - getAverages(params), - getSums(params), - !isTopTraces ? getPercentiles(params) : Promise.resolve(undefined), - ]); - - const stats = [ - ...averages, - ...counts, - ...sums, - ...(percentiles ? percentiles : []), - ]; - - const items = joinByKey(stats, 'key'); - - const itemsWithRelativeImpact = getItemsWithRelativeImpact(setup, items); - - const defaultServiceName = - options.type === 'top_transactions' ? options.serviceName : undefined; - - const itemsWithKeys: TransactionGroup[] = itemsWithRelativeImpact.map( - (item) => { - let transactionName: string; - let serviceName: string; - - if (typeof item.key === 'string') { - transactionName = item.key; - serviceName = defaultServiceName!; - } else { - transactionName = item.key[TRANSACTION_NAME]; - serviceName = item.key[SERVICE_NAME]; + const [counts, averages, sums, percentiles] = await Promise.all([ + getCounts(params), + getAverages(params), + getSums(params), + !isTopTraces ? getPercentiles(params) : Promise.resolve(undefined), + ]); + + const stats = [ + ...averages, + ...counts, + ...sums, + ...(percentiles ? percentiles : []), + ]; + + const items = joinByKey(stats, 'key'); + + const itemsWithRelativeImpact = getItemsWithRelativeImpact(setup, items); + + const defaultServiceName = + options.type === 'top_transactions' ? options.serviceName : undefined; + + const itemsWithKeys: TransactionGroup[] = itemsWithRelativeImpact.map( + (item) => { + let transactionName: string; + let serviceName: string; + + if (typeof item.key === 'string') { + transactionName = item.key; + serviceName = defaultServiceName!; + } else { + transactionName = item.key[TRANSACTION_NAME]; + serviceName = item.key[SERVICE_NAME]; + } + + return { + ...item, + transactionName, + serviceName, + }; } + ); - return { - ...item, - transactionName, - serviceName, - }; - } - ); - - return { - items: take( - // sort by impact by default so most impactful services are not cut off - sortBy(itemsWithKeys, 'impact').reverse(), - bucketSize - ), - // The aggregation is considered accurate if the configured bucket size is larger or equal to the number of buckets returned - // the actual number of buckets retrieved are `bucketsize + 1` to detect whether it's above the limit - isAggregationAccurate: expectedBucketSize >= itemsWithRelativeImpact.length, - bucketSize, - }; + return { + items: take( + // sort by impact by default so most impactful services are not cut off + sortBy(itemsWithKeys, 'impact').reverse(), + bucketSize + ), + // The aggregation is considered accurate if the configured bucket size is larger or equal to the number of buckets returned + // the actual number of buckets retrieved are `bucketsize + 1` to detect whether it's above the limit + isAggregationAccurate: + expectedBucketSize >= itemsWithRelativeImpact.length, + bucketSize, + }; + }); } interface TransactionGroup { diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index bd0edfcf9e9e57..839efc9009c389 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -26,6 +26,7 @@ import { getOutcomeAggregation, getTransactionErrorRateTimeSeries, } from '../helpers/transaction_error_rate'; +import { withApmSpan } from '../../utils/with_apm_span'; export async function getErrorRate({ serviceName, @@ -44,74 +45,78 @@ export async function getErrorRate({ transactionErrorRate: Coordinate[]; average: number | null; }> { - const { start, end, esFilter, apmEventClient } = setup; + return withApmSpan('get_transaction_group_error_rate', async () => { + const { start, end, esFilter, apmEventClient } = setup; - const transactionNamefilter = transactionName - ? [{ term: { [TRANSACTION_NAME]: transactionName } }] - : []; - const transactionTypefilter = transactionType - ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] - : []; + const transactionNamefilter = transactionName + ? [{ term: { [TRANSACTION_NAME]: transactionName } }] + : []; + const transactionTypefilter = transactionType + ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] + : []; - const filter = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - { - terms: { [EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success] }, - }, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ...transactionNamefilter, - ...transactionTypefilter, - ...esFilter, - ]; + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + { range: rangeFilter(start, end) }, + { + terms: { + [EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success], + }, + }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...transactionNamefilter, + ...transactionTypefilter, + ...esFilter, + ]; - const outcomes = getOutcomeAggregation(); + const outcomes = getOutcomeAggregation(); - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { bool: { filter } }, - aggs: { - outcomes, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: getBucketSize({ start, end }).intervalString, - min_doc_count: 0, - extended_bounds: { min: start, max: end }, - }, - aggs: { - outcomes, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { bool: { filter } }, + aggs: { + outcomes, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize({ start, end }).intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: { + outcomes, + }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); + const resp = await apmEventClient.search(params); - const noHits = resp.hits.total.value === 0; + const noHits = resp.hits.total.value === 0; - if (!resp.aggregations) { - return { noHits, transactionErrorRate: [], average: null }; - } + if (!resp.aggregations) { + return { noHits, transactionErrorRate: [], average: null }; + } - const transactionErrorRate = getTransactionErrorRateTimeSeries( - resp.aggregations.timeseries.buckets - ); + const transactionErrorRate = getTransactionErrorRateTimeSeries( + resp.aggregations.timeseries.buckets + ); - const average = calculateTransactionErrorPercentage( - resp.aggregations.outcomes - ); + const average = calculateTransactionErrorPercentage( + resp.aggregations.outcomes + ); - return { noHits, transactionErrorRate, average }; + return { noHits, transactionErrorRate, average }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts index 5c740fc0db686c..ee19a6a8d15918 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts @@ -11,6 +11,7 @@ import { arrayUnionToCallable } from '../../../common/utils/array_union_to_calla import { AggregationInputMap } from '../../../../../typings/elasticsearch'; import { TransactionGroupRequestBase, TransactionGroupSetup } from './fetcher'; import { getTransactionDurationFieldForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { withApmSpan } from '../../utils/with_apm_span'; interface MetricParams { request: TransactionGroupRequestBase; @@ -35,116 +36,120 @@ function mergeRequestWithAggs< }); } -export async function getAverages({ +export function getAverages({ request, setup, searchAggregatedTransactions, }: MetricParams) { - const params = mergeRequestWithAggs(request, { - avg: { + return withApmSpan('get_avg_transaction_group_duration', async () => { + const params = mergeRequestWithAggs(request, { avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), + avg: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, }, - }, - }); - - const response = await setup.apmEventClient.search(params); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - return { - key: bucket.key as BucketKey, - avg: bucket.avg.value, - }; + }); + + const response = await setup.apmEventClient.search(params); + + return arrayUnionToCallable( + response.aggregations?.transaction_groups.buckets ?? [] + ).map((bucket) => { + return { + key: bucket.key as BucketKey, + avg: bucket.avg.value, + }; + }); }); } -export async function getCounts({ - request, - setup, - searchAggregatedTransactions, -}: MetricParams) { - const params = mergeRequestWithAggs(request, { - transaction_type: { - top_hits: { - size: 1, - _source: [TRANSACTION_TYPE], +export function getCounts({ request, setup }: MetricParams) { + return withApmSpan('get_transaction_group_transaction_count', async () => { + const params = mergeRequestWithAggs(request, { + transaction_type: { + top_hits: { + size: 1, + _source: [TRANSACTION_TYPE], + }, }, - }, - }); - - const response = await setup.apmEventClient.search(params); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - // type is Transaction | APMBaseDoc because it could be a metric document - const source = (bucket.transaction_type.hits.hits[0] - ._source as unknown) as { transaction: { type: string } }; - - return { - key: bucket.key as BucketKey, - count: bucket.doc_count, - transactionType: source.transaction.type, - }; + }); + + const response = await setup.apmEventClient.search(params); + + return arrayUnionToCallable( + response.aggregations?.transaction_groups.buckets ?? [] + ).map((bucket) => { + // type is Transaction | APMBaseDoc because it could be a metric document + const source = (bucket.transaction_type.hits.hits[0] + ._source as unknown) as { transaction: { type: string } }; + + return { + key: bucket.key as BucketKey, + count: bucket.doc_count, + transactionType: source.transaction.type, + }; + }); }); } -export async function getSums({ +export function getSums({ request, setup, searchAggregatedTransactions, }: MetricParams) { - const params = mergeRequestWithAggs(request, { - sum: { + return withApmSpan('get_transaction_group_latency_sums', async () => { + const params = mergeRequestWithAggs(request, { sum: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), + sum: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, }, - }, - }); - - const response = await setup.apmEventClient.search(params); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - return { - key: bucket.key as BucketKey, - sum: bucket.sum.value, - }; + }); + + const response = await setup.apmEventClient.search(params); + + return arrayUnionToCallable( + response.aggregations?.transaction_groups.buckets ?? [] + ).map((bucket) => { + return { + key: bucket.key as BucketKey, + sum: bucket.sum.value, + }; + }); }); } -export async function getPercentiles({ +export function getPercentiles({ request, setup, searchAggregatedTransactions, }: MetricParams) { - const params = mergeRequestWithAggs(request, { - p95: { - percentiles: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - hdr: { number_of_significant_value_digits: 2 }, - percents: [95], + return withApmSpan('get_transaction_group_latency_percentiles', async () => { + const params = mergeRequestWithAggs(request, { + p95: { + percentiles: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + hdr: { number_of_significant_value_digits: 2 }, + percents: [95], + }, }, - }, - }); - - const response = await setup.apmEventClient.search(params); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - return { - key: bucket.key as BucketKey, - p95: Object.values(bucket.p95.values)[0], - }; + }); + + const response = await setup.apmEventClient.search(params); + + return arrayUnionToCallable( + response.aggregations?.transaction_groups.buckets ?? [] + ).map((bucket) => { + return { + key: bucket.key as BucketKey, + p95: Object.values(bucket.p95.values)[0], + }; + }); }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts index 53669d745c5344..8a2579b4a2b875 100644 --- a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts @@ -22,8 +22,9 @@ import { rangeFilter } from '../../../../common/utils/range_filter'; import { getMetricsDateHistogramParams } from '../../helpers/metrics'; import { MAX_KPIS } from './constants'; import { getVizColorForIndex } from '../../../../common/viz_colors'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function getTransactionBreakdown({ +export function getTransactionBreakdown({ setup, serviceName, transactionName, @@ -34,193 +35,195 @@ export async function getTransactionBreakdown({ transactionName?: string; transactionType: string; }) { - const { esFilter, apmEventClient, start, end, config } = setup; + return withApmSpan('get_transaction_breakdown', async () => { + const { esFilter, apmEventClient, start, end, config } = setup; - const subAggs = { - sum_all_self_times: { - sum: { - field: SPAN_SELF_TIME_SUM, - }, - }, - total_transaction_breakdown_count: { - sum: { - field: TRANSACTION_BREAKDOWN_COUNT, + const subAggs = { + sum_all_self_times: { + sum: { + field: SPAN_SELF_TIME_SUM, + }, }, - }, - types: { - terms: { - field: SPAN_TYPE, - size: 20, - order: { - _count: 'desc' as const, + total_transaction_breakdown_count: { + sum: { + field: TRANSACTION_BREAKDOWN_COUNT, }, }, - aggs: { - subtypes: { - terms: { - field: SPAN_SUBTYPE, - missing: '', - size: 20, - order: { - _count: 'desc' as const, - }, + types: { + terms: { + field: SPAN_TYPE, + size: 20, + order: { + _count: 'desc' as const, }, - aggs: { - total_self_time_per_subtype: { - sum: { - field: SPAN_SELF_TIME_SUM, + }, + aggs: { + subtypes: { + terms: { + field: SPAN_SUBTYPE, + missing: '', + size: 20, + order: { + _count: 'desc' as const, + }, + }, + aggs: { + total_self_time_per_subtype: { + sum: { + field: SPAN_SELF_TIME_SUM, + }, }, }, }, }, }, - }, - }; - - const filters = [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ]; - - if (transactionName) { - filters.push({ term: { [TRANSACTION_NAME]: transactionName } }); - } - - const params = { - apm: { - events: [ProcessorEvent.metric], - }, - body: { - size: 0, - query: { - bool: { - filter: filters, - }, + }; + + const filters = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { range: rangeFilter(start, end) }, + ...esFilter, + ]; + + if (transactionName) { + filters.push({ term: { [TRANSACTION_NAME]: transactionName } }); + } + + const params = { + apm: { + events: [ProcessorEvent.metric], }, - aggs: { - ...subAggs, - by_date: { - date_histogram: getMetricsDateHistogramParams( - start, - end, - config['xpack.apm.metricsInterval'] - ), - aggs: subAggs, + body: { + size: 0, + query: { + bool: { + filter: filters, + }, + }, + aggs: { + ...subAggs, + by_date: { + date_histogram: getMetricsDateHistogramParams( + start, + end, + config['xpack.apm.metricsInterval'] + ), + aggs: subAggs, + }, }, }, - }, - }; - - const resp = await apmEventClient.search(params); - - const formatBucket = ( - aggs: - | Required['aggregations'] - | Required['aggregations']['by_date']['buckets'][0] - ) => { - const sumAllSelfTimes = aggs.sum_all_self_times.value || 0; - - const breakdowns = flatten( - aggs.types.buckets.map((bucket) => { - const type = bucket.key as string; - - return bucket.subtypes.buckets.map((subBucket) => { - return { - name: (subBucket.key as string) || type, - percentage: - (subBucket.total_self_time_per_subtype.value || 0) / - sumAllSelfTimes, - }; - }); - }) - ); - - return breakdowns; - }; - - const visibleKpis = resp.aggregations - ? orderBy(formatBucket(resp.aggregations), 'percentage', 'desc').slice( - 0, - MAX_KPIS - ) - : []; - - const kpis = orderBy( - visibleKpis.map((kpi) => ({ - ...kpi, - lowerCaseName: kpi.name.toLowerCase(), - })), - 'lowerCaseName' - ).map((kpi, index) => { - const { lowerCaseName, ...rest } = kpi; - return { - ...rest, - color: getVizColorForIndex(index), }; - }); - const kpiNames = kpis.map((kpi) => kpi.name); + const resp = await apmEventClient.search(params); + + const formatBucket = ( + aggs: + | Required['aggregations'] + | Required['aggregations']['by_date']['buckets'][0] + ) => { + const sumAllSelfTimes = aggs.sum_all_self_times.value || 0; + + const breakdowns = flatten( + aggs.types.buckets.map((bucket) => { + const type = bucket.key as string; + + return bucket.subtypes.buckets.map((subBucket) => { + return { + name: (subBucket.key as string) || type, + percentage: + (subBucket.total_self_time_per_subtype.value || 0) / + sumAllSelfTimes, + }; + }); + }) + ); + + return breakdowns; + }; - const bucketsByDate = resp.aggregations?.by_date.buckets || []; + const visibleKpis = resp.aggregations + ? orderBy(formatBucket(resp.aggregations), 'percentage', 'desc').slice( + 0, + MAX_KPIS + ) + : []; + + const kpis = orderBy( + visibleKpis.map((kpi) => ({ + ...kpi, + lowerCaseName: kpi.name.toLowerCase(), + })), + 'lowerCaseName' + ).map((kpi, index) => { + const { lowerCaseName, ...rest } = kpi; + return { + ...rest, + color: getVizColorForIndex(index), + }; + }); - const timeseriesPerSubtype = bucketsByDate.reduce((prev, bucket) => { - const formattedValues = formatBucket(bucket); - const time = bucket.key; + const kpiNames = kpis.map((kpi) => kpi.name); - const updatedSeries = kpiNames.reduce((p, kpiName) => { - const { name, percentage } = formattedValues.find( - (val) => val.name === kpiName - ) || { - name: kpiName, - percentage: null, - }; + const bucketsByDate = resp.aggregations?.by_date.buckets || []; - if (!p[name]) { - p[name] = []; - } - return { - ...p, - [name]: p[name].concat({ - x: time, - y: percentage, - }), - }; - }, prev); - - const lastValues = Object.values(updatedSeries).map(last); - - // If for a given timestamp, some series have data, but others do not, - // we have to set any null values to 0 to make sure the stacked area chart - // is drawn correctly. - // If we set all values to 0, the chart always displays null values as 0, - // and the chart looks weird. - const hasAnyValues = lastValues.some((value) => value?.y !== null); - const hasNullValues = lastValues.some((value) => value?.y === null); - - if (hasAnyValues && hasNullValues) { - Object.values(updatedSeries).forEach((series) => { - const value = series[series.length - 1]; - const isEmpty = value.y === null; - if (isEmpty) { - // local mutation to prevent complicated map/reduce calls - value.y = 0; + const timeseriesPerSubtype = bucketsByDate.reduce((prev, bucket) => { + const formattedValues = formatBucket(bucket); + const time = bucket.key; + + const updatedSeries = kpiNames.reduce((p, kpiName) => { + const { name, percentage } = formattedValues.find( + (val) => val.name === kpiName + ) || { + name: kpiName, + percentage: null, + }; + + if (!p[name]) { + p[name] = []; } - }); - } + return { + ...p, + [name]: p[name].concat({ + x: time, + y: percentage, + }), + }; + }, prev); + + const lastValues = Object.values(updatedSeries).map(last); + + // If for a given timestamp, some series have data, but others do not, + // we have to set any null values to 0 to make sure the stacked area chart + // is drawn correctly. + // If we set all values to 0, the chart always displays null values as 0, + // and the chart looks weird. + const hasAnyValues = lastValues.some((value) => value?.y !== null); + const hasNullValues = lastValues.some((value) => value?.y === null); + + if (hasAnyValues && hasNullValues) { + Object.values(updatedSeries).forEach((series) => { + const value = series[series.length - 1]; + const isEmpty = value.y === null; + if (isEmpty) { + // local mutation to prevent complicated map/reduce calls + value.y = 0; + } + }); + } - return updatedSeries; - }, {} as Record>); + return updatedSeries; + }, {} as Record>); - const timeseries = kpis.map((kpi) => ({ - title: kpi.name, - color: kpi.color, - type: 'areaStacked', - data: timeseriesPerSubtype[kpi.name], - hideLegend: false, - legendValue: asPercent(kpi.percentage, 1), - })); + const timeseries = kpis.map((kpi) => ({ + title: kpi.name, + color: kpi.color, + type: 'areaStacked', + data: timeseriesPerSubtype[kpi.name], + hideLegend: false, + legendValue: asPercent(kpi.percentage, 1), + })); - return { timeseries }; + return { timeseries }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts index 86621c7aaa7e2f..22a34ded4c20d5 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { withApmSpan } from '../../../../utils/with_apm_span'; import { SERVICE_NAME, TRACE_ID, @@ -65,134 +66,147 @@ export async function getBuckets({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { start, end, esFilter, apmEventClient } = setup; + return withApmSpan( + 'get_latency_distribution_buckets_with_samples', + async () => { + const { start, end, esFilter, apmEventClient } = setup; - const commonFilters = [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { term: { [TRANSACTION_NAME]: transactionName } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ]; + const commonFilters = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { term: { [TRANSACTION_NAME]: transactionName } }, + { range: rangeFilter(start, end) }, + ...esFilter, + ]; - async function getSamplesForDistributionBuckets() { - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - query: { - bool: { - filter: [ - ...commonFilters, - { term: { [TRANSACTION_SAMPLED]: true } }, - ], - should: [ - { term: { [TRACE_ID]: traceId } }, - { term: { [TRANSACTION_ID]: transactionId } }, - ], - }, - }, - aggs: { - distribution: { - histogram: getHistogramAggOptions({ - bucketSize, - field: TRANSACTION_DURATION, - distributionMax, - }), - aggs: { - samples: { - top_hits: { - _source: [TRANSACTION_ID, TRACE_ID], - size: 10, - sort: { - _score: 'desc', + async function getSamplesForDistributionBuckets() { + const response = await withApmSpan( + 'get_samples_for_latency_distribution_buckets', + () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + query: { + bool: { + filter: [ + ...commonFilters, + { term: { [TRANSACTION_SAMPLED]: true } }, + ], + should: [ + { term: { [TRACE_ID]: traceId } }, + { term: { [TRANSACTION_ID]: transactionId } }, + ], + }, + }, + aggs: { + distribution: { + histogram: getHistogramAggOptions({ + bucketSize, + field: TRANSACTION_DURATION, + distributionMax, + }), + aggs: { + samples: { + top_hits: { + _source: [TRANSACTION_ID, TRACE_ID], + size: 10, + sort: { + _score: 'desc', + }, + }, + }, + }, }, }, }, - }, - }, - }, - }, - }); + }) + ); - return ( - response.aggregations?.distribution.buckets.map((bucket) => { - return { - key: bucket.key, - samples: bucket.samples.hits.hits.map((hit) => ({ - traceId: hit._source.trace.id, - transactionId: hit._source.transaction.id, - })), - }; - }) ?? [] - ); - } + return ( + response.aggregations?.distribution.buckets.map((bucket) => { + return { + key: bucket.key, + samples: bucket.samples.hits.hits.map((hit) => ({ + traceId: hit._source.trace.id, + transactionId: hit._source.transaction.id, + })), + }; + }) ?? [] + ); + } - async function getDistributionBuckets() { - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - query: { - bool: { - filter: [ - ...commonFilters, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - }, - aggs: { - distribution: { - histogram: getHistogramAggOptions({ - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - bucketSize, - distributionMax, - }), - }, - }, - }, - }); + async function getDistributionBuckets() { + const response = await withApmSpan( + 'get_latency_distribution_buckets', + () => + apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + query: { + bool: { + filter: [ + ...commonFilters, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + }, + aggs: { + distribution: { + histogram: getHistogramAggOptions({ + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + bucketSize, + distributionMax, + }), + }, + }, + }, + }) + ); - return ( - response.aggregations?.distribution.buckets.map((bucket) => { - return { - key: bucket.key, - count: bucket.doc_count, - }; - }) ?? [] - ); - } + return ( + response.aggregations?.distribution.buckets.map((bucket) => { + return { + key: bucket.key, + count: bucket.doc_count, + }; + }) ?? [] + ); + } - const [ - samplesForDistributionBuckets, - distributionBuckets, - ] = await Promise.all([ - getSamplesForDistributionBuckets(), - getDistributionBuckets(), - ]); + const [ + samplesForDistributionBuckets, + distributionBuckets, + ] = await Promise.all([ + getSamplesForDistributionBuckets(), + getDistributionBuckets(), + ]); - const buckets = joinByKey( - [...samplesForDistributionBuckets, ...distributionBuckets], - 'key' - ).map((bucket) => ({ - ...bucket, - samples: bucket.samples ?? [], - count: bucket.count ?? 0, - })); + const buckets = joinByKey( + [...samplesForDistributionBuckets, ...distributionBuckets], + 'key' + ).map((bucket) => ({ + ...bucket, + samples: bucket.samples ?? [], + count: bucket.count ?? 0, + })); - return { - noHits: buckets.length === 0, - bucketSize, - buckets, - }; + return { + noHits: buckets.length === 0, + bucketSize, + buckets, + }; + } + ); } diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts index 5a7502aaf5932b..ed54dae32704e3 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts @@ -15,6 +15,7 @@ import { getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getDistributionMax({ serviceName, @@ -29,49 +30,51 @@ export async function getDistributionMax({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { start, end, esFilter, apmEventClient } = setup; + return withApmSpan('get_latency_distribution_max', async () => { + const { start, end, esFilter, apmEventClient } = setup; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { term: { [TRANSACTION_NAME]: transactionName } }, - { - range: { - '@timestamp': { - gte: start, - lte: end, - format: 'epoch_millis', + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { term: { [TRANSACTION_NAME]: transactionName } }, + { + range: { + '@timestamp': { + gte: start, + lte: end, + format: 'epoch_millis', + }, }, }, - }, - ...esFilter, - ], + ...esFilter, + ], + }, }, - }, - aggs: { - stats: { - max: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), + aggs: { + stats: { + max: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); - return resp.aggregations?.stats.value ?? null; + const resp = await apmEventClient.search(params); + return resp.aggregations?.stats.value ?? null; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts index d1d2881b6cb5b5..22436cac40183d 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts @@ -10,6 +10,7 @@ import { getBuckets } from './get_buckets'; import { getDistributionMax } from './get_distribution_max'; import { roundToNearestFiveOrTen } from '../../helpers/round_to_nearest_five_or_ten'; import { MINIMUM_BUCKET_SIZE, BUCKET_TARGET_COUNT } from '../constants'; +import { withApmSpan } from '../../../utils/with_apm_span'; function getBucketSize(max: number) { const bucketSize = max / BUCKET_TARGET_COUNT; @@ -35,35 +36,37 @@ export async function getTransactionDistribution({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const distributionMax = await getDistributionMax({ - serviceName, - transactionName, - transactionType, - setup, - searchAggregatedTransactions, - }); + return withApmSpan('get_transaction_latency_distribution', async () => { + const distributionMax = await getDistributionMax({ + serviceName, + transactionName, + transactionType, + setup, + searchAggregatedTransactions, + }); - if (distributionMax == null) { - return { noHits: true, buckets: [], bucketSize: 0 }; - } + if (distributionMax == null) { + return { noHits: true, buckets: [], bucketSize: 0 }; + } - const bucketSize = getBucketSize(distributionMax); + const bucketSize = getBucketSize(distributionMax); - const { buckets, noHits } = await getBuckets({ - serviceName, - transactionName, - transactionType, - transactionId, - traceId, - distributionMax, - bucketSize, - setup, - searchAggregatedTransactions, - }); + const { buckets, noHits } = await getBuckets({ + serviceName, + transactionName, + transactionType, + transactionId, + traceId, + distributionMax, + bucketSize, + setup, + searchAggregatedTransactions, + }); - return { - noHits, - buckets, - bucketSize, - }; + return { + noHits, + buckets, + bucketSize, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts index 7dd9ba63bd6f51..002ddd1ec35f04 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts @@ -7,6 +7,7 @@ import { ESSearchResponse } from '../../../../../../typings/elasticsearch'; import { PromiseReturnType } from '../../../../../observability/typings/common'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; export type ESResponse = Exclude< @@ -14,7 +15,7 @@ export type ESResponse = Exclude< undefined >; -export async function anomalySeriesFetcher({ +export function anomalySeriesFetcher({ serviceName, transactionType, intervalString, @@ -29,63 +30,65 @@ export async function anomalySeriesFetcher({ start: number; end: number; }) { - const params = { - body: { - size: 0, - query: { - bool: { - filter: [ - { terms: { result_type: ['model_plot', 'record'] } }, - { term: { partition_field_value: serviceName } }, - { term: { by_field_value: transactionType } }, - { - range: { - timestamp: { - gte: start, - lte: end, - format: 'epoch_millis', + return withApmSpan('get_latency_anomaly_data', async () => { + const params = { + body: { + size: 0, + query: { + bool: { + filter: [ + { terms: { result_type: ['model_plot', 'record'] } }, + { term: { partition_field_value: serviceName } }, + { term: { by_field_value: transactionType } }, + { + range: { + timestamp: { + gte: start, + lte: end, + format: 'epoch_millis', + }, }, }, - }, - ], - }, - }, - aggs: { - job_id: { - terms: { - field: 'job_id', + ], }, - aggs: { - ml_avg_response_times: { - date_histogram: { - field: 'timestamp', - fixed_interval: intervalString, - extended_bounds: { min: start, max: end }, - }, - aggs: { - anomaly_score: { - top_metrics: { - metrics: [ - { field: 'record_score' }, - { field: 'timestamp' }, - { field: 'bucket_span' }, - ] as const, - sort: { - record_score: 'desc' as const, + }, + aggs: { + job_id: { + terms: { + field: 'job_id', + }, + aggs: { + ml_avg_response_times: { + date_histogram: { + field: 'timestamp', + fixed_interval: intervalString, + extended_bounds: { min: start, max: end }, + }, + aggs: { + anomaly_score: { + top_metrics: { + metrics: [ + { field: 'record_score' }, + { field: 'timestamp' }, + { field: 'bucket_span' }, + ] as const, + sort: { + record_score: 'desc' as const, + }, }, }, + lower: { min: { field: 'model_lower' } }, + upper: { max: { field: 'model_upper' } }, }, - lower: { min: { field: 'model_lower' } }, - upper: { max: { field: 'model_upper' } }, }, }, }, }, }, - }, - }; + }; - return (ml.mlSystem.mlAnomalySearch(params, []) as unknown) as Promise< - ESSearchResponse - >; + return (ml.mlSystem.mlAnomalySearch(params, []) as unknown) as Promise< + ESSearchResponse + >; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts index f3dae239d957d9..29dd562330cc15 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts @@ -15,6 +15,7 @@ import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { anomalySeriesFetcher } from './fetcher'; import { getMLJobIds } from '../../service_map/get_service_anomalies'; import { ANOMALY_THRESHOLD } from '../../../../../ml/common'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getAnomalySeries({ serviceName, @@ -59,74 +60,77 @@ export async function getAnomalySeries({ return undefined; } - const { intervalString } = getBucketSize({ start, end }); - - // move the start back with one bucket size, to ensure to get anomaly data in the beginning - // this is required because ML has a minimum bucket size (default is 900s) so if our buckets - // are smaller, we might have several null buckets in the beginning - const mlStart = start - 900 * 1000; - - const [anomaliesResponse, jobIds] = await Promise.all([ - anomalySeriesFetcher({ - serviceName, - transactionType, - intervalString, - ml, - start: mlStart, - end, - }), - getMLJobIds(ml.anomalyDetectors, environment), - ]); - - const scoreSeriesCollection = anomaliesResponse?.aggregations?.job_id.buckets - .filter((bucket) => jobIds.includes(bucket.key as string)) - .map((bucket) => { - const dateBuckets = bucket.ml_avg_response_times.buckets; - - return { - jobId: bucket.key as string, - anomalyScore: compact( - dateBuckets.map((dateBucket) => { - const metrics = maybe(dateBucket.anomaly_score.top[0])?.metrics; - const score = metrics?.record_score; - - if ( - !metrics || - !isFiniteNumber(score) || - score < ANOMALY_THRESHOLD.CRITICAL - ) { - return null; - } - - const anomalyStart = Date.parse(metrics.timestamp as string); - const anomalyEnd = - anomalyStart + (metrics.bucket_span as number) * 1000; - - return { - x0: anomalyStart, - x: anomalyEnd, - y: score, - }; - }) - ), - anomalyBoundaries: dateBuckets - .filter( - (dateBucket) => - dateBucket.lower.value !== null && dateBucket.upper.value !== null - ) - .map((dateBucket) => ({ - x: dateBucket.key, - y0: dateBucket.lower.value as number, - y: dateBucket.upper.value as number, - })), - }; - }); - - if ((scoreSeriesCollection?.length ?? 0) > 1) { - logger.warn( - `More than one ML job was found for ${serviceName} for environment ${environment}. Only showing results from ${scoreSeriesCollection?.[0].jobId}` - ); - } - - return scoreSeriesCollection?.[0]; + return withApmSpan('get_latency_anomaly_series', async () => { + const { intervalString } = getBucketSize({ start, end }); + + // move the start back with one bucket size, to ensure to get anomaly data in the beginning + // this is required because ML has a minimum bucket size (default is 900s) so if our buckets + // are smaller, we might have several null buckets in the beginning + const mlStart = start - 900 * 1000; + + const [anomaliesResponse, jobIds] = await Promise.all([ + anomalySeriesFetcher({ + serviceName, + transactionType, + intervalString, + ml, + start: mlStart, + end, + }), + getMLJobIds(ml.anomalyDetectors, environment), + ]); + + const scoreSeriesCollection = anomaliesResponse?.aggregations?.job_id.buckets + .filter((bucket) => jobIds.includes(bucket.key as string)) + .map((bucket) => { + const dateBuckets = bucket.ml_avg_response_times.buckets; + + return { + jobId: bucket.key as string, + anomalyScore: compact( + dateBuckets.map((dateBucket) => { + const metrics = maybe(dateBucket.anomaly_score.top[0])?.metrics; + const score = metrics?.record_score; + + if ( + !metrics || + !isFiniteNumber(score) || + score < ANOMALY_THRESHOLD.CRITICAL + ) { + return null; + } + + const anomalyStart = Date.parse(metrics.timestamp as string); + const anomalyEnd = + anomalyStart + (metrics.bucket_span as number) * 1000; + + return { + x0: anomalyStart, + x: anomalyEnd, + y: score, + }; + }) + ), + anomalyBoundaries: dateBuckets + .filter( + (dateBucket) => + dateBucket.lower.value !== null && + dateBucket.upper.value !== null + ) + .map((dateBucket) => ({ + x: dateBucket.key, + y0: dateBucket.lower.value as number, + y: dateBucket.upper.value as number, + })), + }; + }); + + if ((scoreSeriesCollection?.length ?? 0) > 1) { + logger.warn( + `More than one ML job was found for ${serviceName} for environment ${environment}. Only showing results from ${scoreSeriesCollection?.[0].jobId}` + ); + } + + return scoreSeriesCollection?.[0]; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts index f5dd645eacdbe1..ee27d00fdc0d4d 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts @@ -21,6 +21,7 @@ import { } from '../../../lib/helpers/aggregated_transactions'; import { getBucketSize } from '../../../lib/helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../../lib/helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { getLatencyAggregation, getLatencyValue, @@ -29,7 +30,7 @@ export type LatencyChartsSearchResponse = PromiseReturnType< typeof searchLatency >; -async function searchLatency({ +function searchLatency({ serviceName, transactionType, transactionName, @@ -100,7 +101,7 @@ async function searchLatency({ return apmEventClient.search(params); } -export async function getLatencyTimeseries({ +export function getLatencyTimeseries({ serviceName, transactionType, transactionName, @@ -115,32 +116,34 @@ export async function getLatencyTimeseries({ searchAggregatedTransactions: boolean; latencyAggregationType: LatencyAggregationType; }) { - const response = await searchLatency({ - serviceName, - transactionType, - transactionName, - setup, - searchAggregatedTransactions, - latencyAggregationType, - }); + return withApmSpan('get_latency_charts', async () => { + const response = await searchLatency({ + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + latencyAggregationType, + }); - if (!response.aggregations) { - return { latencyTimeseries: [], overallAvgDuration: null }; - } + if (!response.aggregations) { + return { latencyTimeseries: [], overallAvgDuration: null }; + } - return { - overallAvgDuration: - response.aggregations.overall_avg_duration.value || null, - latencyTimeseries: response.aggregations.latencyTimeseries.buckets.map( - (bucket) => { - return { - x: bucket.key, - y: getLatencyValue({ - latencyAggregationType, - aggregation: bucket.latency, - }), - }; - } - ), - }; + return { + overallAvgDuration: + response.aggregations.overall_avg_duration.value || null, + latencyTimeseries: response.aggregations.latencyTimeseries.buckets.map( + (bucket) => { + return { + x: bucket.key, + y: getLatencyValue({ + latencyAggregationType, + aggregation: bucket.latency, + }), + }; + } + ), + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts index 71d9c21c677820..7c1fa9c3a2368e 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts @@ -20,13 +20,14 @@ import { } from '../../../lib/helpers/aggregated_transactions'; import { getBucketSize } from '../../../lib/helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../../lib/helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { getThroughputBuckets } from './transform'; export type ThroughputChartsResponse = PromiseReturnType< typeof searchThroughput >; -async function searchThroughput({ +function searchThroughput({ serviceName, transactionType, transactionName, @@ -102,22 +103,24 @@ export async function getThroughputCharts({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { bucketSize, intervalString } = getBucketSize(setup); + return withApmSpan('get_transaction_throughput_series', async () => { + const { bucketSize, intervalString } = getBucketSize(setup); - const response = await searchThroughput({ - serviceName, - transactionType, - transactionName, - setup, - searchAggregatedTransactions, - intervalString, - }); + const response = await searchThroughput({ + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + intervalString, + }); - return { - throughputTimeseries: getThroughputBuckets({ - throughputResultBuckets: response.aggregations?.throughput.buckets, - bucketSize, - setupTimeRange: setup, - }), - }; + return { + throughputTimeseries: getThroughputBuckets({ + throughputResultBuckets: response.aggregations?.throughput.buckets, + bucketSize, + setupTimeRange: setup, + }), + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts index 119abd06bc7835..2fdb8e25fd9962 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts @@ -12,8 +12,9 @@ import { import { rangeFilter } from '../../../../common/utils/range_filter'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { ProcessorEvent } from '../../../../common/processor_event'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function getTransaction({ +export function getTransaction({ transactionId, traceId, setup, @@ -22,25 +23,27 @@ export async function getTransaction({ traceId: string; setup: Setup & SetupTimeRange; }) { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_transaction', async () => { + const { start, end, apmEventClient } = setup; - const resp = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - size: 1, - query: { - bool: { - filter: [ - { term: { [TRANSACTION_ID]: transactionId } }, - { term: { [TRACE_ID]: traceId } }, - { range: rangeFilter(start, end) }, - ], + const resp = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + size: 1, + query: { + bool: { + filter: [ + { term: { [TRANSACTION_ID]: transactionId } }, + { term: { [TRACE_ID]: traceId } }, + { range: rangeFilter(start, end) }, + ], + }, }, }, - }, - }); + }); - return resp.hits.hits[0]?._source; + return resp.hits.hits[0]?._source; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_transaction_by_trace/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_transaction_by_trace/index.ts index 4024c339b5a23b..dfdad2f59a848f 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_transaction_by_trace/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_transaction_by_trace/index.ts @@ -11,40 +11,40 @@ import { } from '../../../../common/elasticsearch_fieldnames'; import { Setup } from '../../helpers/setup_request'; import { ProcessorEvent } from '../../../../common/processor_event'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function getRootTransactionByTraceId( - traceId: string, - setup: Setup -) { - const { apmEventClient } = setup; +export function getRootTransactionByTraceId(traceId: string, setup: Setup) { + return withApmSpan('get_root_transaction_by_trace_id', async () => { + const { apmEventClient } = setup; - const params = { - apm: { - events: [ProcessorEvent.transaction as const], - }, - body: { - size: 1, - query: { - bool: { - should: [ - { - constant_score: { - filter: { - bool: { - must_not: { exists: { field: PARENT_ID } }, + const params = { + apm: { + events: [ProcessorEvent.transaction as const], + }, + body: { + size: 1, + query: { + bool: { + should: [ + { + constant_score: { + filter: { + bool: { + must_not: { exists: { field: PARENT_ID } }, + }, }, }, }, - }, - ], - filter: [{ term: { [TRACE_ID]: traceId } }], + ], + filter: [{ term: { [TRACE_ID]: traceId } }], + }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); - return { - transaction: resp.hits.hits[0]?._source, - }; + const resp = await apmEventClient.search(params); + return { + transaction: resp.hits.hits[0]?._source, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts b/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts index 1be16698a1a1f9..dc7def06259331 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts +++ b/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts @@ -13,6 +13,7 @@ import { import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; import { ProcessorEvent } from '../../../common/processor_event'; import { rangeFilter } from '../../../common/utils/range_filter'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; @@ -25,54 +26,60 @@ export async function getEnvironments({ serviceName?: string; searchAggregatedTransactions: boolean; }) { - const { start, end, apmEventClient, config } = setup; + const spanName = serviceName + ? 'get_environments_for_service' + : 'get_environments'; - const filter: ESFilter[] = [{ range: rangeFilter(start, end) }]; + return withApmSpan(spanName, async () => { + const { start, end, apmEventClient, config } = setup; - if (serviceName) { - filter.push({ - term: { [SERVICE_NAME]: serviceName }, - }); - } + const filter: ESFilter[] = [{ range: rangeFilter(start, end) }]; - const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; + if (serviceName) { + filter.push({ + term: { [SERVICE_NAME]: serviceName }, + }); + } - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.metric, - ProcessorEvent.error, - ], - }, - body: { - size: 0, - query: { - bool: { - filter, - }, + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; + + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.metric, + ProcessorEvent.error, + ], }, - aggs: { - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - missing: ENVIRONMENT_NOT_DEFINED.value, - size: maxServiceEnvironments, + body: { + size: 0, + query: { + bool: { + filter, + }, + }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + missing: ENVIRONMENT_NOT_DEFINED.value, + size: maxServiceEnvironments, + }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); - const aggs = resp.aggregations; - const environmentsBuckets = aggs?.environments.buckets || []; + const resp = await apmEventClient.search(params); + const aggs = resp.aggregations; + const environmentsBuckets = aggs?.environments.buckets || []; - const environments = environmentsBuckets.map( - (environmentBucket) => environmentBucket.key as string - ); + const environments = environmentsBuckets.map( + (environmentBucket) => environmentBucket.key as string + ); - return environments; + return environments; + }); } diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts index 12bfc7e23bf4cc..966f44158a7bbd 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts +++ b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts @@ -13,12 +13,13 @@ import { getLocalFilterQuery } from './get_local_filter_query'; import { Setup } from '../../helpers/setup_request'; import { localUIFilters } from './config'; import { LocalUIFilterName } from '../../../../common/ui_filter'; +import { withApmSpan } from '../../../utils/with_apm_span'; export type LocalUIFiltersAPIResponse = PromiseReturnType< typeof getLocalUIFilters >; -export async function getLocalUIFilters({ +export function getLocalUIFilters({ setup, projection, uiFilters, @@ -29,41 +30,45 @@ export async function getLocalUIFilters({ uiFilters: UIFilters; localFilterNames: LocalUIFilterName[]; }) { - const { apmEventClient } = setup; + return withApmSpan('get_ui_filter_options', () => { + const { apmEventClient } = setup; - const projectionWithoutAggs = cloneDeep(projection); + const projectionWithoutAggs = cloneDeep(projection); - delete projectionWithoutAggs.body.aggs; + delete projectionWithoutAggs.body.aggs; - return Promise.all( - localFilterNames.map(async (name) => { - const query = getLocalFilterQuery({ - uiFilters, - projection, - localUIFilterName: name, - }); + return Promise.all( + localFilterNames.map(async (name) => + withApmSpan('get_ui_filter_options_for_field', async () => { + const query = getLocalFilterQuery({ + uiFilters, + projection, + localUIFilterName: name, + }); - const response = await apmEventClient.search(query); + const response = await apmEventClient.search(query); - const filter = localUIFilters[name]; + const filter = localUIFilters[name]; - const buckets = response?.aggregations?.by_terms?.buckets ?? []; + const buckets = response?.aggregations?.by_terms?.buckets ?? []; - return { - ...filter, - options: orderBy( - buckets.map((bucket) => { - return { - name: bucket.key as string, - count: bucket.bucket_count - ? bucket.bucket_count.value - : bucket.doc_count, - }; - }), - 'count', - 'desc' - ), - }; - }) - ); + return { + ...filter, + options: orderBy( + buckets.map((bucket) => { + return { + name: bucket.key as string, + count: bucket.bucket_count + ? bucket.bucket_count.value + : bucket.doc_count, + }; + }), + 'count', + 'desc' + ), + }; + }) + ) + ); + }); } diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts index 15bfd9d2d9822f..46f2628cc73d5b 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.ts @@ -13,6 +13,7 @@ import { PathReporter } from 'io-ts/lib/PathReporter'; import { isLeft } from 'fp-ts/lib/Either'; import { KibanaResponseFactory, RouteRegistrar } from 'src/core/server'; import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; +import agent from 'elastic-apm-node'; import { merge } from '../../../common/runtime_types/merge'; import { strictKeysRt } from '../../../common/runtime_types/strict_keys_rt'; import { APMConfig } from '../..'; @@ -95,6 +96,12 @@ export function createApi() { }, }, async (context, request, response) => { + if (agent.isStarted()) { + agent.addLabels({ + plugin: 'apm', + }); + } + try { const paramMap = pickBy( { diff --git a/x-pack/plugins/apm/server/routes/index_pattern.ts b/x-pack/plugins/apm/server/routes/index_pattern.ts index 7f2b45072454fe..ed1354a2191645 100644 --- a/x-pack/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/plugins/apm/server/routes/index_pattern.ts @@ -16,8 +16,10 @@ export const staticIndexPatternRoute = createRoute((core) => ({ endpoint: 'POST /api/apm/index_pattern/static', options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const savedObjectsClient = await getInternalSavedObjectsClient(core); + const [setup, savedObjectsClient] = await Promise.all([ + setupRequest(context, request), + getInternalSavedObjectsClient(core), + ]); await createStaticIndexPattern(setup, context, savedObjectsClient); diff --git a/x-pack/plugins/apm/server/routes/observability_overview.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts index 149f899fdfb5f4..1a1fa799639bc1 100644 --- a/x-pack/plugins/apm/server/routes/observability_overview.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -13,6 +13,7 @@ import { hasData } from '../lib/observability_overview/has_data'; import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; +import { withApmSpan } from '../utils/with_apm_span'; export const observabilityOverviewHasDataRoute = createRoute({ endpoint: 'GET /api/apm/observability_overview/has_data', @@ -36,20 +37,19 @@ export const observabilityOverviewRoute = createRoute({ setup ); - const serviceCountPromise = getServiceCount({ - setup, - searchAggregatedTransactions, + return withApmSpan('observability_overview', async () => { + const [serviceCount, transactionCoordinates] = await Promise.all([ + getServiceCount({ + setup, + searchAggregatedTransactions, + }), + getTransactionCoordinates({ + setup, + bucketSize, + searchAggregatedTransactions, + }), + ]); + return { serviceCount, transactionCoordinates }; }); - const transactionCoordinatesPromise = getTransactionCoordinates({ - setup, - bucketSize, - searchAggregatedTransactions, - }); - - const [serviceCount, transactionCoordinates] = await Promise.all([ - serviceCountPromise, - transactionCoordinatesPromise, - ]); - return { serviceCount, transactionCoordinates }; }, }); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 02cae86f6992ef..a5c4de7552d784 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -25,6 +25,7 @@ import { getThroughput } from '../lib/services/get_throughput'; import { getServiceInstances } from '../lib/services/get_service_instances'; import { getServiceMetadataDetails } from '../lib/services/get_service_metadata_details'; import { getServiceMetadataIcons } from '../lib/services/get_service_metadata_icons'; +import { withApmSpan } from '../utils/with_apm_span'; export const servicesRoute = createRoute({ endpoint: 'GET /api/apm/services', @@ -178,14 +179,17 @@ export const serviceAnnotationsRoute = createRoute({ const { serviceName } = context.params.path; const { environment } = context.params.query; + const { observability } = context.plugins; + const [ annotationsClient, searchAggregatedTransactions, ] = await Promise.all([ - context.plugins.observability?.getScopedAnnotationsClient( - context, - request - ), + observability + ? withApmSpan('get_scoped_annotations_client', () => + observability.getScopedAnnotationsClient(context, request) + ) + : undefined, getSearchAggregatedTransactions(setup), ]); @@ -229,10 +233,13 @@ export const serviceAnnotationsCreateRoute = createRoute({ ]), }), handler: async ({ request, context }) => { - const annotationsClient = await context.plugins.observability?.getScopedAnnotationsClient( - context, - request - ); + const { observability } = context.plugins; + + const annotationsClient = observability + ? await withApmSpan('get_scoped_annotations_client', () => + observability.getScopedAnnotationsClient(context, request) + ) + : undefined; if (!annotationsClient) { throw Boom.notFound(); @@ -240,18 +247,20 @@ export const serviceAnnotationsCreateRoute = createRoute({ const { body, path } = context.params; - return annotationsClient.create({ - message: body.service.version, - ...body, - annotation: { - type: 'deployment', - }, - service: { - ...body.service, - name: path.serviceName, - }, - tags: uniq(['apm'].concat(body.tags ?? [])), - }); + return withApmSpan('create_annotation', () => + annotationsClient.create({ + message: body.service.version, + ...body, + annotation: { + type: 'deployment', + }, + service: { + ...body.service, + name: path.serviceName, + }, + tags: uniq(['apm'].concat(body.tags ?? [])), + }) + ); }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index 40235e8d8a74f1..25afb11f264590 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -17,6 +17,7 @@ import { getAllEnvironments } from '../../lib/environments/get_all_environments' import { hasLegacyJobs } from '../../lib/anomaly_detection/has_legacy_jobs'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; import { notifyFeatureUsage } from '../../feature'; +import { withApmSpan } from '../../utils/with_apm_span'; // get ML anomaly detection jobs for each environment export const anomalyDetectionJobsRoute = createRoute({ @@ -31,10 +32,13 @@ export const anomalyDetectionJobsRoute = createRoute({ throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); } - const [jobs, legacyJobs] = await Promise.all([ - getAnomalyDetectionJobs(setup, context.logger), - hasLegacyJobs(setup), - ]); + const [jobs, legacyJobs] = await withApmSpan('get_available_ml_jobs', () => + Promise.all([ + getAnomalyDetectionJobs(setup, context.logger), + hasLegacyJobs(setup), + ]) + ); + return { jobs, hasLegacyJobs: legacyJobs, diff --git a/x-pack/plugins/apm/server/utils/with_apm_span.ts b/x-pack/plugins/apm/server/utils/with_apm_span.ts new file mode 100644 index 00000000000000..9762a7213d0a2b --- /dev/null +++ b/x-pack/plugins/apm/server/utils/with_apm_span.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { withSpan, SpanOptions, parseSpanOptions } from '@kbn/apm-utils'; + +export function withApmSpan( + optionsOrName: SpanOptions | string, + cb: () => Promise +): Promise { + const options = parseSpanOptions(optionsOrName); + + const optionsWithDefaults = { + type: 'plugin:apm', + ...options, + labels: { + plugin: 'apm', + ...options.labels, + }, + }; + + return withSpan(optionsWithDefaults, cb); +} diff --git a/yarn.lock b/yarn.lock index c6a12fa353ecce..8dc2cf35287d75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3416,6 +3416,10 @@ version "0.0.0" uid "" +"@kbn/apm-utils@link:packages/kbn-apm-utils": + version "0.0.0" + uid "" + "@kbn/babel-code-parser@link:packages/kbn-babel-code-parser": version "0.0.0" uid "" From f9804057c85a0357698fa8ef708d14cdbd6342f0 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Fri, 12 Feb 2021 15:46:05 -0600 Subject: [PATCH 018/175] TypeScript project references for APM (#90049) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dario Gieselaar --- src/dev/typescript/projects.ts | 4 -- tsconfig.refs.json | 11 ++--- x-pack/plugins/apm/common/service_map.ts | 35 +++++++++------ x-pack/plugins/apm/kibana.json | 15 +++---- .../Filter/FilterTitleButton.tsx | 18 ++++++-- .../components/app/ServiceMap/Cytoscape.tsx | 2 +- .../Waterfall/SyncBadge.stories.tsx | 8 ++-- .../Waterfall/SyncBadge.tsx | 2 +- .../url_params_context/url_params_context.tsx | 2 +- ...se_transaction_throughput_chart_fetcher.ts | 4 +- .../selectors/latency_chart_selectors.ts | 2 +- ....ts => throughput_chart_selectors.test.ts} | 20 ++++----- ...ctors.ts => throughput_chart_selectors.ts} | 20 ++++----- x-pack/plugins/apm/readme.md | 2 +- x-pack/plugins/apm/scripts/precommit.js | 21 +++------ .../apm/scripts/shared/get_es_client.ts | 30 ++++++++----- x-pack/plugins/apm/scripts/tsconfig.json | 13 ------ .../scripts/upload-telemetry-data/index.ts | 2 +- .../create_apm_event_client/index.ts | 28 ++++++------ .../get_dynamic_index_pattern.ts | 2 +- .../services/get_service_metadata_details.ts | 2 +- .../services/get_service_metadata_icons.ts | 2 +- .../settings/custom_link/custom_link_types.ts | 33 ++++++++------ .../apm/server/lib/traces/get_trace_items.ts | 2 +- .../server/lib/transaction_groups/fetcher.ts | 2 +- x-pack/plugins/apm/tsconfig.json | 45 +++++++++++++++++++ x-pack/test/tsconfig.json | 1 + x-pack/tsconfig.json | 2 +- 28 files changed, 189 insertions(+), 141 deletions(-) rename x-pack/plugins/apm/public/selectors/{throuput_chart_selectors.test.ts => throughput_chart_selectors.test.ts} (79%) rename x-pack/plugins/apm/public/selectors/{throuput_chart_selectors.ts => throughput_chart_selectors.ts} (80%) delete mode 100644 x-pack/plugins/apm/scripts/tsconfig.json create mode 100644 x-pack/plugins/apm/tsconfig.json diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index a5e6c4bef832c3..29379bbb31ee12 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -28,10 +28,6 @@ export const PROJECTS = [ name: 'apm/ftr_e2e', disableTypeCheck: true, }), - new Project(resolve(REPO_ROOT, 'x-pack/plugins/apm/scripts/tsconfig.json'), { - name: 'apm/scripts', - disableTypeCheck: true, - }), // NOTE: using glob.sync rather than glob-all or globby // because it takes less than 10 ms, while the other modules diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 4105f23fd5b3ea..39f3057ec9b2a7 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -57,6 +57,7 @@ { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, { "path": "./x-pack/plugins/actions/tsconfig.json" }, { "path": "./x-pack/plugins/alerts/tsconfig.json" }, + { "path": "./x-pack/plugins/apm/tsconfig.json" }, { "path": "./x-pack/plugins/beats_management/tsconfig.json" }, { "path": "./x-pack/plugins/canvas/tsconfig.json" }, { "path": "./x-pack/plugins/cloud/tsconfig.json" }, @@ -106,10 +107,10 @@ { "path": "./x-pack/plugins/runtime_fields/tsconfig.json" }, { "path": "./x-pack/plugins/index_management/tsconfig.json" }, { "path": "./x-pack/plugins/watcher/tsconfig.json" }, - { "path": "./x-pack/plugins/rollup/tsconfig.json"}, - { "path": "./x-pack/plugins/remote_clusters/tsconfig.json"}, - { "path": "./x-pack/plugins/cross_cluster_replication/tsconfig.json"}, - { "path": "./x-pack/plugins/index_lifecycle_management/tsconfig.json"}, - { "path": "./x-pack/plugins/uptime/tsconfig.json" }, + { "path": "./x-pack/plugins/rollup/tsconfig.json" }, + { "path": "./x-pack/plugins/remote_clusters/tsconfig.json" }, + { "path": "./x-pack/plugins/cross_cluster_replication/tsconfig.json" }, + { "path": "./x-pack/plugins/index_lifecycle_management/tsconfig.json" }, + { "path": "./x-pack/plugins/uptime/tsconfig.json" } ] } diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 31d319bfdbb360..303f6b02c0ea25 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -7,27 +7,34 @@ import { i18n } from '@kbn/i18n'; import cytoscape from 'cytoscape'; -import { - AGENT_NAME, - SERVICE_ENVIRONMENT, - SERVICE_NAME, - SPAN_DESTINATION_SERVICE_RESOURCE, - SPAN_SUBTYPE, - SPAN_TYPE, -} from './elasticsearch_fieldnames'; import { ServiceAnomalyStats } from './anomaly_detection'; +// These should be imported, but until TypeScript 4.2 we're inlining them here. +// All instances of "agent.name", "service.name", "service.environment", "span.type", +// "span.subtype", and "span.destination.service.resource" need to be changed +// back to using the constants. +// See https://github.com/microsoft/TypeScript/issues/37888 +// +// import { +// AGENT_NAME, +// SERVICE_ENVIRONMENT, +// SERVICE_NAME, +// SPAN_DESTINATION_SERVICE_RESOURCE, +// SPAN_SUBTYPE, +// SPAN_TYPE, +// } from './elasticsearch_fieldnames'; + export interface ServiceConnectionNode extends cytoscape.NodeDataDefinition { - [SERVICE_NAME]: string; - [SERVICE_ENVIRONMENT]: string | null; - [AGENT_NAME]: string; + 'service.name': string; + 'service.environment': string | null; + 'agent.name': string; serviceAnomalyStats?: ServiceAnomalyStats; label?: string; } export interface ExternalConnectionNode extends cytoscape.NodeDataDefinition { - [SPAN_DESTINATION_SERVICE_RESOURCE]: string; - [SPAN_TYPE]: string; - [SPAN_SUBTYPE]: string; + 'span.destination.service.resource': string; + 'span.type': string; + 'span.subtype': string; label?: string; } diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 2625fc2f3c1cd1..fe9294a48893a5 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -25,19 +25,14 @@ ], "server": true, "ui": true, - "configPath": [ - "xpack", - "apm" - ], - "extraPublicDirs": [ - "public/style/variables" - ], + "configPath": ["xpack", "apm"], + "extraPublicDirs": ["public/style/variables"], "requiredBundles": [ + "home", "kibanaReact", "kibanaUtils", - "observability", - "home", "maps", - "ml" + "ml", + "observability" ] } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx index 1a59b7d910b1f9..a558813484807e 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx @@ -5,11 +5,21 @@ * 2.0. */ -import React from 'react'; -import { EuiButtonEmpty, EuiTitle } from '@elastic/eui'; -import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; +import { EuiButtonEmpty, EuiButtonEmptyProps, EuiTitle } from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; +import { StyledComponent } from 'styled-components'; +import { + euiStyled, + EuiTheme, +} from '../../../../../../../../../src/plugins/kibana_react/common'; -const Button = euiStyled(EuiButtonEmpty).attrs(() => ({ +// The return type of this component needs to be specified because the inferred +// return type depends on types that are not exported from EUI. You get a TS4023 +// error if the return type is not specified. +const Button: StyledComponent< + FunctionComponent, + EuiTheme +> = euiStyled(EuiButtonEmpty).attrs(() => ({ contentProps: { className: 'alignLeft', }, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 9abadf2bdb9dbe..7651dba89e27e9 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -27,7 +27,7 @@ export const CytoscapeContext = createContext( undefined ); -interface CytoscapeProps { +export interface CytoscapeProps { children?: ReactNode; elements: cytoscape.ElementDefinition[]; height: number; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx index 282048a8c85d6b..8275aa1e5f156c 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { ComponentProps } from 'react'; -import { SyncBadge } from './SyncBadge'; +import React from 'react'; +import { SyncBadge, SyncBadgeProps } from './SyncBadge'; export default { title: 'app/TransactionDetails/SyncBadge', @@ -18,7 +18,7 @@ export default { }, }; -export function Example({ sync }: ComponentProps) { +export function Example({ sync }: SyncBadgeProps) { return ; } -Example.args = { sync: true } as ComponentProps; +Example.args = { sync: true } as SyncBadgeProps; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx index 24301b2cf10fbc..b9e4c6951fa06a 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx @@ -16,7 +16,7 @@ const SpanBadge = euiStyled(EuiBadge)` margin-right: ${px(units.quarter)}; `; -interface SyncBadgeProps { +export interface SyncBadgeProps { /** * Is the request synchronous? True will show blocking, false will show async. */ diff --git a/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx b/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx index e29c092071894e..9cc11eef79eef3 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx +++ b/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx @@ -25,7 +25,7 @@ import { getDateRange } from './helpers'; import { resolveUrlParams } from './resolve_url_params'; import { IUrlParams } from './types'; -interface TimeRange { +export interface TimeRange { rangeFrom: string; rangeTo: string; } diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts index cd2dbca7512afc..af9a5fee24877a 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useFetcher } from './use_fetcher'; import { useUrlParams } from '../context/url_params_context/use_url_params'; -import { getThrouputChartSelector } from '../selectors/throuput_chart_selectors'; +import { getThroughputChartSelector } from '../selectors/throughput_chart_selectors'; import { useTheme } from './use_theme'; import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; @@ -45,7 +45,7 @@ export function useTransactionThroughputChartsFetcher() { ); const memoizedData = useMemo( - () => getThrouputChartSelector({ throuputChart: data, theme }), + () => getThroughputChartSelector({ throughputChart: data, theme }), [data, theme] ); diff --git a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts index aac0c75dacaebb..858d44de8bb7a0 100644 --- a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts @@ -15,7 +15,7 @@ import { APIReturnType } from '../services/rest/createCallApmApi'; export type LatencyChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/latency'>; -interface LatencyChartData { +export interface LatencyChartData { latencyTimeseries: Array>; mlJobId?: string; anomalyTimeseries?: { boundaries: APMChartSpec[]; scores: APMChartSpec }; diff --git a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.test.ts b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.test.ts similarity index 79% rename from x-pack/plugins/apm/public/selectors/throuput_chart_selectors.test.ts rename to x-pack/plugins/apm/public/selectors/throughput_chart_selectors.test.ts index 89e406a9014671..b76b77abaa7bde 100644 --- a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.test.ts +++ b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.test.ts @@ -7,9 +7,9 @@ import { EuiTheme } from '../../../../../src/plugins/kibana_react/common'; import { - getThrouputChartSelector, - ThrouputChartsResponse, -} from './throuput_chart_selectors'; + getThroughputChartSelector, + ThroughputChartsResponse, +} from './throughput_chart_selectors'; const theme = { eui: { @@ -30,26 +30,26 @@ const throughputData = { { key: 'HTTP 4xx', avg: 1, dataPoints: [{ x: 1, y: 2 }] }, { key: 'HTTP 5xx', avg: 1, dataPoints: [{ x: 1, y: 2 }] }, ], -} as ThrouputChartsResponse; +} as ThroughputChartsResponse; -describe('getThrouputChartSelector', () => { +describe('getThroughputChartSelector', () => { it('returns default values when data is undefined', () => { - const throughputTimeseries = getThrouputChartSelector({ theme }); + const throughputTimeseries = getThroughputChartSelector({ theme }); expect(throughputTimeseries).toEqual({ throughputTimeseries: [] }); }); it('returns default values when timeseries is empty', () => { - const throughputTimeseries = getThrouputChartSelector({ + const throughputTimeseries = getThroughputChartSelector({ theme, - throuputChart: { throughputTimeseries: [] }, + throughputChart: { throughputTimeseries: [] }, }); expect(throughputTimeseries).toEqual({ throughputTimeseries: [] }); }); it('return throughput time series', () => { - const throughputTimeseries = getThrouputChartSelector({ + const throughputTimeseries = getThroughputChartSelector({ theme, - throuputChart: throughputData, + throughputChart: throughputData, }); expect(throughputTimeseries).toEqual({ diff --git a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts similarity index 80% rename from x-pack/plugins/apm/public/selectors/throuput_chart_selectors.ts rename to x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts index daf1e69c2e5f99..f9e72bff231f4a 100644 --- a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts @@ -12,36 +12,36 @@ import { TimeSeries } from '../../typings/timeseries'; import { APIReturnType } from '../services/rest/createCallApmApi'; import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; -export type ThrouputChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/throughput'>; +export type ThroughputChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/throughput'>; -interface ThroughputChart { +export interface ThroughputChart { throughputTimeseries: TimeSeries[]; } -export function getThrouputChartSelector({ +export function getThroughputChartSelector({ theme, - throuputChart, + throughputChart, }: { theme: EuiTheme; - throuputChart?: ThrouputChartsResponse; + throughputChart?: ThroughputChartsResponse; }): ThroughputChart { - if (!throuputChart) { + if (!throughputChart) { return { throughputTimeseries: [] }; } return { - throughputTimeseries: getThroughputTimeseries({ throuputChart, theme }), + throughputTimeseries: getThroughputTimeseries({ throughputChart, theme }), }; } function getThroughputTimeseries({ - throuputChart, + throughputChart, theme, }: { theme: EuiTheme; - throuputChart: ThrouputChartsResponse; + throughputChart: ThroughputChartsResponse; }) { - const { throughputTimeseries } = throuputChart; + const { throughputTimeseries } = throughputChart; const bucketKeys = throughputTimeseries.map(({ key }) => key); const getColor = getColorByKey(bucketKeys, theme); diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index 00d7e8e1dd5e42..9ddbd1757ad94f 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -118,7 +118,7 @@ _Note: Run the following commands from `kibana/`._ ### Typescript ``` -yarn tsc --noEmit --project x-pack/tsconfig.json --skipLibCheck +yarn tsc --noEmit --project x-pack/plugins/apm/tsconfig.json --skipLibCheck ``` ### Prettier diff --git a/x-pack/plugins/apm/scripts/precommit.js b/x-pack/plugins/apm/scripts/precommit.js index 71943842b673ff..c741be6c0e9a6b 100644 --- a/x-pack/plugins/apm/scripts/precommit.js +++ b/x-pack/plugins/apm/scripts/precommit.js @@ -14,7 +14,7 @@ const { resolve } = require('path'); const cwd = resolve(__dirname, '../../../..'); -const execaOpts = { cwd, stderr: 'pipe' }; +const execaOpts = { cwd, stderr: 'inherit' }; const tasks = new Listr( [ @@ -36,18 +36,10 @@ const tasks = new Listr( { title: 'Typescript', task: () => - execa('node', [resolve(__dirname, 'optimize-tsconfig.js')]).then(() => - execa( - require.resolve('typescript/bin/tsc'), - [ - '--project', - resolve(__dirname, '../../../tsconfig.json'), - '--pretty', - '--noEmit', - '--skipLibCheck', - ], - execaOpts - ) + execa( + require.resolve('typescript/bin/tsc'), + ['--project', resolve(__dirname, '../tsconfig.json'), '--pretty'], + execaOpts ), }, { @@ -61,10 +53,9 @@ const tasks = new Listr( tasks.run().catch((error) => { // from src/dev/typescript/exec_in_projects.ts process.exitCode = 1; - const errors = error.errors || [error]; for (const e of errors) { - process.stderr.write(e.stdout); + process.stderr.write(e.stderr || e.stdout); } }); diff --git a/x-pack/plugins/apm/scripts/shared/get_es_client.ts b/x-pack/plugins/apm/scripts/shared/get_es_client.ts index ab443e081825a8..f17a55cf4e215a 100644 --- a/x-pack/plugins/apm/scripts/shared/get_es_client.ts +++ b/x-pack/plugins/apm/scripts/shared/get_es_client.ts @@ -30,17 +30,25 @@ export function getEsClient({ auth, }); - return { - ...client, - async search( - request: TSearchRequest - ) { - const response = await client.search(request as any); + async function search< + TDocument = unknown, + TSearchRequest extends ESSearchRequest = ESSearchRequest + >(request: TSearchRequest) { + const response = await client.search(request); - return { - ...response, - body: response.body as ESSearchResponse, - }; - }, + return { + ...response, + body: (response.body as unknown) as ESSearchResponse< + TDocument, + TSearchRequest + >, + }; + } + + // @ts-expect-error + client.search = search; + + return (client as unknown) as Omit & { + search: typeof search; }; } diff --git a/x-pack/plugins/apm/scripts/tsconfig.json b/x-pack/plugins/apm/scripts/tsconfig.json deleted file mode 100644 index f1643608496ad4..00000000000000 --- a/x-pack/plugins/apm/scripts/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../../../tsconfig.base.json", - "include": [ - "./**/*", - "../observability" - ], - "exclude": [], - "compilerOptions": { - "types": [ - "node" - ] - } -} diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts index a7340cd2cfedf6..e4aedf452002dd 100644 --- a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -83,7 +83,7 @@ async function uploadData() { apmAgentConfigurationIndex: '.apm-agent-configuration', }, search: (body) => { - return unwrapEsResponse(client.search(body)); + return unwrapEsResponse(client.search(body as any)); }, indicesStats: (body) => { return unwrapEsResponse(client.indices.stats(body)); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index b93513646fb9f7..c47d511ca565c7 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -6,29 +6,29 @@ */ import { ValuesType } from 'utility-types'; -import { unwrapEsResponse } from '../../../../../../observability/server'; -import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; import { ElasticsearchClient, KibanaRequest, } from '../../../../../../../../src/core/server'; -import { ProcessorEvent } from '../../../../../common/processor_event'; import { ESSearchRequest, ESSearchResponse, } from '../../../../../../../typings/elasticsearch'; -import { ApmIndicesConfig } from '../../../settings/apm_indices/get_apm_indices'; -import { addFilterToExcludeLegacyData } from './add_filter_to_exclude_legacy_data'; -import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; -import { Span } from '../../../../../typings/es_schemas/ui/span'; +import { unwrapEsResponse } from '../../../../../../observability/server'; +import { ProcessorEvent } from '../../../../../common/processor_event'; +import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; import { Metric } from '../../../../../typings/es_schemas/ui/metric'; -import { unpackProcessorEvents } from './unpack_processor_events'; +import { Span } from '../../../../../typings/es_schemas/ui/span'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; +import { ApmIndicesConfig } from '../../../settings/apm_indices/get_apm_indices'; import { callAsyncWithDebug, - getDebugTitle, getDebugBody, + getDebugTitle, } from '../call_async_with_debug'; import { cancelEsRequestOnAbort } from '../cancel_es_request_on_abort'; +import { addFilterToExcludeLegacyData } from './add_filter_to_exclude_legacy_data'; +import { unpackProcessorEvents } from './unpack_processor_events'; export type APMEventESSearchRequest = Omit & { apm: { @@ -36,11 +36,13 @@ export type APMEventESSearchRequest = Omit & { }; }; +// These keys shoul all be `ProcessorEvent.x`, but until TypeScript 4.2 we're inlining them here. +// See https://github.com/microsoft/TypeScript/issues/37888 type TypeOfProcessorEvent = { - [ProcessorEvent.error]: APMError; - [ProcessorEvent.transaction]: Transaction; - [ProcessorEvent.span]: Span; - [ProcessorEvent.metric]: Metric; + error: APMError; + transaction: Transaction; + span: Span; + metric: Metric; }[T]; type ESSearchRequestOf = Omit< diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts index 0d6d8b58b32f22..cb6183510ad168 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts @@ -13,7 +13,7 @@ import { import { APMRequestHandlerContext } from '../../routes/typings'; import { withApmSpan } from '../../utils/with_apm_span'; -interface IndexPatternTitleAndFields { +export interface IndexPatternTitleAndFields { title: string; fields: FieldDescriptor[]; } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts index 24ed72ea995109..f1198a4d858fda 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts @@ -33,7 +33,7 @@ type ServiceMetadataDetailsRaw = Pick< 'service' | 'agent' | 'host' | 'container' | 'kubernetes' | 'cloud' >; -interface ServiceMetadataDetails { +export interface ServiceMetadataDetails { service?: { versions?: string[]; runtime?: { diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts index 6636820defdebe..0ea95a08abaa98 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts @@ -27,7 +27,7 @@ type ServiceMetadataIconsRaw = Pick< 'kubernetes' | 'cloud' | 'container' | 'agent' >; -interface ServiceMetadataIcons { +export interface ServiceMetadataIcons { agentName?: string; containerType?: ContainerType; cloudProvider?: string; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts index 3a7f53adc6aaeb..48f547e3deb0f7 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts @@ -6,29 +6,34 @@ */ import * as t from 'io-ts'; -import { - SERVICE_NAME, - SERVICE_ENVIRONMENT, - TRANSACTION_NAME, - TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; + +// These should be imported, but until TypeScript 4.2 we're inlining them here. +// All instances of "service.name", "service.environment", "transaction.name", +// and "transaction.type" need to be changed back to using the constants. +// See https://github.com/microsoft/TypeScript/issues/37888 +// import { +// SERVICE_NAME, +// SERVICE_ENVIRONMENT, +// TRANSACTION_NAME, +// TRANSACTION_TYPE, +// } from '../../../../common/elasticsearch_fieldnames'; export interface CustomLinkES { id?: string; '@timestamp'?: number; label: string; url: string; - [SERVICE_NAME]?: string[]; - [SERVICE_ENVIRONMENT]?: string[]; - [TRANSACTION_NAME]?: string[]; - [TRANSACTION_TYPE]?: string[]; + 'service.name'?: string[]; + 'service.environment'?: string[]; + 'transaction.name'?: string[]; + 'transaction.type'?: string[]; } export const filterOptionsRt = t.partial({ - [SERVICE_NAME]: t.string, - [SERVICE_ENVIRONMENT]: t.string, - [TRANSACTION_NAME]: t.string, - [TRANSACTION_TYPE]: t.string, + 'service.name': t.string, + 'service.environment': t.string, + 'transaction.name': t.string, + 'transaction.type': t.string, }); export const payloadRt = t.intersection([ diff --git a/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts b/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts index c9769b6143c951..bd3ecf1e0f862d 100644 --- a/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts +++ b/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts @@ -20,7 +20,7 @@ import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { PromiseValueType } from '../../../typings/common'; import { withApmSpan } from '../../utils/with_apm_span'; -interface ErrorsPerTransaction { +export interface ErrorsPerTransaction { [transactionId: string]: number; } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index 46a55d9004aba0..0fad948edde19b 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -213,7 +213,7 @@ export function transactionGroupsFetcher( }); } -interface TransactionGroup { +export interface TransactionGroup { key: string | Record<'service.name' | 'transaction.name', string>; serviceName: string; transactionName: string; diff --git a/x-pack/plugins/apm/tsconfig.json b/x-pack/plugins/apm/tsconfig.json new file mode 100644 index 00000000000000..bb2e0e06679a29 --- /dev/null +++ b/x-pack/plugins/apm/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "../../typings/**/*", + "common/**/*", + "public/**/*", + "scripts/**/*", + "server/**/*", + "typings/**/*", + // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 + "public/**/*.json", + "server/**/*.json" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/apm_oss/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/index_pattern_management/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../actions/tsconfig.json" }, + { "path": "../alerts/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../infra/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../maps/tsconfig.json" }, + { "path": "../ml/tsconfig.json" }, + { "path": "../observability/tsconfig.json" }, + { "path": "../reporting/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../task_manager/tsconfig.json" }, + { "path": "../triggers_actions_ui/tsconfig.json" } + ] +} diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 4b56ebc83d9893..1c2e0aeecd2477 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -40,6 +40,7 @@ { "path": "../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../plugins/actions/tsconfig.json" }, { "path": "../plugins/alerts/tsconfig.json" }, + { "path": "../plugins/apm/tsconfig.json" }, { "path": "../plugins/banners/tsconfig.json" }, { "path": "../plugins/beats_management/tsconfig.json" }, { "path": "../plugins/cloud/tsconfig.json" }, diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 2c475083b589a8..813811d4a9ce45 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -4,7 +4,6 @@ "mocks.ts", "typings/**/*", "tasks/**/*", - "plugins/apm/**/*", "plugins/case/**/*", "plugins/lists/**/*", "plugins/logstash/**/*", @@ -62,6 +61,7 @@ { "path": "../src/plugins/usage_collection/tsconfig.json" }, { "path": "./plugins/actions/tsconfig.json" }, { "path": "./plugins/alerts/tsconfig.json" }, + { "path": "./plugins/apm/tsconfig.json" }, { "path": "./plugins/beats_management/tsconfig.json" }, { "path": "./plugins/canvas/tsconfig.json" }, { "path": "./plugins/cloud/tsconfig.json" }, From 874fadf388eed6fc43dde79e74698158b5245398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Fri, 12 Feb 2021 22:53:05 +0100 Subject: [PATCH 019/175] [APM] Adding comparison to throughput chart (#90128) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dario Gieselaar --- .../date_as_string_rt/index.test.ts | 29 - .../iso_to_epoch_rt/index.test.ts | 32 + .../index.ts | 19 +- .../runtime_types/to_boolean_rt/index.ts | 24 + .../runtime_types/to_number_rt/index.ts | 2 +- .../service_inventory.test.tsx | 9 +- .../service_overview_throughput_chart.tsx | 54 +- .../transaction_overview.test.tsx | 4 +- .../shared/charts/timeseries_chart.tsx | 5 +- .../get_time_range_comparison.test.ts | 120 +++ .../get_time_range_comparison.ts | 88 ++ .../shared/time_comparison/index.test.tsx | 43 +- .../shared/time_comparison/index.tsx | 75 +- .../url_params_context/resolve_url_params.ts | 4 +- .../context/url_params_context/types.ts | 3 +- .../apm/server/lib/helpers/setup_request.ts | 13 +- .../apm/server/lib/services/get_throughput.ts | 50 +- .../apm/server/routes/default_api_types.ts | 11 +- x-pack/plugins/apm/server/routes/services.ts | 67 +- .../routes/settings/agent_configuration.ts | 4 +- x-pack/plugins/apm/server/routes/typings.ts | 2 +- .../offset_previous_period_coordinate.test.ts | 57 ++ .../offset_previous_period_coordinate.ts | 35 + .../services/__snapshots__/throughput.snap | 795 +++++++++++++++++- .../tests/services/throughput.ts | 91 +- 25 files changed, 1454 insertions(+), 182 deletions(-) delete mode 100644 x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts create mode 100644 x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts rename x-pack/plugins/apm/common/runtime_types/{date_as_string_rt => iso_to_epoch_rt}/index.ts (57%) create mode 100644 x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts create mode 100644 x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts create mode 100644 x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts create mode 100644 x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts create mode 100644 x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts diff --git a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts deleted file mode 100644 index 313b597e5d4090..00000000000000 --- a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { dateAsStringRt } from './index'; -import { isLeft, isRight } from 'fp-ts/lib/Either'; - -describe('dateAsStringRt', () => { - it('validates whether a string is a valid date', () => { - expect(isLeft(dateAsStringRt.decode(1566299881499))).toBe(true); - - expect(isRight(dateAsStringRt.decode('2019-08-20T11:18:31.407Z'))).toBe( - true - ); - }); - - it('returns the string it was given', () => { - const either = dateAsStringRt.decode('2019-08-20T11:18:31.407Z'); - - if (isRight(either)) { - expect(either.right).toBe('2019-08-20T11:18:31.407Z'); - } else { - fail(); - } - }); -}); diff --git a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts new file mode 100644 index 00000000000000..573bfdc83e429e --- /dev/null +++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isoToEpochRt } from './index'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('isoToEpochRt', () => { + it('validates whether its input is a valid ISO timestamp', () => { + expect(isRight(isoToEpochRt.decode(1566299881499))).toBe(false); + + expect(isRight(isoToEpochRt.decode('2019-08-20T11:18:31.407Z'))).toBe(true); + }); + + it('decodes valid ISO timestamps to epoch time', () => { + const iso = '2019-08-20T11:18:31.407Z'; + const result = isoToEpochRt.decode(iso); + + if (isRight(result)) { + expect(result.right).toBe(new Date(iso).getTime()); + } else { + fail(); + } + }); + + it('encodes epoch time to ISO string', () => { + expect(isoToEpochRt.encode(1566299911407)).toBe('2019-08-20T11:18:31.407Z'); + }); +}); diff --git a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts similarity index 57% rename from x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts rename to x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts index 182399657f6f3c..1a17f82a521413 100644 --- a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts +++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts @@ -9,15 +9,20 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; // Checks whether a string is a valid ISO timestamp, -// but doesn't convert it into a Date object when decoding +// and returns an epoch timestamp -export const dateAsStringRt = new t.Type( - 'DateAsString', - t.string.is, +export const isoToEpochRt = new t.Type( + 'isoToEpochRt', + t.number.is, (input, context) => either.chain(t.string.validate(input, context), (str) => { - const date = new Date(str); - return isNaN(date.getTime()) ? t.failure(input, context) : t.success(str); + const epochDate = new Date(str).getTime(); + return isNaN(epochDate) + ? t.failure(input, context) + : t.success(epochDate); }), - t.identity + (a) => { + const d = new Date(a); + return d.toISOString(); + } ); diff --git a/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts new file mode 100644 index 00000000000000..1e6828ed4ead35 --- /dev/null +++ b/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts @@ -0,0 +1,24 @@ +/* + * 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 * as t from 'io-ts'; + +export const toBooleanRt = new t.Type( + 'ToBoolean', + t.boolean.is, + (input) => { + let value: boolean; + if (typeof input === 'string') { + value = input === 'true'; + } else { + value = !!input; + } + + return t.success(value); + }, + t.identity +); diff --git a/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts index 4103cb8837cde1..a4632680cb6e18 100644 --- a/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts +++ b/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; export const toNumberRt = new t.Type( 'ToNumber', - t.any.is, + t.number.is, (input, context) => { const number = Number(input); return !isNaN(number) ? t.success(number) : t.failure(input, context); diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx index 69b4149625824d..419b66da5d2220 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx @@ -20,10 +20,12 @@ import { MockApmPluginContextWrapper, } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { clearCache } from '../../../services/rest/callApi'; import * as useDynamicIndexPatternHooks from '../../../hooks/use_dynamic_index_pattern'; import { SessionStorageMock } from '../../../services/__mocks__/SessionStorageMock'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; import * as hook from './use_anomaly_detection_jobs_fetcher'; +import { TimeRangeComparisonType } from '../../shared/time_comparison/get_time_range_comparison'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiCounter: () => {} }, @@ -55,10 +57,10 @@ function wrapper({ children }: { children?: ReactNode }) { params={{ rangeFrom: 'now-15m', rangeTo: 'now', - start: 'mystart', - end: 'myend', + start: '2021-02-12T13:20:43.344Z', + end: '2021-02-12T13:20:58.344Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, }} > {children} @@ -74,6 +76,7 @@ describe('ServiceInventory', () => { beforeEach(() => { // @ts-expect-error global.sessionStorage = new SessionStorageMock(); + clearCache(); jest.spyOn(hook, 'useAnomalyDetectionJobsFetcher').mockReturnValue({ anomalyDetectionJobsStatus: FETCH_STATUS.SUCCESS, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx index d70dae5ae63166..92111c5671c91e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx @@ -15,6 +15,15 @@ import { useTheme } from '../../../hooks/use_theme'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { TimeseriesChart } from '../../shared/charts/timeseries_chart'; +import { + getTimeRangeComparison, + getComparisonChartTheme, +} from '../../shared/time_comparison/get_time_range_comparison'; + +const INITIAL_STATE = { + currentPeriod: [], + previousPeriod: [], +}; export function ServiceOverviewThroughputChart({ height, @@ -25,9 +34,20 @@ export function ServiceOverviewThroughputChart({ const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); const { transactionType } = useApmServiceContext(); - const { start, end } = urlParams; + const { start, end, comparisonEnabled, comparisonType } = urlParams; + const comparisonChartTheme = getComparisonChartTheme(theme); + const { + comparisonStart = undefined, + comparisonEnd = undefined, + } = comparisonType + ? getTimeRangeComparison({ + start, + end, + comparisonType, + }) + : {}; - const { data, status } = useFetcher( + const { data = INITIAL_STATE, status } = useFetcher( (callApmApi) => { if (serviceName && transactionType && start && end) { return callApmApi({ @@ -41,12 +61,22 @@ export function ServiceOverviewThroughputChart({ end, transactionType, uiFilters: JSON.stringify(uiFilters), + comparisonStart, + comparisonEnd, }, }, }); } }, - [serviceName, start, end, uiFilters, transactionType] + [ + serviceName, + start, + end, + uiFilters, + transactionType, + comparisonStart, + comparisonEnd, + ] ); return ( @@ -63,9 +93,10 @@ export function ServiceOverviewThroughputChart({ height={height} showAnnotations={false} fetchStatus={status} + customTheme={comparisonChartTheme} timeseries={[ { - data: data?.throughput ?? [], + data: data.currentPeriod, type: 'linemark', color: theme.eui.euiColorVis0, title: i18n.translate( @@ -73,6 +104,21 @@ export function ServiceOverviewThroughputChart({ { defaultMessage: 'Throughput' } ), }, + ...(comparisonEnabled + ? [ + { + data: data.previousPeriod, + type: 'area', + color: theme.eui.euiColorLightestShade, + title: i18n.translate( + 'xpack.apm.serviceOverview.throughtputChart.previousPeriodLabel', + { + defaultMessage: 'Previous period', + } + ), + }, + ] + : []), ]} yLabelFormat={asTransactionRate} /> diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index 7d0ada3e31bffc..8fb5166bd8676e 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -131,7 +131,7 @@ describe('TransactionOverview', () => { }); expect(history.location.search).toEqual( - '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=yesterday' + '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=day' ); expect(getByText(container, 'firstType')).toBeInTheDocument(); expect(getByText(container, 'secondType')).toBeInTheDocument(); @@ -142,7 +142,7 @@ describe('TransactionOverview', () => { expect(history.push).toHaveBeenCalled(); expect(history.location.search).toEqual( - '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=yesterday' + '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=day' ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx index 441dbf4df28271..7bfe17e82bf4ae 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx @@ -59,6 +59,7 @@ interface Props { anomalyTimeseries?: ReturnType< typeof getLatencyChartSelector >['anomalyTimeseries']; + customTheme?: Record; } export function TimeseriesChart({ @@ -72,13 +73,14 @@ export function TimeseriesChart({ showAnnotations = true, yDomain, anomalyTimeseries, + customTheme = {}, }: Props) { const history = useHistory(); const { annotations } = useAnnotationsContext(); - const chartTheme = useChartTheme(); const { setPointerEvent, chartRef } = useChartPointerEventContext(); const { urlParams } = useUrlParams(); const theme = useTheme(); + const chartTheme = useChartTheme(); const { start, end } = urlParams; @@ -103,6 +105,7 @@ export function TimeseriesChart({ areaSeriesStyle: { line: { visible: false }, }, + ...customTheme, }} onPointerUpdate={setPointerEvent} externalPointerEvents={{ diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts new file mode 100644 index 00000000000000..7234e94881ce79 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { + getTimeRangeComparison, + TimeRangeComparisonType, +} from './get_time_range_comparison'; + +describe('getTimeRangeComparison', () => { + describe('return empty object', () => { + it('when start is not defined', () => { + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + start: undefined, + end, + comparisonType: TimeRangeComparisonType.DayBefore, + }); + expect(result).toEqual({}); + }); + + it('when end is not defined', () => { + const start = '2021-01-28T14:45:00.000Z'; + const result = getTimeRangeComparison({ + start, + end: undefined, + comparisonType: TimeRangeComparisonType.DayBefore, + }); + expect(result).toEqual({}); + }); + }); + + describe('Time range is between 0 - 24 hours', () => { + describe('when day before is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-01-28T14:45:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.DayBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-27T14:45:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-27T15:00:00.000Z'); + }); + }); + describe('when a week before is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-01-28T14:45:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.WeekBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-21T14:45:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); + }); + }); + describe('when previous period is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-02-09T14:40:01.087Z'; + const end = '2021-02-09T14:56:00.000Z'; + const result = getTimeRangeComparison({ + start, + end, + comparisonType: TimeRangeComparisonType.PeriodBefore, + }); + expect(result).toEqual({ + comparisonStart: '2021-02-09T14:24:02.174Z', + comparisonEnd: '2021-02-09T14:40:01.087Z', + }); + }); + }); + }); + + describe('Time range is between 24 hours - 1 week', () => { + describe('when a week before is selected', () => { + it('returns the correct time range - 2 days', () => { + const start = '2021-01-26T15:00:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.WeekBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-19T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); + }); + }); + }); + + describe('Time range is greater than 7 days', () => { + it('uses the date difference to calculate the time range - 8 days', () => { + const start = '2021-01-10T15:00:00.000Z'; + const end = '2021-01-18T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-02T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-10T15:00:00.000Z'); + }); + + it('uses the date difference to calculate the time range - 30 days', () => { + const start = '2021-01-01T15:00:00.000Z'; + const end = '2021-01-31T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2020-12-02T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-01T15:00:00.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts new file mode 100644 index 00000000000000..5dd014441a9e4f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.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 moment from 'moment'; +import { EuiTheme } from 'src/plugins/kibana_react/common'; +import { getDateDifference } from '../../../../common/utils/formatters'; + +export enum TimeRangeComparisonType { + WeekBefore = 'week', + DayBefore = 'day', + PeriodBefore = 'period', +} + +export function getComparisonChartTheme(theme: EuiTheme) { + return { + areaSeriesStyle: { + area: { + fill: theme.eui.euiColorLightestShade, + visible: true, + opacity: 1, + }, + line: { + stroke: theme.eui.euiColorMediumShade, + strokeWidth: 1, + visible: true, + }, + point: { + visible: false, + }, + }, + }; +} + +const oneDayInMilliseconds = moment.duration(1, 'day').asMilliseconds(); +const oneWeekInMilliseconds = moment.duration(1, 'week').asMilliseconds(); + +export function getTimeRangeComparison({ + comparisonType, + start, + end, +}: { + comparisonType: TimeRangeComparisonType; + start?: string; + end?: string; +}) { + if (!start || !end) { + return {}; + } + + const startMoment = moment(start); + const endMoment = moment(end); + + const startEpoch = startMoment.valueOf(); + const endEpoch = endMoment.valueOf(); + + let diff: number; + + switch (comparisonType) { + case TimeRangeComparisonType.DayBefore: + diff = oneDayInMilliseconds; + break; + + case TimeRangeComparisonType.WeekBefore: + diff = oneWeekInMilliseconds; + break; + + case TimeRangeComparisonType.PeriodBefore: + diff = getDateDifference({ + start: startMoment, + end: endMoment, + unitOfTime: 'milliseconds', + precise: true, + }); + break; + + default: + throw new Error('Unknown comparisonType'); + } + + return { + comparisonStart: new Date(startEpoch - diff).toISOString(), + comparisonEnd: new Date(endEpoch - diff).toISOString(), + }; +} diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx index 4ace78f74ee79e..a4f44290fe777f 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx @@ -18,6 +18,7 @@ import { import { TimeComparison } from './'; import * as urlHelpers from '../../shared/Links/url_helpers'; import moment from 'moment'; +import { TimeRangeComparisonType } from './get_time_range_comparison'; function getWrapper(params?: IUrlParams) { return ({ children }: { children?: ReactNode }) => { @@ -53,22 +54,22 @@ describe('TimeComparison', () => { expect(spy).toHaveBeenCalledWith(expect.anything(), { query: { comparisonEnabled: 'true', - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, }, }); }); - it('selects yesterday and enables comparison', () => { + it('selects day before and enables comparison', () => { const Wrapper = getWrapper({ start: '2021-01-28T14:45:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['Yesterday', 'A week ago']); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -80,13 +81,13 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T10:00:00.000Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['Yesterday', 'A week ago']); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -98,13 +99,13 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T10:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now-15m', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['28/01 11:00 - 29/01 11:00']); + expectTextsInDocument(component, ['27/01 11:00 - 28/01 11:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -118,14 +119,14 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T11:00:00.000Z', comparisonEnabled: true, - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsNotInDocument(component, ['Yesterday']); - expectTextsInDocument(component, ['A week ago']); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); }); it('sets default values', () => { const Wrapper = getWrapper({ @@ -139,7 +140,7 @@ describe('TimeComparison', () => { expect(spy).toHaveBeenCalledWith(expect.anything(), { query: { comparisonEnabled: 'true', - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, }, }); }); @@ -148,14 +149,14 @@ describe('TimeComparison', () => { start: '2021-01-26T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsNotInDocument(component, ['Yesterday']); - expectTextsInDocument(component, ['A week ago']); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -167,13 +168,13 @@ describe('TimeComparison', () => { start: '2021-01-26T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: '2021-01-28T15:00:00.000Z', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['26/01 16:00 - 28/01 16:00']); + expectTextsInDocument(component, ['24/01 16:00 - 26/01 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -187,14 +188,14 @@ describe('TimeComparison', () => { start: '2021-01-20T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/01 16:00 - 28/01 16:00']); + expectTextsInDocument(component, ['12/01 16:00 - 20/01 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -206,14 +207,14 @@ describe('TimeComparison', () => { start: '2020-12-20T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/12/20 16:00 - 28/01/21 16:00']); + expectTextsInDocument(component, ['11/11/20 16:00 - 20/12/20 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index e4b03bd57377aa..0b6c1a2c52a980 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -16,6 +16,10 @@ import { useUrlParams } from '../../../context/url_params_context/use_url_params import { px, unit } from '../../../style/variables'; import * as urlHelpers from '../../shared/Links/url_helpers'; import { useBreakPoints } from '../../../hooks/use_break_points'; +import { + getTimeRangeComparison, + TimeRangeComparisonType, +} from './get_time_range_comparison'; const PrependContainer = euiStyled.div` display: flex; @@ -25,15 +29,32 @@ const PrependContainer = euiStyled.div` padding: 0 ${px(unit)}; `; -function formatPreviousPeriodDates({ - momentStart, - momentEnd, +function getDateFormat({ + previousPeriodStart, + currentPeriodEnd, }: { - momentStart: moment.Moment; - momentEnd: moment.Moment; + previousPeriodStart?: string; + currentPeriodEnd?: string; }) { - const isDifferentYears = momentStart.get('year') !== momentEnd.get('year'); - const dateFormat = isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; + const momentPreviousPeriodStart = moment(previousPeriodStart); + const momentCurrentPeriodEnd = moment(currentPeriodEnd); + const isDifferentYears = + momentPreviousPeriodStart.get('year') !== + momentCurrentPeriodEnd.get('year'); + return isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; +} + +function formatDate({ + dateFormat, + previousPeriodStart, + previousPeriodEnd, +}: { + dateFormat: string; + previousPeriodStart?: string; + previousPeriodEnd?: string; +}) { + const momentStart = moment(previousPeriodStart); + const momentEnd = moment(previousPeriodEnd); return `${momentStart.format(dateFormat)} - ${momentEnd.format(dateFormat)}`; } @@ -49,17 +70,17 @@ function getSelectOptions({ const momentStart = moment(start); const momentEnd = moment(end); - const yesterdayOption = { - value: 'yesterday', - text: i18n.translate('xpack.apm.timeComparison.select.yesterday', { - defaultMessage: 'Yesterday', + const dayBeforeOption = { + value: TimeRangeComparisonType.DayBefore, + text: i18n.translate('xpack.apm.timeComparison.select.dayBefore', { + defaultMessage: 'Day before', }), }; - const aWeekAgoOption = { - value: 'week', - text: i18n.translate('xpack.apm.timeComparison.select.weekAgo', { - defaultMessage: 'A week ago', + const weekBeforeOption = { + value: TimeRangeComparisonType.WeekBefore, + text: i18n.translate('xpack.apm.timeComparison.select.weekBefore', { + defaultMessage: 'Week before', }), }; @@ -69,23 +90,39 @@ function getSelectOptions({ unitOfTime: 'days', precise: true, }); + const isRangeToNow = rangeTo === 'now'; if (isRangeToNow) { // Less than or equals to one day if (dateDiff <= 1) { - return [yesterdayOption, aWeekAgoOption]; + return [dayBeforeOption, weekBeforeOption]; } // Less than or equals to one week if (dateDiff <= 7) { - return [aWeekAgoOption]; + return [weekBeforeOption]; } } + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + + const dateFormat = getDateFormat({ + previousPeriodStart: comparisonStart, + currentPeriodEnd: end, + }); + const prevPeriodOption = { - value: 'previousPeriod', - text: formatPreviousPeriodDates({ momentStart, momentEnd }), + value: TimeRangeComparisonType.PeriodBefore, + text: formatDate({ + dateFormat, + previousPeriodStart: comparisonStart, + previousPeriodEnd: comparisonEnd, + }), }; // above one week or when rangeTo is not "now" diff --git a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts index 5b72a50e8dbd89..addef74f5b25b0 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts @@ -11,6 +11,7 @@ import { pickKeys } from '../../../common/utils/pick_keys'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; import { toQuery } from '../../components/shared/Links/url_helpers'; +import { TimeRangeComparisonType } from '../../components/shared/time_comparison/get_time_range_comparison'; import { getDateRange, removeUndefinedProps, @@ -84,8 +85,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { comparisonEnabled: comparisonEnabled ? toBoolean(comparisonEnabled) : undefined, - comparisonType, - + comparisonType: comparisonType as TimeRangeComparisonType | undefined, // ui filters environment, ...localUIFilters, diff --git a/x-pack/plugins/apm/public/context/url_params_context/types.ts b/x-pack/plugins/apm/public/context/url_params_context/types.ts index 723fca4487237f..4332019d1a1c9e 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/types.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/types.ts @@ -7,6 +7,7 @@ import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; import { LocalUIFilterName } from '../../../common/ui_filter'; +import { TimeRangeComparisonType } from '../../components/shared/time_comparison/get_time_range_comparison'; export type IUrlParams = { detailTab?: string; @@ -32,5 +33,5 @@ export type IUrlParams = { percentile?: number; latencyAggregationType?: LatencyAggregationType; comparisonEnabled?: boolean; - comparisonType?: string; + comparisonType?: TimeRangeComparisonType; } & Partial>; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 5de2abc312815e..b12a396befe8c4 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -6,7 +6,6 @@ */ import { Logger } from 'kibana/server'; -import moment from 'moment'; import { isActivePlatinumLicense } from '../../../common/license_check'; import { APMConfig } from '../..'; import { KibanaRequest } from '../../../../../../src/core/server'; @@ -54,19 +53,19 @@ interface SetupRequestParams { /** * Timestamp in ms since epoch */ - start?: string; + start?: number; /** * Timestamp in ms since epoch */ - end?: string; + end?: number; uiFilters?: string; }; } type InferSetup = Setup & - (TParams extends { query: { start: string } } ? { start: number } : {}) & - (TParams extends { query: { end: string } } ? { end: number } : {}); + (TParams extends { query: { start: number } } ? { start: number } : {}) & + (TParams extends { query: { end: number } } ? { end: number } : {}); export async function setupRequest( context: APMRequestHandlerContext, @@ -115,8 +114,8 @@ export async function setupRequest( }; return { - ...('start' in query ? { start: moment.utc(query.start).valueOf() } : {}), - ...('end' in query ? { end: moment.utc(query.end).valueOf() } : {}), + ...('start' in query ? { start: query.start } : {}), + ...('end' in query ? { end: query.end } : {}), ...coreSetupRequest, } as InferSetup; }); diff --git a/x-pack/plugins/apm/server/lib/services/get_throughput.ts b/x-pack/plugins/apm/server/lib/services/get_throughput.ts index c4e217f95bcd1b..33268e9b3332d7 100644 --- a/x-pack/plugins/apm/server/lib/services/get_throughput.ts +++ b/x-pack/plugins/apm/server/lib/services/get_throughput.ts @@ -6,7 +6,6 @@ */ import { ESFilter } from '../../../../../typings/elasticsearch'; -import { PromiseReturnType } from '../../../../observability/typings/common'; import { SERVICE_NAME, TRANSACTION_TYPE, @@ -17,38 +16,27 @@ import { getProcessorEventForAggregatedTransactions, } from '../helpers/aggregated_transactions'; import { getBucketSize } from '../helpers/get_bucket_size'; -import { calculateThroughput } from '../helpers/calculate_throughput'; -import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { Setup } from '../helpers/setup_request'; import { withApmSpan } from '../../utils/with_apm_span'; interface Options { searchAggregatedTransactions: boolean; serviceName: string; - setup: Setup & SetupTimeRange; + setup: Setup; transactionType: string; + start: number; + end: number; } -type ESResponse = PromiseReturnType; - -function transform(options: Options, response: ESResponse) { - if (response.hits.total.value === 0) { - return []; - } - const { start, end } = options.setup; - const buckets = response.aggregations?.throughput.buckets ?? []; - return buckets.map(({ key: x, doc_count: value }) => ({ - x, - y: calculateThroughput({ start, end, value }), - })); -} - -async function fetcher({ +function fetcher({ searchAggregatedTransactions, serviceName, setup, transactionType, + start, + end, }: Options) { - const { start, end, apmEventClient } = setup; + const { apmEventClient } = setup; const { intervalString } = getBucketSize({ start, end }); const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, @@ -72,13 +60,20 @@ async function fetcher({ size: 0, query: { bool: { filter } }, aggs: { - throughput: { + timeseries: { date_histogram: { field: '@timestamp', fixed_interval: intervalString, min_doc_count: 0, extended_bounds: { min: start, max: end }, }, + aggs: { + throughput: { + rate: { + unit: 'minute' as const, + }, + }, + }, }, }, }, @@ -89,8 +84,15 @@ async function fetcher({ export function getThroughput(options: Options) { return withApmSpan('get_throughput_for_service', async () => { - return { - throughput: transform(options, await fetcher(options)), - }; + const response = await fetcher(options); + + return ( + response.aggregations?.timeseries.buckets.map((bucket) => { + return { + x: bucket.key, + y: bucket.throughput.value, + }; + }) ?? [] + ); }); } diff --git a/x-pack/plugins/apm/server/routes/default_api_types.ts b/x-pack/plugins/apm/server/routes/default_api_types.ts index 0ab4e0331652b3..fdc1e8ebe5a55f 100644 --- a/x-pack/plugins/apm/server/routes/default_api_types.ts +++ b/x-pack/plugins/apm/server/routes/default_api_types.ts @@ -6,11 +6,16 @@ */ import * as t from 'io-ts'; -import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; +import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; export const rangeRt = t.type({ - start: dateAsStringRt, - end: dateAsStringRt, + start: isoToEpochRt, + end: isoToEpochRt, +}); + +export const comparisonRangeRt = t.partial({ + comparisonStart: isoToEpochRt, + comparisonEnd: isoToEpochRt, }); export const uiFiltersRt = t.type({ uiFilters: t.string }); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index a5c4de7552d784..ff064e0571d138 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,26 +5,27 @@ * 2.0. */ -import * as t from 'io-ts'; import Boom from '@hapi/boom'; +import * as t from 'io-ts'; import { uniq } from 'lodash'; +import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; +import { toNumberRt } from '../../common/runtime_types/to_number_rt'; +import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; -import { getServiceAgentName } from '../lib/services/get_service_agent_name'; -import { getServices } from '../lib/services/get_services'; -import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; -import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; -import { createRoute } from './create_route'; -import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; -import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; -import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; -import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'; +import { getServices } from '../lib/services/get_services'; +import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServiceDependencies } from '../lib/services/get_service_dependencies'; -import { toNumberRt } from '../../common/runtime_types/to_number_rt'; -import { getThroughput } from '../lib/services/get_throughput'; +import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'; import { getServiceInstances } from '../lib/services/get_service_instances'; import { getServiceMetadataDetails } from '../lib/services/get_service_metadata_details'; import { getServiceMetadataIcons } from '../lib/services/get_service_metadata_icons'; +import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; +import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; +import { getThroughput } from '../lib/services/get_throughput'; +import { offsetPreviousPeriodCoordinates } from '../utils/offset_previous_period_coordinate'; +import { createRoute } from './create_route'; +import { comparisonRangeRt, rangeRt, uiFiltersRt } from './default_api_types'; import { withApmSpan } from '../utils/with_apm_span'; export const servicesRoute = createRoute({ @@ -216,7 +217,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ }), body: t.intersection([ t.type({ - '@timestamp': dateAsStringRt, + '@timestamp': isoToEpochRt, service: t.intersection([ t.type({ version: t.string, @@ -251,6 +252,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ annotationsClient.create({ message: body.service.version, ...body, + '@timestamp': new Date(body['@timestamp']).toISOString(), annotation: { type: 'deployment', }, @@ -325,23 +327,56 @@ export const serviceThroughputRoute = createRoute({ t.type({ transactionType: t.string }), uiFiltersRt, rangeRt, + comparisonRangeRt, ]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName } = context.params.path; - const { transactionType } = context.params.query; + const { + transactionType, + comparisonStart, + comparisonEnd, + } = context.params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); - return getThroughput({ + const { start, end } = setup; + + const commonProps = { searchAggregatedTransactions, serviceName, setup, transactionType, - }); + }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + getThroughput({ + ...commonProps, + start, + end, + }), + comparisonStart && comparisonEnd + ? getThroughput({ + ...commonProps, + start: comparisonStart, + end: comparisonEnd, + }).then((coordinates) => + offsetPreviousPeriodCoordinates({ + currentPeriodStart: start, + previousPeriodStart: comparisonStart, + previousPeriodTimeseries: coordinates, + }) + ) + : [], + ]); + + return { + currentPeriod, + previousPeriod, + }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 61875db0985e49..ae0d9aeeaade16 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import Boom from '@hapi/boom'; +import { toBooleanRt } from '../../../common/runtime_types/to_boolean_rt'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getServiceNames } from '../../lib/settings/agent_configuration/get_service_names'; import { createOrUpdateConfiguration } from '../../lib/settings/agent_configuration/create_or_update_configuration'; @@ -22,7 +23,6 @@ import { serviceRt, agentConfigurationIntakeRt, } from '../../../common/agent_configuration/runtime_types/agent_configuration_intake_rt'; -import { jsonRt } from '../../../common/runtime_types/json_rt'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; // get list of configurations @@ -103,7 +103,7 @@ export const createOrUpdateAgentConfigurationRoute = createRoute({ tags: ['access:apm', 'access:apm_write'], }, params: t.intersection([ - t.partial({ query: t.partial({ overwrite: jsonRt.pipe(t.boolean) }) }), + t.partial({ query: t.partial({ overwrite: toBooleanRt }) }), t.type({ body: agentConfigurationIntakeRt }), ]), handler: async ({ context, request }) => { diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index e5901cabc4ef69..4d3e07040f76b0 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -143,7 +143,7 @@ export type Client< forceCache?: boolean; endpoint: TEndpoint; } & (TRouteState[TEndpoint] extends { params: t.Any } - ? MaybeOptional<{ params: t.TypeOf }> + ? MaybeOptional<{ params: t.OutputOf }> : {}) & (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) ) => Promise< diff --git a/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts new file mode 100644 index 00000000000000..6436c7c5193ecb --- /dev/null +++ b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Coordinate } from '../../typings/timeseries'; +import { offsetPreviousPeriodCoordinates } from './offset_previous_period_coordinate'; + +const previousPeriodStart = new Date('2021-01-27T14:45:00.000Z').valueOf(); +const currentPeriodStart = new Date('2021-01-28T14:45:00.000Z').valueOf(); + +describe('mergePeriodsTimeseries', () => { + describe('returns empty array', () => { + it('when previous timeseries is not defined', () => { + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries: undefined, + }) + ).toEqual([]); + }); + + it('when previous timeseries is empty', () => { + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries: [], + }) + ).toEqual([]); + }); + }); + + it('offsets previous period timeseries', () => { + const previousPeriodTimeseries: Coordinate[] = [ + { x: new Date('2021-01-27T14:45:00.000Z').valueOf(), y: 1 }, + { x: new Date('2021-01-27T15:00:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-27T15:15:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-27T15:30:00.000Z').valueOf(), y: 3 }, + ]; + + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries, + }) + ).toEqual([ + { x: new Date('2021-01-28T14:45:00.000Z').valueOf(), y: 1 }, + { x: new Date('2021-01-28T15:00:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-28T15:15:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-28T15:30:00.000Z').valueOf(), y: 3 }, + ]); + }); +}); diff --git a/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts new file mode 100644 index 00000000000000..837e3d02056f0f --- /dev/null +++ b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { Coordinate } from '../../typings/timeseries'; + +export function offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries, +}: { + currentPeriodStart: number; + previousPeriodStart: number; + previousPeriodTimeseries?: Coordinate[]; +}) { + if (!previousPeriodTimeseries) { + return []; + } + + const dateOffset = moment(currentPeriodStart).diff( + moment(previousPeriodStart) + ); + + return previousPeriodTimeseries.map(({ x, y }) => { + const offsetX = moment(x).add(dateOffset).valueOf(); + return { + x: offsetX, + y, + }; + }); +} diff --git a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap index eee0ec7f9ad38a..b4fd2219cb7331 100644 --- a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap +++ b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap @@ -8,7 +8,7 @@ Array [ }, Object { "x": 1607435880000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607435910000, @@ -16,7 +16,7 @@ Array [ }, Object { "x": 1607435940000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607435970000, @@ -24,11 +24,11 @@ Array [ }, Object { "x": 1607436000000, - "y": 0.1, + "y": 6, }, Object { "x": 1607436030000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436060000, @@ -40,7 +40,7 @@ Array [ }, Object { "x": 1607436120000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607436150000, @@ -56,7 +56,7 @@ Array [ }, Object { "x": 1607436240000, - "y": 0.2, + "y": 12, }, Object { "x": 1607436270000, @@ -68,15 +68,15 @@ Array [ }, Object { "x": 1607436330000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436360000, - "y": 0.166666666666667, + "y": 10, }, Object { "x": 1607436390000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436420000, @@ -88,11 +88,11 @@ Array [ }, Object { "x": 1607436480000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436510000, - "y": 0.166666666666667, + "y": 10, }, Object { "x": 1607436540000, @@ -104,11 +104,11 @@ Array [ }, Object { "x": 1607436600000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436630000, - "y": 0.233333333333333, + "y": 14, }, Object { "x": 1607436660000, @@ -124,7 +124,7 @@ Array [ }, Object { "x": 1607436750000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436780000, @@ -132,15 +132,15 @@ Array [ }, Object { "x": 1607436810000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436840000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436870000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436900000, @@ -152,11 +152,11 @@ Array [ }, Object { "x": 1607436960000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436990000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607437020000, @@ -168,11 +168,11 @@ Array [ }, Object { "x": 1607437080000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437110000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437140000, @@ -184,15 +184,15 @@ Array [ }, Object { "x": 1607437200000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607437230000, - "y": 0.233333333333333, + "y": 14, }, Object { "x": 1607437260000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437290000, @@ -200,11 +200,11 @@ Array [ }, Object { "x": 1607437320000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437350000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607437380000, @@ -216,11 +216,11 @@ Array [ }, Object { "x": 1607437440000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437470000, - "y": 0.1, + "y": 6, }, Object { "x": 1607437500000, @@ -232,7 +232,7 @@ Array [ }, Object { "x": 1607437560000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437590000, @@ -248,3 +248,740 @@ Array [ }, ] `; + +exports[`APM API tests basic apm_8.0.0 Throughput when data is loaded with time comparison has the correct throughput 1`] = ` +Object { + "currentPeriod": Array [ + Object { + "x": 1607436770000, + "y": 0, + }, + Object { + "x": 1607436780000, + "y": 0, + }, + Object { + "x": 1607436790000, + "y": 0, + }, + Object { + "x": 1607436800000, + "y": 0, + }, + Object { + "x": 1607436810000, + "y": 0, + }, + Object { + "x": 1607436820000, + "y": 6, + }, + Object { + "x": 1607436830000, + "y": 0, + }, + Object { + "x": 1607436840000, + "y": 0, + }, + Object { + "x": 1607436850000, + "y": 0, + }, + Object { + "x": 1607436860000, + "y": 6, + }, + Object { + "x": 1607436870000, + "y": 6, + }, + Object { + "x": 1607436880000, + "y": 6, + }, + Object { + "x": 1607436890000, + "y": 0, + }, + Object { + "x": 1607436900000, + "y": 0, + }, + Object { + "x": 1607436910000, + "y": 0, + }, + Object { + "x": 1607436920000, + "y": 0, + }, + Object { + "x": 1607436930000, + "y": 0, + }, + Object { + "x": 1607436940000, + "y": 0, + }, + Object { + "x": 1607436950000, + "y": 0, + }, + Object { + "x": 1607436960000, + "y": 0, + }, + Object { + "x": 1607436970000, + "y": 0, + }, + Object { + "x": 1607436980000, + "y": 12, + }, + Object { + "x": 1607436990000, + "y": 6, + }, + Object { + "x": 1607437000000, + "y": 18, + }, + Object { + "x": 1607437010000, + "y": 0, + }, + Object { + "x": 1607437020000, + "y": 0, + }, + Object { + "x": 1607437030000, + "y": 0, + }, + Object { + "x": 1607437040000, + "y": 0, + }, + Object { + "x": 1607437050000, + "y": 0, + }, + Object { + "x": 1607437060000, + "y": 0, + }, + Object { + "x": 1607437070000, + "y": 0, + }, + Object { + "x": 1607437080000, + "y": 0, + }, + Object { + "x": 1607437090000, + "y": 0, + }, + Object { + "x": 1607437100000, + "y": 6, + }, + Object { + "x": 1607437110000, + "y": 6, + }, + Object { + "x": 1607437120000, + "y": 0, + }, + Object { + "x": 1607437130000, + "y": 0, + }, + Object { + "x": 1607437140000, + "y": 0, + }, + Object { + "x": 1607437150000, + "y": 0, + }, + Object { + "x": 1607437160000, + "y": 0, + }, + Object { + "x": 1607437170000, + "y": 0, + }, + Object { + "x": 1607437180000, + "y": 0, + }, + Object { + "x": 1607437190000, + "y": 0, + }, + Object { + "x": 1607437200000, + "y": 0, + }, + Object { + "x": 1607437210000, + "y": 0, + }, + Object { + "x": 1607437220000, + "y": 12, + }, + Object { + "x": 1607437230000, + "y": 30, + }, + Object { + "x": 1607437240000, + "y": 12, + }, + Object { + "x": 1607437250000, + "y": 0, + }, + Object { + "x": 1607437260000, + "y": 0, + }, + Object { + "x": 1607437270000, + "y": 6, + }, + Object { + "x": 1607437280000, + "y": 0, + }, + Object { + "x": 1607437290000, + "y": 0, + }, + Object { + "x": 1607437300000, + "y": 0, + }, + Object { + "x": 1607437310000, + "y": 0, + }, + Object { + "x": 1607437320000, + "y": 0, + }, + Object { + "x": 1607437330000, + "y": 0, + }, + Object { + "x": 1607437340000, + "y": 6, + }, + Object { + "x": 1607437350000, + "y": 0, + }, + Object { + "x": 1607437360000, + "y": 12, + }, + Object { + "x": 1607437370000, + "y": 0, + }, + Object { + "x": 1607437380000, + "y": 0, + }, + Object { + "x": 1607437390000, + "y": 0, + }, + Object { + "x": 1607437400000, + "y": 0, + }, + Object { + "x": 1607437410000, + "y": 0, + }, + Object { + "x": 1607437420000, + "y": 0, + }, + Object { + "x": 1607437430000, + "y": 0, + }, + Object { + "x": 1607437440000, + "y": 0, + }, + Object { + "x": 1607437450000, + "y": 0, + }, + Object { + "x": 1607437460000, + "y": 6, + }, + Object { + "x": 1607437470000, + "y": 12, + }, + Object { + "x": 1607437480000, + "y": 6, + }, + Object { + "x": 1607437490000, + "y": 0, + }, + Object { + "x": 1607437500000, + "y": 0, + }, + Object { + "x": 1607437510000, + "y": 0, + }, + Object { + "x": 1607437520000, + "y": 0, + }, + Object { + "x": 1607437530000, + "y": 0, + }, + Object { + "x": 1607437540000, + "y": 0, + }, + Object { + "x": 1607437550000, + "y": 0, + }, + Object { + "x": 1607437560000, + "y": 0, + }, + Object { + "x": 1607437570000, + "y": 6, + }, + Object { + "x": 1607437580000, + "y": 0, + }, + Object { + "x": 1607437590000, + "y": 0, + }, + Object { + "x": 1607437600000, + "y": 0, + }, + Object { + "x": 1607437610000, + "y": 0, + }, + Object { + "x": 1607437620000, + "y": 0, + }, + Object { + "x": 1607437630000, + "y": 0, + }, + Object { + "x": 1607437640000, + "y": 0, + }, + Object { + "x": 1607437650000, + "y": 0, + }, + Object { + "x": 1607437660000, + "y": 0, + }, + Object { + "x": 1607437670000, + "y": 0, + }, + ], + "previousPeriod": Array [ + Object { + "x": 1607436770000, + "y": 0, + }, + Object { + "x": 1607436780000, + "y": 0, + }, + Object { + "x": 1607436790000, + "y": 0, + }, + Object { + "x": 1607436800000, + "y": 24, + }, + Object { + "x": 1607436810000, + "y": 0, + }, + Object { + "x": 1607436820000, + "y": 0, + }, + Object { + "x": 1607436830000, + "y": 0, + }, + Object { + "x": 1607436840000, + "y": 12, + }, + Object { + "x": 1607436850000, + "y": 0, + }, + Object { + "x": 1607436860000, + "y": 0, + }, + Object { + "x": 1607436870000, + "y": 0, + }, + Object { + "x": 1607436880000, + "y": 0, + }, + Object { + "x": 1607436890000, + "y": 0, + }, + Object { + "x": 1607436900000, + "y": 0, + }, + Object { + "x": 1607436910000, + "y": 12, + }, + Object { + "x": 1607436920000, + "y": 6, + }, + Object { + "x": 1607436930000, + "y": 6, + }, + Object { + "x": 1607436940000, + "y": 0, + }, + Object { + "x": 1607436950000, + "y": 0, + }, + Object { + "x": 1607436960000, + "y": 0, + }, + Object { + "x": 1607436970000, + "y": 0, + }, + Object { + "x": 1607436980000, + "y": 0, + }, + Object { + "x": 1607436990000, + "y": 0, + }, + Object { + "x": 1607437000000, + "y": 0, + }, + Object { + "x": 1607437010000, + "y": 0, + }, + Object { + "x": 1607437020000, + "y": 0, + }, + Object { + "x": 1607437030000, + "y": 6, + }, + Object { + "x": 1607437040000, + "y": 18, + }, + Object { + "x": 1607437050000, + "y": 0, + }, + Object { + "x": 1607437060000, + "y": 0, + }, + Object { + "x": 1607437070000, + "y": 0, + }, + Object { + "x": 1607437080000, + "y": 0, + }, + Object { + "x": 1607437090000, + "y": 0, + }, + Object { + "x": 1607437100000, + "y": 0, + }, + Object { + "x": 1607437110000, + "y": 0, + }, + Object { + "x": 1607437120000, + "y": 0, + }, + Object { + "x": 1607437130000, + "y": 0, + }, + Object { + "x": 1607437140000, + "y": 0, + }, + Object { + "x": 1607437150000, + "y": 0, + }, + Object { + "x": 1607437160000, + "y": 36, + }, + Object { + "x": 1607437170000, + "y": 0, + }, + Object { + "x": 1607437180000, + "y": 0, + }, + Object { + "x": 1607437190000, + "y": 0, + }, + Object { + "x": 1607437200000, + "y": 0, + }, + Object { + "x": 1607437210000, + "y": 0, + }, + Object { + "x": 1607437220000, + "y": 0, + }, + Object { + "x": 1607437230000, + "y": 0, + }, + Object { + "x": 1607437240000, + "y": 6, + }, + Object { + "x": 1607437250000, + "y": 0, + }, + Object { + "x": 1607437260000, + "y": 0, + }, + Object { + "x": 1607437270000, + "y": 0, + }, + Object { + "x": 1607437280000, + "y": 30, + }, + Object { + "x": 1607437290000, + "y": 6, + }, + Object { + "x": 1607437300000, + "y": 0, + }, + Object { + "x": 1607437310000, + "y": 0, + }, + Object { + "x": 1607437320000, + "y": 0, + }, + Object { + "x": 1607437330000, + "y": 0, + }, + Object { + "x": 1607437340000, + "y": 0, + }, + Object { + "x": 1607437350000, + "y": 0, + }, + Object { + "x": 1607437360000, + "y": 0, + }, + Object { + "x": 1607437370000, + "y": 0, + }, + Object { + "x": 1607437380000, + "y": 0, + }, + Object { + "x": 1607437390000, + "y": 0, + }, + Object { + "x": 1607437400000, + "y": 12, + }, + Object { + "x": 1607437410000, + "y": 6, + }, + Object { + "x": 1607437420000, + "y": 24, + }, + Object { + "x": 1607437430000, + "y": 0, + }, + Object { + "x": 1607437440000, + "y": 0, + }, + Object { + "x": 1607437450000, + "y": 0, + }, + Object { + "x": 1607437460000, + "y": 0, + }, + Object { + "x": 1607437470000, + "y": 0, + }, + Object { + "x": 1607437480000, + "y": 0, + }, + Object { + "x": 1607437490000, + "y": 0, + }, + Object { + "x": 1607437500000, + "y": 0, + }, + Object { + "x": 1607437510000, + "y": 0, + }, + Object { + "x": 1607437520000, + "y": 12, + }, + Object { + "x": 1607437530000, + "y": 30, + }, + Object { + "x": 1607437540000, + "y": 12, + }, + Object { + "x": 1607437550000, + "y": 0, + }, + Object { + "x": 1607437560000, + "y": 0, + }, + Object { + "x": 1607437570000, + "y": 0, + }, + Object { + "x": 1607437580000, + "y": 0, + }, + Object { + "x": 1607437590000, + "y": 0, + }, + Object { + "x": 1607437600000, + "y": 0, + }, + Object { + "x": 1607437610000, + "y": 0, + }, + Object { + "x": 1607437620000, + "y": 0, + }, + Object { + "x": 1607437630000, + "y": 0, + }, + Object { + "x": 1607437640000, + "y": 0, + }, + Object { + "x": 1607437650000, + "y": 6, + }, + Object { + "x": 1607437660000, + "y": 6, + }, + Object { + "x": 1607437670000, + "y": 0, + }, + ], +} +`; diff --git a/x-pack/test/apm_api_integration/tests/services/throughput.ts b/x-pack/test/apm_api_integration/tests/services/throughput.ts index 29f5d84d31b07d..787436ea37b05d 100644 --- a/x-pack/test/apm_api_integration/tests/services/throughput.ts +++ b/x-pack/test/apm_api_integration/tests/services/throughput.ts @@ -8,10 +8,15 @@ import expect from '@kbn/expect'; import qs from 'querystring'; import { first, last } from 'lodash'; +import moment from 'moment'; +import { isFiniteNumber } from '../../../../plugins/apm/common/utils/is_finite_number'; +import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi'; import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { registry } from '../../common/registry'; +type ThroughputReturn = APIReturnType<'GET /api/apm/services/{serviceName}/throughput'>; + export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -29,17 +34,16 @@ export default function ApiTest({ getService }: FtrProviderContext) { })}` ); expect(response.status).to.be(200); - expect(response.body.throughput.length).to.be(0); + expect(response.body.currentPeriod.length).to.be(0); + expect(response.body.previousPeriod.length).to.be(0); }); }); + let throughputResponse: ThroughputReturn; registry.when( 'Throughput when data is loaded', { config: 'basic', archives: [archiveName] }, () => { - let throughputResponse: { - throughput: Array<{ x: number; y: number | null }>; - }; before(async () => { const response = await supertest.get( `/api/apm/services/opbeans-java/throughput?${qs.stringify({ @@ -53,31 +57,98 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('returns some data', () => { - expect(throughputResponse.throughput.length).to.be.greaterThan(0); + expect(throughputResponse.currentPeriod.length).to.be.greaterThan(0); + expect(throughputResponse.previousPeriod.length).not.to.be.greaterThan(0); - const nonNullDataPoints = throughputResponse.throughput.filter(({ y }) => y !== null); + const nonNullDataPoints = throughputResponse.currentPeriod.filter(({ y }) => + isFiniteNumber(y) + ); expect(nonNullDataPoints.length).to.be.greaterThan(0); }); it('has the correct start date', () => { expectSnapshot( - new Date(first(throughputResponse.throughput)?.x ?? NaN).toISOString() + new Date(first(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() ).toMatchInline(`"2020-12-08T13:57:30.000Z"`); }); it('has the correct end date', () => { expectSnapshot( - new Date(last(throughputResponse.throughput)?.x ?? NaN).toISOString() + new Date(last(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() ).toMatchInline(`"2020-12-08T14:27:30.000Z"`); }); it('has the correct number of buckets', () => { - expectSnapshot(throughputResponse.throughput.length).toMatchInline(`61`); + expectSnapshot(throughputResponse.currentPeriod.length).toMatchInline(`61`); + }); + + it('has the correct throughput', () => { + expectSnapshot(throughputResponse.currentPeriod).toMatch(); + }); + } + ); + + registry.when( + 'Throughput when data is loaded with time comparison', + { config: 'basic', archives: [archiveName] }, + () => { + before(async () => { + const response = await supertest.get( + `/api/apm/services/opbeans-java/throughput?${qs.stringify({ + uiFilters: encodeURIComponent('{}'), + transactionType: 'request', + start: moment(metadata.end).subtract(15, 'minutes').toISOString(), + end: metadata.end, + comparisonStart: metadata.start, + comparisonEnd: moment(metadata.start).add(15, 'minutes').toISOString(), + })}` + ); + throughputResponse = response.body; + }); + + it('returns some data', () => { + expect(throughputResponse.currentPeriod.length).to.be.greaterThan(0); + expect(throughputResponse.previousPeriod.length).to.be.greaterThan(0); + + const currentPeriodNonNullDataPoints = throughputResponse.currentPeriod.filter(({ y }) => + isFiniteNumber(y) + ); + const previousPeriodNonNullDataPoints = throughputResponse.previousPeriod.filter(({ y }) => + isFiniteNumber(y) + ); + + expect(currentPeriodNonNullDataPoints.length).to.be.greaterThan(0); + expect(previousPeriodNonNullDataPoints.length).to.be.greaterThan(0); + }); + + it('has the correct start date', () => { + expectSnapshot( + new Date(first(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:12:50.000Z"`); + + expectSnapshot( + new Date(first(throughputResponse.previousPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:12:50.000Z"`); + }); + + it('has the correct end date', () => { + expectSnapshot( + new Date(last(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:27:50.000Z"`); + + expectSnapshot( + new Date(last(throughputResponse.previousPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:27:50.000Z"`); + }); + + it('has the correct number of buckets', () => { + expectSnapshot(throughputResponse.currentPeriod.length).toMatchInline(`91`); + expectSnapshot(throughputResponse.previousPeriod.length).toMatchInline(`91`); }); it('has the correct throughput', () => { - expectSnapshot(throughputResponse.throughput).toMatch(); + expectSnapshot(throughputResponse).toMatch(); }); } ); From 104eacb59a82b55d26e433123cd416f2983052cd Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Sat, 13 Feb 2021 01:42:56 -0700 Subject: [PATCH 020/175] [data.search] Add user information to background session service (#84975) * [data.search] Move search method inside session service and add tests * Move background session service to data_enhanced plugin * Fix types * [data.search] Add user information to background session service * Update trackId & getId to accept user * Fix remaining merge conflicts * Fix test * Remove todos * Fix session service to use user * Remove user conflicts and update SO filter * Allow filter as string or KQL node * Add back user checks * Add API integration tests * Remove unnecessary get calls --- .../common/search/session/types.ts | 6 + x-pack/plugins/data_enhanced/kibana.json | 2 +- x-pack/plugins/data_enhanced/server/plugin.ts | 5 +- .../server/saved_objects/search_session.ts | 9 + .../search/session/session_service.test.ts | 725 ++++++++++++++---- .../server/search/session/session_service.ts | 170 +++- x-pack/plugins/data_enhanced/tsconfig.json | 1 + .../api_integration/apis/search/session.ts | 116 +++ 8 files changed, 838 insertions(+), 196 deletions(-) diff --git a/x-pack/plugins/data_enhanced/common/search/session/types.ts b/x-pack/plugins/data_enhanced/common/search/session/types.ts index 4c5fe846cebd2f..788ab30756e1c4 100644 --- a/x-pack/plugins/data_enhanced/common/search/session/types.ts +++ b/x-pack/plugins/data_enhanced/common/search/session/types.ts @@ -57,6 +57,12 @@ export interface SearchSessionSavedObjectAttributes { * This value is true if the session was actively stored by the user. If it is false, the session may be purged by the system. */ persisted: boolean; + /** + * The realm type/name & username uniquely identifies the user who created this search session + */ + realmType?: string; + realmName?: string; + username?: string; } export interface SearchSessionRequestInfo { diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index 037f52fcb4b05a..a0489ecd30aaac 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "data_enhanced"], "requiredPlugins": ["bfetch", "data", "features", "management", "share", "taskManager"], - "optionalPlugins": ["kibanaUtils", "usageCollection"], + "optionalPlugins": ["kibanaUtils", "usageCollection", "security"], "server": true, "ui": true, "requiredBundles": ["kibanaUtils", "kibanaReact"] diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index 3aaf50fbeb3e69..c3d342b8159e37 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -24,12 +24,15 @@ import { import { getUiSettings } from './ui_settings'; import type { DataEnhancedRequestHandlerContext } from './type'; import { ConfigSchema } from '../config'; +import { SecurityPluginSetup } from '../../security/server'; interface SetupDependencies { data: DataPluginSetup; usageCollection?: UsageCollectionSetup; taskManager: TaskManagerSetupContract; + security?: SecurityPluginSetup; } + export interface StartDependencies { data: DataPluginStart; taskManager: TaskManagerStartContract; @@ -67,7 +70,7 @@ export class EnhancedDataServerPlugin eqlSearchStrategyProvider(this.logger) ); - this.sessionService = new SearchSessionService(this.logger, this.config); + this.sessionService = new SearchSessionService(this.logger, this.config, deps.security); deps.data.__enhance({ search: { diff --git a/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts b/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts index fe522005e45581..fd3d24b71f97da 100644 --- a/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts +++ b/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts @@ -53,6 +53,15 @@ export const searchSessionMapping: SavedObjectsType = { type: 'object', enabled: false, }, + realmType: { + type: 'keyword', + }, + realmName: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, }, }, }; diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts index b195a32ad481f2..f61d89e2301abd 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts @@ -19,6 +19,8 @@ import { coreMock } from 'src/core/server/mocks'; import { ConfigSchema } from '../../../config'; // @ts-ignore import { taskManagerMock } from '../../../../task_manager/server/mocks'; +import { AuthenticatedUser } from '../../../../security/common/model'; +import { nodeBuilder } from '../../../../../../src/plugins/data/common'; const MAX_UPDATE_RETRIES = 3; @@ -31,7 +33,21 @@ describe('SearchSessionService', () => { const MOCK_STRATEGY = 'ese'; const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489'; - const mockSavedObject: SavedObject = { + const mockUser1 = { + username: 'my_username', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, + } as AuthenticatedUser; + const mockUser2 = { + username: 'bar', + authentication_realm: { + type: 'bar', + name: 'bar', + }, + } as AuthenticatedUser; + const mockSavedObject: SavedObject = { id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', type: SEARCH_SESSION_TYPE, attributes: { @@ -39,6 +55,9 @@ describe('SearchSessionService', () => { appId: 'my_app_id', urlGeneratorId: 'my_url_generator_id', idMapping: {}, + realmType: mockUser1.authentication_realm.type, + realmName: mockUser1.authentication_realm.name, + username: mockUser1.username, }, references: [], }; @@ -77,66 +96,551 @@ describe('SearchSessionService', () => { service.stop(); }); - it('get calls saved objects client', async () => { - savedObjectsClient.get.mockResolvedValue(mockSavedObject); + describe('save', () => { + it('throws if `name` is not provided', () => { + expect(() => + service.save({ savedObjectsClient }, mockUser1, sessionId, {}) + ).rejects.toMatchInlineSnapshot(`[Error: Name is required]`); + }); + + it('throws if `appId` is not provided', () => { + expect( + service.save({ savedObjectsClient }, mockUser1, sessionId, { name: 'banana' }) + ).rejects.toMatchInlineSnapshot(`[Error: AppId is required]`); + }); + + it('throws if `generator id` is not provided', () => { + expect( + service.save({ savedObjectsClient }, mockUser1, sessionId, { + name: 'banana', + appId: 'nanana', + }) + ).rejects.toMatchInlineSnapshot(`[Error: UrlGeneratorId is required]`); + }); + + it('saving updates an existing saved object and persists it', async () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + + await service.save({ savedObjectsClient }, mockUser1, sessionId, { + name: 'banana', + appId: 'nanana', + urlGeneratorId: 'panama', + }); + + expect(savedObjectsClient.update).toHaveBeenCalled(); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).not.toHaveProperty('idMapping'); + expect(callAttributes).toHaveProperty('touched'); + expect(callAttributes).toHaveProperty('persisted', true); + expect(callAttributes).toHaveProperty('name', 'banana'); + expect(callAttributes).toHaveProperty('appId', 'nanana'); + expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); + expect(callAttributes).toHaveProperty('initialState', {}); + expect(callAttributes).toHaveProperty('restoreState', {}); + }); + + it('saving creates a new persisted saved object, if it did not exist', async () => { + const mockCreatedSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + + savedObjectsClient.update.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId) + ); + savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject); + + await service.save({ savedObjectsClient }, mockUser1, sessionId, { + name: 'banana', + appId: 'nanana', + urlGeneratorId: 'panama', + }); + + expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + + const [type, callAttributes, options] = savedObjectsClient.create.mock.calls[0]; + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(options?.id).toBe(sessionId); + expect(callAttributes).toHaveProperty('idMapping', {}); + expect(callAttributes).toHaveProperty('touched'); + expect(callAttributes).toHaveProperty('expires'); + expect(callAttributes).toHaveProperty('created'); + expect(callAttributes).toHaveProperty('persisted', true); + expect(callAttributes).toHaveProperty('name', 'banana'); + expect(callAttributes).toHaveProperty('appId', 'nanana'); + expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); + expect(callAttributes).toHaveProperty('initialState', {}); + expect(callAttributes).toHaveProperty('restoreState', {}); + expect(callAttributes).toHaveProperty('realmType', mockUser1.authentication_realm.type); + expect(callAttributes).toHaveProperty('realmName', mockUser1.authentication_realm.name); + expect(callAttributes).toHaveProperty('username', mockUser1.username); + }); + + it('throws error if user conflicts', () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + expect( + service.get({ savedObjectsClient }, mockUser2, sessionId) + ).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + }); + + it('works without security', async () => { + savedObjectsClient.update.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId) + ); + + await service.save( + { savedObjectsClient }, + + null, + sessionId, + { + name: 'my_name', + appId: 'my_app_id', + urlGeneratorId: 'my_url_generator_id', + } + ); + + expect(savedObjectsClient.create).toHaveBeenCalled(); + const [[, attributes]] = savedObjectsClient.create.mock.calls; + expect(attributes).toHaveProperty('realmType', undefined); + expect(attributes).toHaveProperty('realmName', undefined); + expect(attributes).toHaveProperty('username', undefined); + }); + }); + + describe('get', () => { + it('calls saved objects client', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + const response = await service.get({ savedObjectsClient }, mockUser1, sessionId); + + expect(response).toBe(mockSavedObject); + expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId); + }); - const response = await service.get({ savedObjectsClient }, sessionId); + it('works without security', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); - expect(response).toBe(mockSavedObject); - expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId); + const response = await service.get({ savedObjectsClient }, null, sessionId); + + expect(response).toBe(mockSavedObject); + expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId); + }); }); - it('find calls saved objects client', async () => { - const mockFindSavedObject = { - ...mockSavedObject, - score: 1, - }; - const mockResponse = { - saved_objects: [mockFindSavedObject], - total: 1, - per_page: 1, - page: 0, - }; - savedObjectsClient.find.mockResolvedValue(mockResponse); + describe('find', () => { + it('calls saved objects client with user filter', async () => { + const mockFindSavedObject = { + ...mockSavedObject, + score: 1, + }; + const mockResponse = { + saved_objects: [mockFindSavedObject], + total: 1, + per_page: 1, + page: 0, + }; + savedObjectsClient.find.mockResolvedValue(mockResponse); + + const options = { page: 0, perPage: 5 }; + const response = await service.find({ savedObjectsClient }, mockUser1, options); + + expect(response).toBe(mockResponse); + const [[findOptions]] = savedObjectsClient.find.mock.calls; + expect(findOptions).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmType", + }, + Object { + "type": "literal", + "value": "my_realm_type", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmName", + }, + Object { + "type": "literal", + "value": "my_realm_name", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.username", + }, + Object { + "type": "literal", + "value": "my_username", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "page": 0, + "perPage": 5, + "type": "search-session", + } + `); + }); - const options = { page: 0, perPage: 5 }; - const response = await service.find({ savedObjectsClient }, options); + it('mixes in passed-in filter as string and KQL node', async () => { + const mockFindSavedObject = { + ...mockSavedObject, + score: 1, + }; + const mockResponse = { + saved_objects: [mockFindSavedObject], + total: 1, + per_page: 1, + page: 0, + }; + savedObjectsClient.find.mockResolvedValue(mockResponse); + + const options1 = { filter: 'foobar' }; + const response1 = await service.find({ savedObjectsClient }, mockUser1, options1); + + const options2 = { filter: nodeBuilder.is('foo', 'bar') }; + const response2 = await service.find({ savedObjectsClient }, mockUser1, options2); + + expect(response1).toBe(mockResponse); + expect(response2).toBe(mockResponse); + + const [[findOptions1], [findOptions2]] = savedObjectsClient.find.mock.calls; + expect(findOptions1).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmType", + }, + Object { + "type": "literal", + "value": "my_realm_type", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmName", + }, + Object { + "type": "literal", + "value": "my_realm_name", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.username", + }, + Object { + "type": "literal", + "value": "my_username", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": null, + }, + Object { + "type": "literal", + "value": "foobar", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "type": "search-session", + } + `); + expect(findOptions2).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmType", + }, + Object { + "type": "literal", + "value": "my_realm_type", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmName", + }, + Object { + "type": "literal", + "value": "my_realm_name", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.username", + }, + Object { + "type": "literal", + "value": "my_username", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "foo", + }, + Object { + "type": "literal", + "value": "bar", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "type": "search-session", + } + `); + }); - expect(response).toBe(mockResponse); - expect(savedObjectsClient.find).toHaveBeenCalledWith({ - ...options, - type: SEARCH_SESSION_TYPE, + it('has no filter without security', async () => { + const mockFindSavedObject = { + ...mockSavedObject, + score: 1, + }; + const mockResponse = { + saved_objects: [mockFindSavedObject], + total: 1, + per_page: 1, + page: 0, + }; + savedObjectsClient.find.mockResolvedValue(mockResponse); + + const options = { page: 0, perPage: 5 }; + const response = await service.find({ savedObjectsClient }, null, options); + + expect(response).toBe(mockResponse); + const [[findOptions]] = savedObjectsClient.find.mock.calls; + expect(findOptions).toMatchInlineSnapshot(` + Object { + "filter": undefined, + "page": 0, + "perPage": 5, + "type": "search-session", + } + `); }); }); - it('update calls saved objects client with added touch time', async () => { - const mockUpdateSavedObject = { - ...mockSavedObject, - attributes: {}, - }; - savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + describe('update', () => { + it('update calls saved objects client with added touch time', async () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + + const attributes = { name: 'new_name' }; + const response = await service.update( + { savedObjectsClient }, + mockUser1, + sessionId, + attributes + ); + + expect(response).toBe(mockUpdateSavedObject); + + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('name', attributes.name); + expect(callAttributes).toHaveProperty('touched'); + }); + + it('throws if user conflicts', () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - const attributes = { name: 'new_name' }; - const response = await service.update({ savedObjectsClient }, sessionId, attributes); + const attributes = { name: 'new_name' }; + expect( + service.update({ savedObjectsClient }, mockUser2, sessionId, attributes) + ).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + }); - expect(response).toBe(mockUpdateSavedObject); + it('works without security', async () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + const attributes = { name: 'new_name' }; + const response = await service.update({ savedObjectsClient }, null, sessionId, attributes); + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(id).toBe(sessionId); - expect(callAttributes).toHaveProperty('name', attributes.name); - expect(callAttributes).toHaveProperty('touched'); + expect(response).toBe(mockUpdateSavedObject); + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('name', 'new_name'); + expect(callAttributes).toHaveProperty('touched'); + }); }); - it('cancel updates object status', async () => { - await service.cancel({ savedObjectsClient }, sessionId); - const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + describe('cancel', () => { + it('updates object status', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + await service.cancel({ savedObjectsClient }, mockUser1, sessionId); + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(id).toBe(sessionId); - expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED); - expect(callAttributes).toHaveProperty('touched'); + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED); + expect(callAttributes).toHaveProperty('touched'); + }); + + it('throws if user conflicts', () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + expect( + service.cancel({ savedObjectsClient }, mockUser2, sessionId) + ).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + }); + + it('works without security', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + await service.cancel({ savedObjectsClient }, null, sessionId); + + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED); + expect(callAttributes).toHaveProperty('touched'); + }); }); describe('trackId', () => { @@ -151,7 +655,7 @@ describe('SearchSessionService', () => { }; savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -194,7 +698,7 @@ describe('SearchSessionService', () => { }); }); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -213,7 +717,7 @@ describe('SearchSessionService', () => { }); }); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -238,7 +742,7 @@ describe('SearchSessionService', () => { ); savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -289,7 +793,7 @@ describe('SearchSessionService', () => { SavedObjectsErrorHelpers.createConflictError(SEARCH_SESSION_TYPE, searchId) ); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -309,7 +813,7 @@ describe('SearchSessionService', () => { SavedObjectsErrorHelpers.createConflictError(SEARCH_SESSION_TYPE, searchId) ); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -341,15 +845,15 @@ describe('SearchSessionService', () => { savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); await Promise.all([ - service.trackId({ savedObjectsClient }, searchRequest1, searchId1, { + service.trackId({ savedObjectsClient }, mockUser1, searchRequest1, searchId1, { sessionId: sessionId1, strategy: MOCK_STRATEGY, }), - service.trackId({ savedObjectsClient }, searchRequest2, searchId2, { + service.trackId({ savedObjectsClient }, mockUser1, searchRequest2, searchId2, { sessionId: sessionId1, strategy: MOCK_STRATEGY, }), - service.trackId({ savedObjectsClient }, searchRequest3, searchId3, { + service.trackId({ savedObjectsClient }, mockUser1, searchRequest3, searchId3, { sessionId: sessionId2, strategy: MOCK_STRATEGY, }), @@ -394,7 +898,7 @@ describe('SearchSessionService', () => { const searchRequest = { params: {} }; expect(() => - service.getId({ savedObjectsClient }, searchRequest, {}) + service.getId({ savedObjectsClient }, mockUser1, searchRequest, {}) ).rejects.toMatchInlineSnapshot(`[Error: Session ID is required]`); }); @@ -402,7 +906,10 @@ describe('SearchSessionService', () => { const searchRequest = { params: {} }; expect(() => - service.getId({ savedObjectsClient }, searchRequest, { sessionId, isStored: false }) + service.getId({ savedObjectsClient }, mockUser1, searchRequest, { + sessionId, + isStored: false, + }) ).rejects.toMatchInlineSnapshot( `[Error: Cannot get search ID from a session that is not stored]` ); @@ -412,7 +919,7 @@ describe('SearchSessionService', () => { const searchRequest = { params: {} }; expect(() => - service.getId({ savedObjectsClient }, searchRequest, { + service.getId({ savedObjectsClient }, mockUser1, searchRequest, { sessionId, isStored: true, isRestore: false, @@ -427,24 +934,19 @@ describe('SearchSessionService', () => { const requestHash = createRequestHash(searchRequest.params); const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0'; const mockSession = { - id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', - type: SEARCH_SESSION_TYPE, + ...mockSavedObject, attributes: { - name: 'my_name', - appId: 'my_app_id', - urlGeneratorId: 'my_url_generator_id', + ...mockSavedObject.attributes, idMapping: { [requestHash]: { id: searchId, - strategy: MOCK_STRATEGY, }, }, }, - references: [], }; savedObjectsClient.get.mockResolvedValue(mockSession); - const id = await service.getId({ savedObjectsClient }, searchRequest, { + const id = await service.getId({ savedObjectsClient }, mockUser1, searchRequest, { sessionId, isStored: true, isRestore: true, @@ -457,12 +959,9 @@ describe('SearchSessionService', () => { describe('getSearchIdMapping', () => { it('retrieves the search IDs and strategies from the saved object', async () => { const mockSession = { - id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', - type: SEARCH_SESSION_TYPE, + ...mockSavedObject, attributes: { - name: 'my_name', - appId: 'my_app_id', - urlGeneratorId: 'my_url_generator_id', + ...mockSavedObject.attributes, idMapping: { foo: { id: 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0', @@ -470,11 +969,11 @@ describe('SearchSessionService', () => { }, }, }, - references: [], }; savedObjectsClient.get.mockResolvedValue(mockSession); const searchIdMapping = await service.getSearchIdMapping( { savedObjectsClient }, + mockUser1, mockSession.id ); expect(searchIdMapping).toMatchInlineSnapshot(` @@ -484,88 +983,4 @@ describe('SearchSessionService', () => { `); }); }); - - describe('save', () => { - it('save throws if `name` is not provided', () => { - expect(service.save({ savedObjectsClient }, sessionId, {})).rejects.toMatchInlineSnapshot( - `[Error: Name is required]` - ); - }); - - it('save throws if `appId` is not provided', () => { - expect( - service.save({ savedObjectsClient }, sessionId, { name: 'banana' }) - ).rejects.toMatchInlineSnapshot(`[Error: AppId is required]`); - }); - - it('save throws if `generator id` is not provided', () => { - expect( - service.save({ savedObjectsClient }, sessionId, { name: 'banana', appId: 'nanana' }) - ).rejects.toMatchInlineSnapshot(`[Error: UrlGeneratorId is required]`); - }); - - it('saving updates an existing saved object and persists it', async () => { - const mockUpdateSavedObject = { - ...mockSavedObject, - attributes: {}, - }; - savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - - await service.save({ savedObjectsClient }, sessionId, { - name: 'banana', - appId: 'nanana', - urlGeneratorId: 'panama', - }); - - expect(savedObjectsClient.update).toHaveBeenCalled(); - expect(savedObjectsClient.create).not.toHaveBeenCalled(); - - const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(id).toBe(sessionId); - expect(callAttributes).not.toHaveProperty('idMapping'); - expect(callAttributes).toHaveProperty('touched'); - expect(callAttributes).toHaveProperty('persisted', true); - expect(callAttributes).toHaveProperty('name', 'banana'); - expect(callAttributes).toHaveProperty('appId', 'nanana'); - expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); - expect(callAttributes).toHaveProperty('initialState', {}); - expect(callAttributes).toHaveProperty('restoreState', {}); - }); - - it('saving creates a new persisted saved object, if it did not exist', async () => { - const mockCreatedSavedObject = { - ...mockSavedObject, - attributes: {}, - }; - - savedObjectsClient.update.mockRejectedValue( - SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId) - ); - savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject); - - await service.save({ savedObjectsClient }, sessionId, { - name: 'banana', - appId: 'nanana', - urlGeneratorId: 'panama', - }); - - expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); - - const [type, callAttributes, options] = savedObjectsClient.create.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(options?.id).toBe(sessionId); - expect(callAttributes).toHaveProperty('idMapping', {}); - expect(callAttributes).toHaveProperty('touched'); - expect(callAttributes).toHaveProperty('expires'); - expect(callAttributes).toHaveProperty('created'); - expect(callAttributes).toHaveProperty('persisted', true); - expect(callAttributes).toHaveProperty('name', 'banana'); - expect(callAttributes).toHaveProperty('appId', 'nanana'); - expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); - expect(callAttributes).toHaveProperty('initialState', {}); - expect(callAttributes).toHaveProperty('restoreState', {}); - }); - }); }); diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts index 6a36b1b4859ed3..c95c58a8dc06ba 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { notFound } from '@hapi/boom'; import { debounce } from 'lodash'; import { CoreSetup, @@ -16,8 +17,13 @@ import { SavedObjectsFindOptions, SavedObjectsErrorHelpers, } from '../../../../../../src/core/server'; -import { IKibanaSearchRequest, ISearchOptions } from '../../../../../../src/plugins/data/common'; -import { ISearchSessionService } from '../../../../../../src/plugins/data/server'; +import { + IKibanaSearchRequest, + ISearchOptions, + nodeBuilder, +} from '../../../../../../src/plugins/data/common'; +import { esKuery, ISearchSessionService } from '../../../../../../src/plugins/data/server'; +import { AuthenticatedUser, SecurityPluginSetup } from '../../../../security/server'; import { TaskManagerSetupContract, TaskManagerStartContract, @@ -49,6 +55,7 @@ const DEBOUNCE_UPDATE_OR_CREATE_MAX_WAIT = 5000; interface UpdateOrCreateQueueEntry { deps: SearchSessionDependencies; + user: AuthenticatedUser | null; sessionId: string; attributes: Partial; resolve: () => void; @@ -63,7 +70,11 @@ export class SearchSessionService private sessionConfig: SearchSessionsConfig; private readonly updateOrCreateBatchQueue: UpdateOrCreateQueueEntry[] = []; - constructor(private readonly logger: Logger, private readonly config: ConfigSchema) { + constructor( + private readonly logger: Logger, + private readonly config: ConfigSchema, + private readonly security?: SecurityPluginSetup + ) { this.sessionConfig = this.config.search.sessions; } @@ -114,7 +125,12 @@ export class SearchSessionService Object.keys(batchedSessionAttributes).forEach((sessionId) => { const thisSession = queue.filter((s) => s.sessionId === sessionId); - this.updateOrCreate(thisSession[0].deps, sessionId, batchedSessionAttributes[sessionId]) + this.updateOrCreate( + thisSession[0].deps, + thisSession[0].user, + sessionId, + batchedSessionAttributes[sessionId] + ) .then(() => { thisSession.forEach((s) => s.resolve()); }) @@ -128,11 +144,12 @@ export class SearchSessionService ); private scheduleUpdateOrCreate = ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial ): Promise => { return new Promise((resolve, reject) => { - this.updateOrCreateBatchQueue.push({ deps, sessionId, attributes, resolve, reject }); + this.updateOrCreateBatchQueue.push({ deps, user, sessionId, attributes, resolve, reject }); // TODO: this would be better if we'd debounce per sessionId this.processUpdateOrCreateBatchQueue(); }); @@ -140,6 +157,7 @@ export class SearchSessionService private updateOrCreate = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial, retry: number = 1 @@ -148,13 +166,14 @@ export class SearchSessionService this.logger.debug(`Conflict error | ${sessionId}`); // Randomize sleep to spread updates out in case of conflicts await sleep(100 + Math.random() * 50); - return await this.updateOrCreate(deps, sessionId, attributes, retry + 1); + return await this.updateOrCreate(deps, user, sessionId, attributes, retry + 1); }; this.logger.debug(`updateOrCreate | ${sessionId} | ${retry}`); try { return (await this.update( deps, + user, sessionId, attributes )) as SavedObject; @@ -162,7 +181,7 @@ export class SearchSessionService if (SavedObjectsErrorHelpers.isNotFoundError(e)) { try { this.logger.debug(`Object not found | ${sessionId}`); - return await this.create(deps, sessionId, attributes); + return await this.create(deps, user, sessionId, attributes); } catch (createError) { if ( SavedObjectsErrorHelpers.isConflictError(createError) && @@ -188,6 +207,7 @@ export class SearchSessionService public save = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, { name, @@ -201,7 +221,7 @@ export class SearchSessionService if (!appId) throw new Error('AppId is required'); if (!urlGeneratorId) throw new Error('UrlGeneratorId is required'); - return this.updateOrCreate(deps, sessionId, { + return this.updateOrCreate(deps, user, sessionId, { name, appId, urlGeneratorId, @@ -213,10 +233,16 @@ export class SearchSessionService private create = ( { savedObjectsClient }: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial ) => { this.logger.debug(`create | ${sessionId}`); + + const realmType = user?.authentication_realm.type; + const realmName = user?.authentication_realm.name; + const username = user?.username; + return savedObjectsClient.create( SEARCH_SESSION_TYPE, { @@ -229,40 +255,69 @@ export class SearchSessionService touched: new Date().toISOString(), idMapping: {}, persisted: false, + realmType, + realmName, + username, ...attributes, }, { id: sessionId } ); }; - // TODO: Throw an error if this session doesn't belong to this user - public get = ({ savedObjectsClient }: SearchSessionDependencies, sessionId: string) => { + public get = async ( + { savedObjectsClient }: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) => { this.logger.debug(`get | ${sessionId}`); - return savedObjectsClient.get( + const session = await savedObjectsClient.get( SEARCH_SESSION_TYPE, sessionId ); + this.throwOnUserConflict(user, session); + return session; }; - // TODO: Throw an error if this session doesn't belong to this user public find = ( { savedObjectsClient }: SearchSessionDependencies, + user: AuthenticatedUser | null, options: Omit ) => { + const userFilters = + user === null + ? [] + : [ + nodeBuilder.is( + `${SEARCH_SESSION_TYPE}.attributes.realmType`, + `${user.authentication_realm.type}` + ), + nodeBuilder.is( + `${SEARCH_SESSION_TYPE}.attributes.realmName`, + `${user.authentication_realm.name}` + ), + nodeBuilder.is(`${SEARCH_SESSION_TYPE}.attributes.username`, `${user.username}`), + ]; + const filterKueryNode = + typeof options.filter === 'string' + ? esKuery.fromKueryExpression(options.filter) + : options.filter; + const filter = nodeBuilder.and(userFilters.concat(filterKueryNode ?? [])); return savedObjectsClient.find({ ...options, + filter, type: SEARCH_SESSION_TYPE, }); }; - // TODO: Throw an error if this session doesn't belong to this user - public update = ( - { savedObjectsClient }: SearchSessionDependencies, + public update = async ( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial ) => { this.logger.debug(`update | ${sessionId}`); - return savedObjectsClient.update( + await this.get(deps, user, sessionId); // Verify correct user + return deps.savedObjectsClient.update( SEARCH_SESSION_TYPE, sessionId, { @@ -272,22 +327,35 @@ export class SearchSessionService ); }; - public extend(deps: SearchSessionDependencies, sessionId: string, expires: Date) { + public async extend( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string, + expires: Date + ) { this.logger.debug(`extend | ${sessionId}`); - - return this.update(deps, sessionId, { expires: expires.toISOString() }); + return this.update(deps, user, sessionId, { expires: expires.toISOString() }); } - // TODO: Throw an error if this session doesn't belong to this user - public cancel = (deps: SearchSessionDependencies, sessionId: string) => { - return this.update(deps, sessionId, { + public cancel = async ( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) => { + this.logger.debug(`delete | ${sessionId}`); + return this.update(deps, user, sessionId, { status: SearchSessionStatus.CANCELLED, }); }; - // TODO: Throw an error if this session doesn't belong to this user - public delete = ({ savedObjectsClient }: SearchSessionDependencies, sessionId: string) => { - return savedObjectsClient.delete(SEARCH_SESSION_TYPE, sessionId); + public delete = async ( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) => { + this.logger.debug(`delete | ${sessionId}`); + await this.get(deps, user, sessionId); // Verify correct user + return deps.savedObjectsClient.delete(SEARCH_SESSION_TYPE, sessionId); }; /** @@ -296,6 +364,7 @@ export class SearchSessionService */ public trackId = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, searchRequest: IKibanaSearchRequest, searchId: string, { sessionId, strategy }: ISearchOptions @@ -315,11 +384,15 @@ export class SearchSessionService idMapping = { [requestHash]: searchInfo }; } - await this.scheduleUpdateOrCreate(deps, sessionId, { idMapping }); + await this.scheduleUpdateOrCreate(deps, user, sessionId, { idMapping }); }; - public async getSearchIdMapping(deps: SearchSessionDependencies, sessionId: string) { - const searchSession = await this.get(deps, sessionId); + public async getSearchIdMapping( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) { + const searchSession = await this.get(deps, user, sessionId); const searchIdMapping = new Map(); Object.values(searchSession.attributes.idMapping).forEach((requestInfo) => { searchIdMapping.set(requestInfo.id, requestInfo.strategy); @@ -334,6 +407,7 @@ export class SearchSessionService */ public getId = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, searchRequest: IKibanaSearchRequest, { sessionId, isStored, isRestore }: ISearchOptions ) => { @@ -345,7 +419,7 @@ export class SearchSessionService throw new Error('Get search ID is only supported when restoring a session'); } - const session = await this.get(deps, sessionId); + const session = await this.get(deps, user, sessionId); const requestHash = createRequestHash(searchRequest.params); if (!session.attributes.idMapping.hasOwnProperty(requestHash)) { this.logger.error(`getId | ${sessionId} | ${requestHash} not found`); @@ -358,22 +432,40 @@ export class SearchSessionService public asScopedProvider = ({ savedObjects }: CoreStart) => { return (request: KibanaRequest) => { + const user = this.security?.authc.getCurrentUser(request) ?? null; const savedObjectsClient = savedObjects.getScopedClient(request, { includedHiddenTypes: [SEARCH_SESSION_TYPE], }); const deps = { savedObjectsClient }; return { - getId: this.getId.bind(this, deps), - trackId: this.trackId.bind(this, deps), - getSearchIdMapping: this.getSearchIdMapping.bind(this, deps), - save: this.save.bind(this, deps), - get: this.get.bind(this, deps), - find: this.find.bind(this, deps), - update: this.update.bind(this, deps), - extend: this.extend.bind(this, deps), - cancel: this.cancel.bind(this, deps), - delete: this.delete.bind(this, deps), + getId: this.getId.bind(this, deps, user), + trackId: this.trackId.bind(this, deps, user), + getSearchIdMapping: this.getSearchIdMapping.bind(this, deps, user), + save: this.save.bind(this, deps, user), + get: this.get.bind(this, deps, user), + find: this.find.bind(this, deps, user), + update: this.update.bind(this, deps, user), + extend: this.extend.bind(this, deps, user), + cancel: this.cancel.bind(this, deps, user), + delete: this.delete.bind(this, deps, user), }; }; }; + + private throwOnUserConflict = ( + user: AuthenticatedUser | null, + session?: SavedObject + ) => { + if (user === null || !session) return; + if ( + user.authentication_realm.type !== session.attributes.realmType || + user.authentication_realm.name !== session.attributes.realmName || + user.username !== session.attributes.username + ) { + this.logger.debug( + `User ${user.username} has no access to search session ${session.attributes.sessionId}` + ); + throw notFound(); + } + }; } diff --git a/x-pack/plugins/data_enhanced/tsconfig.json b/x-pack/plugins/data_enhanced/tsconfig.json index 29bfd71cb32b40..216c115545a451 100644 --- a/x-pack/plugins/data_enhanced/tsconfig.json +++ b/x-pack/plugins/data_enhanced/tsconfig.json @@ -25,6 +25,7 @@ { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, { "path": "../task_manager/tsconfig.json" }, { "path": "../features/tsconfig.json" }, diff --git a/x-pack/test/api_integration/apis/search/session.ts b/x-pack/test/api_integration/apis/search/session.ts index 8412d6c1ad5d1e..27010a6ab90f6f 100644 --- a/x-pack/test/api_integration/apis/search/session.ts +++ b/x-pack/test/api_integration/apis/search/session.ts @@ -328,6 +328,122 @@ export default function ({ getService }: FtrProviderContext) { ); }); + describe('with security', () => { + before(async () => { + await security.user.create('other_user', { + password: 'password', + roles: ['superuser'], + full_name: 'other user', + }); + }); + + after(async () => { + await security.user.delete('other_user'); + }); + + it(`should prevent users from accessing other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .get(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .expect(404); + }); + + it(`should prevent users from deleting other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .delete(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .expect(404); + }); + + it(`should prevent users from cancelling other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .post(`/internal/session/${sessionId}/cancel`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .expect(404); + }); + + it(`should prevent users from extending other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .post(`/internal/session/${sessionId}/_extend`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .send({ + expires: '2021-02-26T21:02:43.742Z', + }) + .expect(404); + }); + + it(`should prevent unauthorized users from creating sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertestWithoutAuth + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(401); + }); + }); + describe('search session permissions', () => { before(async () => { await security.role.create('data_analyst', { From 5c3c3efdd87089fb1a326854c83397a7253bd7c6 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Sat, 13 Feb 2021 04:28:35 -0500 Subject: [PATCH 021/175] Sharing saved objects, phase 2.5 (#89344) --- ...migrating-legacy-plugins-examples.asciidoc | 2 +- .../core/public/kibana-plugin-core-public.md | 2 +- ...n-core-public.savedobjectsnamespacetype.md | 4 +- .../core/server/kibana-plugin-core-server.md | 2 +- ...text.converttomultinamespacetypeversion.md | 13 + ...core-server.savedobjectmigrationcontext.md | 2 + ...objectmigrationcontext.migrationversion.md | 13 + ...n-core-server.savedobjectsnamespacetype.md | 4 +- ...vedobjectsresolveresponse.aliastargetid.md | 13 + ...core-server.savedobjectsresolveresponse.md | 1 + ...type.converttomultinamespacetypeversion.md | 24 +- ...ana-plugin-core-server.savedobjectstype.md | 19 +- ...avedobjecttyperegistry.ismultinamespace.md | 2 +- ...ver.savedobjecttyperegistry.isshareable.md | 24 + ...gin-core-server.savedobjecttyperegistry.md | 3 +- src/core/public/public.api.md | 2 +- .../migrations/core/document_migrator.test.ts | 4 +- .../migrations/core/document_migrator.ts | 18 +- .../server/saved_objects/migrations/mocks.ts | 10 +- .../server/saved_objects/migrations/types.ts | 8 + .../saved_objects_type_registry.mock.ts | 2 + .../saved_objects_type_registry.test.ts | 26 + .../saved_objects_type_registry.ts | 11 +- .../service/lib/repository.test.js | 240 ++++-- .../saved_objects/service/lib/repository.ts | 10 +- .../service/saved_objects_client.ts | 4 + src/core/server/saved_objects/types.ts | 38 +- src/core/server/server.api.md | 6 +- .../saved_objects_management/kibana.json | 2 +- .../management_section/mount_section.tsx | 9 +- .../objects_table/components/table.tsx | 11 - .../saved_objects_table_page.tsx | 68 +- .../saved_objects_management/public/plugin.ts | 2 + .../public/services/types/column.ts | 3 - .../saved_objects_management/tsconfig.json | 1 + src/plugins/spaces_oss/public/api.mock.ts | 29 +- src/plugins/spaces_oss/public/api.ts | 235 ++++++ src/plugins/spaces_oss/public/index.ts | 18 +- src/plugins/spaces_oss/public/types.ts | 4 +- .../apis/saved_objects/migrations.ts | 2 +- .../server/saved_objects/migrations.test.ts | 30 +- .../server/create_migration.test.ts | 96 ++- .../server/create_migration.ts | 25 +- .../encrypted_saved_objects_service.test.ts | 180 +++++ .../crypto/encrypted_saved_objects_service.ts | 39 +- .../saved_objects/get_descriptor_namespace.ts | 2 +- .../server/saved_objects/index.ts | 4 +- x-pack/plugins/ml/kibana.json | 1 - .../components/job_spaces_list/index.ts | 2 +- .../job_spaces_list/job_spaces_list.tsx | 93 ++- .../cannot_edit_callout.tsx | 30 - .../jobs_spaces_flyout.tsx | 132 ---- .../job_spaces_selector/spaces_selector.scss | 3 - .../job_spaces_selector/spaces_selectors.tsx | 223 ------ .../contexts/spaces/spaces_context.ts | 36 - .../analytics_list/analytics_list.tsx | 7 +- .../components/analytics_list/use_columns.tsx | 6 +- .../components/jobs_list/jobs_list.js | 5 +- .../jobs_list_view/jobs_list_view.js | 17 +- .../jobs_list_page/jobs_list_page.tsx | 32 +- .../application/management/jobs_list/index.ts | 4 +- .../components/copy_to_space_flyout.test.tsx | 21 +- .../components/copy_to_space_flyout.tsx | 31 +- .../components/copy_to_space_form.tsx | 9 +- .../components/processing_copy_to_space.tsx | 15 +- .../components/space_result.tsx | 4 - .../components/space_result_details.tsx | 2 - .../copy_saved_objects_to_space_action.tsx | 11 +- .../summarize_copy_result.test.ts | 20 +- .../summarize_copy_result.ts | 21 +- .../copy_saved_objects_to_space/types.ts | 27 + x-pack/plugins/spaces/public/index.ts | 4 +- x-pack/plugins/spaces/public/plugin.tsx | 31 +- .../components/constants.ts | 12 + .../components/context_wrapper.tsx | 40 - .../components/index.ts | 5 +- .../components/legacy_url_conflict.tsx | 18 + .../legacy_url_conflict_internal.test.tsx | 68 ++ .../legacy_url_conflict_internal.tsx | 114 +++ .../components/no_spaces_available.tsx | 4 +- .../components/selectable_spaces_control.tsx | 164 +++- .../components/share_mode_control.tsx | 156 ++-- .../components/share_to_space_flyout.test.tsx | 489 ------------ .../components/share_to_space_flyout.tsx | 288 +------ .../share_to_space_flyout_internal.test.tsx | 741 ++++++++++++++++++ .../share_to_space_flyout_internal.tsx | 352 +++++++++ .../components/share_to_space_form.tsx | 93 ++- .../share_saved_objects_to_space/index.ts | 2 + ...are_saved_objects_to_space_action.test.tsx | 13 +- .../share_saved_objects_to_space_action.tsx | 41 +- ...are_saved_objects_to_space_column.test.tsx | 207 ----- .../share_saved_objects_to_space_column.tsx | 145 +--- ...are_saved_objects_to_space_service.test.ts | 7 +- .../share_saved_objects_to_space_service.ts | 23 +- .../share_saved_objects_to_space/types.ts | 7 +- .../utils}/index.ts | 7 +- .../utils/redirect_legacy_url.test.ts | 40 + .../utils/redirect_legacy_url.ts | 33 + .../public/space_list}/index.ts | 2 +- .../spaces/public/space_list/space_list.tsx | 16 + .../space_list/space_list_internal.test.tsx | 310 ++++++++ .../public/space_list/space_list_internal.tsx | 144 ++++ .../spaces/public/spaces_context/context.tsx | 42 + .../spaces/public/spaces_context/index.ts | 9 + .../spaces/public/spaces_context/types.ts | 25 + .../spaces/public/spaces_context/wrapper.tsx | 89 +++ x-pack/plugins/spaces/public/types.ts | 31 + .../spaces/public/ui_api/components.ts | 34 + x-pack/plugins/spaces/public/ui_api/index.ts | 27 + x-pack/plugins/spaces/public/ui_api/mocks.ts | 31 + .../translations/translations/ja-JP.json | 52 -- .../translations/translations/zh-CN.json | 52 -- .../saved_objects/spaces/data.json | 34 + .../saved_objects/spaces/mappings.json | 13 + .../saved_object_test_plugin/server/plugin.ts | 7 + .../common/lib/saved_object_test_cases.ts | 10 + .../common/lib/saved_object_test_utils.ts | 2 +- .../common/suites/export.ts | 21 + .../common/suites/find.ts | 7 + .../common/suites/resolve.ts | 12 +- .../security_and_spaces/apis/bulk_create.ts | 10 + .../security_and_spaces/apis/bulk_get.ts | 5 + .../security_and_spaces/apis/bulk_update.ts | 5 + .../security_and_spaces/apis/create.ts | 8 + .../security_and_spaces/apis/delete.ts | 5 + .../security_and_spaces/apis/export.ts | 2 + .../security_and_spaces/apis/find.ts | 1 + .../security_and_spaces/apis/get.ts | 5 + .../security_and_spaces/apis/import.ts | 11 + .../apis/resolve_import_errors.ts | 11 + .../security_and_spaces/apis/update.ts | 5 + .../security_only/apis/bulk_create.ts | 2 + .../security_only/apis/bulk_get.ts | 2 + .../security_only/apis/bulk_update.ts | 2 + .../security_only/apis/create.ts | 2 + .../security_only/apis/delete.ts | 2 + .../security_only/apis/export.ts | 2 + .../security_only/apis/find.ts | 1 + .../security_only/apis/get.ts | 2 + .../security_only/apis/import.ts | 3 + .../apis/resolve_import_errors.ts | 2 + .../security_only/apis/update.ts | 2 + .../spaces_only/apis/bulk_create.ts | 10 + .../spaces_only/apis/bulk_get.ts | 5 + .../spaces_only/apis/bulk_update.ts | 5 + .../spaces_only/apis/create.ts | 8 + .../spaces_only/apis/delete.ts | 5 + .../spaces_only/apis/get.ts | 5 + .../spaces_only/apis/import.ts | 10 + .../spaces_only/apis/resolve_import_errors.ts | 10 + .../spaces_only/apis/update.ts | 5 + 151 files changed, 3982 insertions(+), 2264 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss delete mode 100644 x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx delete mode 100644 x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts delete mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx delete mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx delete mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx rename x-pack/plugins/{ml/public/application/contexts/spaces => spaces/public/share_saved_objects_to_space/utils}/index.ts (68%) create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts rename x-pack/plugins/{ml/public/application/components/job_spaces_selector => spaces/public/space_list}/index.ts (81%) create mode 100644 x-pack/plugins/spaces/public/space_list/space_list.tsx create mode 100644 x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx create mode 100644 x-pack/plugins/spaces/public/space_list/space_list_internal.tsx create mode 100644 x-pack/plugins/spaces/public/spaces_context/context.tsx create mode 100644 x-pack/plugins/spaces/public/spaces_context/index.ts create mode 100644 x-pack/plugins/spaces/public/spaces_context/types.ts create mode 100644 x-pack/plugins/spaces/public/spaces_context/wrapper.tsx create mode 100644 x-pack/plugins/spaces/public/types.ts create mode 100644 x-pack/plugins/spaces/public/ui_api/components.ts create mode 100644 x-pack/plugins/spaces/public/ui_api/index.ts create mode 100644 x-pack/plugins/spaces/public/ui_api/mocks.ts diff --git a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc index 92a624649d3c50..6361b3c921128a 100644 --- a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc +++ b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc @@ -800,7 +800,7 @@ However, there are some minor changes: * The `schema.isNamespaceAgnostic` property has been renamed: `SavedObjectsType.namespaceType`. It no longer accepts a boolean but -instead an enum of `single`, `multiple`, or `agnostic` (see +instead an enum of `single`, `multiple`, `multiple-isolated`, or `agnostic` (see {kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md[SavedObjectsNamespaceType]). * The `schema.indexPattern` was accepting either a `string` or a `(config: LegacyConfig) => string`. `SavedObjectsType.indexPattern` only diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 5524cf328fbfe6..ba48011ef84e08 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -168,7 +168,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | | [SavedObjectsClientContract](./kibana-plugin-core-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) | | [SavedObjectsImportWarning](./kibana-plugin-core-public.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-public.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-public.savedobjectsimportactionrequiredwarning.md) for more details. | -| [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | +| [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. | | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | | [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string | | [Toast](./kibana-plugin-core-public.toast.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md index f2205d2cee4240..cf5e6cb29a5329 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md @@ -4,10 +4,10 @@ ## SavedObjectsNamespaceType type -The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. Signature: ```typescript -export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 1791335d58fef9..3ec63840a67cba 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -310,7 +310,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [SavedObjectsImportHook](./kibana-plugin-core-server.savedobjectsimporthook.md) | A hook associated with a specific saved object type, that will be invoked during the import process. The hook will have access to the objects of the registered type.Currently, the only supported feature for import hooks is to return warnings to be displayed in the UI when the import succeeds. The only interactions the hook can have with the import process is via the hook's response. Mutating the objects inside the hook's code will have no effect. | | [SavedObjectsImportWarning](./kibana-plugin-core-server.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-server.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-server.savedobjectsimportactionrequiredwarning.md) for more details. | -| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | +| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. | | [SavedObjectUnsanitizedDoc](./kibana-plugin-core-server.savedobjectunsanitizeddoc.md) | Describes Saved Object documents from Kibana < 7.0.0 which don't have a references root property defined. This type should only be used in migrations. | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [ServiceStatusLevel](./kibana-plugin-core-server.servicestatuslevel.md) | A convenience type that represents the union of each value in [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md). | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md new file mode 100644 index 00000000000000..2a30693f4da84a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) > [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) + +## SavedObjectMigrationContext.convertToMultiNamespaceTypeVersion property + +The version in which this object type is being converted to a multi-namespace type + +Signature: + +```typescript +convertToMultiNamespaceTypeVersion?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md index 901f2dde0944ce..c8a291e5028453 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md @@ -16,5 +16,7 @@ export interface SavedObjectMigrationContext | Property | Type | Description | | --- | --- | --- | +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) | string | The version in which this object type is being converted to a multi-namespace type | | [log](./kibana-plugin-core-server.savedobjectmigrationcontext.log.md) | SavedObjectsMigrationLogger | logger instance to be used by the migration handler | +| [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) | string | The migration version that this migration function is defined for | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md new file mode 100644 index 00000000000000..7b20ae41048f66 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) > [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) + +## SavedObjectMigrationContext.migrationVersion property + +The migration version that this migration function is defined for + +Signature: + +```typescript +migrationVersion: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md index 9075a780bd2c79..01a712aa89aa9a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md @@ -4,10 +4,10 @@ ## SavedObjectsNamespaceType type -The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. Signature: ```typescript -export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md new file mode 100644 index 00000000000000..2e73d6ba2e1a9f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) > [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) + +## SavedObjectsResolveResponse.aliasTargetId property + +The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + +Signature: + +```typescript +aliasTargetId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md index cfb309da0a716f..ffcf15dbc80c7c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -15,6 +15,7 @@ export interface SavedObjectsResolveResponse | Property | Type | Description | | --- | --- | --- | +| [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) | string | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | | [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | | [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md index 064bd0b35699df..20346919fc652e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md @@ -4,13 +4,13 @@ ## SavedObjectsType.convertToMultiNamespaceTypeVersion property -If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. +If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version. Requirements: -1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) +1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) -Example of a single-namespace type in 7.10: +Example of a single-namespace type in 7.12: ```ts { @@ -21,7 +21,19 @@ Example of a single-namespace type in 7.10: } ``` -Example after converting to a multi-namespace type in 7.11: +Example after converting to a multi-namespace (isolated) type in 8.0: + +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple-isolated', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '8.0.0' +} + +``` +Example after converting to a multi-namespace (shareable) type in 8.1: ```ts { @@ -29,11 +41,11 @@ Example after converting to a multi-namespace type in 7.11: hidden: false, namespaceType: 'multiple', mappings: {...}, - convertToMultiNamespaceTypeVersion: '7.11.0' + convertToMultiNamespaceTypeVersion: '8.0.0' } ``` -Note: a migration function can be optionally specified for the same version. +Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md index eacad53be39fe0..d882938d731c8c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md @@ -19,7 +19,7 @@ This is only internal for now, and will only be public when we expose the regist | Property | Type | Description | | --- | --- | --- | | [convertToAliasScript](./kibana-plugin-core-server.savedobjectstype.converttoaliasscript.md) | string | If defined, will be used to convert the type to an alias. | -| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to multi-namespace objects when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.10: +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.12: ```ts { name: 'foo', @@ -29,18 +29,29 @@ This is only internal for now, and will only be public when we expose the regist } ``` -Example after converting to a multi-namespace type in 7.11: +Example after converting to a multi-namespace (isolated) type in 8.0: +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple-isolated', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '8.0.0' +} + +``` +Example after converting to a multi-namespace (shareable) type in 8.1: ```ts { name: 'foo', hidden: false, namespaceType: 'multiple', mappings: {...}, - convertToMultiNamespaceTypeVersion: '7.11.0' + convertToMultiNamespaceTypeVersion: '8.0.0' } ``` -Note: a migration function can be optionally specified for the same version. | +Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. | | [hidden](./kibana-plugin-core-server.savedobjectstype.hidden.md) | boolean | Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an extraType when creating the repository.See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md). | | [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | string | If defined, the type instances will be stored in the given index instead of the default one. | | [management](./kibana-plugin-core-server.savedobjectstype.management.md) | SavedObjectsTypeManagementDefinition | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md index 6532c5251d816f..0ff07ae2804ff8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md @@ -4,7 +4,7 @@ ## SavedObjectTypeRegistry.isMultiNamespace() method -Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered +Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to `false` if the type is not registered Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md new file mode 100644 index 00000000000000..ee240268f9d67a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isShareable](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) + +## SavedObjectTypeRegistry.isShareable() method + +Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered + +Signature: + +```typescript +isShareable(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md index 55ad7ca137de0a..0f2de8c8ef9b33 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md @@ -23,8 +23,9 @@ export declare class SavedObjectTypeRegistry | [getVisibleTypes()](./kibana-plugin-core-server.savedobjecttyperegistry.getvisibletypes.md) | | Returns all visible [types](./kibana-plugin-core-server.savedobjectstype.md).A visible type is a type that doesn't explicitly define hidden=true during registration. | | [isHidden(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ishidden.md) | | Returns the hidden property for given type, or false if the type is not registered. | | [isImportableAndExportable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isimportableandexportable.md) | | Returns the management.importableAndExportable property for given type, or false if the type is not registered or does not define a management section. | -| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | +| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to false if the type is not registered | | [isNamespaceAgnostic(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md) | | Returns whether the type is namespace-agnostic (global); resolves to false if the type is not registered | +| [isShareable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | | [isSingleNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md) | | Returns whether the type is single-namespace (isolated); resolves to true if the type is not registered | | [registerType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.registertype.md) | | Register a [type](./kibana-plugin-core-server.savedobjectstype.md) inside the registry. A type can only be registered once. subsequent calls with the same type name will throw an error. | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 8ee530f5a04e87..2e23b26f636c8f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1385,7 +1385,7 @@ export interface SavedObjectsMigrationVersion { } // @public -export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; // @public (undocumented) export interface SavedObjectsStart { diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 776c7b195922e1..f29a8b61b48858 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -143,7 +143,7 @@ describe('DocumentMigrator', () => { ).toThrow(/Migrations are not ready. Make sure prepareMigrations is called first./i); }); - it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple'`, () => { + it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple' or 'multiple-isolated'`, () => { const invalidDefinition = { kibanaVersion: '3.2.3', typeRegistry: createRegistry({ @@ -154,7 +154,7 @@ describe('DocumentMigrator', () => { log: mockLogger, }; expect(() => new DocumentMigrator(invalidDefinition)).toThrow( - `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple', but got 'single'.` + `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple' or 'multiple-isolated', but got 'single'.` ); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index b61c4cfe967e71..47f4dda75cdcd7 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -312,9 +312,9 @@ function validateMigrationDefinition( convertToMultiNamespaceTypeVersion: string, type: string ) { - if (namespaceType !== 'multiple') { + if (namespaceType !== 'multiple' && namespaceType !== 'multiple-isolated') { throw new Error( - `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple', but got '${namespaceType}'.` + `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple' or 'multiple-isolated', but got '${namespaceType}'.` ); } else if (!Semver.valid(convertToMultiNamespaceTypeVersion)) { throw new Error( @@ -374,7 +374,7 @@ function buildActiveMigrations( const migrationTransforms = Object.entries(migrationsMap ?? {}).map( ([version, transform]) => ({ version, - transform: wrapWithTry(version, type.name, transform, log), + transform: wrapWithTry(version, type, transform, log), transformType: 'migrate', }) ); @@ -655,24 +655,28 @@ function transformComparator(a: Transform, b: Transform) { */ function wrapWithTry( version: string, - type: string, + type: SavedObjectsType, migrationFn: SavedObjectMigrationFn, log: Logger ) { return function tryTransformDoc(doc: SavedObjectUnsanitizedDoc) { try { - const context = { log: new MigrationLogger(log) }; + const context = { + log: new MigrationLogger(log), + migrationVersion: version, + convertToMultiNamespaceTypeVersion: type.convertToMultiNamespaceTypeVersion, + }; const result = migrationFn(doc, context); // A basic sanity check to help migration authors detect basic errors // (e.g. forgetting to return the transformed doc) if (!result || !result.type) { - throw new Error(`Invalid saved object returned from migration ${type}:${version}.`); + throw new Error(`Invalid saved object returned from migration ${type.name}:${version}.`); } return { transformedDoc: result, additionalDocs: [] }; } catch (error) { - const failedTransform = `${type}:${version}`; + const failedTransform = `${type.name}:${version}`; const failedDoc = JSON.stringify(doc); log.warn( `Failed to transform document ${doc?.id}. Transform: ${failedTransform}\nDoc: ${failedDoc}` diff --git a/src/core/server/saved_objects/migrations/mocks.ts b/src/core/server/saved_objects/migrations/mocks.ts index f0360ec180d6e4..4a62fcc95997bb 100644 --- a/src/core/server/saved_objects/migrations/mocks.ts +++ b/src/core/server/saved_objects/migrations/mocks.ts @@ -21,9 +21,17 @@ export const createSavedObjectsMigrationLoggerMock = (): jest.Mocked => { +const createContextMock = ({ + migrationVersion = '8.0.0', + convertToMultiNamespaceTypeVersion, +}: { + migrationVersion?: string; + convertToMultiNamespaceTypeVersion?: string; +} = {}): jest.Mocked => { const mock = { log: createSavedObjectsMigrationLoggerMock(), + migrationVersion, + convertToMultiNamespaceTypeVersion, }; return mock; }; diff --git a/src/core/server/saved_objects/migrations/types.ts b/src/core/server/saved_objects/migrations/types.ts index 630be58eb047dc..619a7f85a327b3 100644 --- a/src/core/server/saved_objects/migrations/types.ts +++ b/src/core/server/saved_objects/migrations/types.ts @@ -57,6 +57,14 @@ export interface SavedObjectMigrationContext { * logger instance to be used by the migration handler */ log: SavedObjectsMigrationLogger; + /** + * The migration version that this migration function is defined for + */ + migrationVersion: string; + /** + * The version in which this object type is being converted to a multi-namespace type + */ + convertToMultiNamespaceTypeVersion?: string; } /** diff --git a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts index 79b9c2feb1cbb4..d53a53d745c0c8 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts @@ -20,6 +20,7 @@ const createRegistryMock = (): jest.Mocked< isNamespaceAgnostic: jest.fn(), isSingleNamespace: jest.fn(), isMultiNamespace: jest.fn(), + isShareable: jest.fn(), isHidden: jest.fn(), getIndex: jest.fn(), isImportableAndExportable: jest.fn(), @@ -36,6 +37,7 @@ const createRegistryMock = (): jest.Mocked< (type: string) => type !== 'global' && type !== 'shared' ); mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); + mock.isShareable.mockImplementation((type: string) => type === 'shared'); mock.isImportableAndExportable.mockReturnValue(true); return mock; diff --git a/src/core/server/saved_objects/saved_objects_type_registry.test.ts b/src/core/server/saved_objects/saved_objects_type_registry.test.ts index c0eb7891cd7d43..872b61706c526a 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.test.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.test.ts @@ -239,6 +239,7 @@ describe('SavedObjectTypeRegistry', () => { it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); expectResult(false, { namespaceType: 'single' }); expectResult(false, { namespaceType: undefined }); }); @@ -263,6 +264,7 @@ describe('SavedObjectTypeRegistry', () => { it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'agnostic' }); expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); }); }); @@ -277,12 +279,36 @@ describe('SavedObjectTypeRegistry', () => { expect(registry.isMultiNamespace('unknownType')).toEqual(false); }); + it(`returns true for namespaceType 'multiple' and 'multiple-isolated'`, () => { + expectResult(true, { namespaceType: 'multiple' }); + expectResult(true, { namespaceType: 'multiple-isolated' }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'single' }); + expectResult(false, { namespaceType: undefined }); + }); + }); + + describe('#isShareable', () => { + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isShareable('foo')).toBe(expected); + }; + + it(`returns false when the type is not registered`, () => { + expect(registry.isShareable('unknownType')).toEqual(false); + }); + it(`returns true for namespaceType 'multiple'`, () => { expectResult(true, { namespaceType: 'multiple' }); }); it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); expectResult(false, { namespaceType: 'single' }); expectResult(false, { namespaceType: undefined }); }); diff --git a/src/core/server/saved_objects/saved_objects_type_registry.ts b/src/core/server/saved_objects/saved_objects_type_registry.ts index 8a50beda83d2a0..a63837132b652e 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.ts @@ -86,10 +86,19 @@ export class SavedObjectTypeRegistry { } /** - * Returns whether the type is multi-namespace (shareable); + * Returns whether the type is multi-namespace (shareable *or* isolated); * resolves to `false` if the type is not registered */ public isMultiNamespace(type: string) { + const namespaceType = this.types.get(type)?.namespaceType; + return namespaceType === 'multiple' || namespaceType === 'multiple-isolated'; + } + + /** + * Returns whether the type is multi-namespace (shareable); + * resolves to `false` if the type is not registered + */ + public isShareable(type: string) { return this.types.get(type)?.namespaceType === 'multiple'; } diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index e77143d13612ff..d26d92e84925a7 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -48,9 +48,29 @@ describe('SavedObjectsRepository', () => { const KIBANA_VERSION = '2.0.0'; const CUSTOM_INDEX_TYPE = 'customIndex'; + /** This type has namespaceType: 'agnostic'. */ const NAMESPACE_AGNOSTIC_TYPE = 'globalType'; - const MULTI_NAMESPACE_TYPE = 'shareableType'; - const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'shareableTypeCustomIndex'; + /** + * This type has namespaceType: 'multiple'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is shareable across + * namespaces. + **/ + const MULTI_NAMESPACE_TYPE = 'multiNamespaceType'; + /** + * This type has namespaceType: 'multiple-isolated'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is NOT shareable + * across namespaces. This distinction only matters when using the `addToNamespaces` and `deleteFromNamespaces` APIs, or when using the + * `initialNamespaces` argument with the `create` and `bulkCreate` APIs. Those allow you to define or change what namespaces an object + * exists in. + * + * In a nutshell, this type is more restrictive than `MULTI_NAMESPACE_TYPE`, so we use `MULTI_NAMESPACE_ISOLATED_TYPE` for any test cases + * where `MULTI_NAMESPACE_TYPE` would also satisfy the test case. + **/ + const MULTI_NAMESPACE_ISOLATED_TYPE = 'multiNamespaceIsolatedType'; + /** This type has namespaceType: 'multiple', and it uses a custom index. */ + const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'multiNamespaceTypeCustomIndex'; const HIDDEN_TYPE = 'hiddenType'; const mappings = { @@ -93,6 +113,13 @@ describe('SavedObjectsRepository', () => { }, }, }, + [MULTI_NAMESPACE_ISOLATED_TYPE]: { + properties: { + evenYetAnotherField: { + type: 'keyword', + }, + }, + }, [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]: { properties: { evenYetAnotherField: { @@ -132,6 +159,10 @@ describe('SavedObjectsRepository', () => { ...createType(MULTI_NAMESPACE_TYPE), namespaceType: 'multiple', }); + registry.registerType({ + ...createType(MULTI_NAMESPACE_ISOLATED_TYPE), + namespaceType: 'multiple-isolated', + }); registry.registerType({ ...createType(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE), namespaceType: 'multiple', @@ -345,13 +376,14 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); - it(`throws when type is not multi-namespace`, async () => { + it(`throws when type is not shareable`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [newNs1, newNs2], message); expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); @@ -518,11 +550,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES mget action before bulk action for any types that are multi-namespace, when id is defined`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkCreateSuccess(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledTimes(1); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; expect(client.mget.mock.calls[0][0].body).toEqual({ docs }); }); @@ -601,7 +635,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); const expected = expect.not.objectContaining({ namespace: expect.anything() }); @@ -614,7 +648,7 @@ describe('SavedObjectsRepository', () => { it(`adds namespaces to request body for any types that are multi-namespace`, async () => { const test = async (namespace) => { - const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); const namespaces = [namespace ?? 'default']; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const expected = expect.objectContaining({ namespaces }); @@ -706,7 +740,7 @@ describe('SavedObjectsRepository', () => { const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); @@ -753,7 +787,7 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); - it(`returns error when initialNamespaces is used with a non-multi-namespace object`, async () => { + it(`returns error when initialNamespaces is used with a non-shareable object`, async () => { const test = async (objType) => { const obj = { ...obj3, type: objType, initialNamespaces: [] }; await bulkCreateError( @@ -767,9 +801,10 @@ describe('SavedObjectsRepository', () => { }; await test('dashboard'); await test(NAMESPACE_AGNOSTIC_TYPE); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; await bulkCreateError( obj, @@ -792,7 +827,7 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE }; + const obj = { ...obj3, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const response1 = { status: 200, docs: [ @@ -884,7 +919,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespace to body when not using single-namespace type`, async () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespace: expect.anything() }, false, 1); @@ -892,14 +927,20 @@ describe('SavedObjectsRepository', () => { }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespaces: [namespace] }, true, 1); expectMigrationArgs({ namespaces: [namespace] }, true, 2); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); await bulkCreateSuccess(objects); expectMigrationArgs({ namespaces: ['default'] }, true, 1); expectMigrationArgs({ namespaces: ['default'] }, true, 2); @@ -1070,7 +1111,7 @@ describe('SavedObjectsRepository', () => { _expectClientCallArgs(objects, { getId }); client.mget.mockClear(); - objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE })); await bulkGetSuccess(objects, { namespace }); _expectClientCallArgs(objects, { getId }); }); @@ -1130,7 +1171,7 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const response = getMockMgetResponse([obj1, obj, obj2]); response.docs[1].namespaces = ['bar-namespace']; await bulkGetErrorNotFound([obj1, obj, obj2], { namespace }, response); @@ -1189,7 +1230,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkGetSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ @@ -1291,12 +1332,14 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkUpdateSuccess(objects); expect(client.bulk).toHaveBeenCalled(); expect(client.mget).toHaveBeenCalled(); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; expect(client.mget).toHaveBeenCalledWith( expect.objectContaining({ body: { docs } }), expect.anything() @@ -1313,7 +1356,7 @@ describe('SavedObjectsRepository', () => { }); it(`formats the ES request for any types that are multi-namespace`, async () => { - const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess([obj1, _obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; expect(client.bulk).toHaveBeenCalledWith( @@ -1384,8 +1427,8 @@ describe('SavedObjectsRepository', () => { it(`defaults to the version of the existing document for multi-namespace types`, async () => { // only multi-namespace documents are obtained using a pre-flight mget request const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkUpdateSuccess(objects); const overrides = { @@ -1406,7 +1449,7 @@ describe('SavedObjectsRepository', () => { // test with both non-multi-namespace and multi-namespace types const objects = [ { ...obj1, version }, - { ...obj2, type: MULTI_NAMESPACE_TYPE, version }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, version }, ]; await bulkUpdateSuccess(objects); const overrides = { if_seq_no: 100, if_primary_term: 200 }; @@ -1459,7 +1502,7 @@ describe('SavedObjectsRepository', () => { if_seq_no: expect.any(Number), }; const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; - const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess([_obj1], { namespace }); expectClientCallArgsAction([_obj1], { method: 'update', getId }); @@ -1558,19 +1601,19 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when ES is unable to find the document (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE, found: false }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false }; const mgetResponse = getMockMgetResponse([_obj]); await bulkUpdateMultiError([obj1, _obj, obj2], undefined, mgetResponse); }); it(`returns error when ES is unable to find the index (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const mgetResponse = { statusCode: 404 }; await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); it(`returns error when there is a conflict with an existing multi-namespace saved object (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const mgetResponse = getMockMgetResponse([_obj], 'bar-namespace'); await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); @@ -1643,7 +1686,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ @@ -1654,7 +1697,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes originId property if present in cluster call response`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj], {}, true); expect(result).toEqual({ saved_objects: [ @@ -1669,9 +1712,9 @@ describe('SavedObjectsRepository', () => { describe('#checkConflicts', () => { const obj1 = { type: 'dashboard', id: 'one' }; const obj2 = { type: 'dashboard', id: 'two' }; - const obj3 = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; - const obj4 = { type: MULTI_NAMESPACE_TYPE, id: 'four' }; - const obj5 = { type: MULTI_NAMESPACE_TYPE, id: 'five' }; + const obj3 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; + const obj4 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'four' }; + const obj5 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'five' }; const obj6 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'six' }; const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' }; const namespace = 'foo-namespace'; @@ -1854,7 +1897,7 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES get action then index action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, overwrite: true }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true }); expect(client.get).toHaveBeenCalled(); expect(client.index).toHaveBeenCalled(); }); @@ -1975,10 +2018,10 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id and adds namespaces to body when using multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, body: expect.objectContaining({ namespaces: [namespace] }), }), expect.anything() @@ -2013,7 +2056,7 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when options.initialNamespaces is used with a non-multi-namespace object`, async () => { + it(`throws when options.initialNamespaces is used with a non-shareable object`, async () => { const test = async (objType) => { await expect( savedObjectsRepository.create(objType, attributes, { initialNamespaces: [namespace] }) @@ -2024,10 +2067,11 @@ describe('SavedObjectsRepository', () => { ); }; await test('dashboard'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { await expect( savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) ).rejects.toThrowError( @@ -2056,17 +2100,20 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); + const response = getMockGetResponse( + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + 'bar-namespace' + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + savedObjectsRepository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true, namespace, }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalled(); }); @@ -2105,17 +2152,17 @@ describe('SavedObjectsRepository', () => { expectMigrationArgs({ namespace: expect.anything() }, false, 1); client.create.mockClear(); - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id }); expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace }); expectMigrationArgs({ namespaces: [namespace] }); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id }); expectMigrationArgs({ namespaces: ['default'] }); }); @@ -2181,13 +2228,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use ES get action then delete action when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); expect(client.delete).toHaveBeenCalledTimes(1); }); it(`includes the version of the existing document when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -2238,9 +2285,9 @@ describe('SavedObjectsRepository', () => { ); client.delete.mockClear(); - await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }), + expect.objectContaining({ id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}` }), expect.anything() ); }); @@ -2273,7 +2320,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -2281,27 +2328,29 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); response._source.namespaces = [namespace, 'bar-namespace']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -2309,13 +2358,13 @@ describe('SavedObjectsRepository', () => { }); it(`throws when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); response._source.namespaces = ['*']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -3200,10 +3249,10 @@ describe('SavedObjectsRepository', () => { ); client.get.mockClear(); - await getSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, }), expect.anything() ); @@ -3250,11 +3299,13 @@ describe('SavedObjectsRepository', () => { }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -3276,7 +3327,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await getSuccess(MULTI_NAMESPACE_TYPE, id); + const result = await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(result).toMatchObject({ namespaces: expect.any(Array), }); @@ -3451,8 +3502,12 @@ describe('SavedObjectsRepository', () => { it('but alias target does not exist in this namespace', async () => { const objects = [ - { type: MULTI_NAMESPACE_TYPE, id }, // correct namespace field is added by getMockMgetResponse - { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, // correct namespace field is added by getMockMgetResponse + { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id: aliasTargetId, + namespace: `not-${namespace}`, + }, // overrides namespace field that would otherwise be added by getMockMgetResponse ]; await expectExactMatchResult(objects); }); @@ -3475,6 +3530,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ saved_object: expect.objectContaining({ type, id: aliasTargetId }), outcome: 'aliasMatch', + aliasTargetId, }); }; @@ -3488,8 +3544,8 @@ describe('SavedObjectsRepository', () => { it('because actual target does not exist in this namespace', async () => { const objects = [ - { type: MULTI_NAMESPACE_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse - { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse ]; await expectAliasMatchResult(objects); }); @@ -3515,6 +3571,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'conflict', + aliasTargetId, }); }); }); @@ -3570,7 +3627,9 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES get action then update action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { - await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace }); + await incrementCounterSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledTimes(1); }); @@ -3625,10 +3684,12 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace }); + await incrementCounterSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, }), expect.anything() ); @@ -3693,15 +3754,23 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); + const response = getMockGetResponse( + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + 'bar-namespace' + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, counterFields, { - namespace, - }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + savedObjectsRepository.incrementCounter( + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + counterFields, + { + namespace, + } + ) + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -4009,7 +4078,7 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); - it(`throws when type is not multi-namespace`, async () => { + it(`throws when type is not shareable`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [namespace1, namespace2], message); @@ -4017,6 +4086,7 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); @@ -4181,7 +4251,7 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES get action then update action when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledTimes(1); }); @@ -4245,7 +4315,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to the version of the existing document when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { references }); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { references }); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -4300,15 +4370,17 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { namespace }); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { namespace }); expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }), + expect.objectContaining({ + id: expect.stringMatching(`${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`), + }), expect.anything() ); }); it(`includes _source_includes when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'] }), expect.anything() @@ -4353,7 +4425,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -4361,16 +4433,18 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -4407,7 +4481,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + const result = await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(result).toMatchObject({ namespaces: expect.any(Array), }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index b8a72377b0d764..78c3cdcb91e029 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -251,7 +251,7 @@ export class SavedObjectsRepository { const namespace = normalizeNamespace(options.namespace); if (initialNamespaces) { - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( '"options.initialNamespaces" can only be used on multi-namespace types' ); @@ -340,7 +340,7 @@ export class SavedObjectsRepository { if (!this._allowedTypes.includes(object.type)) { error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); } else if (object.initialNamespaces) { - if (!this._registry.isMultiNamespace(object.type)) { + if (!this._registry.isShareable(object.type)) { error = SavedObjectsErrorHelpers.createBadRequestError( '"initialNamespaces" can only be used on multi-namespace types' ); @@ -1085,6 +1085,7 @@ export class SavedObjectsRepository { return { saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), outcome: 'conflict', + aliasTargetId: legacyUrlAlias.targetId, }; } else if (foundExactMatch) { return { @@ -1095,6 +1096,7 @@ export class SavedObjectsRepository { return { saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc), outcome: 'aliasMatch', + aliasTargetId: legacyUrlAlias.targetId, }; } throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -1194,7 +1196,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( `${type} doesn't support multiple namespaces` ); @@ -1257,7 +1259,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( `${type} doesn't support multiple namespaces` ); diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index b93f3022e4236c..b078f3eef018cd 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -339,6 +339,10 @@ export interface SavedObjectsResolveResponse { * `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. */ outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; + /** + * The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + */ + aliasTargetId?: string; } /** diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 66110d096213f5..57a77a9ebc5257 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -213,13 +213,17 @@ export type SavedObjectsClientContract = Pick SavedObjectMigrationMap); /** - * If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. + * If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this + * version. * * Requirements: * * 1. This string value must be a valid semver version * 2. This type must have previously specified {@link SavedObjectsNamespaceType | `namespaceType: 'single'`} - * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} + * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} *or* + * {@link SavedObjectsNamespaceType | `namespaceType: 'multiple-isolated'`} * - * Example of a single-namespace type in 7.10: + * Example of a single-namespace type in 7.12: * * ```ts * { @@ -278,7 +284,19 @@ export interface SavedObjectsType { * } * ``` * - * Example after converting to a multi-namespace type in 7.11: + * Example after converting to a multi-namespace (isolated) type in 8.0: + * + * ```ts + * { + * name: 'foo', + * hidden: false, + * namespaceType: 'multiple-isolated', + * mappings: {...}, + * convertToMultiNamespaceTypeVersion: '8.0.0' + * } + * ``` + * + * Example after converting to a multi-namespace (shareable) type in 8.1: * * ```ts * { @@ -286,11 +304,11 @@ export interface SavedObjectsType { * hidden: false, * namespaceType: 'multiple', * mappings: {...}, - * convertToMultiNamespaceTypeVersion: '7.11.0' + * convertToMultiNamespaceTypeVersion: '8.0.0' * } * ``` * - * Note: a migration function can be optionally specified for the same version. + * Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. */ convertToMultiNamespaceTypeVersion?: string; /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b5f8b9d69abf31..34df3bcf853248 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2094,7 +2094,9 @@ export interface SavedObjectExportBaseOptions { // @public export interface SavedObjectMigrationContext { + convertToMultiNamespaceTypeVersion?: string; log: SavedObjectsMigrationLogger; + migrationVersion: string; } // @public @@ -2758,7 +2760,7 @@ export interface SavedObjectsMigrationVersion { } // @public -export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; // @public (undocumented) export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions { @@ -2850,6 +2852,7 @@ export interface SavedObjectsResolveImportErrorsOptions { // @public (undocumented) export interface SavedObjectsResolveResponse { + aliasTargetId?: string; outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; // (undocumented) saved_object: SavedObject; @@ -2963,6 +2966,7 @@ export class SavedObjectTypeRegistry { isImportableAndExportable(type: string): boolean; isMultiNamespace(type: string): boolean; isNamespaceAgnostic(type: string): boolean; + isShareable(type: string): boolean; isSingleNamespace(type: string): boolean; registerType(type: SavedObjectsType): void; } diff --git a/src/plugins/saved_objects_management/kibana.json b/src/plugins/saved_objects_management/kibana.json index f062433605c537..6c6d11d053c0f9 100644 --- a/src/plugins/saved_objects_management/kibana.json +++ b/src/plugins/saved_objects_management/kibana.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "requiredPlugins": ["management", "data"], - "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "savedObjectsTaggingOss"], + "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "savedObjectsTaggingOss", "spacesOss"], "extraPublicDirs": ["public/lib"], "requiredBundles": ["kibanaReact", "home"] } diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index d6cebd491b6e37..b855850ed185d4 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -37,7 +37,11 @@ export const mountManagementSection = async ({ mountParams, serviceRegistry, }: MountParams) => { - const [coreStart, { data, savedObjectsTaggingOss }, pluginStart] = await core.getStartServices(); + const [ + coreStart, + { data, savedObjectsTaggingOss, spacesOss }, + pluginStart, + ] = await core.getStartServices(); const { element, history, setBreadcrumbs } = mountParams; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); @@ -57,6 +61,8 @@ export const mountManagementSection = async ({ return children! as React.ReactElement; }; + const spacesApi = spacesOss?.isSpacesAvailable ? spacesOss : undefined; + ReactDOM.render( @@ -79,6 +85,7 @@ export const mountManagementSection = async ({ { @@ -80,22 +79,12 @@ export class Table extends PureComponent { isExportPopoverOpen: false, isIncludeReferencesDeepChecked: true, activeAction: undefined, - isColumnDataLoaded: false, }; constructor(props: TableProps) { super(props); } - componentDidMount() { - this.loadColumnData(); - } - - loadColumnData = async () => { - await Promise.all(this.props.columnRegistry.getAll().map((column) => column.loadData())); - this.setState({ isColumnDataLoaded: true }); - }; - onChange = ({ query, error }: any) => { if (error) { this.setState({ diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index 8049b8adfdf1ce..c5ae2127ac0305 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -15,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { CoreStart, ChromeBreadcrumb } from 'src/core/public'; import { DataPublicPluginStart } from '../../../data/public'; import { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; +import type { SpacesAvailableStartContract } from '../../../spaces_oss/public'; import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, @@ -22,10 +23,13 @@ import { } from '../services'; import { SavedObjectsTable } from './objects_table'; +const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}; + const SavedObjectsTablePage = ({ coreStart, dataStart, taggingApi, + spacesApi, allowedTypes, serviceRegistry, actionRegistry, @@ -35,6 +39,7 @@ const SavedObjectsTablePage = ({ coreStart: CoreStart; dataStart: DataPublicPluginStart; taggingApi?: SavedObjectsTaggingApi; + spacesApi?: SpacesAvailableStartContract; allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; actionRegistry: SavedObjectsManagementActionServiceStart; @@ -65,35 +70,42 @@ const SavedObjectsTablePage = ({ ]); }, [setBreadcrumbs]); + const ContextWrapper = useMemo( + () => spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent, + [spacesApi] + ); + return ( - { - const { editUrl } = savedObject.meta; - if (editUrl) { - return coreStart.application.navigateToUrl( - coreStart.http.basePath.prepend(`/app${editUrl}`) - ); - } - }} - canGoInApp={(savedObject) => { - const { inAppUrl } = savedObject.meta; - return inAppUrl ? Boolean(get(capabilities, inAppUrl.uiCapabilitiesPath)) : false; - }} - /> + + { + const { editUrl } = savedObject.meta; + if (editUrl) { + return coreStart.application.navigateToUrl( + coreStart.http.basePath.prepend(`/app${editUrl}`) + ); + } + }} + canGoInApp={(savedObject) => { + const { inAppUrl } = savedObject.meta; + return inAppUrl ? Boolean(get(capabilities, inAppUrl.uiCapabilitiesPath)) : false; + }} + /> + ); }; // eslint-disable-next-line import/no-default-export diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index a4c7a84b419baa..f4578c4c4b8e10 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -15,6 +15,7 @@ import { DiscoverStart } from '../../discover/public'; import { HomePublicPluginSetup, FeatureCatalogueCategory } from '../../home/public'; import { VisualizationsStart } from '../../visualizations/public'; import { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; +import type { SpacesOssPluginStart } from '../../spaces_oss/public'; import { SavedObjectsManagementActionService, SavedObjectsManagementActionServiceSetup, @@ -49,6 +50,7 @@ export interface StartDependencies { visualizations?: VisualizationsStart; discover?: DiscoverStart; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; + spacesOss?: SpacesOssPluginStart; } export class SavedObjectsManagementPlugin diff --git a/src/plugins/saved_objects_management/public/services/types/column.ts b/src/plugins/saved_objects_management/public/services/types/column.ts index 6103f1bd3d5c0c..1be279db912051 100644 --- a/src/plugins/saved_objects_management/public/services/types/column.ts +++ b/src/plugins/saved_objects_management/public/services/types/column.ts @@ -12,7 +12,4 @@ import { SavedObjectsManagementRecord } from '.'; export interface SavedObjectsManagementColumn { id: string; euiColumn: Omit, 'sortable'>; - - data?: T; - loadData: () => Promise; } diff --git a/src/plugins/saved_objects_management/tsconfig.json b/src/plugins/saved_objects_management/tsconfig.json index eb047c346714ca..99849dea386181 100644 --- a/src/plugins/saved_objects_management/tsconfig.json +++ b/src/plugins/saved_objects_management/tsconfig.json @@ -21,5 +21,6 @@ { "path": "../kibana_react/tsconfig.json" }, { "path": "../management/tsconfig.json" }, { "path": "../visualizations/tsconfig.json" }, + { "path": "../spaces_oss/tsconfig.json" }, ] } diff --git a/src/plugins/spaces_oss/public/api.mock.ts b/src/plugins/spaces_oss/public/api.mock.ts index 4c6b8bb6ee3381..c4a410c76e7962 100644 --- a/src/plugins/spaces_oss/public/api.mock.ts +++ b/src/plugins/spaces_oss/public/api.mock.ts @@ -7,13 +7,40 @@ */ import { of } from 'rxjs'; -import { SpacesApi } from './api'; +import { SpacesApi, SpacesApiUi, SpacesApiUiComponent } from './api'; const createApiMock = (): jest.Mocked => ({ activeSpace$: of(), getActiveSpace: jest.fn(), + ui: createApiUiMock(), }); +type SpacesApiUiMock = Omit, 'components'> & { + components: SpacesApiUiComponentMock; +}; + +const createApiUiMock = () => { + const mock: SpacesApiUiMock = { + components: createApiUiComponentsMock(), + redirectLegacyUrl: jest.fn(), + }; + + return mock; +}; + +type SpacesApiUiComponentMock = jest.Mocked; + +const createApiUiComponentsMock = () => { + const mock: SpacesApiUiComponentMock = { + SpacesContext: jest.fn(), + ShareToSpaceFlyout: jest.fn(), + SpaceList: jest.fn(), + LegacyUrlConflict: jest.fn(), + }; + + return mock; +}; + export const spacesApiMock = { create: createApiMock, }; diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index 5fa8b4fc29719a..2d5e144158d78b 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -7,6 +7,7 @@ */ import { Observable } from 'rxjs'; +import type { FunctionComponent } from 'react'; import { Space } from '../common'; /** @@ -15,4 +16,238 @@ import { Space } from '../common'; export interface SpacesApi { readonly activeSpace$: Observable; getActiveSpace(): Promise; + /** + * UI API to use to add spaces capabilities to an application + */ + ui: SpacesApiUi; +} + +/** + * @public + */ +export interface SpacesApiUi { + /** + * {@link SpacesApiUiComponent | React components} to support the spaces feature. + */ + components: SpacesApiUiComponent; + /** + * Redirect the user from a legacy URL to a new URL. This needs to be used if a call to `SavedObjectsClient.resolve()` results in an + * `"aliasMatch"` outcome, which indicates that the user has loaded the page using a legacy URL. Calling this function will trigger a + * client-side redirect to the new URL, and it will display a toast to the user. + * + * Consumers need to determine the local path for the new URL on their own, based on the object ID that was used to call + * `SavedObjectsClient.resolve()` (old ID) and the object ID in the result (new ID). For example... + * + * The old object ID is `workpad-123` and the new object ID is `workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e`. + * + * Full legacy URL: `https://localhost:5601/app/canvas#/workpad/workpad-123/page/1` + * + * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` + * + * The protocol, hostname, port, base path, and app path are automatically included. + * + * @param path The path to use for the new URL, optionally including `search` and/or `hash` URL components. + * @param objectNoun The string that is used to describe the object in the toast, e.g., _The **object** you're looking for has a new + * location_. Default value is 'object'. + */ + redirectLegacyUrl: (path: string, objectNoun?: string) => Promise; +} + +/** + * React UI components to be used to display the spaces feature in any application. + * + * @public + */ +export interface SpacesApiUiComponent { + /** + * Provides a context that is required to render some Spaces components. + */ + SpacesContext: FunctionComponent; + /** + * Displays a flyout to edit the spaces that an object is shared to. + * + * Note: must be rendered inside of a SpacesContext. + */ + ShareToSpaceFlyout: FunctionComponent; + /** + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for + * any number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras + * (along with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it + * supersedes all of the above and just displays a single badge without a button. + * + * Note: must be rendered inside of a SpacesContext. + */ + SpaceList: FunctionComponent; + /** + * Displays a callout that needs to be used if a call to `SavedObjectsClient.resolve()` results in an `"conflict"` outcome, which + * indicates that the user has loaded the page which is associated directly with one object (A), *and* with a legacy URL that points to a + * different object (B). + * + * In this case, `SavedObjectsClient.resolve()` has returned object A. This component displays a warning callout to the user explaining + * that there is a conflict, and it includes a button that will redirect the user to object B when clicked. + * + * Consumers need to determine the local path for the new URL on their own, based on the object ID that was used to call + * `SavedObjectsClient.resolve()` (A) and the `aliasTargetId` value in the response (B). For example... + * + * A is `workpad-123` and B is `workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e`. + * + * Full legacy URL: `https://localhost:5601/app/canvas#/workpad/workpad-123/page/1` + * + * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` + */ + LegacyUrlConflict: FunctionComponent; +} + +/** + * @public + */ +export interface SpacesContextProps { + /** + * If a feature is specified, all Spaces components will treat it appropriately if the feature is disabled in a given Space. + */ + feature?: string; +} + +/** + * @public + */ +export interface ShareToSpaceFlyoutProps { + /** + * The object to render the flyout for. + */ + savedObjectTarget: ShareToSpaceSavedObjectTarget; + /** + * The EUI icon that is rendered in the flyout's title. + * + * Default is 'share'. + */ + flyoutIcon?: string; + /** + * The string that is rendered in the flyout's title. + * + * Default is 'Edit spaces for object'. + */ + flyoutTitle?: string; + /** + * When enabled, if the object is not yet shared to multiple spaces, a callout will be displayed that suggests the user might want to + * create a copy instead. + * + * Default value is false. + */ + enableCreateCopyCallout?: boolean; + /** + * When enabled, if no other spaces exist _and_ the user has the appropriate privileges, a sentence will be displayed that suggests the + * user might want to create a space. + * + * Default value is false. + */ + enableCreateNewSpaceLink?: boolean; + /** + * When set to 'within-space' (default), the flyout behaves like it is running on a page within the active space, and it will prevent the + * user from removing the object from the active space. + * + * Conversely, when set to 'outside-space', the flyout behaves like it is running on a page outside of any space, so it will allow the + * user to remove the object from the active space. + */ + behaviorContext?: 'within-space' | 'outside-space'; + /** + * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object. If + * this is not defined, a default handler will be used that calls `/api/spaces/_share_saved_object_add` and/or + * `/api/spaces/_share_saved_object_remove` and displays toast(s) indicating what occurred. + */ + changeSpacesHandler?: (spacesToAdd: string[], spacesToRemove: string[]) => Promise; + /** + * Optional callback when the target object is updated. + */ + onUpdate?: () => void; + /** + * Optional callback when the flyout is closed. + */ + onClose?: () => void; +} + +/** + * @public + */ +export interface ShareToSpaceSavedObjectTarget { + /** + * The object's type. + */ + type: string; + /** + * The object's ID. + */ + id: string; + /** + * The namespaces that the object currently exists in. + */ + namespaces: string[]; + /** + * The EUI icon that is rendered in the flyout's subtitle. + * + * Default is 'empty'. + */ + icon?: string; + /** + * The string that is rendered in the flyout's subtitle. + * + * Default is `${type} [id=${id}]`. + */ + title?: string; + /** + * The string that is used to describe the object in several places, e.g., _Make **object** available in selected spaces only_. + * + * Default value is 'object'. + */ + noun?: string; +} + +/** + * @public + */ +export interface SpaceListProps { + /** + * The namespaces of a saved object to render into a corresponding list of spaces. + */ + namespaces: string[]; + /** + * Optional limit to the number of spaces that can be displayed in the list. If the number of spaces exceeds this limit, they will be + * hidden behind a "show more" button. Set to 0 to disable. + * + * Default value is 5. + */ + displayLimit?: number; + /** + * When set to 'within-space' (default), the space list behaves like it is running on a page within the active space, and it will omit the + * active space (e.g., it displays a list of all the _other_ spaces that an object is shared to). + * + * Conversely, when set to 'outside-space', the space list behaves like it is running on a page outside of any space, so it will not omit + * the active space. + */ + behaviorContext?: 'within-space' | 'outside-space'; +} + +/** + * @public + */ +export interface LegacyUrlConflictProps { + /** + * The string that is used to describe the object in the callout, e.g., _There is a legacy URL for this page that points to a different + * **object**_. + * + * Default value is 'object'. + */ + objectNoun?: string; + /** + * The ID of the object that is currently shown on the page. + */ + currentObjectId: string; + /** + * The ID of the other object that the legacy URL alias points to. + */ + otherObjectId: string; + /** + * The path to use for the new URL, optionally including `search` and/or `hash` URL components. + */ + otherObjectPath: string; } diff --git a/src/plugins/spaces_oss/public/index.ts b/src/plugins/spaces_oss/public/index.ts index 70172f620d0435..be42bd9a899b10 100644 --- a/src/plugins/spaces_oss/public/index.ts +++ b/src/plugins/spaces_oss/public/index.ts @@ -8,8 +8,22 @@ import { SpacesOssPlugin } from './plugin'; -export { SpacesOssPluginSetup, SpacesOssPluginStart } from './types'; +export { + SpacesOssPluginSetup, + SpacesOssPluginStart, + SpacesAvailableStartContract, + SpacesUnavailableStartContract, +} from './types'; -export { SpacesApi } from './api'; +export { + SpacesApi, + SpacesApiUi, + SpacesApiUiComponent, + SpacesContextProps, + ShareToSpaceFlyoutProps, + ShareToSpaceSavedObjectTarget, + SpaceListProps, + LegacyUrlConflictProps, +} from './api'; export const plugin = () => new SpacesOssPlugin(); diff --git a/src/plugins/spaces_oss/public/types.ts b/src/plugins/spaces_oss/public/types.ts index 80b1f7aa840bbc..831aaa2c459439 100644 --- a/src/plugins/spaces_oss/public/types.ts +++ b/src/plugins/spaces_oss/public/types.ts @@ -8,11 +8,11 @@ import { SpacesApi } from './api'; -interface SpacesAvailableStartContract extends SpacesApi { +export interface SpacesAvailableStartContract extends SpacesApi { isSpacesAvailable: true; } -interface SpacesUnavailableStartContract { +export interface SpacesUnavailableStartContract { isSpacesAvailable: false; } diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index f2f9d24488ac04..5a5158825a2248 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -440,7 +440,7 @@ export default ({ getService }: FtrProviderContext) => { }, { ...BAR_TYPE, - namespaceType: 'multiple', + namespaceType: 'multiple-isolated', convertToMultiNamespaceTypeVersion: '2.0.0', }, BAZ_TYPE, // must be registered for reference transforms to be applied to objects of this type diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts index 2f9187b1ccc6aa..36e228ead31da7 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts @@ -12,7 +12,7 @@ import { SavedObjectUnsanitizedDoc } from 'kibana/server'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; import { migrationMocks } from 'src/core/server/mocks'; -const { log } = migrationMocks.createContext(); +const migrationContext = migrationMocks.createContext(); const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); describe('7.10.0', () => { @@ -26,7 +26,7 @@ describe('7.10.0', () => { test('marks alerts as legacy', () => { const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; const alert = getMockData({}); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -42,7 +42,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'metrics', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -59,7 +59,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'securitySolution', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -76,7 +76,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'alerting', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -104,7 +104,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -142,7 +142,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -179,7 +179,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -206,7 +206,7 @@ describe('7.10.0', () => { const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; const alert = getMockData(); const dateStart = Date.now(); - const migratedAlert = migration710(alert, { log }); + const migratedAlert = migration710(alert, migrationContext); const dateStop = Date.now(); const dateExecutionStatus = Date.parse( migratedAlert.attributes.executionStatus.lastExecutionDate @@ -242,14 +242,14 @@ describe('7.10.0 migrates with failure', () => { const alert = getMockData({ consumer: 'alerting', }); - const res = migration710(alert, { log }); + const res = migration710(alert, migrationContext); expect(res).toMatchObject({ ...alert, attributes: { ...alert.attributes, }, }); - expect(log.error).toHaveBeenCalledWith( + expect(migrationContext.log.error).toHaveBeenCalledWith( `encryptedSavedObject 7.10.0 migration failed for alert ${alert.id} with error: Can't migrate!`, { alertDocument: { @@ -274,7 +274,7 @@ describe('7.11.0', () => { test('add updatedAt field to alert - set to SavedObject updated_at attribute', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}, true); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -287,7 +287,7 @@ describe('7.11.0', () => { test('add updatedAt field to alert - set to createdAt when SavedObject updated_at is not defined', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -300,7 +300,7 @@ describe('7.11.0', () => { test('add notifyWhen=onActiveAlert when throttle is null', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -313,7 +313,7 @@ describe('7.11.0', () => { test('add notifyWhen=onActiveAlert when throttle is set', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({ throttle: '5m' }); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts index 508879c3596e54..4df51af8b16b02 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts @@ -15,7 +15,7 @@ afterEach(() => { }); describe('createMigration()', () => { - const { log } = migrationMocks.createContext(); + const migrationContext = migrationMocks.createContext(); const inputType = { type: 'known-type-1', attributesToEncrypt: new Set(['firstAttr']) }; const migrationType = { type: 'known-type-1', @@ -88,7 +88,7 @@ describe('createMigration()', () => { namespace: 'namespace', attributes, }, - { log } + migrationContext ); expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith( @@ -97,7 +97,8 @@ describe('createMigration()', () => { type: 'known-type-1', namespace: 'namespace', }, - attributes + attributes, + { convertToMultiNamespaceType: false } ); expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( @@ -112,7 +113,7 @@ describe('createMigration()', () => { }); describe('migration of a single legacy type', () => { - it('uses the input type as the mirgation type when omitted', async () => { + it('uses the input type as the migration type when omitted', async () => { const serviceWithLegacyType = encryptedSavedObjectsServiceMock.create(); const instantiateServiceWithLegacyType = jest.fn(() => serviceWithLegacyType); @@ -142,7 +143,7 @@ describe('createMigration()', () => { namespace: 'namespace', attributes, }, - { log } + migrationContext ); expect(serviceWithLegacyType.decryptAttributesSync).toHaveBeenCalledWith( @@ -151,7 +152,8 @@ describe('createMigration()', () => { type: 'known-type-1', namespace: 'namespace', }, - attributes + attributes, + { convertToMultiNamespaceType: false } ); expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( @@ -163,6 +165,81 @@ describe('createMigration()', () => { attributes ); }); + + describe('uses the object `namespaces` field to populate the descriptor when the migration context indicates this type is being converted', () => { + const doTest = ({ + objectNamespace, + decryptDescriptorNamespace, + }: { + objectNamespace: string | undefined; + decryptDescriptorNamespace: string | undefined; + }) => { + const instantiateServiceWithLegacyType = jest.fn(() => + encryptedSavedObjectsServiceMock.create() + ); + + const migrationCreator = getCreateMigration( + encryptionSavedObjectService, + instantiateServiceWithLegacyType + ); + const noopMigration = migrationCreator( + function (doc): doc is SavedObjectUnsanitizedDoc { + return true; + }, + (doc) => doc + ); + + const attributes = { + firstAttr: 'first_attr', + }; + + encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes); + encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(attributes); + + noopMigration( + { + id: '123', + type: 'known-type-1', + namespaces: objectNamespace ? [objectNamespace] : [], + attributes, + }, + migrationMocks.createContext({ + migrationVersion: '8.0.0', + convertToMultiNamespaceTypeVersion: '8.0.0', + }) + ); + + expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith( + { + id: '123', + type: 'known-type-1', + namespace: decryptDescriptorNamespace, + }, + attributes, + { convertToMultiNamespaceType: true } + ); + + expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( + { + id: '123', + type: 'known-type-1', + }, + attributes + ); + }; + + it('when namespaces is an empty array', () => { + doTest({ objectNamespace: undefined, decryptDescriptorNamespace: undefined }); + }); + + it('when the first namespace element is "default"', () => { + doTest({ objectNamespace: 'default', decryptDescriptorNamespace: undefined }); + }); + + it('when the first namespace element is another string', () => { + doTest({ objectNamespace: 'foo', decryptDescriptorNamespace: 'foo' }); + }); + }); }); describe('migration across two legacy types', () => { @@ -216,7 +293,7 @@ describe('createMigration()', () => { firstAttr: '#####', }, }, - { log } + migrationContext ) ).toMatchObject({ id: '123', @@ -257,7 +334,7 @@ describe('createMigration()', () => { nonEncryptedAttr: 'non encrypted', }, }, - { log } + migrationContext ) ).toMatchObject({ id: '123', @@ -278,7 +355,8 @@ describe('createMigration()', () => { { firstAttr: '#####', nonEncryptedAttr: 'non encrypted', - } + }, + { convertToMultiNamespaceType: false } ); expect(serviceWithMigrationLegacyType.encryptAttributesSync).toHaveBeenCalledWith( diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts index eb262997a8e451..cf5357c40fa20a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts @@ -11,6 +11,7 @@ import { SavedObjectMigrationContext, } from 'src/core/server'; import { EncryptedSavedObjectTypeRegistration, EncryptedSavedObjectsService } from './crypto'; +import { normalizeNamespace } from './saved_objects'; type SavedObjectOptionalMigrationFn = ( doc: SavedObjectUnsanitizedDoc | SavedObjectUnsanitizedDoc, @@ -63,11 +64,19 @@ export const getCreateMigration = ( return encryptedDoc; } - const descriptor = { - id: encryptedDoc.id!, - type: encryptedDoc.type, - namespace: encryptedDoc.namespace, - }; + // If an object has been converted right before this migration function is called, it will no longer have a `namespace` field, but it + // will have a `namespaces` field; in that case, the first/only element in that array should be used as the namespace in the descriptor + // during decryption. + const convertToMultiNamespaceType = + context.convertToMultiNamespaceTypeVersion === context.migrationVersion; + const decryptDescriptorNamespace = convertToMultiNamespaceType + ? normalizeNamespace(encryptedDoc.namespaces?.[0]) // `namespaces` contains string values, but we need to normalize this to the namespace ID representation + : encryptedDoc.namespace; + + const { id, type } = encryptedDoc; + // These descriptors might have a `namespace` that is undefined. That is expected for multi-namespace and namespace-agnostic types. + const decryptDescriptor = { id, type, namespace: decryptDescriptorNamespace }; + const encryptDescriptor = { id, type, namespace: encryptedDoc.namespace }; // decrypt the attributes using the input type definition // then migrate the document @@ -75,12 +84,14 @@ export const getCreateMigration = ( return mapAttributes( migration( mapAttributes(encryptedDoc, (inputAttributes) => - inputService.decryptAttributesSync(descriptor, inputAttributes) + inputService.decryptAttributesSync(decryptDescriptor, inputAttributes, { + convertToMultiNamespaceType, + }) ), context ), (migratedAttributes) => - migratedService.encryptAttributesSync(descriptor, migratedAttributes) + migratedService.encryptAttributesSync(encryptDescriptor, migratedAttributes) ); }; }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index f70810943d179f..7bc08d0e7b30fa 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -819,6 +819,55 @@ describe('#decryptAttributes', () => { ); }); + it('retries decryption without namespace if incorrect namespace is provided and convertToMultiNamespaceType option is enabled', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrThree']), + }); + + const encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, // namespace was not included in descriptor during encryption + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + await expect( + service.decryptAttributes( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).resolves.toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (success) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('decrypts even if no attributes are included into AAD', async () => { const attributes = { attrOne: 'one', attrThree: 'three' }; service.registerType({ @@ -1017,6 +1066,47 @@ describe('#decryptAttributes', () => { ); }); + it('fails if retry decryption without namespace is not correct', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'some-other-ns' }, + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + await expect(() => + service.decryptAttributes( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).rejects.toThrowError(EncryptionError); + expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (fail) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('fails to decrypt if encrypted attribute is defined, but not a string', async () => { const mockUser = mockAuthenticatedUser(); await expect( @@ -1707,6 +1797,55 @@ describe('#decryptAttributesSync', () => { }); }); + it('retries decryption without namespace if incorrect namespace is provided and convertToMultiNamespaceType option is enabled', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrThree']), + }); + + const encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, // namespace was not included in descriptor during encryption + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + expect( + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (success) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('decrypts even if no attributes are included into AAD', () => { const attributes = { attrOne: 'one', attrThree: 'three' }; service.registerType({ @@ -1852,6 +1991,47 @@ describe('#decryptAttributesSync', () => { ).toThrowError(EncryptionError); }); + it('fails if retry decryption without namespace is not correct', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'some-other-ns' }, + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + expect(() => + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).toThrowError(EncryptionError); + expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (fail) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('fails to decrypt if encrypted attribute is defined, but not a string', () => { expect(() => service.decryptAttributesSync( diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 23aef07ff8781f..17757c9d8b2ba3 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -61,6 +61,14 @@ interface DecryptParameters extends CommonParameters { * Indicates whether decryption should only be performed using secondary decryption-only keys. */ omitPrimaryEncryptionKey?: boolean; + /** + * Indicates whether the object to be decrypted is being converted from a single-namespace type to a multi-namespace type. In this case, + * we may need to attempt decryption twice: once with a namespace in the descriptor (for use during index migration), and again without a + * namespace in the descriptor (for use during object migration). In other words, if the object is being decrypted during index migration, + * the object was previously encrypted with its namespace in the descriptor portion of the AAD; on the other hand, if the object is being + * decrypted during object migration, the object was never encrypted with its namespace in the descriptor portion of the AAD. + */ + convertToMultiNamespaceType?: boolean; } interface EncryptedSavedObjectsServiceOptions { @@ -366,14 +374,17 @@ export class EncryptedSavedObjectsService { let iteratorResult = iterator.next(); while (!iteratorResult.done) { - const [attributeValue, encryptionAAD] = iteratorResult.value; + const [attributeValue, encryptionAADs] = iteratorResult.value; // We check this inside of the iterator to throw only if we do need to decrypt anything. let decryptionError = decrypters.length === 0 ? new Error('Decryption is disabled because of missing decryption keys.') : undefined; - for (const decrypter of decrypters) { + const decryptersPerAAD = decrypters.flatMap((decr) => + encryptionAADs.map((aad) => [decr, aad] as [Crypto, string]) + ); + for (const [decrypter, encryptionAAD] of decryptersPerAAD) { try { iteratorResult = iterator.next(await decrypter.decrypt(attributeValue, encryptionAAD)); decryptionError = undefined; @@ -414,14 +425,17 @@ export class EncryptedSavedObjectsService { let iteratorResult = iterator.next(); while (!iteratorResult.done) { - const [attributeValue, encryptionAAD] = iteratorResult.value; + const [attributeValue, encryptionAADs] = iteratorResult.value; // We check this inside of the iterator to throw only if we do need to decrypt anything. let decryptionError = decrypters.length === 0 ? new Error('Decryption is disabled because of missing decryption keys.') : undefined; - for (const decrypter of decrypters) { + const decryptersPerAAD = decrypters.flatMap((decr) => + encryptionAADs.map((aad) => [decr, aad] as [Crypto, string]) + ); + for (const [decrypter, encryptionAAD] of decryptersPerAAD) { try { iteratorResult = iterator.next(decrypter.decryptSync(attributeValue, encryptionAAD)); decryptionError = undefined; @@ -445,13 +459,13 @@ export class EncryptedSavedObjectsService { private *attributesToDecryptIterator>( descriptor: SavedObjectDescriptor, attributes: T, - params?: CommonParameters - ): Iterator<[string, string], T, EncryptOutput> { + params?: DecryptParameters + ): Iterator<[string, string[]], T, EncryptOutput> { const typeDefinition = this.typeDefinitions.get(descriptor.type); if (typeDefinition === undefined) { return attributes; } - let encryptionAAD: string | undefined; + const encryptionAADs: string[] = []; const decryptedAttributes: Record = {}; for (const attributeName of typeDefinition.attributesToEncrypt) { const attributeValue = attributes[attributeName]; @@ -467,11 +481,16 @@ export class EncryptedSavedObjectsService { )}` ); } - if (!encryptionAAD) { - encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); + if (!encryptionAADs.length) { + encryptionAADs.push(this.getAAD(typeDefinition, descriptor, attributes)); + if (params?.convertToMultiNamespaceType && descriptor.namespace) { + // This is happening during a migration; create an alternate AAD for decrypting the object attributes by stripping out the namespace from the descriptor. + const { namespace, ...alternateDescriptor } = descriptor; + encryptionAADs.push(this.getAAD(typeDefinition, alternateDescriptor, attributes)); + } } try { - decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!; + decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAADs])!; } catch (err) { this.options.logger.error( `Failed to decrypt "${attributeName}" attribute: ${err.message || err}` diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts index 627e15e591a81a..0f737995e8d2af 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts @@ -24,5 +24,5 @@ export const getDescriptorNamespace = ( * Ensure that a namespace is always in its namespace ID representation. * This allows `'default'` to be used interchangeably with `undefined`. */ -const normalizeNamespace = (namespace?: string) => +export const normalizeNamespace = (namespace?: string) => namespace === undefined ? namespace : SavedObjectsUtils.namespaceStringToId(namespace); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index cac7b9ba9d5cc4..9e7c1f65922907 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -17,7 +17,9 @@ import { import { SecurityPluginSetup } from '../../../security/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; -import { getDescriptorNamespace } from './get_descriptor_namespace'; +import { getDescriptorNamespace, normalizeNamespace } from './get_descriptor_namespace'; + +export { normalizeNamespace }; interface SetupSavedObjectsParams { service: PublicMethodsOf; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 790c9a28b656c9..d13920b084183c 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -39,7 +39,6 @@ "dashboard", "savedObjects", "home", - "spaces", "maps" ], "extraPublicDirs": [ diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts b/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts index cac8f63b6e0496..8acec6a45a0c8c 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { JobSpacesList, ALL_SPACES_ID } from './job_spaces_list'; +export { JobSpacesList } from './job_spaces_list'; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx index 2aa7c6bb4a6e38..6e0715de12fb9f 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx @@ -5,64 +5,87 @@ * 2.0. */ -import React, { FC, useState, useEffect } from 'react'; +import React, { FC, useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { JobSpacesFlyout } from '../job_spaces_selector'; -import { JobType } from '../../../../common/types/saved_objects'; -import { useSpacesContext } from '../../contexts/spaces'; -import { Space, SpaceAvatar } from '../../../../../spaces/public'; - -export const ALL_SPACES_ID = '*'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { ShareToSpaceFlyoutProps } from 'src/plugins/spaces_oss/public'; +import { + JobType, + ML_SAVED_OBJECT_TYPE, + SavedObjectResult, +} from '../../../../common/types/saved_objects'; +import type { SpacesPluginStart } from '../../../../../spaces/public'; +import { ml } from '../../services/ml_api_service'; +import { useToastNotificationService } from '../../services/toast_notification_service'; interface Props { + spacesApi: SpacesPluginStart; spaceIds: string[]; jobId: string; jobType: JobType; refresh(): void; } -function filterUnknownSpaces(ids: string[]) { - return ids.filter((id) => id !== '?'); -} +const ALL_SPACES_ID = '*'; +const objectNoun = i18n.translate('xpack.ml.management.jobsSpacesList.objectNoun', { + defaultMessage: 'job', +}); -export const JobSpacesList: FC = ({ spaceIds, jobId, jobType, refresh }) => { - const { allSpaces } = useSpacesContext(); +export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, refresh }) => { + const { displayErrorToast } = useToastNotificationService(); const [showFlyout, setShowFlyout] = useState(false); - const [spaces, setSpaces] = useState([]); - useEffect(() => { - const tempSpaces = spaceIds.includes(ALL_SPACES_ID) - ? [{ id: ALL_SPACES_ID, name: ALL_SPACES_ID, disabledFeatures: [], color: '#DDD' }] - : allSpaces.filter((s) => spaceIds.includes(s.id)); - setSpaces(tempSpaces); - }, [spaceIds, allSpaces]); + async function changeSpacesHandler(spacesToAdd: string[], spacesToRemove: string[]) { + if (spacesToAdd.length) { + const resp = await ml.savedObjects.assignJobToSpace(jobType, [jobId], spacesToAdd); + handleApplySpaces(resp); + } + if (spacesToRemove.length && !spacesToAdd.includes(ALL_SPACES_ID)) { + const resp = await ml.savedObjects.removeJobFromSpace(jobType, [jobId], spacesToRemove); + handleApplySpaces(resp); + } + onClose(); + } function onClose() { setShowFlyout(false); refresh(); } + function handleApplySpaces(resp: SavedObjectResult) { + Object.entries(resp).forEach(([id, { success, error }]) => { + if (success === false) { + const title = i18n.translate('xpack.ml.management.jobsSpacesList.updateSpaces.error', { + defaultMessage: 'Error updating {id}', + values: { id }, + }); + displayErrorToast(error, title); + } + }); + } + + const { SpaceList, ShareToSpaceFlyout } = spacesApi.ui.components; + const shareToSpaceFlyoutProps: ShareToSpaceFlyoutProps = { + savedObjectTarget: { + type: ML_SAVED_OBJECT_TYPE, + id: jobId, + namespaces: spaceIds, + title: jobId, + noun: objectNoun, + }, + behaviorContext: 'outside-space', + changeSpacesHandler, + onClose, + }; + return ( <> setShowFlyout(true)} style={{ height: 'auto' }}> - - {spaces.map((space) => ( - - - - ))} - + - {showFlyout && ( - - )} + {showFlyout && } ); }; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx deleted file mode 100644 index 94ed9ad0d30748..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSpacer, EuiCallOut } from '@elastic/eui'; - -export const CannotEditCallout: FC<{ jobId: string }> = ({ jobId }) => ( - <> - - - - - -); diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx deleted file mode 100644 index 12304cd133d8ef..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC, useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { difference, xor } from 'lodash'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiFlyoutFooter, - EuiFlexGroup, - EuiFlexItem, - EuiButton, - EuiButtonEmpty, - EuiTitle, - EuiFlyoutBody, -} from '@elastic/eui'; - -import { JobType, SavedObjectResult } from '../../../../common/types/saved_objects'; -import { ml } from '../../services/ml_api_service'; -import { useToastNotificationService } from '../../services/toast_notification_service'; - -import { SpacesSelector } from './spaces_selectors'; - -interface Props { - jobId: string; - jobType: JobType; - spaceIds: string[]; - onClose: () => void; -} -export const JobSpacesFlyout: FC = ({ jobId, jobType, spaceIds, onClose }) => { - const { displayErrorToast } = useToastNotificationService(); - - const [selectedSpaceIds, setSelectedSpaceIds] = useState(spaceIds); - const [saving, setSaving] = useState(false); - const [savable, setSavable] = useState(false); - const [canEditSpaces, setCanEditSpaces] = useState(false); - - useEffect(() => { - const different = xor(selectedSpaceIds, spaceIds).length !== 0; - setSavable(different === true && selectedSpaceIds.length > 0); - }, [selectedSpaceIds.length]); - - async function applySpaces() { - if (savable) { - setSaving(true); - const addedSpaces = difference(selectedSpaceIds, spaceIds); - const removedSpaces = difference(spaceIds, selectedSpaceIds); - if (addedSpaces.length) { - const resp = await ml.savedObjects.assignJobToSpace(jobType, [jobId], addedSpaces); - handleApplySpaces(resp); - } - if (removedSpaces.length) { - const resp = await ml.savedObjects.removeJobFromSpace(jobType, [jobId], removedSpaces); - handleApplySpaces(resp); - } - onClose(); - } - } - - function handleApplySpaces(resp: SavedObjectResult) { - Object.entries(resp).forEach(([id, { success, error }]) => { - if (success === false) { - const title = i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.updateSpaces.error', - { - defaultMessage: 'Error updating {id}', - values: { id }, - } - ); - displayErrorToast(error, title); - } - }); - } - - return ( - <> - - - -

- -

-
-
- - - - - - - - - - - - - - - - - -
- - ); -}; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss deleted file mode 100644 index 75cdbd972455b0..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss +++ /dev/null @@ -1,3 +0,0 @@ -.mlCopyToSpace__spacesList { - margin-top: $euiSizeXS; -} diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx deleted file mode 100644 index 281ac5028995b2..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx +++ /dev/null @@ -1,223 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import './spaces_selector.scss'; -import React, { FC, useState, useEffect, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiFormRow, - EuiSelectable, - EuiSelectableOption, - EuiIconTip, - EuiText, - EuiCheckableCard, - EuiFormFieldset, -} from '@elastic/eui'; - -import { SpaceAvatar } from '../../../../../spaces/public'; -import { useSpacesContext } from '../../contexts/spaces'; -import { ML_SAVED_OBJECT_TYPE } from '../../../../common/types/saved_objects'; -import { ALL_SPACES_ID } from '../job_spaces_list'; -import { CannotEditCallout } from './cannot_edit_callout'; - -type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; - -interface Props { - jobId: string; - spaceIds: string[]; - setSelectedSpaceIds: (ids: string[]) => void; - selectedSpaceIds: string[]; - canEditSpaces: boolean; - setCanEditSpaces: (canEditSpaces: boolean) => void; -} - -export const SpacesSelector: FC = ({ - jobId, - spaceIds, - setSelectedSpaceIds, - selectedSpaceIds, - canEditSpaces, - setCanEditSpaces, -}) => { - const { spacesManager, allSpaces } = useSpacesContext(); - - const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); - - useEffect(() => { - if (spacesManager !== null) { - const getPermissions = spacesManager.getShareSavedObjectPermissions(ML_SAVED_OBJECT_TYPE); - Promise.all([getPermissions]).then(([{ shareToAllSpaces }]) => { - setCanShareToAllSpaces(shareToAllSpaces); - setCanEditSpaces(shareToAllSpaces || spaceIds.includes(ALL_SPACES_ID) === false); - }); - } - }, []); - - function toggleShareOption(isAllSpaces: boolean) { - const updatedSpaceIds = isAllSpaces - ? [ALL_SPACES_ID, ...selectedSpaceIds] - : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID); - setSelectedSpaceIds(updatedSpaceIds); - } - - function updateSelectedSpaces(selectedOptions: SpaceOption[]) { - const ids = selectedOptions.filter((opt) => opt.checked).map((opt) => opt['data-space-id']); - setSelectedSpaceIds(ids); - } - - const isGlobalControlChecked = useMemo(() => selectedSpaceIds.includes(ALL_SPACES_ID), [ - selectedSpaceIds, - ]); - - const options = useMemo( - () => - allSpaces.map((space) => { - return { - label: space.name, - prepend: , - checked: selectedSpaceIds.includes(space.id) ? 'on' : undefined, - disabled: canEditSpaces === false, - ['data-space-id']: space.id, - ['data-test-subj']: `mlSpaceSelectorRow_${space.id}`, - }; - }), - [allSpaces, selectedSpaceIds, canEditSpaces] - ); - - const shareToAllSpaces = useMemo( - () => ({ - id: 'shareToAllSpaces', - title: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title', { - defaultMessage: 'All spaces', - }), - text: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text', { - defaultMessage: 'Make job available in all current and future spaces.', - }), - ...(!canShareToAllSpaces && { - tooltip: isGlobalControlChecked - ? i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip', - { defaultMessage: 'You need additional privileges to change this option.' } - ) - : i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip', - { defaultMessage: 'You need additional privileges to use this option.' } - ), - }), - disabled: !canShareToAllSpaces, - }), - [isGlobalControlChecked, canShareToAllSpaces] - ); - - const shareToExplicitSpaces = useMemo( - () => ({ - id: 'shareToExplicitSpaces', - title: i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title', - { - defaultMessage: 'Select spaces', - } - ), - text: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text', { - defaultMessage: 'Make job available in selected spaces only.', - }), - disabled: !canShareToAllSpaces && isGlobalControlChecked, - }), - [canShareToAllSpaces, isGlobalControlChecked] - ); - - return ( - <> - {canEditSpaces === false && } - - toggleShareOption(false)} - disabled={shareToExplicitSpaces.disabled} - > - - } - fullWidth - > - updateSelectedSpaces(newOptions as SpaceOption[])} - listProps={{ - bordered: true, - rowHeight: 40, - className: 'mlCopyToSpace__spacesList', - 'data-test-subj': 'mlFormSpaceSelector', - }} - searchable - > - {(list, search) => { - return ( - <> - {search} - {list} - - ); - }} - - - - - - - toggleShareOption(true)} - disabled={shareToAllSpaces.disabled} - /> - - - ); -}; - -function createLabel({ - title, - text, - disabled, - tooltip, -}: { - title: string; - text: string; - disabled: boolean; - tooltip?: string; -}) { - return ( - <> - - - {title} - - {tooltip && ( - - - - )} - - - - {text} - - - ); -} diff --git a/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts b/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts deleted file mode 100644 index dca7d0989d4de9..00000000000000 --- a/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { createContext, useContext } from 'react'; -import { HttpSetup } from 'src/core/public'; -import { SpacesManager, Space } from '../../../../../spaces/public'; - -export interface SpacesContextValue { - spacesManager: SpacesManager | null; - allSpaces: Space[]; - spacesEnabled: boolean; -} - -export const SpacesContext = createContext>({}); - -export function createSpacesContext(http: HttpSetup, spacesEnabled: boolean) { - return { - spacesManager: spacesEnabled ? new SpacesManager(http) : null, - allSpaces: [], - spacesEnabled, - } as SpacesContextValue; -} - -export function useSpacesContext() { - const context = useContext(SpacesContext); - - if (context.spacesManager === undefined) { - throw new Error('required attribute is undefined'); - } - - return context as SpacesContextValue; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index dc5b494d0e1812..8423e569a99f24 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -29,6 +29,7 @@ import { import { getAnalyticsFactory } from '../../services/analytics_service'; import { getTaskStateBadge, getJobTypeBadge, useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; +import type { SpacesPluginStart } from '../../../../../../../../spaces/public'; import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; import { CreateAnalyticsButton } from '../create_analytics_button'; import { SourceSelection } from '../source_selection'; @@ -84,7 +85,7 @@ function getItemIdToExpandedRowMap( interface Props { isManagementTable?: boolean; isMlEnabledInSpace?: boolean; - spacesEnabled?: boolean; + spacesApi?: SpacesPluginStart; blockRefresh?: boolean; pageState: ListingPageUrlState; updatePageState: (update: Partial) => void; @@ -92,7 +93,7 @@ interface Props { export const DataFrameAnalyticsList: FC = ({ isManagementTable = false, isMlEnabledInSpace = true, - spacesEnabled = false, + spacesApi, blockRefresh = false, pageState, updatePageState, @@ -178,7 +179,7 @@ export const DataFrameAnalyticsList: FC = ({ setExpandedRowItemIds, isManagementTable, isMlEnabledInSpace, - spacesEnabled, + spacesApi, refresh ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 7a0f00fd377bfc..cb0e2b0092c557 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -33,6 +33,7 @@ import { import { useActions } from './use_actions'; import { useMlLink } from '../../../../../contexts/kibana'; import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; +import type { SpacesPluginStart } from '../../../../../../../../spaces/public'; import { JobSpacesList } from '../../../../../components/job_spaces_list'; enum TASK_STATE_COLOR { @@ -150,7 +151,7 @@ export const useColumns = ( setExpandedRowItemIds: React.Dispatch>, isManagementTable: boolean = false, isMlEnabledInSpace: boolean = true, - spacesEnabled: boolean = true, + spacesApi?: SpacesPluginStart, refresh: () => void = () => {} ) => { const { actions, modals } = useActions(isManagementTable); @@ -281,7 +282,7 @@ export const useColumns = ( ]; if (isManagementTable === true) { - if (spacesEnabled === true) { + if (spacesApi) { // insert before last column columns.splice(columns.length - 1, 0, { name: i18n.translate('xpack.ml.jobsList.analyticsSpacesLabel', { @@ -290,6 +291,7 @@ export const useColumns = ( render: (item: DataFrameAnalyticsListRow) => Array.isArray(item.spaceIds) ? ( job.deleting !== true, selectableMessage: (selectable, rowItem) => @@ -243,7 +243,7 @@ export class JobsList extends Component { ]; if (isManagementTable === true) { - if (spacesEnabled === true) { + if (spacesApi) { // insert before last column columns.splice(columns.length - 1, 0, { name: i18n.translate('xpack.ml.jobsList.spacesLabel', { @@ -251,6 +251,7 @@ export class JobsList extends Component { }), render: (item) => ( {}; @@ -269,10 +268,10 @@ export class JobsListView extends Component { const expandedJobsIds = Object.keys(this.state.itemIdToExpandedRowMap); try { - let spaces = {}; - if (this.props.spacesEnabled && this.props.isManagementTable) { + let jobsSpaces = {}; + if (this.props.spacesApi && this.props.isManagementTable) { const allSpaces = await ml.savedObjects.jobsSpaces(); - spaces = allSpaces['anomaly-detector']; + jobsSpaces = allSpaces['anomaly-detector']; } let jobsAwaitingNodeCount = 0; @@ -285,11 +284,11 @@ export class JobsListView extends Component { } job.latestTimestampSortValue = job.latestTimestampMs || 0; job.spaceIds = - this.props.spacesEnabled && + this.props.spacesApi && this.props.isManagementTable && - spaces && - spaces[job.id] !== undefined - ? spaces[job.id] + jobsSpaces && + jobsSpaces[job.id] !== undefined + ? jobsSpaces[job.id] : []; if (job.awaitingNodeAssignment === true) { @@ -410,7 +409,7 @@ export class JobsListView extends Component { loading={loading} isManagementTable={true} isMlEnabledInSpace={this.props.isMlEnabledInSpace} - spacesEnabled={this.props.spacesEnabled} + spacesApi={this.props.spacesApi} jobsViewState={this.props.jobsViewState} onJobsViewStateUpdate={this.props.onJobsViewStateUpdate} refreshJobs={() => this.refreshJobSummaryList(true)} diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index a322174e8a8c44..b61a28aff732a6 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -24,7 +24,6 @@ import { } from '@elastic/eui'; import { PLUGIN_ID } from '../../../../../../common/constants/app'; -import { createSpacesContext, SpacesContext } from '../../../../contexts/spaces'; import { ManagementAppMountParams } from '../../../../../../../../../src/plugins/management/public/'; import { checkGetManagementMlJobsResolver } from '../../../../capabilities/check_capabilities'; @@ -39,7 +38,7 @@ import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_vi import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; import { AccessDeniedPage } from '../access_denied_page'; import { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; -import { SpacesPluginStart } from '../../../../../../../spaces/public'; +import type { SpacesPluginStart } from '../../../../../../../spaces/public'; import { JobSpacesSyncFlyout } from '../../../../components/job_spaces_sync'; import { getDefaultAnomalyDetectionJobsListState } from '../../../../jobs/jobs_list/jobs'; import { getMlGlobalServices } from '../../../../app'; @@ -68,7 +67,9 @@ function usePageState( return [pageState, updateState]; } -function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { +const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}; + +function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | undefined): Tab[] { const [adPageState, updateAdPageState] = usePageState(getDefaultAnomalyDetectionJobsListState()); const [dfaPageState, updateDfaPageState] = usePageState(getDefaultDFAListState()); @@ -88,7 +89,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { onJobsViewStateUpdate={updateAdPageState} isManagementTable={true} isMlEnabledInSpace={isMlEnabledInSpace} - spacesEnabled={spacesEnabled} + spacesApi={spacesApi} /> ), @@ -105,7 +106,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { @@ -121,28 +122,21 @@ export const JobsListPage: FC<{ coreStart: CoreStart; share: SharePluginStart; history: ManagementAppMountParams['history']; - spaces?: SpacesPluginStart; -}> = ({ coreStart, share, history, spaces }) => { - const spacesEnabled = spaces !== undefined; + spacesApi?: SpacesPluginStart; +}> = ({ coreStart, share, history, spacesApi }) => { + const spacesEnabled = spacesApi !== undefined; const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); const [showSyncFlyout, setShowSyncFlyout] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); - const tabs = useTabs(isMlEnabledInSpace, spacesEnabled); + const tabs = useTabs(isMlEnabledInSpace, spacesApi); const [currentTabId, setCurrentTabId] = useState(tabs[0].id); const I18nContext = coreStart.i18n.Context; - const spacesContext = useMemo(() => createSpacesContext(coreStart.http, spacesEnabled), []); const check = async () => { try { const { mlFeatureEnabledInSpace } = await checkGetManagementMlJobsResolver(); setIsMlEnabledInSpace(mlFeatureEnabledInSpace); - spacesContext.spacesEnabled = spacesEnabled; - if (spacesEnabled && spacesContext.spacesManager !== null) { - spacesContext.allSpaces = (await spacesContext.spacesManager.getSpaces()).filter( - (space) => space.disabledFeatures.includes(PLUGIN_ID) === false - ); - } } catch (e) { setAccessDenied(true); } @@ -191,13 +185,15 @@ export const JobsListPage: FC<{ return ; } + const ContextWrapper = spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent; + return ( - + - + diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts index 4059207aafcc36..dde543ac6ac9cb 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts +++ b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts @@ -22,10 +22,10 @@ const renderApp = ( history: ManagementAppMountParams['history'], coreStart: CoreStart, share: SharePluginStart, - spaces?: SpacesPluginStart + spacesApi?: SpacesPluginStart ) => { ReactDOM.render( - React.createElement(JobsListPage, { coreStart, history, share, spaces }), + React.createElement(JobsListPage, { coreStart, history, share, spacesApi }), element ); return () => { diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx index d89997042a3d89..c880e3144fac04 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx @@ -20,7 +20,7 @@ import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; import { ToastsApi } from 'src/core/public'; -import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; +import { SavedObjectTarget } from '../types'; interface SetupOpts { mockSpaces?: Space[]; @@ -70,19 +70,14 @@ const setup = async (opts: SetupOpts = {}) => { const savedObjectToCopy = { type: 'dashboard', id: 'my-dash', - references: [ - { - type: 'visualization', - id: 'my-viz', - name: 'My Viz', - }, - ], - meta: { icon: 'dashboard', title: 'foo', namespaceType: 'single' }, - } as SavedObjectsManagementRecord; + namespaces: ['default'], + icon: 'dashboard', + title: 'foo', + } as SavedObjectTarget; const wrapper = mountWithIntl( { }; describe('CopyToSpaceFlyout', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - it('waits for spaces to load', async () => { const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx index abf7f7fe40e8de..c86a7c92993a26 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { EuiFlyout, EuiIcon, @@ -27,18 +27,17 @@ import { ToastsStart } from 'src/core/public'; import { ProcessedImportResponse, processImportResponse, - SavedObjectsManagementRecord, } from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; import { SpacesManager } from '../../spaces_manager'; import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { CopyToSpaceFlyoutFooter } from './copy_to_space_flyout_footer'; import { CopyToSpaceForm } from './copy_to_space_form'; -import { CopyOptions, ImportRetry } from '../types'; +import { CopyOptions, ImportRetry, SavedObjectTarget } from '../types'; interface Props { onClose: () => void; - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: SavedObjectTarget; spacesManager: SpacesManager; toastNotifications: ToastsStart; } @@ -48,7 +47,17 @@ const CREATE_NEW_COPIES_DEFAULT = true; const OVERWRITE_ALL_DEFAULT = true; export const CopySavedObjectsToSpaceFlyout = (props: Props) => { - const { onClose, savedObject, spacesManager, toastNotifications } = props; + const { onClose, savedObjectTarget: object, spacesManager, toastNotifications } = props; + const savedObjectTarget = useMemo( + () => ({ + type: object.type, + id: object.id, + namespaces: object.namespaces, + icon: object.icon || 'apps', + title: object.title || `${object.type} [id=${object.id}]`, + }), + [object] + ); const [copyOptions, setCopyOptions] = useState({ includeRelated: INCLUDE_RELATED_DEFAULT, createNewCopies: CREATE_NEW_COPIES_DEFAULT, @@ -100,7 +109,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setCopyResult({}); try { const copySavedObjectsResult = await spacesManager.copySavedObjects( - [{ type: savedObject.type, id: savedObject.id }], + [{ type: savedObjectTarget.type, id: savedObjectTarget.id }], copyOptions.selectedSpaceIds, copyOptions.includeRelated, copyOptions.createNewCopies, @@ -160,7 +169,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setConflictResolutionInProgress(true); try { await spacesManager.resolveCopySavedObjectsErrors( - [{ type: savedObject.type, id: savedObject.id }], + [{ type: savedObjectTarget.type, id: savedObjectTarget.id }], retries, copyOptions.includeRelated, copyOptions.createNewCopies @@ -220,7 +229,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { if (!copyInProgress) { return ( { // Step3: Copy operation is in progress return ( { - + -

{savedObject.meta.title}

+

{savedObjectTarget.title}

diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx index 5bf171874d5a89..6c0ab695d94d8f 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx @@ -8,27 +8,26 @@ import React from 'react'; import { EuiSpacer, EuiTitle, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { CopyOptions } from '../types'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { CopyOptions, SavedObjectTarget } from '../types'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { CopyModeControl, CopyMode } from './copy_mode_control'; interface Props { - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: Required; spaces: Space[]; onUpdate: (copyOptions: CopyOptions) => void; copyOptions: CopyOptions; } export const CopyToSpaceForm = (props: Props) => { - const { savedObject, spaces, onUpdate, copyOptions } = props; + const { savedObjectTarget, spaces, onUpdate, copyOptions } = props; // if the user is not creating new copies, prevent them from copying objects an object into a space where it already exists const getDisabledSpaceIds = (createNewCopies: boolean) => createNewCopies ? new Set() - : (savedObject.namespaces ?? []).reduce((acc, cur) => acc.add(cur), new Set()); + : savedObjectTarget.namespaces.reduce((acc, cur) => acc.add(cur), new Set()); const changeCopyMode = ({ createNewCopies, overwrite }: CopyMode) => { const disabled = getDisabledSpaceIds(createNewCopies); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx index b30e996dbd0c1d..08c72b595a61d4 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx @@ -14,17 +14,14 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - ProcessedImportResponse, - SavedObjectsManagementRecord, -} from 'src/plugins/saved_objects_management/public'; +import { ProcessedImportResponse } from 'src/plugins/saved_objects_management/public'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; -import { CopyOptions, ImportRetry } from '../types'; +import { CopyOptions, ImportRetry, SavedObjectTarget } from '../types'; import { SpaceResult, SpaceResultProcessing } from './space_result'; import { summarizeCopyResult } from '..'; interface Props { - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: Required; copyInProgress: boolean; conflictResolutionInProgress: boolean; copyResult: Record; @@ -98,7 +95,10 @@ export const ProcessingCopyToSpace = (props: Props) => { {props.copyOptions.selectedSpaceIds.map((id) => { const space = props.spaces.find((s) => s.id === id) as Space; const spaceCopyResult = props.copyResult[space.id]; - const summarizedSpaceCopyResult = summarizeCopyResult(props.savedObject, spaceCopyResult); + const summarizedSpaceCopyResult = summarizeCopyResult( + props.savedObjectTarget, + spaceCopyResult + ); return ( @@ -106,7 +106,6 @@ export const ProcessingCopyToSpace = (props: Props) => { ) : ( { summarizedCopyResult, retries, onRetriesChange, - savedObject, conflictResolutionInProgress, } = props; const { objects } = summarizedCopyResult; @@ -109,7 +106,6 @@ export const SpaceResult = (props: Props) => { > diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts index 346bafceabf662..525efc4158f728 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts @@ -11,6 +11,7 @@ import { FailedImport, SavedObjectsManagementRecord, } from 'src/plugins/saved_objects_management/public'; +import { SavedObjectTarget } from './types'; // Sample data references: // @@ -21,6 +22,13 @@ import { // Dashboard has references to visualizations, and transitive references to index patterns const OBJECTS = { + COPY_TARGET: { + type: 'dashboard', + id: 'foo', + namespaces: [], + icon: 'dashboardApp', + title: 'my-dashboard-title', + } as Required, MY_DASHBOARD: { type: 'dashboard', id: 'foo', @@ -132,7 +140,7 @@ const createCopyResult = ( describe('summarizeCopyResult', () => { it('indicates the result is processing when not provided', () => { const copyResult = undefined; - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -155,7 +163,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract conflicts, including transitive conflicts', () => { const copyResult = createCopyResult({ withConflicts: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -235,7 +243,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract missing references errors', () => { const copyResult = createCopyResult({ withMissingReferencesError: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -292,7 +300,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract unresolvable errors', () => { const copyResult = createCopyResult({ withUnresolvableError: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -359,7 +367,7 @@ describe('summarizeCopyResult', () => { it('processes a result without errors', () => { const copyResult = createCopyResult(); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -426,7 +434,7 @@ describe('summarizeCopyResult', () => { it('indicates when successes and failures have been overwritten', () => { const copyResult = createCopyResult({ withMissingReferencesError: true, overwrite: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult.objects).toHaveLength(4); for (const obj of summarizedResult.objects) { diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts index 1e5282436a4913..0986f5723a6dee 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts @@ -5,15 +5,12 @@ * 2.0. */ -import { - SavedObjectsManagementRecord, - ProcessedImportResponse, - FailedImport, -} from 'src/plugins/saved_objects_management/public'; +import { ProcessedImportResponse, FailedImport } from 'src/plugins/saved_objects_management/public'; import { SavedObjectsImportConflictError, SavedObjectsImportAmbiguousConflictError, } from 'kibana/public'; +import { SavedObjectTarget } from './types'; export interface SummarizedSavedObjectResult { type: string; @@ -67,7 +64,7 @@ export type SummarizedCopyToSpaceResult = | ProcessingResponse; export function summarizeCopyResult( - savedObject: SavedObjectsManagementRecord, + savedObjectTarget: Required, copyResult: ProcessedImportResponse | undefined ): SummarizedCopyToSpaceResult { const conflicts = copyResult?.failedImports.filter(isAnyConflict) ?? []; @@ -95,12 +92,12 @@ export function summarizeCopyResult( }; const objectMap = new Map(); - objectMap.set(`${savedObject.type}:${savedObject.id}`, { - type: savedObject.type, - id: savedObject.id, - name: savedObject.meta.title, - icon: savedObject.meta.icon, - ...getExtraFields(savedObject), + objectMap.set(`${savedObjectTarget.type}:${savedObjectTarget.id}`, { + type: savedObjectTarget.type, + id: savedObjectTarget.id, + name: savedObjectTarget.title, + icon: savedObjectTarget.icon, + ...getExtraFields(savedObjectTarget), }); const addObjectsToMap = ( diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts index 1e3293df8f2581..676b8ee4607518 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts @@ -19,3 +19,30 @@ export type ImportRetry = Omit; export interface CopySavedObjectsToSpaceResponse { [spaceId: string]: SavedObjectsImportResponse; } + +export interface SavedObjectTarget { + /** + * The object's type. + */ + type: string; + /** + * The object's ID. + */ + id: string; + /** + * The namespaces that the object currently exists in. + */ + namespaces: string[]; + /** + * The EUI icon that is rendered in the flyout's subtitle. + * + * Default is 'apps'. + */ + icon?: string; + /** + * The string that is rendered in the flyout's subtitle. + * + * Default is `${type} [id=${id}]`. + */ + title?: string; +} diff --git a/x-pack/plugins/spaces/public/index.ts b/x-pack/plugins/spaces/public/index.ts index a87b953f08c62e..3620ae757052da 100644 --- a/x-pack/plugins/spaces/public/index.ts +++ b/x-pack/plugins/spaces/public/index.ts @@ -11,9 +11,7 @@ export { SpaceAvatar, getSpaceColor, getSpaceImageUrl, getSpaceInitials } from ' export { SpacesPluginSetup, SpacesPluginStart } from './plugin'; -export { SpacesManager } from './spaces_manager'; - -export { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from '../common'; +export type { GetAllSpacesPurpose, GetSpaceResult } from '../common'; // re-export types from oss definition export type { Space } from '../../../../src/plugins/spaces_oss/common'; diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 151157180ae491..2d02d4a3b98d81 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { CoreSetup, CoreStart, Plugin, StartServicesAccessor } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { SpacesOssPluginSetup, SpacesApi } from 'src/plugins/spaces_oss/public'; import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; @@ -20,6 +20,7 @@ import { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space' import { AdvancedSettingsService } from './advanced_settings'; import { ManagementService } from './management'; import { spaceSelectorApp } from './space_selector'; +import { getUiApi } from './ui_api'; export interface PluginsSetup { spacesOss: SpacesOssPluginSetup; @@ -39,11 +40,20 @@ export type SpacesPluginStart = ReturnType; export class SpacesPlugin implements Plugin { private spacesManager!: SpacesManager; + private spacesApi!: SpacesApi; private managementService?: ManagementService; - public setup(core: CoreSetup<{}, SpacesPluginStart>, plugins: PluginsSetup) { + public setup(core: CoreSetup, plugins: PluginsSetup) { this.spacesManager = new SpacesManager(core.http); + this.spacesApi = { + ui: getUiApi({ + spacesManager: this.spacesManager, + getStartServices: core.getStartServices, + }), + activeSpace$: this.spacesManager.onActiveSpaceChange$, + getActiveSpace: () => this.spacesManager.getActiveSpace(), + }; if (plugins.home) { plugins.home.featureCatalogue.register(createSpacesFeatureCatalogueEntry()); @@ -53,7 +63,7 @@ export class SpacesPlugin implements Plugin, + getStartServices: core.getStartServices, spacesManager: this.spacesManager, }); } @@ -69,10 +79,8 @@ export class SpacesPlugin implements Plugin, + spacesApiUi: this.spacesApi.ui, }); const copySavedObjectsToSpaceService = new CopySavedObjectsToSpaceService(); copySavedObjectsToSpaceService.setup({ @@ -88,7 +96,7 @@ export class SpacesPlugin implements Plugin this.spacesManager.getActiveSpace(), - }; - } } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts new file mode 100644 index 00000000000000..ef3248e1cd60a8 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const DEFAULT_OBJECT_NOUN = i18n.translate('xpack.spaces.shareToSpace.objectNoun', { + defaultMessage: 'object', +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx deleted file mode 100644 index 17132d291a612a..00000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState, useEffect, PropsWithChildren } from 'react'; -import { StartServicesAccessor, CoreStart } from 'src/core/public'; -import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; -import { PluginsStart } from '../../plugin'; - -interface Props { - getStartServices: StartServicesAccessor; -} - -export const ContextWrapper = (props: PropsWithChildren) => { - const { getStartServices, children } = props; - - const [coreStart, setCoreStart] = useState(); - - useEffect(() => { - getStartServices().then((startServices) => { - const [coreStartValue] = startServices; - setCoreStart(coreStartValue); - }); - }, [getStartServices]); - - if (!coreStart) { - return null; - } - - const { application, docLinks } = coreStart; - const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ - application, - docLinks, - }); - - return {children}; -}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts index 1fca0980e9d8bc..b133be833d505e 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts @@ -5,5 +5,6 @@ * 2.0. */ -export { ContextWrapper } from './context_wrapper'; -export { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; +export { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; +export { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; +export { getLegacyUrlConflict } from './legacy_url_conflict'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx new file mode 100644 index 00000000000000..b9a01d4deabb57 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { LegacyUrlConflictProps } from 'src/plugins/spaces_oss/public'; +import { LegacyUrlConflictInternal, InternalProps } from './legacy_url_conflict_internal'; + +export const getLegacyUrlConflict = ( + internalProps: InternalProps +): React.FC => { + return (props: LegacyUrlConflictProps) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx new file mode 100644 index 00000000000000..1b897e8afa7d2f --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx @@ -0,0 +1,68 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; +import { EuiCallOut } from '@elastic/eui'; +import { mountWithIntl, findTestSubject } from '@kbn/test/jest'; +import { act } from '@testing-library/react'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { LegacyUrlConflictInternal } from './legacy_url_conflict_internal'; + +const APP_ID = 'testAppId'; +const PATH = 'path'; + +describe('LegacyUrlConflict', () => { + const setup = async () => { + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + const subject = new BehaviorSubject(`not-${APP_ID}`); + subject.next(APP_ID); // test below asserts that the consumer received the most recent APP_ID + startServices.application.currentAppId$ = subject; + const application = startServices.application; + getStartServices.mockResolvedValue([startServices, , ,]); + + const wrapper = mountWithIntl( + + ); + + // wait for wrapper to rerender + await act(async () => {}); + wrapper.update(); + + return { wrapper, application }; + }; + + it('can click the "Go to other object" button', async () => { + const { wrapper, application } = await setup(); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + + const goToOtherButton = findTestSubject(wrapper, 'legacy-url-conflict-go-to-other-button'); + goToOtherButton.simulate('click'); + + expect(application.navigateToApp).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledWith(APP_ID, { path: PATH }); + }); + + it('can click the "Dismiss" button', async () => { + const { wrapper } = await setup(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); // callout is visible + + const dismissButton = findTestSubject(wrapper, 'legacy-url-conflict-dismiss-button'); + dismissButton.simulate('click'); + wrapper.update(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(0); // callout is not visible + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx new file mode 100644 index 00000000000000..1157725c69ee2d --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { firstValueFrom } from '@kbn/std'; +import React, { useState, useEffect } from 'react'; +import type { ApplicationStart, StartServicesAccessor } from 'src/core/public'; +import type { LegacyUrlConflictProps } from 'src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../../plugin'; +import { DEFAULT_OBJECT_NOUN } from './constants'; + +export interface InternalProps { + getStartServices: StartServicesAccessor; +} + +export const LegacyUrlConflictInternal = (props: InternalProps & LegacyUrlConflictProps) => { + const { + getStartServices, + objectNoun = DEFAULT_OBJECT_NOUN, + currentObjectId, + otherObjectId, + otherObjectPath, + } = props; + + const [applicationStart, setApplicationStart] = useState(); + const [isDismissed, setIsDismissed] = useState(false); + const [appId, setAppId] = useState(); + + useEffect(() => { + async function setup() { + const [{ application }] = await getStartServices(); + const appIdValue = await firstValueFrom(application.currentAppId$); // retrieve the most recent value from the BehaviorSubject + setApplicationStart(application); + setAppId(appIdValue); + } + setup(); + }, [getStartServices]); + + if (!applicationStart || !appId || isDismissed) { + return null; + } + + function clickLinkButton() { + applicationStart!.navigateToApp(appId!, { path: otherObjectPath }); + } + + function clickDismissButton() { + setIsDismissed(true); + } + + return ( + + } + > + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx index 678464bcf4d649..46610a2cc9a7c5 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx @@ -26,7 +26,7 @@ export const NoSpacesAvailable = (props: Props) => { { href={getUrlForApp('management', { path: 'kibana/spaces/create' })} > diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index 707f60d5979a1a..1b5870b8b540dc 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -22,58 +22,82 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { NoSpacesAvailable } from './no_spaces_available'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { DocumentationLinksService } from '../../lib'; import { SpaceAvatar } from '../../space_avatar'; -import { SpaceTarget } from '../types'; +import { ShareToSpaceTarget } from '../../types'; +import { useSpaces } from '../../spaces_context'; +import { ShareOptions } from '../types'; interface Props { - spaces: SpaceTarget[]; - selectedSpaceIds: string[]; + spaces: ShareToSpaceTarget[]; + shareOptions: ShareOptions; onChange: (selectedSpaceIds: string[]) => void; + enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; const ROW_HEIGHT = 40; -const partiallyAuthorizedTooltip = { - checked: i18n.translate( - 'xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked', - { defaultMessage: 'You need additional privileges to deselect this space.' } - ), - unchecked: i18n.translate( - 'xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked', - { defaultMessage: 'You need additional privileges to select this space.' } - ), -}; -const partiallyAuthorizedSpaceProps = (checked: boolean) => ({ - append: ( - - ), - disabled: true, -}); -const activeSpaceProps = { - append: Current, - disabled: true, - checked: 'on' as 'on', -}; +const APPEND_ACTIVE_SPACE = ( + + {i18n.translate('xpack.spaces.shareToSpace.currentSpaceBadge', { defaultMessage: 'Current' })} + +); +const APPEND_CANNOT_SELECT = ( + +); +const APPEND_CANNOT_DESELECT = ( + +); +const APPEND_FEATURE_IS_DISABLED = ( + +); export const SelectableSpacesControl = (props: Props) => { - const { spaces, selectedSpaceIds, onChange } = props; - const { services } = useKibana(); + const { + spaces, + shareOptions, + onChange, + enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, + } = props; + const { services } = useSpaces(); const { application, docLinks } = services; + const { selectedSpaceIds, initiallySelectedSpaceIds } = shareOptions; - const activeSpaceId = spaces.find((space) => space.isActiveSpace)!.id; + const activeSpaceId = + !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const options = spaces - .sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0)) + .filter( + // filter out spaces that are not already selected and have the feature disabled in that space + ({ id, isFeatureDisabled }) => !isFeatureDisabled || initiallySelectedSpaceIds.includes(id) + ) + .sort(createSpacesComparator(activeSpaceId)) .map((space) => { const checked = selectedSpaceIds.includes(space.id); + const additionalProps = getAdditionalProps(space, activeSpaceId, checked); return { label: space.name, prepend: , @@ -81,8 +105,7 @@ export const SelectableSpacesControl = (props: Props) => { ['data-space-id']: space.id, ['data-test-subj']: `sts-space-selector-row-${space.id}`, ...(isGlobalControlChecked && { disabled: true }), - ...(space.isPartiallyAuthorized && partiallyAuthorizedSpaceProps(checked)), - ...(space.isActiveSpace && activeSpaceProps), + ...additionalProps, }; }); @@ -112,13 +135,13 @@ export const SelectableSpacesControl = (props: Props) => { + @@ -130,25 +153,28 @@ export const SelectableSpacesControl = (props: Props) => { ); }; const getNoSpacesAvailable = () => { - if (spaces.length < 2) { + if (enableCreateNewSpaceLink && spaces.length < 2) { return ; } return null; }; + // if space-agnostic behavior is not enabled, the active space is not selected or deselected by the user, so we have to artificially pad the count for this label + const selectedCountPad = enableSpaceAgnosticBehavior ? 0 : 1; const selectedCount = - selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + 1; + selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + + selectedCountPad; const hiddenCount = selectedSpaceIds.filter((id) => id === UNKNOWN_SPACE).length; const selectSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel', + 'xpack.spaces.shareToSpace.shareModeControl.selectSpacesLabel', { defaultMessage: 'Select spaces' } ); const selectedSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel', + 'xpack.spaces.shareToSpace.shareModeControl.selectedCountLabel', { defaultMessage: '{selectedCount} selected', values: { selectedCount } } ); const hiddenSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel', + 'xpack.spaces.shareToSpace.shareModeControl.hiddenCountLabel', { defaultMessage: '+{hiddenCount} hidden', values: { hiddenCount } } ); const hiddenSpaces = hiddenCount ? {hiddenSpacesLabel} : null; @@ -193,3 +219,55 @@ export const SelectableSpacesControl = (props: Props) => { ); }; + +/** + * Gets additional props for the selection option. + */ +function getAdditionalProps( + space: ShareToSpaceTarget, + activeSpaceId: string | false, + checked: boolean +) { + if (space.id === activeSpaceId) { + return { + append: APPEND_ACTIVE_SPACE, + disabled: true, + checked: 'on' as 'on', + }; + } + if (space.cannotShareToSpace) { + return { + append: ( + <> + {checked ? APPEND_CANNOT_DESELECT : APPEND_CANNOT_SELECT} + {space.isFeatureDisabled ? APPEND_FEATURE_IS_DISABLED : null} + + ), + disabled: true, + }; + } + if (space.isFeatureDisabled) { + return { + append: APPEND_FEATURE_IS_DISABLED, + }; + } +} + +/** + * Given the active space, create a comparator to sort a ShareToSpaceTarget array so that the active space is at the beginning, and space(s) for + * which the current feature is disabled are all at the end. + */ +function createSpacesComparator(activeSpaceId: string | false) { + return (a: ShareToSpaceTarget, b: ShareToSpaceTarget) => { + if (a.id === activeSpaceId) { + return -1; + } + if (b.id === activeSpaceId) { + return 1; + } + if (a.isFeatureDisabled !== b.isFeatureDisabled) { + return a.isFeatureDisabled ? 1 : -1; + } + return 0; + }; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index 1f71434de577d4..23b2dc02ec3cc2 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -8,27 +8,33 @@ import './share_mode_control.scss'; import React from 'react'; import { + EuiCallOut, EuiCheckableCard, EuiFlexGroup, EuiFlexItem, - EuiFormFieldset, EuiIconTip, + EuiLink, EuiLoadingSpinner, EuiSpacer, EuiText, - EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { ALL_SPACES_ID } from '../../../common/constants'; -import { SpaceTarget } from '../types'; +import { DocumentationLinksService } from '../../lib'; +import { useSpaces } from '../../spaces_context'; +import { ShareToSpaceTarget } from '../../types'; +import { ShareOptions } from '../types'; interface Props { - spaces: SpaceTarget[]; + spaces: ShareToSpaceTarget[]; + objectNoun: string; canShareToAllSpaces: boolean; - selectedSpaceIds: string[]; + shareOptions: ShareOptions; onChange: (selectedSpaceIds: string[]) => void; - disabled?: boolean; + enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } function createLabel({ @@ -63,31 +69,41 @@ function createLabel({ } export const ShareModeControl = (props: Props) => { - const { spaces, canShareToAllSpaces, selectedSpaceIds, onChange } = props; + const { + spaces, + objectNoun, + canShareToAllSpaces, + shareOptions, + onChange, + enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, + } = props; + const { services } = useSpaces(); + const { docLinks } = services; if (spaces.length === 0) { return ; } + const { selectedSpaceIds } = shareOptions; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const shareToAllSpaces = { id: 'shareToAllSpaces', - title: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title', - { defaultMessage: 'All spaces' } - ), - text: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text', - { defaultMessage: 'Make object available in all current and future spaces.' } - ), + title: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title', { + defaultMessage: 'All spaces', + }), + text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text', { + defaultMessage: 'Make {objectNoun} available in all current and future spaces.', + values: { objectNoun }, + }), ...(!canShareToAllSpaces && { tooltip: isGlobalControlChecked ? i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', { defaultMessage: 'You need additional privileges to change this option.' } ) : i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', { defaultMessage: 'You need additional privileges to use this option.' } ), }), @@ -96,19 +112,15 @@ export const ShareModeControl = (props: Props) => { const shareToExplicitSpaces = { id: 'shareToExplicitSpaces', title: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title', + 'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title', { defaultMessage: 'Select spaces' } ), - text: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text', - { defaultMessage: 'Make object available in selected spaces only.' } - ), + text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text', { + defaultMessage: 'Make {objectNoun} available in selected spaces only.', + values: { objectNoun }, + }), disabled: !canShareToAllSpaces && isGlobalControlChecked, }; - const shareOptionsTitle = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle', - { defaultMessage: 'Share options' } - ); const toggleShareOption = (allSpaces: boolean) => { const updatedSpaceIds = allSpaces @@ -117,35 +129,77 @@ export const ShareModeControl = (props: Props) => { onChange(updatedSpaceIds); }; + const getPrivilegeWarning = () => { + if (!shareToExplicitSpaces.disabled) { + return null; + } + + const kibanaPrivilegesUrl = new DocumentationLinksService( + docLinks! + ).getKibanaPrivilegesDocUrl(); + + return ( + <> + + } + color="warning" + > + + + + ), + }} + /> + + + + + ); + }; + return ( <> - - {shareOptionsTitle} - - ), - }} + {getPrivilegeWarning()} + + toggleShareOption(false)} + disabled={shareToExplicitSpaces.disabled} > - toggleShareOption(false)} - disabled={shareToExplicitSpaces.disabled} - > - - - - toggleShareOption(true)} - disabled={shareToAllSpaces.disabled} + - + + + toggleShareOption(true)} + disabled={shareToAllSpaces.disabled} + /> ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx deleted file mode 100644 index 59b8d47e40e024..00000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx +++ /dev/null @@ -1,489 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import Boom from '@hapi/boom'; -import { mountWithIntl, nextTick } from '@kbn/test/jest'; -import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; -import { ShareToSpaceForm } from './share_to_space_form'; -import { EuiLoadingSpinner, EuiSelectable } from '@elastic/eui'; -import { Space } from '../../../../../../src/plugins/spaces_oss/common'; -import { findTestSubject } from '@kbn/test/jest'; -import { SelectableSpacesControl } from './selectable_spaces_control'; -import { act } from '@testing-library/react'; -import { spacesManagerMock } from '../../spaces_manager/mocks'; -import { SpacesManager } from '../../spaces_manager'; -import { coreMock } from '../../../../../../src/core/public/mocks'; -import { ToastsApi } from 'src/core/public'; -import { EuiCallOut } from '@elastic/eui'; -import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; -import { NoSpacesAvailable } from './no_spaces_available'; -import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; -import { ContextWrapper } from '.'; - -interface SetupOpts { - mockSpaces?: Space[]; - namespaces?: string[]; - returnBeforeSpacesLoad?: boolean; -} - -const setup = async (opts: SetupOpts = {}) => { - const onClose = jest.fn(); - const onObjectUpdated = jest.fn(); - - const mockSpacesManager = spacesManagerMock.create(); - - mockSpacesManager.getActiveSpace.mockResolvedValue({ - id: 'my-active-space', - name: 'my active space', - disabledFeatures: [], - }); - - mockSpacesManager.getSpaces.mockResolvedValue( - opts.mockSpaces || [ - { - id: 'space-1', - name: 'Space 1', - disabledFeatures: [], - }, - { - id: 'space-2', - name: 'Space 2', - disabledFeatures: [], - }, - { - id: 'space-3', - name: 'Space 3', - disabledFeatures: [], - }, - { - id: 'my-active-space', - name: 'my active space', - disabledFeatures: [], - }, - ] - ); - - mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ shareToAllSpaces: true }); - - const mockToastNotifications = { - addError: jest.fn(), - addSuccess: jest.fn(), - }; - const savedObjectToShare = { - type: 'dashboard', - id: 'my-dash', - references: [ - { - type: 'visualization', - id: 'my-viz', - name: 'My Viz', - }, - ], - meta: { icon: 'dashboard', title: 'foo' }, - namespaces: opts.namespaces || ['my-active-space', 'space-1'], - } as SavedObjectsManagementRecord; - - const { getStartServices } = coreMock.createSetup(); - const startServices = coreMock.createStart(); - startServices.application.capabilities = { - ...startServices.application.capabilities, - spaces: { manage: true }, - }; - getStartServices.mockResolvedValue([startServices, , ,]); - - // the flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper - // the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change - const wrapper = mountWithIntl( - - - - ); - - // wait for context wrapper to rerender - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - if (!opts.returnBeforeSpacesLoad) { - // Wait for spaces manager to complete and flyout to rerender - await act(async () => { - await nextTick(); - wrapper.update(); - }); - } - - return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; -}; - -describe('ShareToSpaceFlyout', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - it('waits for spaces to load', async () => { - const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - }); - - it('shows a message within a NoSpacesAvailable when no spaces are available', async () => { - const { wrapper, onClose } = await setup({ - mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('shows a message within a NoSpacesAvailable when only the active space is available', async () => { - const { wrapper, onClose } = await setup({ - mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('does not show a warning callout when the saved object has multiple namespaces', async () => { - const { wrapper, onClose } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiCallOut)).toHaveLength(0); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('shows a warning callout when the saved object only has one namespace', async () => { - const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiCallOut)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('does not show the Copy flyout by default', async () => { - const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => { - const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout - - await act(async () => { - copyButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('handles errors thrown from shareSavedObjectsAdd API call', async () => { - const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); - - mockSpacesManager.shareSavedObjectAdd.mockImplementation(() => { - return Promise.reject(Boom.serverUnavailable('Something bad happened')); - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); - expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled(); - expect(mockToastNotifications.addError).toHaveBeenCalled(); - }); - - it('handles errors thrown from shareSavedObjectsRemove API call', async () => { - const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); - - mockSpacesManager.shareSavedObjectRemove.mockImplementation(() => { - return Promise.reject(Boom.serverUnavailable('Something bad happened')); - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); - expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled(); - expect(mockToastNotifications.addError).toHaveBeenCalled(); - }); - - it('allows the form to be filled out to add a space', async () => { - const { - wrapper, - onClose, - mockSpacesManager, - mockToastNotifications, - savedObjectToShare, - } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); - expect(shareSavedObjectRemove).not.toHaveBeenCalled(); - - expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); - expect(mockToastNotifications.addError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('allows the form to be filled out to remove a space', async () => { - const { - wrapper, - onClose, - mockSpacesManager, - mockToastNotifications, - savedObjectToShare, - } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange([]); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).not.toHaveBeenCalled(); - expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); - - expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); - expect(mockToastNotifications.addError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('allows the form to be filled out to add and remove a space', async () => { - const { - wrapper, - onClose, - mockSpacesManager, - mockToastNotifications, - savedObjectToShare, - } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); - expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); - - expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2); - expect(mockToastNotifications.addError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - describe('space selection', () => { - const mockSpaces = [ - { - // normal "fully authorized" space selection option -- not the active space - id: 'space-1', - name: 'Space 1', - disabledFeatures: [], - }, - { - // "partially authorized" space selection option -- not the active space - id: 'space-2', - name: 'Space 2', - disabledFeatures: [], - authorizedPurposes: { shareSavedObjectsIntoSpace: false }, - }, - { - // "active space" selection option (determined by an ID that matches the result of `getActiveSpace`, mocked at top) - id: 'my-active-space', - name: 'my active space', - disabledFeatures: [], - }, - ]; - - const expectActiveSpace = (option: any) => { - expect(option.append).toMatchInlineSnapshot(` - - Current - - `); - // by definition, the active space will always be checked - expect(option.checked).toEqual('on'); - expect(option.disabled).toEqual(true); - }; - const expectInactiveSpace = (option: any, checked: boolean) => { - expect(option.append).toBeUndefined(); - expect(option.checked).toEqual(checked ? 'on' : undefined); - expect(option.disabled).toBeUndefined(); - }; - const expectPartiallyAuthorizedSpace = (option: any, checked: boolean) => { - if (checked) { - expect(option.append).toMatchInlineSnapshot(` - - `); - } else { - expect(option.append).toMatchInlineSnapshot(` - - `); - } - expect(option.checked).toEqual(checked ? 'on' : undefined); - expect(option.disabled).toEqual(true); - }; - - it('correctly defines space selection options when spaces are not selected', async () => { - const namespaces = ['my-active-space']; // the saved object's current namespaces; it will always exist in at least the active namespace - const { wrapper } = await setup({ mockSpaces, namespaces }); - - const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); - expectActiveSpace(selectOptions[0]); - expect(selectOptions[1]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[1], false); - expect(selectOptions[2]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[2], false); - }); - - it('correctly defines space selection options when spaces are selected', async () => { - const namespaces = ['my-active-space', 'space-1', 'space-2']; // the saved object's current namespaces - const { wrapper } = await setup({ mockSpaces, namespaces }); - - const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); - expectActiveSpace(selectOptions[0]); - expect(selectOptions[1]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[1], true); - expect(selectOptions[2]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[2], true); - }); - }); -}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx index 69fd89dab58140..0f9783e3ac8c07 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -5,288 +5,12 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; -import { - EuiFlyout, - EuiIcon, - EuiFlyoutHeader, - EuiTitle, - EuiText, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiButton, - EuiButtonEmpty, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { ToastsStart } from 'src/core/public'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; -import { GetSpaceResult } from '../../../common'; -import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; -import { SpacesManager } from '../../spaces_manager'; -import { ShareToSpaceForm } from './share_to_space_form'; -import { ShareOptions, SpaceTarget } from '../types'; -import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import React from 'react'; +import type { ShareToSpaceFlyoutProps } from '../../../../../../src/plugins/spaces_oss/public'; +import { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; -interface Props { - onClose: () => void; - onObjectUpdated: () => void; - savedObject: SavedObjectsManagementRecord; - spacesManager: SpacesManager; - toastNotifications: ToastsStart; -} - -const arraysAreEqual = (a: unknown[], b: unknown[]) => - a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); - -export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { - const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props; - const { namespaces: currentNamespaces = [] } = savedObject; - const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); - const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); - const [showMakeCopy, setShowMakeCopy] = useState(false); - - const [{ isLoading, spaces }, setSpacesState] = useState<{ - isLoading: boolean; - spaces: SpaceTarget[]; - }>({ isLoading: true, spaces: [] }); - useEffect(() => { - const getSpaces = spacesManager.getSpaces({ includeAuthorizedPurposes: true }); - const getActiveSpace = spacesManager.getActiveSpace(); - const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObject.type); - Promise.all([getSpaces, getActiveSpace, getPermissions]) - .then(([allSpaces, activeSpace, permissions]) => { - setShareOptions({ - selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), - }); - setCanShareToAllSpaces(permissions.shareToAllSpaces); - const createSpaceTarget = (space: GetSpaceResult): SpaceTarget => ({ - ...space, - isActiveSpace: space.id === activeSpace.id, - isPartiallyAuthorized: space.authorizedPurposes?.shareSavedObjectsIntoSpace === false, - }); - setSpacesState({ - isLoading: false, - spaces: allSpaces.map((space) => createSpaceTarget(space)), - }); - }) - .catch((e) => { - toastNotifications.addError(e, { - title: i18n.translate('xpack.spaces.management.shareToSpace.spacesLoadErrorTitle', { - defaultMessage: 'Error loading available spaces', - }), - }); - }); - }, [currentNamespaces, spacesManager, savedObject, toastNotifications]); - - const getSelectionChanges = () => { - const activeSpace = spaces.find((space) => space.isActiveSpace); - if (!activeSpace) { - return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; - } - const initialSelection = currentNamespaces.filter( - (spaceId) => spaceId !== activeSpace.id && spaceId !== UNKNOWN_SPACE - ); - const { selectedSpaceIds } = shareOptions; - const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); - const isSharedToAllSpaces = - !initialSelection.includes(ALL_SPACES_ID) && filteredSelection.includes(ALL_SPACES_ID); - const isUnsharedFromAllSpaces = - initialSelection.includes(ALL_SPACES_ID) && !filteredSelection.includes(ALL_SPACES_ID); - const selectedSpacesChanged = - !filteredSelection.includes(ALL_SPACES_ID) && - !arraysAreEqual(initialSelection, filteredSelection); - const isSelectionChanged = - isSharedToAllSpaces || - isUnsharedFromAllSpaces || - (!isSharedToAllSpaces && !isUnsharedFromAllSpaces && selectedSpacesChanged); - - const selectedSpacesToAdd = filteredSelection.filter( - (spaceId) => !initialSelection.includes(spaceId) - ); - const selectedSpacesToRemove = initialSelection.filter( - (spaceId) => !filteredSelection.includes(spaceId) - ); - - const spacesToAdd = isSharedToAllSpaces - ? [ALL_SPACES_ID] - : isUnsharedFromAllSpaces - ? [activeSpace.id, ...selectedSpacesToAdd] - : selectedSpacesToAdd; - const spacesToRemove = isUnsharedFromAllSpaces - ? [ALL_SPACES_ID] - : isSharedToAllSpaces - ? [activeSpace.id, ...initialSelection] - : selectedSpacesToRemove; - return { isSelectionChanged, spacesToAdd, spacesToRemove }; - }; - const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); - - const [shareInProgress, setShareInProgress] = useState(false); - - async function startShare() { - setShareInProgress(true); - try { - const { type, id, meta } = savedObject; - const title = - currentNamespaces.length === 1 - ? i18n.translate('xpack.spaces.management.shareToSpace.shareNewSuccessTitle', { - defaultMessage: 'Object is now shared', - }) - : i18n.translate('xpack.spaces.management.shareToSpace.shareEditSuccessTitle', { - defaultMessage: 'Object was updated', - }); - const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); - if (spacesToAdd.length > 0) { - await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); - const spaceTargets = isSharedToAllSpaces ? 'all' : `${spacesToAdd.length}`; - const text = - !isSharedToAllSpaces && spacesToAdd.length === 1 - ? i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular', { - defaultMessage: `'{object}' was added to 1 space.`, - values: { object: meta.title }, - }) - : i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural', { - defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, - values: { object: meta.title, spaceTargets }, - }); - toastNotifications.addSuccess({ title, text }); - } - if (spacesToRemove.length > 0) { - await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); - const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); - const spaceTargets = isUnsharedFromAllSpaces ? 'all' : `${spacesToRemove.length}`; - const text = - !isUnsharedFromAllSpaces && spacesToRemove.length === 1 - ? i18n.translate( - 'xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular', - { - defaultMessage: `'{object}' was removed from 1 space.`, - values: { object: meta.title }, - } - ) - : i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural', { - defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, - values: { object: meta.title, spaceTargets }, - }); - if (!isSharedToAllSpaces) { - toastNotifications.addSuccess({ title, text }); - } - } - onObjectUpdated(); - onClose(); - } catch (e) { - setShareInProgress(false); - toastNotifications.addError(e, { - title: i18n.translate('xpack.spaces.management.shareToSpace.shareErrorTitle', { - defaultMessage: 'Error updating saved object', - }), - }); - } - } - - const getFlyoutBody = () => { - // Step 1: loading assets for main form - if (isLoading) { - return ; - } - - const activeSpace = spaces.find((x) => x.isActiveSpace)!; - const showShareWarning = - spaces.length > 1 && arraysAreEqual(currentNamespaces, [activeSpace.id]); - // Step 2: Share has not been initiated yet; User must fill out form to continue. - return ( - setShowMakeCopy(true)} - /> - ); +export const getShareToSpaceFlyoutComponent = (): React.FC => { + return (props: ShareToSpaceFlyoutProps) => { + return ; }; - - if (showMakeCopy) { - return ( - - ); - } - - return ( - - - - - - - - -

- -

-
-
-
-
- - - - - - - -

{savedObject.meta.title}

-
-
-
- - - - {getFlyoutBody()} -
- - - - - onClose()} - data-test-subj="sts-cancel-button" - disabled={shareInProgress} - > - - - - - startShare()} - data-test-subj="sts-initiate-button" - disabled={!isSelectionChanged || shareInProgress} - > - - - - - -
- ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx new file mode 100644 index 00000000000000..1b33b42637fe8b --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -0,0 +1,741 @@ +/* + * 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 Boom from '@hapi/boom'; +import { mountWithIntl, nextTick, findTestSubject } from '@kbn/test/jest'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { + EuiCallOut, + EuiCheckableCard, + EuiCheckableCardProps, + EuiIconTip, + EuiLoadingSpinner, + EuiSelectable, +} from '@elastic/eui'; +import { Space } from '../../../../../../src/plugins/spaces_oss/common'; +import { SelectableSpacesControl } from './selectable_spaces_control'; +import { act } from '@testing-library/react'; +import { spacesManagerMock } from '../../spaces_manager/mocks'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { NoSpacesAvailable } from './no_spaces_available'; +import { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; +import { ShareModeControl } from './share_mode_control'; +import { ReactWrapper } from 'enzyme'; +import { ALL_SPACES_ID } from '../../../common/constants'; +import { getSpacesContextWrapper } from '../../spaces_context'; + +interface SetupOpts { + mockSpaces?: Space[]; + namespaces?: string[]; + returnBeforeSpacesLoad?: boolean; + canShareToAllSpaces?: boolean; // default: true + enableCreateCopyCallout?: boolean; + enableCreateNewSpaceLink?: boolean; + behaviorContext?: 'within-space' | 'outside-space'; + mockFeatureId?: string; // optional feature ID to use for the SpacesContext +} + +const setup = async (opts: SetupOpts = {}) => { + const onClose = jest.fn(); + const onUpdate = jest.fn(); + + const mockSpacesManager = spacesManagerMock.create(); + + // note: this call is made in the SpacesContext + mockSpacesManager.getActiveSpace.mockResolvedValue({ + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }); + + // note: this call is made in the SpacesContext + mockSpacesManager.getSpaces.mockResolvedValue( + opts.mockSpaces || [ + { + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Space 2', + disabledFeatures: [], + }, + { + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + }, + { + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }, + ] + ); + + mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ + shareToAllSpaces: opts.canShareToAllSpaces ?? true, + }); + + const savedObjectToShare = { + type: 'dashboard', + id: 'my-dash', + namespaces: opts.namespaces || ['my-active-space', 'space-1'], + icon: 'dashboard', + title: 'foo', + }; + + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + startServices.application.capabilities = { + ...startServices.application.capabilities, + spaces: { manage: true }, + }; + const mockToastNotifications = startServices.notifications.toasts; + getStartServices.mockResolvedValue([startServices, , ,]); + + const SpacesContext = getSpacesContextWrapper({ + getStartServices, + spacesManager: mockSpacesManager, + }); + const ShareToSpaceFlyout = getShareToSpaceFlyoutComponent(); + // the internal flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper + // the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change + // the ShareToSpaceFlyout component renders the internal flyout inside of the context wrapper + const wrapper = mountWithIntl( + + + + ); + + // wait for context wrapper to rerender + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + if (!opts.returnBeforeSpacesLoad) { + // Wait for spaces manager to complete and flyout to rerender + wrapper.update(); + } + + return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; +}; + +describe('ShareToSpaceFlyout', () => { + it('waits for spaces to load', async () => { + const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + wrapper.update(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + }); + + describe('without enableCreateCopyCallout', () => { + it('does not show a warning callout when the saved object only has one namespace', async () => { + const { wrapper, onClose } = await setup({ + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + describe('with enableCreateCopyCallout', () => { + const enableCreateCopyCallout = true; + + it('does not show a warning callout when the saved object has multiple namespaces', async () => { + const { wrapper, onClose } = await setup({ enableCreateCopyCallout }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows a warning callout when the saved object only has one namespace', async () => { + const { wrapper, onClose } = await setup({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not show the Copy flyout by default', async () => { + const { wrapper, onClose } = await setup({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => { + const { wrapper, onClose } = await setup({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout + + await act(async () => { + copyButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + describe('without enableCreateNewSpaceLink', () => { + it('does not render a NoSpacesAvailable component when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not render a NoSpacesAvailable component when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + describe('with enableCreateNewSpaceLink', () => { + const enableCreateNewSpaceLink = true; + + it('renders a NoSpacesAvailable component when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ + enableCreateNewSpaceLink, + mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('renders a NoSpacesAvailable component when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + enableCreateNewSpaceLink, + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + it('handles errors thrown from shareSavedObjectsAdd API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectAdd.mockRejectedValue( + Boom.serverUnavailable('Something bad happened') + ); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('handles errors thrown from shareSavedObjectsRemove API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectRemove.mockRejectedValue( + Boom.serverUnavailable('Something bad happened') + ); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('allows the form to be filled out to add a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).not.toHaveBeenCalled(); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange([]); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).not.toHaveBeenCalled(); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to add and remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + describe('correctly renders checkable cards', () => { + function getCheckableCardProps( + wrapper: ReactWrapper> + ) { + const iconTip = wrapper.find(EuiIconTip); + return { + checked: wrapper.prop('checked'), + disabled: wrapper.prop('disabled'), + ...(iconTip.length > 0 && { tooltip: iconTip.prop('content') as string }), + }; + } + function getCheckableCards(wrapper: ReactWrapper) { + return { + explicitSpacesCard: getCheckableCardProps( + wrapper.find('#shareToExplicitSpaces').find(EuiCheckableCard) + ), + allSpacesCard: getCheckableCardProps( + wrapper.find('#shareToAllSpaces').find(EuiCheckableCard) + ), + }; + } + + describe('when user has privileges to share to all spaces', () => { + const canShareToAllSpaces = true; + + it('and the object is not shared to all spaces', async () => { + const namespaces = ['my-active-space']; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: true, disabled: false }, + allSpacesCard: { checked: false, disabled: false }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + + it('and the object is shared to all spaces', async () => { + const namespaces = [ALL_SPACES_ID]; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: false, disabled: false }, + allSpacesCard: { checked: true, disabled: false }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + }); + + describe('when user does not have privileges to share to all spaces', () => { + const canShareToAllSpaces = false; + + it('and the object is not shared to all spaces', async () => { + const namespaces = ['my-active-space']; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: true, disabled: false }, + allSpacesCard: { + checked: false, + disabled: true, + tooltip: 'You need additional privileges to use this option.', + }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + + it('and the object is shared to all spaces', async () => { + const namespaces = [ALL_SPACES_ID]; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: false, disabled: true }, + allSpacesCard: { + checked: true, + disabled: true, + tooltip: 'You need additional privileges to change this option.', + }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(1); // "Additional privileges required" callout + }); + }); + }); + + describe('space selection', () => { + const mockFeatureId = 'some-feature'; + + const mockSpaces = [ + { + // normal "fully authorized" space selection option -- not the active space + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + // normal "fully authorized" space selection option, with a disabled feature -- not the active space + id: 'space-2', + name: 'Space 2', + disabledFeatures: [mockFeatureId], + }, + { + // "partially authorized" space selection option -- not the active space + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + authorizedPurposes: { shareSavedObjectsIntoSpace: false }, + }, + { + // "partially authorized" space selection option, with a disabled feature -- not the active space + id: 'space-4', + name: 'Space 4', + disabledFeatures: [mockFeatureId], + authorizedPurposes: { shareSavedObjectsIntoSpace: false }, + }, + { + // "active space" selection option (determined by an ID that matches the result of `getActiveSpace`, mocked at top) + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }, + ]; + + const expectActiveSpace = (option: any, { spaceId }: { spaceId: string }) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toMatchInlineSnapshot(` + + Current + + `); + // by definition, the active space will always be checked + expect(option.checked).toEqual('on'); + expect(option.disabled).toEqual(true); + }; + const expectNeedAdditionalPrivileges = ( + option: any, + { + spaceId, + checked, + featureIsDisabled, + }: { spaceId: string; checked: boolean; featureIsDisabled?: boolean } + ) => { + expect(option['data-space-id']).toEqual(spaceId); + if (checked && featureIsDisabled) { + expect(option.append).toMatchInlineSnapshot(` + + + + + `); + } else if (checked && !featureIsDisabled) { + expect(option.append).toMatchInlineSnapshot(` + + + + `); + } else if (!checked && !featureIsDisabled) { + expect(option.append).toMatchInlineSnapshot(` + + + + `); + } else { + throw new Error('Unexpected test case!'); + } + expect(option.checked).toEqual(checked ? 'on' : undefined); + expect(option.disabled).toEqual(true); + }; + const expectFeatureIsDisabled = (option: any, { spaceId }: { spaceId: string }) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toMatchInlineSnapshot(` + + `); + expect(option.checked).toEqual('on'); + expect(option.disabled).toBeUndefined(); + }; + const expectInactiveSpace = ( + option: any, + { spaceId, checked }: { spaceId: string; checked: boolean } + ) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toBeUndefined(); + expect(option.checked).toEqual(checked ? 'on' : undefined); + expect(option.disabled).toBeUndefined(); + }; + + describe('with behaviorContext="within-space" (default)', () => { + it('correctly defines space selection options', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-3']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: true }); + expectInactiveSpace(options[2], { spaceId: 'space-2', checked: false }); + expectNeedAdditionalPrivileges(options[3], { spaceId: 'space-3', checked: true }); + expectNeedAdditionalPrivileges(options[4], { spaceId: 'space-4', checked: false }); + }); + + describe('with a SpacesContext for a specific feature', () => { + it('correctly defines space selection options when affected spaces are not selected', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces, mockFeatureId }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(3); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: false }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: false }); + // space-2 and space-4 are omitted, because they are not selected and the current feature is disabled in those spaces + }); + + it('correctly defines space selection options when affected spaces are already selected', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-2', 'space-3', 'space-4']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces, mockFeatureId }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: true }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: true }); + // space-2 and space-4 are at the end, because they are selected and the current feature is disabled in those spaces + expectFeatureIsDisabled(options[3], { spaceId: 'space-2' }); + expectNeedAdditionalPrivileges(options[4], { + spaceId: 'space-4', + checked: true, + featureIsDisabled: true, + }); + }); + }); + }); + + describe('with behaviorContext="outside-space"', () => { + const behaviorContext = 'outside-space'; + + it('correctly defines space selection options', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-3']; // the saved object's current namespaces + const { wrapper } = await setup({ behaviorContext, mockSpaces, namespaces }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectInactiveSpace(options[0], { spaceId: 'space-1', checked: true }); + expectInactiveSpace(options[1], { spaceId: 'space-2', checked: false }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: true }); + expectNeedAdditionalPrivileges(options[3], { spaceId: 'space-4', checked: false }); + expectInactiveSpace(options[4], { spaceId: 'my-active-space', checked: true }); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx new file mode 100644 index 00000000000000..8d9875977af18f --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -0,0 +1,352 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { + EuiFlyout, + EuiIcon, + EuiFlyoutHeader, + EuiTitle, + EuiText, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ToastsStart } from 'src/core/public'; +import type { + ShareToSpaceFlyoutProps, + ShareToSpaceSavedObjectTarget, +} from 'src/plugins/spaces_oss/public'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; +import { SpacesManager } from '../../spaces_manager'; +import { ShareToSpaceTarget } from '../../types'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { ShareOptions } from '../types'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { useSpaces } from '../../spaces_context'; +import { DEFAULT_OBJECT_NOUN } from './constants'; + +const ALL_SPACES_TARGET = i18n.translate('xpack.spaces.shareToSpace.allSpacesTarget', { + defaultMessage: 'all', +}); + +const arraysAreEqual = (a: unknown[], b: unknown[]) => + a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); + +function createDefaultChangeSpacesHandler( + object: Required>, + spacesManager: SpacesManager, + toastNotifications: ToastsStart +) { + return async (spacesToAdd: string[], spacesToRemove: string[]) => { + const { type, id, title } = object; + const toastTitle = i18n.translate('xpack.spaces.shareToSpace.shareSuccessTitle', { + values: { objectNoun: object.noun }, + defaultMessage: 'Updated {objectNoun}', + }); + const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); + if (spacesToAdd.length > 0) { + await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); + const spaceTargets = isSharedToAllSpaces ? ALL_SPACES_TARGET : `${spacesToAdd.length}`; + const toastText = + !isSharedToAllSpaces && spacesToAdd.length === 1 + ? i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextSingular', { + defaultMessage: `'{object}' was added to 1 space.`, + values: { object: title }, + }) + : i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextPlural', { + defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, + values: { object: title, spaceTargets }, + }); + toastNotifications.addSuccess({ title: toastTitle, text: toastText }); + } + if (spacesToRemove.length > 0) { + await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); + const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); + const spaceTargets = isUnsharedFromAllSpaces ? ALL_SPACES_TARGET : `${spacesToRemove.length}`; + const toastText = + !isUnsharedFromAllSpaces && spacesToRemove.length === 1 + ? i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextSingular', { + defaultMessage: `'{object}' was removed from 1 space.`, + values: { object: title }, + }) + : i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextPlural', { + defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, + values: { object: title, spaceTargets }, + }); + if (!isSharedToAllSpaces) { + toastNotifications.addSuccess({ title: toastTitle, text: toastText }); + } + } + }; +} + +export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { + const { spacesManager, shareToSpacesDataPromise, services } = useSpaces(); + const { notifications } = services; + const toastNotifications = notifications!.toasts; + + const { savedObjectTarget: object } = props; + const savedObjectTarget = useMemo( + () => ({ + type: object.type, + id: object.id, + namespaces: object.namespaces, + icon: object.icon, + title: object.title || `${object.type} [id=${object.id}]`, + noun: object.noun || DEFAULT_OBJECT_NOUN, + }), + [object] + ); + const { + flyoutIcon, + flyoutTitle = i18n.translate('xpack.spaces.shareToSpace.flyoutTitle', { + defaultMessage: 'Edit spaces for {objectNoun}', + values: { objectNoun: savedObjectTarget.noun }, + }), + enableCreateCopyCallout = false, + enableCreateNewSpaceLink = false, + behaviorContext, + changeSpacesHandler = createDefaultChangeSpacesHandler( + savedObjectTarget, + spacesManager, + toastNotifications + ), + onUpdate = () => null, + onClose = () => null, + } = props; + const enableSpaceAgnosticBehavior = behaviorContext === 'outside-space'; + + const [shareOptions, setShareOptions] = useState({ + selectedSpaceIds: [], + initiallySelectedSpaceIds: [], + }); + const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); + const [showMakeCopy, setShowMakeCopy] = useState(false); + + const [{ isLoading, spaces }, setSpacesState] = useState<{ + isLoading: boolean; + spaces: ShareToSpaceTarget[]; + }>({ isLoading: true, spaces: [] }); + useEffect(() => { + const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObjectTarget.type); + Promise.all([shareToSpacesDataPromise, getPermissions]) + .then(([shareToSpacesData, permissions]) => { + const activeSpaceId = !enableSpaceAgnosticBehavior && shareToSpacesData.activeSpaceId; + const selectedSpaceIds = savedObjectTarget.namespaces.filter( + (spaceId) => spaceId !== activeSpaceId + ); + setShareOptions({ + selectedSpaceIds, + initiallySelectedSpaceIds: selectedSpaceIds, + }); + setCanShareToAllSpaces(permissions.shareToAllSpaces); + setSpacesState({ + isLoading: false, + spaces: [...shareToSpacesData.spacesMap].map(([, spaceTarget]) => spaceTarget), + }); + }) + .catch((e) => { + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.shareToSpace.spacesLoadErrorTitle', { + defaultMessage: 'Error loading available spaces', + }), + }); + }); + }, [ + savedObjectTarget, + spacesManager, + shareToSpacesDataPromise, + toastNotifications, + enableSpaceAgnosticBehavior, + ]); + + const getSelectionChanges = () => { + if (!spaces.length) { + return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; + } + const activeSpaceId = + !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; + const initialSelection = savedObjectTarget.namespaces.filter( + (spaceId) => spaceId !== activeSpaceId && spaceId !== UNKNOWN_SPACE + ); + const { selectedSpaceIds } = shareOptions; + const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); + + const initiallySharedToAllSpaces = initialSelection.includes(ALL_SPACES_ID); + const selectionIncludesAllSpaces = filteredSelection.includes(ALL_SPACES_ID); + + const isSharedToAllSpaces = !initiallySharedToAllSpaces && selectionIncludesAllSpaces; + const isUnsharedFromAllSpaces = initiallySharedToAllSpaces && !selectionIncludesAllSpaces; + + const selectedSpacesChanged = + !selectionIncludesAllSpaces && !arraysAreEqual(initialSelection, filteredSelection); + const isSelectionChanged = + isSharedToAllSpaces || + isUnsharedFromAllSpaces || + (!isSharedToAllSpaces && !isUnsharedFromAllSpaces && selectedSpacesChanged); + + const selectedSpacesToAdd = filteredSelection.filter( + (spaceId) => !initialSelection.includes(spaceId) + ); + const selectedSpacesToRemove = initialSelection.filter( + (spaceId) => !filteredSelection.includes(spaceId) + ); + + const activeSpaceArray = activeSpaceId ? [activeSpaceId] : []; // if we have an active space, it is automatically selected + const spacesToAdd = isSharedToAllSpaces + ? [ALL_SPACES_ID] + : isUnsharedFromAllSpaces + ? [...activeSpaceArray, ...selectedSpacesToAdd] + : selectedSpacesToAdd; + const spacesToRemove = + isUnsharedFromAllSpaces || !isSharedToAllSpaces + ? selectedSpacesToRemove + : [...activeSpaceArray, ...initialSelection]; + return { isSelectionChanged, spacesToAdd, spacesToRemove }; + }; + const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); + + const [shareInProgress, setShareInProgress] = useState(false); + + async function startShare() { + setShareInProgress(true); + try { + await changeSpacesHandler(spacesToAdd, spacesToRemove); + onUpdate(); + onClose(); + } catch (e) { + setShareInProgress(false); + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.shareToSpace.shareErrorTitle', { + values: { objectNoun: savedObjectTarget.noun }, + defaultMessage: 'Error updating {objectNoun}', + }), + }); + } + } + + const getFlyoutBody = () => { + // Step 1: loading assets for main form + if (isLoading) { + return ; + } + + // If the object has not been shared yet (e.g., it currently exists in exactly one space), and there is at least one space that we could + // share this object to, we want to display a callout to the user that explains the ramifications of shared objects. They might actually + // want to make a copy instead, so this callout contains a link that opens the Copy flyout. + const showCreateCopyCallout = + enableCreateCopyCallout && + spaces.length > 1 && + savedObjectTarget.namespaces.length === 1 && + !arraysAreEqual(savedObjectTarget.namespaces, [ALL_SPACES_ID]); + // Step 2: Share has not been initiated yet; User must fill out form to continue. + return ( + setShowMakeCopy(true)} + enableCreateNewSpaceLink={enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} + /> + ); + }; + + if (showMakeCopy) { + return ( + + ); + } + + const isStartShareButtonDisabled = + !isSelectionChanged || + shareInProgress || + (enableSpaceAgnosticBehavior && !shareOptions.selectedSpaceIds.length); // the object must exist in at least one space, or all spaces + + return ( + + + + {flyoutIcon && ( + + + + )} + + +

{flyoutTitle}

+
+
+
+
+ + + {savedObjectTarget.icon && ( + + + + )} + + +

{savedObjectTarget.title}

+
+
+
+ + + + {getFlyoutBody()} +
+ + + + + onClose()} + data-test-subj="sts-cancel-button" + disabled={shareInProgress} + > + + + + + startShare()} + data-test-subj="sts-initiate-button" + disabled={isStartShareButtonDisabled} + > + + + + + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx index ef5b731375f495..49c581b07004b8 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx @@ -7,73 +7,84 @@ import './share_to_space_form.scss'; import React, { Fragment } from 'react'; -import { EuiHorizontalRule, EuiCallOut, EuiLink } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ShareOptions, SpaceTarget } from '../types'; +import { ShareToSpaceTarget } from '../../types'; +import { ShareOptions } from '../types'; import { ShareModeControl } from './share_mode_control'; interface Props { - spaces: SpaceTarget[]; + spaces: ShareToSpaceTarget[]; + objectNoun: string; onUpdate: (shareOptions: ShareOptions) => void; shareOptions: ShareOptions; - showShareWarning: boolean; + showCreateCopyCallout: boolean; canShareToAllSpaces: boolean; makeCopy: () => void; + enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } export const ShareToSpaceForm = (props: Props) => { - const { spaces, onUpdate, shareOptions, showShareWarning, canShareToAllSpaces, makeCopy } = props; + const { + spaces, + objectNoun, + onUpdate, + shareOptions, + showCreateCopyCallout, + canShareToAllSpaces, + makeCopy, + enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, + } = props; const setSelectedSpaceIds = (selectedSpaceIds: string[]) => onUpdate({ ...shareOptions, selectedSpaceIds }); - const getShareWarning = () => { - if (!showShareWarning) { - return null; - } - - return ( - - - } - color="warning" - > + const createCopyCallout = showCreateCopyCallout ? ( + + makeCopy()}> - - - ), - }} + id="xpack.spaces.shareToSpace.shareWarningTitle" + defaultMessage="Changes are synchronized across spaces" /> - + } + color="warning" + > + makeCopy()}> + + + ), + }} + /> + - - - ); - }; + +
+ ) : null; return (
- {getShareWarning()} + {createCopyCallout} setSelectedSpaceIds(selection)} + enableCreateNewSpaceLink={enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} />
); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts index 5f8d0dfc2e949c..beed0fd9d592ae 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts @@ -6,3 +6,5 @@ */ export { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space_service'; +export { getShareToSpaceFlyoutComponent, getLegacyUrlConflict } from './components'; +export { createRedirectLegacyUrl } from './utils'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx index abe1579f2058fc..a8d503d306ee81 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx @@ -5,21 +5,14 @@ * 2.0. */ -import { coreMock, notificationServiceMock } from 'src/core/public/mocks'; import { SavedObjectsManagementRecord } from '../../../../../src/plugins/saved_objects_management/public'; -import { spacesManagerMock } from '../spaces_manager/mocks'; +import { uiApiMock } from '../ui_api/mocks'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; describe('ShareToSpaceSavedObjectsManagementAction', () => { const createAction = () => { - const spacesManager = spacesManagerMock.create(); - const notificationsStart = notificationServiceMock.createStartContract(); - const { getStartServices } = coreMock.createSetup(); - return new ShareToSpaceSavedObjectsManagementAction( - spacesManager, - notificationsStart, - getStartServices - ); + const spacesApiUi = uiApiMock.create(); + return new ShareToSpaceSavedObjectsManagementAction(spacesApiUi); }; describe('#euiAction.available', () => { describe('with an object type that has a namespaceType of "multiple"', () => { diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx index f115119275abd6..feb073745c6162 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx @@ -7,23 +7,20 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { NotificationsStart, StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementAction, SavedObjectsManagementRecord, } from '../../../../../src/plugins/saved_objects_management/public'; -import { ContextWrapper, ShareSavedObjectsToSpaceFlyout } from './components'; -import { SpacesManager } from '../spaces_manager'; -import { PluginsStart } from '../plugin'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction { public id: string = 'share_saved_objects_to_space'; public euiAction = { - name: i18n.translate('xpack.spaces.management.shareToSpace.actionTitle', { + name: i18n.translate('xpack.spaces.shareToSpace.actionTitle', { defaultMessage: 'Share to space', }), - description: i18n.translate('xpack.spaces.management.shareToSpace.actionDescription', { + description: i18n.translate('xpack.spaces.shareToSpace.actionDescription', { defaultMessage: 'Share this saved object to one or more spaces', }), icon: 'share', @@ -43,11 +40,7 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage private isDataChanged: boolean = false; - constructor( - private readonly spacesManager: SpacesManager, - private readonly notifications: NotificationsStart, - private readonly getStartServices: StartServicesAccessor - ) { + constructor(private readonly spacesApiUi: SpacesApiUi) { super(); } @@ -56,16 +49,24 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage throw new Error('No record available! `render()` was likely called before `start()`.'); } + const savedObjectTarget = { + type: this.record.type, + id: this.record.id, + namespaces: this.record.namespaces ?? [], + title: this.record.meta.title, + icon: this.record.meta.icon, + }; + const { ShareToSpaceFlyout } = this.spacesApiUi.components; + return ( - - (this.isDataChanged = true)} - savedObject={this.record} - spacesManager={this.spacesManager} - toastNotifications={this.notifications.toasts} - /> - + (this.isDataChanged = true)} + onClose={this.onClose} + enableCreateCopyCallout={true} + enableCreateNewSpaceLink={true} + /> ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx deleted file mode 100644 index d0949da27c5798..00000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { shallowWithIntl } from '@kbn/test/jest'; -import { SpacesManager } from '../spaces_manager'; -import { spacesManagerMock } from '../spaces_manager/mocks'; -import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { SpaceTarget } from './types'; - -const ACTIVE_SPACE: SpaceTarget = { - id: 'default', - name: 'Default', - color: '#ffffff', - isActiveSpace: true, -}; -const getSpaceData = (inactiveSpaceCount: number = 0) => { - const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] - .map((name) => ({ - id: name.toLowerCase(), - name, - color: `#123456`, // must be a valid color as `render()` is used below - isActiveSpace: false, - })) - .slice(0, inactiveSpaceCount); - const spaceTargets = [ACTIVE_SPACE, ...inactive]; - const namespaces = spaceTargets.map(({ id }) => id); - return { spaceTargets, namespaces }; -}; - -describe('ShareToSpaceSavedObjectsManagementColumn', () => { - let spacesManager: SpacesManager; - beforeEach(() => { - spacesManager = spacesManagerMock.create(); - }); - - const createColumn = (spaceTargets: SpaceTarget[], namespaces: string[]) => { - const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); - column.data = spaceTargets.reduce( - (acc, cur) => acc.set(cur.id, cur), - new Map() - ); - const element = column.euiColumn.render(namespaces); - return shallowWithIntl(element); - }; - - /** - * This node displays up to five named spaces (and an indicator for any number of unauthorized spaces) by default. The active space is - * omitted from this list. If more than five named spaces would be displayed, the extras (along with the unauthorized spaces indicator, if - * present) are hidden behind a button. - * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. - */ - describe('#euiColumn.render', () => { - describe('with only the active space', () => { - const { spaceTargets, namespaces } = getSpaceData(); - const wrapper = createColumn(spaceTargets, namespaces); - - it('does not show badges or button', async () => { - const badges = wrapper.find('EuiBadge'); - expect(badges).toHaveLength(0); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space and one inactive space', () => { - const { spaceTargets, namespaces } = getSpaceData(1); - const wrapper = createColumn(spaceTargets, namespaces); - - it('shows one badge without button', async () => { - const badges = wrapper.find('EuiBadge'); - expect(badges).toMatchInlineSnapshot(` - - Alpha - - `); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space and five inactive spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, namespaces); - - it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space, five inactive spaces, and one unauthorized space', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, [...namespaces, '?']); - - it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+1']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); - - it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+2']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space and six inactive spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, namespaces); - - it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 1 }, - }); - - button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot']); - }); - }); - - describe('with the active space, six inactive spaces, and one unauthorized space', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, [...namespaces, '?']); - - it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 2 }, - }); - - button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+1']); - }); - }); - - describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); - - it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 3 }, - }); - - button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+2']); - }); - }); - - describe('with only "all spaces"', () => { - const wrapper = createColumn([], ['*']); - - it('shows one badge without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['* All spaces']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { - // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, ['*', ...namespaces, '?']); - - it('shows one badge without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['* All spaces']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - }); -}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx index 6195095156258c..05e0976da0710c 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx @@ -5,151 +5,30 @@ * 2.0. */ -import React, { useState, ReactNode } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiBadge } from '@elastic/eui'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { EuiToolTip } from '@elastic/eui'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import { SavedObjectsManagementColumn } from '../../../../../src/plugins/saved_objects_management/public'; -import { SpaceTarget } from './types'; -import { SpacesManager } from '../spaces_manager'; -import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; -import { getSpaceColor } from '..'; - -const SPACES_DISPLAY_COUNT = 5; - -type SpaceMap = Map; -interface ColumnDataProps { - namespaces?: string[]; - data?: SpaceMap; -} - -const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { - const [isExpanded, setIsExpanded] = useState(false); - - if (!data) { - return null; - } - - const isSharedToAllSpaces = namespaces?.includes(ALL_SPACES_ID); - const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) - .length; - let displayedSpaces: SpaceTarget[]; - let button: ReactNode = null; - - if (isSharedToAllSpaces) { - displayedSpaces = [ - { - id: ALL_SPACES_ID, - name: i18n.translate('xpack.spaces.management.shareToSpace.allSpacesLabel', { - defaultMessage: `* All spaces`, - }), - isActiveSpace: false, - color: '#D3DAE6', - }, - ]; - } else { - const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; - const authorizedSpaceTargets: SpaceTarget[] = []; - authorized.forEach((namespace) => { - const spaceTarget = data.get(namespace); - if (spaceTarget === undefined) { - // in the event that a new space was created after this page has loaded, fall back to displaying the space ID - authorizedSpaceTargets.push({ id: namespace, name: namespace, isActiveSpace: false }); - } else if (!spaceTarget.isActiveSpace) { - authorizedSpaceTargets.push(spaceTarget); - } - }); - displayedSpaces = isExpanded - ? authorizedSpaceTargets - : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); - - if (authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT) { - button = isExpanded ? ( - setIsExpanded(false)}> - - - ) : ( - setIsExpanded(true)}> - - - ); - } - } - - const unauthorizedCountBadge = - !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedCount > 0 ? ( - - - } - > - +{unauthorizedCount} - - - ) : null; - - return ( - - {displayedSpaces.map(({ id, name, color }) => ( - - {name} - - ))} - {unauthorizedCountBadge} - {button} - - ); -}; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; export class ShareToSpaceSavedObjectsManagementColumn - implements SavedObjectsManagementColumn { + implements SavedObjectsManagementColumn { public id: string = 'share_saved_objects_to_space'; - public data: Map | undefined; public euiColumn = { field: 'namespaces', - name: i18n.translate('xpack.spaces.management.shareToSpace.columnTitle', { + name: i18n.translate('xpack.spaces.shareToSpace.columnTitle', { defaultMessage: 'Shared spaces', }), - description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { + description: i18n.translate('xpack.spaces.shareToSpace.columnDescription', { defaultMessage: 'The other spaces that this object is currently shared to', }), - render: (namespaces: string[] | undefined) => ( - - ), - }; - - constructor(private readonly spacesManager: SpacesManager) {} - - public loadData = () => { - this.data = undefined; - return Promise.all([this.spacesManager.getSpaces(), this.spacesManager.getActiveSpace()]).then( - ([spaces, activeSpace]) => { - this.data = spaces - .map((space) => ({ - ...space, - isActiveSpace: space.id === activeSpace.id, - color: getSpaceColor(space), - })) - .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); - return this.data; + render: (namespaces: string[] | undefined) => { + if (!namespaces) { + return null; } - ); + return ; + }, }; + + constructor(private readonly spacesApiUi: SpacesApiUi) {} } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts index eeadb157b51873..6e74fa31ec4b8b 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts @@ -7,19 +7,16 @@ import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { spacesManagerMock } from '../spaces_manager/mocks'; import { ShareSavedObjectsToSpaceService } from '.'; -import { coreMock, notificationServiceMock } from 'src/core/public/mocks'; import { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; +import { uiApiMock } from '../ui_api/mocks'; describe('ShareSavedObjectsToSpaceService', () => { describe('#setup', () => { it('registers the ShareToSpaceSavedObjectsManagement Action and Column', () => { const deps = { - spacesManager: spacesManagerMock.create(), - notificationsSetup: notificationServiceMock.createSetupContract(), savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + spacesApiUi: uiApiMock.create(), }; const service = new ShareSavedObjectsToSpaceService(); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts index 08a7db106d6bb9..86b9c07bebe924 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts @@ -5,35 +5,22 @@ * 2.0. */ -import { NotificationsSetup, StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { SpacesManager } from '../spaces_manager'; -import { PluginsStart } from '../plugin'; interface SetupDeps { - spacesManager: SpacesManager; savedObjectsManagementSetup: SavedObjectsManagementPluginSetup; - notificationsSetup: NotificationsSetup; - getStartServices: StartServicesAccessor; + spacesApiUi: SpacesApiUi; } export class ShareSavedObjectsToSpaceService { - public setup({ - spacesManager, - savedObjectsManagementSetup, - notificationsSetup, - getStartServices, - }: SetupDeps) { - const action = new ShareToSpaceSavedObjectsManagementAction( - spacesManager, - notificationsSetup, - getStartServices - ); + public setup({ savedObjectsManagementSetup, spacesApiUi }: SetupDeps) { + const action = new ShareToSpaceSavedObjectsManagementAction(spacesApiUi); savedObjectsManagementSetup.actions.register(action); // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. - // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); + // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesApiUi); // savedObjectsManagementSetup.columns.register(column); } } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts index f5e0d09a99e4bb..fda561d8c4af11 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts @@ -6,10 +6,10 @@ */ import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/public'; -import { GetSpaceResult } from '..'; export interface ShareOptions { selectedSpaceIds: string[]; + initiallySelectedSpaceIds: string[]; } export type ImportRetry = Omit; @@ -17,8 +17,3 @@ export type ImportRetry = Omit; export interface ShareSavedObjectsToSpaceResponse { [spaceId: string]: SavedObjectsImportResponse; } - -export interface SpaceTarget extends Omit { - isActiveSpace: boolean; - isPartiallyAuthorized?: boolean; -} diff --git a/x-pack/plugins/ml/public/application/contexts/spaces/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts similarity index 68% rename from x-pack/plugins/ml/public/application/contexts/spaces/index.ts rename to x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts index 7b87bab8057e9c..a40bc87cd4dc30 100644 --- a/x-pack/plugins/ml/public/application/contexts/spaces/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts @@ -5,9 +5,4 @@ * 2.0. */ -export { - SpacesContext, - SpacesContextValue, - createSpacesContext, - useSpacesContext, -} from './spaces_context'; +export { createRedirectLegacyUrl } from './redirect_legacy_url'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts new file mode 100644 index 00000000000000..84d2958092a650 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { createRedirectLegacyUrl } from './redirect_legacy_url'; + +const APP_ID = 'testAppId'; + +describe('#redirectLegacyUrl', () => { + const setup = () => { + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + const subject = new BehaviorSubject(`not-${APP_ID}`); + subject.next(APP_ID); // test below asserts that the consumer received the most recent APP_ID + startServices.application.currentAppId$ = subject; + const toasts = startServices.notifications.toasts; + const application = startServices.application; + getStartServices.mockResolvedValue([startServices, , ,]); + + const redirectLegacyUrl = createRedirectLegacyUrl(getStartServices); + + return { redirectLegacyUrl, toasts, application }; + }; + + it('creates a toast and redirects to the given path in the current app', async () => { + const { redirectLegacyUrl, toasts, application } = setup(); + + const path = '/foo?bar#baz'; + await redirectLegacyUrl(path); + + expect(toasts.addInfo).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledWith(APP_ID, { replace: true, path }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts new file mode 100644 index 00000000000000..694465e34049c1 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { firstValueFrom } from '@kbn/std'; +import type { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUi } from 'src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../../plugin'; +import { DEFAULT_OBJECT_NOUN } from '../components/constants'; + +export function createRedirectLegacyUrl( + getStartServices: StartServicesAccessor +): SpacesApiUi['redirectLegacyUrl'] { + return async function (path: string, objectNoun: string = DEFAULT_OBJECT_NOUN) { + const [{ notifications, application }] = await getStartServices(); + const { currentAppId$, navigateToApp } = application; + const appId = await firstValueFrom(currentAppId$); // retrieve the most recent value from the BehaviorSubject + + const title = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.title', { + defaultMessage: `We redirected you to a new URL`, + }); + const text = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.text', { + defaultMessage: `The {objectNoun} you're looking for has a new location. Use this URL from now on.`, + values: { objectNoun }, + }); + notifications.toasts.addInfo({ title, text }); + await navigateToApp(appId!, { replace: true, path }); + }; +} diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts b/x-pack/plugins/spaces/public/space_list/index.ts similarity index 81% rename from x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts rename to x-pack/plugins/spaces/public/space_list/index.ts index da960a20c15388..1570ad123b9ab7 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts +++ b/x-pack/plugins/spaces/public/space_list/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { JobSpacesFlyout } from './jobs_spaces_flyout'; +export { getSpaceListComponent } from './space_list'; diff --git a/x-pack/plugins/spaces/public/space_list/space_list.tsx b/x-pack/plugins/spaces/public/space_list/space_list.tsx new file mode 100644 index 00000000000000..d8bd47b66b5c62 --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list.tsx @@ -0,0 +1,16 @@ +/* + * 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 type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { SpaceListInternal } from './space_list_internal'; + +export const getSpaceListComponent = (): React.FC => { + return (props: SpaceListProps) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx new file mode 100644 index 00000000000000..e0e8cc23373797 --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx @@ -0,0 +1,310 @@ +/* + * 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 { mountWithIntl } from '@kbn/test/jest'; +import { act } from '@testing-library/react'; +import { coreMock } from 'src/core/public/mocks'; +import type { Space } from 'src/plugins/spaces_oss/common'; +import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { getSpacesContextWrapper } from '../spaces_context'; +import { spacesManagerMock } from '../spaces_manager/mocks'; +import { ReactWrapper } from 'enzyme'; +import { SpaceListInternal } from './space_list_internal'; + +const ACTIVE_SPACE: Space = { + id: 'default', + name: 'Default', + initials: 'D!', // so it can be differentiated from 'Delta' + disabledFeatures: [], +}; +const getSpaceData = (inactiveSpaceCount: number = 0) => { + const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] + .map((name) => { + const id = name.toLowerCase(); + return { id, name, disabledFeatures: [`${id}-feature`] }; + }) + .slice(0, inactiveSpaceCount); + const spaces = [ACTIVE_SPACE, ...inactive]; + const namespaces = spaces.map(({ id }) => id); + return { spaces, namespaces }; +}; + +/** + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for any + * number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras (along + * with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it supersedes all + * of the above and just displays a single badge without a button. + */ +describe('SpaceListInternal', () => { + const createSpaceList = async ({ + spaces, + props, + feature, + }: { + spaces: Space[]; + props: SpaceListProps; + feature?: string; + }) => { + const { getStartServices } = coreMock.createSetup(); + const spacesManager = spacesManagerMock.create(); + spacesManager.getActiveSpace.mockResolvedValue(ACTIVE_SPACE); + spacesManager.getSpaces.mockResolvedValue(spaces); + + const SpacesContext = getSpacesContextWrapper({ getStartServices, spacesManager }); + const wrapper = mountWithIntl( + + + + ); + + // wait for context wrapper to rerender + await act(async () => {}); + wrapper.update(); + + return wrapper; + }; + + function getListText(wrapper: ReactWrapper) { + return wrapper.find('EuiFlexItem').map((x) => x.text()); + } + function getButton(wrapper: ReactWrapper) { + return wrapper.find('EuiButtonEmpty'); + } + + describe('using default properties', () => { + describe('with only the active space', () => { + const { spaces, namespaces } = getSpaceData(); + + it('does not show badges or button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toHaveLength(0); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and one inactive space', () => { + const { spaces, namespaces } = getSpaceData(1); + + it('shows one badge without button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and five inactive spaces', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?', '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', '+2']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and six inactive spaces', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + + const button = getButton(wrapper); + expect(button.text()).toEqual('+1 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with the active space, six inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+2 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?', '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+3 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', '+2']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with only "all spaces"', () => { + it('shows one badge without button', async () => { + const props = { namespaces: ['*'] }; + const wrapper = await createSpaceList({ spaces: [], props }); + + expect(getListText(wrapper)).toEqual(['*']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { + // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else + const { spaces, namespaces } = getSpaceData(6); + + it('shows one badge without button', async () => { + const props = { namespaces: ['*', ...namespaces, '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['*']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + }); + + describe('using custom properties', () => { + describe('with the active space, eight inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(8); + + it('with displayLimit=0, shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 0 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + + it('with displayLimit=1, shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 1 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+8 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + + it('with displayLimit=7, shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 7 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+2 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + + it('with displayLimit=8, shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 8 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + + it('with behaviorContext="outside-space", shows badges with button', async () => { + const props: SpaceListProps = { + namespaces: [...namespaces, '?'], + behaviorContext: 'outside-space', + }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['D!', 'A', 'B', 'C', 'D']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+5 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['D!', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + }); + + describe('with a SpacesContext for a specific feature', () => { + describe('with the active space, eight inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(8); + + it('shows badges with button, showing disabled features at the end of the list', async () => { + // Each space that is generated by the getSpaceData function has a disabled feature derived from its own ID. + // E.g., the Alpha space has `disabledFeatures: ['alpha-feature']`, the Bravo space has `disabledFeatures: ['bravo-feature']`, and + // so on and so forth. For this test case we will render the Space context for the 'bravo-feature' feature, so the SpaceAvatar for + // the Bravo space will appear at the end of the list. + const props = { namespaces: [...namespaces, '?'] }; + const feature = 'bravo-feature'; + const wrapper = await createSpaceList({ spaces, props, feature }); + + expect(getListText(wrapper)).toEqual(['A', 'C', 'D', 'E', 'F']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+4 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'C', 'D', 'E', 'F', 'G', 'H', 'B', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx new file mode 100644 index 00000000000000..b0250105885d2f --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, ReactNode, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { ShareToSpacesData, ShareToSpaceTarget } from '../types'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; +import { useSpaces } from '../spaces_context'; +import { SpaceAvatar } from '../space_avatar'; + +const DEFAULT_DISPLAY_LIMIT = 5; + +/** + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for any + * number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras (along + * with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it supersedes all + * of the above and just displays a single badge without a button. + */ +export const SpaceListInternal = ({ + namespaces, + displayLimit = DEFAULT_DISPLAY_LIMIT, + behaviorContext, +}: SpaceListProps) => { + const { shareToSpacesDataPromise } = useSpaces(); + + const [isExpanded, setIsExpanded] = useState(false); + const [shareToSpacesData, setShareToSpacesData] = useState(); + + useEffect(() => { + shareToSpacesDataPromise.then((x) => { + setShareToSpacesData(x); + }); + }, [shareToSpacesDataPromise]); + + if (!shareToSpacesData) { + return null; + } + + const isSharedToAllSpaces = namespaces.includes(ALL_SPACES_ID); + const unauthorizedSpacesCount = namespaces.filter((namespace) => namespace === UNKNOWN_SPACE) + .length; + let displayedSpaces: ShareToSpaceTarget[]; + let button: ReactNode = null; + + if (isSharedToAllSpaces) { + displayedSpaces = [ + { + id: ALL_SPACES_ID, + name: i18n.translate('xpack.spaces.spaceList.allSpacesLabel', { + defaultMessage: `* All spaces`, + }), + initials: '*', + color: '#D3DAE6', + }, + ]; + } else { + const authorized = namespaces.filter((namespace) => namespace !== UNKNOWN_SPACE); + const enabledSpaceTargets: ShareToSpaceTarget[] = []; + const disabledSpaceTargets: ShareToSpaceTarget[] = []; + authorized.forEach((namespace) => { + const spaceTarget = shareToSpacesData.spacesMap.get(namespace); + if (spaceTarget === undefined) { + // in the event that a new space was created after this page has loaded, fall back to displaying the space ID + enabledSpaceTargets.push({ id: namespace, name: namespace }); + } else if (behaviorContext === 'outside-space' || !spaceTarget.isActiveSpace) { + if (spaceTarget.isFeatureDisabled) { + disabledSpaceTargets.push(spaceTarget); + } else { + enabledSpaceTargets.push(spaceTarget); + } + } + }); + const authorizedSpaceTargets = [...enabledSpaceTargets, ...disabledSpaceTargets]; + + displayedSpaces = + isExpanded || !displayLimit + ? authorizedSpaceTargets + : authorizedSpaceTargets.slice(0, displayLimit); + + if (displayLimit && authorizedSpaceTargets.length > displayLimit) { + button = isExpanded ? ( + setIsExpanded(false)}> + + + ) : ( + setIsExpanded(true)}> + + + ); + } + } + + const unauthorizedSpacesCountBadge = + !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedSpacesCount > 0 ? ( + + + } + > + +{unauthorizedSpacesCount} + + + ) : null; + + return ( + + {displayedSpaces.map((space) => { + // color may be undefined, which is intentional; SpacesAvatar calls the getSpaceColor function before rendering + const color = space.isFeatureDisabled ? 'hollow' : space.color; + return ( + + + + ); + })} + {unauthorizedSpacesCountBadge} + {button} + + ); +}; diff --git a/x-pack/plugins/spaces/public/spaces_context/context.tsx b/x-pack/plugins/spaces/public/spaces_context/context.tsx new file mode 100644 index 00000000000000..548b2158558c55 --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/context.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { SpacesManager } from '../spaces_manager'; +import { ShareToSpacesData } from '../types'; +import { SpacesReactContext, SpacesReactContextValue, KibanaServices } from './types'; + +const { useContext, createElement, createContext } = React; + +const context = createContext>>({}); + +export const useSpaces = (): SpacesReactContextValue< + KibanaServices & Extra +> => + useContext( + (context as unknown) as React.Context> + ); + +export const createSpacesReactContext = ( + services: Services, + spacesManager: SpacesManager, + shareToSpacesDataPromise: Promise +): SpacesReactContext => { + const value: SpacesReactContextValue = { + spacesManager, + shareToSpacesDataPromise, + services, + }; + const Provider: React.FC = ({ children }) => + createElement(context.Provider as React.ComponentType, { value, children }); + + return { + value, + Provider, + Consumer: (context.Consumer as unknown) as React.Consumer>, + }; +}; diff --git a/x-pack/plugins/spaces/public/spaces_context/index.ts b/x-pack/plugins/spaces/public/spaces_context/index.ts new file mode 100644 index 00000000000000..fdf28ad5957cfe --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useSpaces } from './context'; +export { getSpacesContextWrapper } from './wrapper'; diff --git a/x-pack/plugins/spaces/public/spaces_context/types.ts b/x-pack/plugins/spaces/public/spaces_context/types.ts new file mode 100644 index 00000000000000..c2f7db69add09c --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { CoreStart } from 'src/core/public'; +import { ShareToSpacesData } from '../types'; +import { SpacesManager } from '../spaces_manager'; + +export type KibanaServices = Partial; + +export interface SpacesReactContextValue { + readonly spacesManager: SpacesManager; + readonly shareToSpacesDataPromise: Promise; + readonly services: Services; +} + +export interface SpacesReactContext { + value: SpacesReactContextValue; + Provider: React.FC; + Consumer: React.Consumer>; +} diff --git a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx new file mode 100644 index 00000000000000..18112945ea738c --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect, PropsWithChildren, useMemo } from 'react'; +import { + StartServicesAccessor, + DocLinksStart, + ApplicationStart, + NotificationsStart, +} from 'src/core/public'; +import type { SpacesContextProps } from '../../../../../src/plugins/spaces_oss/public'; +import { createSpacesReactContext } from './context'; +import { PluginsStart } from '../plugin'; +import { SpacesManager } from '../spaces_manager'; +import { ShareToSpacesData, ShareToSpaceTarget } from '../types'; +import { SpacesReactContext } from './types'; + +interface InternalProps { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +interface Services { + application: ApplicationStart; + docLinks: DocLinksStart; + notifications: NotificationsStart; +} + +async function getShareToSpacesData( + spacesManager: SpacesManager, + feature?: string +): Promise { + const spaces = await spacesManager.getSpaces({ includeAuthorizedPurposes: true }); + const activeSpace = await spacesManager.getActiveSpace(); + const spacesMap = spaces + .map(({ authorizedPurposes, disabledFeatures, ...space }) => { + const isActiveSpace = space.id === activeSpace.id; + const cannotShareToSpace = authorizedPurposes?.shareSavedObjectsIntoSpace === false; + const isFeatureDisabled = feature !== undefined && disabledFeatures.includes(feature); + return { + ...space, + ...(isActiveSpace && { isActiveSpace }), + ...(cannotShareToSpace && { cannotShareToSpace }), + ...(isFeatureDisabled && { isFeatureDisabled }), + }; + }) + .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); + + return { + spacesMap, + activeSpaceId: activeSpace.id, + }; +} + +const SpacesContextWrapper = (props: PropsWithChildren) => { + const { spacesManager, getStartServices, feature, children } = props; + + const [context, setContext] = useState | undefined>(); + const shareToSpacesDataPromise = useMemo(() => getShareToSpacesData(spacesManager, feature), [ + spacesManager, + feature, + ]); + + useEffect(() => { + getStartServices().then(([coreStart]) => { + const { application, docLinks, notifications } = coreStart; + const services = { application, docLinks, notifications }; + setContext(createSpacesReactContext(services, spacesManager, shareToSpacesDataPromise)); + }); + }, [getStartServices, shareToSpacesDataPromise, spacesManager]); + + if (!context) { + return null; + } + + return {children}; +}; + +export const getSpacesContextWrapper = ( + internalProps: InternalProps +): React.FC => { + return ({ children, ...props }: PropsWithChildren) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/types.ts b/x-pack/plugins/spaces/public/types.ts new file mode 100644 index 00000000000000..a49df82154849f --- /dev/null +++ b/x-pack/plugins/spaces/public/types.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GetSpaceResult } from '../common'; + +/** + * The structure for all of the space data that must be loaded for share-to-space components to function. + */ +export interface ShareToSpacesData { + /** A map of each existing space's ID and its associated {@link ShareToSpaceTarget}. */ + readonly spacesMap: Map; + /** The ID of the active space. */ + readonly activeSpaceId: string; +} + +/** + * The data that was fetched for a specific space. Includes optional additional fields that are needed to handle edge cases in the + * share-to-space components that consume it. + */ +export interface ShareToSpaceTarget extends Omit { + /** True if this space is the active space. */ + isActiveSpace?: true; + /** True if the user has read access to this space, but is not authorized to share objects into this space. */ + cannotShareToSpace?: true; + /** True if the current feature (specified in the `SpacesContext`) is disabled in this space. */ + isFeatureDisabled?: true; +} diff --git a/x-pack/plugins/spaces/public/ui_api/components.ts b/x-pack/plugins/spaces/public/ui_api/components.ts new file mode 100644 index 00000000000000..6a8dedb5f5b683 --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/components.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUiComponent } from '../../../../../src/plugins/spaces_oss/public'; +import { PluginsStart } from '../plugin'; +import { + getShareToSpaceFlyoutComponent, + getLegacyUrlConflict, +} from '../share_saved_objects_to_space'; +import { getSpacesContextWrapper } from '../spaces_context'; +import { SpacesManager } from '../spaces_manager'; +import { getSpaceListComponent } from '../space_list'; + +export interface GetComponentsOptions { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +export const getComponents = ({ + spacesManager, + getStartServices, +}: GetComponentsOptions): SpacesApiUiComponent => { + return { + SpacesContext: getSpacesContextWrapper({ spacesManager, getStartServices }), + ShareToSpaceFlyout: getShareToSpaceFlyoutComponent(), + SpaceList: getSpaceListComponent(), + LegacyUrlConflict: getLegacyUrlConflict({ getStartServices }), + }; +}; diff --git a/x-pack/plugins/spaces/public/ui_api/index.ts b/x-pack/plugins/spaces/public/ui_api/index.ts new file mode 100644 index 00000000000000..e278eb691910fe --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../plugin'; +import type { SpacesManager } from '../spaces_manager'; +import { getComponents } from './components'; +import { createRedirectLegacyUrl } from '../share_saved_objects_to_space'; + +interface GetUiApiOptions { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +export const getUiApi = ({ spacesManager, getStartServices }: GetUiApiOptions): SpacesApiUi => { + const components = getComponents({ spacesManager, getStartServices }); + + return { + components, + redirectLegacyUrl: createRedirectLegacyUrl(getStartServices), + }; +}; diff --git a/x-pack/plugins/spaces/public/ui_api/mocks.ts b/x-pack/plugins/spaces/public/ui_api/mocks.ts new file mode 100644 index 00000000000000..c9aa2a2b2b52f9 --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/mocks.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + SpacesApiUi, + SpacesApiUiComponent, +} from '../../../../../src/plugins/spaces_oss/public'; + +function createComponentsMock(): jest.Mocked { + return { + SpacesContext: jest.fn(), + ShareToSpaceFlyout: jest.fn(), + SpaceList: jest.fn(), + LegacyUrlConflict: jest.fn(), + }; +} + +function createUiApiMock(): jest.Mocked { + return { + components: createComponentsMock(), + redirectLegacyUrl: jest.fn(), + }; +} + +export const uiApiMock = { + create: createUiApiMock, +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 801c2141c7d1c6..4d94539a514d1d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13283,7 +13283,6 @@ "xpack.ml.jobsList.actionExecuteSuccessfullyNotificationMessage": "{successesJobsCount, plural, one{{successJob}} other{# 件のジョブ}} {actionTextPT}成功", "xpack.ml.jobsList.actionFailedNotificationMessage": "{failureId} が {actionText} に失敗しました", "xpack.ml.jobsList.actionsLabel": "アクション", - "xpack.ml.jobsList.analyticsSpacesLabel": "スペース", "xpack.ml.jobsList.auditMessageColumn.screenReaderDescription": "このカラムは、過去24時間にエラーまたは警告があった場合にアイコンを表示します", "xpack.ml.jobsList.breadcrumb": "ジョブ", "xpack.ml.jobsList.cannotSelectRowForJobMessage": "ジョブID {jobId}を選択できません", @@ -13486,19 +13485,6 @@ "xpack.ml.management.jobsList.noPermissionToAccessLabel": "ML ジョブへのアクセスにはパーミッションが必要です", "xpack.ml.management.jobsList.syncFlyoutButton": "保存されたオブジェクトを同期", "xpack.ml.management.jobsListTitle": "機械学習ジョブ", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.text": "このジョブのスペースを変更するには、すべてのスペースでジョブを修正する権限が必要です。詳細については、システム管理者に連絡してください。", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.title": "{jobId} のスペースを編集する権限が不十分です", - "xpack.ml.management.spacesSelectorFlyout.closeButton": "閉じる", - "xpack.ml.management.spacesSelectorFlyout.headerLabel": "{jobId} のスペースを選択", - "xpack.ml.management.spacesSelectorFlyout.saveButton": "保存", - "xpack.ml.management.spacesSelectorFlyout.selectSpacesLabel": "スペースを選択", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text": "現在と将来のすべてのスペースでジョブを使用可能にします。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title": "すべてのスペース", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text": "選択したスペースでのみジョブを使用可能にします。", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title": "スペースを選択", - "xpack.ml.management.spacesSelectorFlyout.updateSpaces.error": "{id} の更新エラー", "xpack.ml.management.syncSavedObjectsFlyout.closeButton": "閉じる", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.description": "異常検知のデータフィード ID がない保存されたオブジェクトがある場合は、ID が追加されます。", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.title": "データフィードがない選択されたオブジェクト({count})", @@ -20606,44 +20592,6 @@ "xpack.spaces.management.manageSpacePage.updateSpaceButton": "スペースを更新", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "リザーブされたスペースはビルトインのため、部分的な変更しかできません。", "xpack.spaces.management.selectAllFeaturesLink": "すべて選択", - "xpack.spaces.management.shareToSpace.actionDescription": "この保存されたオブジェクトを1つ以上のスペースと共有します。", - "xpack.spaces.management.shareToSpace.actionTitle": "スペースと共有", - "xpack.spaces.management.shareToSpace.allSpacesLabel": "*すべてのスペース", - "xpack.spaces.management.shareToSpace.cancelButton": "キャンセル", - "xpack.spaces.management.shareToSpace.columnDescription": "このオブジェクトが現在共有されている他のスペース", - "xpack.spaces.management.shareToSpace.columnTitle": "共有されているスペース", - "xpack.spaces.management.shareToSpace.columnUnauthorizedLabel": "これらのスペースを表示するアクセス権がありません。", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText": "新しいスペースを作成", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text": "オブジェクトを共有するには、{createANewSpaceLink}できます。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked": "このスペースの選択を解除するには、追加の権限が必要です。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked": "このスペースを選択するには、追加の権限が必要です。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースに追加されました。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular": "「{object}」は1つのスペースに追加されました。", - "xpack.spaces.management.shareToSpace.shareEditSuccessTitle": "オブジェクトが更新されました", - "xpack.spaces.management.shareToSpace.shareErrorTitle": "保存されたオブジェクトの更新エラー", - "xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount}個が非表示", - "xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount}個が選択済み", - "xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel": "スペースを選択", - "xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle": "共有オプション", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text": "現在と将来のすべてのスペースでオブジェクトを使用可能にします。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title": "すべてのスペース", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "選択したスペースでのみオブジェクトを使用可能にします。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "スペースを選択", - "xpack.spaces.management.shareToSpace.shareNewSuccessTitle": "オブジェクトは共有されています", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースから削除されました。", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular": "「{object}」は1つのスペースから削除されました。", - "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存して閉じる", - "xpack.spaces.management.shareToSpace.shareWarningBody": "1つのスペースでのみ編集するには、{makeACopyLink}してください。", - "xpack.spaces.management.shareToSpace.shareWarningLink": "コピーを作成", - "xpack.spaces.management.shareToSpace.shareWarningTitle": "共有オブジェクトの編集は、すべてのスペースで変更を適用します。", - "xpack.spaces.management.shareToSpace.showLessSpacesLink": "縮小表示", - "xpack.spaces.management.shareToSpace.showMoreSpacesLink": "他 {count} 件", - "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "追加権限", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "非表示のスペースを表示するには、{additionalPrivilegesLink}が必要です。", - "xpack.spaces.management.shareToSpaceFlyoutHeader": "スペースと共有", "xpack.spaces.management.showAllFeaturesText": "すべて表示", "xpack.spaces.management.spaceIdentifier.customizeSpaceLinkText": "[カスタマイズ]", "xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel": "URL 識別子をカスタマイズ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c7218ddaae2394..a35d4c67dde001 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13315,7 +13315,6 @@ "xpack.ml.jobsList.actionExecuteSuccessfullyNotificationMessage": "{successesJobsCount, plural, one{{successJob}} other{# 个作业}}{actionTextPT}已成功", "xpack.ml.jobsList.actionFailedNotificationMessage": "{failureId} 未能{actionText}", "xpack.ml.jobsList.actionsLabel": "操作", - "xpack.ml.jobsList.analyticsSpacesLabel": "工作区", "xpack.ml.jobsList.auditMessageColumn.screenReaderDescription": "过去 24 小时里该作业有错误或警告时,此列显示图标", "xpack.ml.jobsList.breadcrumb": "作业", "xpack.ml.jobsList.cannotSelectRowForJobMessage": "无法选择作业 ID {jobId}", @@ -13518,19 +13517,6 @@ "xpack.ml.management.jobsList.noPermissionToAccessLabel": "您需要访问 ML 作业的权限", "xpack.ml.management.jobsList.syncFlyoutButton": "同步已保存对象", "xpack.ml.management.jobsListTitle": "Machine Learning", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.text": "要更改此作业的工作区,您需要有在所有工作区中修改作业的权限。请与您的系统管理员联系,以获取更多信息。", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.title": "权限不足,无法编辑 {jobId} 的工作区", - "xpack.ml.management.spacesSelectorFlyout.closeButton": "关闭", - "xpack.ml.management.spacesSelectorFlyout.headerLabel": "为 {jobId} 选择工作区", - "xpack.ml.management.spacesSelectorFlyout.saveButton": "保存", - "xpack.ml.management.spacesSelectorFlyout.selectSpacesLabel": "选择工作区", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text": "使作业在所有当前和将来工作区中可用。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title": "所有工作区", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text": "使作业仅在选定工作区中可用。", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title": "选择工作区", - "xpack.ml.management.spacesSelectorFlyout.updateSpaces.error": "更新 {id} 时出错", "xpack.ml.management.syncSavedObjectsFlyout.closeButton": "关闭", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.description": "如果有已保存对象缺失异常检测作业的数据馈送 ID,则将添加该 ID。", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.title": "缺失数据馈送的已保存对象 ({count})", @@ -20653,44 +20639,6 @@ "xpack.spaces.management.manageSpacePage.updateSpaceButton": "更新工作区", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "保留的空间是内置的,只能进行部分修改。", "xpack.spaces.management.selectAllFeaturesLink": "全选", - "xpack.spaces.management.shareToSpace.actionDescription": "将此已保存对象共享到一个或多个工作区", - "xpack.spaces.management.shareToSpace.actionTitle": "共享到工作区", - "xpack.spaces.management.shareToSpace.allSpacesLabel": "* 所有工作区", - "xpack.spaces.management.shareToSpace.cancelButton": "取消", - "xpack.spaces.management.shareToSpace.columnDescription": "目前将此对象共享到的其他工作区", - "xpack.spaces.management.shareToSpace.columnTitle": "共享工作区", - "xpack.spaces.management.shareToSpace.columnUnauthorizedLabel": "您无权查看这些工作区。", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText": "创建新工作区", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text": "您可以{createANewSpaceLink},用于共享您的对象。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked": "您需要额外权限才能取消选择此工作区。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked": "您需要额外权限才能选择此工作区。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural": "“{object}”已添加到 {spaceTargets} 个工作区。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular": "“{object}”已添加到 1 个工作区。", - "xpack.spaces.management.shareToSpace.shareEditSuccessTitle": "对象已更新", - "xpack.spaces.management.shareToSpace.shareErrorTitle": "更新已保存对象时出错", - "xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount} 个已隐藏", - "xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount} 个已选择", - "xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel": "选择工作区", - "xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle": "共享选项", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text": "使对象在当前和将来的所有空间中可用。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title": "所有工作区", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "仅使对象在选定工作区中可用。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "选择工作区", - "xpack.spaces.management.shareToSpace.shareNewSuccessTitle": "对象现已共享", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural": "“{object}”已从 {spaceTargets} 个工作区中移除。", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular": "“{object}”已从 1 个工作区中移除。", - "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存并关闭", - "xpack.spaces.management.shareToSpace.shareWarningBody": "要仅在一个工作区中编辑,请改为{makeACopyLink}。", - "xpack.spaces.management.shareToSpace.shareWarningLink": "创建副本", - "xpack.spaces.management.shareToSpace.shareWarningTitle": "编辑共享对象会在所有工作区中应用更改", - "xpack.spaces.management.shareToSpace.showLessSpacesLink": "显示更少", - "xpack.spaces.management.shareToSpace.showMoreSpacesLink": "另外 {count} 个", - "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "加载可用工作区时出错", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "其他权限", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "要查看隐藏的工作区,您需要{additionalPrivilegesLink}。", - "xpack.spaces.management.shareToSpaceFlyoutHeader": "共享到工作区", "xpack.spaces.management.showAllFeaturesText": "全部显示", "xpack.spaces.management.spaceIdentifier.customizeSpaceLinkText": "[定制]", "xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel": "定制 URL 标识符", diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 32cae675dea746..5fac012d5e8b96 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -618,3 +618,37 @@ } } } + +{ + "type": "doc", + "value": { + "id": "sharecapabletype:only_default_space", + "index": ".kibana", + "source": { + "sharecapabletype": { + "title": "A share-capable (isolated) saved-object only in the default space" + }, + "type": "sharecapabletype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharecapabletype:only_space_1", + "index": ".kibana", + "source": { + "sharecapabletype": { + "title": "A share-capable (isolated) saved-object only in space_1" + }, + "type": "sharecapabletype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 561c2ecc56fa26..50c4fb305a6d0c 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -263,6 +263,19 @@ } } }, + "sharecapabletype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, "space": { "properties": { "_reserved": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts index d05a08eeeedd16..e29bbc0db56b6a 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts +++ b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts @@ -52,6 +52,13 @@ export class Plugin { management, mappings, }); + core.savedObjects.registerType({ + name: 'sharecapabletype', + hidden: false, + namespaceType: 'multiple-isolated', + management, + mappings, + }); core.savedObjects.registerType({ name: 'globaltype', hidden: false, diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts index c16d26d834b331..8506611f245608 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts @@ -53,6 +53,16 @@ export const SAVED_OBJECT_TEST_CASES: Record = Object.fr id: 'only_space_2', expectedNamespaces: [SPACE_2_ID], }), + MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE: Object.freeze({ + type: 'sharecapabletype', + id: 'only_default_space', + expectedNamespaces: [DEFAULT_SPACE_ID], + }), + MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1: Object.freeze({ + type: 'sharecapabletype', + id: 'only_space_1', + expectedNamespaces: [SPACE_1_ID], + }), NAMESPACE_AGNOSTIC: Object.freeze({ type: 'globaltype', id: 'globaltype-id', diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index 6dfe257f21c0b1..43e92cc21c469c 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -115,7 +115,7 @@ export const createRequest = ({ type, id }: TestCase) => ({ type, id }); const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); const isNamespaceAgnostic = (type: string) => type === 'globaltype'; -const isMultiNamespace = (type: string) => type === 'sharedtype'; +const isMultiNamespace = (type: string) => type === 'sharedtype' || type === 'sharecapabletype'; export const expectResponses = { forbiddenTypes: (action: string) => ( typeOrTypes: string | string[] diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index f46fdcf01367cc..94b75f1fd536d8 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -89,6 +89,27 @@ export const getTestCases = (spaceId?: string): { [key: string]: ExportTestCase .flat(), ], }, + ...(spaceId !== SPACE_2_ID && { + // we do not have a multi-namespace isolated object in Space 2 + multiNamespaceIsolatedObject: { + title: 'multi-namespace isolated object', + ...(spaceId === SPACE_1_ID + ? CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1 + : CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE), + }, + }), + multiNamespaceIsolatedType: { + title: 'multi-namespace isolated type', + type: 'sharecapabletype', + successResult: [ + ...(spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? [] + : [CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE] + ).flat(), + ], + }, namespaceAgnosticObject: { title: 'namespace-agnostic object', ...CASES.NAMESPACE_AGNOSTIC, diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index cdeb210dddffbc..27905459c29b77 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -107,6 +107,13 @@ export const getTestCases = ( savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype'), }, } as FindTestCase, + multiNamespaceIsolatedType: { + title: buildTitle('find multi-namespace isolated type'), + query: `type=sharecapabletype&fields=title${namespacesQueryParam}`, + successResult: { + savedObjects: getExpectedSavedObjects((t) => t.type === 'sharecapabletype'), + }, + } as FindTestCase, namespaceAgnosticType: { title: buildTitle('find namespace-agnostic type'), query: `type=globaltype&fields=title${namespacesQueryParam}`, diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts index 94c417eeeadd5b..80a4a805224bf5 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts @@ -30,6 +30,7 @@ export type ResolveTestSuite = TestSuite; export interface ResolveTestCase extends TestCase { expectedOutcome?: 'exactMatch' | 'aliasMatch' | 'conflict'; expectedId?: string; + expectedAliasTargetId?: string; } const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; @@ -48,6 +49,7 @@ export const TEST_CASES = Object.freeze({ expectedNamespaces: EACH_SPACE, expectedOutcome: 'aliasMatch' as 'aliasMatch', expectedId: 'alias-match-newid', + expectedAliasTargetId: 'alias-match-newid', }), CONFLICT: Object.freeze({ type: 'resolvetype', @@ -55,6 +57,7 @@ export const TEST_CASES = Object.freeze({ expectedNamespaces: EACH_SPACE, expectedOutcome: 'conflict' as 'conflict', // only in space 1, where the alias exists expectedId: 'conflict', + expectedAliasTargetId: 'conflict-newid', }), DISABLED: Object.freeze({ type: 'resolvetype', @@ -77,10 +80,15 @@ export function resolveTestSuiteFactory(esArchiver: any, supertest: SuperTest { ...fail409(!overwrite || spaceId !== SPACE_2_ID), ...unresolvableConflict(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts index 89a791b06dc5d9..d547b95d34f7ef 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts index 9cc6cbc967c323..b818a4b6bf33cf 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index eb221fc314ae30..7f5f0b453ff251 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -49,6 +49,14 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts index 13c6b418d30333..6a6fc8a15decfd 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -45,6 +45,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts index 788e8e92a9d437..774d7f98f1635d 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts @@ -19,11 +19,13 @@ const createTestCases = (spaceId: string) => { const exportableObjects = [ cases.singleNamespaceObject, cases.multiNamespaceObject, + cases.multiNamespaceIsolatedObject, cases.namespaceAgnosticObject, ]; const exportableTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, ]; const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index 78c38967f6e1d9..6d9c38ecca5962 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -27,6 +27,7 @@ const createTestCases = (currentSpace: string, crossSpaceSearch?: string[]) => { const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, cases.eachType, cases.pageBeyondTotal, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts index e493af65257c14..e61d5c10c2dbb3 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index c40d8c3140c6ea..659ee2c2e23635 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -75,6 +75,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict @@ -124,6 +134,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index 0ba8c171b3e259..3f213e519e57d0 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -72,6 +72,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict @@ -112,6 +122,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts index 5007497df5005a..44296597d52ea7 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index bacade65153b23..b8b57289212da4 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -33,6 +33,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(), ...unresolvableConflict() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts index b80eb7ed347e0f..18edb7502c65a0 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts index 9b3bc39c64d11c..59da44dcd8ec4a 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts @@ -33,6 +33,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index 3ffb9b2d6705aa..0aae9ebe7c9145 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -32,6 +32,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail409() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts index e176c254589148..7d9ec0b152174f 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts @@ -31,6 +31,8 @@ const createTestCases = () => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, force: true }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts index 5cd6ea9242e123..a1580c85a3680b 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts @@ -19,11 +19,13 @@ const createTestCases = () => { const exportableObjects = [ cases.singleNamespaceObject, cases.multiNamespaceObject, + cases.multiNamespaceIsolatedObject, cases.namespaceAgnosticObject, ]; const exportableTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, ]; const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts index 5a52402fcdf599..eb30024015fbb2 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -24,6 +24,7 @@ const createTestCases = (crossSpaceSearch?: string[]) => { const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, cases.eachType, cases.pageBeyondTotal, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts index 5f5417761dbd17..9910900c2f51bc 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts index 0cf5cdd98efa8c..b46e3fabff95b3 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -54,6 +54,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...destinationId() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...destinationId() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...destinationId() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict @@ -103,6 +105,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts index 7df930d5086644..1d20de4f620fe2 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts @@ -46,6 +46,7 @@ const createTestCases = (overwrite: boolean) => { const group2 = [ { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict @@ -85,6 +86,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts index bafc90c710ac31..c0ec36fcf75c4d 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index aa771d7c48dda0..6bb7828e12f238 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -55,6 +55,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite || spaceId !== SPACE_2_ID), ...unresolvableConflict(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts index 0f78983953bba6..e1d0243377b8e9 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts index 164ecdd2992749..30dc034715ed47 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts @@ -31,6 +31,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index ff192530b47cf1..39c97be1b6285e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -44,6 +44,14 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts index 1d38a50a96d191..1a168bac948bef 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts @@ -39,6 +39,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts index b34ee15174e996..374bf4f0c2577e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index ffe302883b43ac..b1f30657dd9c0e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -61,6 +61,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index dde99164bd38c1..35f5d3dabde88e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -65,6 +65,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts index 3940c815aa3532..bf5d635a11d8a7 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, From 5fb5be69f319d5ba7fa38fd4c92de8474ff1f273 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Sat, 13 Feb 2021 13:36:40 +0100 Subject: [PATCH 022/175] [Search Sessions] Enable Search Sessions (#91097) * enable search sessions * adjust tests * adjust tests * adjust tests * adjust tests * make order consistent Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/data_enhanced/config.ts | 2 +- .../public/search/sessions_mgmt/index.ts | 2 +- .../feature_controls/advanced_settings_security.ts | 2 +- .../api_keys/feature_controls/api_keys_security.ts | 4 ++-- .../feature_controls/ccr_security.ts | 4 ++-- .../discover/feature_controls/discover_security.ts | 6 +++++- .../feature_controls/ilm_security.ts | 4 ++-- .../feature_controls/index_management_security.ts | 4 ++-- .../feature_controls/index_patterns_security.ts | 2 +- .../feature_controls/ingest_pipelines_security.ts | 4 ++-- .../feature_controls/license_management_security.ts | 7 +++++-- .../logstash/feature_controls/logstash_security.ts | 4 ++-- .../feature_controls/management_security.ts | 11 +++++++++-- .../feature_controls/remote_clusters_security.ts | 7 +++++-- .../transform/feature_controls/transform_security.ts | 4 ++-- .../feature_controls/upgrade_assistant_security.ts | 6 +++--- 16 files changed, 45 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/data_enhanced/config.ts b/x-pack/plugins/data_enhanced/config.ts index a3eb3cffb85a76..fc1f22d50b09fa 100644 --- a/x-pack/plugins/data_enhanced/config.ts +++ b/x-pack/plugins/data_enhanced/config.ts @@ -13,7 +13,7 @@ export const configSchema = schema.object({ /** * Turns the feature on \ off (incl. removing indicator and management screens) */ - enabled: schema.boolean({ defaultValue: false }), + enabled: schema.boolean({ defaultValue: true }), /** * pageSize controls how many search session objects we load at once while monitoring * session completion diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts index e13cd06f52a4d7..e916eed6bcbc44 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts @@ -56,7 +56,7 @@ export function registerSearchSessionsMgmt( services.management.sections.section.kibana.registerApp({ id: APP.id, title: APP.getI18nName(), - order: 2, + order: 1.75, mount: async (params) => { const { SearchSessionsMgmtApp: MgmtApp } = await import('./application'); const mgmtApp = new MgmtApp(coreSetup, config, params, services); diff --git a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts index 4a3dc2b4343920..b93e20ffeed6eb 100644 --- a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts +++ b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts @@ -148,7 +148,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { kibana: [ { feature: { - discover: ['all'], + discover: ['read'], }, spaces: ['*'], }, diff --git a/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts b/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts index c3f3c563840cae..aed7fc63c7b1b8 100644 --- a/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts +++ b/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with manage_security', () => { + describe('global dashboard read with manage_security', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'manage_security'], true); + await security.testUser.setRoles(['global_dashboard_read', 'manage_security'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts b/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts index 9e2976c36b7b4b..421814f550e686 100644 --- a/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts +++ b/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with ccr_user', () => { + describe('global dashboard read with ccr_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'ccr_user'], true); + await security.testUser.setRoles(['global_dashboard_read', 'ccr_user'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index ca314cecbea988..d595dc98a9a1a5 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -84,7 +84,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows discover navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link) => link.text)).to.eql(['Overview', 'Discover']); + expect(navLinks.map((link) => link.text)).to.eql([ + 'Overview', + 'Discover', + 'Stack Management', // because `global_discover_all_role` enables search sessions + ]); }); it('shows save button', async () => { diff --git a/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts b/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts index e3fe9224905ae5..f71f7e827980cb 100644 --- a/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts +++ b/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with manage_ilm', () => { + describe('global dashboard read with manage_ilm', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'manage_ilm'], true); + await security.testUser.setRoles(['global_dashboard_read', 'manage_ilm'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts b/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts index bebac46ced78c3..4b453c519fa07f 100644 --- a/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts +++ b/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with index_management_user', () => { + describe('global dashboard read with index_management_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'index_management_user'], true); + await security.testUser.setRoles(['global_dashboard_read', 'index_management_user'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts index 825783eba37d01..8da6871842b152 100644 --- a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts +++ b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts @@ -149,7 +149,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { kibana: [ { feature: { - discover: ['all'], + discover: ['read'], }, spaces: ['*'], }, diff --git a/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts b/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts index c777f68dcdd0bc..6c22ccaa76245c 100644 --- a/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts +++ b/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with ingest_pipelines_user', () => { + describe('global dashboard read with ingest_pipelines_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'ingest_pipelines_user'], true); + await security.testUser.setRoles(['global_dashboard_read', 'ingest_pipelines_user'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts b/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts index ce0f3ba673205a..f46cb7100902e7 100644 --- a/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts +++ b/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts @@ -45,9 +45,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with license_management_user', () => { + describe('global dashboard read with license_management_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'license_management_user'], true); + await security.testUser.setRoles( + ['global_dashboard_read', 'license_management_user'], + true + ); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts b/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts index 347679f92a9ec6..587b62547ad8b4 100644 --- a/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts +++ b/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with logstash_read_user', () => { + describe('global dashboard read with logstash_read_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'logstash_read_user'], true); + await security.testUser.setRoles(['global_dashboard_read', 'logstash_read_user'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/management/feature_controls/management_security.ts b/x-pack/test/functional/apps/management/feature_controls/management_security.ts index 57851bc85d8670..7d121e91007499 100644 --- a/x-pack/test/functional/apps/management/feature_controls/management_security.ts +++ b/x-pack/test/functional/apps/management/feature_controls/management_security.ts @@ -28,7 +28,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('no management privileges', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all'], true); + await security.testUser.setRoles(['global_dashboard_read'], true); }); after(async () => { await security.testUser.restoreDefaults(); @@ -68,7 +68,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); expect(sections[1]).to.eql({ sectionId: 'kibana', - sectionLinks: ['indexPatterns', 'objects', 'tags', 'spaces', 'settings'], + sectionLinks: [ + 'indexPatterns', + 'objects', + 'tags', + 'search_sessions', + 'spaces', + 'settings', + ], }); }); }); diff --git a/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts b/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts index 8ff9699f9a21ae..0406419098168d 100644 --- a/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts +++ b/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts @@ -45,9 +45,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with license_management_user', () => { + describe('global dashboard read with license_management_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'license_management_user'], true); + await security.testUser.setRoles( + ['global_dashboard_read', 'license_management_user'], + true + ); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts index 46e0c01afcc38c..04c94e0a3e381d 100644 --- a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts +++ b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts @@ -46,9 +46,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with transform_user', () => { + describe('global dashboard read with transform_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'transform_user'], true); + await security.testUser.setRoles(['global_dashboard_read', 'transform_user'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts index 3308cf4cc562d9..e30ac06abc7ca0 100644 --- a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts +++ b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts @@ -45,10 +45,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with global_upgrade_assistant_role', () => { + describe('global dashboard read with global_upgrade_assistant_role', () => { before(async () => { await security.testUser.setRoles( - ['global_dashboard_all', 'global_upgrade_assistant_role'], + ['global_dashboard_read', 'global_upgrade_assistant_role'], true ); }); @@ -60,7 +60,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(links.map((link) => link.text)).to.contain('Stack Management'); }); - describe('[SkipCloud] global dashboard all with global_upgrade_assistant_role', function () { + describe('[SkipCloud] global dashboard read with global_upgrade_assistant_role', function () { this.tags('skipCloud'); it('should render the "Stack" section with Upgrde Assistant', async function () { await PageObjects.common.navigateToApp('management'); From 1d11f026818ce142d94c184ca017d18f5af2ed2a Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Sat, 13 Feb 2021 18:44:33 +0000 Subject: [PATCH 023/175] skip flaky suite (#90416) --- .../apis/security_solution/uncommon_processes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts b/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts index 3d76513b8379dd..f79885246b0acf 100644 --- a/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts +++ b/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts @@ -20,7 +20,8 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - describe('uncommon_processes', () => { + // FLAKY: https://github.com/elastic/kibana/issues/90416 + describe.skip('uncommon_processes', () => { before(() => esArchiver.load('auditbeat/hosts')); after(() => esArchiver.unload('auditbeat/hosts')); From 3dbcc2713eb9efe971ee17fa8520176e064312e6 Mon Sep 17 00:00:00 2001 From: spalger Date: Sat, 13 Feb 2021 13:26:29 -0700 Subject: [PATCH 024/175] [dev/ts-refs] don't use cache when building a new cache or when using --clean --- src/dev/typescript/build_ts_refs_cli.ts | 22 +++++++++++-------- .../typescript/ref_output_cache/repo_info.ts | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/dev/typescript/build_ts_refs_cli.ts b/src/dev/typescript/build_ts_refs_cli.ts index 0c4f312126762f..fc8911a2517733 100644 --- a/src/dev/typescript/build_ts_refs_cli.ts +++ b/src/dev/typescript/build_ts_refs_cli.ts @@ -23,13 +23,18 @@ export async function runBuildRefsCli() { async ({ log, flags }) => { const outDirs = getOutputsDeep(REF_CONFIG_PATHS); - if (flags.clean) { + const cacheEnabled = process.env.BUILD_TS_REFS_CACHE_ENABLE === 'true' || !!flags.cache; + const doCapture = process.env.BUILD_TS_REFS_CACHE_CAPTURE === 'true'; + const doClean = !!flags.clean || doCapture; + const doInitCache = cacheEnabled && !doClean; + + if (doClean) { log.info('deleting', outDirs.length, 'ts output directories'); await concurrentMap(100, outDirs, (outDir) => del(outDir)); } let outputCache; - if (flags.cache) { + if (cacheEnabled) { outputCache = await RefOutputCache.create({ log, outDirs, @@ -37,17 +42,19 @@ export async function runBuildRefsCli() { workingDir: CACHE_WORKING_DIR, upstreamUrl: 'https://github.com/elastic/kibana.git', }); + } + if (outputCache && doInitCache) { await outputCache.initCaches(); } await buildAllTsRefs(log); - if (outputCache) { - if (process.env.BUILD_TS_REFS_CACHE_CAPTURE === 'true') { - await outputCache.captureCache(Path.resolve(REPO_ROOT, 'target/ts_refs_cache')); - } + if (outputCache && doCapture) { + await outputCache.captureCache(Path.resolve(REPO_ROOT, 'target/ts_refs_cache')); + } + if (outputCache) { await outputCache.cleanup(); } }, @@ -55,9 +62,6 @@ export async function runBuildRefsCli() { description: 'Build TypeScript projects', flags: { boolean: ['clean', 'cache'], - default: { - cache: process.env.BUILD_TS_REFS_CACHE_ENABLE === 'true' ? true : false, - }, }, log: { defaultLevel: 'debug', diff --git a/src/dev/typescript/ref_output_cache/repo_info.ts b/src/dev/typescript/ref_output_cache/repo_info.ts index 5ca792332bafa9..9a51f3f75182b1 100644 --- a/src/dev/typescript/ref_output_cache/repo_info.ts +++ b/src/dev/typescript/ref_output_cache/repo_info.ts @@ -31,7 +31,7 @@ export class RepoInfo { this.log.info('determining merge base with upstream'); - const mergeBase = this.git(['merge-base', ref, 'FETCH_HEAD']); + const mergeBase = await this.git(['merge-base', ref, 'FETCH_HEAD']); this.log.info('merge base with', upstreamBranch, 'is', mergeBase); return mergeBase; From c1d1b2b453465a2d788110b7847e15c31be95b28 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Sun, 14 Feb 2021 19:05:36 +0200 Subject: [PATCH 025/175] [Saved Objects] Allow exporting and importing hidden types (#90178) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...-core-server.requesthandlercontext.core.md | 5 +- ...lugin-core-server.requesthandlercontext.md | 2 +- src/core/server/core_route_handler_context.ts | 28 +- src/core/server/index.ts | 6 +- src/core/server/mocks.ts | 5 +- .../server/saved_objects/routes/delete.ts | 4 +- .../server/saved_objects/routes/export.ts | 13 +- .../server/saved_objects/routes/import.ts | 11 +- .../routes/integration_tests/delete.test.ts | 3 +- .../routes/integration_tests/export.test.ts | 7 +- .../routes/integration_tests/import.test.ts | 6 +- .../resolve_import_errors.test.ts | 9 +- .../routes/resolve_import_errors.ts | 14 +- src/core/server/server.api.md | 5 +- .../services/sample_data/routes/install.ts | 14 +- .../services/sample_data/routes/uninstall.ts | 8 +- .../components/import_summary.tsx | 5 +- .../server/routes/find.ts | 21 +- .../server/routes/get.ts | 8 +- .../server/routes/relationships.ts | 20 +- .../server/routes/scroll_count.ts | 24 +- .../server/routes/scroll_export.ts | 15 +- .../hidden_saved_objects/data.json | 29 + .../hidden_saved_objects/mappings.json | 513 ++++++++++++++++++ .../management/saved_objects_page.ts | 12 + test/plugin_functional/config.ts | 1 + .../saved_objects_hidden_type/kibana.json | 8 + .../saved_objects_hidden_type/package.json | 14 + .../saved_objects_hidden_type/server/index.ts | 11 + .../server/plugin.ts | 46 ++ .../saved_objects_hidden_type/tsconfig.json | 16 + .../saved_objects_hidden_type/delete.ts | 60 ++ .../saved_objects_hidden_type/export.ts | 63 +++ .../saved_objects_hidden_type/find.ts | 56 ++ .../saved_objects_hidden_type/import.ts | 88 +++ .../saved_objects_hidden_type/index.ts | 20 + .../exports/_import_hidden_importable.ndjson | 1 + .../_import_hidden_non_importable.ndjson | 1 + .../interface/saved_objects_management.ts | 55 ++ .../resolve_import_errors.ts | 112 ++++ .../saved_objects_management/find.ts | 77 +++ .../saved_objects_management/get.ts | 53 ++ .../saved_objects_management/index.ts | 3 + .../saved_objects_management/scroll_count.ts | 49 ++ 44 files changed, 1452 insertions(+), 69 deletions(-) create mode 100644 test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/data.json create mode 100644 test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json create mode 100644 test/plugin_functional/plugins/saved_objects_hidden_type/kibana.json create mode 100644 test/plugin_functional/plugins/saved_objects_hidden_type/package.json create mode 100644 test/plugin_functional/plugins/saved_objects_hidden_type/server/index.ts create mode 100644 test/plugin_functional/plugins/saved_objects_hidden_type/server/plugin.ts create mode 100644 test/plugin_functional/plugins/saved_objects_hidden_type/tsconfig.json create mode 100644 test/plugin_functional/test_suites/saved_objects_hidden_type/delete.ts create mode 100644 test/plugin_functional/test_suites/saved_objects_hidden_type/export.ts create mode 100644 test/plugin_functional/test_suites/saved_objects_hidden_type/find.ts create mode 100644 test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts create mode 100644 test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts create mode 100644 test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson create mode 100644 test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson create mode 100644 test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts create mode 100644 test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts create mode 100644 test/plugin_functional/test_suites/saved_objects_management/find.ts create mode 100644 test/plugin_functional/test_suites/saved_objects_management/get.ts create mode 100644 test/plugin_functional/test_suites/saved_objects_management/scroll_count.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md index 3a5e84ffdc3724..268dcdd77d6b47 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md @@ -11,8 +11,9 @@ core: { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md index 5300c85cf94064..54d85910f823c1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md @@ -18,5 +18,5 @@ export interface RequestHandlerContext | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exporter: ISavedObjectsExporter;
importer: ISavedObjectsImporter;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
} | | +| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract;
getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter;
getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
} | | diff --git a/src/core/server/core_route_handler_context.ts b/src/core/server/core_route_handler_context.ts index f3e06ad8f1daa2..f5123a91e71003 100644 --- a/src/core/server/core_route_handler_context.ts +++ b/src/core/server/core_route_handler_context.ts @@ -13,8 +13,7 @@ import { SavedObjectsClientContract } from './saved_objects/types'; import { InternalSavedObjectsServiceStart, ISavedObjectTypeRegistry, - ISavedObjectsExporter, - ISavedObjectsImporter, + SavedObjectsClientProviderOptions, } from './saved_objects'; import { InternalElasticsearchServiceStart, @@ -58,8 +57,6 @@ class CoreSavedObjectsRouteHandlerContext { ) {} #scopedSavedObjectsClient?: SavedObjectsClientContract; #typeRegistry?: ISavedObjectTypeRegistry; - #exporter?: ISavedObjectsExporter; - #importer?: ISavedObjectsImporter; public get client() { if (this.#scopedSavedObjectsClient == null) { @@ -75,19 +72,18 @@ class CoreSavedObjectsRouteHandlerContext { return this.#typeRegistry; } - public get exporter() { - if (this.#exporter == null) { - this.#exporter = this.savedObjectsStart.createExporter(this.client); - } - return this.#exporter; - } + public getClient = (options?: SavedObjectsClientProviderOptions) => { + if (!options) return this.client; + return this.savedObjectsStart.getScopedClient(this.request, options); + }; - public get importer() { - if (this.#importer == null) { - this.#importer = this.savedObjectsStart.createImporter(this.client); - } - return this.#importer; - } + public getExporter = (client: SavedObjectsClientContract) => { + return this.savedObjectsStart.createExporter(client); + }; + + public getImporter = (client: SavedObjectsClientContract) => { + return this.savedObjectsStart.createImporter(client); + }; } class CoreUiSettingsRouteHandlerContext { diff --git a/src/core/server/index.ts b/src/core/server/index.ts index dac2d210eb395f..8e4cdc7d59e322 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -49,6 +49,7 @@ import { SavedObjectsServiceStart, ISavedObjectsExporter, ISavedObjectsImporter, + SavedObjectsClientProviderOptions, } from './saved_objects'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; @@ -415,8 +416,9 @@ export interface RequestHandlerContext { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index a4ce94b1776128..19056ae1b9bc76 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -196,8 +196,9 @@ function createCoreRequestHandlerContextMock() { savedObjects: { client: savedObjectsClientMock.create(), typeRegistry: savedObjectsTypeRegistryMock.create(), - exporter: savedObjectsServiceMock.createExporter(), - importer: savedObjectsServiceMock.createImporter(), + getClient: savedObjectsClientMock.create, + getExporter: savedObjectsServiceMock.createExporter, + getImporter: savedObjectsServiceMock.createImporter, }, elasticsearch: { client: elasticsearchServiceMock.createScopedClusterClient(), diff --git a/src/core/server/saved_objects/routes/delete.ts b/src/core/server/saved_objects/routes/delete.ts index 609ce2692c7770..fe08acf23fd238 100644 --- a/src/core/server/saved_objects/routes/delete.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -32,11 +32,13 @@ export const registerDeleteRoute = (router: IRouter, { coreUsageData }: RouteDep catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { force } = req.query; + const { getClient } = context.core.savedObjects; const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsDelete({ request: req }).catch(() => {}); - const result = await context.core.savedObjects.client.delete(type, id, { force }); + const client = getClient(); + const result = await client.delete(type, id, { force }); return res.ok({ body: result }); }) ); diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index fa5517303f18f2..e0293a4522fc14 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -165,9 +165,9 @@ export const registerExportRoute = ( }, catchAndReturnBoomErrors(async (context, req, res) => { const cleaned = cleanOptions(req.body); - const supportedTypes = context.core.savedObjects.typeRegistry - .getImportableAndExportableTypes() - .map((t) => t.name); + const { typeRegistry, getExporter, getClient } = context.core.savedObjects; + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((t) => t.name); + let options: EitherExportOptions; try { options = validateOptions(cleaned, { @@ -181,7 +181,12 @@ export const registerExportRoute = ( }); } - const exporter = context.core.savedObjects.exporter; + const includedHiddenTypes = supportedTypes.filter((supportedType) => + typeRegistry.isHidden(supportedType) + ); + + const client = getClient({ includedHiddenTypes }); + const exporter = getExporter(client); const usageStatsClient = coreUsageData.getClient(); usageStatsClient diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index e84c638d3ec999..6f75bcf9fd5bf2 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -63,6 +63,7 @@ export const registerImportRoute = ( }, catchAndReturnBoomErrors(async (context, req, res) => { const { overwrite, createNewCopies } = req.query; + const { getClient, getImporter, typeRegistry } = context.core.savedObjects; const usageStatsClient = coreUsageData.getClient(); usageStatsClient @@ -84,7 +85,15 @@ export const registerImportRoute = ( }); } - const { importer } = context.core.savedObjects; + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((t) => t.name); + + const includedHiddenTypes = supportedTypes.filter((supportedType) => + typeRegistry.isHidden(supportedType) + ); + + const client = getClient({ includedHiddenTypes }); + const importer = getImporter(client); + try { const result = await importer.import({ readStream, diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts index 7b7a71b7ca858e..eaec6e16cbd8ca 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -26,7 +26,8 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => { beforeEach(async () => { ({ server, httpSetup, handlerContext } = await setupServer()); - savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient = handlerContext.savedObjects.getClient(); + handlerContext.savedObjects.getClient = jest.fn().mockImplementation(() => savedObjectsClient); const router = httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index 40f13064b53f08..09d475f29f3629 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -40,9 +40,13 @@ describe('POST /api/saved_objects/_export', () => { handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) ); - exporter = handlerContext.savedObjects.exporter; + exporter = handlerContext.savedObjects.getExporter(); const router = httpSetup.createRouter('/api/saved_objects/'); + handlerContext.savedObjects.getExporter = jest + .fn() + .mockImplementation(() => exporter as ReturnType); + coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsExport.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); @@ -77,6 +81,7 @@ describe('POST /api/saved_objects/_export', () => { ], }, ]; + exporter.exportByTypes.mockResolvedValueOnce(createListStream(sortedObjects)); const result = await supertest(httpSetup.server.listener) diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 24122c61c9f42d..be4d2160a967be 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -68,9 +68,9 @@ describe(`POST ${URL}`, () => { typeRegistry: handlerContext.savedObjects.typeRegistry, importSizeLimit: 10000, }); - handlerContext.savedObjects.importer.import.mockImplementation((options) => - importer.import(options) - ); + handlerContext.savedObjects.getImporter = jest + .fn() + .mockImplementation(() => importer as jest.Mocked); const router = httpSetup.createRouter('/internal/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index b23211aef092fc..d84b56156b5434 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -66,7 +66,7 @@ describe(`POST ${URL}`, () => { } as any) ); - savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient = handlerContext.savedObjects.getClient(); savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const importer = new SavedObjectsImporter({ @@ -74,9 +74,10 @@ describe(`POST ${URL}`, () => { typeRegistry: handlerContext.savedObjects.typeRegistry, importSizeLimit: 10000, }); - handlerContext.savedObjects.importer.resolveImportErrors.mockImplementation((options) => - importer.resolveImportErrors(options) - ); + + handlerContext.savedObjects.getImporter = jest + .fn() + .mockImplementation(() => importer as jest.Mocked); const router = httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 2a664328d4df29..a05c7d30b91fdc 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -9,6 +9,7 @@ import { extname } from 'path'; import { Readable } from 'stream'; import { schema } from '@kbn/config-schema'; +import { chain } from 'lodash'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; @@ -91,7 +92,18 @@ export const registerResolveImportErrorsRoute = ( }); } - const { importer } = context.core.savedObjects; + const { getClient, getImporter, typeRegistry } = context.core.savedObjects; + + const includedHiddenTypes = chain(req.body.retries) + .map('type') + .uniq() + .filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); + const importer = getImporter(client); try { const result = await importer.resolveImportErrors({ diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 34df3bcf853248..377cd2bc2068a9 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1924,8 +1924,9 @@ export interface RequestHandlerContext { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; diff --git a/src/plugins/home/server/services/sample_data/routes/install.ts b/src/plugins/home/server/services/sample_data/routes/install.ts index d32a854f2bc4b2..7c00a46602e26e 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.ts @@ -143,7 +143,15 @@ export function createInstallRoute( let createResults; try { - createResults = await context.core.savedObjects.client.bulkCreate( + const { getClient, typeRegistry } = context.core.savedObjects; + + const includedHiddenTypes = sampleDataset.savedObjects + .map((object) => object.type) + .filter((supportedType) => typeRegistry.isHidden(supportedType)); + + const client = getClient({ includedHiddenTypes }); + + createResults = await client.bulkCreate( sampleDataset.savedObjects.map(({ version, ...savedObject }) => savedObject), { overwrite: true } ); @@ -156,8 +164,8 @@ export function createInstallRoute( return Boolean(savedObjectCreateResult.error); }); if (errors.length > 0) { - const errMsg = `sample_data install errors while loading saved objects. Errors: ${errors.join( - ',' + const errMsg = `sample_data install errors while loading saved objects. Errors: ${JSON.stringify( + errors )}`; logger.warn(errMsg); return res.customError({ body: errMsg, statusCode: 403 }); diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.ts index 54e6fa0936abcf..aa8ed67cf840a2 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.ts @@ -33,7 +33,7 @@ export function createUninstallRoute( client: { callAsCurrentUser }, }, }, - savedObjects: { client: savedObjectsClient }, + savedObjects: { getClient: getSavedObjectsClient, typeRegistry }, }, }, request, @@ -61,6 +61,12 @@ export function createUninstallRoute( } } + const includedHiddenTypes = sampleDataset.savedObjects + .map((object) => object.type) + .filter((supportedType) => typeRegistry.isHidden(supportedType)); + + const savedObjectsClient = getSavedObjectsClient({ includedHiddenTypes }); + const deletePromises = sampleDataset.savedObjects.map(({ type, id }) => savedObjectsClient.delete(type, id) ); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx index b1570bb1fff0d9..8b07351f6c2c20 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx @@ -123,7 +123,10 @@ const CountIndicators: FC<{ importItems: ImportItem[] }> = ({ importItems }) => {errorCount && ( -

+

{ + const { query } = req; const managementService = await managementServicePromise; - const { client } = context.core.savedObjects; - const searchTypes = Array.isArray(req.query.type) ? req.query.type : [req.query.type]; - const includedFields = Array.isArray(req.query.fields) - ? req.query.fields - : [req.query.fields]; + const { getClient, typeRegistry } = context.core.savedObjects; + + const searchTypes = Array.isArray(query.type) ? query.type : [query.type]; + const includedFields = Array.isArray(query.fields) ? query.fields : [query.fields]; + const importAndExportableTypes = searchTypes.filter((type) => - managementService.isImportAndExportable(type) + typeRegistry.isImportableAndExportable(type) ); + const includedHiddenTypes = importAndExportableTypes.filter((type) => + typeRegistry.isHidden(type) + ); + + const client = getClient({ includedHiddenTypes }); const searchFields = new Set(); + importAndExportableTypes.forEach((type) => { const searchField = managementService.getDefaultSearchField(type); if (searchField) { @@ -64,7 +71,7 @@ export const registerFindRoute = ( }); const findResponse = await client.find({ - ...req.query, + ...query, fields: undefined, searchFields: [...searchFields], }); diff --git a/src/plugins/saved_objects_management/server/routes/get.ts b/src/plugins/saved_objects_management/server/routes/get.ts index 1e0115db9e43c0..5a48f2f2affa78 100644 --- a/src/plugins/saved_objects_management/server/routes/get.ts +++ b/src/plugins/saved_objects_management/server/routes/get.ts @@ -26,10 +26,14 @@ export const registerGetRoute = ( }, }, router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; const managementService = await managementServicePromise; - const { client } = context.core.savedObjects; + const { getClient, typeRegistry } = context.core.savedObjects; + const includedHiddenTypes = [type].filter( + (entry) => typeRegistry.isHidden(entry) && typeRegistry.isImportableAndExportable(entry) + ); - const { type, id } = req.params; + const client = getClient({ includedHiddenTypes }); const findResponse = await client.get(type, id); const enhancedSavedObject = injectMetaAttributes(findResponse, managementService); diff --git a/src/plugins/saved_objects_management/server/routes/relationships.ts b/src/plugins/saved_objects_management/server/routes/relationships.ts index 5e30f49cde67fb..9e2c2031d8abda 100644 --- a/src/plugins/saved_objects_management/server/routes/relationships.ts +++ b/src/plugins/saved_objects_management/server/routes/relationships.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'src/core/server'; +import { chain } from 'lodash'; import { findRelationships } from '../lib'; import { ISavedObjectsManagement } from '../services'; @@ -31,12 +32,21 @@ export const registerRelationshipsRoute = ( }, router.handleLegacyErrors(async (context, req, res) => { const managementService = await managementServicePromise; - const { client } = context.core.savedObjects; + const { getClient, typeRegistry } = context.core.savedObjects; const { type, id } = req.params; - const { size } = req.query; - const savedObjectTypes = Array.isArray(req.query.savedObjectTypes) - ? req.query.savedObjectTypes - : [req.query.savedObjectTypes]; + const { size, savedObjectTypes: maybeArraySavedObjectTypes } = req.query; + const savedObjectTypes = Array.isArray(maybeArraySavedObjectTypes) + ? maybeArraySavedObjectTypes + : [maybeArraySavedObjectTypes]; + + const includedHiddenTypes = chain(maybeArraySavedObjectTypes) + .uniq() + .filter( + (entry) => typeRegistry.isHidden(entry) && typeRegistry.isImportableAndExportable(entry) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); const findRelationsResponse = await findRelationships({ type, diff --git a/src/plugins/saved_objects_management/server/routes/scroll_count.ts b/src/plugins/saved_objects_management/server/routes/scroll_count.ts index dfe361e7b96499..89a895adf60084 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_count.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_count.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter, SavedObjectsFindOptions } from 'src/core/server'; +import { chain } from 'lodash'; import { findAll } from '../lib'; export const registerScrollForCountRoute = (router: IRouter) => { @@ -30,18 +31,27 @@ export const registerScrollForCountRoute = (router: IRouter) => { }, }, router.handleLegacyErrors(async (context, req, res) => { - const { client } = context.core.savedObjects; + const { getClient, typeRegistry } = context.core.savedObjects; + const { typesToInclude, searchString, references } = req.body; + const includedHiddenTypes = chain(typesToInclude) + .uniq() + .filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); const findOptions: SavedObjectsFindOptions = { - type: req.body.typesToInclude, + type: typesToInclude, perPage: 1000, }; - if (req.body.searchString) { - findOptions.search = `${req.body.searchString}*`; + if (searchString) { + findOptions.search = `${searchString}*`; findOptions.searchFields = ['title']; } - if (req.body.references) { - findOptions.hasReference = req.body.references; + if (references) { + findOptions.hasReference = references; findOptions.hasReferenceOperator = 'OR'; } @@ -54,7 +64,7 @@ export const registerScrollForCountRoute = (router: IRouter) => { return accum; }, {} as Record); - for (const type of req.body.typesToInclude) { + for (const type of typesToInclude) { if (!counts[type]) { counts[type] = 0; } diff --git a/src/plugins/saved_objects_management/server/routes/scroll_export.ts b/src/plugins/saved_objects_management/server/routes/scroll_export.ts index 3417efa709e5f5..8d11437af661b9 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_export.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_export.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'src/core/server'; +import { chain } from 'lodash'; import { findAll } from '../lib'; export const registerScrollForExportRoute = (router: IRouter) => { @@ -21,10 +22,20 @@ export const registerScrollForExportRoute = (router: IRouter) => { }, }, router.handleLegacyErrors(async (context, req, res) => { - const { client } = context.core.savedObjects; + const { typesToInclude } = req.body; + const { getClient, typeRegistry } = context.core.savedObjects; + const includedHiddenTypes = chain(typesToInclude) + .uniq() + .filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); + const objects = await findAll(client, { perPage: 1000, - type: req.body.typesToInclude, + type: typesToInclude, }); return res.ok({ diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/data.json new file mode 100644 index 00000000000000..6a272dc16e462f --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/data.json @@ -0,0 +1,29 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "test-hidden-importable-exportable:ff3733a0-9fty-11e7-ahb3-3dcb94193fab", + "source": { + "type": "test-hidden-importable-exportable", + "updated_at": "2021-02-11T18:51:23.794Z", + "test-hidden-importable-exportable": { + "title": "Hidden Saved object type that is importable/exportable." + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "test-hidden-non-importable-exportable:op3767a1-9rcg-53u7-jkb3-3dnb74193awc", + "source": { + "type": "test-hidden-non-importable-exportable", + "updated_at": "2021-02-11T18:51:23.794Z", + "test-hidden-non-importable-exportable": { + "title": "Hidden Saved object type that is not importable/exportable." + } + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json new file mode 100644 index 00000000000000..00d349a27795d7 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json @@ -0,0 +1,513 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "test-export-transform": { + "properties": { + "title": { "type": "text" }, + "enabled": { "type": "boolean" } + } + }, + "test-export-add": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-add-dep": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-transform-error": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-invalid-transform": { + "properties": { + "title": { "type": "text" } + } + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape", + "tree": "quadtree" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "test-hidden-non-importable-exportable": { + "properties": { + "title": { + "type": "text" + } + } + }, + "test-hidden-importable-exportable": { + "properties": { + "title": { + "type": "text" + } + } + } + } + } + } +} diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index 971bc2d48d22d3..c28d351aa77fbb 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -315,6 +315,18 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv }) ); } + + async getImportErrorsCount() { + log.debug(`Toggling overwriteAll`); + const errorCountNode = await testSubjects.find('importSavedObjectsErrorsCount'); + const errorCountText = await errorCountNode.getVisibleText(); + const match = errorCountText.match(/(\d)+/); + if (!match) { + throw Error(`unable to parse error count from text ${errorCountText}`); + } + + return +match[1]; + } } return new SavedObjectsPage(); diff --git a/test/plugin_functional/config.ts b/test/plugin_functional/config.ts index bd5ef814ae6c0a..fc747fcd71f17b 100644 --- a/test/plugin_functional/config.ts +++ b/test/plugin_functional/config.ts @@ -30,6 +30,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./test_suites/application_links'), require.resolve('./test_suites/data_plugin'), require.resolve('./test_suites/saved_objects_management'), + require.resolve('./test_suites/saved_objects_hidden_type'), ], services: { ...functionalConfig.get('services'), diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/kibana.json b/test/plugin_functional/plugins/saved_objects_hidden_type/kibana.json new file mode 100644 index 00000000000000..baef662c695d49 --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "savedObjectsHiddenType", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["saved_objects_hidden_type"], + "server": true, + "ui": false +} diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/package.json b/test/plugin_functional/plugins/saved_objects_hidden_type/package.json new file mode 100644 index 00000000000000..af5212209d574a --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/package.json @@ -0,0 +1,14 @@ +{ + "name": "saved_objects_hidden_type", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/saved_objects_hidden_type", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/server/index.ts b/test/plugin_functional/plugins/saved_objects_hidden_type/server/index.ts new file mode 100644 index 00000000000000..2093b6e8449a46 --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { SavedObjectsHiddenTypePlugin } from './plugin'; + +export const plugin = () => new SavedObjectsHiddenTypePlugin(); diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/server/plugin.ts b/test/plugin_functional/plugins/saved_objects_hidden_type/server/plugin.ts new file mode 100644 index 00000000000000..da2a0a2def1c24 --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/server/plugin.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { Plugin, CoreSetup } from 'kibana/server'; + +export class SavedObjectsHiddenTypePlugin implements Plugin { + public setup({ savedObjects }: CoreSetup, deps: {}) { + // example of a SO type that is hidden and importableAndExportable + savedObjects.registerType({ + name: 'test-hidden-importable-exportable', + hidden: true, + namespaceType: 'single', + mappings: { + properties: { + title: { type: 'text' }, + }, + }, + management: { + importableAndExportable: true, + }, + }); + + // example of a SO type that is hidden and not importableAndExportable + savedObjects.registerType({ + name: 'test-hidden-non-importable-exportable', + hidden: true, + namespaceType: 'single', + mappings: { + properties: { + title: { type: 'text' }, + }, + }, + management: { + importableAndExportable: false, + }, + }); + } + + public start() {} + public stop() {} +} diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/tsconfig.json b/test/plugin_functional/plugins/saved_objects_hidden_type/tsconfig.json new file mode 100644 index 00000000000000..da457c9ba32fcc --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "server/**/*.ts", + "../../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/delete.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/delete.ts new file mode 100644 index 00000000000000..666afe1acedca6 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/delete.ts @@ -0,0 +1,60 @@ +/* + * 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('delete', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('should return generic 404 when trying to delete a doc with importableAndExportable types', async () => + await supertest + .delete( + `/api/saved_objects/test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab` + ) + .set('kbn-xsrf', 'true') + .expect(404) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: + 'Saved object [test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab] not found', + }); + })); + + it('returns empty response for non importableAndExportable types', async () => + await supertest + .delete( + `/api/saved_objects/test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc` + ) + .set('kbn-xsrf', 'true') + .expect(404) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: + 'Saved object [test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc] not found', + }); + })); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/export.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/export.ts new file mode 100644 index 00000000000000..af25835db5a81a --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/export.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +function ndjsonToObject(input: string): string[] { + return input.split('\n').map((str) => JSON.parse(str)); +} + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('export', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('exports objects with importableAndExportable types', async () => + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-hidden-importable-exportable'], + }) + .expect(200) + .then((resp) => { + const objects = ndjsonToObject(resp.text); + expect(objects).to.have.length(2); + expect(objects[0]).to.have.property('id', 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab'); + expect(objects[0]).to.have.property('type', 'test-hidden-importable-exportable'); + })); + + it('excludes objects with non importableAndExportable types', async () => + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-hidden-non-importable-exportable'], + }) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'Trying to export non-exportable type(s): test-hidden-non-importable-exportable', + }); + })); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/find.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/find.ts new file mode 100644 index 00000000000000..723140f5c6bf5a --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/find.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('returns empty response for importableAndExportable types', async () => + await supertest + .get('/api/saved_objects/_find?type=test-hidden-importable-exportable&fields=title') + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + + it('returns empty response for non importableAndExportable types', async () => + await supertest + .get('/api/saved_objects/_find?type=test-hidden-non-importable-exportable&fields=title') + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts new file mode 100644 index 00000000000000..5de7d8375dd8ce --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/import.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 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('import', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('imports objects with importableAndExportable type', async () => { + const fileBuffer = Buffer.from( + '{"id":"some-id-1","type":"test-hidden-importable-exportable","attributes":{"title":"my title"},"references":[]}', + 'utf8' + ); + await supertest + .post('/api/saved_objects/_import') + .set('kbn-xsrf', 'true') + .attach('file', fileBuffer, 'export.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 1, + success: true, + warnings: [], + successResults: [ + { + type: 'test-hidden-importable-exportable', + id: 'some-id-1', + meta: { + title: 'my title', + }, + }, + ], + }); + }); + }); + + it('does not import objects with non importableAndExportable type', async () => { + const fileBuffer = Buffer.from( + '{"id":"some-id-1","type":"test-hidden-non-importable-exportable","attributes":{"title":"my title"},"references":[]}', + 'utf8' + ); + await supertest + .post('/api/saved_objects/_import') + .set('kbn-xsrf', 'true') + .attach('file', fileBuffer, 'export.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 0, + success: false, + warnings: [], + errors: [ + { + id: 'some-id-1', + type: 'test-hidden-non-importable-exportable', + title: 'my title', + meta: { + title: 'my title', + }, + error: { + type: 'unsupported_type', + }, + }, + ], + }); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts new file mode 100644 index 00000000000000..00ba74a988cf4c --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ loadTestFile }: PluginFunctionalProviderContext) { + describe('Saved objects with hidden type', function () { + loadTestFile(require.resolve('./import')); + loadTestFile(require.resolve('./export')); + loadTestFile(require.resolve('./resolve_import_errors')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./interface/saved_objects_management')); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson new file mode 100644 index 00000000000000..a74585c07b8687 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson @@ -0,0 +1 @@ +{"attributes": { "title": "Hidden Saved object type that is importable/exportable." }, "id":"ff3733a0-9fty-11e7-ahb3-3dcb94193fab", "references":[], "type":"test-hidden-importable-exportable", "version":1} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson new file mode 100644 index 00000000000000..25eea91b8bc435 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson @@ -0,0 +1 @@ +{"attributes": { "title": "Hidden Saved object type that is not importable/exportable." },"id":"op3767a1-9rcg-53u7-jkb3-3dnb74193awc","references":[],"type":"test-hidden-non-importable-exportable","version":1} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts new file mode 100644 index 00000000000000..dfd0b9dd074769 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts @@ -0,0 +1,55 @@ +/* + * 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 path from 'path'; +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../../services'; + +export default function ({ getPageObjects, getService }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); + const esArchiver = getService('esArchiver'); + const fixturePaths = { + hiddenImportable: path.join(__dirname, 'exports', '_import_hidden_importable.ndjson'), + hiddenNonImportable: path.join(__dirname, 'exports', '_import_hidden_non_importable.ndjson'), + }; + + describe('Saved objects management Interface', () => { + before(() => esArchiver.emptyKibanaIndex()); + beforeEach(async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + }); + describe('importable/exportable hidden type', () => { + it('imports objects successfully', async () => { + await PageObjects.savedObjects.importFile(fixturePaths.hiddenImportable); + await PageObjects.savedObjects.checkImportSucceeded(); + }); + + it('shows test-hidden-importable-exportable in table', async () => { + await PageObjects.savedObjects.searchForObject('type:(test-hidden-importable-exportable)'); + const results = await PageObjects.savedObjects.getTableSummary(); + expect(results.length).to.be(1); + + const { title } = results[0]; + expect(title).to.be( + 'test-hidden-importable-exportable [id=ff3733a0-9fty-11e7-ahb3-3dcb94193fab]' + ); + }); + }); + + describe('non-importable/exportable hidden type', () => { + it('fails to import object', async () => { + await PageObjects.savedObjects.importFile(fixturePaths.hiddenNonImportable); + await PageObjects.savedObjects.checkImportSucceeded(); + + const errorsCount = await PageObjects.savedObjects.getImportErrorsCount(); + expect(errorsCount).to.be(1); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts new file mode 100644 index 00000000000000..dddee085ae22b0 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts @@ -0,0 +1,112 @@ +/* + * 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('export', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('resolves objects with importableAndExportable types', async () => { + const fileBuffer = Buffer.from( + '{"id":"ff3733a0-9fty-11e7-ahb3-3dcb94193fab","type":"test-hidden-importable-exportable","attributes":{"title":"new title!"},"references":[]}', + 'utf8' + ); + + await supertest + .post('/api/saved_objects/_resolve_import_errors') + .set('kbn-xsrf', 'true') + .field( + 'retries', + JSON.stringify([ + { + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + type: 'test-hidden-importable-exportable', + overwrite: true, + }, + ]) + ) + .attach('file', fileBuffer, 'import.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 1, + success: true, + warnings: [], + successResults: [ + { + type: 'test-hidden-importable-exportable', + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + meta: { + title: 'new title!', + }, + overwrite: true, + }, + ], + }); + }); + }); + + it('rejects objects with non importableAndExportable types', async () => { + const fileBuffer = Buffer.from( + '{"id":"op3767a1-9rcg-53u7-jkb3-3dnb74193awc","type":"test-hidden-non-importable-exportable","attributes":{"title":"new title!"},"references":[]}', + 'utf8' + ); + + await supertest + .post('/api/saved_objects/_resolve_import_errors') + .set('kbn-xsrf', 'true') + .field( + 'retries', + JSON.stringify([ + { + id: 'op3767a1-9rcg-53u7-jkb3-3dnb74193awc', + type: 'test-hidden-non-importable-exportable', + overwrite: true, + }, + ]) + ) + .attach('file', fileBuffer, 'import.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 0, + success: false, + warnings: [], + errors: [ + { + id: 'op3767a1-9rcg-53u7-jkb3-3dnb74193awc', + type: 'test-hidden-non-importable-exportable', + title: 'new title!', + meta: { + title: 'new title!', + }, + error: { + type: 'unsupported_type', + }, + overwrite: true, + }, + ], + }); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/find.ts b/test/plugin_functional/test_suites/saved_objects_management/find.ts new file mode 100644 index 00000000000000..5dce8f43339a16 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/find.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + describe('saved objects with hidden type', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + it('returns saved objects with importableAndExportable types', async () => + await supertest + .get( + '/api/kibana/management/saved_objects/_find?type=test-hidden-importable-exportable&fields=title' + ) + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'test-hidden-importable-exportable', + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + attributes: { + title: 'Hidden Saved object type that is importable/exportable.', + }, + references: [], + updated_at: '2021-02-11T18:51:23.794Z', + version: 'WzIsMl0=', + namespaces: ['default'], + score: 0, + meta: { + namespaceType: 'single', + }, + }, + ], + }); + })); + + it('returns empty response for non importableAndExportable types', async () => + await supertest + .get( + '/api/kibana/management/saved_objects/_find?type=test-hidden-non-importable-exportable' + ) + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/get.ts b/test/plugin_functional/test_suites/saved_objects_management/get.ts new file mode 100644 index 00000000000000..fa35983df8301a --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/get.ts @@ -0,0 +1,53 @@ +/* + * 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('get', () => { + describe('saved objects with hidden type', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + const hiddenTypeExportableImportable = + 'test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab'; + const hiddenTypeNonExportableImportable = + 'test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc'; + + it('should return 200 for hidden types that are importableAndExportable', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${hiddenTypeExportableImportable}`) + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + const { body } = resp; + const { type, id, meta } = body; + expect(type).to.eql('test-hidden-importable-exportable'); + expect(id).to.eql('ff3733a0-9fty-11e7-ahb3-3dcb94193fab'); + expect(meta).to.not.equal(undefined); + })); + + it('should return 404 for hidden types that are not importableAndExportable', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${hiddenTypeNonExportableImportable}`) + .set('kbn-xsrf', 'true') + .expect(404)); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/index.ts b/test/plugin_functional/test_suites/saved_objects_management/index.ts index f6d383e60388db..9f2d28b582f786 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/index.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/index.ts @@ -10,6 +10,9 @@ import { PluginFunctionalProviderContext } from '../../services'; export default function ({ loadTestFile }: PluginFunctionalProviderContext) { describe('Saved Objects Management', function () { + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./scroll_count')); + loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./export_transform')); loadTestFile(require.resolve('./import_warnings')); }); diff --git a/test/plugin_functional/test_suites/saved_objects_management/scroll_count.ts b/test/plugin_functional/test_suites/saved_objects_management/scroll_count.ts new file mode 100644 index 00000000000000..f74cd5b938447d --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/scroll_count.ts @@ -0,0 +1,49 @@ +/* + * 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const apiUrl = '/api/kibana/management/saved_objects/scroll/counts'; + + describe('scroll_count', () => { + describe('saved objects with hidden type', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('only counts hidden types that are importableAndExportable', async () => { + const res = await supertest + .post(apiUrl) + .set('kbn-xsrf', 'true') + .send({ + typesToInclude: [ + 'test-hidden-non-importable-exportable', + 'test-hidden-importable-exportable', + ], + }) + .expect(200); + + expect(res.body).to.eql({ + 'test-hidden-importable-exportable': 1, + 'test-hidden-non-importable-exportable': 0, + }); + }); + }); + }); +} From fb2552661df91337465ea67bd4093d916110cef2 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 15 Feb 2021 12:04:01 +0100 Subject: [PATCH 026/175] [ILM] Rollover min age tooltip and copy fixes (#91110) * removed an unnecessary tooltip in rollover field, added a tooltip to min age field when rollover is enabled * slight update to copy, added jest test and added comment about testing * page title and timeline title to sentence case * added link to learn more about timing to phase timeline component * fix jest test copy * remove unused import * fix i18n * remove unused translations * slight refactor to conditional for clarity * slight refactor to i18n text naming Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../client_integration/app/app.test.ts | 4 +- .../edit_policy/edit_policy.helpers.tsx | 6 +++ .../edit_policy/edit_policy.test.ts | 47 +++++++++++++++++++ .../sections/edit_policy/components/index.ts | 1 + .../components/phases/hot_phase/hot_phase.tsx | 26 +++------- .../min_age_field/min_age_field.tsx | 33 ++++++++++++- .../components/timeline/timeline.tsx | 22 +++++---- .../sections/edit_policy/edit_policy.tsx | 4 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 10 files changed, 108 insertions(+), 37 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts index 38cfad1a3b188b..36f04be3b30b1d 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts @@ -22,8 +22,8 @@ const PERCENT_SIGN_NAME = 'test%'; const PERCENT_SIGN_WITH_OTHER_CHARS_NAME = 'test%#'; const PERCENT_SIGN_25_SEQUENCE = 'test%25'; -const createPolicyTitle = 'Create Policy'; -const editPolicyTitle = 'Edit Policy'; +const createPolicyTitle = 'Create policy'; +const editPolicyTitle = 'Edit policy'; window.scrollTo = jest.fn(); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 7e1b7c5267a8bf..83a13f0523a403 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -271,6 +271,9 @@ export const setup = async (arg?: { appServicesContext: Partial (): boolean => + exists(`${phase}-rolloverMinAgeInputIconTip`); + return { ...testBed, actions: { @@ -306,6 +309,7 @@ export const setup = async (arg?: { appServicesContext: Partial exists('phaseErrorIndicator-warm'), + hasRolloverTipOnMinAge: hasRolloverTipOnMinAge('warm'), ...createShrinkActions('warm'), ...createForceMergeActions('warm'), setReadonly: setReadonly('warm'), @@ -321,11 +325,13 @@ export const setup = async (arg?: { appServicesContext: Partial exists('phaseErrorIndicator-cold'), + hasRolloverTipOnMinAge: hasRolloverTipOnMinAge('cold'), ...createIndexPriorityActions('cold'), ...createSearchableSnapshotActions('cold'), }, delete: { ...createToggleDeletePhaseActions(), + hasRolloverTipOnMinAge: hasRolloverTipOnMinAge('delete'), setMinAgeValue: setMinAgeValue('delete'), setMinAgeUnits: setMinAgeUnits('delete'), }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 6f325084938e8d..f1a15d805faf8a 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -769,6 +769,38 @@ describe('', () => { }); }); }); + describe('with rollover', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setListNodes({ + isUsingDeprecatedDataRoleConfig: false, + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data: ['123'] }, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['abc'] }); + httpRequestsMockHelpers.setLoadSnapshotPolicies([]); + + await act(async () => { + testBed = await setup(); + }); + + const { component } = testBed; + component.update(); + }); + + test('shows rollover tip on minimum age', async () => { + const { actions } = testBed; + + await actions.warm.enable(true); + await actions.cold.enable(true); + await actions.delete.enablePhase(); + + expect(actions.warm.hasRolloverTipOnMinAge()).toBeTruthy(); + expect(actions.cold.hasRolloverTipOnMinAge()).toBeTruthy(); + expect(actions.delete.hasRolloverTipOnMinAge()).toBeTruthy(); + }); + }); + describe('without rollover', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); @@ -778,6 +810,7 @@ describe('', () => { nodesByRoles: { data: ['123'] }, }); httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + httpRequestsMockHelpers.setLoadSnapshotPolicies([]); await act(async () => { testBed = await setup({ @@ -799,6 +832,20 @@ describe('', () => { expect(actions.hot.searchableSnapshotsExists()).toBeFalsy(); expect(actions.cold.searchableSnapshotDisabledDueToRollover()).toBeTruthy(); }); + + test('hiding rollover tip on minimum age', async () => { + const { actions } = testBed; + await actions.hot.toggleDefaultRollover(false); + await actions.hot.toggleRollover(false); + + await actions.warm.enable(true); + await actions.cold.enable(true); + await actions.delete.enablePhase(); + + expect(actions.warm.hasRolloverTipOnMinAge()).toBeFalsy(); + expect(actions.cold.hasRolloverTipOnMinAge()).toBeFalsy(); + expect(actions.delete.hasRolloverTipOnMinAge()).toBeFalsy(); + }); }); describe('policy timeline', () => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index dc4f1e31d3696e..ccc553c58e8993 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -13,4 +13,5 @@ export { FieldLoadingError } from './field_loading_error'; export { Timeline } from './timeline'; export { FormErrorsCallout } from './form_errors_callout'; export { PhaseFooter } from './phase_footer'; +export { InfinityIcon } from './infinity_icon'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index c77493476b9295..6d4e2750bb2e88 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -16,7 +16,6 @@ import { EuiCallOut, EuiTextColor, EuiSwitch, - EuiIconTip, EuiText, } from '@elastic/eui'; @@ -121,25 +120,12 @@ export const HotPhase: FunctionComponent = () => {
path="_meta.hot.customRollover.enabled"> {(field) => ( - <> - field.setValue(e.target.checked)} - data-test-subj="rolloverSwitch" - /> -   - - } - /> - + field.setValue(e.target.checked)} + data-test-subj="rolloverSwitch" + /> )} {isUsingRollover && ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx index 2f1a058f5a9436..04b756dc235598 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx @@ -17,11 +17,12 @@ import { EuiFormRow, EuiSelect, EuiText, + EuiIconTip, } from '@elastic/eui'; import { getFieldValidityAndErrorMessage } from '../../../../../../../shared_imports'; -import { UseField } from '../../../../form'; +import { UseField, useConfigurationIssues } from '../../../../form'; import { getUnitsAriaLabelForPhase, getTimingLabelForPhase } from './util'; @@ -62,6 +63,17 @@ const i18nTexts = { defaultMessage: 'nanoseconds', } ), + rolloverToolTipDescription: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minimumAge.rolloverToolTipDescription', + { + defaultMessage: + 'Data age is calculated from rollover. Rollover is configured in the hot phase.', + } + ), + minAgeUnitFieldSuffix: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minimumAge.minimumAgeFieldSuffixLabel', + { defaultMessage: 'old' } + ), }; interface Props { @@ -69,6 +81,7 @@ interface Props { } export const MinAgeField: FunctionComponent = ({ phase }): React.ReactElement => { + const { isUsingRollover } = useConfigurationIssues(); return ( {(field) => { @@ -110,6 +123,22 @@ export const MinAgeField: FunctionComponent = ({ phase }): React.ReactEle const { isInvalid: isUnitFieldInvalid } = getFieldValidityAndErrorMessage( unitField ); + const icon = ( + <> + {/* This element is rendered for testing purposes only */} +
+ + + ); + const selectAppendValue: Array< + string | React.ReactElement + > = isUsingRollover + ? [i18nTexts.minAgeUnitFieldSuffix, icon] + : [i18nTexts.minAgeUnitFieldSuffix]; return ( = ({ phase }): React.ReactEle unitField.setValue(e.target.value); }} isInvalid={isUnitFieldInvalid} - append={'old'} + append={selectAppendValue} data-test-subj={`${phase}-selectedMinimumAgeUnits`} aria-label={getUnitsAriaLabelForPhase(phase)} options={[ diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx index 8097ab51eb59e3..c996c45171d2ff 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { FunctionComponent, memo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiText, EuiIconTip } from '@elastic/eui'; @@ -18,7 +19,7 @@ import { AbsoluteTimings, } from '../../lib'; -import { InfinityIcon } from '../infinity_icon'; +import { InfinityIcon, LearnMoreLink } from '..'; import { TimelinePhaseText } from './components'; @@ -47,7 +48,7 @@ const SCORE_BUFFER_AMOUNT = 50; const i18nTexts = { title: i18n.translate('xpack.indexLifecycleMgmt.timeline.title', { - defaultMessage: 'Policy Summary', + defaultMessage: 'Policy summary', }), description: i18n.translate('xpack.indexLifecycleMgmt.timeline.description', { defaultMessage: 'This policy moves data through the following phases.', @@ -55,13 +56,6 @@ const i18nTexts = { hotPhase: i18n.translate('xpack.indexLifecycleMgmt.timeline.hotPhaseSectionTitle', { defaultMessage: 'Hot phase', }), - rolloverTooltip: i18n.translate( - 'xpack.indexLifecycleMgmt.timeline.hotPhaseRolloverToolTipContent', - { - defaultMessage: - 'How long it takes to reach the rollover criteria in the hot phase can vary. Data moves to the next phase when the time since rollover reaches the minimum age.', - } - ), warmPhase: i18n.translate('xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle', { defaultMessage: 'Warm phase', }), @@ -143,6 +137,16 @@ export const Timeline: FunctionComponent = memo( {i18nTexts.description} +   + + } + /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 0c7b5565372a55..637fbd893aaa05 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -142,10 +142,10 @@ export const EditPolicy: React.FunctionComponent = ({ history }) => {

{isNewPolicy ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { - defaultMessage: 'Create Policy', + defaultMessage: 'Create policy', }) : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { - defaultMessage: 'Edit Policy {originalPolicyName}', + defaultMessage: 'Edit policy {originalPolicyName}', values: { originalPolicyName }, })}

diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4d94539a514d1d..2d4584748c39d1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9467,7 +9467,6 @@ "xpack.indexLifecycleMgmt.editPolicy.forcemerge.numberOfSegmentsRequiredError": "セグメント数の評価が必要です。", "xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage": "このページのエラーを修正してください。", "xpack.indexLifecycleMgmt.editPolicy.hidePolicyJsonButto": "リクエストを非表示", - "xpack.indexLifecycleMgmt.editPolicy.hotPhase.enableRolloverTipContent": "現在のインデックスが定義された条件のいずれかを満たすときに、新しいインデックスにロールオーバーします。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.learnAboutRolloverLinkText": "詳細", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDefaultsTipContent": "インデックスが 30 日経過するか、50 GB に達したらロールオーバーします。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDescriptionMessage": "効率的なストレージと高いパフォーマンスのための時系列データの自動ロールアウト。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a35d4c67dde001..50e178527c3ec6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9491,7 +9491,6 @@ "xpack.indexLifecycleMgmt.editPolicy.forcemerge.numberOfSegmentsRequiredError": "必须指定分段数的值。", "xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage": "请修复此页面上的错误。", "xpack.indexLifecycleMgmt.editPolicy.hidePolicyJsonButto": "隐藏请求", - "xpack.indexLifecycleMgmt.editPolicy.hotPhase.enableRolloverTipContent": "在当前索引满足定义的条件之一时,滚动更新到新索引。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.learnAboutRolloverLinkText": "了解详情", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDefaultsTipContent": "当索引已存在 30 天或达到 50 GB 时滚动更新。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDescriptionMessage": "自动滚动更新时间序列数据,以实现高效存储和更高性能。", From 75a7f78730b7672ae45894b9bd7537401f9a3923 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Mon, 15 Feb 2021 12:25:19 +0100 Subject: [PATCH 027/175] [Lens] Improves ranking feature in Top values (#90749) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../operations/definitions/terms/index.tsx | 49 ++++++++++++++++--- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 517cb941f2f670..3b0cb67cbce411 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -16,6 +16,7 @@ import { EuiPopover, EuiButtonEmpty, EuiText, + EuiIconTip, } from '@elastic/eui'; import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public'; import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public'; @@ -316,9 +317,25 @@ export const termsOperation: OperationDefinition )} + {i18n.translate('xpack.lens.indexPattern.terms.orderBy', { + defaultMessage: 'Rank by', + })}{' '} + + + } display="columnCompressed" fullWidth > @@ -338,14 +355,30 @@ export const termsOperation: OperationDefinition + {i18n.translate('xpack.lens.indexPattern.terms.orderDirection', { + defaultMessage: 'Rank direction', + })}{' '} + + + } display="columnCompressed" fullWidth > @@ -378,7 +411,7 @@ export const termsOperation: OperationDefinition diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2d4584748c39d1..ed11b669f231ae 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11326,9 +11326,7 @@ "xpack.lens.indexPattern.terms.missingLabel": "(欠落値)", "xpack.lens.indexPattern.terms.orderAlphabetical": "アルファベット順", "xpack.lens.indexPattern.terms.orderAscending": "昇順", - "xpack.lens.indexPattern.terms.orderBy": "並び順", "xpack.lens.indexPattern.terms.orderDescending": "降順", - "xpack.lens.indexPattern.terms.orderDirection": "全体的な方向", "xpack.lens.indexPattern.terms.otherBucketDescription": "他の値を「その他」としてグループ化", "xpack.lens.indexPattern.terms.otherLabel": "その他", "xpack.lens.indexPattern.terms.size": "値の数", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 50e178527c3ec6..c4c827ca7f55ee 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11354,9 +11354,7 @@ "xpack.lens.indexPattern.terms.missingLabel": "(缺失值)", "xpack.lens.indexPattern.terms.orderAlphabetical": "按字母顺序", "xpack.lens.indexPattern.terms.orderAscending": "升序", - "xpack.lens.indexPattern.terms.orderBy": "排序依据", "xpack.lens.indexPattern.terms.orderDescending": "降序", - "xpack.lens.indexPattern.terms.orderDirection": "排序方向", "xpack.lens.indexPattern.terms.otherBucketDescription": "将其他值分组为“其他”", "xpack.lens.indexPattern.terms.otherLabel": "其他", "xpack.lens.indexPattern.terms.size": "值数目", From 0ee7be1f0d9dfdb4c9880d74ba50bedffbd8a3db Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Mon, 15 Feb 2021 12:48:06 +0100 Subject: [PATCH 028/175] [Lens] Keyboard-selected items follow user traversal of drop zones (#90546) --- .../__snapshots__/drag_drop.test.tsx.snap | 15 +- .../lens/public/drag_drop/drag_drop.scss | 36 +++- .../lens/public/drag_drop/drag_drop.test.tsx | 173 ++++++++++++++---- .../lens/public/drag_drop/drag_drop.tsx | 56 +++++- .../lens/public/drag_drop/providers.tsx | 19 +- .../config_panel/layer_panel.test.tsx | 26 ++- .../editor_frame/editor_frame.test.tsx | 10 +- .../workspace_panel_wrapper.scss | 5 +- .../dimension_panel/droppable.test.ts | 7 +- .../indexpattern_datasource/field_item.scss | 7 + 10 files changed, 285 insertions(+), 69 deletions(-) diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index b3b695b22ad716..e5594bb0bb7699 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -1,12 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DragDrop defined dropType is reflected in the className 1`] = ` - + +
`; exports[`DragDrop items that has dropType=undefined get special styling when another item is dragged 1`] = ` @@ -23,6 +27,7 @@ exports[`DragDrop items that has dropType=undefined get special styling when ano exports[`DragDrop renders if nothing is being dragged 1`] = `
+ ); @@ -96,7 +97,7 @@ describe('DragDrop', () => { jest.runAllTimers(); expect(dataTransfer.setData).toBeCalledWith('text', 'hello'); - expect(setDragging).toBeCalledWith(value); + expect(setDragging).toBeCalledWith({ ...value }); expect(setA11yMessage).toBeCalledWith('Lifted hello'); }); @@ -175,7 +176,7 @@ describe('DragDrop', () => { test('items that has dropType=undefined get special styling when another item is dragged', () => { const component = mount( - + @@ -198,7 +199,6 @@ describe('DragDrop', () => { const getAdditionalClassesOnEnter = jest.fn().mockReturnValue('additional'); const getAdditionalClassesOnDroppable = jest.fn().mockReturnValue('droppable'); const setA11yMessage = jest.fn(); - let activeDropTarget; const component = mount( { setDragging={() => { dragging = { id: '1', humanData: { label: 'label1' } }; }} - setActiveDropTarget={(val) => { - activeDropTarget = { activeDropTarget: val }; - }} - activeDropTarget={activeDropTarget} > { , style: {} } }, setActiveDropTarget, setA11yMessage, activeDropTarget: { @@ -376,21 +372,115 @@ describe('DragDrop', () => { .simulate('focus'); act(() => { keyboardHandler.simulate('keydown', { key: 'ArrowRight' }); - expect(setActiveDropTarget).toBeCalledWith({ - ...items[2].value, - onDrop, - dropType: items[2].dropType, - }); - keyboardHandler.simulate('keydown', { key: 'Enter' }); - expect(setA11yMessage).toBeCalledWith( - 'Selected label3 in group at position 1. Press space or enter to replace label3 with label1.' - ); - expect(setActiveDropTarget).toBeCalledWith(undefined); - expect(onDrop).toBeCalledWith( - { humanData: { label: 'label1', position: 1 }, id: '1' }, - 'move_compatible' - ); }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[2].value, + onDrop, + dropType: items[2].dropType, + }); + keyboardHandler.simulate('keydown', { key: 'Enter' }); + expect(setA11yMessage).toBeCalledWith( + 'Selected label3 in group at position 1. Press space or enter to replace label3 with label1.' + ); + expect(setActiveDropTarget).toBeCalledWith(undefined); + expect(onDrop).toBeCalledWith( + { humanData: { label: 'label1', position: 1 }, id: '1' }, + 'move_compatible' + ); + }); + + test('Keyboard navigation: dragstart sets dragging in the context and calls it with proper params', async () => { + const setDragging = jest.fn(); + + const setA11yMessage = jest.fn(); + const component = mount( + + + + + + ); + + const keyboardHandler = component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('focus'); + + keyboardHandler.simulate('keydown', { key: 'Enter' }); + jest.runAllTimers(); + + expect(setDragging).toBeCalledWith({ + ...value, + ghost: { + children: , + style: { + height: 0, + width: 0, + }, + }, + }); + expect(setA11yMessage).toBeCalledWith('Lifted hello'); + }); + + test('Keyboard navigation: ActiveDropTarget gets ghost image', () => { + const onDrop = jest.fn(); + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const items = [ + { + draggable: true, + value: { + id: '1', + humanData: { label: 'label1', position: 1 }, + }, + children: '1', + order: [2, 0, 0, 0], + }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', + + value: { + id: '2', + + humanData: { label: 'label2', position: 1 }, + }, + onDrop, + dropType: 'move_compatible' as DropType, + order: [2, 0, 1, 0], + }, + ]; + const component = mount( + Hello
, style: {} } }, + setActiveDropTarget, + setA11yMessage, + activeDropTarget: { + activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, + dropTargetsByOrder: { + '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, + }, + }, + keyboardMode: true, + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + + expect(component.find(DragDrop).at(1).find('.lnsDragDrop_ghost').text()).toEqual('Hello'); }); describe('reordering', () => { @@ -427,7 +517,7 @@ describe('DragDrop', () => { const registerDropTarget = jest.fn(); const baseContext = { dragging, - setDragging: (val?: DragDropIdentifier) => { + setDragging: (val?: DraggingIdentifier) => { dragging = val; }, keyboardMode, @@ -479,7 +569,11 @@ describe('DragDrop', () => { test(`Reorderable group with lifted element renders properly`, () => { const setA11yMessage = jest.fn(); const setDragging = jest.fn(); - const component = mountComponent({ dragging: items[0], setDragging, setA11yMessage }); + const component = mountComponent({ + dragging: { ...items[0] }, + setDragging, + setA11yMessage, + }); act(() => { component .find('[data-test-subj="lnsDragDrop"]') @@ -488,7 +582,7 @@ describe('DragDrop', () => { jest.runAllTimers(); }); - expect(setDragging).toBeCalledWith(items[0]); + expect(setDragging).toBeCalledWith({ ...items[0] }); expect(setA11yMessage).toBeCalledWith('Lifted label1'); expect( component @@ -498,7 +592,7 @@ describe('DragDrop', () => { }); test(`Reordered elements get extra styles to show the reorder effect when dragging`, () => { - const component = mountComponent({ dragging: items[0] }); + const component = mountComponent({ dragging: { ...items[0] } }); act(() => { component @@ -545,7 +639,11 @@ describe('DragDrop', () => { const setA11yMessage = jest.fn(); const setDragging = jest.fn(); - const component = mountComponent({ dragging: items[0], setDragging, setA11yMessage }); + const component = mountComponent({ + dragging: { ...items[0] }, + setDragging, + setA11yMessage, + }); component .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') @@ -558,14 +656,14 @@ describe('DragDrop', () => { ); expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); - expect(onDrop).toBeCalledWith(items[0], 'reorder'); + expect(onDrop).toBeCalledWith({ ...items[0] }, 'reorder'); }); test(`Keyboard Navigation: User cannot move an element outside of the group`, () => { const setA11yMessage = jest.fn(); const setActiveDropTarget = jest.fn(); const component = mountComponent({ - dragging: items[0], + dragging: { ...items[0] }, keyboardMode: true, activeDropTarget: { activeDropTarget: undefined, @@ -594,7 +692,7 @@ describe('DragDrop', () => { }); test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => { const component = mountComponent({ - dragging: items[0], + dragging: { ...items[0] }, activeDropTarget: { activeDropTarget: { ...items[2], dropType: 'reorder', onDrop }, dropTargetsByOrder: { @@ -621,7 +719,10 @@ describe('DragDrop', () => { test(`Keyboard Navigation: Doesn't call onDrop when movement is cancelled`, () => { const setA11yMessage = jest.fn(); const onDropHandler = jest.fn(); - const component = mountComponent({ dragging: items[0], setA11yMessage }, onDropHandler); + const component = mountComponent( + { dragging: { ...items[0] }, setA11yMessage }, + onDropHandler + ); const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'Escape' }); @@ -640,7 +741,7 @@ describe('DragDrop', () => { test(`Keyboard Navigation: Reordered elements get extra styles to show the reorder effect`, () => { const setA11yMessage = jest.fn(); const component = mountComponent({ - dragging: items[0], + dragging: { ...items[0] }, keyboardMode: true, activeDropTarget: { activeDropTarget: undefined, @@ -704,7 +805,7 @@ describe('DragDrop', () => { '2,0,1,1': { ...items[1], onDrop, dropType: 'reorder' }, }, }} - dragging={items[0]} + dragging={{ ...items[0] }} setActiveDropTarget={setActiveDropTarget} setA11yMessage={setA11yMessage} > diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 07c1368e534566..4b250643203279 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -177,6 +177,7 @@ export const DragDrop = (props: BaseProps) => { ); const dropProps = { ...props, + keyboardMode, setKeyboardMode, dragging, setDragging, @@ -219,7 +220,10 @@ const DragInner = memo(function DragInner({ ariaDescribedBy, setA11yMessage, }: DragInnerProps) { - const dragStart = (e?: DroppableEvent | React.KeyboardEvent) => { + const dragStart = ( + e: DroppableEvent | React.KeyboardEvent, + keyboardModeOn?: boolean + ) => { // Setting stopPropgagation causes Chrome failures, so // we are manually checking if we've already handled this // in a nested child, and doing nothing if so... @@ -237,9 +241,21 @@ const DragInner = memo(function DragInner({ // dragStart event, so we drop a setTimeout to avoid that. const currentTarget = e?.currentTarget; + setTimeout(() => { - setDragging(value); + setDragging({ + ...value, + ghost: keyboardModeOn + ? { + children, + style: { width: currentTarget.offsetWidth, height: currentTarget.offsetHeight }, + } + : undefined, + }); setA11yMessage(announce.lifted(value.humanData)); + if (keyboardModeOn) { + setKeyboardMode(true); + } if (onDragStart) { onDragStart(currentTarget); } @@ -284,8 +300,19 @@ const DragInner = memo(function DragInner({ : announce.noTarget() ); }; + const shouldShowGhostImageInstead = + isDragging && + dragType === 'move' && + keyboardMode && + activeDropTarget?.activeDropTarget && + activeDropTarget?.activeDropTarget.dropType !== 'reorder'; return ( -
+
, + style: {}, + }, }; const component = mountWithIntl( @@ -463,7 +467,7 @@ describe('LayerPanel', () => { }) ); - component.find('[data-test-subj="lnsGroup"] DragDrop').first().simulate('drop'); + component.find('[data-test-subj="lnsGroup"] DragDrop .lnsDragDrop').first().simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ @@ -497,6 +501,10 @@ describe('LayerPanel', () => { indexPatternId: 'a', id: '1', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -554,6 +562,10 @@ describe('LayerPanel', () => { groupId: 'a', id: 'a', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -571,7 +583,7 @@ describe('LayerPanel', () => { ); // Simulate drop on the pre-populated dimension - component.find('[data-test-subj="lnsGroupB"] DragDrop').at(0).simulate('drop'); + component.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop').at(0).simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'b', @@ -582,7 +594,7 @@ describe('LayerPanel', () => { ); // Simulate drop on the empty dimension - component.find('[data-test-subj="lnsGroupB"] DragDrop').at(1).simulate('drop'); + component.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop').at(1).simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'newid', @@ -613,6 +625,10 @@ describe('LayerPanel', () => { groupId: 'a', id: 'a', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -659,6 +675,10 @@ describe('LayerPanel', () => { groupId: 'a', id: 'a', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index da1d7f6eacd028..108e4aa84418fb 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -1323,7 +1323,10 @@ describe('editor_frame', () => { getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { if (!dragging || dragging.id !== 'draggedField') { - setDragging({ id: 'draggedField', humanData: { label: 'draggedField' } }); + setDragging({ + id: 'draggedField', + humanData: { label: 'draggedField' }, + }); } }, }, @@ -1425,7 +1428,10 @@ describe('editor_frame', () => { getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { if (!dragging || dragging.id !== 'draggedField') { - setDragging({ id: 'draggedField', humanData: { label: '1' } }); + setDragging({ + id: 'draggedField', + humanData: { label: '1' }, + }); } }, }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss index ae9294c474b42e..0ace88b3d3ab75 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss @@ -2,7 +2,6 @@ .lnsWorkspacePanelWrapper { @include euiScrollBar; - overflow: hidden; // Override panel size padding padding: 0 !important; // sass-lint:disable-line no-important margin-bottom: $euiSize; @@ -10,6 +9,7 @@ flex-direction: column; position: relative; // For positioning the dnd overlay min-height: $euiSizeXXL * 10; + overflow: visible; .lnsWorkspacePanelWrapper__pageContentBody { @include euiScrollBar; @@ -17,7 +17,6 @@ display: flex; align-items: stretch; justify-content: stretch; - overflow: auto; > * { flex: 1 1 100%; @@ -34,6 +33,8 @@ // Color the whole panel instead background-color: transparent !important; // sass-lint:disable-line no-important border: none !important; // sass-lint:disable-line no-important + width: 100%; + height: 100%; } .lnsExpressionRenderer { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts index b374be98748f0a..1f0381d92ce640 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { IndexPatternDimensionEditorProps } from './dimension_panel'; import { onDrop, getDropTypes } from './droppable'; @@ -187,7 +186,11 @@ describe('IndexPatternDimensionEditorPanel', () => { groupId, dragDropContext: { ...dragDropContext, - dragging: { name: 'bar', id: 'bar', humanData: { label: 'Label' } }, + dragging: { + name: 'bar', + id: 'bar', + humanData: { label: 'Label' }, + }, }, }) ).toBe(undefined); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss index 8a6e10c8be6e4f..19f5b919752028 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.scss @@ -29,6 +29,13 @@ } } +.kbnFieldButton.lnsDragDrop_ghost { + .lnsFieldItem__infoIcon { + visibility: hidden; + opacity: 0; + } +} + .kbnFieldButton__name { transition: background-color $euiAnimSpeedFast ease-in-out; } From e9e7453e1dd6785cebf53fd344830f73dadab42c Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Mon, 15 Feb 2021 13:00:31 +0100 Subject: [PATCH 029/175] [Lens] Improves error messages when in Dashboard (#90668) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../visualization.test.tsx | 16 +- .../datatable_visualization/visualization.tsx | 2 +- .../editor_frame/state_helpers.ts | 55 +++- .../workspace_panel/workspace_panel.tsx | 5 +- .../embeddable/embeddable.test.tsx | 173 ++++++---- .../embeddable/embeddable.tsx | 12 +- .../embeddable/embeddable_factory.ts | 5 +- .../embeddable/expression_wrapper.tsx | 61 +++- .../editor_frame_service/error_helper.ts | 12 + .../public/editor_frame_service/mocks.tsx | 2 +- .../public/editor_frame_service/service.tsx | 6 +- .../lens/public/editor_frame_service/types.ts | 5 + .../visualization.test.ts | 18 +- .../metric_visualization/visualization.tsx | 2 +- .../lens/public/pie_visualization/toolbar.tsx | 9 +- .../pie_visualization/visualization.test.ts | 30 +- .../pie_visualization/visualization.tsx | 4 +- x-pack/plugins/lens/public/types.ts | 7 +- .../lens/public/visualization_container.scss | 8 + .../xy_visualization/color_assignment.ts | 2 +- .../xy_visualization/visualization.test.ts | 308 ++++++++---------- .../public/xy_visualization/visualization.tsx | 2 +- .../xy_visualization/xy_config_panel.tsx | 2 +- x-pack/test/functional/apps/lens/dashboard.ts | 34 ++ 24 files changed, 447 insertions(+), 333 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 2a6228f16867dc..ad5e1e552ccd2b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -459,10 +459,10 @@ describe('Datatable Visualization', () => { label: 'label', }); - const error = datatableVisualization.getErrorMessages( - { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, - frame - ); + const error = datatableVisualization.getErrorMessages({ + layerId: 'a', + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }); expect(error).toBeUndefined(); }); @@ -478,10 +478,10 @@ describe('Datatable Visualization', () => { label: 'label', }); - const error = datatableVisualization.getErrorMessages( - { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, - frame - ); + const error = datatableVisualization.getErrorMessages({ + layerId: 'a', + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }); expect(error).toBeUndefined(); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 9625a814c79589..47f8ce09aea68c 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -276,7 +276,7 @@ export const datatableVisualization: Visualization }; }, - getErrorMessages(state, frame) { + getErrorMessages(state) { return undefined; }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 559e773dbc1673..9c7ef19132c46a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -18,6 +18,9 @@ import { import { buildExpression } from './expression_helpers'; import { Document } from '../../persistence/saved_object_store'; import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; +import { getActiveDatasourceIdFromDoc } from './state_management'; +import { ErrorMessage } from '../types'; +import { getMissingCurrentDatasource, getMissingVisualizationTypeError } from '../error_helper'; export async function initializeDatasources( datasourceMap: Record, @@ -72,7 +75,7 @@ export async function persistedStateToExpression( datasources: Record, visualizations: Record, doc: Document -): Promise { +): Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }> { const { state: { visualization: visualizationState, datasourceStates: persistedDatasourceStates }, visualizationType, @@ -80,7 +83,12 @@ export async function persistedStateToExpression( title, description, } = doc; - if (!visualizationType) return null; + if (!visualizationType) { + return { + ast: null, + errors: [{ shortMessage: '', longMessage: getMissingVisualizationTypeError() }], + }; + } const visualization = visualizations[visualizationType!]; const datasourceStates = await initializeDatasources( datasources, @@ -97,15 +105,33 @@ export async function persistedStateToExpression( const datasourceLayers = createDatasourceLayers(datasources, datasourceStates); - return buildExpression({ - title, - description, + const datasourceId = getActiveDatasourceIdFromDoc(doc); + if (datasourceId == null) { + return { + ast: null, + errors: [{ shortMessage: '', longMessage: getMissingCurrentDatasource() }], + }; + } + const validationResult = validateDatasourceAndVisualization( + datasources[datasourceId], + datasourceStates[datasourceId].state, visualization, visualizationState, - datasourceMap: datasources, - datasourceStates, - datasourceLayers, - }); + { datasourceLayers } + ); + + return { + ast: buildExpression({ + title, + description, + visualization, + visualizationState, + datasourceMap: datasources, + datasourceStates, + datasourceLayers, + }), + errors: validationResult, + }; } export const validateDatasourceAndVisualization = ( @@ -113,13 +139,8 @@ export const validateDatasourceAndVisualization = ( currentDatasourceState: unknown | null, currentVisualization: Visualization | null, currentVisualizationState: unknown | undefined, - frameAPI: FramePublicAPI -): - | Array<{ - shortMessage: string; - longMessage: string; - }> - | undefined => { + frameAPI: Pick +): ErrorMessage[] | undefined => { const layersGroups = currentVisualizationState ? currentVisualization ?.getLayerIds(currentVisualizationState) @@ -141,7 +162,7 @@ export const validateDatasourceAndVisualization = ( : undefined; const visualizationValidationErrors = currentVisualizationState - ? currentVisualization?.getErrorMessages(currentVisualizationState, frameAPI) + ? currentVisualization?.getErrorMessages(currentVisualizationState) : undefined; if (datasourceValidationErrors?.length || visualizationValidationErrors?.length) { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 2c4cecd356cedc..219c0638f9b56c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -155,10 +155,7 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({ datasourceLayers: framePublicAPI.datasourceLayers, }); } catch (e) { - const buildMessages = activeVisualization?.getErrorMessages( - visualizationState, - framePublicAPI - ); + const buildMessages = activeVisualization?.getErrorMessages(visualizationState); const defaultMessage = { shortMessage: i18n.translate('xpack.lens.editorFrame.buildExpressionError', { defaultMessage: 'An unexpected error occurred while preparing the chart', diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index d2085a4cc8a8b1..227c8b4741501d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -116,11 +116,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { @@ -140,6 +143,36 @@ describe('embeddable', () => { | expression`); }); + it('should not render the visualization if any error arises', async () => { + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: [{ shortMessage: '', longMessage: 'my validation error' }], + }), + }, + {} as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({} as LensEmbeddableInput); + embeddable.render(mountpoint); + + expect(expressionRenderer).toHaveBeenCalledTimes(0); + }); + it('should initialize output with deduped list of index patterns', async () => { attributeService = attributeServiceMockFromSavedVis({ ...savedVis, @@ -162,11 +195,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, {} as LensEmbeddableInput @@ -194,11 +230,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -232,11 +271,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -265,11 +307,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -312,11 +357,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, input @@ -359,11 +407,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, input @@ -405,11 +456,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, input @@ -440,11 +494,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -475,11 +532,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -510,11 +570,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123', timeRange, query, filters } as LensEmbeddableInput diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index dc5f9b366e6b52..ef265881f6eb3f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -51,6 +51,7 @@ import { IndexPatternsContract } from '../../../../../../src/plugins/data/public import { getEditPath, DOC_TYPE } from '../../../common'; import { IBasePath } from '../../../../../../src/core/public'; import { LensAttributeService } from '../../lens_attribute_service'; +import type { ErrorMessage } from '../types'; export type LensSavedObjectAttributes = Omit; @@ -77,7 +78,9 @@ export interface LensEmbeddableOutput extends EmbeddableOutput { export interface LensEmbeddableDeps { attributeService: LensAttributeService; - documentToExpression: (doc: Document) => Promise; + documentToExpression: ( + doc: Document + ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; editable: boolean; indexPatternService: IndexPatternsContract; expressionRenderer: ReactExpressionRendererType; @@ -99,6 +102,7 @@ export class Embeddable private subscription: Subscription; private isInitialized = false; private activeData: Partial | undefined; + private errors: ErrorMessage[] | undefined; private externalSearchContext: { timeRange?: TimeRange; @@ -225,8 +229,9 @@ export class Embeddable type: this.type, savedObjectId: (input as LensByReferenceInput)?.savedObjectId, }; - const expression = await this.deps.documentToExpression(this.savedVis); - this.expression = expression ? toExpression(expression) : null; + const { ast, errors } = await this.deps.documentToExpression(this.savedVis); + this.errors = errors; + this.expression = ast ? toExpression(ast) : null; await this.initializeOutput(); this.isInitialized = true; } @@ -279,6 +284,7 @@ export class Embeddable Promise; + documentToExpression: ( + doc: Document + ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; } export class EmbeddableFactory implements EmbeddableFactoryDefinition { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index 8873388633552a..a559e6a02419d6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon, EuiEmptyPrompt } from '@elastic/eui'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -18,10 +18,12 @@ import { ExecutionContextSearch } from 'src/plugins/data/public'; import { DefaultInspectorAdapters, RenderMode } from 'src/plugins/expressions'; import classNames from 'classnames'; import { getOriginalRequestErrorMessage } from '../error_helper'; +import { ErrorMessage } from '../types'; export interface ExpressionWrapperProps { ExpressionRenderer: ReactExpressionRendererType; expression: string | null; + errors: ErrorMessage[] | undefined; variables?: Record; searchContext: ExecutionContextSearch; searchSessionId?: string; @@ -37,6 +39,46 @@ export interface ExpressionWrapperProps { className?: string; } +interface VisualizationErrorProps { + errors: ExpressionWrapperProps['errors']; +} + +export function VisualizationErrorPanel({ errors }: VisualizationErrorProps) { + return ( +
+ + {errors ? ( + <> +

{errors[0].longMessage}

+ {errors.length > 1 ? ( +

+ +

+ ) : null} + + ) : ( +

+ +

+ )} + + } + /> +
+ ); +} + export function ExpressionWrapper({ ExpressionRenderer: ExpressionRendererComponent, expression, @@ -50,23 +92,12 @@ export function ExpressionWrapper({ hasCompatibleActions, style, className, + errors, }: ExpressionWrapperProps) { return ( - {expression === null || expression === '' ? ( - - - - - - - - - - + {errors || expression === null || expression === '' ? ( + ) : (
{ setDimension: jest.fn(), removeDimension: jest.fn(), - getErrorMessages: jest.fn((_state, _frame) => undefined), + getErrorMessages: jest.fn((_state) => undefined), }; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 9e54a4d630dc28..8769aceca3bfd0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -72,7 +72,7 @@ export class EditorFrameService { * This is an asynchronous process and should only be triggered once for a saved object. * @param doc parsed Lens saved object */ - private async documentToExpression(doc: Document) { + private documentToExpression = async (doc: Document) => { const [resolvedDatasources, resolvedVisualizations] = await Promise.all([ collectAsyncDefinitions(this.datasources), collectAsyncDefinitions(this.visualizations), @@ -81,7 +81,7 @@ export class EditorFrameService { const { persistedStateToExpression } = await import('../async_services'); return await persistedStateToExpression(resolvedDatasources, resolvedVisualizations, doc); - } + }; public setup( core: CoreSetup, @@ -98,7 +98,7 @@ export class EditorFrameService { coreHttp: coreStart.http, timefilter: deps.data.query.timefilter.timefilter, expressionRenderer: deps.expressions.ReactExpressionRenderer, - documentToExpression: this.documentToExpression.bind(this), + documentToExpression: this.documentToExpression, indexPatternService: deps.data.indexPatterns, uiActions: deps.uiActions, }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/types.ts b/x-pack/plugins/lens/public/editor_frame_service/types.ts index dc5a4aa0e234b1..6043e963438991 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/types.ts @@ -8,3 +8,8 @@ import { Datatable } from 'src/plugins/expressions'; export type TableInspectorAdapter = Record; + +export interface ErrorMessage { + shortMessage: string; + longMessage: string; +} diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts index 84abc38bf4106f..66e524435ebc8e 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts @@ -197,23 +197,7 @@ describe('metric_visualization', () => { describe('#getErrorMessages', () => { it('returns undefined if no error is raised', () => { - const datasource: DatasourcePublicAPI = { - ...createMockDatasource('l1').publicAPIMock, - getOperationForColumnId(_: string) { - return { - id: 'a', - dataType: 'number', - isBucketed: false, - label: 'shazm', - }; - }, - }; - const frame = { - ...mockFrame(), - datasourceLayers: { l1: datasource }, - }; - - const error = metricVisualization.getErrorMessages(exampleState(), frame); + const error = metricVisualization.getErrorMessages(exampleState()); expect(error).not.toBeDefined(); }); diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index b86ba71083440f..91516b7b7319b9 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -117,7 +117,7 @@ export const metricVisualization: Visualization = { return { ...prevState, accessor: undefined }; }, - getErrorMessages(state, frame) { + getErrorMessages(state) { // Is it possible to break it? return undefined; }, diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 5ec97e90e57d91..e3bd54032a93cf 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -17,6 +17,7 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { Position } from '@elastic/charts'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PieVisualizationState, SharedPieLayerState } from './types'; import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; @@ -250,11 +251,15 @@ const DecimalPlaceSlider = ({ ); }; -export function DimensionEditor(props: VisualizationDimensionEditorProps) { +export function DimensionEditor( + props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + } +) { return ( <> { props.setState({ ...props.state, palette: newPalette }); diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts index 52fd4daac63c5f..0cdeaa8c043d83 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts @@ -7,8 +7,6 @@ import { getPieVisualization } from './visualization'; import { PieVisualizationState } from './types'; -import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; -import { DatasourcePublicAPI, FramePublicAPI } from '../types'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; jest.mock('../id_generator'); @@ -36,37 +34,11 @@ function exampleState(): PieVisualizationState { }; } -function mockFrame(): FramePublicAPI { - return { - ...createMockFramePublicAPI(), - addNewLayer: () => LAYER_ID, - datasourceLayers: { - [LAYER_ID]: createMockDatasource(LAYER_ID).publicAPIMock, - }, - }; -} - // Just a basic bootstrap here to kickstart the tests describe('pie_visualization', () => { describe('#getErrorMessages', () => { it('returns undefined if no error is raised', () => { - const datasource: DatasourcePublicAPI = { - ...createMockDatasource('l1').publicAPIMock, - getOperationForColumnId(_: string) { - return { - id: 'a', - dataType: 'number', - isBucketed: false, - label: 'shazm', - }; - }, - }; - const frame = { - ...mockFrame(), - datasourceLayers: { l1: datasource }, - }; - - const error = pieVisualization.getErrorMessages(exampleState(), frame); + const error = pieVisualization.getErrorMessages(exampleState()); expect(error).not.toBeDefined(); }); diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 6408d7496d332d..683acc49859b68 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -227,7 +227,7 @@ export const getPieVisualization = ({ renderDimensionEditor(domElement, props) { render( - + , domElement ); @@ -274,7 +274,7 @@ export const getPieVisualization = ({ )); }, - getErrorMessages(state, frame) { + getErrorMessages(state) { // not possible to break it? return undefined; }, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index cccc35acb3fca1..ba02a3376bae7c 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -358,7 +358,7 @@ export interface LensMultiTable { export interface VisualizationConfigProps { layerId: string; - frame: FramePublicAPI; + frame: Pick; state: T; } @@ -631,10 +631,7 @@ export interface Visualization { * The frame will call this function on all visualizations at few stages (pre-build/build error) in order * to provide more context to the error and show it to the user */ - getErrorMessages: ( - state: T, - frame: FramePublicAPI - ) => Array<{ shortMessage: string; longMessage: string }> | undefined; + getErrorMessages: (state: T) => Array<{ shortMessage: string; longMessage: string }> | undefined; /** * The frame calls this function to display warnings about visualization diff --git a/x-pack/plugins/lens/public/visualization_container.scss b/x-pack/plugins/lens/public/visualization_container.scss index a67aa50127c813..d40a0b48ab40eb 100644 --- a/x-pack/plugins/lens/public/visualization_container.scss +++ b/x-pack/plugins/lens/public/visualization_container.scss @@ -15,3 +15,11 @@ position: static; // Let the progress indicator position itself against the outer parent } } + +.lnsEmbeddedError { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index 27cc16ebf862bd..d2e87ece5b5ec8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -95,7 +95,7 @@ export function getColorAssignments( export function getAccessorColorConfig( colorAssignments: ColorAssignments, - frame: FramePublicAPI, + frame: Pick, layer: XYLayerConfig, paletteService: PaletteRegistry ): AccessorConfig[] { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index cdb7f452cf7cf2..c244fa7fdfc899 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -589,137 +589,119 @@ describe('xy_visualization', () => { describe('#getErrorMessages', () => { it("should not return an error when there's only one dimension (X or Y)", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + ], + }) ).not.toBeDefined(); }); it("should not return an error when there's only one dimension on multiple layers (same axis everywhere)", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + ], + }) ).not.toBeDefined(); }); it('should not return an error when mixing different valid configurations in multiple layers', () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: ['a'], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: ['a'], + splitAccessor: 'a', + }, + ], + }) ).not.toBeDefined(); }); it("should not return an error when there's only one splitAccessor dimension configured", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + ], + }) ).not.toBeDefined(); expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + ], + }) ).not.toBeDefined(); }); it('should return an error when there are multiple layers, one axis configured for each layer (but different axis from each other)', () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: ['a'], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: ['a'], + }, + ], + }) ).toEqual([ { shortMessage: 'Missing Vertical axis.', @@ -729,34 +711,31 @@ describe('xy_visualization', () => { }); it('should return an error with batched messages for the same error with multiple layers', () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - { - layerId: 'third', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + { + layerId: 'third', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + ], + }) ).toEqual([ { shortMessage: 'Missing Vertical axis.', @@ -766,32 +745,29 @@ describe('xy_visualization', () => { }); it("should return an error when some layers are complete but other layers aren't", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - { - layerId: 'third', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + { + layerId: 'third', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + ], + }) ).toEqual([ { shortMessage: 'Missing Vertical axis.', diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index a4dc7a91822bd1..1ee4b2e050f3ec 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -340,7 +340,7 @@ export const getXyVisualization = ({ toExpression(state, layers, paletteService, attributes), toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), - getErrorMessages(state, frame) { + getErrorMessages(state) { // Data error handling below here const hasNoAccessors = ({ accessors }: XYLayerConfig) => accessors == null || accessors.length === 0; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index dd5fff3c49f4fd..14733ad0f613bd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -508,7 +508,7 @@ export function DimensionEditor( return ( <> { setState(updateLayer(state, { ...layer, palette: newPalette }, index)); diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index 5cbd5dff45e1e0..0d2db53ba73af7 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -194,5 +194,39 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardAddPanel.filterEmbeddableNames('lnsPieVis'); await find.existsByLinkText('lnsPieVis'); }); + + it('should show validation messages if any error appears', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + + await dashboardAddPanel.clickCreateNewLink(); + await dashboardAddPanel.clickVisType('lens'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'moving_average', + keepOpen: true, + }); + await PageObjects.lens.configureReference({ + operation: 'sum', + field: 'bytes', + }); + await PageObjects.lens.closeDimensionEditor(); + + // remove the x dimension to trigger the validation error + await PageObjects.lens.removeDimension('lnsXY_xDimensionPanel'); + await PageObjects.lens.saveAndReturn(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('embeddable-lens-failure'); + }); }); } From 2f7689882fa43c0b25ff850ad54a1f4556b3aacb Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Mon, 15 Feb 2021 13:01:23 +0100 Subject: [PATCH 030/175] [Lens] Fix empty display name issue in XY chart (#91132) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../dimension_panel/dimension_editor.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 6dffeb351d2601..8f5da64fcc9a81 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -59,6 +59,9 @@ const LabelInput = ({ value, onChange }: { value: string; onChange: (value: stri const [inputValue, setInputValue] = useState(value); const unflushedChanges = useRef(false); + // Save the initial value + const initialValue = useRef(value); + const onChangeDebounced = useMemo(() => { const callback = _.debounce((val: string) => { onChange(val); @@ -79,7 +82,7 @@ const LabelInput = ({ value, onChange }: { value: string; onChange: (value: stri const handleInputChange = (e: React.ChangeEvent) => { const val = String(e.target.value); setInputValue(val); - onChangeDebounced(val); + onChangeDebounced(val || initialValue.current); }; return ( @@ -96,6 +99,7 @@ const LabelInput = ({ value, onChange }: { value: string; onChange: (value: stri data-test-subj="indexPattern-label-edit" value={inputValue} onChange={handleInputChange} + placeholder={initialValue.current} /> ); From bac1d13df664ab988f7d08a1d1c77c9ac52922f5 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Mon, 15 Feb 2021 13:16:20 +0100 Subject: [PATCH 031/175] [ML] Unskip test. Fix modelMemoryLimit value. (#91280) Unskips test and updates modelMemoryLimit value. --- x-pack/test/api_integration/apis/ml/modules/setup_module.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts index c4b3a4ed0adcf8..8e5da7c56bb647 100644 --- a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts @@ -277,7 +277,7 @@ export default ({ getService }: FtrProviderContext) => { jobId: 'pf7_log-entry-categories-count', jobState: JOB_STATE.CLOSED, datafeedState: DATAFEED_STATE.STOPPED, - modelMemoryLimit: '26mb', + modelMemoryLimit: '41mb', }, ], searches: [] as string[], @@ -713,8 +713,7 @@ export default ({ getService }: FtrProviderContext) => { return successObjects; } - // blocks ES snapshot promotion: https://github.com/elastic/kibana/issues/91224 - describe.skip('module setup', function () { + describe('module setup', function () { before(async () => { await ml.testResources.setKibanaTimeZoneToUTC(); }); From 5a5b8ad9a89ce39274bc241a47c8fc0692e229cc Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 15 Feb 2021 13:17:31 +0100 Subject: [PATCH 032/175] [Lens] Adjust new copy for 7.12 (#90413) --- .../__snapshots__/table_basic.test.tsx.snap | 24 +++++++++---------- .../components/columns.tsx | 4 ++-- .../workspace_panel/workspace_panel.tsx | 21 +--------------- .../calculations/moving_average.tsx | 11 ++++----- .../definitions/calculations/utils.ts | 2 +- .../operations/definitions/date_histogram.tsx | 6 ++--- .../definitions/ranges/range_editor.tsx | 8 +++---- .../xy_visualization/xy_config_panel.tsx | 2 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../test/functional/apps/lens/smokescreen.ts | 6 ++--- x-pack/test/functional/apps/lens/table.ts | 6 ++--- .../test/functional/page_objects/lens_page.ts | 2 +- 13 files changed, 34 insertions(+), 60 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap index d69af298018e74..158c7fa4aeec37 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap @@ -107,10 +107,10 @@ exports[`DatatableComponent it renders actions column when there are row actions "showMoveLeft": false, "showMoveRight": false, "showSortAsc": Object { - "label": "Sort asc", + "label": "Sort ascending", }, "showSortDesc": Object { - "label": "Sort desc", + "label": "Sort descending", }, }, "cellActions": undefined, @@ -144,10 +144,10 @@ exports[`DatatableComponent it renders actions column when there are row actions "showMoveLeft": false, "showMoveRight": false, "showSortAsc": Object { - "label": "Sort asc", + "label": "Sort ascending", }, "showSortDesc": Object { - "label": "Sort desc", + "label": "Sort descending", }, }, "cellActions": undefined, @@ -181,10 +181,10 @@ exports[`DatatableComponent it renders actions column when there are row actions "showMoveLeft": false, "showMoveRight": false, "showSortAsc": Object { - "label": "Sort asc", + "label": "Sort ascending", }, "showSortDesc": Object { - "label": "Sort desc", + "label": "Sort descending", }, }, "cellActions": undefined, @@ -329,10 +329,10 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "showMoveLeft": false, "showMoveRight": false, "showSortAsc": Object { - "label": "Sort asc", + "label": "Sort ascending", }, "showSortDesc": Object { - "label": "Sort desc", + "label": "Sort descending", }, }, "cellActions": undefined, @@ -366,10 +366,10 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "showMoveLeft": false, "showMoveRight": false, "showSortAsc": Object { - "label": "Sort asc", + "label": "Sort ascending", }, "showSortDesc": Object { - "label": "Sort desc", + "label": "Sort descending", }, }, "cellActions": undefined, @@ -403,10 +403,10 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "showMoveLeft": false, "showMoveRight": false, "showSortAsc": Object { - "label": "Sort asc", + "label": "Sort ascending", }, "showSortDesc": Object { - "label": "Sort desc", + "label": "Sort descending", }, }, "cellActions": undefined, diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx index 5ff1e84276ba77..fdb05599c38e99 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -152,14 +152,14 @@ export const createGridColumns = ( ? false : { label: i18n.translate('xpack.lens.table.sort.ascLabel', { - defaultMessage: 'Sort asc', + defaultMessage: 'Sort ascending', }), }, showSortDesc: isReadOnly ? false : { label: i18n.translate('xpack.lens.table.sort.descLabel', { - defaultMessage: 'Sort desc', + defaultMessage: 'Sort descending', }), }, additional: isReadOnly diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 219c0638f9b56c..83d2100a832cfd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -10,16 +10,7 @@ import classNames from 'classnames'; import { FormattedMessage } from '@kbn/i18n/react'; import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiText, - EuiTextColor, - EuiButtonEmpty, - EuiLink, - EuiTitle, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiButtonEmpty, EuiLink } from '@elastic/eui'; import { CoreStart, CoreSetup } from 'kibana/public'; import { DataPublicPluginStart, @@ -420,16 +411,6 @@ export const InnerVisualizationWrapper = ({ - - - - - - - {localState.configurationValidationError[0].longMessage} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index 7fdc58b74e5093..bc361973bb62cf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -192,22 +192,21 @@ const MovingAveragePopup = () => {

@@ -227,7 +226,7 @@ const MovingAveragePopup = () => {

@@ -85,16 +100,6 @@ export function DocViewTable({ const isCollapsible = value.length > COLLAPSE_LINE_LENGTH; const isCollapsed = isCollapsible && !fieldRowOpen[field]; - const toggleColumn = - onRemoveColumn && onAddColumn && Array.isArray(columns) - ? () => { - if (columns.includes(field)) { - onRemoveColumn(field); - } else { - onAddColumn(field); - } - } - : undefined; const displayUnderscoreWarning = !mapping(field) && field.indexOf('_') === 0; const fieldType = isNestedFieldParent(field, indexPattern) @@ -109,10 +114,10 @@ export function DocViewTable({ displayUnderscoreWarning={displayUnderscoreWarning} isCollapsed={isCollapsed} isCollapsible={isCollapsible} - isColumnActive={Array.isArray(columns) && columns.includes(field)} + isColumnActive={!!columns?.includes(field)} onFilter={filter} onToggleCollapse={() => toggleValueCollapse(field)} - onToggleColumn={toggleColumn} + onToggleColumn={() => toggleColumn(field)} value={value} valueRaw={valueRaw} /> @@ -123,7 +128,7 @@ export function DocViewTable({ data-test-subj={`tableDocViewRow-multifieldsTitle-${field}`} > - - + {columns} ); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js b/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js index cb9baaa4112bb9..fbec412c30f48d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js @@ -7,6 +7,7 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { getDisplayName } from './lib/get_display_name'; import { labelDateFormatter } from './lib/label_date_formatter'; import { findIndex, first } from 'lodash'; @@ -22,7 +23,7 @@ export function visWithSplits(WrappedComponent) { const splitsVisData = visData[model.id].series.reduce((acc, series) => { const [seriesId, splitId] = series.id.split(':'); const seriesModel = model.series.find((s) => s.id === seriesId); - if (!seriesModel || !splitId) return acc; + if (!seriesModel) return acc; const label = series.splitByLabel; @@ -80,7 +81,12 @@ export function visWithSplits(WrappedComponent) { model={model} visData={newVisData} onBrush={props.onBrush} - additionalLabel={additionalLabel} + additionalLabel={ + additionalLabel || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + }) + } backgroundColor={props.backgroundColor} getConfig={props.getConfig} /> diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index 9ca53edc50ce92..4ec60661ffed29 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -9,6 +9,7 @@ import React, { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { i18n } from '@kbn/i18n'; import { labelDateFormatter } from '../../../components/lib/label_date_formatter'; import { @@ -188,7 +189,12 @@ export const TimeSeries = ({ key={key} seriesId={id} seriesGroupId={groupId} - name={seriesName} + name={ + seriesName || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + }) + } data={data} hideInLegend={hideInLegend} bars={bars} @@ -213,7 +219,12 @@ export const TimeSeries = ({ key={key} seriesId={id} seriesGroupId={groupId} - name={seriesName} + name={ + seriesName || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + }) + } data={data} hideInLegend={hideInLegend} lines={lines} diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js index 42d6d095d06480..542ee0871fdcb7 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js @@ -8,6 +8,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; import { getLastValue } from '../../../../common/get_last_value'; import { labelDateFormatter } from '../../components/lib/label_date_formatter'; import reactcss from 'reactcss'; @@ -103,6 +104,7 @@ export class TopN extends Component { // if both are 0, the division returns NaN causing unexpected behavior. // For this it defaults to 0 const width = 100 * (Math.abs(lastValue) / intervalLength) || 0; + const label = item.labelFormatted ? labelDateFormatter(item.labelFormatted) : item.label; const styles = reactcss( { @@ -128,7 +130,10 @@ export class TopN extends Component { return (
  + {i18n.translate('discover.fieldChooser.discoverField.multiFields', { defaultMessage: 'Multi fields', @@ -142,10 +147,12 @@ export function DocViewTable({ displayUnderscoreWarning={displayUnderscoreWarning} isCollapsed={isCollapsed} isCollapsible={isCollapsible} - isColumnActive={Array.isArray(columns) && columns.includes(field)} + isColumnActive={Array.isArray(columns) && columns.includes(multiField)} onFilter={filter} - onToggleCollapse={() => toggleValueCollapse(field)} - onToggleColumn={toggleColumn} + onToggleCollapse={() => { + toggleValueCollapse(multiField); + }} + onToggleColumn={() => toggleColumn(multiField)} value={value} valueRaw={valueRaw} /> From 42e11e6763472a1e25c0854506a618fe06c33bde Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Mon, 15 Feb 2021 07:48:16 -0700 Subject: [PATCH 042/175] [data.search.session] Server telemetry on search sessions (#91256) * [data.search.session] Server telemetry on search sessions * Update telemetry mappings * Added tests and logger Co-authored-by: Liza K --- .../server/collectors/fetch.test.ts | 94 +++++++++++++++++++ .../data_enhanced/server/collectors/fetch.ts | 59 ++++++++++++ .../data_enhanced/server/collectors/index.ts | 8 ++ .../server/collectors/register.ts | 38 ++++++++ x-pack/plugins/data_enhanced/server/plugin.ts | 5 + .../schema/xpack_plugins.json | 13 +++ 6 files changed, 217 insertions(+) create mode 100644 x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts create mode 100644 x-pack/plugins/data_enhanced/server/collectors/fetch.ts create mode 100644 x-pack/plugins/data_enhanced/server/collectors/index.ts create mode 100644 x-pack/plugins/data_enhanced/server/collectors/register.ts diff --git a/x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts b/x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts new file mode 100644 index 00000000000000..380cc0e3545021 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/fetch.test.ts @@ -0,0 +1,94 @@ +/* + * 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 { + SharedGlobalConfig, + ElasticsearchClient, + SavedObjectsErrorHelpers, + Logger, +} from '../../../../../src/core/server'; +import { BehaviorSubject } from 'rxjs'; +import { fetchProvider } from './fetch'; +import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; + +describe('fetchProvider', () => { + let fetchFn: any; + let esClient: jest.Mocked; + let mockLogger: Logger; + + beforeEach(async () => { + const config$ = new BehaviorSubject({ + kibana: { + index: '123', + }, + } as any); + mockLogger = { + warn: jest.fn(), + debug: jest.fn(), + } as any; + esClient = elasticsearchServiceMock.createElasticsearchClient(); + fetchFn = fetchProvider(config$, mockLogger); + }); + + test('returns when ES returns no results', async () => { + esClient.search.mockResolvedValue({ + statusCode: 200, + body: { + aggregations: { + persisted: { + buckets: [], + }, + }, + }, + } as any); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(0); + expect(collRes.persistedCount).toBe(0); + expect(collRes.totalCount).toBe(0); + expect(mockLogger.warn).not.toBeCalled(); + }); + + test('returns when ES throws an error', async () => { + esClient.search.mockRejectedValue( + SavedObjectsErrorHelpers.createTooManyRequestsError('a', 'b') + ); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(0); + expect(collRes.persistedCount).toBe(0); + expect(collRes.totalCount).toBe(0); + expect(mockLogger.warn).toBeCalledTimes(1); + }); + + test('returns when ES returns full buckets', async () => { + esClient.search.mockResolvedValue({ + statusCode: 200, + body: { + aggregations: { + persisted: { + buckets: [ + { + key_as_string: 'true', + doc_count: 10, + }, + { + key_as_string: 'false', + doc_count: 7, + }, + ], + }, + }, + }, + } as any); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(7); + expect(collRes.persistedCount).toBe(10); + expect(collRes.totalCount).toBe(17); + }); +}); diff --git a/x-pack/plugins/data_enhanced/server/collectors/fetch.ts b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts new file mode 100644 index 00000000000000..428de148fdd4f0 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts @@ -0,0 +1,59 @@ +/* + * 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 { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { SearchResponse } from 'elasticsearch'; +import { SharedGlobalConfig, Logger } from 'kibana/server'; +import { CollectorFetchContext } from '../../../../../src/plugins/usage_collection/server'; +import { SEARCH_SESSION_TYPE } from '../../common'; +import { ReportedUsage } from './register'; + +interface SessionPersistedTermsBucket { + key_as_string: 'false' | 'true'; + doc_count: number; +} + +export function fetchProvider(config$: Observable, logger: Logger) { + return async ({ esClient }: CollectorFetchContext): Promise => { + try { + const config = await config$.pipe(first()).toPromise(); + const { body: esResponse } = await esClient.search>({ + index: config.kibana.index, + body: { + size: 0, + aggs: { + persisted: { + terms: { + field: `${SEARCH_SESSION_TYPE}.persisted`, + }, + }, + }, + }, + }); + + const { buckets } = esResponse.aggregations.persisted; + if (!buckets.length) { + return { transientCount: 0, persistedCount: 0, totalCount: 0 }; + } + + const { transientCount = 0, persistedCount = 0 } = buckets.reduce( + (usage: Partial, bucket: SessionPersistedTermsBucket) => { + const key = bucket.key_as_string === 'false' ? 'transientCount' : 'persistedCount'; + return { ...usage, [key]: bucket.doc_count }; + }, + {} + ); + const totalCount = transientCount + persistedCount; + logger.debug(`fetchProvider | ${persistedCount} persisted | ${transientCount} transient`); + return { transientCount, persistedCount, totalCount }; + } catch (e) { + logger.warn(`fetchProvider | error | ${e.message}`); + return { transientCount: 0, persistedCount: 0, totalCount: 0 }; + } + }; +} diff --git a/x-pack/plugins/data_enhanced/server/collectors/index.ts b/x-pack/plugins/data_enhanced/server/collectors/index.ts new file mode 100644 index 00000000000000..4a82c76e96dee8 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { registerUsageCollector } from './register'; diff --git a/x-pack/plugins/data_enhanced/server/collectors/register.ts b/x-pack/plugins/data_enhanced/server/collectors/register.ts new file mode 100644 index 00000000000000..fe96b7f7ced1b4 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/register.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext, Logger } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { fetchProvider } from './fetch'; + +export interface ReportedUsage { + transientCount: number; + persistedCount: number; + totalCount: number; +} + +export async function registerUsageCollector( + usageCollection: UsageCollectionSetup, + context: PluginInitializerContext, + logger: Logger +) { + try { + const collector = usageCollection.makeUsageCollector({ + type: 'search-session', + isReady: () => true, + fetch: fetchProvider(context.config.legacy.globalConfig$, logger), + schema: { + transientCount: { type: 'long' }, + persistedCount: { type: 'long' }, + totalCount: { type: 'long' }, + }, + }); + usageCollection.registerCollector(collector); + } catch (err) { + return; // kibana plugin is not enabled (test environment) + } +} diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index c3d342b8159e37..1037de4f79ea72 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -24,6 +24,7 @@ import { import { getUiSettings } from './ui_settings'; import type { DataEnhancedRequestHandlerContext } from './type'; import { ConfigSchema } from '../config'; +import { registerUsageCollector } from './collectors'; import { SecurityPluginSetup } from '../../security/server'; interface SetupDependencies { @@ -85,6 +86,10 @@ export class EnhancedDataServerPlugin this.sessionService.setup(core, { taskManager: deps.taskManager, }); + + if (deps.usageCollection) { + registerUsageCollector(deps.usageCollection, this.initializerContext, this.logger); + } } public start(core: CoreStart, { taskManager }: StartDependencies) { diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index d4b07203e8109e..d3487078fd114b 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -3183,6 +3183,19 @@ } } }, + "search-session": { + "properties": { + "transientCount": { + "type": "long" + }, + "persistedCount": { + "type": "long" + }, + "totalCount": { + "type": "long" + } + } + }, "security_solution": { "properties": { "detections": { From 0817e9803261245e98e10ccae93277c85197875d Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 15 Feb 2021 16:14:50 +0100 Subject: [PATCH 043/175] [Lens] Make sure telemetry shape is always the same (#91129) --- .../plugins/lens/server/usage/collectors.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/lens/server/usage/collectors.ts b/x-pack/plugins/lens/server/usage/collectors.ts index 4c20f946b1a44a..2f7a72ba17ea0b 100644 --- a/x-pack/plugins/lens/server/usage/collectors.ts +++ b/x-pack/plugins/lens/server/usage/collectors.ts @@ -13,6 +13,19 @@ import { TaskManagerStartContract } from '../../../task_manager/server'; import { LensUsage, LensTelemetryState } from './types'; import { lensUsageSchema } from './schema'; +const emptyUsageCollection = { + saved_overall: {}, + saved_30_days: {}, + saved_90_days: {}, + saved_overall_total: 0, + saved_30_days_total: 0, + saved_90_days_total: 0, + events_30_days: {}, + events_90_days: {}, + suggestion_events_30_days: {}, + suggestion_events_90_days: {}, +}; + export function registerLensUsageCollector( usageCollection: UsageCollectionSetup, taskManager: Promise @@ -29,6 +42,7 @@ export function registerLensUsageCollector( const suggestions = getDataByDate(state.suggestionsByDate); return { + ...emptyUsageCollection, ...state.saved, events_30_days: events.last30, events_90_days: events.last90, @@ -36,19 +50,7 @@ export function registerLensUsageCollector( suggestion_events_90_days: suggestions.last90, }; } catch (err) { - return { - saved_overall_total: 0, - saved_30_days_total: 0, - saved_90_days_total: 0, - saved_overall: {}, - saved_30_days: {}, - saved_90_days: {}, - - events_30_days: {}, - events_90_days: {}, - suggestion_events_30_days: {}, - suggestion_events_90_days: {}, - }; + return emptyUsageCollection; } }, isReady: async () => { From 8127103a710325344964be93a559e6e042073385 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Mon, 15 Feb 2021 16:16:39 +0100 Subject: [PATCH 044/175] [Lens] Fix partial move on datatable (#90630) * [Lens] Fix partial move on datatable * test * fixing test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../config_panel/layer_panel.test.tsx | 72 +++++++++++++++++++ .../editor_frame/config_panel/layer_panel.tsx | 18 ++--- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index aece9188d7cf43..1f97399fdd2929 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -697,5 +697,77 @@ describe('LayerPanel', () => { }) ); }); + + it('should call onDrop and update visualization when replacing between compatible groups', () => { + const mockVis = { + ...mockVisualization, + removeDimension: jest.fn(), + setDimension: jest.fn(() => 'modifiedState'), + }; + mockVis.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'a' }, { columnId: 'b' }], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + { + groupLabel: 'B', + groupId: 'b', + accessors: [{ columnId: 'c' }], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup2', + }, + ], + }); + + const draggingOperation = { + layerId: 'first', + columnId: 'a', + groupId: 'a', + id: 'a', + humanData: { label: 'Label' }, + }; + + mockDatasource.onDrop.mockReturnValue({ deleted: 'a' }); + const updateVisualization = jest.fn(); + + const component = mountWithIntl( + + + + ); + act(() => { + component.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible'); + }); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + dropType: 'replace_compatible', + droppedItem: draggingOperation, + }) + ); + expect(mockVis.setDimension).toHaveBeenCalledWith({ + columnId: 'c', + groupId: 'b', + layerId: 'first', + prevState: 'state', + }); + expect(mockVis.removeDimension).toHaveBeenCalledWith( + expect.objectContaining({ + columnId: 'a', + layerId: 'first', + prevState: 'modifiedState', + }) + ); + expect(updateVisualization).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 5ba73e98b42c12..5d84f826ab988c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -161,14 +161,12 @@ export function LayerPanel( dropType, }); if (dropResult) { - updateVisualization( - setDimension({ - columnId, - groupId, - layerId: targetLayerId, - prevState: props.visualizationState, - }) - ); + const newVisState = setDimension({ + columnId, + groupId, + layerId: targetLayerId, + prevState: props.visualizationState, + }); if (typeof dropResult === 'object') { // When a column is moved, we delete the reference to the old @@ -176,9 +174,11 @@ export function LayerPanel( removeDimension({ columnId: dropResult.deleted, layerId: targetLayerId, - prevState: props.visualizationState, + prevState: newVisState, }) ); + } else { + updateVisualization(newVisState); } } }; From bbc24b375ea0020326a1b983ca3bcfa48c30d6f4 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Mon, 15 Feb 2021 16:55:36 +0100 Subject: [PATCH 045/175] Debug flaky test (#90762) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../api_integration/tagging_api/apis/delete.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts index ed4bc8f4f8c7b7..c0cf77c7d2b8c9 100644 --- a/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts +++ b/x-pack/test/saved_object_tagging/api_integration/tagging_api/apis/delete.ts @@ -13,8 +13,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - // FLAKY: https://github.com/elastic/kibana/issues/90552 - describe.skip('DELETE /api/saved_objects_tagging/tags/{id}', () => { + describe('DELETE /api/saved_objects_tagging/tags/{id}', () => { beforeEach(async () => { await esArchiver.load('delete_with_references'); }); @@ -24,9 +23,15 @@ export default function ({ getService }: FtrProviderContext) { }); it('should delete the tag', async () => { - await supertest.get(`/api/saved_objects_tagging/tags/tag-1`).expect(200); - - await supertest.delete(`/api/saved_objects_tagging/tags/tag-1`).expect(200); + const getRes = await supertest.get(`/api/saved_objects_tagging/tags/tag-1`); + // eslint-disable-next-line no-console + console.trace('%O', getRes.body); + expect(getRes.status).to.eql(200); + + const delRes = await supertest.delete(`/api/saved_objects_tagging/tags/tag-1`); + // eslint-disable-next-line no-console + console.trace('%O', delRes.body); + expect(delRes.status).to.eql(200); await supertest.get(`/api/saved_objects_tagging/tags/tag-1`).expect(404); }); From 4d4856c9ceab0241884dd8ed7e574c5e6a33a445 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Mon, 15 Feb 2021 16:47:28 +0000 Subject: [PATCH 046/175] [Discover] Fix icon for conflicting fields (#90641) * [Discover] Fix icon for conflicting fields * Fix tooltip text a bit * Fix failing snapshot * Minor code fix Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../sidebar/discover_field.test.tsx | 16 ++++++++ .../components/sidebar/discover_field.tsx | 37 ++++++++++++++++++- .../__snapshots__/field_icon.test.tsx.snap | 3 +- .../public/field_icon/field_icon.tsx | 2 +- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx index c16dab618b284c..54a2de14e2e26e 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx @@ -114,4 +114,20 @@ describe('discover sidebar field', function () { findTestSubject(comp, 'field-_source-showDetails').simulate('click'); expect(props.getDetails).not.toHaveBeenCalled(); }); + it('displays warning for conflicting fields', function () { + const field = new IndexPatternField({ + name: 'troubled_field', + type: 'conflict', + esTypes: ['integer', 'text'], + searchable: true, + aggregatable: true, + readFromDocValues: false, + }); + const { comp } = getComponent({ + selected: true, + field, + }); + const dscField = findTestSubject(comp, 'field-troubled_field-showDetails'); + expect(dscField.find('.kbnFieldButton__infoIcon').length).toEqual(1); + }); }); diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx index fa857bbfcbbe44..8cd63f09e0d2cf 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -9,7 +9,14 @@ import './discover_field.scss'; import React, { useState } from 'react'; -import { EuiPopover, EuiPopoverTitle, EuiButtonIcon, EuiToolTip, EuiTitle } from '@elastic/eui'; +import { + EuiPopover, + EuiPopoverTitle, + EuiButtonIcon, + EuiToolTip, + EuiTitle, + EuiIcon, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { UiCounterMetricType } from '@kbn/analytics'; import classNames from 'classnames'; @@ -217,6 +224,33 @@ export function DiscoverField({ ); } + const getFieldInfoIcon = () => { + if (field.type !== 'conflict') { + return null; + } + return ( + + + + ); + }; + + const fieldInfoIcon = getFieldInfoIcon(); + const shouldRenderMultiFields = !!multiFields; const renderMultiFields = () => { if (!multiFields) { @@ -263,6 +297,7 @@ export function DiscoverField({ fieldIcon={dscFieldIcon} fieldAction={actionButton} fieldName={fieldName} + fieldInfoIcon={fieldInfoIcon} /> } isOpen={infoIsOpen} diff --git a/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap b/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap index cde6a625ac8e86..07697fc036d1f9 100644 --- a/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap +++ b/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap @@ -47,8 +47,9 @@ exports[`FieldIcon renders known field types conflict is rendered 1`] = ` diff --git a/src/plugins/kibana_react/public/field_icon/field_icon.tsx b/src/plugins/kibana_react/public/field_icon/field_icon.tsx index b466568037d941..9e3178c6b5b057 100644 --- a/src/plugins/kibana_react/public/field_icon/field_icon.tsx +++ b/src/plugins/kibana_react/public/field_icon/field_icon.tsx @@ -34,7 +34,7 @@ const defaultIcon = { iconType: 'questionInCircle', color: 'gray' }; export const typeToEuiIconMap: Partial> = { boolean: { iconType: 'tokenBoolean' }, // icon for an index pattern mapping conflict in discover - conflict: { iconType: 'alert', color: 'euiVisColor9' }, + conflict: { iconType: 'alert', color: 'euiColorVis9', shape: 'square' }, date: { iconType: 'tokenDate' }, geo_point: { iconType: 'tokenGeo' }, geo_shape: { iconType: 'tokenGeo' }, From 91d3a6ac94d841e8f959bdf085948b1747a3579d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Mon, 15 Feb 2021 18:00:47 +0100 Subject: [PATCH 047/175] [ILM] Copy update (#91100) * Added copy adjustments, fixed wrong capitalization of titles and swap primary buttons back to correct order * Apply suggestions from code review Co-authored-by: James Rodewig <40268737+jrodewig@users.noreply.github.com> Co-authored-by: debadair * Fixed eslint issues and added labels for buttons Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: James Rodewig <40268737+jrodewig@users.noreply.github.com> Co-authored-by: debadair --- .../components/phase_footer/phase_footer.tsx | 39 +++++++-------- .../sections/edit_policy/edit_policy.tsx | 50 +++++++++---------- .../sections/edit_policy/i18n_texts.ts | 11 ++-- 3 files changed, 47 insertions(+), 53 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx index 82f0725bfe7d0f..22422ceab8a040 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx @@ -17,6 +17,20 @@ import { usePhaseTimings } from '../../form'; import { InfinityIconSvg } from '../infinity_icon/infinity_icon.svg'; +const deleteDataLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.phaseTiming.beforeDeleteDescription', + { + defaultMessage: 'Delete data after this phase', + } +); + +const keepDataLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.phaseTiming.foreverTimingDescription', + { + defaultMessage: 'Keep data in this phase forever', + } +); + interface Props { phase: PhasesExceptDelete; } @@ -31,15 +45,6 @@ export const PhaseFooter: FunctionComponent = ({ phase }) => { if (!phaseConfiguration.isFinalDataPhase) { return null; } - - const phaseDescription = isDeletePhaseEnabled - ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseTiming.beforeDeleteDescription', { - defaultMessage: 'Data will be deleted after this phase', - }) - : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseTiming.foreverTimingDescription', { - defaultMessage: 'Data will remain in this phase forever', - }); - const selectedButton = isDeletePhaseEnabled ? 'ilmEnableDeletePhaseButton' : 'ilmDisableDeletePhaseButton'; @@ -47,22 +52,12 @@ export const PhaseFooter: FunctionComponent = ({ phase }) => { const buttons = [ { id: `ilmDisableDeletePhaseButton`, - label: i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.disablePhaseButtonLabel', - { - defaultMessage: 'Keep data in this phase forever', - } - ), + label: keepDataLabel, iconType: InfinityIconSvg, }, { id: `ilmEnableDeletePhaseButton`, - label: i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.enablePhaseButtonLabel', - { - defaultMessage: 'Delete data after this phase', - } - ), + label: deleteDataLabel, iconType: 'trash', 'data-test-subj': 'enableDeletePhaseButton', }, @@ -72,7 +67,7 @@ export const PhaseFooter: FunctionComponent = ({ phase }) => { - {phaseDescription} + {isDeletePhaseEnabled ? deleteDataLabel : keepDataLabel} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 637fbd893aaa05..befb8faf51aa19 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -258,33 +258,8 @@ export const EditPolicy: React.FunctionComponent = ({ history }) => { - - - {isShowingPolicyJsonFlyout ? ( - - ) : ( - - )} - - - - - - - - - = ({ history }) => { )} + + + + + + + + + + {isShowingPolicyJsonFlyout ? ( + + ) : ( + + )} + + {isShowingPolicyJsonFlyout ? ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 3923cf93cd0d33..1d75fb5031216e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -189,27 +189,26 @@ export const i18nTexts = { defaultMessage: 'Cold phase', }), delete: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.deletePhase.deletePhaseTitle', { - defaultMessage: 'Delete Data', + defaultMessage: 'Delete phase', }), }, descriptions: { hot: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseDescription', { defaultMessage: - 'You actively store and query data in the hot phase. All policies have a hot phase.', + 'Store your most-recent, most frequently-searched data in the hot tier, which provides the best indexing and search performance at the highest cost.', }), warm: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseDescription', { defaultMessage: - 'You are still querying your index, but it is read-only. You can allocate shards to less performant hardware. For faster searches, you can reduce the number of shards and force merge segments.', + 'Move data to the warm tier, which is optimized for search performance over indexing performance. Data is infrequently added or updated in the warm phase.', }), cold: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescription', { defaultMessage: - 'You are querying your index less frequently, so you can allocate shards on significantly less performant hardware. Because your queries are slower, you can reduce the number of replicas.', + 'Move data to the cold tier, which is optimized for cost savings over search performance. Data is normally read-only in the cold phase.', }), delete: i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.deletePhaseDescription', { - defaultMessage: - 'You no longer need your index. You can define when it is safe to delete it.', + defaultMessage: 'Delete data you no longer need.', } ), }, From 643794eb9f2b1e14fb5c4ef4f357743c8c8bea50 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Mon, 15 Feb 2021 19:06:40 +0200 Subject: [PATCH 048/175] fix ui counters flaky test (#91372) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apis/ui_counters/ui_counters.ts | 71 ++++++++++++------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index c287e73e3ace9c..2d55e224f31cee 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -10,11 +10,12 @@ import expect from '@kbn/expect'; import { ReportManager, METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { SavedObject } from '../../../../src/core/server'; +import { UICounterSavedObjectAttributes } from '../../../../src/plugins/kibana_usage_collection/server/collectors/ui_counters/ui_counter_saved_object_type'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const es = getService('es'); const createUiCounterEvent = (eventName: string, type: UiCounterMetricType, count = 1) => ({ eventName, @@ -23,12 +24,21 @@ export default function ({ getService }: FtrProviderContext) { count, }); - // FLAKY: https://github.com/elastic/kibana/issues/85086 - describe.skip('UI Counters API', () => { - before(async () => { - await esArchiver.emptyKibanaIndex(); - }); + const getCounterById = ( + savedObjects: Array>, + targetId: string + ): SavedObject => { + const savedObject = savedObjects.find(({ id }: { id: string }) => id === targetId); + if (!savedObject) { + throw new Error(`Unable to find savedObject id ${targetId}`); + } + + return savedObject; + }; + + describe('UI Counters API', () => { const dayDate = moment().format('DDMMYYYY'); + before(async () => await esArchiver.emptyKibanaIndex()); it('stores ui counter events in savedObjects', async () => { const reportManager = new ReportManager(); @@ -44,12 +54,18 @@ export default function ({ getService }: FtrProviderContext) { .send({ report }) .expect(200); - const { body: response } = await es.search({ index: '.kibana', q: 'type:ui-counter' }); + const { + body: { saved_objects: savedObjects }, + } = await supertest + .get('/api/saved_objects/_find?type=ui-counter') + .set('kbn-xsrf', 'kibana') + .expect(200); - const ids = response.hits.hits.map(({ _id }: { _id: string }) => _id); - expect(ids.includes(`ui-counter:myApp:${dayDate}:${METRIC_TYPE.COUNT}:my_event`)).to.eql( - true + const countTypeEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.COUNT}:my_event` ); + expect(countTypeEvent.attributes.count).to.eql(1); }); it('supports multiple events', async () => { @@ -70,28 +86,29 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); const { - body: { - hits: { hits }, - }, - } = await es.search({ index: '.kibana', q: 'type:ui-counter' }); - - const countTypeEvent = hits.find( - (hit: { _id: string }) => - hit._id === `ui-counter:myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}` + body: { saved_objects: savedObjects }, + } = await supertest + .get('/api/saved_objects/_find?type=ui-counter&fields=count') + .set('kbn-xsrf', 'kibana') + .expect(200); + + const countTypeEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}` ); - expect(countTypeEvent._source['ui-counter'].count).to.eql(1); + expect(countTypeEvent.attributes.count).to.eql(1); - const clickTypeEvent = hits.find( - (hit: { _id: string }) => - hit._id === `ui-counter:myApp:${dayDate}:${METRIC_TYPE.CLICK}:${uniqueEventName}` + const clickTypeEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.CLICK}:${uniqueEventName}` ); - expect(clickTypeEvent._source['ui-counter'].count).to.eql(2); + expect(clickTypeEvent.attributes.count).to.eql(2); - const secondEvent = hits.find( - (hit: { _id: string }) => - hit._id === `ui-counter:myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}_2` + const secondEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}_2` ); - expect(secondEvent._source['ui-counter'].count).to.eql(1); + expect(secondEvent.attributes.count).to.eql(1); }); }); } From 4d0144001214d15fe4ffc24f63a9a197e87a53b2 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Mon, 15 Feb 2021 20:17:21 +0300 Subject: [PATCH 049/175] [Discover] Could not expand cell content in DiscoverGrid (#91289) Closes: #90796 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/discover_grid/constants.ts | 1 - .../discover_grid_columns.test.tsx | 14 ++++---- .../discover_grid/discover_grid_schema.tsx | 33 ++++--------------- .../get_render_cell_value.test.tsx | 8 ++++- .../discover_grid/get_render_cell_value.tsx | 2 +- 5 files changed, 22 insertions(+), 36 deletions(-) diff --git a/src/plugins/discover/public/application/components/discover_grid/constants.ts b/src/plugins/discover/public/application/components/discover_grid/constants.ts index 795cdbc9c48f72..03e5740793396e 100644 --- a/src/plugins/discover/public/application/components/discover_grid/constants.ts +++ b/src/plugins/discover/public/application/components/discover_grid/constants.ts @@ -9,7 +9,6 @@ // data types export const kibanaJSON = 'kibana-json'; export const geoPoint = 'geo-point'; -export const unknownType = 'unknown'; export const gridStyle = { border: 'all', fontSize: 's', diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx index 2857001b2443e3..2317b8841a37ab 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx @@ -28,7 +28,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "extension", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, Object { "actions": Object { @@ -43,7 +43,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "message", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, ] `); @@ -68,7 +68,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "extension", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, Object { "actions": Object { @@ -80,7 +80,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "message", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, ] `); @@ -106,7 +106,7 @@ describe('Discover grid columns ', function () { "id": "timestamp", "initialWidth": 180, "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, Object { "actions": Object { @@ -121,7 +121,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "extension", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, Object { "actions": Object { @@ -136,7 +136,7 @@ describe('Discover grid columns ', function () { "display": undefined, "id": "message", "isSortable": undefined, - "schema": "unknown", + "schema": "kibana-json", }, ] `); diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx index 945cbbcb8a8321..83ade88386dbc2 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -import React, { ReactNode } from 'react'; -import { EuiCodeBlock } from '@elastic/eui'; -import { geoPoint, kibanaJSON, unknownType } from './constants'; +import React from 'react'; +import { EuiCodeBlock, EuiDataGridPopoverContents } from '@elastic/eui'; +import { geoPoint, kibanaJSON } from './constants'; import { KBN_FIELD_TYPES } from '../../../../../data/common'; export function getSchemaByKbnType(kbnType: string | undefined) { @@ -24,12 +24,10 @@ export function getSchemaByKbnType(kbnType: string | undefined) { return 'string'; case KBN_FIELD_TYPES.DATE: return 'datetime'; - case KBN_FIELD_TYPES._SOURCE: - return kibanaJSON; case KBN_FIELD_TYPES.GEO_POINT: return geoPoint; default: - return unknownType; + return kibanaJSON; } } @@ -45,16 +43,6 @@ export function getSchemaDetectors() { icon: '', color: '', }, - { - type: unknownType, - detector() { - return 0; // this schema is always explicitly defined - }, - sortTextAsc: '', - sortTextDesc: '', - icon: '', - color: '', - }, { type: geoPoint, detector() { @@ -70,19 +58,12 @@ export function getSchemaDetectors() { /** * Returns custom popover content for certain schemas */ -export function getPopoverContents() { +export function getPopoverContents(): EuiDataGridPopoverContents { return { - [geoPoint]: ({ children }: { children: ReactNode }) => { + [geoPoint]: ({ children }) => { return {children}; }, - [unknownType]: ({ children }: { children: ReactNode }) => { - return ( - - {children} - - ); - }, - [kibanaJSON]: ({ children }: { children: ReactNode }) => { + [kibanaJSON]: ({ children }) => { return ( {children} diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx index 594aaac2168d4a..786d7bc74bf6b9 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx @@ -78,7 +78,13 @@ describe('Discover grid cell rendering', function () { ); expect(component.html()).toMatchInlineSnapshot(` "{ - "bytes": 100 + "_id": "1", + "_index": "test", + "_type": "test", + "_score": 1, + "_source": { + "bytes": 100 + } }" `); }); diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx index 6ed19813830c85..45f30a9d26f935 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx @@ -55,7 +55,7 @@ export const getRenderCellValueFn = ( if (field && field.type === '_source') { if (isDetails) { // nicely formatted JSON for the expanded view - return {JSON.stringify(row[columnId], null, 2)}; + return {JSON.stringify(row, null, 2)}; } const formatted = indexPattern.formatHit(row); From 8ed1c3ca3e9b2758b0db6903587075ebfcfb7e84 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 15 Feb 2021 18:24:58 +0100 Subject: [PATCH 050/175] Url template editor (#88577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 set up Storybook for URL template editor * feat: 🎸 add basic syntax highlighting * feat: 🎸 add autocompletion example * feat: 🎸 add Handlebars language * fix: 🐛 first register language * feat: 🎸 add url and handlebars language parsing * feat: 🎸 use simple Handlebars language * refactor: 💡 move to a separate file * feat: 🎸 add Monaco editor to URL drilldown * feat: 🎸 remove editor line numbers * feat: 🎸 allow user to provide Handlebars variables * feat: 🎸 wire in URL drilldown variables into Monaco editor * feat: 🎸 add metadata to event level variables * feat: 🎸 allow to specify Handlebars variable kind * feat: 🎸 add global variables to autocompletion * refactor: 💡 restructure event and context variable code * feat: 🎸 sort variables by scope group * feat: 🎸 add meta information to context variables * docs: ✏️ use correct variable labels * feat: 🎸 fix component demo props * feat: 🎸 improve highlighting of URL parts * feat: 🎸 improve syntax highlighting colors * feat: 🎸 improve highlighting colors * feat: 🎸 add color to url query parameter key * feat: 🎸 improve visual layout url editor * feat: 🎸 highlight URL slashes with light color * feat: 🎸 connect URL editor to state * feat: 🎸 tweak URL parameter colors * feat: 🎸 improve URL schema color * feat: 🎸 insert variables on click in variable dropdown * fix: 🐛 fix unit tests and translation * test: 💍 fix drilldown tests after refactor * feat: 🎸 add dark mode support to URL template editor * test: 💍 fix URL drilldown test after adding dark mode support * fix: 🐛 use text color which can be converted to dark mode * test: 💍 fill in URL template in monaco editor * fix: 🐛 fix translation key * chore: 🤖 update license headers * chore: 🤖 update license headers * feat: 🎸 preview values of global variables * feat: 🎸 preview values of context variables * chore: 🤖 fix url editor Storybook config * fix: 🐛 make translation key unique * feat: 🎸 stop Esc key propagation in URL editor * feat: 🎸 reduce editor height * feat: 🎸 set example URL once URL drilldown is created * feat: 🎸 add word wrapping to URL editor * feat: 🎸 use EUI variable in SCSS * feat: 🎸 add "Example: " prefix to default template * feat: 🎸 do not insert extra brackets * feat: 🎸 make URL param values same color as text * perf: ⚡️ make URL drilldown config component lazy loaded * test: 💍 remove default URL drilldown template * fix: 🐛 disable autocompletion popup while typing * style: 💄 don't use "Example: " prefix in default URL --- docs/user/dashboard/url-drilldown.asciidoc | 6 +- src/dev/storybook/aliases.ts | 1 + .../public/code_editor/editor_theme.ts | 5 +- src/plugins/kibana_react/public/index.ts | 1 + .../url_template_editor/.storybook/main.js | 10 + .../public/url_template_editor/constants.ts | 9 + .../public/url_template_editor/index.ts | 9 + .../public/url_template_editor/language.ts | 198 ++++++++++++ .../public/url_template_editor/styles.scss | 5 + .../url_template_editor.stories.tsx | 45 +++ .../url_template_editor.tsx | 163 ++++++++++ .../public/lib/url_drilldown.test.ts | 6 +- .../public/lib/url_drilldown.tsx | 58 ++-- .../public/lib/url_drilldown_scope.test.ts | 294 ----------------- .../public/lib/url_drilldown_scope.ts | 266 ---------------- .../lib/variables/context_variables.test.ts | 216 +++++++++++++ .../public/lib/variables/context_variables.ts | 249 +++++++++++++++ .../lib/variables/event_variables.test.ts | 109 +++++++ .../public/lib/variables/event_variables.ts | 298 ++++++++++++++++++ .../public/lib/variables/global_variables.ts | 39 +++ .../public/lib/variables/i18n.ts | 16 + .../public/lib/variables/util.ts | 25 ++ .../drilldowns/url_drilldown/public/plugin.ts | 1 + .../plugins/ui_actions_enhanced/kibana.json | 4 +- .../url_drilldown/components/index.ts | 2 +- .../url_drilldown_collect_config/i18n.ts | 21 -- .../url_drilldown_collect_config/index.ts | 8 + .../url_drilldown_collect_config/lazy.tsx | 25 ++ .../test_samples/demo.tsx | 9 +- .../url_drilldown_collect_config.tsx | 129 ++------ .../components/variable_popover/i18n.ts | 29 ++ .../components/variable_popover/index.tsx | 90 ++++++ .../services/dashboard/drilldowns_manage.ts | 20 +- 33 files changed, 1646 insertions(+), 720 deletions(-) create mode 100644 src/plugins/kibana_react/public/url_template_editor/.storybook/main.js create mode 100644 src/plugins/kibana_react/public/url_template_editor/constants.ts create mode 100644 src/plugins/kibana_react/public/url_template_editor/index.ts create mode 100644 src/plugins/kibana_react/public/url_template_editor/language.ts create mode 100644 src/plugins/kibana_react/public/url_template_editor/styles.scss create mode 100644 src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.tsx create mode 100644 src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx delete mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts delete mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts create mode 100644 x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/i18n.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/index.tsx diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index b292c1ae5e03ff..ad3dd17fcfa111 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -190,7 +190,7 @@ internal {kib} navigations with carrying over current filters. | Current query string. | -| context.panel.query.lang +| context.panel.query.language | Current query language. | @@ -200,8 +200,8 @@ context.panel.timeRange.to Tip: Use in combination with <> helper to format date. | -| context.panel.timeRange.indexPatternId + -context.panel.timeRange.indexPatternIds +| context.panel.indexPatternId + +context.panel.indexPatternIds |Index pattern ids used by a panel. | diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 910f2bad15dde3..c72c81f489fb9d 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -10,6 +10,7 @@ export const storybookAliases = { apm: 'x-pack/plugins/apm/.storybook', canvas: 'x-pack/plugins/canvas/storybook', codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook', + url_template_editor: 'src/plugins/kibana_react/public/url_template_editor/.storybook', dashboard: 'src/plugins/dashboard/.storybook', dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook', data_enhanced: 'x-pack/plugins/data_enhanced/.storybook', diff --git a/src/plugins/kibana_react/public/code_editor/editor_theme.ts b/src/plugins/kibana_react/public/code_editor/editor_theme.ts index e3ea509c4349a5..b5d4627a5d89a3 100644 --- a/src/plugins/kibana_react/public/code_editor/editor_theme.ts +++ b/src/plugins/kibana_react/public/code_editor/editor_theme.ts @@ -41,7 +41,7 @@ export function createTheme( { token: 'annotation', foreground: euiTheme.euiColorMediumShade }, { token: 'type', foreground: euiTheme.euiColorVis0 }, - { token: 'delimiter', foreground: euiTheme.euiColorDarkestShade }, + { token: 'delimiter', foreground: euiTheme.euiTextSubduedColor }, { token: 'delimiter.html', foreground: euiTheme.euiColorDarkShade }, { token: 'delimiter.xml', foreground: euiTheme.euiColorPrimary }, @@ -81,6 +81,9 @@ export function createTheme( { token: 'operator.sql', foreground: euiTheme.euiColorMediumShade }, { token: 'operator.swift', foreground: euiTheme.euiColorMediumShade }, { token: 'predefined.sql', foreground: euiTheme.euiColorMediumShade }, + + { token: 'text', foreground: euiTheme.euiTitleColor }, + { token: 'label', foreground: euiTheme.euiColorVis9 }, ], colors: { 'editor.foreground': euiTheme.euiColorDarkestShade, diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index c453a2cbe11f48..f2c2c263da5cd8 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -7,6 +7,7 @@ */ export * from './code_editor'; +export * from './url_template_editor'; export * from './exit_full_screen_button'; export * from './context'; export * from './overview_page'; diff --git a/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js b/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js new file mode 100644 index 00000000000000..742239e638b8ac --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +// eslint-disable-next-line import/no-commonjs +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/kibana_react/public/url_template_editor/constants.ts b/src/plugins/kibana_react/public/url_template_editor/constants.ts new file mode 100644 index 00000000000000..6c1a1dbce5d674 --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 LANG = 'handlebars_url'; diff --git a/src/plugins/kibana_react/public/url_template_editor/index.ts b/src/plugins/kibana_react/public/url_template_editor/index.ts new file mode 100644 index 00000000000000..0b0ef85ad427bf --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * from './url_template_editor'; diff --git a/src/plugins/kibana_react/public/url_template_editor/language.ts b/src/plugins/kibana_react/public/url_template_editor/language.ts new file mode 100644 index 00000000000000..278a7130ad1fa6 --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/language.ts @@ -0,0 +1,198 @@ +/* + * 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. + */ + +/** + * This file is adapted from: https://github.com/microsoft/monaco-languages/blob/master/src/handlebars/handlebars.ts + * License: https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md + */ + +import { monaco } from '@kbn/monaco'; + +export const conf: monaco.languages.LanguageConfiguration = { + wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g, + + comments: { + blockComment: ['{{!--', '--}}'], + }, + + brackets: [ + ['<', '>'], + ['{{', '}}'], + ['{', '}'], + ['(', ')'], + ], + + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + + surroundingPairs: [ + { open: '<', close: '>' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], +}; + +export const language: monaco.languages.IMonarchLanguage = { + // Set defaultToken to invalid to see what you do not tokenize yet. + defaultToken: 'invalid', + tokenPostfix: '', + brackets: [ + { + token: 'constant.delimiter.double', + open: '{{', + close: '}}', + }, + { + token: 'constant.delimiter.triple', + open: '{{{', + close: '}}}', + }, + ], + + tokenizer: { + root: [ + { include: '@maybeHandlebars' }, + { include: '@whitespace' }, + { include: '@urlScheme' }, + { include: '@urlAuthority' }, + { include: '@urlSlash' }, + { include: '@urlParamKey' }, + { include: '@urlParamValue' }, + { include: '@text' }, + ], + + maybeHandlebars: [ + [ + /\{\{/, + { + token: '@rematch', + switchTo: '@handlebars.root', + }, + ], + ], + + whitespace: [[/[ \t\r\n]+/, '']], + + text: [ + [ + /[^<{\?\&\/]+/, + { + token: 'text', + next: '@popall', + }, + ], + ], + + rematchAsRoot: [ + [ + /.+/, + { + token: '@rematch', + switchTo: '@root', + }, + ], + ], + + urlScheme: [ + [ + /([a-zA-Z0-9\+\.\-]{1,10})(:)/, + [ + { + token: 'text.keyword.scheme.url', + }, + { + token: 'delimiter', + }, + ], + ], + ], + + urlAuthority: [ + [ + /(\/\/)([a-zA-Z0-9\.\-_]+)/, + [ + { + token: 'delimiter', + }, + { + token: 'metatag.keyword.authority.url', + }, + ], + ], + ], + + urlSlash: [ + [ + /\/+/, + { + token: 'delimiter', + }, + ], + ], + + urlParamKey: [ + [ + /([\?\&\#])([a-zA-Z0-9_\-]+)/, + [ + { + token: 'delimiter.key.query.url', + }, + { + token: 'label.label.key.query.url', + }, + ], + ], + ], + + urlParamValue: [ + [ + /(\=)([^\?\&\{}]+)/, + [ + { + token: 'text.separator.value.query.url', + }, + { + token: 'text.value.query.url', + }, + ], + ], + ], + + handlebars: [ + [ + /\{\{\{?/, + { + token: '@brackets', + bracket: '@open', + }, + ], + [ + /\}\}\}?/, + { + token: '@brackets', + bracket: '@close', + switchTo: '@$S2.$S3', + }, + ], + { include: 'handlebarsExpression' }, + ], + + handlebarsExpression: [ + [/"[^"]*"/, 'string.handlebars'], + [/[#/][^\s}]+/, 'keyword.helper.handlebars'], + [/else\b/, 'keyword.helper.handlebars'], + [/[\s]+/], + [/[^}]/, 'variable.parameter.handlebars'], + ], + }, +} as monaco.languages.IMonarchLanguage; diff --git a/src/plugins/kibana_react/public/url_template_editor/styles.scss b/src/plugins/kibana_react/public/url_template_editor/styles.scss new file mode 100644 index 00000000000000..99379b21454ec1 --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/styles.scss @@ -0,0 +1,5 @@ +.urlTemplateEditor__container { + .monaco-editor .lines-content.monaco-editor-background { + margin: $euiSizeS; + } +} diff --git a/src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.tsx b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.tsx new file mode 100644 index 00000000000000..67f34e6eeb14f7 --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.tsx @@ -0,0 +1,45 @@ +/* + * 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 { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; +import { UrlTemplateEditor } from './url_template_editor'; +import { CodeEditor } from '../code_editor/code_editor'; + +storiesOf('UrlTemplateEditor', module) + .add('default', () => ( + + )) + .add('with variables', () => ( + + )); diff --git a/src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx new file mode 100644 index 00000000000000..f830b4012976ae --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx @@ -0,0 +1,163 @@ +/* + * 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 * as React from 'react'; +import { monaco } from '@kbn/monaco'; +import { Props as CodeEditorProps } from '../code_editor/code_editor'; +import { CodeEditor } from '../code_editor'; +import { LANG } from './constants'; +import { language, conf } from './language'; + +import './styles.scss'; + +monaco.languages.register({ + id: LANG, +}); +monaco.languages.setMonarchTokensProvider(LANG, language); +monaco.languages.setLanguageConfiguration(LANG, conf); + +export interface UrlTemplateEditorVariable { + label: string; + title?: string; + documentation?: string; + kind?: monaco.languages.CompletionItemKind; + sortText?: string; +} +export interface UrlTemplateEditorProps { + value: string; + height?: CodeEditorProps['height']; + variables?: UrlTemplateEditorVariable[]; + onChange: CodeEditorProps['onChange']; + onEditor?: (editor: monaco.editor.IStandaloneCodeEditor) => void; + Editor?: React.ComponentType; +} + +export const UrlTemplateEditor: React.FC = ({ + height = 105, + value, + variables, + onChange, + onEditor, + Editor = CodeEditor, +}) => { + const refEditor = React.useRef(null); + const handleEditor = React.useCallback((editor: monaco.editor.IStandaloneCodeEditor) => { + refEditor.current = editor; + + if (onEditor) { + onEditor(editor); + } + }, []); + + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { + const editor = refEditor.current; + if (!editor) return; + + if (event.key === 'Escape') { + if (editor.hasWidgetFocus()) { + // Don't propagate Escape click if Monaco editor is focused, this allows + // user to close the autocomplete widget with Escape button without + // closing the EUI flyout. + event.stopPropagation(); + editor.trigger('editor', 'hideSuggestWidget', []); + } + } + }, []); + + React.useEffect(() => { + if (!variables) { + return; + } + + const { dispose } = monaco.languages.registerCompletionItemProvider(LANG, { + triggerCharacters: ['{', '/', '?', '&', '='], + provideCompletionItems(model, position, context, token) { + const { lineNumber } = position; + const line = model.getLineContent(lineNumber); + const wordUntil = model.getWordUntilPosition(position); + const word = model.getWordAtPosition(position) || wordUntil; + const { startColumn, endColumn } = word; + const range = { + startLineNumber: lineNumber, + endLineNumber: lineNumber, + startColumn, + endColumn, + }; + + const leadingMustacheCount = + 0 + + (line[range.startColumn - 2] === '{' ? 1 : 0) + + (line[range.startColumn - 3] === '{' ? 1 : 0); + + const trailingMustacheCount = + 0 + + (line[range.endColumn - 1] === '}' ? 1 : 0) + + (line[range.endColumn + 0] === '}' ? 1 : 0); + + return { + suggestions: variables.map( + ({ + label, + title = '', + documentation = '', + kind = monaco.languages.CompletionItemKind.Variable, + sortText, + }) => ({ + kind, + label, + insertText: + (leadingMustacheCount === 2 ? '' : leadingMustacheCount === 1 ? '{' : '{{') + + label + + (trailingMustacheCount === 2 ? '' : trailingMustacheCount === 1 ? '}' : '}}'), + detail: title, + documentation, + range, + sortText, + }) + ), + }; + }, + }); + + return () => { + dispose(); + }; + }, [variables]); + + return ( +
+ +
+ ); +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts index 580b4120ae46dd..07c6addda27674 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts @@ -6,6 +6,7 @@ */ import { IExternalUrl } from 'src/core/public'; +import { uiSettingsServiceMock } from 'src/core/public/mocks'; import { UrlDrilldown, ActionContext, Config } from './url_drilldown'; import { IEmbeddable, VALUE_CLICK_TRIGGER } from '../../../../../../src/plugins/embeddable/public'; import { DatatableColumnType } from '../../../../../../src/plugins/expressions/common'; @@ -74,6 +75,7 @@ const createDrilldown = (isExternalUrlValid: boolean = true) => { getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs', getVariablesHelpDocsLink: () => 'http://localhost:5601/docs', navigateToUrl: mockNavigateToUrl, + uiSettings: uiSettingsServiceMock.createSetupContract(), }); return drilldown; }; @@ -408,7 +410,7 @@ describe('UrlDrilldown', () => { ]; for (const expectedItem of expectedList) { - expect(list.includes(expectedItem)).toBe(true); + expect(!!list.find(({ label }) => label === expectedItem)).toBe(true); } }); @@ -438,7 +440,7 @@ describe('UrlDrilldown', () => { ]; for (const expectedItem of expectedList) { - expect(list.includes(expectedItem)).toBe(true); + expect(!!list.find(({ label }) => label === expectedItem)).toBe(true); } }); }); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx index a8c34a871310bb..bf2ed8c2a45d1b 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { getFlattenedObject } from '@kbn/std'; -import { IExternalUrl } from 'src/core/public'; -import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { IExternalUrl, IUiSettingsClient } from 'src/core/public'; import { ChartActionContext, CONTEXT_MENU_TRIGGER, @@ -20,6 +18,11 @@ import { import { ROW_CLICK_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; import { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; +import { + reactToUiComponent, + UrlTemplateEditorVariable, + KibanaContextProvider, +} from '../../../../../../src/plugins/kibana_react/public'; import { UiActionsEnhancedDrilldownDefinition as Drilldown, UrlDrilldownGlobalScope, @@ -29,8 +32,10 @@ import { urlDrilldownCompileUrl, UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, } from '../../../../ui_actions_enhanced/public'; -import { getPanelVariables, getEventScope, getEventVariableList } from './url_drilldown_scope'; import { txtUrlDrilldownDisplayName } from './i18n'; +import { getEventVariableList, getEventScopeValues } from './variables/event_variables'; +import { getContextVariableList, getContextScopeValues } from './variables/context_variables'; +import { getGlobalVariableList } from './variables/global_variables'; interface EmbeddableQueryInput extends EmbeddableInput { query?: Query; @@ -47,6 +52,7 @@ interface UrlDrilldownDeps { navigateToUrl: (url: string) => Promise; getSyntaxHelpDocsLink: () => string; getVariablesHelpDocsLink: () => string; + uiSettings: IUiSettingsClient; } export type ActionContext = ChartActionContext; @@ -90,21 +96,30 @@ export class UrlDrilldown implements Drilldown { // eslint-disable-next-line react-hooks/rules-of-hooks const variables = React.useMemo(() => this.getVariableList(context), [context]); + return ( - + + + ); }; public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); public readonly createConfig = () => ({ - url: { template: '' }, + url: { + template: 'https://example.com/?{{event.key}}={{event.value}}', + }, openInNewTab: true, encodeUrl: true, }); @@ -167,21 +182,20 @@ export class UrlDrilldown implements Drilldown { return { + event: getEventScopeValues(context), + context: getContextScopeValues(context), ...this.deps.getGlobalScope(), - context: { - panel: getPanelVariables(context), - }, - event: getEventScope(context), }; }; - public readonly getVariableList = (context: ActionFactoryContext): string[] => { + public readonly getVariableList = ( + context: ActionFactoryContext + ): UrlTemplateEditorVariable[] => { + const globalScopeValues = this.deps.getGlobalScope(); const eventVariables = getEventVariableList(context); - const contextVariables = Object.keys(getFlattenedObject(getPanelVariables(context))).map( - (key) => 'context.panel.' + key - ); - const globalVariables = Object.keys(getFlattenedObject(this.deps.getGlobalScope())); + const contextVariables = getContextVariableList(context); + const globalVariables = getGlobalVariableList(globalScopeValues); - return [...eventVariables.sort(), ...contextVariables.sort(), ...globalVariables.sort()]; + return [...eventVariables, ...contextVariables, ...globalVariables]; }; } diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts deleted file mode 100644 index 5c639a61ba4c24..00000000000000 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - getEventScope, - ValueClickTriggerEventScope, - getEventVariableList, - getPanelVariables, -} from './url_drilldown_scope'; -import { - RowClickContext, - ROW_CLICK_TRIGGER, -} from '../../../../../../src/plugins/ui_actions/public'; -import { createPoint, rowClickData, TestEmbeddable } from './test/data'; - -describe('VALUE_CLICK_TRIGGER', () => { - describe('supports `points[]`', () => { - test('getEventScope()', () => { - const mockDataPoints = [ - createPoint({ field: 'field0', value: 'value0' }), - createPoint({ field: 'field1', value: 'value1' }), - createPoint({ field: 'field2', value: 'value2' }), - ]; - - const eventScope = getEventScope({ - data: { data: mockDataPoints }, - }) as ValueClickTriggerEventScope; - - expect(eventScope.key).toBe('field0'); - expect(eventScope.value).toBe('value0'); - expect(eventScope.points).toHaveLength(mockDataPoints.length); - expect(eventScope.points).toMatchInlineSnapshot(` - Array [ - Object { - "key": "field0", - "value": "value0", - }, - Object { - "key": "field1", - "value": "value1", - }, - Object { - "key": "field2", - "value": "value2", - }, - ] - `); - }); - }); - - describe('handles undefined, null or missing values', () => { - test('undefined or missing values are removed from the result scope', () => { - const point = createPoint({ field: undefined } as any); - const eventScope = getEventScope({ - data: { data: [point] }, - }) as ValueClickTriggerEventScope; - - expect('key' in eventScope).toBeFalsy(); - expect('value' in eventScope).toBeFalsy(); - }); - - test('null value stays in the result scope', () => { - const point = createPoint({ field: 'field', value: null }); - const eventScope = getEventScope({ - data: { data: [point] }, - }) as ValueClickTriggerEventScope; - - expect(eventScope.value).toBeNull(); - }); - }); -}); - -describe('ROW_CLICK_TRIGGER', () => { - test('getEventVariableList() returns correct list of runtime variables', () => { - const vars = getEventVariableList({ - triggers: [ROW_CLICK_TRIGGER], - }); - expect(vars).toEqual(['event.rowIndex', 'event.values', 'event.keys', 'event.columnNames']); - }); - - test('getEventScope() returns correct variables for row click trigger', () => { - const context = ({ - embeddable: {}, - data: rowClickData as any, - } as unknown) as RowClickContext; - const res = getEventScope(context); - - expect(res).toEqual({ - rowIndex: 1, - values: ['IT', '2.25', 3, 0, 2], - keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'], - columnNames: [ - 'Top values of DestCountry', - 'Top values of FlightTimeHour', - 'Count of records', - 'Average of DistanceMiles', - 'Unique count of OriginAirportID', - ], - }); - }); -}); - -describe('getPanelVariables()', () => { - test('returns only ID for empty embeddable', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - }); - }); - - test('returns title as specified in input', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - title: 'title1', - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - title: 'title1', - }); - }); - - test('returns output title if input and output titles are specified', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - title: 'title1', - }, - { - title: 'title2', - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - title: 'title2', - }); - }); - - test('returns title from output if title in input is missing', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - { - title: 'title2', - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - title: 'title2', - }); - }); - - test('returns saved object ID from output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - savedObjectId: '5678', - }, - { - savedObjectId: '1234', - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - savedObjectId: '1234', - }); - }); - - test('returns saved object ID from input if it is not set on output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - savedObjectId: '5678', - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - savedObjectId: '5678', - }); - }); - - test('returns query, timeRange and filters from input', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - query: { - language: 'C++', - query: 'std::cout << 123;', - }, - timeRange: { - from: 'FROM', - to: 'TO', - }, - filters: [ - { - meta: { - alias: 'asdf', - disabled: false, - negate: false, - }, - }, - ], - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - query: { - language: 'C++', - query: 'std::cout << 123;', - }, - timeRange: { - from: 'FROM', - to: 'TO', - }, - filters: [ - { - meta: { - alias: 'asdf', - disabled: false, - negate: false, - }, - }, - ], - }); - }); - - test('returns a single index pattern from output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - { - indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }], - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', - }); - }); - - test('returns multiple index patterns from output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - { - indexPatterns: [ - { id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }, - { id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' }, - ], - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - indexPatternIds: [ - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', - 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', - ], - }); - }); -}); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts deleted file mode 100644 index 0ee388c321feb2..00000000000000 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts +++ /dev/null @@ -1,266 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/** - * This file contains all the logic for mapping from trigger's context and action factory context to variables for URL drilldown scope, - * Please refer to ./README.md for explanation of different scope sources - */ - -import type { Filter, Query, TimeRange } from '../../../../../../src/plugins/data/public'; -import { - isRangeSelectTriggerContext, - isValueClickTriggerContext, - isRowClickTriggerContext, - isContextMenuTriggerContext, - RangeSelectContext, - SELECT_RANGE_TRIGGER, - ValueClickContext, - VALUE_CLICK_TRIGGER, - EmbeddableInput, - EmbeddableOutput, -} from '../../../../../../src/plugins/embeddable/public'; -import type { - ActionContext, - ActionFactoryContext, - EmbeddableWithQueryInput, -} from './url_drilldown'; -import { - RowClickContext, - ROW_CLICK_TRIGGER, -} from '../../../../../../src/plugins/ui_actions/public'; - -/** - * Part of context scope extracted from an embeddable - * Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}` - */ -interface EmbeddableUrlDrilldownContextScope extends EmbeddableInput { - /** - * ID of the embeddable panel. - */ - id: string; - - /** - * Title of the embeddable panel. - */ - title?: string; - - /** - * In case panel supports only 1 index pattern. - */ - indexPatternId?: string; - - /** - * In case panel supports more then 1 index pattern. - */ - indexPatternIds?: string[]; - - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; - savedObjectId?: string; -} - -export function getPanelVariables(contextScopeInput: unknown): EmbeddableUrlDrilldownContextScope { - function hasEmbeddable(val: unknown): val is { embeddable: EmbeddableWithQueryInput } { - if (val && typeof val === 'object' && 'embeddable' in val) return true; - return false; - } - if (!hasEmbeddable(contextScopeInput)) - throw new Error( - "UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context" - ); - const embeddable = contextScopeInput.embeddable; - - return getEmbeddableVariables(embeddable); -} - -function hasSavedObjectId(obj: Record): obj is { savedObjectId: string } { - return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string'; -} - -/** - * @todo Same functionality is implemented in x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts, - * combine both implementations into a common approach. - */ -function getIndexPatternIds(output: EmbeddableOutput): string[] { - function hasIndexPatterns( - _output: Record - ): _output is { indexPatterns: Array<{ id?: string }> } { - return ( - 'indexPatterns' in _output && - Array.isArray(_output.indexPatterns) && - _output.indexPatterns.length > 0 - ); - } - return hasIndexPatterns(output) - ? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[]) - : []; -} - -export function getEmbeddableVariables( - embeddable: EmbeddableWithQueryInput -): EmbeddableUrlDrilldownContextScope { - const input = embeddable.getInput(); - const output = embeddable.getOutput(); - const indexPatternsIds = getIndexPatternIds(output); - - return deleteUndefinedKeys({ - id: input.id, - title: output.title ?? input.title, - savedObjectId: - output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined), - query: input.query, - timeRange: input.timeRange, - filters: input.filters, - indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined, - indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined, - }); -} - -/** - * URL drilldown event scope, - * available as {{event.$}} - */ -export type UrlDrilldownEventScope = - | ValueClickTriggerEventScope - | RangeSelectTriggerEventScope - | RowClickTriggerEventScope - | ContextMenuTriggerEventScope; - -export type EventScopeInput = ActionContext; -export interface ValueClickTriggerEventScope { - key?: string; - value: Primitive; - negate: boolean; - points: Array<{ key?: string; value: Primitive }>; -} -export interface RangeSelectTriggerEventScope { - key: string; - from?: string | number; - to?: string | number; -} - -export interface RowClickTriggerEventScope { - rowIndex: number; - values: Primitive[]; - keys: string[]; - columnNames: string[]; -} -export type ContextMenuTriggerEventScope = object; - -export function getEventScope(eventScopeInput: EventScopeInput): UrlDrilldownEventScope { - if (isRangeSelectTriggerContext(eventScopeInput)) { - return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); - } else if (isValueClickTriggerContext(eventScopeInput)) { - return getEventScopeFromValueClickTriggerContext(eventScopeInput); - } else if (isRowClickTriggerContext(eventScopeInput)) { - return getEventScopeFromRowClickTriggerContext(eventScopeInput); - } else if (isContextMenuTriggerContext(eventScopeInput)) { - return {}; - } else { - throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger"); - } -} - -function getEventScopeFromRangeSelectTriggerContext( - eventScopeInput: RangeSelectContext -): RangeSelectTriggerEventScope { - const { table, column: columnIndex, range } = eventScopeInput.data; - const column = table.columns[columnIndex]; - return deleteUndefinedKeys({ - key: toPrimitiveOrUndefined(column?.meta.field) as string, - from: toPrimitiveOrUndefined(range[0]) as string | number | undefined, - to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined, - }); -} - -function getEventScopeFromValueClickTriggerContext( - eventScopeInput: ValueClickContext -): ValueClickTriggerEventScope { - const negate = eventScopeInput.data.negate ?? false; - const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => { - const column = table.columns[columnIndex]; - return { - value: toPrimitiveOrUndefined(value) as Primitive, - key: column?.meta?.field, - }; - }); - - return deleteUndefinedKeys({ - key: points[0]?.key, - value: points[0]?.value, - negate, - points, - }); -} - -function getEventScopeFromRowClickTriggerContext(ctx: RowClickContext): RowClickTriggerEventScope { - const { data } = ctx; - const embeddable = ctx.embeddable as EmbeddableWithQueryInput; - - const { rowIndex } = data; - const columns = data.columns || data.table.columns.map(({ id }) => id); - const values: Primitive[] = []; - const keys: string[] = []; - const columnNames: string[] = []; - const row = data.table.rows[rowIndex]; - - for (const columnId of columns) { - const column = data.table.columns.find(({ id }) => id === columnId); - if (!column) { - // This should never happe, but in case it does we log data necessary for debugging. - // eslint-disable-next-line no-console - console.error(data, embeddable ? `Embeddable [${embeddable.getTitle()}]` : null); - throw new Error('Could not find a datatable column.'); - } - values.push(row[columnId]); - keys.push(column.meta.field || ''); - columnNames.push(column.name || column.meta.field || ''); - } - - const scope: RowClickTriggerEventScope = { - rowIndex, - values, - keys, - columnNames, - }; - - return scope; -} - -export function getEventVariableList(context: ActionFactoryContext): string[] { - const [trigger] = context.triggers; - - switch (trigger) { - case SELECT_RANGE_TRIGGER: - return ['event.key', 'event.from', 'event.to']; - case VALUE_CLICK_TRIGGER: - return ['event.key', 'event.value', 'event.negate', 'event.points']; - case ROW_CLICK_TRIGGER: - return ['event.rowIndex', 'event.values', 'event.keys', 'event.columnNames']; - } - - return []; -} - -type Primitive = string | number | boolean | null; -function toPrimitiveOrUndefined(v: unknown): Primitive | undefined { - if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null) - return v; - if (typeof v === 'object' && v instanceof Date) return v.toISOString(); - if (typeof v === 'undefined') return undefined; - return String(v); -} - -function deleteUndefinedKeys>(obj: T): T { - Object.keys(obj).forEach((key) => { - if (obj[key] === undefined) { - delete obj[key]; - } - }); - return obj; -} diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts new file mode 100644 index 00000000000000..c3c41ef082ffc5 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts @@ -0,0 +1,216 @@ +/* + * 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 { getContextScopeValues } from './context_variables'; +import { TestEmbeddable } from '../test/data'; + +describe('getContextScopeValues()', () => { + test('returns only ID for empty embeddable', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + }, + }); + }); + + test('returns title as specified in input', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + title: 'title1', + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + title: 'title1', + }, + }); + }); + + test('returns output title if input and output titles are specified', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + title: 'title1', + }, + { + title: 'title2', + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + title: 'title2', + }, + }); + }); + + test('returns title from output if title in input is missing', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + title: 'title2', + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + title: 'title2', + }, + }); + }); + + test('returns saved object ID from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + savedObjectId: '5678', + }, + { + savedObjectId: '1234', + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + savedObjectId: '1234', + }, + }); + }); + + test('returns saved object ID from input if it is not set on output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + savedObjectId: '5678', + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + savedObjectId: '5678', + }, + }); + }); + + test('returns query, timeRange and filters from input', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + query: { + language: 'C++', + query: 'std::cout << 123;', + }, + timeRange: { + from: 'FROM', + to: 'TO', + }, + filters: [ + { + meta: { + alias: 'asdf', + disabled: false, + negate: false, + }, + }, + ], + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + query: { + language: 'C++', + query: 'std::cout << 123;', + }, + timeRange: { + from: 'FROM', + to: 'TO', + }, + filters: [ + { + meta: { + alias: 'asdf', + disabled: false, + negate: false, + }, + }, + ], + }, + }); + }); + + test('returns a single index pattern from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }], + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + }, + }); + }); + + test('returns multiple index patterns from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + indexPatterns: [ + { id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }, + { id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' }, + ], + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + indexPatternIds: [ + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts new file mode 100644 index 00000000000000..c0ff776c94ca10 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts @@ -0,0 +1,249 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { monaco } from '@kbn/monaco'; +import { getFlattenedObject } from '@kbn/std'; +import { txtValue } from './i18n'; +import type { Filter, Query, TimeRange } from '../../../../../../../src/plugins/data/public'; +import { + EmbeddableInput, + EmbeddableOutput, +} from '../../../../../../../src/plugins/embeddable/public'; +import type { EmbeddableWithQueryInput } from '../url_drilldown'; +import { deleteUndefinedKeys } from './util'; +import type { ActionFactoryContext } from '../url_drilldown'; +import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public'; + +/** + * Part of context scope extracted from an embeddable + * Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}` + */ +interface PanelValues extends EmbeddableInput { + /** + * ID of the embeddable panel. + */ + id: string; + + /** + * Title of the embeddable panel. + */ + title?: string; + + /** + * In case panel supports only 1 index pattern. + */ + indexPatternId?: string; + + /** + * In case panel supports more then 1 index pattern. + */ + indexPatternIds?: string[]; + + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + savedObjectId?: string; +} + +interface ContextValues { + panel: PanelValues; +} + +function hasSavedObjectId(obj: Record): obj is { savedObjectId: string } { + return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string'; +} + +/** + * @todo Same functionality is implemented in x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts, + * combine both implementations into a common approach. + */ +function getIndexPatternIds(output: EmbeddableOutput): string[] { + function hasIndexPatterns( + _output: Record + ): _output is { indexPatterns: Array<{ id?: string }> } { + return ( + 'indexPatterns' in _output && + Array.isArray(_output.indexPatterns) && + _output.indexPatterns.length > 0 + ); + } + return hasIndexPatterns(output) + ? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[]) + : []; +} + +export function getEmbeddableVariables(embeddable: EmbeddableWithQueryInput): PanelValues { + const input = embeddable.getInput(); + const output = embeddable.getOutput(); + const indexPatternsIds = getIndexPatternIds(output); + + return deleteUndefinedKeys({ + id: input.id, + title: output.title ?? input.title, + savedObjectId: + output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined), + query: input.query, + timeRange: input.timeRange, + filters: input.filters, + indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined, + indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined, + }); +} + +const getContextPanelScopeValues = (contextScopeInput: unknown): PanelValues => { + function hasEmbeddable(val: unknown): val is { embeddable: EmbeddableWithQueryInput } { + if (val && typeof val === 'object' && 'embeddable' in val) return true; + return false; + } + if (!hasEmbeddable(contextScopeInput)) + throw new Error( + "UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context" + ); + const embeddable = contextScopeInput.embeddable; + + return getEmbeddableVariables(embeddable); +}; + +export const getContextScopeValues = (contextScopeInput: unknown): ContextValues => { + return { + panel: getContextPanelScopeValues(contextScopeInput), + }; +}; + +type VariableDescription = Pick; + +const variableDescriptions: Record = { + id: { + title: i18n.translate('xpack.urlDrilldown.context.panel.id.title', { + defaultMessage: 'Panel ID.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.id.documentation', { + defaultMessage: 'ID of the panel where drilldown is executed.', + }), + }, + title: { + title: i18n.translate('xpack.urlDrilldown.context.panel.title.title', { + defaultMessage: 'Panel title.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.title.documentation', { + defaultMessage: 'Title of the panel where drilldown is executed.', + }), + }, + filters: { + title: i18n.translate('xpack.urlDrilldown.context.panel.filters.title', { + defaultMessage: 'Panel filters.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.filters.documentation', { + defaultMessage: 'List of Kibana filters applied to a panel.', + }), + }, + 'query.query': { + title: i18n.translate('xpack.urlDrilldown.context.panel.query.query.title', { + defaultMessage: 'Query string.', + }), + }, + 'query.language': { + title: i18n.translate('xpack.urlDrilldown.context.panel.query.language.title', { + defaultMessage: 'Query language.', + }), + }, + 'timeRange.from': { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.from.title', { + defaultMessage: 'Time picker "from" value.', + }), + }, + 'timeRange.to': { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.to.title', { + defaultMessage: 'Time picker "to" value.', + }), + }, + indexPatternId: { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.indexPatternId.title', { + defaultMessage: 'Index pattern ID.', + }), + documentation: i18n.translate( + 'xpack.urlDrilldown.context.panel.timeRange.indexPatternId.documentation', + { + defaultMessage: 'First index pattern ID used by the panel.', + } + ), + }, + indexPatternIds: { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.indexPatternIds.title', { + defaultMessage: 'Index pattern IDs.', + }), + documentation: i18n.translate( + 'xpack.urlDrilldown.context.panel.timeRange.indexPatternIds.documentation', + { + defaultMessage: 'List of all index pattern IDs used by the panel.', + } + ), + }, + savedObjectId: { + title: i18n.translate('xpack.urlDrilldown.context.panel.savedObjectId.title', { + defaultMessage: 'Saved object ID.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.savedObjectId.documentation', { + defaultMessage: 'ID of the saved object behind the panel.', + }), + }, +}; + +const kind = monaco.languages.CompletionItemKind.Variable; +const sortPrefix = '2.'; + +const formatValue = (value: unknown) => { + if (typeof value === 'object') { + return '\n' + JSON.stringify(value, null, 4); + } + + return String(value); +}; + +const getPanelVariableList = (values: PanelValues): UrlTemplateEditorVariable[] => { + const variables: UrlTemplateEditorVariable[] = []; + const flattenedValues = getFlattenedObject(values); + const keys = Object.keys(flattenedValues).sort(); + + for (const key of keys) { + const description = variableDescriptions[key]; + const label = 'context.panel.' + key; + + if (!description) { + variables.push({ + label, + sortText: sortPrefix + label, + documentation: !!flattenedValues[key] ? txtValue(formatValue(flattenedValues[key])) : '', + kind, + }); + continue; + } + + variables.push({ + label, + sortText: sortPrefix + label, + title: description.title, + documentation: + (description.documentation || '') + + (!!description.documentation && !!flattenedValues[key] ? '\n\n' : '') + + (!!flattenedValues[key] ? txtValue(formatValue(flattenedValues[key])) : ''), + kind, + }); + } + + return variables; +}; + +export const getContextVariableList = ( + context: ActionFactoryContext +): UrlTemplateEditorVariable[] => { + const values = getContextScopeValues(context); + const variables: UrlTemplateEditorVariable[] = getPanelVariableList(values.panel); + + return variables; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts new file mode 100644 index 00000000000000..3d0c55a08d1bff --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts @@ -0,0 +1,109 @@ +/* + * 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 { + getEventScopeValues, + getEventVariableList, + ValueClickTriggerEventScope, +} from './event_variables'; +import { + RowClickContext, + ROW_CLICK_TRIGGER, +} from '../../../../../../../src/plugins/ui_actions/public'; +import { createPoint, rowClickData } from '../test/data'; + +describe('VALUE_CLICK_TRIGGER', () => { + describe('supports `points[]`', () => { + test('getEventScopeValues()', () => { + const mockDataPoints = [ + createPoint({ field: 'field0', value: 'value0' }), + createPoint({ field: 'field1', value: 'value1' }), + createPoint({ field: 'field2', value: 'value2' }), + ]; + + const eventScope = getEventScopeValues({ + data: { data: mockDataPoints }, + }) as ValueClickTriggerEventScope; + + expect(eventScope.key).toBe('field0'); + expect(eventScope.value).toBe('value0'); + expect(eventScope.points).toHaveLength(mockDataPoints.length); + expect(eventScope.points).toMatchInlineSnapshot(` + Array [ + Object { + "key": "field0", + "value": "value0", + }, + Object { + "key": "field1", + "value": "value1", + }, + Object { + "key": "field2", + "value": "value2", + }, + ] + `); + }); + }); + + describe('handles undefined, null or missing values', () => { + test('undefined or missing values are removed from the result scope', () => { + const point = createPoint({ field: undefined } as any); + const eventScope = getEventScopeValues({ + data: { data: [point] }, + }) as ValueClickTriggerEventScope; + + expect('key' in eventScope).toBeFalsy(); + expect('value' in eventScope).toBeFalsy(); + }); + + test('null value stays in the result scope', () => { + const point = createPoint({ field: 'field', value: null }); + const eventScope = getEventScopeValues({ + data: { data: [point] }, + }) as ValueClickTriggerEventScope; + + expect(eventScope.value).toBeNull(); + }); + }); +}); + +describe('ROW_CLICK_TRIGGER', () => { + test('getEventVariableList() returns correct list of runtime variables', () => { + const vars = getEventVariableList({ + triggers: [ROW_CLICK_TRIGGER], + }); + expect(vars.map(({ label }) => label)).toEqual([ + 'event.values', + 'event.keys', + 'event.columnNames', + 'event.rowIndex', + ]); + }); + + test('getEventScopeValues() returns correct variables for row click trigger', () => { + const context = ({ + embeddable: {}, + data: rowClickData as any, + } as unknown) as RowClickContext; + const res = getEventScopeValues(context); + + expect(res).toEqual({ + rowIndex: 1, + values: ['IT', '2.25', 3, 0, 2], + keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'], + columnNames: [ + 'Top values of DestCountry', + 'Top values of FlightTimeHour', + 'Count of records', + 'Average of DistanceMiles', + 'Unique count of OriginAirportID', + ], + }); + }); +}); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts new file mode 100644 index 00000000000000..8eb798eea74cbe --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts @@ -0,0 +1,298 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { monaco } from '@kbn/monaco'; +import { + isRangeSelectTriggerContext, + isValueClickTriggerContext, + isRowClickTriggerContext, + isContextMenuTriggerContext, + RangeSelectContext, + SELECT_RANGE_TRIGGER, + ValueClickContext, + VALUE_CLICK_TRIGGER, +} from '../../../../../../../src/plugins/embeddable/public'; +import type { + ActionContext, + ActionFactoryContext, + EmbeddableWithQueryInput, +} from '../url_drilldown'; +import { + RowClickContext, + ROW_CLICK_TRIGGER, +} from '../../../../../../../src/plugins/ui_actions/public'; +import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public'; +import { deleteUndefinedKeys, toPrimitiveOrUndefined, Primitive } from './util'; + +/** + * URL drilldown event scope, available as `{{event.*}}` Handlebars variables. + */ +export type UrlDrilldownEventScope = + | ValueClickTriggerEventScope + | RangeSelectTriggerEventScope + | RowClickTriggerEventScope + | ContextMenuTriggerEventScope; + +export type EventScopeInput = ActionContext; + +export interface ValueClickTriggerEventScope { + key?: string; + value: Primitive; + negate: boolean; + points: Array<{ key?: string; value: Primitive }>; +} + +export interface RangeSelectTriggerEventScope { + key: string; + from?: string | number; + to?: string | number; +} + +export interface RowClickTriggerEventScope { + rowIndex: number; + values: Primitive[]; + keys: string[]; + columnNames: string[]; +} + +export type ContextMenuTriggerEventScope = object; + +const getEventScopeFromRangeSelectTriggerContext = ( + eventScopeInput: RangeSelectContext +): RangeSelectTriggerEventScope => { + const { table, column: columnIndex, range } = eventScopeInput.data; + const column = table.columns[columnIndex]; + return deleteUndefinedKeys({ + key: toPrimitiveOrUndefined(column?.meta.field) as string, + from: toPrimitiveOrUndefined(range[0]) as string | number | undefined, + to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined, + }); +}; + +const getEventScopeFromValueClickTriggerContext = ( + eventScopeInput: ValueClickContext +): ValueClickTriggerEventScope => { + const negate = eventScopeInput.data.negate ?? false; + const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => { + const column = table.columns[columnIndex]; + return { + value: toPrimitiveOrUndefined(value) as Primitive, + key: column?.meta?.field, + }; + }); + + return deleteUndefinedKeys({ + key: points[0]?.key, + value: points[0]?.value, + negate, + points, + }); +}; + +const getEventScopeFromRowClickTriggerContext = ( + ctx: RowClickContext +): RowClickTriggerEventScope => { + const { data } = ctx; + const embeddable = ctx.embeddable as EmbeddableWithQueryInput; + + const { rowIndex } = data; + const columns = data.columns || data.table.columns.map(({ id }) => id); + const values: Primitive[] = []; + const keys: string[] = []; + const columnNames: string[] = []; + const row = data.table.rows[rowIndex]; + + for (const columnId of columns) { + const column = data.table.columns.find(({ id }) => id === columnId); + if (!column) { + // This should never happe, but in case it does we log data necessary for debugging. + // eslint-disable-next-line no-console + console.error(data, embeddable ? `Embeddable [${embeddable.getTitle()}]` : null); + throw new Error('Could not find a datatable column.'); + } + values.push(row[columnId]); + keys.push(column.meta.field || ''); + columnNames.push(column.name || column.meta.field || ''); + } + + const scope: RowClickTriggerEventScope = { + rowIndex, + values, + keys, + columnNames, + }; + + return scope; +}; + +export const getEventScopeValues = (eventScopeInput: EventScopeInput): UrlDrilldownEventScope => { + if (isRangeSelectTriggerContext(eventScopeInput)) { + return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); + } else if (isValueClickTriggerContext(eventScopeInput)) { + return getEventScopeFromValueClickTriggerContext(eventScopeInput); + } else if (isRowClickTriggerContext(eventScopeInput)) { + return getEventScopeFromRowClickTriggerContext(eventScopeInput); + } else if (isContextMenuTriggerContext(eventScopeInput)) { + return {}; + } else { + throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger"); + } +}; + +const kind = monaco.languages.CompletionItemKind.Event; +const sortPrefix = '1.'; + +const valueClickVariables: readonly UrlTemplateEditorVariable[] = [ + { + label: 'event.value', + sortText: sortPrefix + 'event.value', + title: i18n.translate('xpack.urlDrilldown.click.event.value.title', { + defaultMessage: 'Click value.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.value.documentation', { + defaultMessage: 'Value behind clicked data point.', + }), + kind, + }, + { + label: 'event.key', + sortText: sortPrefix + 'event.key', + title: i18n.translate('xpack.urlDrilldown.click.event.key.title', { + defaultMessage: 'Name of clicked field.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.key.documentation', { + defaultMessage: 'Field name behind clicked data point.', + }), + kind, + }, + { + label: 'event.negate', + sortText: sortPrefix + 'event.negate', + title: i18n.translate('xpack.urlDrilldown.click.event.negate.title', { + defaultMessage: 'Whether the filter is negated.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.negate.documentation', { + defaultMessage: 'Boolean, indicating whether clicked data point resulted in negative filter.', + }), + kind, + }, + { + label: 'event.points', + sortText: sortPrefix + 'event.points', + title: i18n.translate('xpack.urlDrilldown.click.event.points.title', { + defaultMessage: 'List of all clicked points.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.points.documentation', { + defaultMessage: + 'Some visualizations have clickable points that emit more than one data point. Use list of data points in case a single value is insufficient.', + }), + kind, + }, +]; + +const rowClickVariables: readonly UrlTemplateEditorVariable[] = [ + { + label: 'event.values', + sortText: sortPrefix + 'event.values', + title: i18n.translate('xpack.urlDrilldown.row.event.values.title', { + defaultMessage: 'List of row cell values.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.values.documentation', { + defaultMessage: 'An array of all cell values for the raw on which the action will execute.', + }), + kind, + }, + { + label: 'event.keys', + sortText: sortPrefix + 'event.keys', + title: i18n.translate('xpack.urlDrilldown.row.event.keys.title', { + defaultMessage: 'List of row cell fields.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.keys.documentation', { + defaultMessage: 'An array of field names for each column.', + }), + kind, + }, + { + label: 'event.columnNames', + sortText: sortPrefix + 'event.columnNames', + title: i18n.translate('xpack.urlDrilldown.row.event.columnNames.title', { + defaultMessage: 'List of table column names.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.columnNames.documentation', { + defaultMessage: 'An array of column names.', + }), + kind, + }, + { + label: 'event.rowIndex', + sortText: sortPrefix + 'event.rowIndex', + title: i18n.translate('xpack.urlDrilldown.row.event.rowIndex.title', { + defaultMessage: 'Clicked row index.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.rowIndex.documentation', { + defaultMessage: 'Number, representing the row that was clicked, starting from 0.', + }), + kind, + }, +]; + +const selectRangeVariables: readonly UrlTemplateEditorVariable[] = [ + { + label: 'event.key', + sortText: sortPrefix + 'event.key', + title: i18n.translate('xpack.urlDrilldown.range.event.key.title', { + defaultMessage: 'Name of aggregation field.', + }), + documentation: i18n.translate('xpack.urlDrilldown.range.event.key.documentation', { + defaultMessage: 'Aggregation field behind the selected range, if available.', + }), + kind, + }, + { + label: 'event.from', + sortText: sortPrefix + 'event.from', + title: i18n.translate('xpack.urlDrilldown.range.event.from.title', { + defaultMessage: 'Range start value.', + }), + documentation: i18n.translate('xpack.urlDrilldown.range.event.from.documentation', { + defaultMessage: + '`from` value of the selected range. Depending on your data, could be either a date or number.', + }), + kind, + }, + { + label: 'event.to', + sortText: sortPrefix + 'event.to', + title: i18n.translate('xpack.urlDrilldown.range.event.to.title', { + defaultMessage: 'Range end value.', + }), + documentation: i18n.translate('xpack.urlDrilldown.range.event.to.documentation', { + defaultMessage: + '`to` value of the selected range. Depending on your data, could be either a date or number.', + }), + kind, + }, +]; + +export const getEventVariableList = ( + context: ActionFactoryContext +): UrlTemplateEditorVariable[] => { + const [trigger] = context.triggers; + + switch (trigger) { + case VALUE_CLICK_TRIGGER: + return [...valueClickVariables]; + case ROW_CLICK_TRIGGER: + return [...rowClickVariables]; + case SELECT_RANGE_TRIGGER: + return [...selectRangeVariables]; + } + + return []; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.ts new file mode 100644 index 00000000000000..7338e6b471211a --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { monaco } from '@kbn/monaco'; +import { txtValue } from './i18n'; +import { UrlDrilldownGlobalScope } from '../../../../../ui_actions_enhanced/public'; +import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public'; + +const kind = monaco.languages.CompletionItemKind.Constant; +const sortPrefix = '3.'; + +export const getGlobalVariableList = ( + values: UrlDrilldownGlobalScope +): UrlTemplateEditorVariable[] => { + const globalVariables: UrlTemplateEditorVariable[] = [ + { + label: 'kibanaUrl', + sortText: sortPrefix + 'kibanaUrl', + title: i18n.translate('xpack.urlDrilldown.global.kibanaUrl.documentation.title', { + defaultMessage: 'Link to Kibana homepage.', + }), + documentation: + i18n.translate('xpack.urlDrilldown.global.kibanaUrl.documentation', { + defaultMessage: + 'Kibana base URL. Useful for creating URL drilldowns that navigate within Kibana.', + }) + + '\n\n' + + txtValue(values.kibanaUrl), + kind, + }, + ]; + + return globalVariables; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts new file mode 100644 index 00000000000000..b7b7cab535702a --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtValue = (value: string) => + i18n.translate('xpack.urlDrilldown.valuePreview', { + defaultMessage: 'Value: {value}', + values: { + value, + }, + }); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts new file mode 100644 index 00000000000000..ef9045b9ba1083 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type Primitive = string | number | boolean | null; + +export const toPrimitiveOrUndefined = (v: unknown): Primitive | undefined => { + if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null) + return v; + if (typeof v === 'object' && v instanceof Date) return v.toISOString(); + if (typeof v === 'undefined') return undefined; + return String(v); +}; + +export const deleteUndefinedKeys = >(obj: T): T => { + Object.keys(obj).forEach((key) => { + if (obj[key] === undefined) { + delete obj[key]; + } + }); + return obj; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts b/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts index 4b8e26c4a866b6..b733691c639b6f 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts @@ -47,6 +47,7 @@ export class UrlDrilldownPlugin startServices().core.docLinks.links.dashboard.urlDrilldownTemplateSyntax, getVariablesHelpDocsLink: () => startServices().core.docLinks.links.dashboard.urlDrilldownVariables, + uiSettings: core.uiSettings, }) ); diff --git a/x-pack/plugins/ui_actions_enhanced/kibana.json b/x-pack/plugins/ui_actions_enhanced/kibana.json index 5435019f216f2a..dbc136a2588842 100644 --- a/x-pack/plugins/ui_actions_enhanced/kibana.json +++ b/x-pack/plugins/ui_actions_enhanced/kibana.json @@ -2,13 +2,13 @@ "id": "uiActionsEnhanced", "version": "kibana", "configPath": ["xpack", "ui_actions_enhanced"], + "server": true, + "ui": true, "requiredPlugins": [ "embeddable", "uiActions", "licensing" ], - "server": true, - "ui": true, "requiredBundles": [ "kibanaUtils", "kibanaReact", diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts index c00bf2eed14759..ac2ddd1bfc14d6 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config/url_drilldown_collect_config'; +export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config'; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts index 60f43f86b7645f..3df237b03229ec 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -25,13 +25,6 @@ export const txtUrlPreviewHelpText = i18n.translate( } ); -export const txtAddVariableButtonTitle = i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle', - { - defaultMessage: 'Add variable', - } -); - export const txtUrlTemplateLabel = i18n.translate( 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel', { @@ -46,20 +39,6 @@ export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate( } ); -export const txtUrlTemplateVariablesHelpLinkText = i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText', - { - defaultMessage: 'Help', - } -); - -export const txtUrlTemplateVariablesFilterPlaceholderText = i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText', - { - defaultMessage: 'Filter variables', - } -); - export const txtUrlTemplatePreviewLabel = i18n.translate( 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel', { diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts new file mode 100644 index 00000000000000..7922158c62e7fc --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { UrlDrilldownCollectConfig, UrlDrilldownCollectConfigProps } from './lazy'; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx new file mode 100644 index 00000000000000..d4b04ce489dab6 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import type { UrlDrilldownCollectConfigProps } from './url_drilldown_collect_config'; + +const UrlDrilldownCollectConfigLazy = React.lazy(() => + import('./url_drilldown_collect_config').then(({ UrlDrilldownCollectConfig }) => ({ + default: UrlDrilldownCollectConfig, + })) +); + +export type { UrlDrilldownCollectConfigProps }; + +export const UrlDrilldownCollectConfig: React.FC = (props) => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx index 7a8b1b5b967ed3..f6a75765cd8ad5 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx @@ -20,7 +20,14 @@ export const Demo = () => { {JSON.stringify(config)} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx index 2537695b5edabe..c9da71440d2366 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -5,55 +5,49 @@ * 2.0. */ -import React, { useRef, useState } from 'react'; +import React, { useRef } from 'react'; import { EuiFormRow, - EuiIcon, EuiLink, - EuiPopover, - EuiPopoverFooter, - EuiPopoverTitle, - EuiSelectable, - EuiText, - EuiTextArea, - EuiSelectableOption, EuiSwitch, EuiAccordion, EuiSpacer, EuiPanel, EuiTextColor, } from '@elastic/eui'; +import { monaco } from '@kbn/monaco'; import { UrlDrilldownConfig } from '../../types'; import './index.scss'; import { - txtAddVariableButtonTitle, txtUrlTemplateSyntaxHelpLinkText, - txtUrlTemplateVariablesHelpLinkText, - txtUrlTemplateVariablesFilterPlaceholderText, txtUrlTemplateLabel, txtUrlTemplateOpenInNewTab, - txtUrlTemplatePlaceholder, txtUrlTemplateAdditionalOptions, txtUrlTemplateEncodeUrl, txtUrlTemplateEncodeDescription, } from './i18n'; +import { VariablePopover } from '../variable_popover'; +import { + UrlTemplateEditor, + UrlTemplateEditorVariable, +} from '../../../../../../../../src/plugins/kibana_react/public'; -export interface UrlDrilldownCollectConfig { +export interface UrlDrilldownCollectConfigProps { config: UrlDrilldownConfig; - variables: string[]; + variables: UrlTemplateEditorVariable[]; onConfig: (newConfig: UrlDrilldownConfig) => void; syntaxHelpDocsLink?: string; variablesHelpDocsLink?: string; } -export const UrlDrilldownCollectConfig: React.FC = ({ +export const UrlDrilldownCollectConfig: React.FC = ({ config, variables, onConfig, syntaxHelpDocsLink, variablesHelpDocsLink, }) => { - const textAreaRef = useRef(null); + const editorRef = useRef(null); const [showUrlError, setShowUrlError] = React.useState(false); const urlTemplate = config.url.template ?? ''; @@ -72,19 +66,16 @@ export const UrlDrilldownCollectConfig: React.FC = ({ const isEmpty = !urlTemplate; const isInvalid = showUrlError && isEmpty; const variablesDropdown = ( - { - if (textAreaRef.current) { - updateUrlTemplate( - urlTemplate.substr(0, textAreaRef.current!.selectionStart) + - `{{${variable}}}` + - urlTemplate.substr(textAreaRef.current!.selectionEnd) - ); - } else { - updateUrlTemplate(urlTemplate + `{{${variable}}}`); - } + const editor = editorRef.current; + if (!editor) return; + + editor.trigger('keyboard', 'type', { + text: '{{' + variable + '}}', + }); }} /> ); @@ -105,17 +96,13 @@ export const UrlDrilldownCollectConfig: React.FC = ({ } labelAppend={variablesDropdown} > - updateUrlTemplate(event.target.value)} - onBlur={() => setShowUrlError(true)} - rows={3} - inputRef={textAreaRef} + onChange={(newUrlTemplate) => updateUrlTemplate(newUrlTemplate)} + onEditor={(editor) => { + editorRef.current = editor; + }} /> @@ -156,71 +143,3 @@ export const UrlDrilldownCollectConfig: React.FC = ({ ); }; - -function AddVariableButton({ - variables, - onSelect, - variablesHelpLink, -}: { - variables: string[]; - onSelect: (variable: string) => void; - variablesHelpLink?: string; -}) { - const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); - const closePopover = () => setIsVariablesPopoverOpen(false); - - const options: EuiSelectableOption[] = variables.map((variable: string) => ({ - key: variable, - label: variable, - })); - - return ( - - setIsVariablesPopoverOpen(true)}> - {txtAddVariableButtonTitle} - - - } - isOpen={isVariablesPopoverOpen} - closePopover={closePopover} - panelPaddingSize="none" - anchorPosition="downLeft" - > - { - const selected = newOptions.find((o) => o.checked === 'on'); - if (!selected) return; - onSelect(selected.key!); - closePopover(); - }} - listProps={{ - showIcons: false, - }} - > - {(list, search) => ( -
- {search} - {list} - {variablesHelpLink && ( - - - {txtUrlTemplateVariablesHelpLinkText} - - - )} -
- )} -
-
- ); -} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/i18n.ts new file mode 100644 index 00000000000000..ece7a71778eb97 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/i18n.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtAddVariableButtonTitle = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle', + { + defaultMessage: 'Add variable', + } +); + +export const txtUrlTemplateVariablesHelpLinkText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText', + { + defaultMessage: 'Help', + } +); + +export const txtUrlTemplateVariablesFilterPlaceholderText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText', + { + defaultMessage: 'Filter variables', + } +); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/index.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/index.tsx new file mode 100644 index 00000000000000..a0c1b2ad24b111 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/variable_popover/index.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiIcon, + EuiLink, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiSelectable, + EuiText, + EuiSelectableOption, +} from '@elastic/eui'; +import { + txtAddVariableButtonTitle, + txtUrlTemplateVariablesHelpLinkText, + txtUrlTemplateVariablesFilterPlaceholderText, +} from './i18n'; +import { UrlTemplateEditorVariable } from '../../../../../../../../src/plugins/kibana_react/public'; + +export interface Props { + variables: UrlTemplateEditorVariable[]; + onSelect: (variable: string) => void; + variablesHelpLink?: string; +} + +export const VariablePopover: React.FC = ({ variables, onSelect, variablesHelpLink }) => { + const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); + const closePopover = () => setIsVariablesPopoverOpen(false); + + const options: EuiSelectableOption[] = variables.map(({ label }) => ({ + key: label, + label, + })); + + return ( + + setIsVariablesPopoverOpen(true)}> + {txtAddVariableButtonTitle} + + + } + isOpen={isVariablesPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + { + const selected = newOptions.find((o) => o.checked === 'on'); + if (!selected) return; + onSelect(selected.key!); + closePopover(); + }} + listProps={{ + showIcons: false, + }} + > + {(list, search) => ( +
+ {search} + {list} + {variablesHelpLink && ( + + + {txtUrlTemplateVariablesHelpLinkText} + + + )} +
+ )} +
+
+ ); +}; diff --git a/x-pack/test/functional/services/dashboard/drilldowns_manage.ts b/x-pack/test/functional/services/dashboard/drilldowns_manage.ts index 21fa18272c14b7..6f4deb2d329864 100644 --- a/x-pack/test/functional/services/dashboard/drilldowns_manage.ts +++ b/x-pack/test/functional/services/dashboard/drilldowns_manage.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Key } from 'selenium-webdriver'; import { FtrProviderContext } from '../../ftr_provider_context'; const CREATE_DRILLDOWN_FLYOUT_DATA_TEST_SUBJ = 'createDrilldownFlyout'; @@ -24,7 +25,8 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon const flyout = getService('flyout'); const comboBox = getService('comboBox'); const esArchiver = getService('esArchiver'); - + const find = getService('find'); + const browser = getService('browser'); return new (class DashboardDrilldownsManage { readonly DASHBOARD_WITH_PIE_CHART_NAME = 'Dashboard with Pie Chart'; readonly DASHBOARD_WITH_AREA_CHART_NAME = 'Dashboard With Area Chart'; @@ -116,8 +118,22 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon } } + async eraseInput(maxChars: number) { + const keys = [ + ...Array(maxChars).fill(Key.ARROW_RIGHT), + ...Array(maxChars).fill(Key.BACK_SPACE), + ]; + await browser + .getActions() + .sendKeys(...keys) + .perform(); + } + async fillInURLTemplate(destinationURLTemplate: string) { - await testSubjects.setValue('urlInput', destinationURLTemplate); + const monaco = await find.byCssSelector('.urlTemplateEditor__container .monaco-editor'); + await monaco.clickMouseButton(); + await this.eraseInput(300); + await browser.pressKeys(destinationURLTemplate); } async saveChanges() { From f1f206b2c825ea92e56d164c288ddac3268037d7 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 15 Feb 2021 18:34:46 +0100 Subject: [PATCH 051/175] Legacy ES client: use config.httpAuth instead of config.hosts.auth (#91276) * Use httpAuth instead of host.auth * NIT --- .../legacy/elasticsearch_client_config.test.ts | 4 +--- .../legacy/elasticsearch_client_config.ts | 11 +++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts index d8b9b79bc381db..6239ad270d5b57 100644 --- a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts +++ b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts @@ -99,7 +99,6 @@ test('parses fully specified config', () => { "apiVersion": "v7.0.0", "hosts": Array [ Object { - "auth": "elastic:changeme", "headers": Object { "x-elastic-product-origin": "kibana", "xsrf": "something", @@ -111,7 +110,6 @@ test('parses fully specified config', () => { "query": null, }, Object { - "auth": "elastic:changeme", "headers": Object { "x-elastic-product-origin": "kibana", "xsrf": "something", @@ -123,7 +121,6 @@ test('parses fully specified config', () => { "query": null, }, Object { - "auth": "elastic:changeme", "headers": Object { "x-elastic-product-origin": "kibana", "xsrf": "something", @@ -135,6 +132,7 @@ test('parses fully specified config', () => { "query": null, }, ], + "httpAuth": "elastic:changeme", "keepAlive": true, "log": [Function], "pingTimeout": 12345, diff --git a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts index 728fda04a8b5ed..d68e7635c57cb4 100644 --- a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts +++ b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts @@ -106,11 +106,14 @@ export function parseElasticsearchClientConfig( esClientConfig.sniffInterval = getDurationAsMs(config.sniffInterval); } + const needsAuth = auth !== false && config.username && config.password; + if (needsAuth) { + esClientConfig.httpAuth = `${config.username}:${config.password}`; + } + if (Array.isArray(config.hosts)) { - const needsAuth = auth !== false && config.username && config.password; esClientConfig.hosts = config.hosts.map((nodeUrl: string) => { const uri = url.parse(nodeUrl); - const httpsURI = uri.protocol === 'https:'; const httpURI = uri.protocol === 'http:'; @@ -126,10 +129,6 @@ export function parseElasticsearchClientConfig( }, }; - if (needsAuth) { - host.auth = `${config.username}:${config.password}`; - } - return host; }); } From 0a5e054fdc3ebf9b18653e6661344a580f7bd583 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Mon, 15 Feb 2021 13:36:39 -0500 Subject: [PATCH 052/175] [Fleet] Don't upgrade agent(s) in a managed policy (#91303) ## Summary - Make sure any agents requesting to be upgraded, are not enrolled in a managed policy. - `force: true` will only bypass agent / kibana version checks. It will not bypass managed policy check. To workaround, the enrolled policy should be changed to unmanaged (`is_managed: false`) as we do with enroll, reassign, etc. - Took more efficient approach to bulk actions. One `bulkGet` for N agents/policies vs N `get`s approach used for bulk reassignment of agents. See discussion in https://github.com/elastic/kibana/pull/88688/files#r568941761 - [x] API - [ ] UI - [x] tests ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### Manual tests #### upgrade one ``` curl --location --request POST 'http://localhost:5601/api/fleet/agents/8d9748e0-6d52-11eb-8cbd-47e38cd1c8de/upgrade' --header 'kbn-xsrf: ' --header 'Content-Type: application/json' --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' --data-raw '{ "version": "8.0.0" }' {"statusCode":400,"error":"Bad Request","message":"Cannot upgrade agent 8d9748e0-6d52-11eb-8cbd-47e38cd1c8de in managed policy bf319100-6d50-11eb-8859-15a87f509a99"} ``` ``` curl --location --request POST 'http://localhost:5601/api/fleet/agents/8d9748e0-6d52-11eb-8cbd-47e38cd1c8de/upgrade' --header 'kbn-xsrf: ' --header 'Content-Type: application/json' --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' --data-raw '{ "version": "8.0.0", "force": true }' {"statusCode":400,"error":"Bad Request","message":"Cannot upgrade agent 8d9748e0-6d52-11eb-8cbd-47e38cd1c8de in managed policy bf319100-6d50-11eb-8859-15a87f509a99"} ``` #### bulk upgrade ``` curl --location --request POST 'http://localhost:5601/api/fleet/agents/bulk_upgrade' --header 'kbn-xsrf: ' --header 'Content-Type: application/json' --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' --data-raw '{ "version": "8.0.0", "agents": [ "8d9748e0-6d52-11eb-8cbd-47e38cd1c8de" ] }' {} ``` ``` curl --location --request POST 'http://localhost:5601/api/fleet/agents/bulk_upgrade' --header 'kbn-xsrf: ' --header 'Content-Type: application/json' --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' --data-raw '{ "version": "8.0.0", "agents": [ "8d9748e0-6d52-11eb-8cbd-47e38cd1c8de" ], "force": true }' {"statusCode":400,"error":"Bad Request","message":"Cannot update agent in managed policy bf319100-6d50-11eb-8859-15a87f509a99"}``` ``` --- .../fleet/server/services/agent_policy.ts | 15 ++++ .../fleet/server/services/agents/upgrade.ts | 47 +++++++++-- .../apis/agents/upgrade.ts | 79 +++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 31e9a63175d181..f31f38796055c1 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -209,6 +209,21 @@ class AgentPolicyService { return agentPolicy; } + public async getByIDs( + soClient: SavedObjectsClientContract, + ids: string[], + options: { fields?: string[] } = {} + ): Promise { + const objects = ids.map((id) => ({ ...options, id, type: SAVED_OBJECT_TYPE })); + const agentPolicySO = await soClient.bulkGet(objects); + + return agentPolicySO.saved_objects.map((so) => ({ + id: so.id, + version: so.version, + ...so.attributes, + })); + } + public async list( soClient: SavedObjectsClientContract, options: ListWithKuery & { diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 5105e145309827..d73cc38e32c39d 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -8,8 +8,16 @@ import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { AgentAction, AgentActionSOAttributes } from '../../types'; import { AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../constants'; +import { agentPolicyService } from '../../services'; +import { IngestManagerError } from '../../errors'; import { bulkCreateAgentActions, createAgentAction } from './actions'; -import { getAgents, listAllAgents, updateAgent, bulkUpdateAgents } from './crud'; +import { + getAgents, + listAllAgents, + updateAgent, + bulkUpdateAgents, + getAgentPolicyForAgent, +} from './crud'; import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; @@ -31,6 +39,14 @@ export async function sendUpgradeAgentAction({ version, source_uri: sourceUri, }; + + const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); + if (agentPolicy?.is_managed) { + throw new IngestManagerError( + `Cannot upgrade agent ${agentId} in managed policy ${agentPolicy.id}` + ); + } + await createAgentAction(soClient, esClient, { agent_id: agentId, created_at: now, @@ -89,19 +105,40 @@ export async function sendUpgradeAgentsActions( showInactive: false, }) ).agents; - const agentsToUpdate = options.force + + // upgradeable if they pass the version check + const upgradeableAgents = options.force ? agents : agents.filter((agent) => isAgentUpgradeable(agent, kibanaVersion)); + + // get any policy ids from upgradable agents + const policyIdsToGet = new Set( + upgradeableAgents.filter((agent) => agent.policy_id).map((agent) => agent.policy_id!) + ); + + // get the agent policies for those ids + const agentPolicies = await agentPolicyService.getByIDs(soClient, Array.from(policyIdsToGet), { + fields: ['is_managed'], + }); + + // throw if any of those agent policies are managed + for (const policy of agentPolicies) { + if (policy.is_managed) { + throw new IngestManagerError(`Cannot upgrade agent in managed policy ${policy.id}`); + } + } + + // Create upgrade action for each agent const now = new Date().toISOString(); const data = { version: options.version, source_uri: options.sourceUri, }; - // Create upgrade action for each agent + await bulkCreateAgentActions( soClient, esClient, - agentsToUpdate.map((agent) => ({ + upgradeableAgents.map((agent) => ({ agent_id: agent.id, created_at: now, data, @@ -113,7 +150,7 @@ export async function sendUpgradeAgentsActions( return await bulkUpdateAgents( soClient, esClient, - agentsToUpdate.map((agent) => ({ + upgradeableAgents.map((agent) => ({ agentId: agent.id, data: { upgraded_at: null, diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index ce0da9158da2c9..1b7afac7e2f6c8 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -427,5 +427,84 @@ export default function (providerContext: FtrProviderContext) { }) .expect(400); }); + + describe('fleet upgrade agent(s) in a managed policy', function () { + it('should respond 400 to bulk upgrade and not update the agent SOs', async () => { + // update enrolled policy to managed + await supertest.put(`/api/fleet/agent_policies/policy1`).set('kbn-xsrf', 'xxxx').send({ + name: 'Test policy', + namespace: 'default', + is_managed: true, + }); + + const kibanaVersion = await kibanaServer.version.get(); + await kibanaServer.savedObjects.update({ + id: 'agent1', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { + local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, + }, + }); + await kibanaServer.savedObjects.update({ + id: 'agent2', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { + local_metadata: { + elastic: { + agent: { upgradeable: true, version: semver.inc(kibanaVersion, 'patch') }, + }, + }, + }, + }); + + // attempt to upgrade agent in managed policy + const { body } = await supertest + .post(`/api/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersion, + agents: ['agent1', 'agent2'], + }) + .expect(400); + expect(body.message).to.contain('Cannot upgrade agent in managed policy policy1'); + + const [agent1data, agent2data] = await Promise.all([ + supertest.get(`/api/fleet/agents/agent1`), + supertest.get(`/api/fleet/agents/agent2`), + ]); + + expect(typeof agent1data.body.item.upgrade_started_at).to.be('undefined'); + expect(typeof agent2data.body.item.upgrade_started_at).to.be('undefined'); + }); + + it('should respond 400 to upgrade and not update the agent SOs', async () => { + // update enrolled policy to managed + await supertest.put(`/api/fleet/agent_policies/policy1`).set('kbn-xsrf', 'xxxx').send({ + name: 'Test policy', + namespace: 'default', + is_managed: true, + }); + + const kibanaVersion = await kibanaServer.version.get(); + await kibanaServer.savedObjects.update({ + id: 'agent1', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { + local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, + }, + }); + + // attempt to upgrade agent in managed policy + const { body } = await supertest + .post(`/api/fleet/agents/agent1/upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ version: kibanaVersion }) + .expect(400); + expect(body.message).to.contain('Cannot upgrade agent agent1 in managed policy policy1'); + + const agent1data = await supertest.get(`/api/fleet/agents/agent1`); + expect(typeof agent1data.body.item.upgrade_started_at).to.be('undefined'); + }); + }); }); } From a8e1e47de6ee7b385c17928e91589db630835d6c Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 15 Feb 2021 20:47:00 +0100 Subject: [PATCH 053/175] [ML] Anomaly Detection alert initialisation from the ML app (#91283) --- x-pack/plugins/ml/common/types/alerts.ts | 1 + .../plugins/ml/common/types/capabilities.ts | 2 + .../alerting/interim_results_control.tsx | 34 +++ .../ml/public/alerting/job_selector.tsx | 10 +- .../ml/public/alerting/ml_alerting_flyout.tsx | 105 ++++++++ .../alerting/ml_anomaly_alert_trigger.tsx | 33 ++- .../ml/public/alerting/register_ml_alerts.ts | 8 +- x-pack/plugins/ml/public/application/app.tsx | 1 + .../contexts/kibana/kibana_context.ts | 2 + .../create_watch_flyout.js | 182 -------------- .../create_watch_service.js | 199 --------------- .../create_watch_flyout/create_watch_view.js | 215 ---------------- .../components/create_watch_flyout/email.html | 43 ---- .../email_influencers.html | 9 - .../components/create_watch_flyout/index.js | 8 - .../create_watch_flyout/select_severity.tsx | 134 ---------- .../components/create_watch_flyout/watch.js | 232 ------------------ .../components/job_actions/management.js | 20 +- .../components/jobs_list/jobs_list.js | 8 +- .../jobs_list_view/jobs_list_view.js | 26 +- .../multi_job_actions/actions_menu.js | 22 ++ .../multi_job_actions/multi_job_actions.js | 2 + .../start_datafeed_modal.js | 37 ++- .../post_save_options/post_save_options.tsx | 43 +--- x-pack/plugins/ml/public/plugin.ts | 10 +- .../ml/server/lib/alerts/alerting_service.ts | 7 + .../capabilities/check_capabilities.test.ts | 3 +- x-pack/plugins/ml/server/routes/alerting.ts | 2 + .../server/routes/schemas/alerting_schema.ts | 1 + .../machine_learning/empty_ml_capabilities.ts | 1 + .../translations/translations/ja-JP.json | 24 -- .../translations/translations/zh-CN.json | 24 -- 32 files changed, 289 insertions(+), 1159 deletions(-) create mode 100644 x-pack/plugins/ml/public/alerting/interim_results_control.tsx create mode 100644 x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js diff --git a/x-pack/plugins/ml/common/types/alerts.ts b/x-pack/plugins/ml/common/types/alerts.ts index d19385a175efd9..51d06b99062300 100644 --- a/x-pack/plugins/ml/common/types/alerts.ts +++ b/x-pack/plugins/ml/common/types/alerts.ts @@ -89,4 +89,5 @@ export type MlAnomalyDetectionAlertParams = { }; severity: number; resultType: AnomalyResultType; + includeInterim: boolean; } & AlertTypeParams; diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index cccf87f0a7950d..61a5013642cd7a 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -57,6 +57,8 @@ export const adminMlCapabilities = { canCreateDataFrameAnalytics: false, canDeleteDataFrameAnalytics: false, canStartStopDataFrameAnalytics: false, + // Alerts + canCreateMlAlerts: false, }; export type UserMlCapabilities = typeof userMlCapabilities; diff --git a/x-pack/plugins/ml/public/alerting/interim_results_control.tsx b/x-pack/plugins/ml/public/alerting/interim_results_control.tsx new file mode 100644 index 00000000000000..fa930d9a0ea0fb --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/interim_results_control.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface InterimResultsControlProps { + value: boolean; + onChange: (update: boolean) => void; +} + +export const InterimResultsControl: FC = React.memo( + ({ value, onChange }) => { + return ( + + + } + checked={value} + onChange={onChange.bind(null, !value)} + /> + + ); + } +); diff --git a/x-pack/plugins/ml/public/alerting/job_selector.tsx b/x-pack/plugins/ml/public/alerting/job_selector.tsx index 969ed5af79107e..60bb7517406b8e 100644 --- a/x-pack/plugins/ml/public/alerting/job_selector.tsx +++ b/x-pack/plugins/ml/public/alerting/job_selector.tsx @@ -19,7 +19,7 @@ interface JobSelection { export interface JobSelectorControlProps { jobSelection?: JobSelection; - onSelectionChange: (jobSelection: JobSelection) => void; + onChange: (jobSelection: JobSelection) => void; adJobsApiService: MlApiServices['jobs']; /** * Validation is handled by alerting framework @@ -29,7 +29,7 @@ export interface JobSelectorControlProps { export const JobSelectorControl: FC = ({ jobSelection, - onSelectionChange, + onChange, adJobsApiService, errors, }) => { @@ -70,7 +70,7 @@ export const JobSelectorControl: FC = ({ } }, [adJobsApiService]); - const onChange: EuiComboBoxProps['onChange'] = useCallback( + const onSelectionChange: EuiComboBoxProps['onChange'] = useCallback( (selectedOptions) => { const selectedJobIds: JobId[] = []; const selectedGroupIds: string[] = []; @@ -81,7 +81,7 @@ export const JobSelectorControl: FC = ({ selectedGroupIds.push(label); } }); - onSelectionChange({ + onChange({ ...(selectedJobIds.length > 0 ? { jobIds: selectedJobIds } : {}), ...(selectedGroupIds.length > 0 ? { groupIds: selectedGroupIds } : {}), }); @@ -114,7 +114,7 @@ export const JobSelectorControl: FC = ({ selectedOptions={selectedOptions} options={options} - onChange={onChange} + onChange={onSelectionChange} fullWidth data-test-subj={'mlAnomalyAlertJobSelection'} isInvalid={!!errors?.length} diff --git a/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx new file mode 100644 index 00000000000000..ba573fe42f5f23 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { JobId } from '../../common/types/anomaly_detection_jobs'; +import { useMlKibana } from '../application/contexts/kibana'; +import { ML_ALERT_TYPES } from '../../common/constants/alerts'; +import { PLUGIN_ID } from '../../common/constants/app'; + +interface MlAnomalyAlertFlyoutProps { + jobIds: JobId[]; + onSave?: () => void; + onCloseFlyout: () => void; +} + +/** + * Invoke alerting flyout from the ML plugin context. + * @param jobIds + * @param onCloseFlyout + * @constructor + */ +export const MlAnomalyAlertFlyout: FC = ({ + jobIds, + onCloseFlyout, + onSave, +}) => { + const { + services: { triggersActionsUi }, + } = useMlKibana(); + + const AddAlertFlyout = useMemo( + () => + triggersActionsUi && + triggersActionsUi.getAddAlertFlyout({ + consumer: PLUGIN_ID, + onClose: () => { + onCloseFlyout(); + }, + // Callback for successful save + reloadAlerts: async () => { + if (onSave) { + onSave(); + } + }, + canChangeTrigger: false, + alertTypeId: ML_ALERT_TYPES.ANOMALY_DETECTION, + metadata: {}, + initialValues: { + params: { + jobSelection: { + jobIds, + }, + }, + }, + }), + [triggersActionsUi] + ); + + return <>{AddAlertFlyout}; +}; + +interface JobListMlAnomalyAlertFlyoutProps { + setShowFunction: (callback: Function) => void; + unsetShowFunction: () => void; +} + +/** + * Component to wire the Alerting flyout with the Job list view. + * @param setShowFunction + * @param unsetShowFunction + * @constructor + */ +export const JobListMlAnomalyAlertFlyout: FC = ({ + setShowFunction, + unsetShowFunction, +}) => { + const [isVisible, setIsVisible] = useState(false); + const [jobIds, setJobIds] = useState(); + + const showFlyoutCallback = useCallback((jobIdsUpdate: JobId[]) => { + setJobIds(jobIdsUpdate); + setIsVisible(true); + }, []); + + useEffect(() => { + setShowFunction(showFlyoutCallback); + return () => { + unsetShowFunction(); + }; + }, []); + + return isVisible && jobIds ? ( + setIsVisible(false)} + onSave={() => { + setIsVisible(false); + }} + /> + ) : null; +}; diff --git a/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx b/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx index 5991a603890d71..3dd023a6187dda 100644 --- a/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx +++ b/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import React, { FC, useCallback, useEffect, useMemo } from 'react'; +import React, { FC, useCallback, useMemo } from 'react'; import { EuiSpacer, EuiForm } from '@elastic/eui'; +import useMount from 'react-use/lib/useMount'; import { JobSelectorControl } from './job_selector'; import { useMlKibana } from '../application/contexts/kibana'; import { jobsApiProvider } from '../application/services/ml_api_service/jobs'; @@ -18,6 +19,7 @@ import { PreviewAlertCondition } from './preview_alert_condition'; import { ANOMALY_THRESHOLD } from '../../common'; import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts'; import { ANOMALY_RESULT_TYPE } from '../../common/constants/anomalies'; +import { InterimResultsControl } from './interim_results_control'; interface MlAnomalyAlertTriggerProps { alertParams: MlAnomalyDetectionAlertParams; @@ -25,12 +27,14 @@ interface MlAnomalyAlertTriggerProps { key: T, value: MlAnomalyDetectionAlertParams[T] ) => void; + setAlertProperty: (prop: string, update: Partial) => void; errors: Record; } const MlAnomalyAlertTrigger: FC = ({ alertParams, setAlertParams, + setAlertProperty, errors, }) => { const { @@ -49,21 +53,26 @@ const MlAnomalyAlertTrigger: FC = ({ [] ); - useEffect(function setDefaults() { - if (alertParams.severity === undefined) { - onAlertParamChange('severity')(ANOMALY_THRESHOLD.CRITICAL); + useMount(function setDefaults() { + const { jobSelection, ...rest } = alertParams; + if (Object.keys(rest).length === 0) { + setAlertProperty('params', { + // Set defaults + severity: ANOMALY_THRESHOLD.CRITICAL, + resultType: ANOMALY_RESULT_TYPE.BUCKET, + includeInterim: true, + // Preserve job selection + jobSelection, + }); } - if (alertParams.resultType === undefined) { - onAlertParamChange('resultType')(ANOMALY_RESULT_TYPE.BUCKET); - } - }, []); + }); return ( = ({ onChange={useCallback(onAlertParamChange('severity'), [])} /> + + + diff --git a/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts b/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts index 7f55eba9cbdc20..1d7bd06989bd94 100644 --- a/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts +++ b/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts @@ -7,14 +7,12 @@ import { i18n } from '@kbn/i18n'; import { lazy } from 'react'; -import { MlStartDependencies } from '../plugin'; import { ML_ALERT_TYPES } from '../../common/constants/alerts'; import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; -export function registerMlAlerts( - alertTypeRegistry: MlStartDependencies['triggersActionsUi']['alertTypeRegistry'] -) { - alertTypeRegistry.register({ +export function registerMlAlerts(triggersActionsUi: TriggersAndActionsUIPublicPluginSetup) { + triggersActionsUi.alertTypeRegistry.register({ id: ML_ALERT_TYPES.ANOMALY_DETECTION, description: i18n.translate('xpack.ml.alertTypes.anomalyDetection.description', { defaultMessage: 'Alert when anomaly detection jobs results match the condition.', diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 0199e13e93d8c0..3df67bc16ab058 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -81,6 +81,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { storage: localStorage, embeddable: deps.embeddable, maps: deps.maps, + triggersActionsUi: deps.triggersActionsUi, ...coreStart, }; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index 1dd30d5d99335d..99d4b77547d9d0 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -19,6 +19,7 @@ import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/p import type { EmbeddableStart } from '../../../../../../../src/plugins/embeddable/public'; import type { MapsStartApi } from '../../../../../maps/public'; import type { LensPublicStart } from '../../../../../lens/public'; +import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; interface StartPlugins { data: DataPublicPluginStart; @@ -28,6 +29,7 @@ interface StartPlugins { embeddable: EmbeddableStart; maps?: MapsStartApi; lens?: LensPublicStart; + triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; } export type StartServices = CoreStart & StartPlugins & { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js deleted file mode 100644 index 49dc06888161f6..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -import { - EuiButton, - EuiButtonEmpty, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; - -import { loadFullJob } from '../utils'; -import { mlCreateWatchService } from './create_watch_service'; -import { CreateWatch } from './create_watch_view'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; - -function getSuccessToast(id, url) { - return { - title: i18n.translate( - 'xpack.ml.jobsList.createWatchFlyout.watchCreatedSuccessfullyNotificationMessage', - { - defaultMessage: 'Watch {id} created successfully', - values: { id }, - } - ), - text: ( - - - - - {i18n.translate('xpack.ml.jobsList.createWatchFlyout.editWatchButtonLabel', { - defaultMessage: 'Edit watch', - })} - - - - - ), - }; -} - -export class CreateWatchFlyoutUI extends Component { - constructor(props) { - super(props); - - this.state = { - jobId: null, - bucketSpan: null, - }; - } - - componentDidMount() { - if (typeof this.props.setShowFunction === 'function') { - this.props.setShowFunction(this.showFlyout); - } - } - - componentWillUnmount() { - if (typeof this.props.unsetShowFunction === 'function') { - this.props.unsetShowFunction(); - } - } - - closeFlyout = (watchCreated = false) => { - this.setState({ isFlyoutVisible: false }, () => { - if (typeof this.props.flyoutHidden === 'function') { - this.props.flyoutHidden(watchCreated); - } - }); - }; - - showFlyout = (jobId) => { - loadFullJob(jobId) - .then((job) => { - const bucketSpan = job.analysis_config.bucket_span; - mlCreateWatchService.config.includeInfluencers = job.analysis_config.influencers.length > 0; - - this.setState({ - job, - jobId, - bucketSpan, - isFlyoutVisible: true, - }); - }) - .catch((error) => { - console.error(error); - }); - }; - - save = () => { - const { toasts } = this.props.kibana.services.notifications; - mlCreateWatchService - .createNewWatch(this.state.jobId) - .then((resp) => { - toasts.addSuccess(getSuccessToast(resp.id, resp.url)); - this.closeFlyout(true); - }) - .catch((error) => { - toasts.addDanger( - i18n.translate( - 'xpack.ml.jobsList.createWatchFlyout.watchNotSavedErrorNotificationMessage', - { - defaultMessage: 'Could not save watch', - } - ) - ); - console.error(error); - }); - }; - - render() { - const { jobId, bucketSpan } = this.state; - - let flyout; - - if (this.state.isFlyoutVisible) { - flyout = ( - - - -

- -

-
-
- - - - - - - - - - - - - - - - - -
- ); - } - return
{flyout}
; - } -} -CreateWatchFlyoutUI.propTypes = { - setShowFunction: PropTypes.func.isRequired, - unsetShowFunction: PropTypes.func.isRequired, - flyoutHidden: PropTypes.func, -}; - -export const CreateWatchFlyout = withKibana(CreateWatchFlyoutUI); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js deleted file mode 100644 index cd81355b3f97e9..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { template } from 'lodash'; -import { http } from '../../../../services/http_service'; - -import emailBody from './email.html'; -import emailInfluencersBody from './email_influencers.html'; -import { DEFAULT_WATCH_SEVERITY } from './select_severity'; -import { watch } from './watch.js'; -import { i18n } from '@kbn/i18n'; -import { getBasePath, getApplication } from '../../../../util/dependency_cache'; - -const compiledEmailBody = template(emailBody); -const compiledEmailInfluencersBody = template(emailInfluencersBody); - -const emailSection = { - send_email: { - throttle_period_in_millis: 900000, // 15m - email: { - profile: 'standard', - to: [], - subject: i18n.translate('xpack.ml.newJob.simple.watcher.email.mlWatcherAlertSubjectTitle', { - defaultMessage: 'ML Watcher Alert', - }), - body: { - html: '', - }, - }, - }, -}; - -// generate a random number between min and max -function randomNumber(min, max) { - return Math.floor(Math.random() * (max - min + 1) + min); -} - -function saveWatch(watchModel) { - const path = `/api/watcher/watch/${watchModel.id}`; - - return http({ - path, - method: 'PUT', - body: JSON.stringify(watchModel.upstreamJSON), - }); -} - -class CreateWatchService { - constructor() { - this.config = {}; - - this.STATUS = { - SAVE_FAILED: -1, - SAVING: 0, - SAVED: 1, - }; - - this.status = { - realtimeJob: null, - watch: null, - }; - } - - reset() { - this.status.realtimeJob = null; - this.status.watch = null; - - this.config.id = ''; - this.config.includeEmail = false; - this.config.email = ''; - this.config.interval = '20m'; - this.config.watcherEditURL = ''; - this.config.includeInfluencers = false; - - // Current implementation means that default needs to match that of the select severity control. - const { display, val } = DEFAULT_WATCH_SEVERITY; - this.config.threshold = { display, val }; - } - - createNewWatch = function (jobId) { - return new Promise((resolve, reject) => { - this.status.watch = this.STATUS.SAVING; - if (jobId !== undefined) { - const id = `ml-${jobId}`; - this.config.id = id; - - // set specific properties of the the watch - watch.input.search.request.body.query.bool.filter[0].term.job_id = jobId; - watch.input.search.request.body.query.bool.filter[1].range.timestamp.gte = `now-${this.config.interval}`; - watch.input.search.request.body.aggs.bucket_results.filter.range.anomaly_score.gte = this.config.threshold.val; - - if (this.config.includeEmail && this.config.email !== '') { - const { getUrlForApp } = getApplication(); - const emails = this.config.email.split(','); - emailSection.send_email.email.to = emails; - - // create the html by adding the variables to the compiled email body. - emailSection.send_email.email.body.html = compiledEmailBody({ - serverAddress: getUrlForApp('ml', { absolute: true }), - influencersSection: - this.config.includeInfluencers === true - ? compiledEmailInfluencersBody({ - topInfluencersLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.topInfluencersLabel', - { - defaultMessage: 'Top influencers:', - } - ), - }) - : '', - elasticStackMachineLearningAlertLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.elasticStackMachineLearningAlertLabel', - { - defaultMessage: 'Elastic Stack Machine Learning Alert', - } - ), - jobLabel: i18n.translate('xpack.ml.newJob.simple.watcher.email.jobLabel', { - defaultMessage: 'Job', - }), - timeLabel: i18n.translate('xpack.ml.newJob.simple.watcher.email.timeLabel', { - defaultMessage: 'Time', - }), - anomalyScoreLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.anomalyScoreLabel', - { - defaultMessage: 'Anomaly score', - } - ), - openInAnomalyExplorerLinkText: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.openInAnomalyExplorerLinkText', - { - defaultMessage: 'Click here to open in Anomaly Explorer.', - } - ), - topRecordsLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.topRecordsLabel', - { defaultMessage: 'Top records:' } - ), - }); - - // add email section to watch - watch.actions.send_email = emailSection.send_email; - } - - // set the trigger interval to be a random number between 60 and 120 seconds - // this is to avoid all watches firing at once if the server restarts - // and the watches synchronize - const triggerInterval = randomNumber(60, 120); - watch.trigger.schedule.interval = `${triggerInterval}s`; - - const watchModel = { - id, - upstreamJSON: { - id, - type: 'json', - isNew: false, // Set to false, as we want to allow watches to be overwritten. - isActive: true, - watch, - }, - }; - - const basePath = getBasePath(); - if (id !== '') { - saveWatch(watchModel) - .then(() => { - this.status.watch = this.STATUS.SAVED; - this.config.watcherEditURL = `${basePath.get()}/app/management/insightsAndAlerting/watcher/watches/watch/${id}/edit?_g=()`; - resolve({ - id, - url: this.config.watcherEditURL, - }); - }) - .catch((resp) => { - this.status.watch = this.STATUS.SAVE_FAILED; - reject(resp); - }); - } - } else { - this.status.watch = this.STATUS.SAVE_FAILED; - reject(); - } - }); - }; - - loadWatch(jobId) { - const id = `ml-${jobId}`; - const path = `/api/watcher/watch/${id}`; - return http({ - path, - method: 'GET', - }); - } -} - -export const mlCreateWatchService = new CreateWatchService(); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js deleted file mode 100644 index 2997d56b68f06f..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -import { EuiCheckbox, EuiFieldText, EuiCallOut } from '@elastic/eui'; - -import { has } from 'lodash'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { parseInterval } from '../../../../../../common/util/parse_interval'; -import { ml } from '../../../../services/ml_api_service'; -import { SelectSeverity } from './select_severity'; -import { mlCreateWatchService } from './create_watch_service'; -const STATUS = mlCreateWatchService.STATUS; - -export class CreateWatch extends Component { - static propTypes = { - jobId: PropTypes.string.isRequired, - bucketSpan: PropTypes.string.isRequired, - }; - - constructor(props) { - super(props); - mlCreateWatchService.reset(); - this.config = mlCreateWatchService.config; - - this.state = { - jobId: this.props.jobId, - bucketSpan: this.props.bucketSpan, - interval: this.config.interval, - threshold: this.config.threshold, - includeEmail: this.config.emailIncluded, - email: this.config.email, - emailEnabled: false, - status: null, - watchAlreadyExists: false, - }; - } - - componentDidMount() { - // make the interval 2 times the bucket span - if (this.state.bucketSpan) { - const intervalObject = parseInterval(this.state.bucketSpan); - let bs = intervalObject.asMinutes() * 2; - if (bs < 1) { - bs = 1; - } - - const interval = `${bs}m`; - this.setState({ interval }, () => { - this.config.interval = interval; - }); - } - - // load elasticsearch settings to see if email has been configured - ml.getNotificationSettings().then((resp) => { - if (has(resp, 'defaults.xpack.notification.email')) { - this.setState({ emailEnabled: true }); - } - }); - - mlCreateWatchService - .loadWatch(this.state.jobId) - .then(() => { - this.setState({ watchAlreadyExists: true }); - }) - .catch(() => { - this.setState({ watchAlreadyExists: false }); - }); - } - - onThresholdChange = (threshold) => { - this.setState({ threshold }, () => { - this.config.threshold = threshold; - }); - }; - - onIntervalChange = (e) => { - const interval = e.target.value; - this.setState({ interval }, () => { - this.config.interval = interval; - }); - }; - - onIncludeEmailChanged = (e) => { - const includeEmail = e.target.checked; - this.setState({ includeEmail }, () => { - this.config.includeEmail = includeEmail; - }); - }; - - onEmailChange = (e) => { - const email = e.target.value; - this.setState({ email }, () => { - this.config.email = email; - }); - }; - - render() { - const { status } = this.state; - - if (status === null || status === STATUS.SAVING || status === STATUS.SAVE_FAILED) { - return ( -
-
-
-
- -
- - ), - }} - /> -
- -
-
- -
-
- -
-
-
- {this.state.emailEnabled && ( -
- - } - checked={this.state.includeEmail} - onChange={this.onIncludeEmailChanged} - /> - {this.state.includeEmail && ( -
- -
- )} -
- )} - {this.state.watchAlreadyExists && ( - - } - /> - )} -
- ); - } else if (status === STATUS.SAVED) { - return ( -
- -
- ); - } else { - return
; - } - } -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html deleted file mode 100644 index 713a68ba0c0365..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - <%= elasticStackMachineLearningAlertLabel %> - -
-
- - - <%= jobLabel %> - : {{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0._source.job_id}} -
- - - <%= timeLabel %> - : {{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.timestamp_iso8601.0}} -
- - - <%= anomalyScoreLabel %> - : {{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.score.0}} -
-
- - - <%= openInAnomalyExplorerLinkText %> - -
-
- - <%= influencersSection %> - - - <%= topRecordsLabel %> - -
- {{#ctx.payload.aggregations.record_results.top_record_hits.hits.hits}} - {{_source.function}}({{_source.field_name}}) {{_source.by_field_value}} {{_source.over_field_value}} {{_source.partition_field_value}} [{{fields.score.0}}] -
- {{/ctx.payload.aggregations.record_results.top_record_hits.hits.hits}} - - - diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html deleted file mode 100644 index ab22ef672e97bf..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html +++ /dev/null @@ -1,9 +0,0 @@ - - <%= topInfluencersLabel %> - -
- {{#ctx.payload.aggregations.influencer_results.top_influencer_hits.hits.hits}} - {{_source.influencer_field_name}} = {{_source.influencer_field_value}} [{{fields.score.0}}] -
- {{/ctx.payload.aggregations.influencer_results.top_influencer_hits.hits.hits}} -
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js deleted file mode 100644 index 06588671832800..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { CreateWatchFlyout } from './create_watch_flyout'; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx deleted file mode 100644 index 347e25816672b4..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* - * React component for rendering a select element with threshold levels. - * This is basically a copy of SelectSeverity in public/application/components/controls/select_severity - * but which stores its state internally rather than in the appState - */ -import React, { Fragment, FC, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; - -import { getSeverityColor } from '../../../../../../common/util/anomaly_utils'; - -const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', { - defaultMessage: 'warning', -}); -const minorLabel = i18n.translate('xpack.ml.controls.selectSeverity.minorLabel', { - defaultMessage: 'minor', -}); -const majorLabel = i18n.translate('xpack.ml.controls.selectSeverity.majorLabel', { - defaultMessage: 'major', -}); -const criticalLabel = i18n.translate('xpack.ml.controls.selectSeverity.criticalLabel', { - defaultMessage: 'critical', -}); - -const optionsMap = { - [warningLabel]: 0, - [minorLabel]: 25, - [majorLabel]: 50, - [criticalLabel]: 75, -}; - -interface TableSeverity { - val: number; - display: string; - color: string; -} - -export const SEVERITY_OPTIONS: TableSeverity[] = [ - { - val: 0, - display: warningLabel, - color: getSeverityColor(0), - }, - { - val: 25, - display: minorLabel, - color: getSeverityColor(25), - }, - { - val: 50, - display: majorLabel, - color: getSeverityColor(50), - }, - { - val: 75, - display: criticalLabel, - color: getSeverityColor(75), - }, -]; - -function optionValueToThreshold(value: number) { - // Get corresponding threshold object with required display and val properties from the specified value. - let threshold = SEVERITY_OPTIONS.find((opt) => opt.val === value); - - // Default to warning if supplied value doesn't map to one of the options. - if (threshold === undefined) { - threshold = SEVERITY_OPTIONS[0]; - } - - return threshold; -} - -export const DEFAULT_WATCH_SEVERITY = SEVERITY_OPTIONS[3]; - -const getSeverityOptions = () => - SEVERITY_OPTIONS.map(({ color, display, val }) => ({ - value: display, - inputDisplay: ( - - - {display} - - - ), - dropdownDisplay: ( - - - {display} - - - -

- -

-
-
- ), - })); - -interface Props { - onChange: (sev: TableSeverity) => void; -} - -export const SelectSeverity: FC = ({ onChange }) => { - const [severity, setSeverity] = useState(DEFAULT_WATCH_SEVERITY); - - const onSeverityChange = (valueDisplay: string) => { - const option = optionValueToThreshold(optionsMap[valueDisplay]); - setSeverity(option); - onChange(option); - }; - - return ( - - ); -}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js deleted file mode 100644 index 2fcde2184bf062..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns'; - -export const watch = { - trigger: { - schedule: { - interval: '60s', - }, - }, - input: { - search: { - request: { - search_type: 'query_then_fetch', - indices: [ML_RESULTS_INDEX_PATTERN], - body: { - size: 0, - query: { - bool: { - filter: [ - { - term: { - job_id: null, - }, - }, - { - range: { - timestamp: { - gte: null, - }, - }, - }, - { - terms: { - result_type: ['bucket', 'record', 'influencer'], - }, - }, - ], - }, - }, - aggs: { - bucket_results: { - filter: { - range: { - anomaly_score: { - gte: null, - }, - }, - }, - aggs: { - top_bucket_hits: { - top_hits: { - sort: [ - { - anomaly_score: { - order: 'desc', - }, - }, - ], - _source: { - includes: [ - 'job_id', - 'result_type', - 'timestamp', - 'anomaly_score', - 'is_interim', - ], - }, - size: 1, - script_fields: { - start: { - script: { - lang: 'painless', - source: `LocalDateTime.ofEpochSecond((doc["timestamp"].value.getMillis()-((doc["bucket_span"].value * 1000) - * params.padding)) / 1000, 0, ZoneOffset.UTC).toString()+\":00.000Z\"`, - params: { - padding: 10, - }, - }, - }, - end: { - script: { - lang: 'painless', - source: `LocalDateTime.ofEpochSecond((doc["timestamp"].value.getMillis()+((doc["bucket_span"].value * 1000) - * params.padding)) / 1000, 0, ZoneOffset.UTC).toString()+\":00.000Z\"`, - params: { - padding: 10, - }, - }, - }, - timestamp_epoch: { - script: { - lang: 'painless', - source: 'doc["timestamp"].value.getMillis()/1000', - }, - }, - timestamp_iso8601: { - script: { - lang: 'painless', - source: 'doc["timestamp"].value', - }, - }, - score: { - script: { - lang: 'painless', - source: 'Math.round(doc["anomaly_score"].value)', - }, - }, - }, - }, - }, - }, - }, - influencer_results: { - filter: { - range: { - influencer_score: { - gte: 3, - }, - }, - }, - aggs: { - top_influencer_hits: { - top_hits: { - sort: [ - { - influencer_score: { - order: 'desc', - }, - }, - ], - _source: { - includes: [ - 'result_type', - 'timestamp', - 'influencer_field_name', - 'influencer_field_value', - 'influencer_score', - 'isInterim', - ], - }, - size: 3, - script_fields: { - score: { - script: { - lang: 'painless', - source: 'Math.round(doc["influencer_score"].value)', - }, - }, - }, - }, - }, - }, - }, - record_results: { - filter: { - range: { - record_score: { - gte: 3, - }, - }, - }, - aggs: { - top_record_hits: { - top_hits: { - sort: [ - { - record_score: { - order: 'desc', - }, - }, - ], - _source: { - includes: [ - 'result_type', - 'timestamp', - 'record_score', - 'is_interim', - 'function', - 'field_name', - 'by_field_value', - 'over_field_value', - 'partition_field_value', - ], - }, - size: 3, - script_fields: { - score: { - script: { - lang: 'painless', - source: 'Math.round(doc["record_score"].value)', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - condition: { - compare: { - 'ctx.payload.aggregations.bucket_results.doc_count': { - gt: 0, - }, - }, - }, - actions: { - log: { - logging: { - level: 'info', - text: '', // this gets populated below. - }, - }, - }, -}; - -// Add logging text. Broken over a few lines due to its length. -let txt = - 'Alert for job [{{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0._source.job_id}}] at '; -txt += - '[{{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.timestamp_iso8601.0}}] score '; -txt += '[{{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.score.0}}]'; -watch.actions.log.logging.text = txt; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js index 8f955e771327e6..471295938acde3 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js @@ -17,7 +17,8 @@ export function actionsMenuContent( showEditJobFlyout, showDeleteJobModal, showStartDatafeedModal, - refreshJobs + refreshJobs, + showCreateAlertFlyout ) { const canCreateJob = checkPermission('canCreateJob') && mlNodesAvailable(); const canUpdateJob = checkPermission('canUpdateJob'); @@ -25,6 +26,7 @@ export function actionsMenuContent( const canUpdateDatafeed = checkPermission('canUpdateDatafeed'); const canStartStopDatafeed = checkPermission('canStartStopDatafeed') && mlNodesAvailable(); const canCloseJob = checkPermission('canCloseJob') && mlNodesAvailable(); + const canCreateMlAlerts = checkPermission('canCreateMlAlerts'); return [ { @@ -59,6 +61,22 @@ export function actionsMenuContent( }, 'data-test-subj': 'mlActionButtonStopDatafeed', }, + { + name: i18n.translate('xpack.ml.jobsList.managementActions.createAlertLabel', { + defaultMessage: 'Create alert', + }), + description: i18n.translate('xpack.ml.jobsList.managementActions.createAlertLabel', { + defaultMessage: 'Create alert', + }), + icon: 'bell', + enabled: (item) => item.deleting !== true, + available: () => canCreateMlAlerts, + onClick: (item) => { + showCreateAlertFlyout([item.id]); + closeMenu(true); + }, + 'data-test-subj': 'mlActionButtonCreateAlert', + }, { name: i18n.translate('xpack.ml.jobsList.managementActions.closeJobLabel', { defaultMessage: 'Close job', diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index 261c58bebaaa86..4674342990df4a 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -116,8 +116,8 @@ export class JobsList extends Component { onSelectionChange: this.props.selectJobChange, }; // Adding 'width' props to columns for use in the Kibana management jobs list table - // The version of the table used in ML > Job Managment depends on many EUI class overrides that set the width explicitly. - // The ML > Job Managment table won't change as the overwritten class styles take precedence, though these values may need to + // The version of the table used in ML > Job Management depends on many EUI class overrides that set the width explicitly. + // The ML > Job Management table won't change as the overwritten class styles take precedence, though these values may need to // be updated if we move to always using props for width. const columns = [ { @@ -299,7 +299,8 @@ export class JobsList extends Component { this.props.showEditJobFlyout, this.props.showDeleteJobModal, this.props.showStartDatafeedModal, - this.props.refreshJobs + this.props.refreshJobs, + this.props.showCreateAlertFlyout ), }); } @@ -371,6 +372,7 @@ JobsList.propTypes = { showEditJobFlyout: PropTypes.func, showDeleteJobModal: PropTypes.func, showStartDatafeedModal: PropTypes.func, + showCreateAlertFlyout: PropTypes.func, refreshJobs: PropTypes.func, selectedJobsCount: PropTypes.number.isRequired, loading: PropTypes.bool, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 352bd839ba1f48..ac7224b3f31642 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -28,7 +28,6 @@ import { JobFilterBar } from '../job_filter_bar'; import { EditJobFlyout } from '../edit_job_flyout'; import { DeleteJobModal } from '../delete_job_modal'; import { StartDatafeedModal } from '../start_datafeed_modal'; -import { CreateWatchFlyout } from '../create_watch_flyout'; import { MultiJobActions } from '../multi_job_actions'; import { NewJobButton } from '../new_job_button'; import { JobStatsBar } from '../jobs_stats_bar'; @@ -40,6 +39,7 @@ import { UpgradeWarning } from '../../../../components/upgrade'; import { RefreshJobsListButton } from '../refresh_jobs_list_button'; import { DELETING_JOBS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants/jobs_list'; +import { JobListMlAnomalyAlertFlyout } from '../../../../../alerting/ml_alerting_flyout'; let deletingJobsRefreshTimeout = null; @@ -66,7 +66,7 @@ export class JobsListView extends Component { this.showEditJobFlyout = () => {}; this.showDeleteJobModal = () => {}; this.showStartDatafeedModal = () => {}; - this.showCreateWatchFlyout = () => {}; + this.showCreateAlertFlyout = () => {}; // work around to keep track of whether the component is mounted // used to block timeouts for results polling // which can run after unmounting @@ -205,14 +205,14 @@ export class JobsListView extends Component { this.showStartDatafeedModal = () => {}; }; - setShowCreateWatchFlyoutFunction = (func) => { - this.showCreateWatchFlyout = func; + setShowCreateAlertFlyoutFunction = (func) => { + this.showCreateAlertFlyout = func; }; - unsetShowCreateWatchFlyoutFunction = () => { - this.showCreateWatchFlyout = () => {}; + unsetShowCreateAlertFlyoutFunction = () => { + this.showCreateAlertFlyout = () => {}; }; - getShowCreateWatchFlyoutFunction = () => { - return this.showCreateWatchFlyout; + getShowCreateAlertFlyoutFunction = () => { + return this.showCreateAlertFlyout; }; selectJobChange = (selectedJobs) => { @@ -477,6 +477,7 @@ export class JobsListView extends Component { allJobIds={jobIds} showStartDatafeedModal={this.showStartDatafeedModal} showDeleteJobModal={this.showDeleteJobModal} + showCreateAlertFlyout={this.showCreateAlertFlyout} refreshJobs={() => this.refreshJobSummaryList(true)} /> this.refreshJobSummaryList(true)} /> -
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js index 5760fbeb38642d..e1314eb7188362 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js @@ -27,6 +27,7 @@ class MultiJobActionsMenuUI extends Component { this.canDeleteJob = checkPermission('canDeleteJob'); this.canStartStopDatafeed = checkPermission('canStartStopDatafeed') && mlNodesAvailable(); this.canCloseJob = checkPermission('canCloseJob') && mlNodesAvailable(); + this.canCreateMlAlerts = checkPermission('canCreateMlAlerts'); } onButtonClick = () => { @@ -144,6 +145,26 @@ class MultiJobActionsMenuUI extends Component { ); } + if (this.canCreateMlAlerts) { + items.push( + { + this.props.showCreateAlertFlyout(this.props.jobs.map(({ id }) => id)); + this.closePopover(); + }} + data-test-subj="mlADJobListMultiSelectCreateAlertActionButton" + > + + + ); + } + return ( )} @@ -67,4 +68,5 @@ MultiJobActions.propTypes = { showStartDatafeedModal: PropTypes.func.isRequired, showDeleteJobModal: PropTypes.func.isRequired, refreshJobs: PropTypes.func.isRequired, + showCreateAlertFlyout: PropTypes.func.isRequired, }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js index 3ac6455bd745fc..5f5759e49208cb 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js @@ -39,8 +39,8 @@ export class StartDatafeedModal extends Component { isModalVisible: false, startTime: now, endTime: now, - createWatch: false, - allowCreateWatch: false, + createAlert: false, + allowCreateAlert: false, initialSpecifiedStartTime: now, now, timeRangeValid: true, @@ -48,7 +48,7 @@ export class StartDatafeedModal extends Component { this.initialSpecifiedStartTime = now; this.refreshJobs = this.props.refreshJobs; - this.getShowCreateWatchFlyoutFunction = this.props.getShowCreateWatchFlyoutFunction; + this.getShowCreateAlertFlyoutFunction = this.props.getShowCreateAlertFlyoutFunction; } componentDidMount() { @@ -71,8 +71,8 @@ export class StartDatafeedModal extends Component { this.setState({ endTime: time }); }; - setCreateWatch = (e) => { - this.setState({ createWatch: e.target.checked }); + setCreateAlert = (e) => { + this.setState({ createAlert: e.target.checked }); }; closeModal = () => { @@ -83,21 +83,21 @@ export class StartDatafeedModal extends Component { this.setState({ timeRangeValid }); }; - showModal = (jobs, showCreateWatchFlyout) => { + showModal = (jobs, showCreateAlertFlyout) => { const startTime = undefined; const now = moment(); const endTime = now; const initialSpecifiedStartTime = getLowestLatestTime(jobs); - const allowCreateWatch = jobs.length === 1; + const allowCreateAlert = jobs.length > 0; this.setState({ jobs, isModalVisible: true, startTime, endTime, initialSpecifiedStartTime, - showCreateWatchFlyout, - allowCreateWatch, - createWatch: false, + showCreateAlertFlyout, + allowCreateAlert, + createAlert: false, now, }); }; @@ -112,9 +112,8 @@ export class StartDatafeedModal extends Component { : this.state.endTime; forceStartDatafeeds(jobs, start, end, () => { - if (this.state.createWatch && jobs.length === 1) { - const jobId = jobs[0].id; - this.getShowCreateWatchFlyoutFunction()(jobId); + if (this.state.createAlert && jobs.length > 0) { + this.getShowCreateAlertFlyoutFunction()(jobs.map((job) => job.id)); } this.refreshJobs(); }); @@ -127,7 +126,7 @@ export class StartDatafeedModal extends Component { initialSpecifiedStartTime, startTime, endTime, - createWatch, + createAlert, now, timeRangeValid, } = this.state; @@ -172,15 +171,15 @@ export class StartDatafeedModal extends Component {
} - checked={createWatch} - onChange={this.setCreateWatch} + checked={createAlert} + onChange={this.setCreateAlert} />
)} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx index a39ffd171d1ca0..6cefc239905c78 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx @@ -13,38 +13,21 @@ import { JobRunner } from '../../../../../common/job_runner'; import { useMlKibana } from '../../../../../../../contexts/kibana'; import { extractErrorMessage } from '../../../../../../../../../common/util/errors'; -// @ts-ignore -import { CreateWatchFlyout } from '../../../../../../jobs_list/components/create_watch_flyout/index'; import { JobCreatorContext } from '../../../job_creator_context'; import { DATAFEED_STATE } from '../../../../../../../../../common/constants/states'; +import { MlAnomalyAlertFlyout } from '../../../../../../../../alerting/ml_alerting_flyout'; interface Props { jobRunner: JobRunner | null; } -type ShowFlyout = (jobId: string) => void; - export const PostSaveOptions: FC = ({ jobRunner }) => { const { services: { notifications }, } = useMlKibana(); const { jobCreator } = useContext(JobCreatorContext); const [datafeedState, setDatafeedState] = useState(DATAFEED_STATE.STOPPED); - const [watchFlyoutVisible, setWatchFlyoutVisible] = useState(false); - const [watchCreated, setWatchCreated] = useState(false); - - function setShowCreateWatchFlyoutFunction(showFlyout: ShowFlyout) { - showFlyout(jobCreator.jobId); - } - - function flyoutHidden(jobCreated: boolean) { - setWatchFlyoutVisible(false); - setWatchCreated(jobCreated); - } - - function unsetShowCreateWatchFlyoutFunction() { - setWatchFlyoutVisible(false); - } + const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false); async function startJobInRealTime() { const { toasts } = notifications; @@ -93,28 +76,26 @@ export const PostSaveOptions: FC = ({ jobRunner }) => { />
+ setWatchFlyoutVisible(true)} - data-test-subj="mlJobWizardButtonCreateWatch" + onClick={setAlertFlyoutVisible.bind(null, true)} + data-test-subj="mlJobWizardButtonCreateAlert" > - {datafeedState === DATAFEED_STATE.STARTED && watchFlyoutVisible && ( - )} diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index b4eb5a6d702b74..212d6fe13a6b4b 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -62,7 +62,7 @@ export interface MlStartDependencies { embeddable: EmbeddableStart; maps?: MapsStartApi; lens?: LensPublicStart; - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; } export interface MlSetupDependencies { @@ -76,7 +76,7 @@ export interface MlSetupDependencies { kibanaVersion: string; share: SharePluginSetup; indexPatternManagement: IndexPatternManagementSetup; - triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi?: TriggersAndActionsUIPublicPluginSetup; } export type MlCoreSetup = CoreSetup; @@ -129,6 +129,10 @@ export class MlPlugin implements Plugin { this.urlGenerator = registerUrlGenerator(pluginsSetup.share, core); } + if (pluginsSetup.triggersActionsUi) { + registerMlAlerts(pluginsSetup.triggersActionsUi); + } + const licensing = pluginsSetup.licensing.license$.pipe(take(1)); licensing.subscribe(async (license) => { const [coreStart] = await core.getStartServices(); @@ -190,7 +194,7 @@ export class MlPlugin implements Plugin { http: core.http, i18n: core.i18n, }); - registerMlAlerts(deps.triggersActionsUi.alertTypeRegistry); + return { urlGenerator: this.urlGenerator, }; diff --git a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts index 3b83e6d005077b..5ef883cc50fbb3 100644 --- a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts +++ b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts @@ -309,6 +309,13 @@ export function alertingServiceProvider(mlClient: MlClient) { result_type: Object.values(ANOMALY_RESULT_TYPE), }, }, + ...(params.includeInterim + ? [] + : [ + { + term: { is_interim: false }, + }, + ]), ], }, }, diff --git a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts index 8bfa825baacd93..49a63d2796969e 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts @@ -51,7 +51,7 @@ describe('check_capabilities', () => { ); const { capabilities } = await getCapabilities(); const count = Object.keys(capabilities).length; - expect(count).toBe(28); + expect(count).toBe(29); }); }); @@ -102,6 +102,7 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteDataFrameAnalytics).toBe(false); expect(capabilities.canCreateDataFrameAnalytics).toBe(false); expect(capabilities.canStartStopDataFrameAnalytics).toBe(false); + expect(capabilities.canCreateMlAlerts).toBe(false); }); test('full capabilities', async () => { diff --git a/x-pack/plugins/ml/server/routes/alerting.ts b/x-pack/plugins/ml/server/routes/alerting.ts index b7a1be2434e8bc..7b7f3a7db9723a 100644 --- a/x-pack/plugins/ml/server/routes/alerting.ts +++ b/x-pack/plugins/ml/server/routes/alerting.ts @@ -17,6 +17,8 @@ export function alertingRoutes({ router, routeGuard }: RouteInitialization) { * @api {post} /api/ml/alerting/preview Preview alerting condition * @apiName PreviewAlert * @apiDescription Returns a preview of the alerting condition + * + * @apiSchema (body) mlAnomalyDetectionAlertPreviewRequest */ router.post( { diff --git a/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts b/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts index 636185808f9a5b..9e13b7ed81a15c 100644 --- a/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts @@ -27,6 +27,7 @@ export const mlAnomalyDetectionAlertParams = schema.object({ ), severity: schema.number(), resultType: schema.string(), + includeInterim: schema.boolean({ defaultValue: true }), }); export const mlAnomalyDetectionAlertPreviewRequest = schema.object({ diff --git a/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts b/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts index e966e3fb714ad9..54c2beaa06b09b 100644 --- a/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts +++ b/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts @@ -37,6 +37,7 @@ export const emptyMlCapabilities: MlCapabilitiesResponse = { canDeleteDataFrameAnalytics: false, canCreateDataFrameAnalytics: false, canStartStopDataFrameAnalytics: false, + canCreateMlAlerts: false, }, isPlatinumOrTrialLicense: false, mlFeatureEnabledInSpace: false, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e24db7d2cf9c32..e65bf0a8c83c60 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13288,12 +13288,6 @@ "xpack.ml.jobsList.closeJobErrorMessage": "ジョブをクローズできませんでした", "xpack.ml.jobsList.collapseJobDetailsAriaLabel": "{itemId} の詳細を非表示", "xpack.ml.jobsList.createNewJobButtonLabel": "ジョブを作成", - "xpack.ml.jobsList.createWatchFlyout.closeButtonLabel": "閉じる", - "xpack.ml.jobsList.createWatchFlyout.editWatchButtonLabel": "ウォッチを編集", - "xpack.ml.jobsList.createWatchFlyout.pageTitle": "{jobId} のウォッチを作成", - "xpack.ml.jobsList.createWatchFlyout.saveButtonLabel": "保存", - "xpack.ml.jobsList.createWatchFlyout.watchCreatedSuccessfullyNotificationMessage": "ウォッチ {id} が作成されました", - "xpack.ml.jobsList.createWatchFlyout.watchNotSavedErrorNotificationMessage": "ウォッチを保存できませんでした", "xpack.ml.jobsList.datafeedStateLabel": "データフィード状態", "xpack.ml.jobsList.deleteActionStatusText": "削除", "xpack.ml.jobsList.deletedActionStatusText": "削除されました", @@ -13443,7 +13437,6 @@ "xpack.ml.jobsList.startDatafeedModal.continueFromNowLabel": "今から続行", "xpack.ml.jobsList.startDatafeedModal.continueFromSpecifiedTimeLabel": "特定の時刻から続行", "xpack.ml.jobsList.startDatafeedModal.continueFromStartTimeLabel": "{formattedLatestStartTime} から続行", - "xpack.ml.jobsList.startDatafeedModal.createWatchDescription": "データフィードの開始後ウォッチを作成します", "xpack.ml.jobsList.startDatafeedModal.enterDateText\"": "日付を入力", "xpack.ml.jobsList.startDatafeedModal.noEndTimeLabel": "終了時刻が指定されていません (リアルタイム検索)", "xpack.ml.jobsList.startDatafeedModal.searchEndTimeTitle": "検索終了時刻", @@ -13657,23 +13650,7 @@ "xpack.ml.newJob.recognize.viewResultsAriaLabel": "結果を表示", "xpack.ml.newJob.recognize.viewResultsLinkText": "結果を表示", "xpack.ml.newJob.recognize.visualizationsLabel": "ビジュアライゼーション", - "xpack.ml.newJob.simple.createWatchView.emailAddressPlaceholder": "メールアドレス", - "xpack.ml.newJob.simple.createWatchView.nowLabel": "今 - {selectInterval}", - "xpack.ml.newJob.simple.createWatchView.sendEmailLabel": "メールを送信", - "xpack.ml.newJob.simple.createWatchView.severityThresholdLabel": "深刻度のしきい値", - "xpack.ml.newJob.simple.createWatchView.successLabel": "成功", - "xpack.ml.newJob.simple.createWatchView.timeRangeLabel": "時間範囲", - "xpack.ml.newJob.simple.createWatchView.watchAlreadyExistsWarningMessage": "警告、ウォッチ mi-{jobId} は既に存在します。適用をクリックするとオリジナルが上書きされます。", - "xpack.ml.newJob.simple.createWatchView.watchEmailAddressAriaLabel": "ウォッチのメールアドレス", "xpack.ml.newJob.simple.recognize.jobsCreationFailedTitle": "ジョブの作成に失敗", - "xpack.ml.newJob.simple.watcher.email.anomalyScoreLabel": "異常スコア", - "xpack.ml.newJob.simple.watcher.email.elasticStackMachineLearningAlertLabel": "Elastic Stack 機械学習アラート", - "xpack.ml.newJob.simple.watcher.email.jobLabel": "ジョブ名", - "xpack.ml.newJob.simple.watcher.email.mlWatcherAlertSubjectTitle": "ML Watcher アラート", - "xpack.ml.newJob.simple.watcher.email.openInAnomalyExplorerLinkText": "異常エクスプローラーを開くにはここをクリックしてください。", - "xpack.ml.newJob.simple.watcher.email.timeLabel": "時間", - "xpack.ml.newJob.simple.watcher.email.topInfluencersLabel": "トップ影響因子:", - "xpack.ml.newJob.simple.watcher.email.topRecordsLabel": "トップの記録:", "xpack.ml.newJob.wizard.autoSetJobCreatorTimeRange.error": "インデックスの開始時刻と終了時刻の取得中にエラーが発生しました", "xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.closeButton": "閉じる", "xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.saveButton": "保存", @@ -13904,7 +13881,6 @@ "xpack.ml.newJob.wizard.summaryStep.jobDetails.splitField.title": "フィールドの分割", "xpack.ml.newJob.wizard.summaryStep.jobDetails.summaryCountField.title": "サマリーカウントフィールド", "xpack.ml.newJob.wizard.summaryStep.jobDetails.useDedicatedIndex.title": "専用インデックスを使用", - "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.createWatch": "ウォッチを作成", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTime": "リアルタイムで実行中のジョブを開始", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTimeError": "ジョブの開始エラー", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTimeSuccess": "ジョブ {jobId} が開始しました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5d583971552b5d..cdc394a237e3ec 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13320,12 +13320,6 @@ "xpack.ml.jobsList.closeJobErrorMessage": "作业无法关闭", "xpack.ml.jobsList.collapseJobDetailsAriaLabel": "隐藏 {itemId} 的详情", "xpack.ml.jobsList.createNewJobButtonLabel": "创建作业", - "xpack.ml.jobsList.createWatchFlyout.closeButtonLabel": "关闭", - "xpack.ml.jobsList.createWatchFlyout.editWatchButtonLabel": "编辑监视", - "xpack.ml.jobsList.createWatchFlyout.pageTitle": "创建 {jobId} 的监视", - "xpack.ml.jobsList.createWatchFlyout.saveButtonLabel": "保存", - "xpack.ml.jobsList.createWatchFlyout.watchCreatedSuccessfullyNotificationMessage": "监视 {id} 已成功创建", - "xpack.ml.jobsList.createWatchFlyout.watchNotSavedErrorNotificationMessage": "无法保存监视", "xpack.ml.jobsList.datafeedStateLabel": "数据馈送状态", "xpack.ml.jobsList.deleteActionStatusText": "删除", "xpack.ml.jobsList.deletedActionStatusText": "已删除", @@ -13475,7 +13469,6 @@ "xpack.ml.jobsList.startDatafeedModal.continueFromNowLabel": "从当前继续", "xpack.ml.jobsList.startDatafeedModal.continueFromSpecifiedTimeLabel": "从指定时间继续", "xpack.ml.jobsList.startDatafeedModal.continueFromStartTimeLabel": "从 {formattedLatestStartTime} 继续", - "xpack.ml.jobsList.startDatafeedModal.createWatchDescription": "在数据馈送开始后创建监视", "xpack.ml.jobsList.startDatafeedModal.enterDateText\"": "输入日期", "xpack.ml.jobsList.startDatafeedModal.noEndTimeLabel": "无结束时间(实时搜索)", "xpack.ml.jobsList.startDatafeedModal.searchEndTimeTitle": "搜索结束时间", @@ -13694,23 +13687,7 @@ "xpack.ml.newJob.recognize.viewResultsAriaLabel": "查看结果", "xpack.ml.newJob.recognize.viewResultsLinkText": "查看结果", "xpack.ml.newJob.recognize.visualizationsLabel": "可视化", - "xpack.ml.newJob.simple.createWatchView.emailAddressPlaceholder": "电子邮件地址", - "xpack.ml.newJob.simple.createWatchView.nowLabel": "立即 - {selectInterval}", - "xpack.ml.newJob.simple.createWatchView.sendEmailLabel": "发送电子邮件", - "xpack.ml.newJob.simple.createWatchView.severityThresholdLabel": "严重性阈值", - "xpack.ml.newJob.simple.createWatchView.successLabel": "成功", - "xpack.ml.newJob.simple.createWatchView.timeRangeLabel": "时间范围", - "xpack.ml.newJob.simple.createWatchView.watchAlreadyExistsWarningMessage": "警告,监视 ml-{jobId} 已存在,点击“应用”将覆盖原始监视。", - "xpack.ml.newJob.simple.createWatchView.watchEmailAddressAriaLabel": "监视电子邮件地址", "xpack.ml.newJob.simple.recognize.jobsCreationFailedTitle": "作业创建失败", - "xpack.ml.newJob.simple.watcher.email.anomalyScoreLabel": "异常分数", - "xpack.ml.newJob.simple.watcher.email.elasticStackMachineLearningAlertLabel": "Elastic Stack Machine Learning 告警", - "xpack.ml.newJob.simple.watcher.email.jobLabel": "作业", - "xpack.ml.newJob.simple.watcher.email.mlWatcherAlertSubjectTitle": "ML Watcher 告警", - "xpack.ml.newJob.simple.watcher.email.openInAnomalyExplorerLinkText": "单击此处在 Anomaly Explorer 中打开。", - "xpack.ml.newJob.simple.watcher.email.timeLabel": "时间", - "xpack.ml.newJob.simple.watcher.email.topInfluencersLabel": "排在前面的影响因素:", - "xpack.ml.newJob.simple.watcher.email.topRecordsLabel": "排在前面的记录:", "xpack.ml.newJob.wizard.autoSetJobCreatorTimeRange.error": "检索索引的开始和结束时间", "xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.closeButton": "关闭", "xpack.ml.newJob.wizard.categorizationAnalyzerFlyout.saveButton": "保存", @@ -13941,7 +13918,6 @@ "xpack.ml.newJob.wizard.summaryStep.jobDetails.splitField.title": "分割字段", "xpack.ml.newJob.wizard.summaryStep.jobDetails.summaryCountField.title": "汇总计数字段", "xpack.ml.newJob.wizard.summaryStep.jobDetails.useDedicatedIndex.title": "使用专用索引", - "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.createWatch": "创建监视", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTime": "启动实时运行的作业", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTimeError": "启动作业时出错", "xpack.ml.newJob.wizard.summaryStep.postSaveOptions.startJobInRealTimeSuccess": "作业 {jobId} 已启动", From 7fab0e63bc35348268386f37805b78b8b629a614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Mon, 15 Feb 2021 22:46:38 +0100 Subject: [PATCH 054/175] [Logs UI] Replace custom `useInterval` with `react-use` version (#90966) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../infra/public/hooks/use_interval.ts | 27 ------------------- .../page_results_content.tsx | 2 +- .../logs/log_entry_rate/page_content.tsx | 2 +- .../use_log_entry_rate_results_url_state.tsx | 2 +- 4 files changed, 3 insertions(+), 30 deletions(-) delete mode 100644 x-pack/plugins/infra/public/hooks/use_interval.ts diff --git a/x-pack/plugins/infra/public/hooks/use_interval.ts b/x-pack/plugins/infra/public/hooks/use_interval.ts deleted file mode 100644 index e2f33c9458e9a5..00000000000000 --- a/x-pack/plugins/infra/public/hooks/use_interval.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect, useRef } from 'react'; - -export function useInterval(callback: () => void, delay: number | null) { - const savedCallback = useRef(callback); - - useEffect(() => { - savedCallback.current = callback; - }, [callback]); - - useEffect(() => { - function tick() { - savedCallback.current(); - } - - if (delay !== null) { - const id = setInterval(tick, delay); - return () => clearInterval(id); - } - }, [delay]); -} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index 616e3ed3f11f53..1206e5c3654412 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -10,6 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import useInterval from 'react-use/lib/useInterval'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useTrackPageview } from '../../../../../observability/public'; @@ -17,7 +18,6 @@ import { TimeRange } from '../../../../common/time/time_range'; import { CategoryJobNoticesSection } from '../../../components/logging/log_analysis_job_status'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; -import { useInterval } from '../../../hooks/use_interval'; import { PageViewLogInContext } from '../stream/page_view_log_in_context'; import { TopCategoriesSection } from './sections/top_categories'; import { useLogEntryCategoriesResults } from './use_log_entry_categories_results'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx index 5fd00527b8b704..114f8ff9db3b36 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import React, { memo, useEffect, useCallback } from 'react'; +import useInterval from 'react-use/lib/useInterval'; import { SubscriptionSplashContent } from '../../../components/subscription_splash_content'; import { isJobStatusWithResults } from '../../../../common/log_analysis'; import { LoadingPage } from '../../../components/loading_page'; @@ -27,7 +28,6 @@ import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analy import { useLogSourceContext } from '../../../containers/logs/log_source'; import { LogEntryRateResultsContent } from './page_results_content'; import { LogEntryRateSetupContent } from './page_setup_content'; -import { useInterval } from '../../../hooks/use_interval'; const JOB_STATUS_POLLING_INTERVAL = 30000; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results_url_state.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results_url_state.tsx index ccfae14fd4a598..a845e59ce6d32b 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results_url_state.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results_url_state.tsx @@ -6,13 +6,13 @@ */ import { useCallback, useMemo, useState } from 'react'; +import useInterval from 'react-use/lib/useInterval'; import datemath from '@elastic/datemath'; import moment from 'moment'; import * as rt from 'io-ts'; import { TimeRange as KibanaTimeRange } from '../../../../../../../src/plugins/data/public'; import { TimeRange } from '../../../../common/time/time_range'; import { useUrlState } from '../../../utils/use_url_state'; -import { useInterval } from '../../../hooks/use_interval'; import { useKibanaTimefilterTime, useSyncKibanaTimeFilterTime, From 52cd0d94d5044a38d43fedccd345164773f35bf6 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 16 Feb 2021 09:12:56 +0200 Subject: [PATCH 055/175] [TSVB] Fixes chart scroll when legend has many items (#91394) * [TSVB] Fixes chart scroll when legend has many items * Fix functional test * Follow another approach to work well with FF --- .../public/application/components/vis_types/_vis_types.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss b/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss index 9c07721fa00b36..198f0f42d503c7 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: column; flex: 1 1 100%; + overflow: auto; .tvbVisTimeSeries { overflow: hidden; From 22bb8d39f05016dcecaf4fb4bc9bbdf493f398d4 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 16 Feb 2021 11:27:33 +0100 Subject: [PATCH 056/175] switch to es archiver fixtures instead of sample data (#91397) --- x-pack/test/accessibility/apps/lens.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/x-pack/test/accessibility/apps/lens.ts b/x-pack/test/accessibility/apps/lens.ts index 2f5ebe3c1a2dc6..dcde0b10b7a052 100644 --- a/x-pack/test/accessibility/apps/lens.ts +++ b/x-pack/test/accessibility/apps/lens.ts @@ -11,15 +11,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'visualize', 'timePicker', 'home', 'lens']); const a11y = getService('a11y'); const testSubjects = getService('testSubjects'); + const esArchiver = getService('esArchiver'); const listingTable = getService('listingTable'); describe('Lens', () => { const lensChartName = 'MyLensChart'; before(async () => { - await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { - useActualUrl: true, - }); - await PageObjects.home.addSampleDataSet('flights'); + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('lens/basic'); }); after(async () => { @@ -28,6 +27,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await listingTable.checkListingSelectAllCheckbox(); await listingTable.clickDeleteSelected(); await PageObjects.common.clickConfirmOnModal(); + await esArchiver.unload('logstash_functional'); + await esArchiver.unload('lens/basic'); }); it('lens', async () => { @@ -41,17 +42,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); await PageObjects.timePicker.ensureHiddenNoDataPopover(); + await PageObjects.lens.goToTimeRange(); await PageObjects.lens.configureDimension({ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', operation: 'terms', - field: 'DestCityName', + field: 'ip', }); await PageObjects.lens.configureDimension({ dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', operation: 'avg', - field: 'AvgTicketPrice', + field: 'bytes', }); await a11y.testAppSnapshot(); @@ -76,6 +78,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); await PageObjects.timePicker.ensureHiddenNoDataPopover(); + await PageObjects.lens.goToTimeRange(); await PageObjects.lens.openDimensionEditor('lnsXY_xDimensionPanel > lns-empty-dimension'); await a11y.testAppSnapshot(); @@ -96,13 +99,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.configureDimension({ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', operation: 'date_histogram', - field: 'timestamp', + field: '@timestamp', }); await PageObjects.lens.configureDimension({ dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', operation: 'avg', - field: 'AvgTicketPrice', + field: 'bytes', }); await testSubjects.click('lnsSuggestion-barChart > lnsSuggestion'); @@ -118,7 +121,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', operation: 'terms', - field: 'DestCityName', + field: 'ip', }, 1 ); @@ -127,7 +130,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', operation: 'median', - field: 'FlightTimeMin', + field: 'bytes', }, 1 ); From f06e722a9986434e0ff4eeba5a4d107998a14361 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Tue, 16 Feb 2021 03:28:13 -0700 Subject: [PATCH 057/175] [Search Sessions] Search session UI telemetry (#89950) Co-authored-by: Liza K Co-authored-by: Anton Dosov --- ...n-plugins-data-public.searchinterceptor.md | 2 +- ...ns-data-public.searchinterceptor.search.md | 2 +- src/plugins/data/public/index.ts | 1 + src/plugins/data/public/public.api.md | 66 ++++++++++++---- .../collectors/create_usage_collector.test.ts | 62 ++++++++++++++- .../collectors/create_usage_collector.ts | 59 ++++++++++---- .../data/public/search/collectors/mocks.ts | 28 +++++++ .../data/public/search/collectors/types.ts | 76 ++++++++++++++++++- src/plugins/data/public/search/index.ts | 8 +- src/plugins/data/public/search/mocks.ts | 2 + .../data/public/search/search_interceptor.ts | 7 +- src/plugins/data/public/search/types.ts | 2 +- x-pack/plugins/data_enhanced/public/plugin.ts | 12 ++- .../public/search/search_interceptor.test.ts | 44 +---------- .../public/search/search_interceptor.ts | 9 --- .../sessions_mgmt/application/index.tsx | 1 + .../sessions_mgmt/components/main.test.tsx | 10 ++- .../search/sessions_mgmt/components/main.tsx | 2 + .../components/table/table.test.tsx | 13 +++- .../sessions_mgmt/components/table/table.tsx | 10 ++- .../public/search/sessions_mgmt/index.ts | 3 +- .../public/search/sessions_mgmt/lib/api.ts | 13 +++- .../sessions_mgmt/lib/get_columns.test.tsx | 47 +++++++++--- .../search/sessions_mgmt/lib/get_columns.tsx | 11 ++- ...onnected_search_session_indicator.test.tsx | 30 ++++++++ .../connected_search_session_indicator.tsx | 31 ++++++-- .../search_session_tour.tsx | 34 ++++++++- .../search_session_indicator.tsx | 4 + 28 files changed, 463 insertions(+), 126 deletions(-) create mode 100644 src/plugins/data/public/search/collectors/mocks.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md index 5f266e7d8bd8c1..2247813562dc72 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -28,6 +28,6 @@ export declare class SearchInterceptor | --- | --- | --- | | [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | | | [handleSearchError(e, timeoutSignal, options)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | -| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when cancelPending is called, when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | +| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | | [showError(e)](./kibana-plugin-plugins-data-public.searchinterceptor.showerror.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md index 61f8eeb973f4c0..a54b43da4add8b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md @@ -4,7 +4,7 @@ ## SearchInterceptor.search() method -Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when `cancelPending` is called, when the request times out, or when the original `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. +Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when the request times out, or when the original `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. Signature: diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 83a248ee2c3dee..df799ede08a310 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -389,6 +389,7 @@ export type { ISessionService, SearchSessionInfoProvider, ISessionsClient, + SearchUsageCollector, } from './search'; export { ISearchOptions, isErrorResponse, isCompleteResponse, isPartialResponse } from '../common'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 4668ce2208610d..0920d0d716d73a 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1661,7 +1661,7 @@ export interface ISearchSetup { aggs: AggsSetup; session: ISessionService; sessionsClient: ISessionsClient; - // Warning: (ae-forgotten-export) The symbol "SearchUsageCollector" needs to be exported by the entry point index.d.ts + // Warning: (ae-incompatible-release-tags) The symbol "usageCollector" is marked as @public, but its signature references "SearchUsageCollector" which is marked as @internal // // (undocumented) usageCollector?: SearchUsageCollector; @@ -2329,6 +2329,8 @@ export interface SearchInterceptorDeps { toasts: ToastsSetup; // (undocumented) uiSettings: CoreSetup_2['uiSettings']; + // Warning: (ae-incompatible-release-tags) The symbol "usageCollector" is marked as @public, but its signature references "SearchUsageCollector" which is marked as @internal + // // (undocumented) usageCollector?: SearchUsageCollector; } @@ -2453,6 +2455,38 @@ export class SearchTimeoutError extends KbnError { mode: TimeoutErrorMode; } +// @internal (undocumented) +export interface SearchUsageCollector { + // (undocumented) + trackQueryTimedOut: () => Promise; + // (undocumented) + trackSessionCancelled: () => Promise; + // (undocumented) + trackSessionDeleted: () => Promise; + // (undocumented) + trackSessionExtended: () => Promise; + // (undocumented) + trackSessionIndicatorSaveDisabled: () => Promise; + // (undocumented) + trackSessionIndicatorTourLoading: () => Promise; + // (undocumented) + trackSessionIndicatorTourRestored: () => Promise; + // (undocumented) + trackSessionIsRestored: () => Promise; + // (undocumented) + trackSessionReloaded: () => Promise; + // (undocumented) + trackSessionSavedResults: () => Promise; + // (undocumented) + trackSessionSentToBackground: () => Promise; + // (undocumented) + trackSessionsListLoaded: () => Promise; + // (undocumented) + trackSessionViewRestored: () => Promise; + // (undocumented) + trackViewSessionsList: () => Promise; +} + // Warning: (ae-missing-release-tag) "SortDirection" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2607,21 +2641,21 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:413:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:425:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/search/session/session_service.ts:42:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/collectors/create_usage_collector.test.ts b/src/plugins/data/public/search/collectors/create_usage_collector.test.ts index df9903a4683e11..145bb191fde11b 100644 --- a/src/plugins/data/public/search/collectors/create_usage_collector.test.ts +++ b/src/plugins/data/public/search/collectors/create_usage_collector.test.ts @@ -45,12 +45,66 @@ describe('Search Usage Collector', () => { ); }); - test('tracks query cancellation', async () => { - await usageCollector.trackQueriesCancelled(); + test('tracks session sent to background', async () => { + await usageCollector.trackSessionSentToBackground(); expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); - expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.LOADED); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_SENT_TO_BACKGROUND + ); + }); + + test('tracks session saved results', async () => { + await usageCollector.trackSessionSavedResults(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_SAVED_RESULTS + ); + }); + + test('tracks session view restored', async () => { + await usageCollector.trackSessionViewRestored(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_VIEW_RESTORED + ); + }); + + test('tracks session is restored', async () => { + await usageCollector.trackSessionIsRestored(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_IS_RESTORED + ); + }); + + test('tracks session reloaded', async () => { + await usageCollector.trackSessionReloaded(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_RELOADED + ); + }); + + test('tracks session extended', async () => { + await usageCollector.trackSessionExtended(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_EXTENDED + ); + }); + + test('tracks session cancelled', async () => { + await usageCollector.trackSessionCancelled(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( - SEARCH_EVENT_TYPE.QUERIES_CANCELLED + SEARCH_EVENT_TYPE.SESSION_CANCELLED ); }); }); diff --git a/src/plugins/data/public/search/collectors/create_usage_collector.ts b/src/plugins/data/public/search/collectors/create_usage_collector.ts index e9a192a2710c46..3fe135ea29152a 100644 --- a/src/plugins/data/public/search/collectors/create_usage_collector.ts +++ b/src/plugins/data/public/search/collectors/create_usage_collector.ts @@ -7,6 +7,7 @@ */ import { first } from 'rxjs/operators'; +import { UiCounterMetricType } from '@kbn/analytics'; import { StartServicesAccessor } from '../../../../../core/public'; import { METRIC_TYPE, UsageCollectionSetup } from '../../../../usage_collection/public'; import { SEARCH_EVENT_TYPE, SearchUsageCollector } from './types'; @@ -20,22 +21,48 @@ export const createUsageCollector = ( return application.currentAppId$.pipe(first()).toPromise(); }; - return { - trackQueryTimedOut: async () => { - const currentApp = await getCurrentApp(); - return usageCollection?.reportUiCounter( - currentApp!, - METRIC_TYPE.LOADED, - SEARCH_EVENT_TYPE.QUERY_TIMED_OUT - ); - }, - trackQueriesCancelled: async () => { + const getCollector = (metricType: UiCounterMetricType, eventType: SEARCH_EVENT_TYPE) => { + return async () => { const currentApp = await getCurrentApp(); - return usageCollection?.reportUiCounter( - currentApp!, - METRIC_TYPE.LOADED, - SEARCH_EVENT_TYPE.QUERIES_CANCELLED - ); - }, + return usageCollection?.reportUiCounter(currentApp!, metricType, eventType); + }; + }; + + return { + trackQueryTimedOut: getCollector(METRIC_TYPE.LOADED, SEARCH_EVENT_TYPE.QUERY_TIMED_OUT), + trackSessionIndicatorTourLoading: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_TOUR_LOADING + ), + trackSessionIndicatorTourRestored: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_TOUR_RESTORED + ), + trackSessionIndicatorSaveDisabled: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_SAVE_DISABLED + ), + trackSessionSentToBackground: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_SENT_TO_BACKGROUND + ), + trackSessionSavedResults: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_SAVED_RESULTS + ), + trackSessionViewRestored: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_VIEW_RESTORED + ), + trackSessionIsRestored: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_IS_RESTORED), + trackSessionReloaded: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_RELOADED), + trackSessionExtended: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_EXTENDED), + trackSessionCancelled: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_CANCELLED), + trackSessionDeleted: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_DELETED), + trackViewSessionsList: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_VIEW_LIST), + trackSessionsListLoaded: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSIONS_LIST_LOADED + ), }; }; diff --git a/src/plugins/data/public/search/collectors/mocks.ts b/src/plugins/data/public/search/collectors/mocks.ts new file mode 100644 index 00000000000000..2a546d6310d7f7 --- /dev/null +++ b/src/plugins/data/public/search/collectors/mocks.ts @@ -0,0 +1,28 @@ +/* + * 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 { SearchUsageCollector } from './types'; + +export function createSearchUsageCollectorMock(): jest.Mocked { + return { + trackQueryTimedOut: jest.fn(), + trackSessionIndicatorTourLoading: jest.fn(), + trackSessionIndicatorTourRestored: jest.fn(), + trackSessionIndicatorSaveDisabled: jest.fn(), + trackSessionSentToBackground: jest.fn(), + trackSessionSavedResults: jest.fn(), + trackSessionViewRestored: jest.fn(), + trackSessionIsRestored: jest.fn(), + trackSessionReloaded: jest.fn(), + trackSessionExtended: jest.fn(), + trackSessionCancelled: jest.fn(), + trackSessionDeleted: jest.fn(), + trackViewSessionsList: jest.fn(), + trackSessionsListLoaded: jest.fn(), + }; +} diff --git a/src/plugins/data/public/search/collectors/types.ts b/src/plugins/data/public/search/collectors/types.ts index 9668b4dcbefa2d..49c240d1ccb16a 100644 --- a/src/plugins/data/public/search/collectors/types.ts +++ b/src/plugins/data/public/search/collectors/types.ts @@ -6,12 +6,84 @@ * Side Public License, v 1. */ +/** + * @internal + */ export enum SEARCH_EVENT_TYPE { + /** + * A search reached the timeout configured in UI setting search:timeout + */ QUERY_TIMED_OUT = 'queryTimedOut', - QUERIES_CANCELLED = 'queriesCancelled', + /** + * The session indicator was automatically brought up because of a long running query + */ + SESSION_INDICATOR_TOUR_LOADING = 'sessionIndicatorTourLoading', + /** + * The session indicator was automatically brought up because of a restored session + */ + SESSION_INDICATOR_TOUR_RESTORED = 'sessionIndicatorTourRestored', + /** + * The session indicator was disabled because of a completion timeout + */ + SESSION_INDICATOR_SAVE_DISABLED = 'sessionIndicatorSaveDisabled', + /** + * The user clicked to continue a session in the background (prior to results completing) + */ + SESSION_SENT_TO_BACKGROUND = 'sessionSentToBackground', + /** + * The user clicked to save the session (after results completing) + */ + SESSION_SAVED_RESULTS = 'sessionSavedResults', + /** + * The user clicked to view a completed session + */ + SESSION_VIEW_RESTORED = 'sessionViewRestored', + /** + * The session was successfully restored upon a user navigating + */ + SESSION_IS_RESTORED = 'sessionIsRestored', + /** + * The user clicked to reload an expired/cancelled session + */ + SESSION_RELOADED = 'sessionReloaded', + /** + * The user clicked to extend the expiration of a session + */ + SESSION_EXTENDED = 'sessionExtended', + /** + * The user clicked to cancel a session + */ + SESSION_CANCELLED = 'sessionCancelled', + /** + * The user clicked to delete a session + */ + SESSION_DELETED = 'sessionDeleted', + /** + * The user clicked a link to view the list of sessions + */ + SESSION_VIEW_LIST = 'sessionViewList', + /** + * The user landed on the sessions management page + */ + SESSIONS_LIST_LOADED = 'sessionsListLoaded', } +/** + * @internal + */ export interface SearchUsageCollector { trackQueryTimedOut: () => Promise; - trackQueriesCancelled: () => Promise; + trackSessionIndicatorTourLoading: () => Promise; + trackSessionIndicatorTourRestored: () => Promise; + trackSessionIndicatorSaveDisabled: () => Promise; + trackSessionSentToBackground: () => Promise; + trackSessionSavedResults: () => Promise; + trackSessionViewRestored: () => Promise; + trackSessionIsRestored: () => Promise; + trackSessionReloaded: () => Promise; + trackSessionExtended: () => Promise; + trackSessionCancelled: () => Promise; + trackSessionDeleted: () => Promise; + trackViewSessionsList: () => Promise; + trackSessionsListLoaded: () => Promise; } diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index b1e0bc490823a3..fded4c46992c04 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -8,7 +8,13 @@ export * from './expressions'; -export { ISearchSetup, ISearchStart, ISearchStartSearchSource, SearchEnhancements } from './types'; +export { + ISearchSetup, + ISearchStart, + ISearchStartSearchSource, + SearchEnhancements, + SearchUsageCollector, +} from './types'; export { ES_SEARCH_STRATEGY, diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index b16468120d95ab..273bbfe9e7b081 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -10,6 +10,7 @@ import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks'; import { searchSourceMock } from './search_source/mocks'; import { ISearchSetup, ISearchStart } from './types'; import { getSessionsClientMock, getSessionServiceMock } from './session/mocks'; +import { createSearchUsageCollectorMock } from './collectors/mocks'; function createSetupContract(): jest.Mocked { return { @@ -17,6 +18,7 @@ function createSetupContract(): jest.Mocked { __enhance: jest.fn(), session: getSessionServiceMock(), sessionsClient: getSessionsClientMock(), + usageCollector: createSearchUsageCollectorMock(), }; } diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index f33740cc45bf98..f46a3d258f9486 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -155,13 +155,14 @@ export class SearchInterceptor { const { signal: timeoutSignal } = timeoutController; const timeout$ = timeout ? timer(timeout) : NEVER; const subscription = timeout$.subscribe(() => { + this.deps.usageCollector?.trackQueryTimedOut(); timeoutController.abort(); }); const selfAbortController = new AbortController(); // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: - // 1. The user manually aborts (via `cancelPending`) + // 1. The internal abort controller aborts // 2. The request times out // 3. abort() is called on `selfAbortController`. This is used by session service to abort all pending searches that it tracks // in the current session @@ -221,8 +222,8 @@ export class SearchInterceptor { /** * Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort - * either when `cancelPending` is called, when the request times out, or when the original - * `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. + * either when the request times out, or when the original `AbortSignal` is aborted. Updates + * `pendingCount$` when the request is started/finalized. * * @param request * @options diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 01f5cf3de38bd8..391be8e0537468 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -15,7 +15,7 @@ import { IndexPatternsContract } from '../../common/index_patterns/index_pattern import { UsageCollectionSetup } from '../../../usage_collection/public'; import { ISessionsClient, ISessionService } from './session'; -export { ISearchStartSearchSource }; +export { ISearchStartSearchSource, SearchUsageCollector }; export interface SearchEnhancements { searchInterceptor: ISearchInterceptor; diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 0a116545e6e366..29f3494433befe 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -8,7 +8,11 @@ import React from 'react'; import moment from 'moment'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; -import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, + SearchUsageCollector, +} from '../../../../src/plugins/data/public'; import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginStart } from '../../../../src/plugins/share/public'; @@ -40,6 +44,7 @@ export class DataEnhancedPlugin private enhancedSearchInterceptor!: EnhancedSearchInterceptor; private config!: ConfigSchema; private readonly storage = new Storage(window.localStorage); + private usageCollector?: SearchUsageCollector; constructor(private initializerContext: PluginInitializerContext) {} @@ -71,8 +76,10 @@ export class DataEnhancedPlugin this.config = this.initializerContext.config.get(); if (this.config.search.sessions.enabled) { const sessionsConfig = this.config.search.sessions; - registerSearchSessionsMgmt(core, sessionsConfig, { management }); + registerSearchSessionsMgmt(core, sessionsConfig, { data, management }); } + + this.usageCollector = data.search.usageCollector; } public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { @@ -90,6 +97,7 @@ export class DataEnhancedPlugin disableSaveAfterSessionCompletesTimeout: moment .duration(this.config.search.sessions.notTouchedTimeout) .asMilliseconds(), + usageCollector: this.usageCollector, }) ) ), diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 04a777b9b6897e..02671974e50536 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -16,6 +16,7 @@ import { SearchTimeoutError, SearchSessionState, PainlessError, + DataPublicPluginSetup, } from 'src/plugins/data/public'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; @@ -51,14 +52,15 @@ function mockFetchImplementation(responses: any[]) { } describe('EnhancedSearchInterceptor', () => { - let mockUsageCollector: any; let sessionService: jest.Mocked; let sessionState$: BehaviorSubject; + let dataPluginMockSetup: DataPublicPluginSetup; beforeEach(() => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); sessionState$ = new BehaviorSubject(SearchSessionState.None); + dataPluginMockSetup = dataPluginMock.createSetupContract(); const dataPluginMockStart = dataPluginMock.createStartContract(); sessionService = { ...(dataPluginMockStart.search.session as jest.Mocked), @@ -80,11 +82,6 @@ describe('EnhancedSearchInterceptor', () => { complete.mockClear(); jest.clearAllTimers(); - mockUsageCollector = { - trackQueryTimedOut: jest.fn(), - trackQueriesCancelled: jest.fn(), - }; - const mockPromise = new Promise((resolve) => { resolve([ { @@ -102,7 +99,7 @@ describe('EnhancedSearchInterceptor', () => { startServices: mockPromise as any, http: mockCoreSetup.http, uiSettings: mockCoreSetup.uiSettings, - usageCollector: mockUsageCollector, + usageCollector: dataPluginMockSetup.search.usageCollector, session: sessionService, }); }); @@ -455,39 +452,6 @@ describe('EnhancedSearchInterceptor', () => { }); }); - describe('cancelPending', () => { - test('should abort all pending requests', async () => { - mockFetchImplementation([ - { - time: 10, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - { - time: 20, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]); - - searchInterceptor.search({}).subscribe({ next, error }); - searchInterceptor.search({}).subscribe({ next, error }); - searchInterceptor.cancelPending(); - - await timeTravel(); - - const areAllRequestsAborted = fetchMock.mock.calls.every(([_, signal]) => signal?.aborted); - expect(areAllRequestsAborted).toBe(true); - expect(mockUsageCollector.trackQueriesCancelled).toBeCalledTimes(1); - }); - }); - describe('session', () => { beforeEach(() => { const responses = [ diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts index f211021e457734..0dfec1a35d9006 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -46,15 +46,6 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { : TimeoutErrorMode.CONTACT; } - /** - * Abort our `AbortController`, which in turn aborts any intercepted searches. - */ - public cancelPending = () => { - this.abortController.abort(); - this.abortController = new AbortController(); - if (this.deps.usageCollector) this.deps.usageCollector.trackQueriesCancelled(); - }; - public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { const { combinedSignal, timeoutSignal, cleanup, abort } = this.setupAbortSignal({ abortSignal: options.abortSignal, diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx index 177cfbbb4fd7e2..2dfca534c20b5c 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx @@ -50,6 +50,7 @@ export class SearchSessionsMgmtApp { notifications, urls: share.urlGenerators, application, + usageCollector: pluginsSetup.data.search.usageCollector, }); const documentation = new AsyncSearchIntroDocumentation(docLinks); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx index 1f8f603400c9fd..6b94eccc4e7076 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx @@ -13,14 +13,17 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { coreMock } from 'src/core/public/mocks'; import { SessionsClient } from 'src/plugins/data/public/search'; -import { SessionsConfigSchema } from '..'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '..'; import { SearchSessionsMgmtAPI } from '../lib/api'; import { AsyncSearchIntroDocumentation } from '../lib/documentation'; import { LocaleWrapper, mockUrls } from '../__mocks__'; import { SearchSessionsMgmtMain } from './main'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { managementPluginMock } from '../../../../../../../src/plugins/management/public/mocks'; let mockCoreSetup: MockedKeys; let mockCoreStart: MockedKeys; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let sessionsClient: SessionsClient; let api: SearchSessionsMgmtAPI; @@ -29,6 +32,10 @@ describe('Background Search Session Management Main', () => { beforeEach(() => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -67,6 +74,7 @@ describe('Background Search Session Management Main', () => { ; let mockCoreStart: CoreStart; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let sessionsClient: SessionsClient; let api: SearchSessionsMgmtAPI; @@ -29,6 +32,10 @@ describe('Background Search Session Management Table', () => { beforeEach(async () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -79,6 +86,7 @@ describe('Background Search Session Management Table', () => { { { { ([]); const [isLoading, setIsLoading] = useState(false); const [debouncedIsLoading, setDebouncedIsLoading] = useState(false); @@ -71,7 +72,8 @@ export function SearchSessionsMgmtTable({ core, api, timezone, config, ...props // initial data load useEffect(() => { doRefresh(); - }, [doRefresh]); + plugins.data.search.usageCollector?.trackSessionsListLoaded(); + }, [doRefresh, plugins]); useInterval(doRefresh, refreshInterval); @@ -110,7 +112,7 @@ export function SearchSessionsMgmtTable({ core, api, timezone, config, ...props rowProps={() => ({ 'data-test-subj': 'searchSessionsRow', })} - columns={getColumns(core, api, config, timezone, onActionComplete)} + columns={getColumns(core, plugins, api, config, timezone, onActionComplete)} items={tableData} pagination={pagination} search={search} diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts index e916eed6bcbc44..0ac8fa798cc925 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { CoreStart, HttpStart, I18nStart, IUiSettingsClient } from 'kibana/public'; import { CoreSetup } from 'kibana/public'; -import type { DataPublicPluginStart } from 'src/plugins/data/public'; +import type { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public'; import type { ManagementSetup } from 'src/plugins/management/public'; import type { SharePluginStart } from 'src/plugins/share/public'; import type { ConfigSchema } from '../../../config'; @@ -18,6 +18,7 @@ import type { AsyncSearchIntroDocumentation } from './lib/documentation'; import { SEARCH_SESSIONS_MANAGEMENT_ID } from '../../../../../../src/plugins/data/public'; export interface IManagementSectionsPluginsSetup { + data: DataPublicPluginSetup; management: ManagementSetup; } diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts index 39da58cb769182..838b51994aa715 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts @@ -11,7 +11,10 @@ import moment from 'moment'; import { from, race, timer } from 'rxjs'; import { mapTo, tap } from 'rxjs/operators'; import type { SharePluginStart } from 'src/plugins/share/public'; -import { ISessionsClient } from '../../../../../../../src/plugins/data/public'; +import { + ISessionsClient, + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; import { SearchSessionStatus } from '../../../../common/search'; import { ACTION } from '../components/actions'; import { PersistedSearchSessionSavedObjectAttributes, UISession } from '../types'; @@ -84,17 +87,18 @@ const mapToUISession = (urls: UrlGeneratorsStart, config: SessionsConfigSchema) }; }; -interface SearcgSessuibManagementDeps { +interface SearchSessionManagementDeps { urls: UrlGeneratorsStart; notifications: NotificationsStart; application: ApplicationStart; + usageCollector?: SearchUsageCollector; } export class SearchSessionsMgmtAPI { constructor( private sessionsClient: ISessionsClient, private config: SessionsConfigSchema, - private deps: SearcgSessuibManagementDeps + private deps: SearchSessionManagementDeps ) {} public async fetchTableData(): Promise { @@ -151,6 +155,7 @@ export class SearchSessionsMgmtAPI { } public reloadSearchSession(reloadUrl: string) { + this.deps.usageCollector?.trackSessionReloaded(); this.deps.application.navigateToUrl(reloadUrl); } @@ -160,6 +165,7 @@ export class SearchSessionsMgmtAPI { // Cancel and expire public async sendCancel(id: string): Promise { + this.deps.usageCollector?.trackSessionDeleted(); try { await this.sessionsClient.delete(id); @@ -179,6 +185,7 @@ export class SearchSessionsMgmtAPI { // Extend public async sendExtend(id: string, expires: string): Promise { + this.deps.usageCollector?.trackSessionExtended(); try { await this.sessionsClient.extend(id, expires); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx index fc0a8849006d3c..29f0033aaf0121 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx @@ -13,16 +13,19 @@ import moment from 'moment'; import { ReactElement } from 'react'; import { coreMock } from 'src/core/public/mocks'; import { SessionsClient } from 'src/plugins/data/public/search'; -import { SessionsConfigSchema } from '../'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '../'; import { SearchSessionStatus } from '../../../../common/search'; import { OnActionComplete } from '../components'; import { UISession } from '../types'; import { mockUrls } from '../__mocks__'; import { SearchSessionsMgmtAPI } from './api'; import { getColumns } from './get_columns'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { managementPluginMock } from '../../../../../../../src/plugins/management/public/mocks'; let mockCoreSetup: MockedKeys; let mockCoreStart: CoreStart; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let api: SearchSessionsMgmtAPI; let sessionsClient: SessionsClient; @@ -35,6 +38,10 @@ describe('Search Sessions Management table column factory', () => { beforeEach(async () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -72,7 +79,7 @@ describe('Search Sessions Management table column factory', () => { }); test('returns columns', () => { - const columns = getColumns(mockCoreStart, api, mockConfig, tz, handleAction); + const columns = getColumns(mockCoreStart, mockPluginsSetup, api, mockConfig, tz, handleAction); expect(columns).toMatchInlineSnapshot(` Array [ Object { @@ -124,9 +131,14 @@ describe('Search Sessions Management table column factory', () => { describe('name', () => { test('rendering', () => { - const [, nameColumn] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, nameColumn] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; const name = mount(nameColumn.render!(mockSession.name, mockSession) as ReactElement); @@ -137,9 +149,14 @@ describe('Search Sessions Management table column factory', () => { // Status column describe('status', () => { test('render in_progress', () => { - const [, , status] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, , status] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; const statusLine = mount(status.render!(mockSession.status, mockSession) as ReactElement); expect( @@ -148,9 +165,14 @@ describe('Search Sessions Management table column factory', () => { }); test('error handling', () => { - const [, , status] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, , status] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; mockSession.status = 'INVALID' as SearchSessionStatus; const statusLine = mount(status.render!(mockSession.status, mockSession) as ReactElement); @@ -168,6 +190,7 @@ describe('Search Sessions Management table column factory', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, @@ -184,6 +207,7 @@ describe('Search Sessions Management table column factory', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, @@ -198,6 +222,7 @@ describe('Search Sessions Management table column factory', () => { test('error handling', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx index cbd42ec56bb8b1..d34998d023178c 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx @@ -21,7 +21,7 @@ import { capitalize } from 'lodash'; import React from 'react'; import { FormattedMessage } from 'react-intl'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; -import { SessionsConfigSchema } from '../'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '../'; import { SearchSessionStatus } from '../../../../common/search'; import { TableText } from '../components'; import { OnActionComplete, PopoverActionsMenu } from '../components'; @@ -45,6 +45,7 @@ function isSessionRestorable(status: SearchSessionStatus) { export const getColumns = ( core: CoreStart, + plugins: IManagementSectionsPluginsSetup, api: SearchSessionsMgmtAPI, config: SessionsConfigSchema, timezone: string, @@ -83,6 +84,10 @@ export const getColumns = ( width: '20%', render: (name: UISession['name'], { restoreUrl, reloadUrl, status }) => { const isRestorable = isSessionRestorable(status); + const href = isRestorable ? restoreUrl : reloadUrl; + const trackAction = isRestorable + ? plugins.data.search.usageCollector?.trackSessionViewRestored + : plugins.data.search.usageCollector?.trackSessionReloaded; const notRestorableWarning = isRestorable ? null : ( <> {' '} @@ -99,8 +104,10 @@ export const getColumns = ( ); return ( + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} trackAction?.()} data-test-subj="sessionManagementNameCol" > diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index aacb86f269727a..0aef27310e0906 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -16,18 +16,22 @@ import { ISessionService, RefreshInterval, SearchSessionState, + SearchUsageCollector, TimefilterContract, } from '../../../../../../../src/plugins/data/public'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { TOUR_RESTORE_STEP_KEY, TOUR_TAKING_TOO_LONG_STEP_KEY } from './search_session_tour'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from 'react-intl'; +import { createSearchUsageCollectorMock } from '../../../../../../../src/plugins/data/public/search/collectors/mocks'; const coreStart = coreMock.createStart(); const application = coreStart.application; const dataStart = dataPluginMock.createStartContract(); const sessionService = dataStart.search.session as jest.Mocked; let storage: Storage; +let usageCollector: jest.Mocked; + const refreshInterval$ = new BehaviorSubject({ value: 0, pause: true }); const timeFilter = dataStart.query.timefilter.timefilter as jest.Mocked; timeFilter.getRefreshIntervalUpdate$.mockImplementation(() => refreshInterval$); @@ -41,6 +45,7 @@ function Container({ children }: { children?: ReactNode }) { beforeEach(() => { storage = new Storage(new StubBrowserStorage()); + usageCollector = createSearchUsageCollectorMock(); refreshInterval$.next({ value: 0, pause: true }); sessionService.isSessionStorageReady.mockImplementation(() => true); sessionService.getSearchSessionIndicatorUiConfig.mockImplementation(() => ({ @@ -57,6 +62,7 @@ test("shouldn't show indicator in case no active search session", async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId, container } = render( @@ -84,6 +90,7 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId, container } = render( @@ -113,6 +120,7 @@ test('should show indicator in case there is an active search session', async () timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId } = render( @@ -137,6 +145,7 @@ test('should be disabled in case uiConfig says so ', async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -185,6 +194,7 @@ test('should be disabled during auto-refresh', async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -222,6 +232,7 @@ describe('Completed inactivity', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -253,12 +264,14 @@ describe('Completed inactivity', () => { }); expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled(); + expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(0); act(() => { jest.advanceTimersByTime(2.5 * 60 * 1000); }); expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); + expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(1); }); }); @@ -280,6 +293,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -307,6 +321,9 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeTruthy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(1); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); test("doesn't show tour step if state changed before delay", async () => { @@ -317,6 +334,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -337,6 +355,9 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeFalsy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); }); @@ -348,6 +369,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -360,6 +382,10 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeTruthy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeTruthy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIsRestored).toHaveBeenCalledTimes(1); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(1); }); test("doesn't show tour for irrelevant state", async () => { @@ -370,6 +396,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -383,5 +410,8 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeFalsy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); }); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 81769e5a25544f..7c70a270bd30a2 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; -import { debounce, distinctUntilChanged, map, mapTo, switchMap } from 'rxjs/operators'; +import React, { useCallback, useEffect, useState } from 'react'; +import { debounce, distinctUntilChanged, map, mapTo, switchMap, tap } from 'rxjs/operators'; import { merge, of, timer } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; @@ -15,7 +15,8 @@ import { ISessionService, SearchSessionState, TimefilterContract, -} from '../../../../../../../src/plugins/data/public/'; + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; import { ApplicationStart } from '../../../../../../../src/core/public'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; @@ -31,6 +32,7 @@ export interface SearchSessionIndicatorDeps { * after the last search in the session has completed */ disableSaveAfterSessionCompletesTimeout: number; + usageCollector?: SearchUsageCollector; } export const createConnectedSearchSessionIndicator = ({ @@ -39,6 +41,7 @@ export const createConnectedSearchSessionIndicator = ({ timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }: SearchSessionIndicatorDeps): React.FC => { const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause; const isAutoRefreshEnabled$ = timeFilter @@ -55,7 +58,10 @@ export const createConnectedSearchSessionIndicator = ({ ? merge(of(false), timer(disableSaveAfterSessionCompletesTimeout).pipe(mapTo(true))) : of(false) ), - distinctUntilChanged() + distinctUntilChanged(), + tap((value) => { + if (value) usageCollector?.trackSessionIndicatorSaveDisabled(); + }) ); return () => { @@ -123,7 +129,8 @@ export const createConnectedSearchSessionIndicator = ({ storage, searchSessionIndicator, state, - saveDisabled + saveDisabled, + usageCollector ); const onOpened = useCallback( @@ -138,18 +145,31 @@ export const createConnectedSearchSessionIndicator = ({ const onContinueInBackground = useCallback(() => { if (saveDisabled) return; + usageCollector?.trackSessionSentToBackground(); sessionService.save(); }, [saveDisabled]); const onSaveResults = useCallback(() => { if (saveDisabled) return; + usageCollector?.trackSessionSavedResults(); sessionService.save(); }, [saveDisabled]); const onCancel = useCallback(() => { + usageCollector?.trackSessionCancelled(); sessionService.cancel(); }, []); + const onViewSearchSessions = useCallback(() => { + usageCollector?.trackViewSessionsList(); + }, []); + + useEffect(() => { + if (state === SearchSessionState.Restored) { + usageCollector?.trackSessionIsRestored(); + } + }, [state]); + if (!sessionService.isSessionStorageReady()) return null; return ( @@ -164,6 +184,7 @@ export const createConnectedSearchSessionIndicator = ({ onSaveResults={onSaveResults} onCancel={onCancel} onOpened={onOpened} + onViewSearchSessions={onViewSearchSessions} /> ); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx index 7987278f400ff9..1568d54962eca7 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx @@ -6,9 +6,13 @@ */ import { useCallback, useEffect } from 'react'; +import { once } from 'lodash'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; import { SearchSessionIndicatorRef } from '../search_session_indicator'; -import { SearchSessionState } from '../../../../../../../src/plugins/data/public'; +import { + SearchSessionState, + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; const TOUR_TAKING_TOO_LONG_TIMEOUT = 10000; export const TOUR_TAKING_TOO_LONG_STEP_KEY = `data.searchSession.tour.takingTooLong`; @@ -18,7 +22,8 @@ export function useSearchSessionTour( storage: IStorageWrapper, searchSessionIndicatorRef: SearchSessionIndicatorRef | null, state: SearchSessionState, - searchSessionsDisabled: boolean + searchSessionsDisabled: boolean, + usageCollector?: SearchUsageCollector ) { const markOpenedDone = useCallback(() => { safeSet(storage, TOUR_TAKING_TOO_LONG_STEP_KEY); @@ -28,6 +33,26 @@ export function useSearchSessionTour( safeSet(storage, TOUR_RESTORE_STEP_KEY); }, [storage]); + // Makes sure `trackSessionIndicatorTourLoading` is called only once per sessionId + // if to call `usageCollector?.trackSessionIndicatorTourLoading()` directly inside the `useEffect` below + // it might happen that we cause excessive logging + // ESLint: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + const trackSessionIndicatorTourLoading = useCallback( + once(() => usageCollector?.trackSessionIndicatorTourLoading()), + [usageCollector, state] + ); + + // Makes sure `trackSessionIndicatorTourRestored` is called only once per sessionId + // if to call `usageCollector?.trackSessionIndicatorTourRestored()` directly inside the `useEffect` below + // it might happen that we cause excessive logging + // ESLint: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + const trackSessionIndicatorTourRestored = useCallback( + once(() => usageCollector?.trackSessionIndicatorTourRestored()), + [usageCollector, state] + ); + useEffect(() => { if (searchSessionsDisabled) return; if (!searchSessionIndicatorRef) return; @@ -36,6 +61,7 @@ export function useSearchSessionTour( if (state === SearchSessionState.Loading) { if (!safeHas(storage, TOUR_TAKING_TOO_LONG_STEP_KEY)) { timeoutHandle = window.setTimeout(() => { + trackSessionIndicatorTourLoading(); searchSessionIndicatorRef.openPopover(); }, TOUR_TAKING_TOO_LONG_TIMEOUT); } @@ -43,6 +69,7 @@ export function useSearchSessionTour( if (state === SearchSessionState.Restored) { if (!safeHas(storage, TOUR_RESTORE_STEP_KEY)) { + trackSessionIndicatorTourRestored(); searchSessionIndicatorRef.openPopover(); } } @@ -57,6 +84,9 @@ export function useSearchSessionTour( searchSessionsDisabled, markOpenedDone, markRestoredDone, + usageCollector, + trackSessionIndicatorTourRestored, + trackSessionIndicatorTourLoading, ]); return { diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx index 0d31ce0c98f194..24ffc1359acae4 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx @@ -30,6 +30,7 @@ export interface SearchSessionIndicatorProps { onContinueInBackground?: () => void; onCancel?: () => void; viewSearchSessionsLink?: string; + onViewSearchSessions?: () => void; onSaveResults?: () => void; managementDisabled?: boolean; managementDisabledReasonText?: string; @@ -78,13 +79,16 @@ const ContinueInBackgroundButton = ({ const ViewAllSearchSessionsButton = ({ viewSearchSessionsLink = 'management/kibana/search_sessions', + onViewSearchSessions = () => {}, buttonProps = {}, managementDisabled, managementDisabledReasonText, }: ActionButtonProps) => ( + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} Date: Tue, 16 Feb 2021 14:00:07 +0200 Subject: [PATCH 058/175] [TSVB] Fixes the timeseries legend, renders the metric, gauge charts for series with empty strings (#90760) * [TSVB] Fixes the legend for empty values and renders the metric, gauge charts * Change i18n id Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/components/vis_types/table/vis.js | 8 +++++++- .../application/components/vis_with_splits.js | 10 ++++++++-- .../visualizations/views/timeseries/index.js | 15 +++++++++++++-- .../application/visualizations/views/top_n.js | 7 ++++++- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js index e4ab4eaa0a671c..24d0ca1b588f73 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js @@ -8,6 +8,7 @@ import _, { isArray, last, get } from 'lodash'; import React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; import PropTypes from 'prop-types'; import { RedirectAppLinks } from '../../../../../../kibana_react/public'; import { createTickFormatter } from '../../lib/tick_formatter'; @@ -88,7 +89,12 @@ class TableVis extends Component { }); return (
{rowDisplay} + {rowDisplay || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + })} +
- {item.labelFormatted ? labelDateFormatter(item.labelFormatted) : item.label} + {label || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + })}
From 2ef468ef82388824d8b3f30d262d8f7a7b0e51e8 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 16 Feb 2021 07:19:41 -0500 Subject: [PATCH 059/175] Fixes session idle timeout (#91070) * Fix calls to `/api/saved_objects_tagging/tags` Seen on all pages. * Fix calls to `/api/ui_counters/_report` Seen on all pages. * Fix calls to `/api/index_management/indices/reload` Seen on page: /app/management/data/index_management * Fix calls to `/api/watcher/watches` Seen on page: /app/management/insightsAndAlerting/watcher/watches * Fix calls to `/api/rollup/jobs` Seen on page: /app/management/data/rollup_jobs/job_list * Fix calls to `/api/cross_cluster_replication/follower_indices` Seen on page: /app/management/data/cross_cluster_replication/follower_indices * Fix calls to `/api/cross_cluster_replication/auto_follow_patterns` Seen on page: /app/management/data/cross_cluster_replication/auto_follow_patterns * Fix calls to `/api/transform/transforms` and `/api/transform/transforms/_stats` Seen on page: /app/management/data/transform * Fix calls to `/api/console/proxy` Seen on page: /app/dev_tools#/console * Fix calls to `/api/monitoring/v1/clusters` and `/api/monitoring/v1/elasticsearch_settings/check/cluster` Seen on page: /app/monitoring --- src/plugins/console/public/lib/es/es.ts | 12 ++- .../console/public/lib/mappings/mappings.js | 2 +- .../request/send_request.test.helpers.ts | 22 +++-- .../public/request/send_request.ts | 13 ++- .../request/use_request.test.helpers.tsx | 33 ++++--- .../public/request/use_request.ts | 88 +++++++++++-------- .../saved_objects_tagging_oss/common/index.ts | 2 +- .../saved_objects_tagging_oss/common/types.ts | 6 +- .../public/services/create_reporter.ts | 1 + .../public/app/services/api.js | 6 +- .../app/store/actions/auto_follow_pattern.js | 2 +- .../app/store/actions/follower_index.js | 2 +- .../index_table/index_table.container.js | 4 +- .../index_list/index_table/index_table.js | 6 +- .../public/application/services/api.ts | 14 ++- .../store/actions/reload_indices.js | 4 +- .../checkers/settings_checker.js | 4 +- .../monitoring/public/services/clusters.js | 18 ++-- .../public/application/services/api.js | 4 +- .../public/application/services/http.ts | 13 ++- .../store/actions/refresh_clusters.js | 2 +- .../sections/job_list/job_list.container.js | 4 +- .../crud_app/sections/job_list/job_list.js | 5 +- .../rollup/public/crud_app/services/api.js | 5 +- .../crud_app/store/actions/refresh_jobs.js | 4 +- .../saved_objects_tagging/common/types.ts | 1 + .../saved_objects_tagging/public/plugin.ts | 2 +- .../public/services/tags/tags_client.test.ts | 12 ++- .../public/services/tags/tags_client.ts | 16 +++- .../transform/public/app/hooks/use_api.ts | 16 +++- .../public/app/hooks/use_get_transforms.ts | 5 +- 31 files changed, 222 insertions(+), 106 deletions(-) diff --git a/src/plugins/console/public/lib/es/es.ts b/src/plugins/console/public/lib/es/es.ts index 8053fca91b7d1d..03ee218fa2e1d0 100644 --- a/src/plugins/console/public/lib/es/es.ts +++ b/src/plugins/console/public/lib/es/es.ts @@ -9,6 +9,10 @@ import $ from 'jquery'; import { stringify } from 'query-string'; +interface SendOptions { + asSystemRequest?: boolean; +} + const esVersion: string[] = []; export function getVersion() { @@ -20,13 +24,19 @@ export function getContentType(body: any) { return 'application/json'; } -export function send(method: string, path: string, data: any) { +export function send( + method: string, + path: string, + data: any, + { asSystemRequest }: SendOptions = {} +) { const wrappedDfd = $.Deferred(); const options: JQuery.AjaxSettings = { url: '../api/console/proxy?' + stringify({ path, method }, { sort: false }), headers: { 'kbn-xsrf': 'kibana', + ...(asSystemRequest && { 'kbn-system-request': 'true' }), }, data, contentType: getContentType(data), diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js index 244cc781498a7c..d4996f9fd8862a 100644 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ b/src/plugins/console/public/lib/mappings/mappings.js @@ -250,7 +250,7 @@ function retrieveSettings(settingsKey, settingsToRetrieve) { // Fetch autocomplete info if setting is set to true, and if user has made changes. if (settingsToRetrieve[settingsKey] === true) { - return es.send('GET', settingKeyToPathMap[settingsKey], null); + return es.send('GET', settingKeyToPathMap[settingsKey], null, true); } else { const settingsPromise = new $.Deferred(); if (settingsToRetrieve[settingsKey] === false) { diff --git a/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts b/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts index 5244a6c1e8bf17..3ef33b651f4d26 100644 --- a/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts +++ b/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts @@ -41,20 +41,26 @@ export const createSendRequestHelpers = (): SendRequestHelpers => { // Set up successful request helpers. sendRequestSpy - .withArgs(successRequest.path, { - body: JSON.stringify(successRequest.body), - query: undefined, - }) + .withArgs( + successRequest.path, + sinon.match({ + body: JSON.stringify(successRequest.body), + query: undefined, + }) + ) .resolves(successResponse); const sendSuccessRequest = () => sendRequest({ ...successRequest }); const getSuccessResponse = () => ({ data: successResponse.data, error: null }); // Set up failed request helpers. sendRequestSpy - .withArgs(errorRequest.path, { - body: JSON.stringify(errorRequest.body), - query: undefined, - }) + .withArgs( + errorRequest.path, + sinon.match({ + body: JSON.stringify(errorRequest.body), + query: undefined, + }) + ) .rejects(errorResponse); const sendErrorRequest = () => sendRequest({ ...errorRequest }); const getErrorResponse = () => ({ diff --git a/src/plugins/es_ui_shared/public/request/send_request.ts b/src/plugins/es_ui_shared/public/request/send_request.ts index 32703f21a4668a..11ab99cfb69786 100644 --- a/src/plugins/es_ui_shared/public/request/send_request.ts +++ b/src/plugins/es_ui_shared/public/request/send_request.ts @@ -13,6 +13,11 @@ export interface SendRequestConfig { method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'; query?: HttpFetchQuery; body?: any; + /** + * If set, flags this as a "system request" to indicate that this is not a user-initiated request. For more information, see + * HttpFetchOptions#asSystemRequest. + */ + asSystemRequest?: boolean; } export interface SendRequestResponse { @@ -22,11 +27,15 @@ export interface SendRequestResponse { export const sendRequest = async ( httpClient: HttpSetup, - { path, method, body, query }: SendRequestConfig + { path, method, body, query, asSystemRequest }: SendRequestConfig ): Promise> => { try { const stringifiedBody = typeof body === 'string' ? body : JSON.stringify(body); - const response = await httpClient[method](path, { body: stringifiedBody, query }); + const response = await httpClient[method](path, { + body: stringifiedBody, + query, + asSystemRequest, + }); return { data: response.data ? response.data : response, diff --git a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx index 9f41d13112bc88..82d3764dbf72ab 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx +++ b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx @@ -123,10 +123,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { // Set up successful request helpers. sendRequestSpy - .withArgs(successRequest.path, { - body: JSON.stringify(successRequest.body), - query: undefined, - }) + .withArgs( + successRequest.path, + sinon.match({ + body: JSON.stringify(successRequest.body), + query: undefined, + }) + ) .resolves(successResponse); const setupSuccessRequest = (overrides = {}, requestTimings?: number[]) => setupUseRequest({ ...successRequest, ...overrides }, requestTimings); @@ -134,10 +137,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { // Set up failed request helpers. sendRequestSpy - .withArgs(errorRequest.path, { - body: JSON.stringify(errorRequest.body), - query: undefined, - }) + .withArgs( + errorRequest.path, + sinon.match({ + body: JSON.stringify(errorRequest.body), + query: undefined, + }) + ) .rejects(errorResponse); const setupErrorRequest = (overrides = {}, requestTimings?: number[]) => setupUseRequest({ ...errorRequest, ...overrides }, requestTimings); @@ -152,10 +158,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { // Set up failed request helpers with the alternative error shape. sendRequestSpy - .withArgs(errorWithBodyRequest.path, { - body: JSON.stringify(errorWithBodyRequest.body), - query: undefined, - }) + .withArgs( + errorWithBodyRequest.path, + sinon.match({ + body: JSON.stringify(errorWithBodyRequest.body), + query: undefined, + }) + ) .rejects(errorWithBodyResponse); const setupErrorWithBodyRequest = (overrides = {}) => setupUseRequest({ ...errorWithBodyRequest, ...overrides }); diff --git a/src/plugins/es_ui_shared/public/request/use_request.ts b/src/plugins/es_ui_shared/public/request/use_request.ts index 99eb38ff6023fa..33085bdbf4478d 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.ts +++ b/src/plugins/es_ui_shared/public/request/use_request.ts @@ -65,49 +65,59 @@ export const useRequest = ( /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [path, method, queryStringified, bodyStringified]); - const resendRequest = useCallback(async () => { - // If we're on an interval, this allows us to reset it if the user has manually requested the - // data, to avoid doubled-up requests. - clearPollInterval(); - - const requestId = ++requestCountRef.current; - - // We don't clear error or data, so it's up to the consumer to decide whether to display the - // "old" error/data or loading state when a new request is in-flight. - setIsLoading(true); - - const response = await sendRequest(httpClient, requestBody); - const { data: serializedResponseData, error: responseError } = response; - - const isOutdatedRequest = requestId !== requestCountRef.current; - const isUnmounted = isMounted.current === false; - - // Ignore outdated or irrelevant data. - if (isOutdatedRequest || isUnmounted) { - return; - } - - // Surface to consumers that at least one request has resolved. - isInitialRequestRef.current = false; + const resendRequest = useCallback( + async (asSystemRequest?: boolean) => { + // If we're on an interval, this allows us to reset it if the user has manually requested the + // data, to avoid doubled-up requests. + clearPollInterval(); - setError(responseError); - // If there's an error, keep the data from the last request in case it's still useful to the user. - if (!responseError) { - const responseData = deserializer - ? deserializer(serializedResponseData) - : serializedResponseData; - setData(responseData); - } - // Setting isLoading to false also acts as a signal for scheduling the next poll request. - setIsLoading(false); - }, [requestBody, httpClient, deserializer, clearPollInterval]); + const requestId = ++requestCountRef.current; + + // We don't clear error or data, so it's up to the consumer to decide whether to display the + // "old" error/data or loading state when a new request is in-flight. + setIsLoading(true); + + // Any requests that are sent in the background (without user interaction) should be flagged as "system requests". This should not be + // confused with any terminology in Elasticsearch. This is a Kibana-specific construct that allows the server to differentiate between + // user-initiated and requests "system"-initiated requests, for purposes like security features. + const requestPayload = { ...requestBody, asSystemRequest }; + const response = await sendRequest(httpClient, requestPayload); + const { data: serializedResponseData, error: responseError } = response; + + const isOutdatedRequest = requestId !== requestCountRef.current; + const isUnmounted = isMounted.current === false; + + // Ignore outdated or irrelevant data. + if (isOutdatedRequest || isUnmounted) { + return; + } + + // Surface to consumers that at least one request has resolved. + isInitialRequestRef.current = false; + + setError(responseError); + // If there's an error, keep the data from the last request in case it's still useful to the user. + if (!responseError) { + const responseData = deserializer + ? deserializer(serializedResponseData) + : serializedResponseData; + setData(responseData); + } + // Setting isLoading to false also acts as a signal for scheduling the next poll request. + setIsLoading(false); + }, + [requestBody, httpClient, deserializer, clearPollInterval] + ); const scheduleRequest = useCallback(() => { // If there's a scheduled poll request, this new one will supersede it. clearPollInterval(); if (pollIntervalMs) { - pollIntervalIdRef.current = setTimeout(resendRequest, pollIntervalMs); + pollIntervalIdRef.current = setTimeout( + () => resendRequest(true), // This is happening on an interval in the background, so we flag it as a "system request". + pollIntervalMs + ); } }, [pollIntervalMs, resendRequest, clearPollInterval]); @@ -137,11 +147,15 @@ export const useRequest = ( }; }, [clearPollInterval]); + const resendRequestForConsumer = useCallback(() => { + return resendRequest(); + }, [resendRequest]); + return { isInitialRequest: isInitialRequestRef.current, isLoading, error, data, - resendRequest, // Gives the user the ability to manually request data + resendRequest: resendRequestForConsumer, // Gives the user the ability to manually request data }; }; diff --git a/src/plugins/saved_objects_tagging_oss/common/index.ts b/src/plugins/saved_objects_tagging_oss/common/index.ts index 231bec46f57ab1..a892f41c69314c 100644 --- a/src/plugins/saved_objects_tagging_oss/common/index.ts +++ b/src/plugins/saved_objects_tagging_oss/common/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { Tag, TagAttributes, ITagsClient } from './types'; +export { Tag, TagAttributes, GetAllTagsOptions, ITagsClient } from './types'; diff --git a/src/plugins/saved_objects_tagging_oss/common/types.ts b/src/plugins/saved_objects_tagging_oss/common/types.ts index 344e18a5fd76d2..205f6984ed618e 100644 --- a/src/plugins/saved_objects_tagging_oss/common/types.ts +++ b/src/plugins/saved_objects_tagging_oss/common/types.ts @@ -19,10 +19,14 @@ export interface TagAttributes { color: string; } +export interface GetAllTagsOptions { + asSystemRequest?: boolean; +} + export interface ITagsClient { create(attributes: TagAttributes): Promise; get(id: string): Promise; - getAll(): Promise; + getAll(options?: GetAllTagsOptions): Promise; delete(id: string): Promise; update(id: string, attributes: TagAttributes): Promise; } diff --git a/src/plugins/usage_collection/public/services/create_reporter.ts b/src/plugins/usage_collection/public/services/create_reporter.ts index ef4c007735ff43..e5006646fe368c 100644 --- a/src/plugins/usage_collection/public/services/create_reporter.ts +++ b/src/plugins/usage_collection/public/services/create_reporter.ts @@ -24,6 +24,7 @@ export function createReporter(config: AnalyicsReporterConfig): Reporter { async http(report) { const response = await fetch.post('/api/ui_counters/_report', { body: JSON.stringify({ report }), + asSystemRequest: true, }); if (response.status !== 'ok') { diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js index e1be717db221c2..8067b2cc11b9a0 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js @@ -48,7 +48,8 @@ export const getHttpClient = () => { const createIdString = (ids) => ids.map((id) => encodeURIComponent(id)).join(','); /* Auto Follow Pattern */ -export const loadAutoFollowPatterns = () => httpClient.get(`${API_BASE_PATH}/auto_follow_patterns`); +export const loadAutoFollowPatterns = (asSystemRequest) => + httpClient.get(`${API_BASE_PATH}/auto_follow_patterns`, { asSystemRequest }); export const getAutoFollowPattern = (id) => httpClient.get(`${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`); @@ -100,7 +101,8 @@ export const resumeAutoFollowPattern = (id) => { }; /* Follower Index */ -export const loadFollowerIndices = () => httpClient.get(`${API_BASE_PATH}/follower_indices`); +export const loadFollowerIndices = (asSystemRequest) => + httpClient.get(`${API_BASE_PATH}/follower_indices`, { asSystemRequest }); export const getFollowerIndex = (id) => httpClient.get(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js index e6a9f02b913ca6..79d0eeabb817dd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js @@ -39,7 +39,7 @@ export const loadAutoFollowPatterns = (isUpdating = false) => label: t.AUTO_FOLLOW_PATTERN_LOAD, scope, status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING, - handler: async () => await loadAutoFollowPatternsRequest(), + handler: async () => await loadAutoFollowPatternsRequest(isUpdating), }); export const getAutoFollowPattern = (id) => diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js index 9f8b20622d6ece..7422ba6c84491f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js @@ -40,7 +40,7 @@ export const loadFollowerIndices = (isUpdating = false) => label: t.FOLLOWER_INDEX_LOAD, scope, status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING, - handler: async () => await loadFollowerIndicesRequest(), + handler: async () => await loadFollowerIndicesRequest(isUpdating), }); export const getFollowerIndex = (id) => diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js index a435d9be54864a..93ad0e0dc3be54 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js @@ -76,8 +76,8 @@ const mapDispatchToProps = (dispatch) => { loadIndices: () => { dispatch(loadIndices()); }, - reloadIndices: (indexNames) => { - dispatch(reloadIndices(indexNames)); + reloadIndices: (indexNames, options) => { + dispatch(reloadIndices(indexNames, options)); }, }; }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js index d966c39b76c174..f488290692e7ef 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js @@ -103,7 +103,11 @@ export class IndexTable extends Component { componentDidMount() { this.props.loadIndices(); this.interval = setInterval( - () => this.props.reloadIndices(this.props.indices.map((i) => i.name)), + () => + this.props.reloadIndices( + this.props.indices.map((i) => i.name), + { asSystemRequest: true } + ), REFRESH_RATE_INDEX_LIST ); const { location, filterChanged } = this.props; diff --git a/x-pack/plugins/index_management/public/application/services/api.ts b/x-pack/plugins/index_management/public/application/services/api.ts index ad080b0723b1c7..a7109854d676f3 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -40,6 +40,10 @@ import { useRequest, sendRequest } from './use_request'; import { httpService } from './http'; import { UiMetricService } from './ui_metric'; +interface ReloadIndicesOptions { + asSystemRequest?: boolean; +} + // Temporary hack to provide the uiMetricService instance to this file. // TODO: Refactor and export an ApiService instance through the app dependencies context let uiMetricService: UiMetricService; @@ -78,11 +82,17 @@ export async function loadIndices() { return response.data ? response.data : response; } -export async function reloadIndices(indexNames: string[]) { +export async function reloadIndices( + indexNames: string[], + { asSystemRequest }: ReloadIndicesOptions = {} +) { const body = JSON.stringify({ indexNames, }); - const response = await httpService.httpClient.post(`${API_BASE_PATH}/indices/reload`, { body }); + const response = await httpService.httpClient.post(`${API_BASE_PATH}/indices/reload`, { + body, + asSystemRequest, + }); return response.data ? response.data : response; } diff --git a/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js b/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js index 71838d61c20f8a..9498e55154839e 100644 --- a/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js +++ b/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js @@ -12,10 +12,10 @@ import { loadIndices } from './load_indices'; import { notificationService } from '../../services/notification'; export const reloadIndicesSuccess = createAction('INDEX_MANAGEMENT_RELOAD_INDICES_SUCCESS'); -export const reloadIndices = (indexNames) => async (dispatch) => { +export const reloadIndices = (indexNames, options) => async (dispatch) => { let indices; try { - indices = await request(indexNames); + indices = await request(indexNames, options); } catch (error) { // an index has been deleted // or the user does not have privileges for one of the indices on the current page, diff --git a/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js b/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js index 8f19fb6ab87be6..92a172f4ef3df9 100644 --- a/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js +++ b/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js @@ -44,7 +44,9 @@ export class SettingsChecker { async executeCheck() { try { - const { data } = await this.$http.get(this.getApi()); + const { data } = await this.$http.get(this.getApi(), { + headers: { 'kbn-system-request': 'true' }, + }); const { found, reason } = data; return { found, reason }; diff --git a/x-pack/plugins/monitoring/public/services/clusters.js b/x-pack/plugins/monitoring/public/services/clusters.js index 638b3a91b98744..71ae128072b7fa 100644 --- a/x-pack/plugins/monitoring/public/services/clusters.js +++ b/x-pack/plugins/monitoring/public/services/clusters.js @@ -38,14 +38,18 @@ export function monitoringClustersProvider($injector) { async function getClusters() { try { - const response = await $http.post(url, { - ccs, - timeRange: { - min: min.toISOString(), - max: max.toISOString(), + const response = await $http.post( + url, + { + ccs, + timeRange: { + min: min.toISOString(), + max: max.toISOString(), + }, + codePaths, }, - codePaths, - }); + { headers: { 'kbn-system-request': 'true' } } + ); return formatClusters(response.data); } catch (err) { const Private = $injector.get('Private'); diff --git a/x-pack/plugins/remote_clusters/public/application/services/api.js b/x-pack/plugins/remote_clusters/public/application/services/api.js index c0d21f577dae87..6dd04b70902830 100644 --- a/x-pack/plugins/remote_clusters/public/application/services/api.js +++ b/x-pack/plugins/remote_clusters/public/application/services/api.js @@ -9,8 +9,8 @@ import { UIM_CLUSTER_ADD, UIM_CLUSTER_UPDATE } from '../constants'; import { trackUserRequest } from './ui_metric'; import { sendGet, sendPost, sendPut, sendDelete } from './http'; -export async function loadClusters() { - return await sendGet(); +export async function loadClusters(options) { + return await sendGet(undefined, options); } export async function addCluster(cluster) { diff --git a/x-pack/plugins/remote_clusters/public/application/services/http.ts b/x-pack/plugins/remote_clusters/public/application/services/http.ts index 7f205023dfa8a6..831e706b5fa083 100644 --- a/x-pack/plugins/remote_clusters/public/application/services/http.ts +++ b/x-pack/plugins/remote_clusters/public/application/services/http.ts @@ -10,11 +10,15 @@ import { API_BASE_PATH } from '../../../common/constants'; let _httpClient: HttpSetup; +interface SendGetOptions { + asSystemRequest?: boolean; +} + export function init(httpClient: HttpSetup): void { _httpClient = httpClient; } -export function getFullPath(path: string): string { +export function getFullPath(path?: string): string { if (path) { return `${API_BASE_PATH}/${path}`; } @@ -35,8 +39,11 @@ export function sendPost( }); } -export function sendGet(path: string): Promise { - return _httpClient.get(getFullPath(path)); +export function sendGet( + path?: string, + { asSystemRequest }: SendGetOptions = {} +): Promise { + return _httpClient.get(getFullPath(path), { asSystemRequest }); } export function sendPut( diff --git a/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js b/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js index 8a765e171a8af2..3dae779f0dc789 100644 --- a/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js +++ b/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js @@ -14,7 +14,7 @@ import { REFRESH_CLUSTERS_SUCCESS } from '../action_types'; export const refreshClusters = () => async (dispatch) => { let clusters; try { - clusters = await sendLoadClustersRequest(); + clusters = await sendLoadClustersRequest({ asSystemRequest: true }); } catch (error) { return showApiWarning( error, diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js index e71b3b68702675..ce7e29af8323da 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js @@ -32,8 +32,8 @@ const mapDispatchToProps = (dispatch) => { loadJobs: () => { dispatch(loadJobs()); }, - refreshJobs: () => { - dispatch(refreshJobs()); + refreshJobs: (options) => { + dispatch(refreshJobs(options)); }, openDetailPanel: (jobId) => { dispatch(openDetailPanel({ jobId: jobId })); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js index d5038f40a686b4..589546a11ef38e 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js @@ -73,7 +73,10 @@ export class JobListUi extends Component { } componentDidMount() { - this.interval = setInterval(this.props.refreshJobs, REFRESH_RATE_MS); + this.interval = setInterval( + () => this.props.refreshJobs({ asSystemRequest: true }), + REFRESH_RATE_MS + ); } componentWillUnmount() { diff --git a/x-pack/plugins/rollup/public/crud_app/services/api.js b/x-pack/plugins/rollup/public/crud_app/services/api.js index 66efb6c2f09a0f..b12cc62c9daa89 100644 --- a/x-pack/plugins/rollup/public/crud_app/services/api.js +++ b/x-pack/plugins/rollup/public/crud_app/services/api.js @@ -19,8 +19,9 @@ import { trackUserRequest } from './track_ui_metric'; const apiPrefix = '/api/rollup'; -export async function loadJobs() { - const { jobs } = await getHttp().get(`${apiPrefix}/jobs`); +export async function loadJobs({ asSystemRequest } = {}) { + const fetchOptions = { asSystemRequest }; + const { jobs } = await getHttp().get(`${apiPrefix}/jobs`, fetchOptions); return jobs; } diff --git a/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js b/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js index 37b6e7a893fbe4..562341a020523a 100644 --- a/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js +++ b/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js @@ -10,10 +10,10 @@ import { i18n } from '@kbn/i18n'; import { loadJobs as sendLoadJobsRequest, deserializeJobs, showApiWarning } from '../../services'; import { REFRESH_JOBS_SUCCESS } from '../action_types'; -export const refreshJobs = () => async (dispatch) => { +export const refreshJobs = (options) => async (dispatch) => { let jobs; try { - jobs = await sendLoadJobsRequest(); + jobs = await sendLoadJobsRequest(options); } catch (error) { return showApiWarning( error, diff --git a/x-pack/plugins/saved_objects_tagging/common/types.ts b/x-pack/plugins/saved_objects_tagging/common/types.ts index bd65f74044bc10..c0b92a71a3d1b7 100644 --- a/x-pack/plugins/saved_objects_tagging/common/types.ts +++ b/x-pack/plugins/saved_objects_tagging/common/types.ts @@ -21,5 +21,6 @@ export type TagWithRelations = Tag & { export type { Tag, TagAttributes, + GetAllTagsOptions, ITagsClient, } from '../../../../src/plugins/saved_objects_tagging_oss/common'; diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.ts index 9821bfb3978027..d4e3f8678fe1f5 100644 --- a/x-pack/plugins/saved_objects_tagging/public/plugin.ts +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.ts @@ -66,7 +66,7 @@ export class SavedObjectTaggingPlugin public start({ http, application, overlays }: CoreStart) { this.tagCache = new TagsCache({ - refreshHandler: () => this.tagClient!.getAll(), + refreshHandler: () => this.tagClient!.getAll({ asSystemRequest: true }), refreshInterval: this.config.cacheRefreshInterval, }); this.tagClient = new TagsClient({ http, changeListener: this.tagCache }); diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts index 39e2df073591e0..24409e8596265e 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts @@ -156,7 +156,17 @@ describe('TagsClient', () => { await tagsClient.getAll(); expect(http.get).toHaveBeenCalledTimes(1); - expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`); + expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`, { + asSystemRequest: undefined, + }); + }); + it('allows `asSystemRequest` option to be set', async () => { + await tagsClient.getAll({ asSystemRequest: true }); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`, { + asSystemRequest: true, + }); }); it('returns the tag objects from the response', async () => { const tags = await tagsClient.getAll(); diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts index 8a99af7af6d024..ef484f0a550b1a 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts @@ -6,7 +6,13 @@ */ import { HttpSetup } from 'src/core/public'; -import { Tag, TagAttributes, ITagsClient, TagWithRelations } from '../../../common/types'; +import { + Tag, + TagAttributes, + GetAllTagsOptions, + ITagsClient, + TagWithRelations, +} from '../../../common/types'; import { ITagsChangeListener } from './tags_cache'; export interface TagsClientOptions { @@ -83,8 +89,12 @@ export class TagsClient implements ITagInternalClient { return tag; } - public async getAll() { - const { tags } = await this.http.get<{ tags: Tag[] }>('/api/saved_objects_tagging/tags'); + public async getAll({ asSystemRequest }: GetAllTagsOptions = {}) { + const fetchOptions = { asSystemRequest }; + const { tags } = await this.http.get<{ tags: Tag[] }>( + '/api/saved_objects_tagging/tags', + fetchOptions + ); trapErrors(() => { if (this.changeListener) { diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 580641cb86bc2a..388bc8b432fc4e 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -54,6 +54,10 @@ export interface FieldHistogramRequestConfig { type?: KBN_FIELD_TYPES; } +interface FetchOptions { + asSystemRequest?: boolean; +} + export const useApi = () => { const { http } = useAppDependencies(); @@ -68,9 +72,11 @@ export const useApi = () => { return e; } }, - async getTransforms(): Promise { + async getTransforms( + fetchOptions: FetchOptions = {} + ): Promise { try { - return await http.get(`${API_BASE_PATH}transforms`); + return await http.get(`${API_BASE_PATH}transforms`, fetchOptions); } catch (e) { return e; } @@ -84,9 +90,11 @@ export const useApi = () => { return e; } }, - async getTransformsStats(): Promise { + async getTransformsStats( + fetchOptions: FetchOptions = {} + ): Promise { try { - return await http.get(`${API_BASE_PATH}transforms/_stats`); + return await http.get(`${API_BASE_PATH}transforms/_stats`, fetchOptions); } catch (e) { return e; } diff --git a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts index 919131341cd5b9..dbb268b44cfd25 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts @@ -39,8 +39,9 @@ export const useGetTransforms = ( return; } - const transformConfigs = await api.getTransforms(); - const transformStats = await api.getTransformsStats(); + const fetchOptions = { asSystemRequest: true }; + const transformConfigs = await api.getTransforms(fetchOptions); + const transformStats = await api.getTransformsStats(fetchOptions); if ( !isGetTransformsResponseSchema(transformConfigs) || From 7f1071149da6358e3d543052932c0069a03ebcd8 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 16 Feb 2021 13:46:02 +0100 Subject: [PATCH 060/175] fix readonly error (#91104) --- x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts | 6 ++++++ x-pack/plugins/lens/server/routes/telemetry.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts b/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts index b09d757b371419..f010c0b8114b52 100644 --- a/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts +++ b/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts @@ -98,6 +98,12 @@ export class LensReportManager { this.write(); } catch (e) { // Silent error because events will be reported during the next timer + + // If posting stats is forbidden for the current user, stop attempting to send them, + // but keep them in storage to push in case the user logs in with sufficient permissions at some point. + if (e.response && e.response.status === 403) { + this.stop(); + } } } } diff --git a/x-pack/plugins/lens/server/routes/telemetry.ts b/x-pack/plugins/lens/server/routes/telemetry.ts index d4eec5beaba908..cb8cf4b15f8d90 100644 --- a/x-pack/plugins/lens/server/routes/telemetry.ts +++ b/x-pack/plugins/lens/server/routes/telemetry.ts @@ -9,6 +9,7 @@ import Boom from '@hapi/boom'; import { errors } from '@elastic/elasticsearch'; import { CoreSetup } from 'src/core/server'; import { schema } from '@kbn/config-schema'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { BASE_API_URL } from '../../common'; import { PluginStartContract } from '../plugin'; @@ -73,6 +74,9 @@ export async function initLensUsageRoute(setup: CoreSetup) return res.ok({ body: {} }); } catch (e) { + if (SavedObjectsErrorHelpers.isForbiddenError(e)) { + return res.forbidden(); + } if (e instanceof errors.ResponseError && e.statusCode === 404) { return res.notFound(); } From bfba5070a55a32197677b7364e0e913db7426619 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 16 Feb 2021 13:24:02 +0000 Subject: [PATCH 061/175] [ML] Adding index pattern runtime fields to anomaly detection wizards (#91168) * [ML] Adding index pattern runtime fields to anomaly detection wizards * hook refactor * small refactor of search json * fixing mml estimation error * changes based on review * sorting fields in metric selection * using useMemo rather than useState Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../ml/common/constants/aggregation_types.ts | 314 +++++++++++++++++ .../types/anomaly_detection_jobs/datafeed.ts | 3 +- x-pack/plugins/ml/common/types/fields.ts | 6 +- x-pack/plugins/ml/common/util/fields_utils.ts | 144 ++++++++ .../common/chart_loader/chart_loader.ts | 29 +- .../new_job/common/chart_loader/searches.ts | 5 +- .../new_job/common/job_creator/job_creator.ts | 90 +++-- .../util/model_memory_estimator.ts | 9 +- .../categorization_examples_loader.ts | 3 +- .../common/results_loader/results_loader.ts | 3 +- .../estimate_bucket_span.ts | 1 + .../multi_metric_view/metric_selection.tsx | 13 +- .../metric_selection_summary.tsx | 8 +- .../population_view/metric_selection.tsx | 13 +- .../metric_selection_summary.tsx | 5 +- .../single_metric_view/metric_selection.tsx | 11 +- .../metric_selection_summary.tsx | 3 +- .../components/split_field/by_field.tsx | 13 +- .../components/split_field/split_field.tsx | 13 +- .../services/ml_api_service/index.ts | 2 + .../services/ml_api_service/jobs.ts | 14 +- .../services/new_job_capabilities_service.ts | 6 +- .../bucket_span_estimator.d.ts | 2 + .../bucket_span_estimator.js | 24 +- .../bucket_span_estimator.test.ts | 1 + .../single_series_checker.js | 5 +- .../calculate_model_memory_limit.ts | 3 +- .../models/fields_service/fields_service.ts | 5 +- .../new_job/categorization/examples.ts | 11 +- .../models/job_service/new_job/line_chart.ts | 16 +- .../job_service/new_job/population_chart.ts | 17 +- .../job_service/new_job_caps/aggregations.ts | 325 ------------------ .../job_service/new_job_caps/field_service.ts | 126 +------ .../models/job_service/new_job_caps/rollup.ts | 5 +- .../plugins/ml/server/routes/job_service.ts | 12 +- .../routes/schemas/job_service_schema.ts | 2 + .../routes/schemas/job_validation_schema.ts | 1 + 37 files changed, 722 insertions(+), 541 deletions(-) create mode 100644 x-pack/plugins/ml/common/util/fields_utils.ts delete mode 100644 x-pack/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts diff --git a/x-pack/plugins/ml/common/constants/aggregation_types.ts b/x-pack/plugins/ml/common/constants/aggregation_types.ts index 7278f1de8b9a74..e5e1543c555f36 100644 --- a/x-pack/plugins/ml/common/constants/aggregation_types.ts +++ b/x-pack/plugins/ml/common/constants/aggregation_types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { Aggregation, METRIC_AGG_TYPE } from '../types/fields'; + export enum ML_JOB_AGGREGATION { // count COUNT = 'count', @@ -84,3 +86,315 @@ export enum ES_AGGREGATION { PERCENTILES = 'percentiles', CARDINALITY = 'cardinality', } + +// aggregation object missing id, title and fields and has null for kibana and dsl aggregation names. +// this is used as the basis for the ML only aggregations +function getBasicMlOnlyAggregation(): Omit { + return { + kibanaName: null, + dslName: null, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + }; +} + +// list of aggregations only support by ML and which don't have an equivalent ES aggregation +// note, not all aggs have a field list. Some aggs cannot be used with a field. +export const mlOnlyAggregations: Aggregation[] = [ + { + id: ML_JOB_AGGREGATION.NON_ZERO_COUNT, + title: 'Non zero count', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.HIGH_NON_ZERO_COUNT, + title: 'High non zero count', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LOW_NON_ZERO_COUNT, + title: 'Low non zero count', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.HIGH_DISTINCT_COUNT, + title: 'High distinct count', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LOW_DISTINCT_COUNT, + title: 'Low distinct count', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.METRIC, + title: 'Metric', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.VARP, + title: 'varp', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.HIGH_VARP, + title: 'High varp', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LOW_VARP, + title: 'Low varp', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.NON_NULL_SUM, + title: 'Non null sum', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.HIGH_NON_NULL_SUM, + title: 'High non null sum', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LOW_NON_NULL_SUM, + title: 'Low non null sum', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.RARE, + title: 'Rare', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.FREQ_RARE, + title: 'Freq rare', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.INFO_CONTENT, + title: 'Info content', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.HIGH_INFO_CONTENT, + title: 'High info content', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LOW_INFO_CONTENT, + title: 'Low info content', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.TIME_OF_DAY, + title: 'Time of day', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.TIME_OF_WEEK, + title: 'Time of week', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LAT_LONG, + title: 'Lat long', + fields: [], + ...getBasicMlOnlyAggregation(), + }, +]; + +export const aggregations: Aggregation[] = [ + { + id: ML_JOB_AGGREGATION.COUNT, + title: 'Count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + }, + { + id: ML_JOB_AGGREGATION.HIGH_COUNT, + title: 'High count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + }, + { + id: ML_JOB_AGGREGATION.LOW_COUNT, + title: 'Low count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + }, + { + id: ML_JOB_AGGREGATION.MEAN, + title: 'Mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_MEAN, + title: 'High mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_MEAN, + title: 'Low mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.SUM, + title: 'Sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_SUM, + title: 'High sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_SUM, + title: 'Low sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MEDIAN, + title: 'Median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_MEDIAN, + title: 'High median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_MEDIAN, + title: 'Low median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MIN, + title: 'Min', + kibanaName: KIBANA_AGGREGATION.MIN, + dslName: ES_AGGREGATION.MIN, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MIN, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MAX, + title: 'Max', + kibanaName: KIBANA_AGGREGATION.MAX, + dslName: ES_AGGREGATION.MAX, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MAX, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.DISTINCT_COUNT, + title: 'Distinct count', + kibanaName: KIBANA_AGGREGATION.CARDINALITY, + dslName: ES_AGGREGATION.CARDINALITY, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, +]; diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts index 77466d27415366..06938485649fb9 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts @@ -6,6 +6,7 @@ */ import { IndexPatternTitle } from '../kibana'; +import { RuntimeMappings } from '../fields'; import { JobId } from './job'; export type DatafeedId = string; @@ -21,7 +22,7 @@ export interface Datafeed { query: object; query_delay?: string; script_fields?: Record; - runtime_mappings?: Record; + runtime_mappings?: RuntimeMappings; scroll_size?: number; delayed_data_check_config?: object; indices_options?: IndicesOptions; diff --git a/x-pack/plugins/ml/common/types/fields.ts b/x-pack/plugins/ml/common/types/fields.ts index f12ed5b23542ae..ae157cef5735fc 100644 --- a/x-pack/plugins/ml/common/types/fields.ts +++ b/x-pack/plugins/ml/common/types/fields.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ES_FIELD_TYPES } from '../../../../../src/plugins/data/common'; +import { ES_FIELD_TYPES, RuntimeField } from '../../../../../src/plugins/data/common'; import { ML_JOB_AGGREGATION, KIBANA_AGGREGATION, @@ -27,6 +27,7 @@ export interface Field { aggregatable?: boolean; aggIds?: AggId[]; aggs?: Aggregation[]; + runtimeField?: RuntimeField; } export interface Aggregation { @@ -103,3 +104,6 @@ export interface ScriptAggCardinality { export interface AggCardinality { cardinality: FieldAggCardinality | ScriptAggCardinality; } + +export type RollupFields = Record]>; +export type RuntimeMappings = Record; diff --git a/x-pack/plugins/ml/common/util/fields_utils.ts b/x-pack/plugins/ml/common/util/fields_utils.ts new file mode 100644 index 00000000000000..98b0fcd6efd805 --- /dev/null +++ b/x-pack/plugins/ml/common/util/fields_utils.ts @@ -0,0 +1,144 @@ +/* + * 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 { + Field, + Aggregation, + NewJobCaps, + METRIC_AGG_TYPE, + RollupFields, + EVENT_RATE_FIELD_ID, +} from '../types/fields'; +import { ES_FIELD_TYPES } from '../../../../../src/plugins/data/common'; +import { ML_JOB_AGGREGATION } from '../constants/aggregation_types'; + +// cross reference fields and aggs. +// fields contain a list of aggs that are compatible, and vice versa. +export function combineFieldsAndAggs( + fields: Field[], + aggs: Aggregation[], + rollupFields: RollupFields +): NewJobCaps { + const keywordFields = getKeywordFields(fields); + const textFields = getTextFields(fields); + const numericalFields = getNumericalFields(fields); + const ipFields = getIpFields(fields); + const geoFields = getGeoFields(fields); + + const isRollup = Object.keys(rollupFields).length > 0; + const mix = mixFactory(isRollup, rollupFields); + + aggs.forEach((a) => { + if (a.type === METRIC_AGG_TYPE && a.fields !== undefined) { + switch (a.id) { + case ML_JOB_AGGREGATION.LAT_LONG: + geoFields.forEach((f) => mix(f, a)); + break; + case ML_JOB_AGGREGATION.INFO_CONTENT: + case ML_JOB_AGGREGATION.HIGH_INFO_CONTENT: + case ML_JOB_AGGREGATION.LOW_INFO_CONTENT: + textFields.forEach((f) => mix(f, a)); + case ML_JOB_AGGREGATION.DISTINCT_COUNT: + case ML_JOB_AGGREGATION.HIGH_DISTINCT_COUNT: + case ML_JOB_AGGREGATION.LOW_DISTINCT_COUNT: + // distinct count (i.e. cardinality) takes keywords, ips + // as well as numerical fields + keywordFields.forEach((f) => mix(f, a)); + ipFields.forEach((f) => mix(f, a)); + // note, no break to fall through to add numerical fields. + default: + // all other aggs take numerical fields + numericalFields.forEach((f) => { + mix(f, a); + }); + break; + } + } + }); + + return { + aggs, + fields: isRollup ? filterFields(fields) : fields, + }; +} + +// remove fields that have no aggs associated to them, unless they are date fields +function filterFields(fields: Field[]): Field[] { + return fields.filter( + (f) => f.aggs && (f.aggs.length > 0 || (f.aggs.length === 0 && f.type === ES_FIELD_TYPES.DATE)) + ); +} + +// returns a mix function that is used to cross-reference aggs and fields. +// wrapped in a provider to allow filtering based on rollup job capabilities +function mixFactory(isRollup: boolean, rollupFields: RollupFields) { + return function mix(field: Field, agg: Aggregation): void { + if ( + isRollup === false || + (rollupFields[field.id] && rollupFields[field.id].find((f) => f.agg === agg.dslName)) + ) { + if (field.aggs !== undefined) { + field.aggs.push(agg); + } + if (agg.fields !== undefined) { + agg.fields.push(field); + } + } + }; +} + +function getKeywordFields(fields: Field[]): Field[] { + return fields.filter((f) => f.type === ES_FIELD_TYPES.KEYWORD); +} + +function getTextFields(fields: Field[]): Field[] { + return fields.filter((f) => f.type === ES_FIELD_TYPES.TEXT); +} + +function getIpFields(fields: Field[]): Field[] { + return fields.filter((f) => f.type === ES_FIELD_TYPES.IP); +} + +function getNumericalFields(fields: Field[]): Field[] { + return fields.filter( + (f) => + f.type === ES_FIELD_TYPES.LONG || + f.type === ES_FIELD_TYPES.UNSIGNED_LONG || + f.type === ES_FIELD_TYPES.INTEGER || + f.type === ES_FIELD_TYPES.SHORT || + f.type === ES_FIELD_TYPES.BYTE || + f.type === ES_FIELD_TYPES.DOUBLE || + f.type === ES_FIELD_TYPES.FLOAT || + f.type === ES_FIELD_TYPES.HALF_FLOAT || + f.type === ES_FIELD_TYPES.SCALED_FLOAT + ); +} + +function getGeoFields(fields: Field[]): Field[] { + return fields.filter( + (f) => f.type === ES_FIELD_TYPES.GEO_POINT || f.type === ES_FIELD_TYPES.GEO_SHAPE + ); +} + +/** + * Sort fields by name, keeping event rate at the beginning + */ +export function sortFields(fields: Field[]) { + if (fields.length === 0) { + return fields; + } + + let eventRate: Field | undefined; + if (fields[0].id === EVENT_RATE_FIELD_ID) { + [eventRate] = fields.splice(0, 1); + } + fields.sort((a, b) => a.name.localeCompare(b.name)); + if (eventRate !== undefined) { + fields.splice(0, 0, eventRate); + } + return fields; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts index 1c56be94d5891e..a36e52f4e863b3 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts @@ -8,12 +8,17 @@ import memoizeOne from 'memoize-one'; import { isEqual } from 'lodash'; import { IndexPatternTitle } from '../../../../../../common/types/kibana'; -import { Field, SplitField, AggFieldPair } from '../../../../../../common/types/fields'; +import { + Field, + SplitField, + AggFieldPair, + RuntimeMappings, +} from '../../../../../../common/types/fields'; import { ml } from '../../../../services/ml_api_service'; import { mlResultsService } from '../../../../services/results_service'; import { getCategoryFields as getCategoryFieldsOrig } from './searches'; import { aggFieldPairsCanBeCharted } from '../job_creator/util/general'; -import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; +import { IndexPattern } from '../../../../../../../../../src/plugins/data/common'; type DetectorIndex = number; export interface LineChartPoint { @@ -50,7 +55,8 @@ export class ChartLoader { aggFieldPairs: AggFieldPair[], splitField: SplitField, splitFieldValue: SplitFieldValue, - intervalMs: number + intervalMs: number, + runtimeMappings: RuntimeMappings | null ): Promise { if (this._timeFieldName !== '') { if (aggFieldPairsCanBeCharted(aggFieldPairs) === false) { @@ -70,7 +76,8 @@ export class ChartLoader { this._query, aggFieldPairNames, splitFieldName, - splitFieldValue + splitFieldValue, + runtimeMappings ?? undefined ); return resp.results; @@ -83,7 +90,8 @@ export class ChartLoader { end: number, aggFieldPairs: AggFieldPair[], splitField: SplitField, - intervalMs: number + intervalMs: number, + runtimeMappings: RuntimeMappings | null ): Promise { if (this._timeFieldName !== '') { if (aggFieldPairsCanBeCharted(aggFieldPairs) === false) { @@ -102,7 +110,8 @@ export class ChartLoader { intervalMs, this._query, aggFieldPairNames, - splitFieldName + splitFieldName, + runtimeMappings ?? undefined ); return resp.results; @@ -136,12 +145,16 @@ export class ChartLoader { return []; } - async loadFieldExampleValues(field: Field): Promise { + async loadFieldExampleValues( + field: Field, + runtimeMappings: RuntimeMappings | null + ): Promise { const { results } = await getCategoryFields( this._indexPatternTitle, field.name, 10, - this._query + this._query, + runtimeMappings ?? undefined ); return results; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts index 1e7ee9ca45bf15..54917c4884f22a 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts @@ -8,6 +8,7 @@ import { get } from 'lodash'; import { ml } from '../../../../services/ml_api_service'; +import { RuntimeMappings } from '../../../../../../common/types/fields'; interface CategoryResults { success: boolean; @@ -18,7 +19,8 @@ export function getCategoryFields( indexPatternName: string, fieldName: string, size: number, - query: any + query: any, + runtimeMappings?: RuntimeMappings ): Promise { return new Promise((resolve, reject) => { ml.esSearch({ @@ -34,6 +36,7 @@ export function getCategoryFields( }, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }) .then((resp: any) => { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index 913832e2fb8a39..ca2c2204fb0c11 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -6,10 +6,15 @@ */ import { BehaviorSubject } from 'rxjs'; +import { cloneDeep } from 'lodash'; import { SavedSearchSavedObject } from '../../../../../../common/types/kibana'; import { UrlConfig } from '../../../../../../common/types/custom_urls'; import { IndexPatternTitle } from '../../../../../../common/types/kibana'; -import { ML_JOB_AGGREGATION } from '../../../../../../common/constants/aggregation_types'; +import { + ML_JOB_AGGREGATION, + aggregations, + mlOnlyAggregations, +} from '../../../../../../common/constants/aggregation_types'; import { ES_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/public'; import { Job, @@ -20,7 +25,8 @@ import { BucketSpan, CustomSettings, } from '../../../../../../common/types/anomaly_detection_jobs'; -import { Aggregation, Field } from '../../../../../../common/types/fields'; +import { Aggregation, Field, RuntimeMappings } from '../../../../../../common/types/fields'; +import { combineFieldsAndAggs } from '../../../../../../common/util/fields_utils'; import { createEmptyJob, createEmptyDatafeed } from './util/default_configs'; import { mlJobService } from '../../../../services/job_service'; import { JobRunner, ProgressSubscriber } from '../job_runner'; @@ -57,7 +63,8 @@ export class JobCreator { protected _aggs: Aggregation[] = []; protected _fields: Field[] = []; protected _scriptFields: Field[] = []; - protected _runtimeMappings: Field[] = []; + protected _runtimeFields: Field[] = []; + protected _runtimeMappings: RuntimeMappings | null = null; protected _aggregationFields: Field[] = []; protected _sparseData: boolean = false; private _stopAllRefreshPolls: { @@ -86,6 +93,8 @@ export class JobCreator { this._job_config.data_description.time_field = indexPattern.timeFieldName; } + this._extractRuntimeMappings(); + this._datafeed_config.query = query; } @@ -489,16 +498,20 @@ export class JobCreator { return this._scriptFields; } - public get runtimeMappings(): Field[] { + public get runtimeMappings(): RuntimeMappings | null { return this._runtimeMappings; } + public get runtimeFields(): Field[] { + return this._runtimeFields; + } + public get aggregationFields(): Field[] { return this._aggregationFields; } public get additionalFields(): Field[] { - return [...this._scriptFields, ...this._runtimeMappings, ...this._aggregationFields]; + return [...this._scriptFields, ...this._runtimeFields, ...this._aggregationFields]; } public get subscribers(): ProgressSubscriber[] { @@ -662,6 +675,52 @@ export class JobCreator { this._job_config.analysis_config.per_partition_categorization!.stop_on_warn = enabled; } + private _extractRuntimeMappings() { + const runtimeFieldMap = this._indexPattern.toSpec().runtimeFieldMap; + if (runtimeFieldMap !== undefined) { + if (this._datafeed_config.runtime_mappings === undefined) { + this._datafeed_config.runtime_mappings = {}; + } + Object.entries(runtimeFieldMap).forEach(([key, val]) => { + this._datafeed_config.runtime_mappings![key] = val; + }); + } + this._populateRuntimeFields(); + } + + private _populateRuntimeFields() { + this._runtimeFields = []; + this._runtimeMappings = this._datafeed_config.runtime_mappings ?? null; + if (this._runtimeMappings !== null) { + const tempRuntimeFields = Object.entries(this._runtimeMappings).map( + ([id, runtimeField]) => + ({ + id, + name: id, + type: runtimeField.type, + aggregatable: true, + aggs: [], + runtimeField, + } as Field) + ); + + const aggs = cloneDeep([...aggregations, ...mlOnlyAggregations]); + this._runtimeFields = combineFieldsAndAggs(tempRuntimeFields, aggs, {}).fields; + } + } + + private _populateScriptFields() { + this._scriptFields = []; + if (this._datafeed_config.script_fields !== undefined) { + this._scriptFields = Object.keys(this._datafeed_config.script_fields).map((f) => ({ + id: f, + name: f, + type: ES_FIELD_TYPES.KEYWORD, + aggregatable: true, + })); + } + } + protected _overrideConfigs(job: Job, datafeed: Datafeed) { this._job_config = job; this._datafeed_config = datafeed; @@ -683,25 +742,8 @@ export class JobCreator { this.useDedicatedIndex = true; } - this._scriptFields = []; - if (this._datafeed_config.script_fields !== undefined) { - this._scriptFields = Object.keys(this._datafeed_config.script_fields).map((f) => ({ - id: f, - name: f, - type: ES_FIELD_TYPES.KEYWORD, - aggregatable: true, - })); - } - - this._runtimeMappings = []; - if (this._datafeed_config.runtime_mappings !== undefined) { - this._runtimeMappings = Object.keys(this._datafeed_config.runtime_mappings).map((f) => ({ - id: f, - name: f, - type: ES_FIELD_TYPES.KEYWORD, - aggregatable: true, - })); - } + this._populateScriptFields(); + this._populateRuntimeFields(); this._aggregationFields = []; const aggs = getDatafeedAggregations(this._datafeed_config); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/model_memory_estimator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/model_memory_estimator.ts index 289bfb54b28551..1f0acfcbec5c86 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/model_memory_estimator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/model_memory_estimator.ts @@ -24,7 +24,11 @@ import { useEffect, useMemo } from 'react'; import { DEFAULT_MODEL_MEMORY_LIMIT } from '../../../../../../../common/constants/new_job'; import { ml } from '../../../../../services/ml_api_service'; import { JobValidator, VALIDATION_DELAY_MS } from '../../job_validator/job_validator'; -import { MLHttpFetchError, MLResponseError } from '../../../../../../../common/util/errors'; +import { + MLHttpFetchError, + MLResponseError, + extractErrorMessage, +} from '../../../../../../../common/util/errors'; import { useMlKibana } from '../../../../../contexts/kibana'; import { JobCreator } from '../job_creator'; @@ -121,8 +125,7 @@ export const useModelMemoryEstimator = ( title: i18n.translate('xpack.ml.newJob.wizard.estimateModelMemoryError', { defaultMessage: 'Model memory limit could not be calculated', }), - text: - error.body.attributes?.body.error.caused_by?.reason || error.body.message || undefined, + text: extractErrorMessage(error), }); }) ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts index b240d6f230b897..06d489ee5a4378 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts @@ -50,7 +50,8 @@ export class CategorizationExamplesLoader { this._timeFieldName, this._jobCreator.start, this._jobCreator.end, - analyzer + analyzer, + this._jobCreator.runtimeMappings ?? undefined ); return resp; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts index 86f7e494870bb3..c4365bd656f9ef 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts @@ -255,7 +255,8 @@ export class ResultsLoader { if (isMultiMetricJobCreator(this._jobCreator)) { if (this._jobCreator.splitField !== null) { const fieldValues = await this._chartLoader.loadFieldExampleValues( - this._jobCreator.splitField + this._jobCreator.splitField, + this._jobCreator.runtimeMappings ); if (fieldValues.length > 0) { this._detectorSplitFieldFilters = { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts index 1e5487057bfb57..f0932b09af46b7 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts @@ -40,6 +40,7 @@ export function useEstimateBucketSpan() { query: mlContext.combinedQuery, splitField: undefined, timeField: mlContext.currentIndexPattern.timeFieldName, + runtimeMappings: jobCreator.runtimeMappings ?? undefined, }; if ( diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx index 9497114d056bad..5bf4beacc1593c 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, FC, useContext, useEffect, useState, useMemo } from 'react'; import { JobCreatorContext } from '../../../job_creator_context'; import { MultiMetricJobCreator } from '../../../../../common/job_creator'; @@ -13,6 +13,7 @@ import { LineChartData } from '../../../../../common/chart_loader'; import { DropDownLabel, DropDownProps } from '../agg_select'; import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; import { AggFieldPair } from '../../../../../../../../../common/types/fields'; +import { sortFields } from '../../../../../../../../../common/util/fields_utils'; import { getChartSettings, defaultChartSettings } from '../../../charts/common/settings'; import { MetricSelector } from './metric_selector'; import { ChartGrid } from './chart_grid'; @@ -33,7 +34,10 @@ export const MultiMetricDetectors: FC = ({ setIsValid }) => { const jobCreator = jc as MultiMetricJobCreator; - const { fields } = newJobCapsService; + const fields = useMemo( + () => sortFields([...newJobCapsService.fields, ...jobCreator.runtimeFields]), + [] + ); const [selectedOptions, setSelectedOptions] = useState([]); const [aggFieldPairList, setAggFieldPairList] = useState( jobCreator.aggFieldPairs @@ -107,7 +111,7 @@ export const MultiMetricDetectors: FC = ({ setIsValid }) => { useEffect(() => { if (splitField !== null) { chartLoader - .loadFieldExampleValues(splitField) + .loadFieldExampleValues(splitField, jobCreator.runtimeMappings) .then(setFieldValues) .catch((error) => { getToastNotificationService().displayErrorToast(error); @@ -135,7 +139,8 @@ export const MultiMetricDetectors: FC = ({ setIsValid }) => { aggFieldPairList, jobCreator.splitField, fieldValues.length > 0 ? fieldValues[0] : null, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); setLineChartsData(resp); } catch (error) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx index fc94d2b0960124..11f2f60e17d3d4 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx @@ -41,7 +41,10 @@ export const MultiMetricDetectorsSummary: FC = () => { (async () => { if (jobCreator.splitField !== null) { try { - const tempFieldValues = await chartLoader.loadFieldExampleValues(jobCreator.splitField); + const tempFieldValues = await chartLoader.loadFieldExampleValues( + jobCreator.splitField, + jobCreator.runtimeMappings + ); setFieldValues(tempFieldValues); } catch (error) { getToastNotificationService().displayErrorToast(error); @@ -72,7 +75,8 @@ export const MultiMetricDetectorsSummary: FC = () => { jobCreator.aggFieldPairs, jobCreator.splitField, fieldValues.length > 0 ? fieldValues[0] : null, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); setLineChartsData(resp); } catch (error) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx index c1e291567ddc7a..aba2acfa41a859 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, FC, useContext, useEffect, useState, useReducer } from 'react'; +import React, { Fragment, FC, useContext, useEffect, useState, useReducer, useMemo } from 'react'; import { EuiHorizontalRule } from '@elastic/eui'; import { JobCreatorContext } from '../../../job_creator_context'; @@ -14,6 +14,7 @@ import { LineChartData } from '../../../../../common/chart_loader'; import { DropDownLabel, DropDownProps } from '../agg_select'; import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; import { Field, AggFieldPair } from '../../../../../../../../../common/types/fields'; +import { sortFields } from '../../../../../../../../../common/util/fields_utils'; import { getChartSettings, defaultChartSettings } from '../../../charts/common/settings'; import { MetricSelector } from './metric_selector'; import { SplitFieldSelector } from '../split_field'; @@ -36,7 +37,10 @@ export const PopulationDetectors: FC = ({ setIsValid }) => { } = useContext(JobCreatorContext); const jobCreator = jc as PopulationJobCreator; - const { fields } = newJobCapsService; + const fields = useMemo( + () => sortFields([...newJobCapsService.fields, ...jobCreator.runtimeFields]), + [] + ); const [selectedOptions, setSelectedOptions] = useState([]); const [aggFieldPairList, setAggFieldPairList] = useState( jobCreator.aggFieldPairs @@ -155,7 +159,8 @@ export const PopulationDetectors: FC = ({ setIsValid }) => { jobCreator.end, aggFieldPairList, jobCreator.splitField, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); setLineChartsData(resp); @@ -175,7 +180,7 @@ export const PopulationDetectors: FC = ({ setIsValid }) => { (async (index: number, field: Field) => { return { index, - fields: await chartLoader.loadFieldExampleValues(field), + fields: await chartLoader.loadFieldExampleValues(field, jobCreator.runtimeMappings), }; })(i, af.by.field) ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx index 0057b50e6de57c..c6150108911017 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx @@ -77,7 +77,8 @@ export const PopulationDetectorsSummary: FC = () => { jobCreator.end, aggFieldPairList, jobCreator.splitField, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); setLineChartsData(resp); @@ -97,7 +98,7 @@ export const PopulationDetectorsSummary: FC = () => { (async (index: number, field: Field) => { return { index, - fields: await chartLoader.loadFieldExampleValues(field), + fields: await chartLoader.loadFieldExampleValues(field, jobCreator.runtimeMappings), }; })(i, af.by.field) ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx index 56a81d7351ec5a..f4a907dcc6a493 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx @@ -5,13 +5,14 @@ * 2.0. */ -import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, FC, useContext, useEffect, useState, useMemo } from 'react'; import { JobCreatorContext } from '../../../job_creator_context'; import { SingleMetricJobCreator } from '../../../../../common/job_creator'; import { LineChartData } from '../../../../../common/chart_loader'; import { AggSelect, DropDownLabel, DropDownProps, createLabel } from '../agg_select'; import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; import { AggFieldPair } from '../../../../../../../../../common/types/fields'; +import { sortFields } from '../../../../../../../../../common/util/fields_utils'; import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart'; import { getChartSettings } from '../../../charts/common/settings'; import { getToastNotificationService } from '../../../../../../../services/toast_notification_service'; @@ -32,7 +33,10 @@ export const SingleMetricDetectors: FC = ({ setIsValid }) => { } = useContext(JobCreatorContext); const jobCreator = jc as SingleMetricJobCreator; - const { fields } = newJobCapsService; + const fields = useMemo( + () => sortFields([...newJobCapsService.fields, ...jobCreator.runtimeFields]), + [] + ); const [selectedOptions, setSelectedOptions] = useState( jobCreator.aggFieldPair !== null ? [{ label: createLabel(jobCreator.aggFieldPair) }] : [] ); @@ -88,7 +92,8 @@ export const SingleMetricDetectors: FC = ({ setIsValid }) => { [aggFieldPair], null, null, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); if (resp[DTR_IDX] !== undefined) { setLineChartData(resp); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx index 66209c31427e74..4d8fc5ef760848 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx @@ -58,7 +58,8 @@ export const SingleMetricDetectorsSummary: FC = () => { [jobCreator.aggFieldPair], null, null, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); if (resp[DTR_IDX] !== undefined) { setLineChartData(resp); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/by_field.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/by_field.tsx index f34695c8c49986..01c538f7ceb011 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/by_field.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/by_field.tsx @@ -5,13 +5,16 @@ * 2.0. */ -import React, { FC, useContext, useEffect, useState } from 'react'; +import React, { FC, useContext, useEffect, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { SplitFieldSelect } from './split_field_select'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field } from '../../../../../../../../../common/types/fields'; -import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { + newJobCapsService, + filterCategoryFields, +} from '../../../../../../../services/new_job_capabilities_service'; import { MultiMetricJobCreator, PopulationJobCreator } from '../../../../../common/job_creator'; interface Props { @@ -22,7 +25,11 @@ export const ByFieldSelector: FC = ({ detectorIndex }) => { const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); const jobCreator = jc as PopulationJobCreator; - const { categoryFields: allCategoryFields } = newJobCapsService; + const runtimeCategoryFields = useMemo(() => filterCategoryFields(jobCreator.runtimeFields), []); + const allCategoryFields = useMemo( + () => [...newJobCapsService.categoryFields, ...runtimeCategoryFields], + [] + ); const [byField, setByField] = useState(jobCreator.getByField(detectorIndex)); const categoryFields = useFilteredCategoryFields( diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field.tsx index d20f186b1d88c6..7a99d4da131854 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field.tsx @@ -5,11 +5,14 @@ * 2.0. */ -import React, { FC, useContext, useEffect, useState } from 'react'; +import React, { FC, useContext, useEffect, useState, useMemo } from 'react'; import { SplitFieldSelect } from './split_field_select'; import { JobCreatorContext } from '../../../job_creator_context'; -import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { + newJobCapsService, + filterCategoryFields, +} from '../../../../../../../services/new_job_capabilities_service'; import { Description } from './description'; import { MultiMetricJobCreator, @@ -23,7 +26,11 @@ export const SplitFieldSelector: FC = () => { const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator; const canClearSelection = isMultiMetricJobCreator(jc); - const { categoryFields } = newJobCapsService; + const runtimeCategoryFields = useMemo(() => filterCategoryFields(jobCreator.runtimeFields), []); + const categoryFields = useMemo( + () => [...newJobCapsService.categoryFields, ...runtimeCategoryFields], + [] + ); const [splitField, setSplitField] = useState(jobCreator.splitField); useEffect(() => { diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index befc1cff6e9fe5..8d0ecddaa97b8f 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -24,6 +24,7 @@ import { import { MlCapabilitiesResponse } from '../../../../common/types/capabilities'; import { Calendar, CalendarId, UpdateCalendar } from '../../../../common/types/calendars'; +import { RuntimeMappings } from '../../../../common/types/fields'; import { Job, JobStats, @@ -63,6 +64,7 @@ export interface BucketSpanEstimatorData { query: any; splitField: string | undefined; timeField: string | undefined; + runtimeMappings: RuntimeMappings | undefined; } export interface BucketSpanEstimatorResponse { diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index 400841587bf8c6..df72bd25c6bcd8 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -17,7 +17,7 @@ import type { Datafeed, } from '../../../../common/types/anomaly_detection_jobs'; import type { JobMessage } from '../../../../common/types/audit_message'; -import type { AggFieldNamePair } from '../../../../common/types/fields'; +import type { AggFieldNamePair, RuntimeMappings } from '../../../../common/types/fields'; import type { ExistingJobsAndGroups } from '../job_service'; import type { CategorizationAnalyzer, @@ -188,7 +188,8 @@ export const jobsApiProvider = (httpService: HttpService) => ({ query: any, aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, - splitFieldValue: string | null + splitFieldValue: string | null, + runtimeMappings?: RuntimeMappings ) { const body = JSON.stringify({ indexPatternTitle, @@ -200,6 +201,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ aggFieldNamePairs, splitFieldName, splitFieldValue, + runtimeMappings, }); return httpService.http({ path: `${ML_BASE_PATH}/jobs/new_job_line_chart`, @@ -216,7 +218,8 @@ export const jobsApiProvider = (httpService: HttpService) => ({ intervalMs: number, query: any, aggFieldNamePairs: AggFieldNamePair[], - splitFieldName: string + splitFieldName: string, + runtimeMappings?: RuntimeMappings ) { const body = JSON.stringify({ indexPatternTitle, @@ -227,6 +230,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ query, aggFieldNamePairs, splitFieldName, + runtimeMappings, }); return httpService.http({ path: `${ML_BASE_PATH}/jobs/new_job_population_chart`, @@ -263,7 +267,8 @@ export const jobsApiProvider = (httpService: HttpService) => ({ timeField: string, start: number, end: number, - analyzer: CategorizationAnalyzer + analyzer: CategorizationAnalyzer, + runtimeMappings?: RuntimeMappings ) { const body = JSON.stringify({ indexPatternTitle, @@ -274,6 +279,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ start, end, analyzer, + runtimeMappings, }); return httpService.http<{ examples: CategoryFieldExample[]; diff --git a/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts b/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts index bd5dfbd6160f34..b9520df4e710f3 100644 --- a/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts +++ b/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts @@ -86,7 +86,7 @@ class NewJobCapsService { } public get categoryFields(): Field[] { - return this._fields.filter((f) => categoryFieldTypes.includes(f.type)); + return filterCategoryFields(this._fields); } public async initializeFromIndexPattern( @@ -252,4 +252,8 @@ function processTextAndKeywordFields(fields: Field[]) { return { fieldsPreferringKeyword, fieldsPreferringText }; } +export function filterCategoryFields(fields: Field[]) { + return fields.filter((f) => categoryFieldTypes.includes(f.type)); +} + export const newJobCapsService = new NewJobCapsService(); diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts index 24743d3bc08749..40a6bd1decd974 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts @@ -7,6 +7,7 @@ import { IScopedClusterClient } from 'kibana/server'; import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; +import { RuntimeMappings } from '../../../common/types/fields'; export interface BucketSpanEstimatorData { aggTypes: Array; @@ -19,6 +20,7 @@ export interface BucketSpanEstimatorData { query: any; splitField: string | undefined; timeField: string | undefined; + runtimeMappings: RuntimeMappings | undefined; } export function estimateBucketSpanFactory({ diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js index 9639a6e1e1317b..79f48645d52f23 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js @@ -20,7 +20,7 @@ export function estimateBucketSpanFactory(client) { class BucketSpanEstimator { constructor( - { index, timeField, aggTypes, fields, duration, query, splitField }, + { index, timeField, aggTypes, fields, duration, query, splitField, runtimeMappings }, splitFieldValues, maxBuckets ) { @@ -38,6 +38,9 @@ export function estimateBucketSpanFactory(client) { minimumBucketSpanMS: 0, }; + this.runtimeMappings = + runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}; + // determine durations for bucket span estimation // taking into account the clusters' search.max_buckets settings // the polled_data_checker uses an aggregation interval of 1 minute @@ -85,7 +88,8 @@ export function estimateBucketSpanFactory(client) { this.fields[i], this.duration, this.query, - this.thresholds + this.thresholds, + this.runtimeMappings ), result: null, }); @@ -107,7 +111,8 @@ export function estimateBucketSpanFactory(client) { this.fields[i], this.duration, queryCopy, - this.thresholds + this.thresholds, + this.runtimeMappings ), result: null, }); @@ -241,7 +246,7 @@ export function estimateBucketSpanFactory(client) { } } - const getFieldCardinality = function (index, field) { + const getFieldCardinality = function (index, field, runtimeMappings) { return new Promise((resolve, reject) => { asCurrentUser .search({ @@ -255,6 +260,7 @@ export function estimateBucketSpanFactory(client) { }, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }) .then(({ body }) => { @@ -267,7 +273,7 @@ export function estimateBucketSpanFactory(client) { }); }; - const getRandomFieldValues = function (index, field, query) { + const getRandomFieldValues = function (index, field, query, runtimeMappings) { let fieldValues = []; return new Promise((resolve, reject) => { const NUM_PARTITIONS = 10; @@ -293,6 +299,7 @@ export function estimateBucketSpanFactory(client) { }, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }) .then(({ body }) => { @@ -379,7 +386,12 @@ export function estimateBucketSpanFactory(client) { // a partition has been selected, so we need to load some field values to use in the // bucket span tests. if (formConfig.splitField !== undefined) { - getRandomFieldValues(formConfig.index, formConfig.splitField, formConfig.query) + getRandomFieldValues( + formConfig.index, + formConfig.splitField, + formConfig.query, + formConfig.runtimeMappings + ) .then((splitFieldValues) => { runEstimator(splitFieldValues); }) diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts index 05a6ae85696d84..aa576d1f69915e 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts @@ -35,6 +35,7 @@ const formConfig: BucketSpanEstimatorData = { }, splitField: undefined, timeField: undefined, + runtimeMappings: undefined, }; describe('ML - BucketSpanEstimator', () => { diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js index 8564ddc72770dc..25c87c5c2acbf8 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js @@ -18,7 +18,7 @@ export function singleSeriesCheckerFactory({ asCurrentUser }) { const REF_DATA_INTERVAL = { name: '1h', ms: 3600000 }; class SingleSeriesChecker { - constructor(index, timeField, aggType, field, duration, query, thresholds) { + constructor(index, timeField, aggType, field, duration, query, thresholds, runtimeMappings) { this.index = index; this.timeField = timeField; this.aggType = aggType; @@ -31,7 +31,7 @@ export function singleSeriesCheckerFactory({ asCurrentUser }) { varDiff: 0, created: false, }; - + this.runtimeMappings = runtimeMappings; this.interval = null; } @@ -171,6 +171,7 @@ export function singleSeriesCheckerFactory({ asCurrentUser }) { }, }, }, + ...this.runtimeMappings, }; if (this.field !== null) { diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts index 7a022c0c1805cd..2efc2f905d9bb9 100644 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -116,7 +116,8 @@ const cardinalityCheckProvider = (client: IScopedClusterClient) => { timeFieldName, earliestMs, latestMs, - bucketSpan + bucketSpan, + datafeedConfig ); } diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts index 3c12aa8c75c039..56eddf9df2e04f 100644 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts +++ b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts @@ -298,13 +298,14 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { timeFieldName: string, earliestMs: number, latestMs: number, - interval: string | undefined + interval: string | undefined, + datafeedConfig?: Datafeed ): Promise<{ [key: string]: number }> { if (!interval) { throw Boom.badRequest('Interval is required to retrieve max bucket cardinalities.'); } - const aggregatableFields = await getAggregatableFields(index, fieldNames); + const aggregatableFields = await getAggregatableFields(index, fieldNames, datafeedConfig); if (aggregatableFields.length === 0) { return {}; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts index 41837dda29e3f0..63df425791e852 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts @@ -14,6 +14,7 @@ import { CategorizationAnalyzer, CategoryFieldExample, } from '../../../../../common/types/categories'; +import { RuntimeMappings } from '../../../../../common/types/fields'; import { ValidationResults } from './validation_results'; const CHUNK_SIZE = 100; @@ -32,7 +33,8 @@ export function categorizationExamplesProvider({ timeField: string | undefined, start: number, end: number, - analyzer: CategorizationAnalyzer + analyzer: CategorizationAnalyzer, + runtimeMappings: RuntimeMappings | undefined ): Promise<{ examples: CategoryFieldExample[]; error?: any }> { if (timeField !== undefined) { const range = { @@ -65,6 +67,7 @@ export function categorizationExamplesProvider({ _source: false, query, sort: ['_doc'], + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }); @@ -165,7 +168,8 @@ export function categorizationExamplesProvider({ timeField: string | undefined, start: number, end: number, - analyzer: CategorizationAnalyzer + analyzer: CategorizationAnalyzer, + runtimeMappings: RuntimeMappings | undefined ) { const resp = await categorizationExamples( indexPatternTitle, @@ -175,7 +179,8 @@ export function categorizationExamplesProvider({ timeField, start, end, - analyzer + analyzer, + runtimeMappings ); const { examples } = resp; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts index 4b367c1430f50d..c83485211b4553 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts @@ -7,7 +7,11 @@ import { get } from 'lodash'; import { IScopedClusterClient } from 'kibana/server'; -import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { + AggFieldNamePair, + EVENT_RATE_FIELD_ID, + RuntimeMappings, +} from '../../../../common/types/fields'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; type DtrIndex = number; @@ -34,7 +38,8 @@ export function newJobLineChartProvider({ asCurrentUser }: IScopedClusterClient) query: object, aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, - splitFieldValue: string | null + splitFieldValue: string | null, + runtimeMappings: RuntimeMappings | undefined ) { const json: object = getSearchJsonFromConfig( indexPatternTitle, @@ -45,7 +50,8 @@ export function newJobLineChartProvider({ asCurrentUser }: IScopedClusterClient) query, aggFieldNamePairs, splitFieldName, - splitFieldValue + splitFieldValue, + runtimeMappings ); const { body } = await asCurrentUser.search(json); @@ -103,7 +109,8 @@ function getSearchJsonFromConfig( query: any, aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, - splitFieldValue: string | null + splitFieldValue: string | null, + runtimeMappings: RuntimeMappings | undefined ): object { const json = { index: indexPatternTitle, @@ -125,6 +132,7 @@ function getSearchJsonFromConfig( aggs: {}, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts index 469ae39296f129..10f6d94e764ac3 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts @@ -7,7 +7,11 @@ import { get } from 'lodash'; import { IScopedClusterClient } from 'kibana/server'; -import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { + AggFieldNamePair, + EVENT_RATE_FIELD_ID, + RuntimeMappings, +} from '../../../../common/types/fields'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; const OVER_FIELD_EXAMPLES_COUNT = 40; @@ -39,7 +43,8 @@ export function newJobPopulationChartProvider({ asCurrentUser }: IScopedClusterC intervalMs: number, query: object, aggFieldNamePairs: AggFieldNamePair[], - splitFieldName: string | null + splitFieldName: string | null, + runtimeMappings: RuntimeMappings | undefined ) { const json: object = getPopulationSearchJsonFromConfig( indexPatternTitle, @@ -49,7 +54,8 @@ export function newJobPopulationChartProvider({ asCurrentUser }: IScopedClusterC intervalMs, query, aggFieldNamePairs, - splitFieldName + splitFieldName, + runtimeMappings ); const { body } = await asCurrentUser.search(json); @@ -131,7 +137,8 @@ function getPopulationSearchJsonFromConfig( intervalMs: number, query: any, aggFieldNamePairs: AggFieldNamePair[], - splitFieldName: string | null + splitFieldName: string | null, + runtimeMappings: RuntimeMappings | undefined ): object { const json = { index: indexPatternTitle, @@ -153,6 +160,7 @@ function getPopulationSearchJsonFromConfig( aggs: {}, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }; @@ -237,5 +245,6 @@ function getPopulationSearchJsonFromConfig( } else { json.body.aggs.times.aggs = aggs; } + return json; } diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts deleted file mode 100644 index eb407357bcda33..00000000000000 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts +++ /dev/null @@ -1,325 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Aggregation, METRIC_AGG_TYPE } from '../../../../common/types/fields'; -import { - ML_JOB_AGGREGATION, - KIBANA_AGGREGATION, - ES_AGGREGATION, -} from '../../../../common/constants/aggregation_types'; - -// aggregation object missing id, title and fields and has null for kibana and dsl aggregation names. -// this is used as the basis for the ML only aggregations -function getBasicMlOnlyAggregation(): Omit { - return { - kibanaName: null, - dslName: null, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - }; -} - -// list of aggregations only support by ML and which don't have an equivalent ES aggregation -// note, not all aggs have a field list. Some aggs cannot be used with a field. -export const mlOnlyAggregations: Aggregation[] = [ - { - id: ML_JOB_AGGREGATION.NON_ZERO_COUNT, - title: 'Non zero count', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.HIGH_NON_ZERO_COUNT, - title: 'High non zero count', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LOW_NON_ZERO_COUNT, - title: 'Low non zero count', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.HIGH_DISTINCT_COUNT, - title: 'High distinct count', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LOW_DISTINCT_COUNT, - title: 'Low distinct count', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.METRIC, - title: 'Metric', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.VARP, - title: 'varp', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.HIGH_VARP, - title: 'High varp', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LOW_VARP, - title: 'Low varp', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.NON_NULL_SUM, - title: 'Non null sum', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.HIGH_NON_NULL_SUM, - title: 'High non null sum', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LOW_NON_NULL_SUM, - title: 'Low non null sum', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.RARE, - title: 'Rare', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.FREQ_RARE, - title: 'Freq rare', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.INFO_CONTENT, - title: 'Info content', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.HIGH_INFO_CONTENT, - title: 'High info content', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LOW_INFO_CONTENT, - title: 'Low info content', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.TIME_OF_DAY, - title: 'Time of day', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.TIME_OF_WEEK, - title: 'Time of week', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LAT_LONG, - title: 'Lat long', - fields: [], - ...getBasicMlOnlyAggregation(), - }, -]; - -export const aggregations: Aggregation[] = [ - { - id: ML_JOB_AGGREGATION.COUNT, - title: 'Count', - kibanaName: KIBANA_AGGREGATION.COUNT, - dslName: ES_AGGREGATION.COUNT, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - }, - { - id: ML_JOB_AGGREGATION.HIGH_COUNT, - title: 'High count', - kibanaName: KIBANA_AGGREGATION.COUNT, - dslName: ES_AGGREGATION.COUNT, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - }, - { - id: ML_JOB_AGGREGATION.LOW_COUNT, - title: 'Low count', - kibanaName: KIBANA_AGGREGATION.COUNT, - dslName: ES_AGGREGATION.COUNT, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - }, - { - id: ML_JOB_AGGREGATION.MEAN, - title: 'Mean', - kibanaName: KIBANA_AGGREGATION.AVG, - dslName: ES_AGGREGATION.AVG, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.AVG, - min: KIBANA_AGGREGATION.AVG, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.HIGH_MEAN, - title: 'High mean', - kibanaName: KIBANA_AGGREGATION.AVG, - dslName: ES_AGGREGATION.AVG, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.AVG, - min: KIBANA_AGGREGATION.AVG, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.LOW_MEAN, - title: 'Low mean', - kibanaName: KIBANA_AGGREGATION.AVG, - dslName: ES_AGGREGATION.AVG, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.AVG, - min: KIBANA_AGGREGATION.AVG, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.SUM, - title: 'Sum', - kibanaName: KIBANA_AGGREGATION.SUM, - dslName: ES_AGGREGATION.SUM, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.SUM, - min: KIBANA_AGGREGATION.SUM, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.HIGH_SUM, - title: 'High sum', - kibanaName: KIBANA_AGGREGATION.SUM, - dslName: ES_AGGREGATION.SUM, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.SUM, - min: KIBANA_AGGREGATION.SUM, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.LOW_SUM, - title: 'Low sum', - kibanaName: KIBANA_AGGREGATION.SUM, - dslName: ES_AGGREGATION.SUM, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.SUM, - min: KIBANA_AGGREGATION.SUM, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.MEDIAN, - title: 'Median', - kibanaName: KIBANA_AGGREGATION.MEDIAN, - dslName: ES_AGGREGATION.PERCENTILES, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.HIGH_MEDIAN, - title: 'High median', - kibanaName: KIBANA_AGGREGATION.MEDIAN, - dslName: ES_AGGREGATION.PERCENTILES, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.LOW_MEDIAN, - title: 'Low median', - kibanaName: KIBANA_AGGREGATION.MEDIAN, - dslName: ES_AGGREGATION.PERCENTILES, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.MIN, - title: 'Min', - kibanaName: KIBANA_AGGREGATION.MIN, - dslName: ES_AGGREGATION.MIN, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MIN, - min: KIBANA_AGGREGATION.MIN, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.MAX, - title: 'Max', - kibanaName: KIBANA_AGGREGATION.MAX, - dslName: ES_AGGREGATION.MAX, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MAX, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.DISTINCT_COUNT, - title: 'Distinct count', - kibanaName: KIBANA_AGGREGATION.CARDINALITY, - dslName: ES_AGGREGATION.CARDINALITY, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - fields: [], - }, -]; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts index 289c0118dce4e4..7ce54cd2f9c5e2 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -8,17 +8,11 @@ import { IScopedClusterClient } from 'kibana/server'; import { cloneDeep } from 'lodash'; import { SavedObjectsClientContract } from 'kibana/server'; -import { - Field, - Aggregation, - FieldId, - NewJobCaps, - METRIC_AGG_TYPE, -} from '../../../../common/types/fields'; -import { ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/server'; -import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; -import { rollupServiceProvider, RollupJob, RollupFields } from './rollup'; -import { aggregations, mlOnlyAggregations } from './aggregations'; +import { Field, FieldId, NewJobCaps, RollupFields } from '../../../../common/types/fields'; +import { ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/common'; +import { combineFieldsAndAggs } from '../../../../common/util/fields_utils'; +import { rollupServiceProvider, RollupJob } from './rollup'; +import { aggregations, mlOnlyAggregations } from '../../../../common/constants/aggregation_types'; const supportedTypes: string[] = [ ES_FIELD_TYPES.DATE, @@ -133,85 +127,10 @@ class FieldsService { const aggs = cloneDeep([...aggregations, ...mlOnlyAggregations]); const fields: Field[] = await this.createFields(); - return await combineFieldsAndAggs(fields, aggs, rollupFields); + return combineFieldsAndAggs(fields, aggs, rollupFields); } } -// cross reference fields and aggs. -// fields contain a list of aggs that are compatible, and vice versa. -async function combineFieldsAndAggs( - fields: Field[], - aggs: Aggregation[], - rollupFields: RollupFields -): Promise { - const keywordFields = getKeywordFields(fields); - const textFields = getTextFields(fields); - const numericalFields = getNumericalFields(fields); - const ipFields = getIpFields(fields); - const geoFields = getGeoFields(fields); - - const isRollup = Object.keys(rollupFields).length > 0; - const mix = mixFactory(isRollup, rollupFields); - - aggs.forEach((a) => { - if (a.type === METRIC_AGG_TYPE && a.fields !== undefined) { - switch (a.id) { - case ML_JOB_AGGREGATION.LAT_LONG: - geoFields.forEach((f) => mix(f, a)); - break; - case ML_JOB_AGGREGATION.INFO_CONTENT: - case ML_JOB_AGGREGATION.HIGH_INFO_CONTENT: - case ML_JOB_AGGREGATION.LOW_INFO_CONTENT: - textFields.forEach((f) => mix(f, a)); - case ML_JOB_AGGREGATION.DISTINCT_COUNT: - case ML_JOB_AGGREGATION.HIGH_DISTINCT_COUNT: - case ML_JOB_AGGREGATION.LOW_DISTINCT_COUNT: - // distinct count (i.e. cardinality) takes keywords, ips - // as well as numerical fields - keywordFields.forEach((f) => mix(f, a)); - ipFields.forEach((f) => mix(f, a)); - // note, no break to fall through to add numerical fields. - default: - // all other aggs take numerical fields - numericalFields.forEach((f) => { - mix(f, a); - }); - break; - } - } - }); - - return { - aggs, - fields: isRollup ? filterFields(fields) : fields, - }; -} - -// remove fields that have no aggs associated to them, unless they are date fields -function filterFields(fields: Field[]): Field[] { - return fields.filter( - (f) => f.aggs && (f.aggs.length > 0 || (f.aggs.length === 0 && f.type === ES_FIELD_TYPES.DATE)) - ); -} - -// returns a mix function that is used to cross-reference aggs and fields. -// wrapped in a provider to allow filtering based on rollup job capabilities -function mixFactory(isRollup: boolean, rollupFields: RollupFields) { - return function mix(field: Field, agg: Aggregation): void { - if ( - isRollup === false || - (rollupFields[field.id] && rollupFields[field.id].find((f) => f.agg === agg.dslName)) - ) { - if (field.aggs !== undefined) { - field.aggs.push(agg); - } - if (agg.fields !== undefined) { - agg.fields.push(field); - } - } - }; -} - function combineAllRollupFields(rollupConfigs: RollupJob[]): RollupFields { const rollupFields: RollupFields = {}; rollupConfigs.forEach((conf) => { @@ -230,36 +149,3 @@ function combineAllRollupFields(rollupConfigs: RollupJob[]): RollupFields { }); return rollupFields; } - -function getKeywordFields(fields: Field[]): Field[] { - return fields.filter((f) => f.type === ES_FIELD_TYPES.KEYWORD); -} - -function getTextFields(fields: Field[]): Field[] { - return fields.filter((f) => f.type === ES_FIELD_TYPES.TEXT); -} - -function getIpFields(fields: Field[]): Field[] { - return fields.filter((f) => f.type === ES_FIELD_TYPES.IP); -} - -function getNumericalFields(fields: Field[]): Field[] { - return fields.filter( - (f) => - f.type === ES_FIELD_TYPES.LONG || - f.type === ES_FIELD_TYPES.UNSIGNED_LONG || - f.type === ES_FIELD_TYPES.INTEGER || - f.type === ES_FIELD_TYPES.SHORT || - f.type === ES_FIELD_TYPES.BYTE || - f.type === ES_FIELD_TYPES.DOUBLE || - f.type === ES_FIELD_TYPES.FLOAT || - f.type === ES_FIELD_TYPES.HALF_FLOAT || - f.type === ES_FIELD_TYPES.SCALED_FLOAT - ); -} - -function getGeoFields(fields: Field[]): Field[] { - return fields.filter( - (f) => f.type === ES_FIELD_TYPES.GEO_POINT || f.type === ES_FIELD_TYPES.GEO_SHAPE - ); -} diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts index e3f1b5939a9077..3b480bae2199eb 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts @@ -9,10 +9,7 @@ import { IScopedClusterClient } from 'kibana/server'; import { SavedObject } from 'kibana/server'; import { IndexPatternAttributes } from 'src/plugins/data/server'; import { SavedObjectsClientContract } from 'kibana/server'; -import { FieldId } from '../../../../common/types/fields'; -import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; - -export type RollupFields = Record]>; +import { RollupFields } from '../../../../common/types/fields'; export interface RollupJob { job_id: string; diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index eb142542944ad2..1e028dfb20b4d9 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -534,6 +534,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { aggFieldNamePairs, splitFieldName, splitFieldValue, + runtimeMappings, } = request.body; const { newJobLineChart } = jobServiceProvider(client, mlClient); @@ -546,7 +547,8 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { query, aggFieldNamePairs, splitFieldName, - splitFieldValue + splitFieldValue, + runtimeMappings ); return response.ok({ @@ -588,6 +590,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { query, aggFieldNamePairs, splitFieldName, + runtimeMappings, } = request.body; const { newJobPopulationChart } = jobServiceProvider(client, mlClient); @@ -599,7 +602,8 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { intervalMs, query, aggFieldNamePairs, - splitFieldName + splitFieldName, + runtimeMappings ); return response.ok({ @@ -705,6 +709,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { start, end, analyzer, + runtimeMappings, } = request.body; const resp = await validateCategoryExamples( @@ -715,7 +720,8 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { timeField, start, end, - analyzer + analyzer, + runtimeMappings ); return response.ok({ diff --git a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts index afd56c0067e4de..65955fbc47a372 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts @@ -16,6 +16,7 @@ export const categorizationFieldExamplesSchema = { start: schema.number(), end: schema.number(), analyzer: schema.any(), + runtimeMappings: schema.maybe(schema.any()), }; export const chartSchema = { @@ -28,6 +29,7 @@ export const chartSchema = { aggFieldNamePairs: schema.arrayOf(schema.any()), splitFieldName: schema.maybe(schema.nullable(schema.string())), splitFieldValue: schema.maybe(schema.nullable(schema.string())), + runtimeMappings: schema.maybe(schema.any()), }; export const datafeedIdsSchema = schema.object({ diff --git a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts index feee5a49ed2cad..8c054d54e0589a 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts @@ -18,6 +18,7 @@ export const estimateBucketSpanSchema = schema.object({ query: schema.any(), splitField: schema.maybe(schema.string()), timeField: schema.maybe(schema.string()), + runtimeMappings: schema.maybe(schema.any()), }); export const modelMemoryLimitSchema = schema.object({ From 7fc56131c0cb36371bd57386f240f53244a14e9a Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Tue, 16 Feb 2021 13:27:25 +0000 Subject: [PATCH 062/175] [Discover] Making source filters test run with fields API (#91404) --- test/functional/apps/discover/_source_filters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_source_filters.ts b/test/functional/apps/discover/_source_filters.ts index 273ccbd8bf5af1..4161f7f289dbfd 100644 --- a/test/functional/apps/discover/_source_filters.ts +++ b/test/functional/apps/discover/_source_filters.ts @@ -30,7 +30,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.loadIfNeeded('logstash_functional'); await kibanaServer.uiSettings.update({ - 'discover:searchFieldsFromSource': true, + 'discover:searchFieldsFromSource': false, }); log.debug('discover'); From 3a05b681ae15b730234f55564862fb53203b6e61 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Tue, 16 Feb 2021 14:49:07 +0000 Subject: [PATCH 063/175] [Docs][Alerting] fixed link to TM settings (#91368) Fixed link to TM settings doc --- docs/setup/settings.asciidoc | 1 + docs/user/alerting/alerting-production-considerations.asciidoc | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index b57152646dda16..52966bf5ac8c93 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -684,3 +684,4 @@ include::secure-settings.asciidoc[] include::{kib-repo-dir}/settings/security-settings.asciidoc[] include::{kib-repo-dir}/settings/spaces-settings.asciidoc[] include::{kib-repo-dir}/settings/telemetry-settings.asciidoc[] +include::{kib-repo-dir}/settings/task-manager-settings.asciidoc[] diff --git a/docs/user/alerting/alerting-production-considerations.asciidoc b/docs/user/alerting/alerting-production-considerations.asciidoc index cc7adc87b150ef..0442b760669cc6 100644 --- a/docs/user/alerting/alerting-production-considerations.asciidoc +++ b/docs/user/alerting/alerting-production-considerations.asciidoc @@ -25,7 +25,7 @@ Because by default tasks are polled at 3 second intervals and only 10 tasks can * Many alerts or actions must be *run at once*. In this case pending tasks will queue in {es}, and be pulled 10 at a time from the queue at 3 second intervals. * *Long running tasks* occupy slots for an extended time, leaving fewer slots for other tasks. -For details on the settings that can influence the performance and throughput of Task Manager, see {task-manager-settings}. +For details on the settings that can influence the performance and throughput of Task Manager, see <>. ============================================== From aa1f02489e8e14d348e0fc6b16f583b6b594bec9 Mon Sep 17 00:00:00 2001 From: Kerry Gallagher Date: Tue, 16 Feb 2021 14:50:21 +0000 Subject: [PATCH 064/175] [Logs UI] Check for privileges with user management links (#91134) * Wrap users management link with privileges check --- .../log_analysis_setup/user_management_link.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx index 24179768604c45..3b0eb6fa898567 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx @@ -9,12 +9,23 @@ import { EuiButton, EuiButtonProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { useLinkProps } from '../../../hooks/use_link_props'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; export const UserManagementLink: React.FunctionComponent = (props) => { + const { + services: { + application: { capabilities }, + }, + } = useKibanaContextForPlugin(); + const canAccessUserManagement = capabilities?.management?.security?.users ?? false; + const linkProps = useLinkProps({ app: 'management', pathname: '/security/users', }); + + if (!canAccessUserManagement) return null; + return ( Date: Tue, 16 Feb 2021 14:50:33 +0000 Subject: [PATCH 065/175] chore(NA): assure bazel bin is available on kbn clean and reset commands (#91406) --- packages/kbn-pm/dist/index.js | 26 ++++++++++++------- packages/kbn-pm/src/commands/clean.ts | 8 +++--- packages/kbn-pm/src/commands/reset.ts | 26 ++++++++++++------- .../kbn-pm/src/utils/bazel/install_tools.ts | 2 +- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 375ad634cbc15d..1b8bd4784e5833 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -48005,6 +48005,8 @@ __webpack_require__.r(__webpack_exports__); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getBazelRepositoryCacheFolder", function() { return _get_cache_folders__WEBPACK_IMPORTED_MODULE_0__["getBazelRepositoryCacheFolder"]; }); /* harmony import */ var _install_tools__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(373); +/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "isBazelBinAvailable", function() { return _install_tools__WEBPACK_IMPORTED_MODULE_1__["isBazelBinAvailable"]; }); + /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "installBazelTools", function() { return _install_tools__WEBPACK_IMPORTED_MODULE_1__["installBazelTools"]; }); /* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(374); @@ -48064,6 +48066,7 @@ async function getBazelRepositoryCacheFolder() { "use strict"; __webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "isBazelBinAvailable", function() { return isBazelBinAvailable; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "installBazelTools", function() { return installBazelTools; }); /* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); /* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(dedent__WEBPACK_IMPORTED_MODULE_0__); @@ -54434,8 +54437,10 @@ const CleanCommand = { } // Runs Bazel soft clean - await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["runBazel"])(['clean']); - _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Soft cleaned bazel'); + if (await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["isBazelBinAvailable"])()) { + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["runBazel"])(['clean']); + _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Soft cleaned bazel'); + } if (toDelete.length === 0) { _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Nothing to delete'); @@ -59124,16 +59129,19 @@ const ResetCommand = { pattern: extraPatterns }); } - } // Runs Bazel hard clean + } // Runs Bazel hard clean and deletes Bazel Cache Folders - await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["runBazel"])(['clean', '--expunge']); - _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Hard cleaned bazel'); // Deletes Bazel Cache Folders + if (await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["isBazelBinAvailable"])()) { + // Hard cleaning bazel + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["runBazel"])(['clean', '--expunge']); + _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Hard cleaned bazel'); // Deletes Bazel Cache Folders - await del__WEBPACK_IMPORTED_MODULE_1___default()([await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["getBazelDiskCacheFolder"])(), await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["getBazelRepositoryCacheFolder"])()], { - force: true - }); - _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Removed disk caches'); + await del__WEBPACK_IMPORTED_MODULE_1___default()([await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["getBazelDiskCacheFolder"])(), await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["getBazelRepositoryCacheFolder"])()], { + force: true + }); + _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Removed disk caches'); + } if (toDelete.length === 0) { return; diff --git a/packages/kbn-pm/src/commands/clean.ts b/packages/kbn-pm/src/commands/clean.ts index 23a2c382fbccc7..a742d6d4e68dfe 100644 --- a/packages/kbn-pm/src/commands/clean.ts +++ b/packages/kbn-pm/src/commands/clean.ts @@ -11,7 +11,7 @@ import del from 'del'; import ora from 'ora'; import { join, relative } from 'path'; -import { runBazel } from '../utils/bazel'; +import { isBazelBinAvailable, runBazel } from '../utils/bazel'; import { isDirectory } from '../utils/fs'; import { log } from '../utils/log'; import { ICommand } from './'; @@ -53,8 +53,10 @@ export const CleanCommand: ICommand = { } // Runs Bazel soft clean - await runBazel(['clean']); - log.success('Soft cleaned bazel'); + if (await isBazelBinAvailable()) { + await runBazel(['clean']); + log.success('Soft cleaned bazel'); + } if (toDelete.length === 0) { log.success('Nothing to delete'); diff --git a/packages/kbn-pm/src/commands/reset.ts b/packages/kbn-pm/src/commands/reset.ts index 71fe7a8c7a6e3c..9eae12a15b164e 100644 --- a/packages/kbn-pm/src/commands/reset.ts +++ b/packages/kbn-pm/src/commands/reset.ts @@ -11,7 +11,12 @@ import del from 'del'; import ora from 'ora'; import { join, relative } from 'path'; -import { getBazelDiskCacheFolder, getBazelRepositoryCacheFolder, runBazel } from '../utils/bazel'; +import { + getBazelDiskCacheFolder, + getBazelRepositoryCacheFolder, + isBazelBinAvailable, + runBazel, +} from '../utils/bazel'; import { isDirectory } from '../utils/fs'; import { log } from '../utils/log'; import { ICommand } from './'; @@ -52,15 +57,18 @@ export const ResetCommand: ICommand = { } } - // Runs Bazel hard clean - await runBazel(['clean', '--expunge']); - log.success('Hard cleaned bazel'); + // Runs Bazel hard clean and deletes Bazel Cache Folders + if (await isBazelBinAvailable()) { + // Hard cleaning bazel + await runBazel(['clean', '--expunge']); + log.success('Hard cleaned bazel'); - // Deletes Bazel Cache Folders - await del([await getBazelDiskCacheFolder(), await getBazelRepositoryCacheFolder()], { - force: true, - }); - log.success('Removed disk caches'); + // Deletes Bazel Cache Folders + await del([await getBazelDiskCacheFolder(), await getBazelRepositoryCacheFolder()], { + force: true, + }); + log.success('Removed disk caches'); + } if (toDelete.length === 0) { return; diff --git a/packages/kbn-pm/src/utils/bazel/install_tools.ts b/packages/kbn-pm/src/utils/bazel/install_tools.ts index 93acbe09b4eab6..8f634726b5ab25 100644 --- a/packages/kbn-pm/src/utils/bazel/install_tools.ts +++ b/packages/kbn-pm/src/utils/bazel/install_tools.ts @@ -26,7 +26,7 @@ async function readBazelToolsVersionFile(repoRootPath: string, versionFilename: return version; } -async function isBazelBinAvailable() { +export async function isBazelBinAvailable() { try { await spawn('bazel', ['--version'], { stdio: 'pipe' }); From f4714d166732e3547ea1b0f684ff75a9ddf3d7aa Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 16 Feb 2021 10:00:00 -0500 Subject: [PATCH 066/175] [TSVB] Add a new "Series Agg" to count the number of series (#91225) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/components/aggs/series_agg.js | 7 ++++ .../response_processors/series/_series_agg.js | 10 +++++ .../series/_series_agg.test.js | 38 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/series_agg.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/series_agg.js index 0d0a964f3c1b60..c6afbaaee47daf 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/series_agg.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/series_agg.js @@ -61,6 +61,13 @@ function SeriesAggUi(props) { }), value: 'mean', }, + { + label: intl.formatMessage({ + id: 'visTypeTimeseries.seriesAgg.functionOptions.countLabel', + defaultMessage: 'Series count', + }), + value: 'count', + }, { label: intl.formatMessage({ id: 'visTypeTimeseries.seriesAgg.functionOptions.overallSumLabel', diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.js index fd8ec0d5a439a1..9ca5ffdfd1c278 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.js @@ -49,6 +49,16 @@ export const SeriesAgg = { }); return [data]; }, + count(targetSeries) { + const data = []; + _.zip(...targetSeries).forEach((row) => { + const key = row[0][0]; + // Filter out undefined or null values + const values = row.map((r) => r && r[1]).filter((v) => v || typeof v === 'number'); + data.push([key, values.length]); + }); + return [data]; + }, overall_max: overall('max'), overall_min: overall('min'), diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.test.js index 3952ecd3edd617..6201e718d42442 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.test.js @@ -67,6 +67,44 @@ describe('seriesAgg', () => { ], ]); }); + + test('returns the count of series', () => { + expect(seriesAgg.count(series)).toEqual([ + [ + [0, 3], + [1, 3], + [2, 3], + ], + ]); + }); + + test('returns the count of missing series', () => { + expect( + seriesAgg.count([ + [ + [0, null], + [1, null], + [2, 0], + ], + [ + [0, 0], + [1, null], + [2, 3], + ], + [ + [0, 2], + [1, null], + [2, 3], + ], + ]) + ).toEqual([ + [ + [0, 2], + [1, 0], + [2, 3], + ], + ]); + }); }); describe('overall', () => { From bb653a40ae74dfb085aeb1a803ff16a6daa33c9c Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Tue, 16 Feb 2021 09:05:01 -0600 Subject: [PATCH 067/175] [ML] Fix import missing range for File Data Visualizer, Discover card visible when disabled, texts (#91352) This PR fixes several issues related to the Data Visualizer --- .../components/import_view/import_view.js | 1 + .../results_links/results_links.tsx | 28 ++++++++++- .../actions_panel/actions_panel.tsx | 47 ++++++------------- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../apps/ml/permissions/full_ml_access.ts | 23 +++++---- .../apps/ml/permissions/no_ml_access.ts | 11 +++-- .../apps/ml/permissions/read_ml_access.ts | 23 +++++---- .../ml/data_visualizer_index_based.ts | 8 ++++ .../apps/ml/permissions/full_ml_access.ts | 19 +++++--- .../apps/ml/permissions/no_ml_access.ts | 11 +++-- .../apps/ml/permissions/read_ml_access.ts | 19 +++++--- 12 files changed, 118 insertions(+), 76 deletions(-) diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js index e22cca2746f99d..0aadf9e17f30db 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js @@ -582,6 +582,7 @@ export class ImportView extends Component { = ({ + fieldStats, index, indexPatternId, timeFieldName, @@ -55,7 +58,7 @@ export const ResultsLinks: FC = ({ const { services: { - application: { getUrlForApp }, + application: { getUrlForApp, capabilities }, share: { urlGenerators: { getUrlGenerator }, }, @@ -66,6 +69,11 @@ export const ResultsLinks: FC = ({ let unmounted = false; const getDiscoverUrl = async (): Promise => { + const isDiscoverAvailable = capabilities.discover?.show ?? false; + if (!isDiscoverAvailable) { + return; + } + const state: DiscoverUrlGeneratorState = { indexPatternId, }; @@ -133,7 +141,7 @@ export const ResultsLinks: FC = ({ return () => { unmounted = true; }; - }, [indexPatternId, getUrlGenerator]); + }, [indexPatternId, getUrlGenerator, JSON.stringify(globalState)]); useEffect(() => { setShowCreateJobLink(checkPermission('canCreateJob') && mlNodesAvailable()); @@ -150,6 +158,22 @@ export const ResultsLinks: FC = ({ setGlobalState(_globalState); }, [duration]); + useEffect(() => { + // Update the global time range from known timeFieldName if stats is available + if ( + fieldStats && + typeof fieldStats === 'object' && + timeFieldName !== undefined && + fieldStats.hasOwnProperty(timeFieldName) && + fieldStats[timeFieldName].earliest !== undefined && + fieldStats[timeFieldName].latest !== undefined + ) { + setGlobalState({ + time: { from: fieldStats[timeFieldName].earliest!, to: fieldStats[timeFieldName].latest! }, + }); + } + }, [timeFieldName, fieldStats]); + async function updateTimeValues(recheck = true) { if (timeFieldName !== undefined) { const { from, to } = await getFullTimeRange(index, timeFieldName); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx index 850367fc1a65a0..ca393c2d8ce725 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx @@ -9,7 +9,7 @@ import React, { FC, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiText, EuiTitle, EuiFlexGroup } from '@elastic/eui'; +import { EuiSpacer, EuiTitle, EuiFlexGroup } from '@elastic/eui'; import { LinkCard } from '../../../../components/link_card'; import { DataRecognizer } from '../../../../components/data_recognizer'; import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; @@ -35,6 +35,7 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer const [discoverLink, setDiscoverLink] = useState(''); const { services: { + application: { capabilities }, share: { urlGenerators: { getUrlGenerator }, }, @@ -66,6 +67,11 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer const indexPatternId = indexPattern.id; const getDiscoverUrl = async (): Promise => { + const isDiscoverAvailable = capabilities.discover?.show ?? false; + if (!isDiscoverAvailable) { + return; + } + const state: DiscoverUrlGeneratorState = { indexPatternId, }; @@ -110,7 +116,7 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer

@@ -118,14 +124,6 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer {showCreateAnomalyDetectionJob && ( <> - -

- -

-
= ({ indexPattern, searchString, searchQuer )} {mlAvailable && indexPattern.id !== undefined && createDataFrameAnalyticsLink && ( <> - -

- -

-
= ({ indexPattern, searchString, searchQuer description={i18n.translate( 'xpack.ml.datavisualizer.actionsPanel.dataframeTypesDescription', { - defaultMessage: 'Create outlier detection, regression, or classification analytics', + defaultMessage: + 'Create outlier detection, regression, or classification analytics.', } )} title={ } data-test-subj="mlDataVisualizerCreateDataFrameAnalyticsCard" @@ -203,7 +186,7 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer

@@ -214,7 +197,7 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer description={i18n.translate( 'xpack.ml.datavisualizer.actionsPanel.viewIndexInDiscoverDescription', { - defaultMessage: 'Explore index in Discover', + defaultMessage: 'Explore the documents in your index.', } )} title={ diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e65bf0a8c83c60..b911fdda69a776 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12872,9 +12872,7 @@ "xpack.ml.dataGridChart.topCategoriesLegend": "上位 {maxChartColumns}/{cardinality} カテゴリ", "xpack.ml.datavisualizer.actionsPanel.advancedDescription": "より高度なユースケースでは、ジョブの作成にすべてのオプションを使用します", "xpack.ml.datavisualizer.actionsPanel.advancedTitle": "高度な設定", - "xpack.ml.datavisualizer.actionsPanel.createJobDescription": "高度なジョブウィザードでジョブを作成し、このデータの異常を検出します:", "xpack.ml.datavisualizer.actionsPanel.createJobTitle": "ジョブの作成", - "xpack.ml.datavisualizer.actionsPanel.selectKnownConfigurationDescription": "認識されたデータの既知の構成を選択します:", "xpack.ml.datavisualizer.dataGrid.collapseDetailsForAllAriaLabel": "すべてのフィールドの詳細を折りたたむ", "xpack.ml.datavisualizer.dataGrid.distinctValuesColumnName": "固有の値", "xpack.ml.datavisualizer.dataGrid.distributionsColumnName": "分布", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cdc394a237e3ec..435984f8e7e4f8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12901,9 +12901,7 @@ "xpack.ml.dataGridChart.topCategoriesLegend": "{cardinality} 个类别中的排名前 {maxChartColumns} 个", "xpack.ml.datavisualizer.actionsPanel.advancedDescription": "使用全部选项为更高级的用例创建作业", "xpack.ml.datavisualizer.actionsPanel.advancedTitle": "高级", - "xpack.ml.datavisualizer.actionsPanel.createJobDescription": "使用“高级作业”向导创建作业,以查找此数据中的异常:", "xpack.ml.datavisualizer.actionsPanel.createJobTitle": "创建作业", - "xpack.ml.datavisualizer.actionsPanel.selectKnownConfigurationDescription": "选择已识别数据的已知配置:", "xpack.ml.datavisualizer.dataGrid.collapseDetailsForAllAriaLabel": "收起所有字段的详细信息", "xpack.ml.datavisualizer.dataGrid.distinctValuesColumnName": "不同值", "xpack.ml.datavisualizer.dataGrid.distributionsColumnName": "分布", diff --git a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts index ad11acb3a6cbbe..8d29b611c0bf54 100644 --- a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts @@ -15,16 +15,19 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - const testUsers = [USER.ML_POWERUSER, USER.ML_POWERUSER_SPACES]; + const testUsers = [ + { user: USER.ML_POWERUSER, discoverAvailable: true }, + { user: USER.ML_POWERUSER_SPACES, discoverAvailable: false }, + ]; describe('for user with full ML access', function () { this.tags(['skipFirefox', 'mlqa']); describe('with no data loaded', function () { - for (const user of testUsers) { - describe(`(${user})`, function () { + for (const testUser of testUsers) { + describe(`(${testUser.user})`, function () { before(async () => { - await ml.securityUI.loginAs(user); + await ml.securityUI.loginAs(testUser.user); await ml.api.cleanMlIndices(); }); @@ -153,10 +156,10 @@ export default function ({ getService }: FtrProviderContext) { await ml.api.deleteFilter(filterId); }); - for (const user of testUsers) { - describe(`(${user})`, function () { + for (const testUser of testUsers) { + describe(`(${testUser.user})`, function () { before(async () => { - await ml.securityUI.loginAs(user); + await ml.securityUI.loginAs(testUser.user); }); after(async () => { @@ -358,10 +361,12 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); await ml.testExecution.logTestStep( - 'should display the actions panel with Discover card' + `should display the actions panel ${ + testUser.discoverAvailable ? 'with' : 'without' + } Discover card` ); await ml.dataVisualizerIndexBased.assertActionsPanelExists(); - await ml.dataVisualizerIndexBased.assertViewInDiscoverCardExists(); + await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); await ml.testExecution.logTestStep('should display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); diff --git a/x-pack/test/functional/apps/ml/permissions/no_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/no_ml_access.ts index ef26530edb271d..280801d1becb5b 100644 --- a/x-pack/test/functional/apps/ml/permissions/no_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/no_ml_access.ts @@ -13,15 +13,18 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'error']); const ml = getService('ml'); - const testUsers = [USER.ML_UNAUTHORIZED, USER.ML_UNAUTHORIZED_SPACES]; + const testUsers = [ + { user: USER.ML_UNAUTHORIZED, discoverAvailable: true }, + { user: USER.ML_UNAUTHORIZED_SPACES, discoverAvailable: true }, + ]; describe('for user with no ML access', function () { this.tags(['skipFirefox', 'mlqa']); - for (const user of testUsers) { - describe(`(${user})`, function () { + for (const testUser of testUsers) { + describe(`(${testUser.user})`, function () { before(async () => { - await ml.securityUI.loginAs(user); + await ml.securityUI.loginAs(testUser.user); }); after(async () => { diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index 00cda88e0dc58c..71ac9c00032dcd 100644 --- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -15,16 +15,19 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - const testUsers = [USER.ML_VIEWER, USER.ML_VIEWER_SPACES]; + const testUsers = [ + { user: USER.ML_VIEWER, discoverAvailable: true }, + { user: USER.ML_VIEWER_SPACES, discoverAvailable: false }, + ]; describe('for user with read ML access', function () { this.tags(['skipFirefox', 'mlqa']); describe('with no data loaded', function () { - for (const user of testUsers) { - describe(`(${user})`, function () { + for (const testUser of testUsers) { + describe(`(${testUser.user})`, function () { before(async () => { - await ml.securityUI.loginAs(user); + await ml.securityUI.loginAs(testUser.user); await ml.api.cleanMlIndices(); }); @@ -154,10 +157,10 @@ export default function ({ getService }: FtrProviderContext) { await ml.api.deleteFilter(filterId); }); - for (const user of testUsers) { - describe(`(${user})`, function () { + for (const testUser of testUsers) { + describe(`(${testUser.user})`, function () { before(async () => { - await ml.securityUI.loginAs(user); + await ml.securityUI.loginAs(testUser.user); }); after(async () => { @@ -351,10 +354,12 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); await ml.testExecution.logTestStep( - 'should display the actions panel with Discover card' + `should display the actions panel ${ + testUser.discoverAvailable ? 'with' : 'without' + } Discover card` ); await ml.dataVisualizerIndexBased.assertActionsPanelExists(); - await ml.dataVisualizerIndexBased.assertViewInDiscoverCardExists(); + await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); diff --git a/x-pack/test/functional/services/ml/data_visualizer_index_based.ts b/x-pack/test/functional/services/ml/data_visualizer_index_based.ts index 53b87042d48da4..4beaa78d0189b2 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_index_based.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_index_based.ts @@ -161,6 +161,14 @@ export function MachineLearningDataVisualizerIndexBasedProvider({ await testSubjects.missingOrFail('mlDataVisualizerCreateDataFrameAnalyticsCard'); }, + async assertViewInDiscoverCard(shouldExist: boolean) { + if (shouldExist) { + await this.assertViewInDiscoverCardExists(); + } else { + await this.assertViewInDiscoverCardNotExists(); + } + }, + async assertViewInDiscoverCardExists() { await testSubjects.existOrFail('mlDataVisualizerViewInDiscoverCard'); }, diff --git a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts index 9806c186914a33..ced46f1f92d308 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts @@ -15,11 +15,14 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - const testUsers = [USER.ML_POWERUSER, USER.ML_POWERUSER_SPACES]; + const testUsers = [ + { user: USER.ML_POWERUSER, discoverAvailable: true }, + { user: USER.ML_POWERUSER_SPACES, discoverAvailable: false }, + ]; describe('for user with full ML access', function () { - for (const user of testUsers) { - describe(`(${user})`, function () { + for (const testUser of testUsers) { + describe(`(${testUser.user})`, function () { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; const ecExpectedModuleId = 'sample_data_ecommerce'; @@ -45,7 +48,7 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.loadIfNeeded('ml/module_sample_ecommerce'); await ml.testResources.createIndexPatternIfNeeded(ecIndexPattern, 'order_date'); - await ml.securityUI.loginAs(user); + await ml.securityUI.loginAs(testUser.user); }); after(async () => { @@ -127,9 +130,13 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should display the data visualizer table'); await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); - await ml.testExecution.logTestStep('should display the actions panel with Discover card'); + await ml.testExecution.logTestStep( + `should display the actions panel ${ + testUser.discoverAvailable ? 'with' : 'without' + } Discover card` + ); await ml.dataVisualizerIndexBased.assertActionsPanelExists(); - await ml.dataVisualizerIndexBased.assertViewInDiscoverCardExists(); + await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); diff --git a/x-pack/test/functional_basic/apps/ml/permissions/no_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/no_ml_access.ts index 24dbee356416ad..91a37d0d98cda3 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/no_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/no_ml_access.ts @@ -13,13 +13,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'error']); const ml = getService('ml'); - const testUsers = [USER.ML_UNAUTHORIZED, USER.ML_UNAUTHORIZED_SPACES]; + const testUsers = [ + { user: USER.ML_UNAUTHORIZED, discoverAvailable: true }, + { user: USER.ML_UNAUTHORIZED_SPACES, discoverAvailable: true }, + ]; describe('for user with no ML access', function () { - for (const user of testUsers) { - describe(`(${user})`, function () { + for (const testUser of testUsers) { + describe(`(${testUser.user})`, function () { before(async () => { - await ml.securityUI.loginAs(user); + await ml.securityUI.loginAs(testUser.user); }); after(async () => { diff --git a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts index 632922a353b335..f207b795820048 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts @@ -15,11 +15,14 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - const testUsers = [USER.ML_VIEWER, USER.ML_VIEWER_SPACES]; + const testUsers = [ + { user: USER.ML_VIEWER, discoverAvailable: true }, + { user: USER.ML_VIEWER_SPACES, discoverAvailable: false }, + ]; describe('for user with read ML access', function () { - for (const user of testUsers) { - describe(`(${user})`, function () { + for (const testUser of testUsers) { + describe(`(${testUser.user})`, function () { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; const ecExpectedModuleId = 'sample_data_ecommerce'; @@ -45,7 +48,7 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.loadIfNeeded('ml/module_sample_ecommerce'); await ml.testResources.createIndexPatternIfNeeded(ecIndexPattern, 'order_date'); - await ml.securityUI.loginAs(user); + await ml.securityUI.loginAs(testUser.user); }); after(async () => { @@ -127,9 +130,13 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should display the data visualizer table'); await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); - await ml.testExecution.logTestStep('should display the actions panel with Discover card'); + await ml.testExecution.logTestStep( + `should display the actions panel ${ + testUser.discoverAvailable ? 'with' : 'without' + } Discover card` + ); await ml.dataVisualizerIndexBased.assertActionsPanelExists(); - await ml.dataVisualizerIndexBased.assertViewInDiscoverCardExists(); + await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); From 58849bca385da66ed9e9c3a2745c52037b14b15b Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Tue, 16 Feb 2021 16:23:53 +0100 Subject: [PATCH 068/175] [Fleet] Escape YAML string values if necessary (#91418) * Use js-yaml.safeDump() to escape string values. * Add unit test. * Explicitly check for YAML special characters. * Remove unnecessary imports. * Use RegExp.prototype.test() for speed. --- .../server/services/epm/agent/agent.test.ts | 75 +++++++++++++++++++ .../fleet/server/services/epm/agent/agent.ts | 26 +++++-- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts index 7ab904b2f15e13..4509deee0d00f7 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts @@ -179,4 +179,79 @@ input: logs input: 'logs', }); }); + + it('should escape string values when necessary', () => { + const stringTemplate = ` +my-package: + opencurly: {{opencurly}} + closecurly: {{closecurly}} + opensquare: {{opensquare}} + closesquare: {{closesquare}} + ampersand: {{ampersand}} + asterisk: {{asterisk}} + question: {{question}} + pipe: {{pipe}} + hyphen: {{hyphen}} + openangle: {{openangle}} + closeangle: {{closeangle}} + equals: {{equals}} + exclamation: {{exclamation}} + percent: {{percent}} + at: {{at}} + colon: {{colon}} + numeric: {{numeric}} + mixed: {{mixed}}`; + + // List of special chars that may lead to YAML parsing errors when not quoted. + // See YAML specification section 5.3 Indicator characters + // https://yaml.org/spec/1.2/spec.html#id2772075 + // {,},[,],&,*,?,|,-,<,>,=,!,%,@,: + const vars = { + opencurly: { value: '{', type: 'string' }, + closecurly: { value: '}', type: 'string' }, + opensquare: { value: '[', type: 'string' }, + closesquare: { value: ']', type: 'string' }, + comma: { value: ',', type: 'string' }, + ampersand: { value: '&', type: 'string' }, + asterisk: { value: '*', type: 'string' }, + question: { value: '?', type: 'string' }, + pipe: { value: '|', type: 'string' }, + hyphen: { value: '-', type: 'string' }, + openangle: { value: '<', type: 'string' }, + closeangle: { value: '>', type: 'string' }, + equals: { value: '=', type: 'string' }, + exclamation: { value: '!', type: 'string' }, + percent: { value: '%', type: 'string' }, + at: { value: '@', type: 'string' }, + colon: { value: ':', type: 'string' }, + numeric: { value: '100', type: 'string' }, + mixed: { value: '1s', type: 'string' }, + }; + + const targetOutput = { + 'my-package': { + opencurly: '{', + closecurly: '}', + opensquare: '[', + closesquare: ']', + ampersand: '&', + asterisk: '*', + question: '?', + pipe: '|', + hyphen: '-', + openangle: '<', + closeangle: '>', + equals: '=', + exclamation: '!', + percent: '%', + at: '@', + colon: ':', + numeric: '100', + mixed: '1s', + }, + }; + + const output = compileTemplate(vars, stringTemplate); + expect(output).toEqual(targetOutput); + }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts index 4f39da5b0b70d7..a71776af245f77 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts @@ -13,7 +13,6 @@ const handlebars = Handlebars.create(); export function compileTemplate(variables: PackagePolicyConfigRecord, templateStr: string) { const { vars, yamlValues } = buildTemplateVariables(variables, templateStr); - const template = handlebars.compile(templateStr, { noEscape: true }); let compiledTemplate = template(vars); compiledTemplate = replaceRootLevelYamlVariables(yamlValues, compiledTemplate); @@ -58,8 +57,17 @@ function replaceVariablesInYaml(yamlVariables: { [k: string]: any }, yaml: any) return yaml; } -const maybeEscapeNumericString = (value: string) => { - return value.length && !isNaN(+value) ? `"${value}"` : value; +const maybeEscapeString = (value: string) => { + // List of special chars that may lead to YAML parsing errors when not quoted. + // See YAML specification section 5.3 Indicator characters + // https://yaml.org/spec/1.2/spec.html#id2772075 + const yamlSpecialCharsRegex = /[{}\[\],&*?|\-<>=!%@:]/; + + // In addition, numeric strings need to be quoted to stay strings. + if ((value.length && !isNaN(+value)) || yamlSpecialCharsRegex.test(value)) { + return `"${value}"`; + } + return value; }; function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateStr: string) { @@ -88,13 +96,15 @@ function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateSt const yamlKeyPlaceholder = `##${key}##`; varPart[lastKeyPart] = `"${yamlKeyPlaceholder}"`; yamlValues[yamlKeyPlaceholder] = recordEntry.value ? safeLoad(recordEntry.value) : null; - } else if (recordEntry.type && recordEntry.type === 'text' && recordEntry.value?.length) { + } else if ( + recordEntry.type && + (recordEntry.type === 'text' || recordEntry.type === 'string') && + recordEntry.value?.length + ) { if (Array.isArray(recordEntry.value)) { - varPart[lastKeyPart] = recordEntry.value.map((value: string) => - maybeEscapeNumericString(value) - ); + varPart[lastKeyPart] = recordEntry.value.map((value: string) => maybeEscapeString(value)); } else { - varPart[lastKeyPart] = maybeEscapeNumericString(recordEntry.value); + varPart[lastKeyPart] = maybeEscapeString(recordEntry.value); } } else { varPart[lastKeyPart] = recordEntry.value; From e8fea280bc362cb13731112fa69311d6508324a7 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 16 Feb 2021 10:48:03 -0500 Subject: [PATCH 069/175] [Fleet] Create default Fleet Server policy with fleet server package (#90973) --- .../fleet/common/constants/agent_policy.ts | 15 ++++++ .../fleet/common/types/models/agent_policy.ts | 1 + .../fleet/server/saved_objects/index.ts | 1 + .../saved_objects/migrations/to_v7_12_0.ts | 9 ++-- .../fleet/server/services/agent_policy.ts | 47 ++++++++++++++++--- x-pack/plugins/fleet/server/services/setup.ts | 22 ++++++++- 6 files changed, 82 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/fleet/common/constants/agent_policy.ts b/x-pack/plugins/fleet/common/constants/agent_policy.ts index 96b6249585bfcd..bed9b6e8390b87 100644 --- a/x-pack/plugins/fleet/common/constants/agent_policy.ts +++ b/x-pack/plugins/fleet/common/constants/agent_policy.ts @@ -28,4 +28,19 @@ export const DEFAULT_AGENT_POLICY: Omit< monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, }; +export const DEFAULT_FLEET_SERVER_AGENT_POLICY: Omit< + AgentPolicy, + 'id' | 'updated_at' | 'updated_by' | 'revision' +> = { + name: 'Default Fleet Server policy', + namespace: 'default', + description: 'Default Fleet Server agent policy created by Kibana', + status: agentPolicyStatuses.Active, + package_policies: [], + is_default: false, + is_default_fleet_server: true, + is_managed: false, + monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, +}; + export const DEFAULT_AGENT_POLICIES_PACKAGES = [defaultPackages.System]; diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index 5f41b0f70ca74f..bc139537400cc4 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -17,6 +17,7 @@ export interface NewAgentPolicy { namespace: string; description?: string; is_default?: boolean; + is_default_fleet_server?: boolean; // Optional when creating a policy is_managed?: boolean; // Optional when creating a policy monitoring_enabled?: Array>; } diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index f2eb8be5c030c6..5b851c692ad3f6 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -162,6 +162,7 @@ const getSavedObjectTypes = ( description: { type: 'text' }, namespace: { type: 'keyword' }, is_default: { type: 'boolean' }, + is_default_fleet_server: { type: 'boolean' }, is_managed: { type: 'boolean' }, status: { type: 'keyword' }, package_policies: { type: 'keyword' }, diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts index 49a0d6fc7737fa..15e68ace987b98 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v7_12_0.ts @@ -17,12 +17,11 @@ export const migrateAgentToV7120: SavedObjectMigrationFn, + Exclude, AgentPolicy > = (agentPolicyDoc) => { - const isV12 = 'is_managed' in agentPolicyDoc.attributes; - if (!isV12) { - agentPolicyDoc.attributes.is_managed = false; - } + agentPolicyDoc.attributes.is_managed = false; + agentPolicyDoc.attributes.is_default_fleet_server = false; + return agentPolicyDoc; }; diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index f31f38796055c1..44962ea31c56c5 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -35,6 +35,7 @@ import { dataTypes, FleetServerPolicy, AGENT_POLICY_INDEX, + DEFAULT_FLEET_SERVER_AGENT_POLICY, } from '../../common'; import { AgentPolicyNameExistsError, @@ -133,6 +134,39 @@ class AgentPolicyService { }; } + public async ensureDefaultFleetServerAgentPolicy( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient + ): Promise<{ + created: boolean; + policy: AgentPolicy; + }> { + const agentPolicies = await soClient.find({ + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + searchFields: ['is_default_fleet_server'], + search: 'true', + }); + + if (agentPolicies.total === 0) { + const newDefaultAgentPolicy: NewAgentPolicy = { + ...DEFAULT_FLEET_SERVER_AGENT_POLICY, + }; + + return { + created: true, + policy: await this.create(soClient, esClient, newDefaultAgentPolicy), + }; + } + + return { + created: false, + policy: { + id: agentPolicies.saved_objects[0].id, + ...agentPolicies.saved_objects[0].attributes, + }, + }; + } + public async create( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -569,18 +603,19 @@ class AgentPolicyService { if (!(await isAgentsSetup(soClient))) { return; } - const policy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId); - if (!policy || !policy.revision) { + const policy = await agentPolicyService.get(soClient, agentPolicyId); + const fullPolicy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId); + if (!policy || !fullPolicy || !fullPolicy.revision) { return; } const fleetServerPolicy: FleetServerPolicy = { '@timestamp': new Date().toISOString(), - revision_idx: policy.revision, + revision_idx: fullPolicy.revision, coordinator_idx: 0, - data: (policy as unknown) as FleetServerPolicy['data'], - policy_id: policy.id, - default_fleet_server: false, + data: (fullPolicy as unknown) as FleetServerPolicy['data'], + policy_id: fullPolicy.id, + default_fleet_server: policy.is_default_fleet_server === true, }; await esClient.create({ diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 6c8f24e7995742..94c3c606f9f8f0 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -56,15 +56,20 @@ async function createSetupSideEffects( esClient: ElasticsearchClient, callCluster: CallESAsCurrentUser ): Promise { + const isFleetServerEnabled = appContextService.getConfig()?.agents.fleetServerEnabled; const [ installedPackages, defaultOutput, { created: defaultAgentPolicyCreated, defaultAgentPolicy }, + { created: defaultFleetServerPolicyCreated, policy: defaultFleetServerPolicy }, ] = await Promise.all([ // packages installed by default ensureInstalledDefaultPackages(soClient, callCluster), outputService.ensureDefaultOutput(soClient), agentPolicyService.ensureDefaultAgentPolicy(soClient, esClient), + isFleetServerEnabled + ? agentPolicyService.ensureDefaultFleetServerAgentPolicy(soClient, esClient) + : {}, updateFleetRoleIfExists(callCluster), settingsService.getSettings(soClient).catch((e: any) => { if (e.isBoom && e.output.statusCode === 404) { @@ -83,7 +88,7 @@ async function createSetupSideEffects( // By moving this outside of the Promise.all, the upgrade will occur first, and then we'll attempt to reinstall any // packages that are stuck in the installing state. await ensurePackagesCompletedInstall(soClient, callCluster); - if (appContextService.getConfig()?.agents.fleetServerEnabled) { + if (isFleetServerEnabled) { await ensureInstalledPackage({ savedObjectsClient: soClient, pkgName: FLEET_SERVER_PACKAGE, @@ -94,15 +99,28 @@ async function createSetupSideEffects( } if (appContextService.getConfig()?.agents?.fleetServerEnabled) { - await ensureInstalledPackage({ + const fleetServerPackage = await ensureInstalledPackage({ savedObjectsClient: soClient, pkgName: FLEET_SERVER_PACKAGE, callCluster, }); await ensureFleetServerIndicesCreated(esClient); await runFleetServerMigration(); + + if (defaultFleetServerPolicyCreated) { + await addPackageToAgentPolicy( + soClient, + esClient, + callCluster, + fleetServerPackage, + defaultFleetServerPolicy, + defaultOutput + ); + } } + // If we just created the default fleet server policy add the fleet server package + // If we just created the default policy, ensure default packages are added to it if (defaultAgentPolicyCreated) { const agentPolicyWithPackagePolicies = await agentPolicyService.get( From 1c093c9760f2d82dc4555e74725eed2fb52c0271 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 16 Feb 2021 16:54:50 +0100 Subject: [PATCH 070/175] [User Experience app] fix e2e tests (#91423) --- .../support/step_definitions/csm/breakdown_filter.ts | 6 +++--- .../cypress/support/step_definitions/csm/csm_dashboard.ts | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts index b7e16f71ce0a49..5b4934eac1f712 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts @@ -31,13 +31,13 @@ Then(`breakdown series should appear in chart`, () => { cy.get('.euiLoadingChart').should('not.exist'); cy.get('[data-cy=pageLoadDist]').within(() => { - cy.get('div.echLegendItem__label[title=Chrome] ', DEFAULT_TIMEOUT) + cy.get('button.echLegendItem__label[title=Chrome] ', DEFAULT_TIMEOUT) .invoke('text') .should('eq', 'Chrome'); - cy.get('div.echLegendItem__label', DEFAULT_TIMEOUT).should( + cy.get('button.echLegendItem__label', DEFAULT_TIMEOUT).should( 'have.text', - 'OverallChromeChrome Mobile WebViewSafariFirefoxMobile SafariChrome MobileChrome Mobile iOS' + 'ChromeChrome Mobile WebViewSafariFirefoxMobile SafariChrome MobileChrome Mobile iOSOverall' ); }); }); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts index 8d01bfa70bc491..47154ee214dc42 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts @@ -52,12 +52,14 @@ Then(`should display percentile for page load chart`, () => { }); Then(`should display chart legend`, () => { - const chartLegend = 'div.echLegendItem__label'; + const chartLegend = 'button.echLegendItem__label'; waitForLoadingToFinish(); cy.get('.euiLoadingChart').should('not.exist'); - cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); + cy.get('[data-cy=pageLoadDist]').within(() => { + cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); + }); }); Then(`should display tooltip on hover`, () => { From a59f49e5063c2bd24c825e11c86e7586bb0bc3bb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 16 Feb 2021 16:58:05 +0100 Subject: [PATCH 071/175] Update dependency @elastic/charts to v24.6.0 (#91382) * Update dependency @elastic/charts to v24.6.0 * Update donut chart snapshot Co-authored-by: Renovate Bot Co-authored-by: Marco Vettorello --- package.json | 2 +- .../charts/__snapshots__/donut_chart.test.tsx.snap | 11 +++++++++++ yarn.lock | 8 ++++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ed21cb7052c1cb..67f1f019e73a3e 100644 --- a/package.json +++ b/package.json @@ -352,7 +352,7 @@ "@cypress/webpack-preprocessor": "^5.5.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "24.5.1", + "@elastic/charts": "24.6.0", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", diff --git a/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap index 238ce6c3f9ceec..31d4322210e2b7 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap @@ -50,6 +50,17 @@ exports[`DonutChart component passes correct props without errors for valid prop "strokeWidth": 1, "visible": true, }, + "axisPanelTitle": Object { + "fill": "#333", + "fontFamily": "sans-serif", + "fontSize": 10, + "fontStyle": "bold", + "padding": Object { + "inner": 8, + "outer": 0, + }, + "visible": true, + }, "axisTitle": Object { "fill": "#333", "fontFamily": "sans-serif", diff --git a/yarn.lock b/yarn.lock index 8dc2cf35287d75..ff0cb49911ca3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2146,10 +2146,10 @@ dependencies: object-hash "^1.3.0" -"@elastic/charts@24.5.1": - version "24.5.1" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.5.1.tgz#4757721b0323b15412c92d696dd76fdef9b963f8" - integrity sha512-eHJna3xyHREaSfTRb+3/34EmyoINopH6yP9KReakXRb0jW8DD4n9IkbPFwpVN3uXQ6ND2x1ObA0ZzLPSLCPozg== +"@elastic/charts@24.6.0": + version "24.6.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.6.0.tgz#2123c72e69e1e4557be41ae55c085a5a9f75d3b6" + integrity sha512-fL0301EcHxJEYRzdlD4JIA3VXY4qwRPSkRrk8hvJNryTlQWEdyXZF3HNczk0IrgST5cfCOGAWG8IVtO59HxUJw== dependencies: "@popperjs/core" "^2.4.0" chroma-js "^2.1.0" From 9ae8ba896491d48c72f47c50652e23716fcb427f Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 16 Feb 2021 17:11:54 +0100 Subject: [PATCH 072/175] [APM] Add setup instructions for PHP agent (#91381) Co-authored-by: Sergey Kleyman Co-authored-by: Sergey Kleyman --- src/plugins/apm_oss/server/index.ts | 1 + .../apm_oss/server/tutorial/envs/on_prem.ts | 5 ++ .../instructions/apm_agent_instructions.ts | 51 +++++++++++++++++++ .../home/common/instruction_variant.ts | 2 + .../apm/server/tutorial/elastic_cloud.ts | 5 ++ 5 files changed, 64 insertions(+) diff --git a/src/plugins/apm_oss/server/index.ts b/src/plugins/apm_oss/server/index.ts index bea9965748f27a..a02e28201a1b90 100644 --- a/src/plugins/apm_oss/server/index.ts +++ b/src/plugins/apm_oss/server/index.ts @@ -47,4 +47,5 @@ export { createGoAgentInstructions, createJavaAgentInstructions, createDotNetAgentInstructions, + createPhpAgentInstructions, } from './tutorial/instructions/apm_agent_instructions'; diff --git a/src/plugins/apm_oss/server/tutorial/envs/on_prem.ts b/src/plugins/apm_oss/server/tutorial/envs/on_prem.ts index 7d6e3431396fc7..7d261abb0cc018 100644 --- a/src/plugins/apm_oss/server/tutorial/envs/on_prem.ts +++ b/src/plugins/apm_oss/server/tutorial/envs/on_prem.ts @@ -27,6 +27,7 @@ import { createGoAgentInstructions, createJavaAgentInstructions, createDotNetAgentInstructions, + createPhpAgentInstructions, } from '../instructions/apm_agent_instructions'; export function onPremInstructions({ @@ -152,6 +153,10 @@ export function onPremInstructions({ id: INSTRUCTION_VARIANT.DOTNET, instructions: createDotNetAgentInstructions(), }, + { + id: INSTRUCTION_VARIANT.PHP, + instructions: createPhpAgentInstructions(), + }, ], statusCheck: { title: i18n.translate('apmOss.tutorial.apmAgents.statusCheck.title', { diff --git a/src/plugins/apm_oss/server/tutorial/instructions/apm_agent_instructions.ts b/src/plugins/apm_oss/server/tutorial/instructions/apm_agent_instructions.ts index ea1f961f5e2db1..8886dec12ccd6b 100644 --- a/src/plugins/apm_oss/server/tutorial/instructions/apm_agent_instructions.ts +++ b/src/plugins/apm_oss/server/tutorial/instructions/apm_agent_instructions.ts @@ -701,3 +701,54 @@ export const createDotNetAgentInstructions = (apmServerUrl = '', secretToken = ' }), }, ]; + +export const createPhpAgentInstructions = (apmServerUrl = '', secretToken = '') => [ + { + title: i18n.translate('apmOss.tutorial.phpClient.download.title', { + defaultMessage: 'Download the APM agent', + }), + textPre: i18n.translate('apmOss.tutorial.phpClient.download.textPre', { + defaultMessage: + 'Download the package corresponding to your platform from [GitHub releases]({githubReleasesLink}).', + values: { + githubReleasesLink: 'https://github.com/elastic/apm-agent-php/releases', + }, + }), + }, + { + title: i18n.translate('apmOss.tutorial.phpClient.installPackage.title', { + defaultMessage: 'Install the downloaded package', + }), + textPre: i18n.translate('apmOss.tutorial.phpClient.installPackage.textPre', { + defaultMessage: 'For example on Alpine Linux using APK package:', + }), + commands: ['apk add --allow-untrusted .apk'], + textPost: i18n.translate('apmOss.tutorial.phpClient.installPackage.textPost', { + defaultMessage: + 'See the [documentation]({documentationLink}) for installation commands on other supported platforms and advanced installation.', + values: { + documentationLink: '{config.docs.base_url}guide/en/apm/agent/php/current/setup.html', + }, + }), + }, + { + title: i18n.translate('apmOss.tutorial.phpClient.configureAgent.title', { + defaultMessage: 'Configure the agent', + }), + textPre: i18n.translate('apmOss.tutorial.phpClient.configureAgent.textPre', { + defaultMessage: + 'APM is automatically started when your app boots. Configure the agent either via `php.ini` file:', + }), + commands: `elastic_apm.server_url=http://localhost:8200 +elastic_apm.service_name="My service" +`.split('\n'), + textPost: i18n.translate('apmOss.tutorial.phpClient.configure.textPost', { + defaultMessage: + 'See the [documentation]({documentationLink}) for configuration options and advanced usage.\n\n', + values: { + documentationLink: + '{config.docs.base_url}guide/en/apm/agent/php/current/configuration.html', + }, + }), + }, +]; diff --git a/src/plugins/home/common/instruction_variant.ts b/src/plugins/home/common/instruction_variant.ts index ae6735568851c8..310ee23460a084 100644 --- a/src/plugins/home/common/instruction_variant.ts +++ b/src/plugins/home/common/instruction_variant.ts @@ -23,6 +23,7 @@ export const INSTRUCTION_VARIANT = { JAVA: 'java', DOTNET: 'dotnet', LINUX: 'linux', + PHP: 'php', }; const DISPLAY_MAP = { @@ -42,6 +43,7 @@ const DISPLAY_MAP = { [INSTRUCTION_VARIANT.JAVA]: 'Java', [INSTRUCTION_VARIANT.DOTNET]: '.NET', [INSTRUCTION_VARIANT.LINUX]: 'Linux', + [INSTRUCTION_VARIANT.PHP]: 'PHP', }; /** diff --git a/x-pack/plugins/apm/server/tutorial/elastic_cloud.ts b/x-pack/plugins/apm/server/tutorial/elastic_cloud.ts index fac38027e1b82b..08e1ff75d43242 100644 --- a/x-pack/plugins/apm/server/tutorial/elastic_cloud.ts +++ b/x-pack/plugins/apm/server/tutorial/elastic_cloud.ts @@ -18,6 +18,7 @@ import { createGoAgentInstructions, createJavaAgentInstructions, createDotNetAgentInstructions, + createPhpAgentInstructions, } from '../../../../../src/plugins/apm_oss/server'; import { CloudSetup } from '../../../cloud/server'; @@ -105,6 +106,10 @@ function getApmAgentInstructionSet(cloudSetup?: CloudSetup) { id: INSTRUCTION_VARIANT.DOTNET, instructions: createDotNetAgentInstructions(apmServerUrl, secretToken), }, + { + id: INSTRUCTION_VARIANT.PHP, + instructions: createPhpAgentInstructions(apmServerUrl, secretToken), + }, ], }; } From be17efd7043c13f3d53b4fc1a078b1692ccae5db Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 16 Feb 2021 11:40:22 -0500 Subject: [PATCH 073/175] [Docs] Add note about TSVB axis scaling (#91211) * [Docs] Add note about TSVB axis scaling * Apply suggestions --- docs/user/dashboard/tsvb.asciidoc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/user/dashboard/tsvb.asciidoc b/docs/user/dashboard/tsvb.asciidoc index 440b597520032c..052e40d845fd91 100644 --- a/docs/user/dashboard/tsvb.asciidoc +++ b/docs/user/dashboard/tsvb.asciidoc @@ -24,7 +24,9 @@ When you open *TSVB*, click *Panel options*, then verify the following: ==== Visualization options Time series:: - Supports annotations based on timestamped documents in a separate {es} index. + By default, the Y axis shows the full range of data, including zero. To scale the axis from + the minimum to maximum values of the data automatically, go to *Series > Options > Fill* and set *Fill to 0*. + You can add annotations to the x-axis based on timestamped documents in a separate {es} index. All other chart types:: *Panel options > Data timerange mode* controls the timespan used for matching documents. From 689c9fac702b23bbe31968bb88150a5a3c2b1db8 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 16 Feb 2021 17:47:10 +0100 Subject: [PATCH 074/175] [Uptime] Fix alert loading on error (#91453) --- .../components/overview/monitor_list/columns/enable_alert.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx index d1401c64a89251..444198bfa9412b 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx @@ -42,7 +42,7 @@ export const EnableMonitorAlert = ({ monitorId, monitorName }: Props) => { const { data: deletedAlertId } = useSelector(isAlertDeletedSelector); - const { data: newAlert } = useSelector(newAlertSelector); + const { data: newAlert, error: newAlertError } = useSelector(newAlertSelector); const isNewAlert = newAlert?.params.search.includes(monitorId); @@ -85,7 +85,7 @@ export const EnableMonitorAlert = ({ monitorId, monitorName }: Props) => { useEffect(() => { setIsLoading(false); - }, [hasAlert, deletedAlertId]); + }, [hasAlert, deletedAlertId, newAlertError]); const hasDefaultConnectors = (settings?.defaultConnectors ?? []).length > 0; From 5f500a3c69eec2db14d1337733c77433e27a654b Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Tue, 16 Feb 2021 10:47:30 -0600 Subject: [PATCH 075/175] Improved painless error toasts (#91346) * better painless error modal --- ...data-public.isearchoptions.indexpattern.md | 13 +++++++ ...ugin-plugins-data-public.isearchoptions.md | 1 + ...data-public.painlesserror._constructor_.md | 3 +- ...-data-public.painlesserror.indexpattern.md | 11 ++++++ ...lugin-plugins-data-public.painlesserror.md | 3 +- ...data-server.isearchoptions.indexpattern.md | 13 +++++++ ...ugin-plugins-data-server.isearchoptions.md | 1 + ...plugin-plugins-data-server.plugin.start.md | 4 +- .../search/search_source/search_source.ts | 3 ++ src/plugins/data/common/search/types.ts | 7 ++++ src/plugins/data/public/public.api.md | 5 ++- .../search/errors/painless_error.test.tsx | 12 +++--- .../public/search/errors/painless_error.tsx | 37 ++++++++++++++----- .../data/public/search/search_interceptor.ts | 2 +- src/plugins/data/server/server.api.md | 1 + .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 17 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.indexpattern.md create mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md new file mode 100644 index 00000000000000..baf44de5088fbd --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ISearchOptions](./kibana-plugin-plugins-data-public.isearchoptions.md) > [indexPattern](./kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md) + +## ISearchOptions.indexPattern property + +Index pattern reference is used for better error messages + +Signature: + +```typescript +indexPattern?: IndexPattern; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md index fc2767cd0231f9..2473c9cfdde8df 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md @@ -15,6 +15,7 @@ export interface ISearchOptions | Property | Type | Description | | --- | --- | --- | | [abortSignal](./kibana-plugin-plugins-data-public.isearchoptions.abortsignal.md) | AbortSignal | An AbortSignal that allows the caller of search to abort a search request. | +| [indexPattern](./kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md) | IndexPattern | Index pattern reference is used for better error messages | | [isRestore](./kibana-plugin-plugins-data-public.isearchoptions.isrestore.md) | boolean | Whether the session is restored (i.e. search requests should re-use the stored search IDs, rather than starting from scratch) | | [isStored](./kibana-plugin-plugins-data-public.isearchoptions.isstored.md) | boolean | Whether the session is already saved (i.e. sent to background) | | [legacyHitsTotal](./kibana-plugin-plugins-data-public.isearchoptions.legacyhitstotal.md) | boolean | Request the legacy format for the total number of hits. If sending rest_total_hits_as_int to something other than true, this should be set to false. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md index 5f43f8477cb9f1..b8f21de3e086ec 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `PainlessError` class Signature: ```typescript -constructor(err: IEsError); +constructor(err: IEsError, indexPattern?: IndexPattern); ``` ## Parameters @@ -17,4 +17,5 @@ constructor(err: IEsError); | Parameter | Type | Description | | --- | --- | --- | | err | IEsError | | +| indexPattern | IndexPattern | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.indexpattern.md new file mode 100644 index 00000000000000..4312f2f8d0c91f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.indexpattern.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [PainlessError](./kibana-plugin-plugins-data-public.painlesserror.md) > [indexPattern](./kibana-plugin-plugins-data-public.painlesserror.indexpattern.md) + +## PainlessError.indexPattern property + +Signature: + +```typescript +indexPattern?: IndexPattern; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md index c77b8b259136b1..3a887d358e2155 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md @@ -14,12 +14,13 @@ export declare class PainlessError extends EsError | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(err)](./kibana-plugin-plugins-data-public.painlesserror._constructor_.md) | | Constructs a new instance of the PainlessError class | +| [(constructor)(err, indexPattern)](./kibana-plugin-plugins-data-public.painlesserror._constructor_.md) | | Constructs a new instance of the PainlessError class | ## Properties | Property | Modifiers | Type | Description | | --- | --- | --- | --- | +| [indexPattern](./kibana-plugin-plugins-data-public.painlesserror.indexpattern.md) | | IndexPattern | | | [painlessStack](./kibana-plugin-plugins-data-public.painlesserror.painlessstack.md) | | string | | ## Methods diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md new file mode 100644 index 00000000000000..cc24363c1bed5e --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) > [indexPattern](./kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md) + +## ISearchOptions.indexPattern property + +Index pattern reference is used for better error messages + +Signature: + +```typescript +indexPattern?: IndexPattern; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md index 9de351b2b90194..7fd4dd5b8e566c 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md @@ -15,6 +15,7 @@ export interface ISearchOptions | Property | Type | Description | | --- | --- | --- | | [abortSignal](./kibana-plugin-plugins-data-server.isearchoptions.abortsignal.md) | AbortSignal | An AbortSignal that allows the caller of search to abort a search request. | +| [indexPattern](./kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md) | IndexPattern | Index pattern reference is used for better error messages | | [isRestore](./kibana-plugin-plugins-data-server.isearchoptions.isrestore.md) | boolean | Whether the session is restored (i.e. search requests should re-use the stored search IDs, rather than starting from scratch) | | [isStored](./kibana-plugin-plugins-data-server.isearchoptions.isstored.md) | boolean | Whether the session is already saved (i.e. sent to background) | | [legacyHitsTotal](./kibana-plugin-plugins-data-server.isearchoptions.legacyhitstotal.md) | boolean | Request the legacy format for the total number of hits. If sending rest_total_hits_as_int to something other than true, this should be set to false. | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md index ea3ba28a52defc..9dc38f96df4be6 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md @@ -12,7 +12,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; @@ -31,7 +31,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }` diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 8580fb7910735e..2cf0455ae2df8b 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -297,6 +297,9 @@ export class SearchSource { switchMap(() => { const searchRequest = this.flatten(); this.history = [searchRequest]; + if (searchRequest.index) { + options.indexPattern = searchRequest.index; + } return getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES) ? from(this.legacyFetch(searchRequest, options)) diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index 4f687a396a47b9..3ac4c33091f6bb 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -8,6 +8,7 @@ import { Observable } from 'rxjs'; import { IEsSearchRequest, IEsSearchResponse } from './es_search'; +import { IndexPattern } from '..'; export type ISearchGeneric = < SearchStrategyRequest extends IKibanaSearchRequest = IEsSearchRequest, @@ -111,4 +112,10 @@ export interface ISearchOptions { * rather than starting from scratch) */ isRestore?: boolean; + + /** + * Index pattern reference is used for better error messages + */ + + indexPattern?: IndexPattern; } diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 0920d0d716d73a..0a3e4666da7473 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1640,6 +1640,7 @@ export type ISearchGeneric = { }); const component = mount(e.getErrorMessage(startMock.application)); - const scriptElem = findTestSubject(component, 'painlessScript').getDOMNode(); - const failedShards = e.attributes?.failed_shards![0]; - const script = failedShards!.reason.script; - expect(scriptElem.textContent).toBe(`Error executing Painless script: '${script}'`); const stackTraceElem = findTestSubject(component, 'painlessStackTrace').getDOMNode(); - const stackTrace = failedShards!.reason.script_stack!.join('\n'); + const stackTrace = failedShards!.reason.script_stack!.splice(-2).join('\n'); expect(stackTraceElem.textContent).toBe(stackTrace); + const humanReadableError = findTestSubject( + component, + 'painlessHumanReadableError' + ).getDOMNode(); + expect(humanReadableError.textContent).toBe(failedShards?.reason.caused_by?.reason); + expect(component.find('EuiButton').length).toBe(1); }); }); diff --git a/src/plugins/data/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx index a73d112a8de48b..bad4567024d00f 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -14,40 +14,59 @@ import { ApplicationStart } from 'kibana/public'; import { IEsError, isEsError } from './types'; import { EsError } from './es_error'; import { getRootCause } from './utils'; +import { IndexPattern } from '../..'; export class PainlessError extends EsError { painlessStack?: string; - constructor(err: IEsError) { + indexPattern?: IndexPattern; + constructor(err: IEsError, indexPattern?: IndexPattern) { super(err); + this.indexPattern = indexPattern; } public getErrorMessage(application: ApplicationStart) { - function onClick() { + function onClick(indexPatternId?: string) { application.navigateToApp('management', { - path: `/kibana/indexPatterns`, + path: `/kibana/indexPatterns${indexPatternId ? `/patterns/${indexPatternId}` : ''}`, }); } const rootCause = getRootCause(this.err); + const scriptFromStackTrace = rootCause?.script_stack + ? rootCause?.script_stack?.slice(-2).join('\n') + : undefined; + // if the error has been properly processed it will highlight where it occurred. + const hasScript = rootCause?.script_stack?.slice(-1)[0]?.indexOf('HERE') || -1 >= 0; + const humanReadableError = rootCause?.caused_by?.reason; + // fallback, show ES stacktrace const painlessStack = rootCause?.script_stack ? rootCause?.script_stack.join('\n') : undefined; + const indexPatternId = this?.indexPattern?.id; return ( <> - + {i18n.translate('data.painlessError.painlessScriptedFieldErrorMessage', { - defaultMessage: "Error executing Painless script: '{script}'", - values: { script: rootCause?.script }, + defaultMessage: + 'Error executing runtime field or scripted field on index pattern {indexPatternName}', + values: { + indexPatternName: this?.indexPattern?.title, + }, })} - {painlessStack ? ( + {scriptFromStackTrace || painlessStack ? ( - {painlessStack} + {hasScript ? scriptFromStackTrace : painlessStack} ) : null} + {humanReadableError ? ( + {humanReadableError} + ) : null} + + - + onClick(indexPatternId)} size="s"> diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index f46a3d258f9486..ec4b628a6bd3af 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -109,7 +109,7 @@ export class SearchInterceptor { return e; } else if (isEsError(e)) { if (isPainlessError(e)) { - return new PainlessError(e); + return new PainlessError(e, options?.indexPattern); } else { return new EsError(e); } diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index ab8f6c9ed39511..23aaab36e79055 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -912,6 +912,7 @@ export class IndexPatternsService implements Plugin_3 Date: Tue, 16 Feb 2021 11:51:03 -0500 Subject: [PATCH 076/175] [Lens] Support histogram mapping type for all numeric functions (#90357) * [Lens] Support histogram mapping type * Fix field stats and allow max/min * Fix types * Revert to regular sample data * Simplify server code * Add test for edge case Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../data/common/search/aggs/metrics/max.ts | 2 +- .../data/common/search/aggs/metrics/min.ts | 2 +- .../indexpattern_datasource/datapanel.tsx | 11 ++- .../operations/definitions/metrics.tsx | 6 +- .../definitions/percentile.test.tsx | 74 ++++++++++++++ .../operations/definitions/percentile.tsx | 6 +- .../public/indexpattern_datasource/utils.ts | 1 + x-pack/plugins/lens/public/types.ts | 3 +- .../public/xy_visualization/xy_suggestions.ts | 1 + .../plugins/lens/server/routes/field_stats.ts | 55 ++++++++--- .../api_integration/apis/lens/field_stats.ts | 97 +++++++++++++++++++ .../apps/visualize/precalculated_histogram.ts | 4 +- .../pre_calculated_histogram/data.json | 22 ++++- 13 files changed, 256 insertions(+), 28 deletions(-) diff --git a/src/plugins/data/common/search/aggs/metrics/max.ts b/src/plugins/data/common/search/aggs/metrics/max.ts index ee2d5ad03ce3a0..5a41cdbb256c80 100644 --- a/src/plugins/data/common/search/aggs/metrics/max.ts +++ b/src/plugins/data/common/search/aggs/metrics/max.ts @@ -36,7 +36,7 @@ export const getMaxMetricAgg = () => { { name: 'field', type: 'field', - filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE], + filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE, KBN_FIELD_TYPES.HISTOGRAM], }, ], }); diff --git a/src/plugins/data/common/search/aggs/metrics/min.ts b/src/plugins/data/common/search/aggs/metrics/min.ts index f9e3c5b59d586b..1805546a9fa346 100644 --- a/src/plugins/data/common/search/aggs/metrics/min.ts +++ b/src/plugins/data/common/search/aggs/metrics/min.ts @@ -36,7 +36,7 @@ export const getMinMetricAgg = () => { { name: 'field', type: 'field', - filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE], + filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE, KBN_FIELD_TYPES.HISTOGRAM], }, ], }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 8047807093eefe..e487e185a8c8fe 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -57,7 +57,15 @@ function sortFields(fieldA: IndexPatternField, fieldB: IndexPatternField) { return fieldA.displayName.localeCompare(fieldB.displayName, undefined, { sensitivity: 'base' }); } -const supportedFieldTypes = new Set(['string', 'number', 'boolean', 'date', 'ip', 'document']); +const supportedFieldTypes = new Set([ + 'string', + 'number', + 'boolean', + 'date', + 'ip', + 'histogram', + 'document', +]); const fieldTypeNames: Record = { document: i18n.translate('xpack.lens.datatypes.record', { defaultMessage: 'record' }), @@ -66,6 +74,7 @@ const fieldTypeNames: Record = { boolean: i18n.translate('xpack.lens.datatypes.boolean', { defaultMessage: 'boolean' }), date: i18n.translate('xpack.lens.datatypes.date', { defaultMessage: 'date' }), ip: i18n.translate('xpack.lens.datatypes.ipAddress', { defaultMessage: 'IP' }), + histogram: i18n.translate('xpack.lens.datatypes.histogram', { defaultMessage: 'histogram' }), }; // Wrapper around esQuery.buildEsQuery, handling errors (e.g. because a query can't be parsed) by diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index e11ee580deb9bf..e724a34be20e8f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -32,6 +32,8 @@ const typeToFn: Record = { median: 'aggMedian', }; +const supportedTypes = ['number', 'histogram']; + function buildMetricOperation>({ type, displayName, @@ -61,7 +63,7 @@ function buildMetricOperation>({ timeScalingMode: optionalTimeScaling ? 'optional' : undefined, getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type: fieldType }) => { if ( - fieldType === 'number' && + supportedTypes.includes(fieldType) && aggregatable && (!aggregationRestrictions || aggregationRestrictions[type]) ) { @@ -77,7 +79,7 @@ function buildMetricOperation>({ return Boolean( newField && - newField.type === 'number' && + supportedTypes.includes(newField.type) && newField.aggregatable && (!newField.aggregationRestrictions || newField.aggregationRestrictions![type]) ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx index 07bab16b7096f4..9ac91be5a17ec2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx @@ -68,6 +68,52 @@ describe('percentile', () => { }; }); + describe('getPossibleOperationForField', () => { + it('should accept number', () => { + expect( + percentileOperation.getPossibleOperationForField({ + name: 'bytes', + displayName: 'bytes', + type: 'number', + esTypes: ['long'], + aggregatable: true, + }) + ).toEqual({ + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }); + }); + + it('should accept histogram', () => { + expect( + percentileOperation.getPossibleOperationForField({ + name: 'response_time', + displayName: 'response_time', + type: 'histogram', + esTypes: ['histogram'], + aggregatable: true, + }) + ).toEqual({ + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }); + }); + + it('should reject keywords', () => { + expect( + percentileOperation.getPossibleOperationForField({ + name: 'origin', + displayName: 'origin', + type: 'string', + esTypes: ['keyword'], + aggregatable: true, + }) + ).toBeUndefined(); + }); + }); + describe('toEsAggsFn', () => { it('should reflect params correctly', () => { const percentileColumn = layer.columns.col2 as PercentileIndexPatternColumn; @@ -134,6 +180,34 @@ describe('percentile', () => { }); }); + describe('isTransferable', () => { + it('should transfer from number to histogram', () => { + const indexPattern = createMockedIndexPattern(); + indexPattern.getFieldByName = jest.fn().mockReturnValue({ + name: 'response_time', + displayName: 'response_time', + type: 'histogram', + esTypes: ['histogram'], + aggregatable: true, + }); + expect( + percentileOperation.isTransferable( + { + label: '', + sourceField: 'response_time', + isBucketed: false, + dataType: 'number', + operationType: 'percentile', + params: { + percentile: 95, + }, + }, + indexPattern + ) + ).toBeTruthy(); + }); + }); + describe('param editor', () => { it('should render current percentile', () => { const updateLayerSpy = jest.fn(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx index f236b2932b2d3f..e7654380bd85f1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx @@ -42,6 +42,8 @@ function ofName(name: string, percentile: number) { const DEFAULT_PERCENTILE_VALUE = 95; +const supportedFieldTypes = ['number', 'histogram']; + export const percentileOperation: OperationDefinition = { type: 'percentile', displayName: i18n.translate('xpack.lens.indexPattern.percentile', { @@ -49,7 +51,7 @@ export const percentileOperation: OperationDefinition { - if (fieldType === 'number' && aggregatable && !aggregationRestrictions) { + if (supportedFieldTypes.includes(fieldType) && aggregatable && !aggregationRestrictions) { return { dataType: 'number', isBucketed: false, @@ -62,7 +64,7 @@ export const percentileOperation: OperationDefinition = DatasourceDimensionDropProp dropType: DropType; }; -export type DataType = 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip'; +export type FieldOnlyDataType = 'document' | 'ip' | 'histogram'; +export type DataType = 'string' | 'number' | 'date' | 'boolean' | FieldOnlyDataType; // An operation represents a column in a table, not any information // about how the column was created such as whether it is a sum or average. diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 8b121232162aad..772934160a0584 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -26,6 +26,7 @@ const columnSortOrder = { ip: 3, boolean: 4, number: 5, + histogram: 6, }; /** diff --git a/x-pack/plugins/lens/server/routes/field_stats.ts b/x-pack/plugins/lens/server/routes/field_stats.ts index e1681a74c2951c..7fd884755d86df 100644 --- a/x-pack/plugins/lens/server/routes/field_stats.ts +++ b/x-pack/plugins/lens/server/routes/field_stats.ts @@ -86,7 +86,11 @@ export async function initFieldsRoute(setup: CoreSetup) { return result; }; - if (field.type === 'number') { + if (field.type === 'histogram') { + return res.ok({ + body: await getNumberHistogram(search, field, false), + }); + } else if (field.type === 'number') { return res.ok({ body: await getNumberHistogram(search, field), }); @@ -120,21 +124,31 @@ export async function initFieldsRoute(setup: CoreSetup) { export async function getNumberHistogram( aggSearchWithBody: (body: unknown) => Promise, - field: IFieldType + field: IFieldType, + useTopHits = true ): Promise { const fieldRef = getFieldRef(field); - const searchBody = { + const baseAggs = { + min_value: { + min: { field: field.name }, + }, + max_value: { + max: { field: field.name }, + }, + sample_count: { value_count: { ...fieldRef } }, + }; + const searchWithoutHits = { + sample: { + sampler: { shard_size: SHARD_SIZE }, + aggs: { ...baseAggs }, + }, + }; + const searchWithHits = { sample: { sampler: { shard_size: SHARD_SIZE }, aggs: { - min_value: { - min: { field: field.name }, - }, - max_value: { - max: { field: field.name }, - }, - sample_count: { value_count: { ...fieldRef } }, + ...baseAggs, top_values: { terms: { ...fieldRef, size: 10 }, }, @@ -142,14 +156,18 @@ export async function getNumberHistogram( }, }; - const minMaxResult = (await aggSearchWithBody(searchBody)) as ESSearchResponse< - unknown, - { body: { aggs: typeof searchBody } } - >; + const minMaxResult = (await aggSearchWithBody( + useTopHits ? searchWithHits : searchWithoutHits + )) as + | ESSearchResponse + | ESSearchResponse; const minValue = minMaxResult.aggregations!.sample.min_value.value; const maxValue = minMaxResult.aggregations!.sample.max_value.value; - const terms = minMaxResult.aggregations!.sample.top_values; + const terms = + 'top_values' in minMaxResult.aggregations!.sample + ? minMaxResult.aggregations!.sample.top_values + : { buckets: [] }; const topValuesBuckets = { buckets: terms.buckets.map((bucket) => ({ count: bucket.doc_count, @@ -169,7 +187,12 @@ export async function getNumberHistogram( sampledValues: minMaxResult.aggregations!.sample.sample_count.value!, sampledDocuments: minMaxResult.aggregations!.sample.doc_count, topValues: topValuesBuckets, - histogram: { buckets: [] }, + histogram: useTopHits + ? { buckets: [] } + : { + // Insert a fake bucket for a single-value histogram + buckets: [{ count: minMaxResult.aggregations!.sample.doc_count, key: minValue }], + }, }; } diff --git a/x-pack/test/api_integration/apis/lens/field_stats.ts b/x-pack/test/api_integration/apis/lens/field_stats.ts index 2cfce5ef31305a..ac4ebb4e5b02c2 100644 --- a/x-pack/test/api_integration/apis/lens/field_stats.ts +++ b/x-pack/test/api_integration/apis/lens/field_stats.ts @@ -23,10 +23,12 @@ export default ({ getService }: FtrProviderContext) => { before(async () => { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.loadIfNeeded('visualize/default'); + await esArchiver.loadIfNeeded('pre_calculated_histogram'); }); after(async () => { await esArchiver.unload('logstash_functional'); await esArchiver.unload('visualize/default'); + await esArchiver.unload('pre_calculated_histogram'); }); describe('field distribution', () => { @@ -347,6 +349,101 @@ export default ({ getService }: FtrProviderContext) => { }); }); + it('should return an auto histogram for precalculated histograms', async () => { + const { body } = await supertest + .post('/api/lens/index_stats/histogram-test/field') + .set(COMMON_HEADERS) + .send({ + dslQuery: { match_all: {} }, + fromDate: TEST_START_TIME, + toDate: TEST_END_TIME, + field: { + name: 'histogram-content', + type: 'histogram', + }, + }) + .expect(200); + + expect(body).to.eql({ + histogram: { + buckets: [ + { + count: 237, + key: 0, + }, + { + count: 323, + key: 0.47000000000000003, + }, + { + count: 454, + key: 0.9400000000000001, + }, + { + count: 166, + key: 1.4100000000000001, + }, + { + count: 168, + key: 1.8800000000000001, + }, + { + count: 425, + key: 2.35, + }, + { + count: 311, + key: 2.8200000000000003, + }, + { + count: 391, + key: 3.29, + }, + { + count: 406, + key: 3.7600000000000002, + }, + { + count: 324, + key: 4.23, + }, + { + count: 628, + key: 4.7, + }, + ], + }, + sampledDocuments: 7, + sampledValues: 3833, + totalDocuments: 7, + topValues: { buckets: [] }, + }); + }); + + it('should return a single-value histogram when filtering a precalculated histogram', async () => { + const { body } = await supertest + .post('/api/lens/index_stats/histogram-test/field') + .set(COMMON_HEADERS) + .send({ + dslQuery: { match: { 'histogram-title': 'single value' } }, + fromDate: TEST_START_TIME, + toDate: TEST_END_TIME, + field: { + name: 'histogram-content', + type: 'histogram', + }, + }) + .expect(200); + + expect(body).to.eql({ + histogram: { buckets: [{ count: 1, key: 1 }] }, + sampledDocuments: 1, + sampledValues: 1, + totalDocuments: 1, + topValues: { buckets: [] }, + }); + }); + it('should return histograms for scripted date fields', async () => { const { body } = await supertest .post('/api/lens/index_stats/logstash-2015.09.22/field') diff --git a/x-pack/test/functional/apps/visualize/precalculated_histogram.ts b/x-pack/test/functional/apps/visualize/precalculated_histogram.ts index a0c8fa3b21ffd2..151c9e981250f9 100644 --- a/x-pack/test/functional/apps/visualize/precalculated_histogram.ts +++ b/x-pack/test/functional/apps/visualize/precalculated_histogram.ts @@ -64,7 +64,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('with average aggregation', async () => { const data = await renderTableForAggregation('Average'); - expect(data).to.eql([['2.8510720308359434']]); + expect(data).to.eql([['2.8653795982259327']]); }); it('with median aggregation', async () => { @@ -79,7 +79,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('with sum aggregation', async () => { const data = await renderTableForAggregation('Sum'); - expect(data).to.eql([['11834.800000000001']]); + expect(data).to.eql([['10983']]); }); }); }); diff --git a/x-pack/test/functional/es_archives/pre_calculated_histogram/data.json b/x-pack/test/functional/es_archives/pre_calculated_histogram/data.json index cab1dbdf844839..121a4036aaacdd 100644 --- a/x-pack/test/functional/es_archives/pre_calculated_histogram/data.json +++ b/x-pack/test/functional/es_archives/pre_calculated_histogram/data.json @@ -5,8 +5,7 @@ "index": ".kibana", "source": { "index-pattern": { - "title": "histogram-test", - "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"histogram-content\",\"type\":\"histogram\",\"esTypes\":[\"histogram\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"histogram-title\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]" + "title": "histogram-test" }, "type": "index-pattern" } @@ -195,3 +194,22 @@ } } } + +{ + "type": "doc", + "value": { + "id": "5e694159d909d9d99b5e12d1", + "index": "histogram-test", + "source": { + "histogram-title": "single value", + "histogram-content": { + "values": [ + 1 + ], + "counts": [ + 1 + ] + } + } + } +} From 6569117b06f94370f1a814ff1a3601fba379c8d1 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 16 Feb 2021 17:52:03 +0100 Subject: [PATCH 077/175] [Expressions] Cancel nested executions when main execution is canceled (#91486) --- .../execution/execution.abortion.test.ts | 71 +++++++++++++++++++ .../expressions/common/execution/execution.ts | 14 +++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/plugins/expressions/common/execution/execution.abortion.test.ts b/src/plugins/expressions/common/execution/execution.abortion.test.ts index 7f6141b60e9a78..33bb7826917473 100644 --- a/src/plugins/expressions/common/execution/execution.abortion.test.ts +++ b/src/plugins/expressions/common/execution/execution.abortion.test.ts @@ -6,9 +6,11 @@ * Side Public License, v 1. */ +import { waitFor } from '@testing-library/react'; import { Execution } from './execution'; import { parseExpression } from '../ast'; import { createUnitTestExecutor } from '../test_helpers'; +import { ExpressionFunctionDefinition } from '../expression_functions'; jest.useFakeTimers(); @@ -81,4 +83,73 @@ describe('Execution abortion tests', () => { jest.useFakeTimers(); }); + + test('nested expressions are aborted when parent aborted', async () => { + jest.useRealTimers(); + const started = jest.fn(); + const completed = jest.fn(); + const aborted = jest.fn(); + + const defer: ExpressionFunctionDefinition<'defer', any, { time: number }, any> = { + name: 'defer', + args: { + time: { + aliases: ['_'], + help: 'Calls function from a context after delay unless aborted', + types: ['number'], + }, + }, + help: '', + fn: async (input, args, { abortSignal }) => { + started(); + await new Promise((r) => { + const timeout = setTimeout(() => { + if (!abortSignal.aborted) { + completed(); + } + r(undefined); + }, args.time); + + abortSignal.addEventListener('abort', () => { + aborted(); + clearTimeout(timeout); + r(undefined); + }); + }); + + return args.time; + }, + }; + + const expression = 'defer time={defer time={defer time=300}}'; + const executor = createUnitTestExecutor(); + executor.registerFunction(defer); + const execution = new Execution({ + executor, + ast: parseExpression(expression), + params: {}, + }); + + execution.start(); + + await waitFor(() => expect(started).toHaveBeenCalledTimes(1)); + + execution.cancel(); + const result = await execution.result; + expect(result).toMatchObject({ + type: 'error', + error: { + message: 'The expression was aborted.', + name: 'AbortError', + }, + }); + + await waitFor(() => expect(aborted).toHaveBeenCalledTimes(1)); + + expect(started).toHaveBeenCalledTimes(1); + expect(aborted).toHaveBeenCalledTimes(1); + expect(completed).toHaveBeenCalledTimes(0); + + jest.useFakeTimers(); + }); }); diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index e555258a0cf998..bf545a0075bed7 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -120,6 +120,13 @@ export class Execution< */ private readonly firstResultFuture = new Defer(); + /** + * Keeping track of any child executions + * Needed to cancel child executions in case parent execution is canceled + * @private + */ + private readonly childExecutions: Execution[] = []; + /** * Contract is a public representation of `Execution` instances. Contract we * can return to other plugins for their consumption. @@ -203,8 +210,10 @@ export class Execution< const chainPromise = this.invokeChain(this.state.get().ast.chain, input); this.race(chainPromise).then(resolve, (error) => { - if (this.abortController.signal.aborted) resolve(createAbortErrorValue()); - else reject(error); + if (this.abortController.signal.aborted) { + this.childExecutions.forEach((ex) => ex.cancel()); + resolve(createAbortErrorValue()); + } else reject(error); }); this.firstResultFuture.promise @@ -460,6 +469,7 @@ export class Execution< ast as ExpressionAstExpression, this.execution.params ); + this.childExecutions.push(execution); execution.start(input); return await execution.result; case 'string': From 2fe0d05375f98ce858abb9cf12c47d83cc38b5bc Mon Sep 17 00:00:00 2001 From: Dmitry Date: Tue, 16 Feb 2021 18:02:56 +0100 Subject: [PATCH 078/175] [coverage] fix jest merging (#90915) * [coverage] fix jest merging * fix path to functional results * revert wrong change * fix ingest script * fix ingest script * [coverage plugin] change id to camelCase Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../code_coverage/shell_scripts/copy_jest_report.sh | 10 ++++++++++ .../shell_scripts/fix_html_reports_parallel.sh | 6 ------ .../generate_team_assignments_and_ingest_coverage.sh | 7 ++++++- ...erge_jest_and_functional.sh => merge_functional.sh} | 4 +--- test/common/fixtures/plugins/coverage/kibana.json | 2 +- test/scripts/jenkins_unit.sh | 3 +++ vars/kibanaCoverage.groovy | 9 +++++---- vars/kibanaPipeline.groovy | 2 +- 8 files changed, 27 insertions(+), 16 deletions(-) create mode 100755 src/dev/code_coverage/shell_scripts/copy_jest_report.sh rename src/dev/code_coverage/shell_scripts/{merge_jest_and_functional.sh => merge_functional.sh} (54%) mode change 100644 => 100755 diff --git a/src/dev/code_coverage/shell_scripts/copy_jest_report.sh b/src/dev/code_coverage/shell_scripts/copy_jest_report.sh new file mode 100755 index 00000000000000..8369d5b467c029 --- /dev/null +++ b/src/dev/code_coverage/shell_scripts/copy_jest_report.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +EXTRACT_START_DIR=tmp/extracted_coverage +EXTRACT_END_DIR=target/kibana-coverage +COMBINED_EXRACT_DIR=/${EXTRACT_START_DIR}/${EXTRACT_END_DIR} + + +echo "### Copy combined jest report" +mkdir -p $EXTRACT_END_DIR/jest-combined +cp -r $COMBINED_EXRACT_DIR/jest-combined/. $EXTRACT_END_DIR/jest-combined/ diff --git a/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh b/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh index 01003b6dc880c7..6e6ba9e1b11180 100644 --- a/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh +++ b/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh @@ -7,12 +7,6 @@ COMBINED_EXRACT_DIR=/${EXTRACT_START_DIR}/${EXTRACT_END_DIR} PWD=$(pwd) du -sh $COMBINED_EXRACT_DIR -echo "### Jest: replacing path in json files" -for i in oss oss-integration xpack; do - sed -i "s|/dev/shm/workspace/kibana|${PWD}|g" $COMBINED_EXRACT_DIR/jest/${i}-coverage-final.json & -done -wait - echo "### Functional: replacing path in json files" for i in {1..9}; do sed -i "s|/dev/shm/workspace/kibana|${PWD}|g" $COMBINED_EXRACT_DIR/functional/${i}*.json & diff --git a/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh b/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh index caa1f1a7613670..243dbaa6197e6d 100644 --- a/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh +++ b/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh @@ -32,11 +32,16 @@ TEAM_ASSIGN_PATH=$5 # Build team assignments dat file node scripts/generate_team_assignments.js --verbose --src .github/CODEOWNERS --dest $TEAM_ASSIGN_PATH -for x in jest functional; do +for x in functional jest; do echo "### Ingesting coverage for ${x}" COVERAGE_SUMMARY_FILE=target/kibana-coverage/${x}-combined/coverage-summary.json + if [[ $x == "jest" ]]; then + # Need to override COVERAGE_INGESTION_KIBANA_ROOT since json file has original intake worker path + export COVERAGE_INGESTION_KIBANA_ROOT=/dev/shm/workspace/kibana + fi + node scripts/ingest_coverage.js --verbose --path ${COVERAGE_SUMMARY_FILE} --vcsInfoPath ./VCS_INFO.txt --teamAssignmentsPath $TEAM_ASSIGN_PATH done diff --git a/src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh b/src/dev/code_coverage/shell_scripts/merge_functional.sh old mode 100644 new mode 100755 similarity index 54% rename from src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh rename to src/dev/code_coverage/shell_scripts/merge_functional.sh index 707c6de3f88a08..5f03e5f24528a7 --- a/src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh +++ b/src/dev/code_coverage/shell_scripts/merge_functional.sh @@ -4,6 +4,4 @@ COVERAGE_TEMP_DIR=/tmp/extracted_coverage/target/kibana-coverage/ export COVERAGE_TEMP_DIR echo "### Merge coverage reports" -for x in jest functional; do - yarn nyc report --nycrc-path src/dev/code_coverage/nyc_config/nyc.${x}.config.js -done +yarn nyc report --nycrc-path src/dev/code_coverage/nyc_config/nyc.functional.config.js diff --git a/test/common/fixtures/plugins/coverage/kibana.json b/test/common/fixtures/plugins/coverage/kibana.json index d80432534d7467..d849db8d0583d9 100644 --- a/test/common/fixtures/plugins/coverage/kibana.json +++ b/test/common/fixtures/plugins/coverage/kibana.json @@ -1,5 +1,5 @@ { - "id": "coverage-fixtures", + "id": "coverageFixtures", "version": "kibana", "server": false, "ui": true diff --git a/test/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index 9e387f97a016e8..a483f8378b8b41 100755 --- a/test/scripts/jenkins_unit.sh +++ b/test/scripts/jenkins_unit.sh @@ -32,4 +32,7 @@ else echo " -> Running jest integration tests with coverage" node scripts/jest_integration --ci --verbose --coverage || true; + + echo " -> Combine code coverage in a single report" + yarn nyc report --nycrc-path src/dev/code_coverage/nyc_config/nyc.jest.config.js fi diff --git a/vars/kibanaCoverage.groovy b/vars/kibanaCoverage.groovy index e393f3a5d2150f..1d5fd211f830f1 100644 --- a/vars/kibanaCoverage.groovy +++ b/vars/kibanaCoverage.groovy @@ -148,9 +148,10 @@ def generateReports(title) { cd .. . src/dev/code_coverage/shell_scripts/extract_archives.sh . src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh - . src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh - # zip combined reports - tar -czf kibana-coverage.tar.gz target/kibana-coverage/**/* + . src/dev/code_coverage/shell_scripts/merge_functional.sh + . src/dev/code_coverage/shell_scripts/copy_jest_report.sh + # zip functional combined report + tar -czf kibana-functional-coverage.tar.gz target/kibana-coverage/functional-combined/* """, title) } @@ -162,7 +163,7 @@ def uploadCombinedReports() { kibanaPipeline.uploadGcsArtifact( "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/coverage/combined", - 'kibana-coverage.tar.gz' + 'kibana-functional-coverage.tar.gz' ) } diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 5efcea3edb9bb2..7adf755bfc5834 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -188,7 +188,7 @@ def withGcsArtifactUpload(workerName, closure) { def ARTIFACT_PATTERNS = [ 'target/junit/**/*', 'target/kibana-*', - 'target/kibana-coverage/**/*', + 'target/kibana-coverage/jest/**/*', 'target/kibana-security-solution/**/*.png', 'target/test-metrics/*', 'target/test-suites-ci-plan.json', From 95e6d135906f52269cc5083affe3d9c904fd4624 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Tue, 16 Feb 2021 12:48:30 -0500 Subject: [PATCH 079/175] [CI] Use custom github action for backport (#91523) --- .github/workflows/backport.yml | 42 ++++++++++++---------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 238a21161b1297..79571d51659d6c 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -18,34 +18,20 @@ jobs: ) runs-on: ubuntu-latest steps: - - name: 'Get backport config' - run: | - curl 'https://raw.githubusercontent.com/elastic/kibana/master/.backportrc.json' > .backportrc.json - - - name: Use Node.js 14.x - uses: actions/setup-node@v1 + - name: Checkout Actions + uses: actions/checkout@v2 with: - node-version: 14.x - - - name: Install backport CLI - run: npm install -g backport@5.6.4 + repository: 'elastic/kibana-github-actions' + ref: main + path: ./actions - - name: Backport PR - run: | - git config --global user.name "kibanamachine" - git config --global user.email "42973632+kibanamachine@users.noreply.github.com" - backport --fork true --username kibanamachine --accessToken "${{ secrets.KIBANAMACHINE_TOKEN }}" --ci --pr "$PR_NUMBER" --labels backport --assignee "$PR_OWNER" | tee 'output.log' - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_OWNER: ${{ github.event.pull_request.user.login }} + - name: Install Actions + run: npm install --production --prefix ./actions - - name: Report backport status - run: | - COMMENT="Backport result - \`\`\` - $(cat output.log) - \`\`\`" - - GITHUB_TOKEN="${{ secrets.KIBANAMACHINE_TOKEN }}" gh api -X POST repos/elastic/kibana/issues/$PR_NUMBER/comments -F body="$COMMENT" - env: - PR_NUMBER: ${{ github.event.pull_request.number }} + - name: Run Backport + uses: ./actions/backport + with: + branch: master + github_token: ${{secrets.KIBANAMACHINE_TOKEN}} + commit_user: kibanamachine + commit_email: 42973632+kibanamachine@users.noreply.github.com From a1a9769f83a1fde5b03d02ed99f68c2dbec8ef8e Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 16 Feb 2021 18:53:33 +0100 Subject: [PATCH 080/175] [Uptime] increase flaky alert retry time (#91455) --- .github/CODEOWNERS | 9 +++++++-- .../functional_with_es_ssl/apps/uptime/alert_flyout.ts | 5 ++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4b0479eedea988..b45ff51b70da3c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -74,7 +74,13 @@ #CC# /src/plugins/apm_oss/ @elastic/apm-ui #CC# /x-pack/plugins/observability/ @elastic/apm-ui -# Client Side Monitoring (lives in APM directories but owned by Uptime) +# Uptime +/x-pack/plugins/uptime @elastic/uptime +/x-pack/test/functional_with_es_ssl/apps/uptime @elastic/uptime +/x-pack/test/functional/apps/uptime @elastic/uptime +/x-pack/test/api_integration/apis/uptime @elastic/uptime + +# Client Side Monitoring / Uptime (lives in APM directories but owned by Uptime) /x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm @elastic/uptime /x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature @elastic/uptime /x-pack/plugins/apm/public/application/csmApp.tsx @elastic/uptime @@ -106,7 +112,6 @@ /x-pack/plugins/fleet/ @elastic/fleet /x-pack/plugins/observability/ @elastic/observability-ui /x-pack/plugins/monitoring/ @elastic/stack-monitoring-ui -/x-pack/plugins/uptime @elastic/uptime # Machine Learning /x-pack/plugins/ml/ @elastic/ml-ui diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts index 057b43913b939f..7274105413145b 100644 --- a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts @@ -9,8 +9,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { - // FLAKY: https://github.com/elastic/kibana/issues/88177 - describe.skip('uptime alerts', () => { + describe('uptime alerts', () => { const pageObjects = getPageObjects(['common', 'uptime']); const supertest = getService('supertest'); const retry = getService('retry'); @@ -90,7 +89,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // the previous line resolves, the API may not be done creating the alert yet, so we // put the fetch code in a retry block with a timeout. let alert: any; - await retry.tryForTime(15000, async () => { + await retry.tryForTime(60 * 1000, async () => { const apiResponse = await supertest.get('/api/alerts/_find?search=uptime-test'); const alertsFromThisTest = apiResponse.body.data.filter( ({ name }: { name: string }) => name === 'uptime-test' From f2e4cce0a2fb5413eccde1a5eaba0aaf56ae44fc Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 16 Feb 2021 10:00:29 -0800 Subject: [PATCH 081/175] [App Search] Set up Curations routes & complete 'Edit Query' action in Analytics tables (#91052) * Set up Curations routes * Update EngineRouter/Nav with Curations * Set up Curations find_or_create API * [bug] Fix view action not working correctly for "" query * Add Edit query action - to call find_or_create curation API & navigate to curation page + fix copy string, only just noticed this :doh: * Add/update unit tests for action column - Refactor out into a single shared test helper file that both AnalyticsTable and RecentQueriesTable simply calls & runs (instead of copying and pasting the same tests twice into 2 diff files) - note: test file can't be `.test.tsx` or Jest tries to automatically run it, which we don't want Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../analytics_tables/analytics_table.test.tsx | 19 ++--- .../recent_queries_table.test.tsx | 19 ++--- .../analytics_tables/shared_columns.tsx | 27 ++++-- .../analytics_tables/shared_columns_tests.tsx | 82 +++++++++++++++++++ .../curations/curations_router.test.tsx | 22 +++++ .../components/curations/curations_router.tsx | 55 +++++++++++++ .../app_search/components/curations/index.ts | 1 + .../components/engine/engine_nav.tsx | 4 +- .../components/engine/engine_router.test.tsx | 10 ++- .../components/engine/engine_router.tsx | 12 ++- .../public/applications/app_search/routes.ts | 7 +- .../routes/app_search/curations.test.ts | 47 +++++++++++ .../server/routes/app_search/curations.ts | 32 ++++++++ .../server/routes/app_search/index.ts | 2 + 14 files changed, 297 insertions(+), 42 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx index 2eac65fc210917..593f70cda404c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx @@ -5,18 +5,18 @@ * 2.0. */ -import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__'; +import { mountWithIntl } from '../../../../../__mocks__'; import '../../../../__mocks__/engine_logic.mock'; import React from 'react'; import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; +import { runActionColumnTests } from './shared_columns_tests'; + import { AnalyticsTable } from './'; describe('AnalyticsTable', () => { - const { navigateToUrl } = mockKibanaValues; - const items = [ { key: 'some search', @@ -69,18 +69,9 @@ describe('AnalyticsTable', () => { expect(tableContent).toContain('0'); }); - it('renders an action column', () => { + describe('renders an action column', () => { const wrapper = mountWithIntl(); - const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first(); - const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first(); - - viewQuery.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith( - '/engines/some-engine/analytics/query_detail/some%20search' - ); - - editQuery.simulate('click'); - // TODO + runActionColumnTests(wrapper); }); it('renders an empty prompt if no items are passed', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx index a5a582d3747bcc..f90d86908d470e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx @@ -5,18 +5,18 @@ * 2.0. */ -import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__'; +import { mountWithIntl } from '../../../../../__mocks__'; import '../../../../__mocks__/engine_logic.mock'; import React from 'react'; import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; +import { runActionColumnTests } from './shared_columns_tests'; + import { RecentQueriesTable } from './'; describe('RecentQueriesTable', () => { - const { navigateToUrl } = mockKibanaValues; - const items = [ { query_string: 'some search', @@ -63,18 +63,9 @@ describe('RecentQueriesTable', () => { expect(tableContent).toContain('3'); }); - it('renders an action column', () => { + describe('renders an action column', () => { const wrapper = mountWithIntl(); - const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first(); - const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first(); - - viewQuery.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith( - '/engines/some-engine/analytics/query_detail/some%20search' - ); - - editQuery.simulate('click'); - // TODO + runActionColumnTests(wrapper); }); it('renders an empty prompt if no items are passed', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx index 9d8365a2f7af10..6c3d2539035aee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx @@ -9,10 +9,12 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { flashAPIErrors } from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; import { KibanaLogic } from '../../../../../shared/kibana'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; -import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../../routes'; -import { generateEnginePath } from '../../../engine'; +import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH, ENGINE_CURATION_PATH } from '../../../../routes'; +import { generateEnginePath, EngineLogic } from '../../../engine'; import { Query, RecentQuery } from '../../types'; import { InlineTagsList } from './inline_tags_list'; @@ -63,7 +65,7 @@ export const ACTIONS_COLUMN = { onClick: (item: Query | RecentQuery) => { const { navigateToUrl } = KibanaLogic.values; - const query = (item as Query).key || (item as RecentQuery).query_string; + const query = (item as Query).key || (item as RecentQuery).query_string || '""'; navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })); }, 'data-test-subj': 'AnalyticsTableViewQueryButton', @@ -74,12 +76,25 @@ export const ACTIONS_COLUMN = { }), description: i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.analytics.table.editTooltip', - { defaultMessage: 'Edit query analytics' } + { defaultMessage: 'Edit query' } ), type: 'icon', icon: 'pencil', - onClick: () => { - // TODO: CurationsLogic + onClick: async (item: Query | RecentQuery) => { + const { http } = HttpLogic.values; + const { navigateToUrl } = KibanaLogic.values; + const { engineName } = EngineLogic.values; + + try { + const query = (item as Query).key || (item as RecentQuery).query_string || '""'; + const response = await http.get( + `/api/app_search/engines/${engineName}/curations/find_or_create`, + { query: { query } } + ); + navigateToUrl(generateEnginePath(ENGINE_CURATION_PATH, { curationId: response.id })); + } catch (e) { + flashAPIErrors(e); + } }, 'data-test-subj': 'AnalyticsTableEditQueryButton', }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx new file mode 100644 index 00000000000000..cb78a6585e43c8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx @@ -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 { + mockHttpValues, + mockKibanaValues, + mockFlashMessageHelpers, +} from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import { ReactWrapper } from 'enzyme'; + +import { nextTick } from '@kbn/test/jest'; + +export const runActionColumnTests = (wrapper: ReactWrapper) => { + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('view action', () => { + it('navigates to the query detail view', () => { + wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first().simulate('click'); + + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some%20search' + ); + }); + + it('falls back to "" for the empty query', () => { + wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').last().simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/%22%22' + ); + }); + }); + + describe('edit action', () => { + it('calls the find_or_create curation API, then navigates the user to the curation', async () => { + http.get.mockReturnValue(Promise.resolve({ id: 'cur-123456789' })); + wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first().simulate('click'); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/api/app_search/engines/some-engine/curations/find_or_create', + { + query: { query: 'some search' }, + } + ); + expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-123456789'); + }); + + it('falls back to "" for the empty query', async () => { + http.get.mockReturnValue(Promise.resolve({ id: 'cur-987654321' })); + wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').last().simulate('click'); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/api/app_search/engines/some-engine/curations/find_or_create', + { + query: { query: '""' }, + } + ); + expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-987654321'); + }); + + it('handles API errors', async () => { + http.get.mockReturnValue(Promise.reject()); + wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first().simulate('click'); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalled(); + }); + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx new file mode 100644 index 00000000000000..047d00ad98a0d5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { shallow } from 'enzyme'; + +import { CurationsRouter } from './'; + +describe('CurationsRouter', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(Switch)).toHaveLength(1); + expect(wrapper.find(Route)).toHaveLength(5); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx new file mode 100644 index 00000000000000..a7f99044cc1c37 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx @@ -0,0 +1,55 @@ +/* + * 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 { Route, Switch } from 'react-router-dom'; + +import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; +import { NotFound } from '../../../shared/not_found'; +import { + ENGINE_CURATIONS_PATH, + ENGINE_CURATIONS_NEW_PATH, + ENGINE_CURATION_PATH, + ENGINE_CURATION_ADD_RESULT_PATH, +} from '../../routes'; + +import { CURATIONS_TITLE } from './constants'; + +interface Props { + engineBreadcrumb: BreadcrumbTrail; +} +export const CurationsRouter: React.FC = ({ engineBreadcrumb }) => { + const CURATIONS_BREADCRUMB = [...engineBreadcrumb, CURATIONS_TITLE]; + + return ( + + + + TODO: Curations overview + + + + TODO: Curation creation view + + + + TODO: Curation view (+ show a NotFound view if ID is invalid) + + + + TODO: Curation Add Result view + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/index.ts index f1eb95a0c878cb..075bc1368b3003 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/index.ts @@ -6,3 +6,4 @@ */ export { CURATIONS_TITLE } from './constants'; +export { CurationsRouter } from './curations_router'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index 447e4d678bcdb6..a4ce724fdb0974 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -220,8 +220,8 @@ export const EngineNav: React.FC = () => { )} {canManageEngineCurations && ( {CURATIONS_TITLE} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index 3740882dee3db2..e6b829a43dcc1c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -17,6 +17,7 @@ import { shallow } from 'enzyme'; import { Loading } from '../../../shared/loading'; import { AnalyticsRouter } from '../analytics'; +import { CurationsRouter } from '../curations'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; @@ -97,7 +98,14 @@ describe('EngineRouter', () => { expect(wrapper.find(AnalyticsRouter)).toHaveLength(1); }); - it('renders an relevance tuning view', () => { + it('renders a curations view', () => { + setMockValues({ ...values, myRole: { canManageEngineCurations: true } }); + const wrapper = shallow(); + + expect(wrapper.find(CurationsRouter)).toHaveLength(1); + }); + + it('renders a relevance tuning view', () => { setMockValues({ ...values, myRole: { canManageEngineRelevanceTuning: true } }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 2f1c3bc57d331e..305bdf74ae501b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -28,12 +28,13 @@ import { // META_ENGINE_SOURCE_ENGINES_PATH, ENGINE_RELEVANCE_TUNING_PATH, // ENGINE_SYNONYMS_PATH, - // ENGINE_CURATIONS_PATH, + ENGINE_CURATIONS_PATH, // ENGINE_RESULT_SETTINGS_PATH, // ENGINE_SEARCH_UI_PATH, // ENGINE_API_LOGS_PATH, } from '../../routes'; import { AnalyticsRouter } from '../analytics'; +import { CurationsRouter } from '../curations'; import { DocumentDetail, Documents } from '../documents'; import { OVERVIEW_TITLE } from '../engine_overview'; import { EngineOverview } from '../engine_overview'; @@ -46,13 +47,13 @@ export const EngineRouter: React.FC = () => { const { myRole: { canViewEngineAnalytics, - canManageEngineRelevanceTuning, // canViewEngineDocuments, // canViewEngineSchema, // canViewEngineCrawler, // canViewMetaEngineSourceEngines, + canManageEngineRelevanceTuning, // canManageEngineSynonyms, - // canManageEngineCurations, + canManageEngineCurations, // canManageEngineResultSettings, // canManageEngineSearchUi, // canViewEngineApiLogs, @@ -97,6 +98,11 @@ export const EngineRouter: React.FC = () => { + {canManageEngineCurations && ( + + + + )} {canManageEngineRelevanceTuning && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index dee8858fada8b4..6fe9be083405e0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -44,9 +44,12 @@ export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`; export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/relevance_tuning`; export const ENGINE_SYNONYMS_PATH = `${ENGINE_PATH}/synonyms`; -export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`; -// TODO: Curations sub-pages export const ENGINE_RESULT_SETTINGS_PATH = `${ENGINE_PATH}/result-settings`; +export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`; +export const ENGINE_CURATIONS_NEW_PATH = `${ENGINE_CURATIONS_PATH}/new`; +export const ENGINE_CURATION_PATH = `${ENGINE_CURATIONS_PATH}/:curationId`; +export const ENGINE_CURATION_ADD_RESULT_PATH = `${ENGINE_CURATIONS_PATH}/:curationId/add_result`; + export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/reference_application/new`; export const ENGINE_API_LOGS_PATH = `${ENGINE_PATH}/api-logs`; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts new file mode 100644 index 00000000000000..5b5d132591f4ef --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts @@ -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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerCurationsRoutes } from './curations'; + +describe('curations routes', () => { + describe('GET /api/app_search/engines/{engineName}/curations/find_or_create', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/curations/find_or_create', + }); + + registerCurationsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/curations/find_or_create', + }); + }); + + describe('validates', () => { + it('required query param', () => { + const request = { query: { query: 'some query' } }; + mockRouter.shouldValidate(request); + }); + + it('missing query', () => { + const request = { query: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts new file mode 100644 index 00000000000000..a4addb3ad0d3ab --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerCurationsRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/curations/find_or_create', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + query: schema.object({ + query: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/curations/find_or_create', + }) + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 92fdcb689db1d2..90b86138a4a6d3 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -9,6 +9,7 @@ import { RouteDependencies } from '../../plugin'; import { registerAnalyticsRoutes } from './analytics'; import { registerCredentialsRoutes } from './credentials'; +import { registerCurationsRoutes } from './curations'; import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; import { registerEnginesRoutes } from './engines'; import { registerSearchSettingsRoutes } from './search_settings'; @@ -21,5 +22,6 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerAnalyticsRoutes(dependencies); registerDocumentsRoutes(dependencies); registerDocumentRoutes(dependencies); + registerCurationsRoutes(dependencies); registerSearchSettingsRoutes(dependencies); }; From d5aea9378a4dc54347022fea107f60f52263d2ba Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 16 Feb 2021 13:02:15 -0500 Subject: [PATCH 082/175] Further optimize check privileges response validation (#90631) --- .../validate_es_response.test.ts.snap | 6 +-- .../authorization/check_privileges.test.ts | 12 ++--- .../authorization/validate_es_response.ts | 45 ++++++++++++------- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap index 76d284a21984e9..04190fbf5eacdd 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap @@ -1,12 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`validateEsPrivilegeResponse fails validation when an action is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: [action3]: expected value of type [boolean] but got [string]"`; +exports[`validateEsPrivilegeResponse fails validation when an action is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: expected value of type [boolean] but got [string]"`; -exports[`validateEsPrivilegeResponse fails validation when an action is missing in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: [action2]: expected value of type [boolean] but got [undefined]"`; +exports[`validateEsPrivilegeResponse fails validation when an action is missing in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: Payload did not match expected actions"`; exports[`validateEsPrivilegeResponse fails validation when an expected resource property is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: Payload did not match expected resources"`; -exports[`validateEsPrivilegeResponse fails validation when an extra action is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: [action4]: definition for this key is missing"`; +exports[`validateEsPrivilegeResponse fails validation when an extra action is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: Payload did not match expected actions"`; exports[`validateEsPrivilegeResponse fails validation when an extra application is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.otherApplication]: definition for this key is missing"`; diff --git a/x-pack/plugins/security/server/authorization/check_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_privileges.test.ts index 93f5efed58fb8d..5bca46f22a5123 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -316,7 +316,7 @@ describe('#atSpace', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [saved_object:bar-type/get]: definition for this key is missing]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); @@ -338,7 +338,7 @@ describe('#atSpace', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [saved_object:foo-type/get]: expected value of type [boolean] but got [undefined]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); }); @@ -1092,7 +1092,7 @@ describe('#atSpaces', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [mock-action:version]: expected value of type [boolean] but got [undefined]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); @@ -2266,7 +2266,7 @@ describe('#globally', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [mock-action:version]: expected value of type [boolean] but got [undefined]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); @@ -2384,7 +2384,7 @@ describe('#globally', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [saved_object:bar-type/get]: definition for this key is missing]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); @@ -2405,7 +2405,7 @@ describe('#globally', () => { }, }); expect(result).toMatchInlineSnapshot( - `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: [saved_object:foo-type/get]: expected value of type [boolean] but got [undefined]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); }); diff --git a/x-pack/plugins/security/server/authorization/validate_es_response.ts b/x-pack/plugins/security/server/authorization/validate_es_response.ts index 19afaaf035c15e..270ff26716e3f2 100644 --- a/x-pack/plugins/security/server/authorization/validate_es_response.ts +++ b/x-pack/plugins/security/server/authorization/validate_es_response.ts @@ -8,6 +8,11 @@ import { schema } from '@kbn/config-schema'; import { HasPrivilegesResponse } from './types'; +/** + * Validates an Elasticsearch "Has privileges" response against the expected application, actions, and resources. + * + * Note: the `actions` and `resources` parameters must be unique string arrays; any duplicates will cause validation to fail. + */ export function validateEsPrivilegeResponse( response: HasPrivilegesResponse, application: string, @@ -24,21 +29,29 @@ export function validateEsPrivilegeResponse( return response; } -function buildActionsValidationSchema(actions: string[]) { - return schema.object({ - ...actions.reduce>((acc, action) => { - return { - ...acc, - [action]: schema.boolean(), - }; - }, {}), - }); -} - function buildValidationSchema(application: string, actions: string[], resources: string[]) { - const actionValidationSchema = buildActionsValidationSchema(actions); + const actionValidationSchema = schema.boolean(); + const actionsValidationSchema = schema.object( + {}, + { + unknowns: 'allow', + validate: (value) => { + const actualActions = Object.keys(value).sort(); + if ( + actions.length !== actualActions.length || + ![...actions].sort().every((x, i) => x === actualActions[i]) + ) { + throw new Error('Payload did not match expected actions'); + } + + Object.values(value).forEach((actionResult) => { + actionValidationSchema.validate(actionResult); + }); + }, + } + ); - const resourceValidationSchema = schema.object( + const resourcesValidationSchema = schema.object( {}, { unknowns: 'allow', @@ -46,13 +59,13 @@ function buildValidationSchema(application: string, actions: string[], resources const actualResources = Object.keys(value).sort(); if ( resources.length !== actualResources.length || - !resources.sort().every((x, i) => x === actualResources[i]) + ![...resources].sort().every((x, i) => x === actualResources[i]) ) { throw new Error('Payload did not match expected resources'); } Object.values(value).forEach((actionResult) => { - actionValidationSchema.validate(actionResult); + actionsValidationSchema.validate(actionResult); }); }, } @@ -63,7 +76,7 @@ function buildValidationSchema(application: string, actions: string[], resources has_all_requested: schema.boolean(), cluster: schema.object({}, { unknowns: 'allow' }), application: schema.object({ - [application]: resourceValidationSchema, + [application]: resourcesValidationSchema, }), index: schema.object({}, { unknowns: 'allow' }), }); From 6f857dd7f3bc8c4ee5322b85cb558c639aa14b9b Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Tue, 16 Feb 2021 13:03:56 -0500 Subject: [PATCH 083/175] [Time to Visualize] Combine Discard & Cancel (#91267) * recombined discard and cancel button functionality --- .../application/listing/confirm_overlays.tsx | 54 +++++++++++++++ .../application/top_nav/dashboard_top_nav.tsx | 67 ++++++++++++------- .../application/top_nav/get_top_nav_config.ts | 19 ------ .../public/application/top_nav/top_nav_ids.ts | 1 - .../dashboard/public/dashboard_strings.ts | 12 ++++ test/accessibility/apps/dashboard.ts | 5 +- .../apps/dashboard/dashboard_unsaved_state.ts | 1 + test/functional/apps/dashboard/view_edit.ts | 32 ++++----- .../functional/page_objects/dashboard_page.ts | 18 ++++- 9 files changed, 139 insertions(+), 70 deletions(-) diff --git a/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx b/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx index 41b27b4fd69260..d302bb4216bc49 100644 --- a/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx +++ b/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx @@ -40,6 +40,60 @@ export const confirmDiscardUnsavedChanges = ( } }); +export type DiscardOrKeepSelection = 'cancel' | 'discard' | 'keep'; + +export const confirmDiscardOrKeepUnsavedChanges = ( + overlays: OverlayStart +): Promise => { + return new Promise((resolve) => { + const session = overlays.openModal( + toMountPoint( + <> + + {leaveConfirmStrings.getLeaveEditModeTitle()} + + + + {leaveConfirmStrings.getLeaveEditModeSubtitle()} + + + + session.close()} + > + {leaveConfirmStrings.getCancelButtonText()} + + { + session.close(); + resolve('keep'); + }} + > + {leaveConfirmStrings.getKeepChangesText()} + + { + session.close(); + resolve('discard'); + }} + > + {leaveConfirmStrings.getConfirmButtonText()} + + + + ), + { + 'data-test-subj': 'dashboardDiscardConfirmModal', + } + ); + }); +}; + export const confirmCreateWithUnsaved = ( overlays: OverlayStart, startBlankCallback: () => void, diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index 786afc81c400cd..e68d371ebb2703 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -43,7 +43,7 @@ import { showOptionsPopover } from './show_options_popover'; import { TopNavIds } from './top_nav_ids'; import { ShowShareModal } from './show_share_modal'; import { PanelToolbar } from './panel_toolbar'; -import { confirmDiscardUnsavedChanges } from '../listing/confirm_overlays'; +import { confirmDiscardOrKeepUnsavedChanges } from '../listing/confirm_overlays'; import { OverlayRef } from '../../../../../core/public'; import { getNewDashboardTitle } from '../../dashboard_strings'; import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage'; @@ -152,34 +152,53 @@ export function DashboardTopNav({ } }, [state.addPanelOverlay]); - const onDiscardChanges = useCallback(() => { - function revertChangesAndExitEditMode() { - dashboardStateManager.resetState(); - dashboardStateManager.clearUnsavedPanels(); - - // We need to do a hard reset of the timepicker. appState will not reload like - // it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on - // reload will cause it not to sync. - if (dashboardStateManager.getIsTimeSavedWithDashboard()) { - dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); - dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); - } - dashboardStateManager.switchViewMode(ViewMode.VIEW); - } - confirmDiscardUnsavedChanges(core.overlays, revertChangesAndExitEditMode); - }, [core.overlays, dashboardStateManager, timefilter]); - const onChangeViewMode = useCallback( (newMode: ViewMode) => { clearAddPanel(); - if (savedDashboard?.id && allowByValueEmbeddables) { - const { getFullEditPath, title, id } = savedDashboard; - chrome.recentlyAccessed.add(getFullEditPath(newMode === ViewMode.EDIT), title, id); + const isPageRefresh = newMode === dashboardStateManager.getViewMode(); + const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW; + const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter); + + function switchViewMode() { + dashboardStateManager.switchViewMode(newMode); + dashboardStateManager.restorePanels(); + + if (savedDashboard?.id && allowByValueEmbeddables) { + const { getFullEditPath, title, id } = savedDashboard; + chrome.recentlyAccessed.add(getFullEditPath(newMode === ViewMode.EDIT), title, id); + } + } + + if (!willLoseChanges) { + switchViewMode(); + return; } - dashboardStateManager.switchViewMode(newMode); - dashboardStateManager.restorePanels(); + + function discardChanges() { + dashboardStateManager.resetState(); + dashboardStateManager.clearUnsavedPanels(); + + // We need to do a hard reset of the timepicker. appState will not reload like + // it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on + // reload will cause it not to sync. + if (dashboardStateManager.getIsTimeSavedWithDashboard()) { + dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); + dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); + } + dashboardStateManager.switchViewMode(ViewMode.VIEW); + } + confirmDiscardOrKeepUnsavedChanges(core.overlays).then((selection) => { + if (selection === 'discard') { + discardChanges(); + } + if (selection !== 'cancel') { + switchViewMode(); + } + }); }, [ + timefilter, + core.overlays, clearAddPanel, savedDashboard, dashboardStateManager, @@ -381,7 +400,6 @@ export function DashboardTopNav({ }, [TopNavIds.EXIT_EDIT_MODE]: () => onChangeViewMode(ViewMode.VIEW), [TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT), - [TopNavIds.DISCARD_CHANGES]: onDiscardChanges, [TopNavIds.SAVE]: runSave, [TopNavIds.QUICK_SAVE]: runQuickSave, [TopNavIds.CLONE]: runClone, @@ -417,7 +435,6 @@ export function DashboardTopNav({ }, [ dashboardCapabilities, dashboardStateManager, - onDiscardChanges, onChangeViewMode, savedDashboard, runClone, diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts index abc128369017c5..26eea1b5f718de 100644 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts @@ -41,14 +41,12 @@ export function getTopNavConfig( getOptionsConfig(actions[TopNavIds.OPTIONS]), getShareConfig(actions[TopNavIds.SHARE]), getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), - getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), getSaveConfig(actions[TopNavIds.SAVE], options.isNewDashboard), ] : [ getOptionsConfig(actions[TopNavIds.OPTIONS]), getShareConfig(actions[TopNavIds.SHARE]), getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), - getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), getSaveConfig(actions[TopNavIds.SAVE]), getQuickSave(actions[TopNavIds.QUICK_SAVE]), ]; @@ -154,23 +152,6 @@ function getViewConfig(action: NavAction) { }; } -/** - * @returns {kbnTopNavConfig} - */ -function getDiscardConfig(action: NavAction) { - return { - id: 'discard', - label: i18n.translate('dashboard.topNave.discardlButtonAriaLabel', { - defaultMessage: 'discard', - }), - description: i18n.translate('dashboard.topNave.discardConfigDescription', { - defaultMessage: 'Discard unsaved changes', - }), - testId: 'dashboardDiscardChanges', - run: action, - }; -} - /** * @returns {kbnTopNavConfig} */ diff --git a/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts b/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts index 92a0db6bd0ba2e..ee3d08e2330ae9 100644 --- a/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts +++ b/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts @@ -13,7 +13,6 @@ export const TopNavIds = { SAVE: 'save', EXIT_EDIT_MODE: 'exitEditMode', ENTER_EDIT_MODE: 'enterEditMode', - DISCARD_CHANGES: 'discard', CLONE: 'clone', FULL_SCREEN: 'fullScreenMode', }; diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts index 96bd32088ec38b..8588fbc1bbdc7f 100644 --- a/src/plugins/dashboard/public/dashboard_strings.ts +++ b/src/plugins/dashboard/public/dashboard_strings.ts @@ -253,6 +253,18 @@ export const leaveConfirmStrings = { i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesSubtitle', { defaultMessage: 'Leave Dashboard with unsaved work?', }), + getKeepChangesText: () => + i18n.translate('dashboard.appLeaveConfirmModal.keepUnsavedChangesButtonLabel', { + defaultMessage: 'Keep unsaved changes', + }), + getLeaveEditModeTitle: () => + i18n.translate('dashboard.changeViewModeConfirmModal.leaveEditMode', { + defaultMessage: 'Leave edit mode with unsaved work?', + }), + getLeaveEditModeSubtitle: () => + i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesOptionalDescription', { + defaultMessage: `If you discard your changes, there's no getting them back.`, + }), getDiscardTitle: () => i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesTitle', { defaultMessage: 'Discard changes to dashboard?', diff --git a/test/accessibility/apps/dashboard.ts b/test/accessibility/apps/dashboard.ts index 0171a462b13680..08d577b3df08cd 100644 --- a/test/accessibility/apps/dashboard.ts +++ b/test/accessibility/apps/dashboard.ts @@ -110,12 +110,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('Exit out of edit mode', async () => { - await PageObjects.dashboard.clickDiscardChanges(); + await PageObjects.dashboard.clickDiscardChanges(false); await a11y.testAppSnapshot(); }); it('Discard changes', async () => { - await PageObjects.common.clickConfirmOnModal(); + await testSubjects.exists('dashboardDiscardConfirmDiscard'); + await testSubjects.click('dashboardDiscardConfirmDiscard'); await PageObjects.dashboard.getIsInViewMode(); await a11y.testAppSnapshot(); }); diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts index 851d7ab7461ed0..eaf0d2f2a97df2 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts @@ -80,6 +80,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('retains unsaved panel count after returning to edit mode', async () => { await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.switchToEditMode(); + await PageObjects.header.waitUntilLoadingHasFinished(); const currentPanelCount = await PageObjects.dashboard.getPanelCount(); expect(currentPanelCount).to.eql(unsavedPanelCount); }); diff --git a/test/functional/apps/dashboard/view_edit.ts b/test/functional/apps/dashboard/view_edit.ts index 5242e59efa0e99..6c7d60c9a15aa1 100644 --- a/test/functional/apps/dashboard/view_edit.ts +++ b/test/functional/apps/dashboard/view_edit.ts @@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); + const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['dashboard', 'header', 'common', 'visualize', 'timePicker']); const dashboardName = 'dashboard with filter'; const filterBar = getService('filterBar'); @@ -74,9 +75,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await PageObjects.dashboard.clickDiscardChanges(); - // confirm lose changes - await PageObjects.common.clickConfirmOnModal(); - const newTime = await PageObjects.timePicker.getTimeConfig(); expect(newTime.start).to.equal(originalTime.start); @@ -90,9 +88,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickDiscardChanges(); - // confirm lose changes - await PageObjects.common.clickConfirmOnModal(); - const query = await queryBar.getQueryString(); expect(query).to.equal(originalQuery); }); @@ -113,9 +108,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickDiscardChanges(); - // confirm lose changes - await PageObjects.common.clickConfirmOnModal(); - hasFilter = await filterBar.hasFilter('animal', 'dog'); expect(hasFilter).to.be(true); }); @@ -133,12 +125,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { redirectToOrigin: true, }); - await PageObjects.dashboard.clickDiscardChanges(); + await PageObjects.dashboard.clickDiscardChanges(false); // for this sleep see https://github.com/elastic/kibana/issues/22299 await PageObjects.common.sleep(500); // confirm lose changes - await PageObjects.common.clickConfirmOnModal(); + await testSubjects.exists('dashboardDiscardConfirmDiscard'); + await testSubjects.click('dashboardDiscardConfirmDiscard'); const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.eql(originalPanelCount); @@ -150,9 +143,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardAddPanel.addVisualization('new viz panel'); await PageObjects.dashboard.clickDiscardChanges(); - // confirm lose changes - await PageObjects.common.clickConfirmOnModal(); - const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.eql(originalPanelCount); }); @@ -171,9 +161,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Sep 19, 2015 @ 06:31:44.000', 'Sep 19, 2015 @ 06:31:44.000' ); - await PageObjects.dashboard.clickDiscardChanges(); + await PageObjects.dashboard.clickDiscardChanges(false); - await PageObjects.common.clickCancelOnModal(); + await testSubjects.exists('dashboardDiscardConfirmCancel'); + await testSubjects.click('dashboardDiscardConfirmCancel'); await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true, }); @@ -200,9 +191,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); const newTime = await PageObjects.timePicker.getTimeConfig(); - await PageObjects.dashboard.clickDiscardChanges(); + await PageObjects.dashboard.clickDiscardChanges(false); - await PageObjects.common.clickCancelOnModal(); + await testSubjects.exists('dashboardDiscardConfirmCancel'); + await testSubjects.click('dashboardDiscardConfirmCancel'); await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true }); await PageObjects.dashboard.loadSavedDashboard(dashboardName); @@ -223,7 +215,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Oct 19, 2014 @ 06:31:44.000', 'Dec 19, 2014 @ 06:31:44.000' ); - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickCancelOutOfEditMode(false); await PageObjects.common.expectConfirmModalOpenState(false); }); @@ -235,7 +227,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const originalQuery = await queryBar.getQueryString(); await queryBar.setQuery(`${originalQuery}extra stuff`); - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickCancelOutOfEditMode(false); await PageObjects.common.expectConfirmModalOpenState(false); diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 4291d67a6bc082..9c571f0f0ef86b 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -246,14 +246,26 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide return await testSubjects.exists('dashboardEditMode'); } - public async clickCancelOutOfEditMode() { + public async clickCancelOutOfEditMode(accept = true) { log.debug('clickCancelOutOfEditMode'); await testSubjects.click('dashboardViewOnlyMode'); + if (accept) { + const confirmation = await testSubjects.exists('dashboardDiscardConfirmKeep'); + if (confirmation) { + await testSubjects.click('dashboardDiscardConfirmKeep'); + } + } } - public async clickDiscardChanges() { + public async clickDiscardChanges(accept = true) { log.debug('clickDiscardChanges'); - await testSubjects.click('dashboardDiscardChanges'); + await testSubjects.click('dashboardViewOnlyMode'); + if (accept) { + const confirmation = await testSubjects.exists('dashboardDiscardConfirmDiscard'); + if (confirmation) { + await testSubjects.click('dashboardDiscardConfirmDiscard'); + } + } } public async clickQuickSave() { From d4f3420b416b0e1378c81a1c85bb6c012dcf6841 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 16 Feb 2021 13:06:25 -0500 Subject: [PATCH 084/175] [Docs] Clarify KQL and Lucene docs (#91065) * [Docs] Clarify KQL and Lucene docs * Code review suggestions --- docs/discover/kuery.asciidoc | 64 +++++++++++++++++++++-------------- docs/discover/search.asciidoc | 59 +++++++++++++++++++++----------- 2 files changed, 78 insertions(+), 45 deletions(-) diff --git a/docs/discover/kuery.asciidoc b/docs/discover/kuery.asciidoc index 8c0012fb6c6bf5..a92fc182f388c3 100644 --- a/docs/discover/kuery.asciidoc +++ b/docs/discover/kuery.asciidoc @@ -1,55 +1,63 @@ [[kuery-query]] === Kibana Query Language -The Kibana Query Language (KQL) makes it easy to find -the fields and syntax for your {es} query. If you have the -https://www.elastic.co/subscriptions[Basic tier] or above, -simply place your cursor in the *Search* field. As you type, you’ll get suggestions for fields, -values, and operators. +The Kibana Query Language (KQL) is a simple syntax for filtering {es} data using +free text search or field-based search. KQL is only used for filtering data, and has +no role in sorting or aggregating the data. + +KQL is able to suggest field names, values, and operators as you type. +The performance of the suggestions is controlled by <>: [role="screenshot"] image::images/kql-autocomplete.png[Autocomplete in Search bar] -If you prefer to use Kibana’s legacy query language, based on the -<>, click *KQL* next to the *Search* field, and then turn off KQL. +KQL has a different set of features than the <>. KQL is able to query +nested fields and <>. KQL does not support regular expressions +or searching with fuzzy terms. To use the legacy Lucene syntax, click *KQL* next to the *Search* field, +and then turn off KQL. [discrete] === Terms query -A terms query matches documents that contain one or more *exact* terms in a field. +A terms query uses *exact search terms*. Spaces separate each search term, and only one term +is required to match the document. Use quotation marks to indicate a *phrase match*. -To match documents where the response field is `200`: +To query using *exact search terms*, enter the field name followed by `:` and +then the values separated by spaces: [source,yaml] ------------------- -response:200 +http.response.status_code:400 401 404 ------------------- -To match documents with the phrase "quick brown fox" in the `message` field. +For text fields, this will match any value regardless of order: [source,yaml] ------------------- -message:"quick brown fox" +http.response.body.content.text:quick brown fox ------------------- -Without the quotes, -the query matches documents regardless of the order in which -they appear. Documents with "quick brown fox" match, -and so does "quick fox brown". +To query for an *exact phrase*, use quotation marks around the values: + +[source,yaml] +------------------- +http.response.body.content.text:"quick brown fox" +------------------- -NOTE: Terms without fields are matched against the default field in your index settings. -If a default field is not -set, terms are matched against all fields. For example, a query -for `response:200` searches for the value 200 -in the response field, but a query for just `200` searches for 200 -across all fields in your index. +Field names are not required by KQL. When a field name is not provided, terms +will be matched by the default fields in your index settings. To search across fields: +[source,yaml] +------------------- +"quick brown fox" +------------------- [discrete] === Boolean queries KQL supports `or`, `and`, and `not`. By default, `and` has a higher precedence than `or`. -To override the default precedence, group operators in parentheses. +To override the default precedence, group operators in parentheses. These operators can +be upper or lower case. To match documents where response is `200`, extension is `php`, or both: @@ -143,7 +151,7 @@ but in some cases you might need to search on dates. Include the date range in q [discrete] === Exist queries -An exist query matches documents that contain a value for a field, in this case, +An exist query matches documents that contain any value for a field, in this case, response: [source,yaml] @@ -151,10 +159,16 @@ response: response:* ------------------- +Existence is defined by {es} and includes all values, including empty text. + [discrete] === Wildcard queries -To match documents where machine.os starts with `win`, such +Wildcards queries can be used to *search by a term prefix* or to *search multiple fields*. +The default settings of {kib} *prevent leading wildcards* for performance reasons, +but this can be allowed with an <>. + +To match documents where `machine.os` starts with `win`, such as "windows 7" and "windows 10": [source,yaml] diff --git a/docs/discover/search.asciidoc b/docs/discover/search.asciidoc index 45f0df5bd773fc..e8faccd50661a8 100644 --- a/docs/discover/search.asciidoc +++ b/docs/discover/search.asciidoc @@ -53,36 +53,55 @@ include::kuery.asciidoc[] [[lucene-query]] === Lucene query syntax -Kibana's legacy query language was based on the Lucene query syntax. For the time being this syntax -is still available under the options menu in the Query Bar and in Advanced Settings. The following -are some tips that can help get you started. +Lucene query syntax is available to {kib} users who opt out of the <>. +Full documentation for this syntax is available as part of {es} +{ref}/query-dsl-query-string-query.html#query-string-syntax[query string syntax]. -* To perform a free text search, simply enter a text string. For example, if +The main reason to use the Lucene query syntax in {kib} is for advanced +Lucene features, such as regular expressions or fuzzy term matching. However, +Lucene syntax is not able to search nested objects or scripted fields. + +To perform a free text search, simply enter a text string. For example, if you're searching web server logs, you could enter `safari` to search all -fields for the term `safari`. +fields: + +[source,yaml] +------------------- +safari +------------------- + +To search for a value in a specific field, prefix the value with the name +of the field: -* To search for a value in a specific field, prefix the value with the name -of the field. For example, you could enter `status:200` to find all of -the entries that contain the value `200` in the `status` field. +[source,yaml] +------------------- +status:200 +------------------- -* To search for a range of values, you can use the bracketed range syntax, +To search for a range of values, use the bracketed range syntax, `[START_VALUE TO END_VALUE]`. For example, to find entries that have 4xx status codes, you could enter `status:[400 TO 499]`. -* To specify more complex search criteria, you can use the Boolean operators -`AND`, `OR`, and `NOT`. For example, to find entries that have 4xx status -codes and have an extension of `php` or `html`, you could enter `status:[400 TO -499] AND (extension:php OR extension:html)`. +[source,yaml] +------------------- +status:[400 TO 499] +------------------- + +For an open range, use a wildcard: -IMPORTANT: When you use the Lucene Query Syntax in the *KQL* search bar, {kib} is unable to search on nested objects and perform aggregations across fields that contain nested objects. -Using `include_in_parent` or `copy_to` as a workaround can cause {kib} to fail. +[source,yaml] +------------------- +status:[400 TO *] +------------------- -For more detailed information about the Lucene query syntax, see the -{ref}/query-dsl-query-string-query.html#query-string-syntax[Query String Query] -docs. +To specify more complex search criteria, use the boolean operators +`AND`, `OR`, and `NOT`. For example, to find entries that have 4xx status +codes and have an extension of `php` or `html`: -NOTE: These examples use the Lucene query syntax. When lucene is selected as your -query language you can also submit queries using the {ref}/query-dsl.html[Elasticsearch Query DSL]. +[source,yaml] +------------------- +status:[400 TO 499] AND (extension:php OR extension:html) +------------------- [[save-open-search]] From 5686d85ac1468c75782e4fd9e232e9e6f7c9dbdb Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Tue, 16 Feb 2021 13:41:34 -0500 Subject: [PATCH 085/175] [Security Solution] add unsupported type to Endpoint Policy response (#91295) * add unsupported type * update types Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../security_solution/common/endpoint/generate_data.ts | 4 +++- .../plugins/security_solution/common/endpoint/types/index.ts | 1 + .../search_strategy/security_solution/hosts/common/index.ts | 1 + x-pack/plugins/security_solution/public/graphql/types.ts | 1 + .../management/pages/endpoint_hosts/view/host_constants.ts | 4 ++++ .../security_solution/server/graphql/hosts/schema.gql.ts | 1 + x-pack/plugins/security_solution/server/graphql/types.ts | 1 + 7 files changed, 12 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index ffeaf853828f13..8aec9768dd50d2 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -101,6 +101,7 @@ const POLICY_RESPONSE_STATUSES: HostPolicyResponseActionStatus[] = [ HostPolicyResponseActionStatus.success, HostPolicyResponseActionStatus.failure, HostPolicyResponseActionStatus.warning, + HostPolicyResponseActionStatus.unsupported, ]; const APPLIED_POLICIES: Array<{ @@ -1492,7 +1493,7 @@ export class EndpointDocGenerator { { name: 'workflow', message: 'Failed to apply a portion of the configuration (kernel)', - status: HostPolicyResponseActionStatus.success, + status: HostPolicyResponseActionStatus.unsupported, }, { name: 'download_model', @@ -1637,6 +1638,7 @@ export class EndpointDocGenerator { HostPolicyResponseActionStatus.failure, HostPolicyResponseActionStatus.success, HostPolicyResponseActionStatus.warning, + HostPolicyResponseActionStatus.unsupported, ]); } diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index d361c0d6282a34..94a09b385a08c8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -933,6 +933,7 @@ export enum HostPolicyResponseActionStatus { success = 'success', failure = 'failure', warning = 'warning', + unsupported = 'unsupported', } /** diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts index 40e353263bcc8c..7e19944ea5856c 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts @@ -13,6 +13,7 @@ export enum HostPolicyResponseActionStatus { success = 'success', failure = 'failure', warning = 'warning', + unsupported = 'unsupported', } export enum HostsFields { diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index 4397573217312d..f70cd37b8da94e 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -273,6 +273,7 @@ export enum HostPolicyResponseActionStatus { success = 'success', failure = 'failure', warning = 'warning', + unsupported = 'unsupported', } export enum TimelineType { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/host_constants.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/host_constants.ts index 37a26d88053521..4745cd9de249db 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/host_constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/host_constants.ts @@ -25,6 +25,7 @@ export const POLICY_STATUS_TO_HEALTH_COLOR = Object.freeze< success: 'success', warning: 'warning', failure: 'danger', + unsupported: 'subdued', }); export const POLICY_STATUS_TO_TEXT = Object.freeze< @@ -39,4 +40,7 @@ export const POLICY_STATUS_TO_TEXT = Object.freeze< failure: i18n.translate('xpack.securitySolution.policyStatusText.failure', { defaultMessage: 'Failure', }), + unsupported: i18n.translate('xpack.securitySolution.policyStatusText.unsupported', { + defaultMessage: 'Unsupported', + }), }); diff --git a/x-pack/plugins/security_solution/server/graphql/hosts/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/hosts/schema.gql.ts index f3d553936bac53..c3a5c4e3b23cf1 100644 --- a/x-pack/plugins/security_solution/server/graphql/hosts/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/hosts/schema.gql.ts @@ -50,6 +50,7 @@ export const hostsSchema = gql` success failure warning + unsupported } type EndpointFields { diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index d1e646e73cf12c..0d6a0e63455b00 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -275,6 +275,7 @@ export enum HostPolicyResponseActionStatus { success = 'success', failure = 'failure', warning = 'warning', + unsupported = 'unsupported', } export enum TimelineType { From 9d8376d890ea30312430e717ea2c0453845aea9f Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 16 Feb 2021 10:42:26 -0800 Subject: [PATCH 086/175] ship @kbn/ui-shared-deps metrics in separate step (#91504) Co-authored-by: spalger --- packages/kbn-ui-shared-deps/scripts/build.js | 32 ++----------------- packages/kbn-ui-shared-deps/webpack.config.js | 31 ++++++++++++++++++ test/scripts/jenkins_baseline.sh | 4 ++- test/scripts/jenkins_build_kibana.sh | 4 ++- test/scripts/jenkins_xpack_baseline.sh | 4 ++- test/scripts/jenkins_xpack_build_kibana.sh | 4 ++- 6 files changed, 45 insertions(+), 34 deletions(-) diff --git a/packages/kbn-ui-shared-deps/scripts/build.js b/packages/kbn-ui-shared-deps/scripts/build.js index 9e1e755b3077a3..0993f785902464 100644 --- a/packages/kbn-ui-shared-deps/scripts/build.js +++ b/packages/kbn-ui-shared-deps/scripts/build.js @@ -7,9 +7,8 @@ */ const Path = require('path'); -const Fs = require('fs'); -const { run, createFailError, CiStatsReporter } = require('@kbn/dev-utils'); +const { run, createFailError } = require('@kbn/dev-utils'); const webpack = require('webpack'); const Stats = require('webpack/lib/Stats'); const del = require('del'); @@ -34,34 +33,6 @@ run( const took = Math.round((stats.endTime - stats.startTime) / 1000); if (!stats.hasErrors() && !stats.hasWarnings()) { - if (!flags.dev) { - const reporter = CiStatsReporter.fromEnv(log); - - const metrics = [ - { - group: '@kbn/ui-shared-deps asset size', - id: 'kbn-ui-shared-deps.js', - value: Fs.statSync(Path.resolve(DIST_DIR, 'kbn-ui-shared-deps.js')).size, - }, - { - group: '@kbn/ui-shared-deps asset size', - id: 'kbn-ui-shared-deps.@elastic.js', - value: Fs.statSync(Path.resolve(DIST_DIR, 'kbn-ui-shared-deps.@elastic.js')).size, - }, - { - group: '@kbn/ui-shared-deps asset size', - id: 'css', - value: - Fs.statSync(Path.resolve(DIST_DIR, 'kbn-ui-shared-deps.css')).size + - Fs.statSync(Path.resolve(DIST_DIR, 'kbn-ui-shared-deps.v7.light.css')).size, - }, - ]; - - log.debug('metrics:', metrics); - - await reporter.metrics(metrics); - } - log.success(`webpack completed in about ${took} seconds`); return; } @@ -101,6 +72,7 @@ run( return; } + log.info('running webpack'); await onCompilationComplete( await new Promise((resolve, reject) => { compiler.run((error, stats) => { diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index 7ff5978e1f2ea2..cc761dae3bfe96 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -12,6 +12,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CompressionPlugin = require('compression-webpack-plugin'); const { REPO_ROOT } = require('@kbn/utils'); const webpack = require('webpack'); +const { RawSource } = require('webpack-sources'); const UiSharedDeps = require('./index'); @@ -145,6 +146,36 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ test: /\.(js|css)$/, cache: false, }), + new (class MetricsPlugin { + apply(compiler) { + compiler.hooks.emit.tap('MetricsPlugin', (compilation) => { + const metrics = [ + { + group: '@kbn/ui-shared-deps asset size', + id: 'kbn-ui-shared-deps.js', + value: compilation.assets['kbn-ui-shared-deps.js'].size(), + }, + { + group: '@kbn/ui-shared-deps asset size', + id: 'kbn-ui-shared-deps.@elastic.js', + value: compilation.assets['kbn-ui-shared-deps.@elastic.js'].size(), + }, + { + group: '@kbn/ui-shared-deps asset size', + id: 'css', + value: + compilation.assets['kbn-ui-shared-deps.css'].size() + + compilation.assets['kbn-ui-shared-deps.v7.light.css'].size(), + }, + ]; + + compilation.emitAsset( + 'metrics.json', + new RawSource(JSON.stringify(metrics, null, 2)) + ); + }); + } + })(), ]), ], }); diff --git a/test/scripts/jenkins_baseline.sh b/test/scripts/jenkins_baseline.sh index 60926238576c77..58d86cddf65fa7 100755 --- a/test/scripts/jenkins_baseline.sh +++ b/test/scripts/jenkins_baseline.sh @@ -7,7 +7,9 @@ echo " -> building and extracting OSS Kibana distributable for use in functional node scripts/build --debug --oss echo " -> shipping metrics from build to ci-stats" -node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json +node scripts/ship_ci_stats \ + --metrics target/optimizer_bundle_metrics.json \ + --metrics packages/kbn-ui-shared-deps/target/metrics.json linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$PARENT_DIR/install/kibana" diff --git a/test/scripts/jenkins_build_kibana.sh b/test/scripts/jenkins_build_kibana.sh index 5819a3ce6765e1..fa0c9522ef5fb1 100755 --- a/test/scripts/jenkins_build_kibana.sh +++ b/test/scripts/jenkins_build_kibana.sh @@ -18,7 +18,9 @@ if [[ -z "$CODE_COVERAGE" ]] ; then node scripts/build --debug --oss echo " -> shipping metrics from build to ci-stats" - node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json + node scripts/ship_ci_stats \ + --metrics target/optimizer_bundle_metrics.json \ + --metrics packages/kbn-ui-shared-deps/target/metrics.json mkdir -p "$WORKSPACE/kibana-build-oss" cp -pR build/oss/kibana-*-SNAPSHOT-linux-x86_64/. $WORKSPACE/kibana-build-oss/ diff --git a/test/scripts/jenkins_xpack_baseline.sh b/test/scripts/jenkins_xpack_baseline.sh index aaacdd4ea3aaec..2755a6e0a705dc 100755 --- a/test/scripts/jenkins_xpack_baseline.sh +++ b/test/scripts/jenkins_xpack_baseline.sh @@ -8,7 +8,9 @@ cd "$KIBANA_DIR" node scripts/build --debug --no-oss echo " -> shipping metrics from build to ci-stats" -node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json +node scripts/ship_ci_stats \ + --metrics target/optimizer_bundle_metrics.json \ + --metrics packages/kbn-ui-shared-deps/target/metrics.json linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$KIBANA_DIR/install/kibana" diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index 36865ce7c4967a..2887a51f262833 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -34,7 +34,9 @@ if [[ -z "$CODE_COVERAGE" ]] ; then node scripts/build --debug --no-oss echo " -> shipping metrics from build to ci-stats" - node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json + node scripts/ship_ci_stats \ + --metrics target/optimizer_bundle_metrics.json \ + --metrics packages/kbn-ui-shared-deps/target/metrics.json linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$KIBANA_DIR/install/kibana" From 3e7ce0c53ee42b55435dedf411fc3314947f734d Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 16 Feb 2021 12:44:15 -0600 Subject: [PATCH 087/175] TS project references for monitoring plugin (#91498) Fixes #89293. --- tsconfig.refs.json | 1 + x-pack/plugins/monitoring/tsconfig.json | 31 +++++++++++++++++++++++++ x-pack/test/tsconfig.json | 3 ++- x-pack/tsconfig.json | 6 ++--- 4 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/monitoring/tsconfig.json diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 39f3057ec9b2a7..7806cf93756e57 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -87,6 +87,7 @@ { "path": "./x-pack/plugins/maps_legacy_licensing/tsconfig.json" }, { "path": "./x-pack/plugins/maps/tsconfig.json" }, { "path": "./x-pack/plugins/ml/tsconfig.json" }, + { "path": "./x-pack/plugins/monitoring/tsconfig.json" }, { "path": "./x-pack/plugins/observability/tsconfig.json" }, { "path": "./x-pack/plugins/osquery/tsconfig.json" }, { "path": "./x-pack/plugins/painless_lab/tsconfig.json" }, diff --git a/x-pack/plugins/monitoring/tsconfig.json b/x-pack/plugins/monitoring/tsconfig.json new file mode 100644 index 00000000000000..760ff188aacfc1 --- /dev/null +++ b/x-pack/plugins/monitoring/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["common/**/*", "public/**/*", "server/**/*"], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_legacy/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/navigation/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../actions/tsconfig.json" }, + { "path": "../alerts/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../encrypted_saved_objects/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../infra/tsconfig.json" }, + { "path": "../license_management/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../observability/tsconfig.json" }, + { "path": "../telemetry_collection_xpack/tsconfig.json" }, + { "path": "../triggers_actions_ui/tsconfig.json" } + ] +} diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 1c2e0aeecd2477..ff3fec1c5aaee7 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -69,6 +69,7 @@ { "path": "../plugins/license_management/tsconfig.json" }, { "path": "../plugins/licensing/tsconfig.json" }, { "path": "../plugins/ml/tsconfig.json" }, + { "path": "../plugins/monitoring/tsconfig.json" }, { "path": "../plugins/observability/tsconfig.json" }, { "path": "../plugins/osquery/tsconfig.json" }, { "path": "../plugins/painless_lab/tsconfig.json" }, @@ -88,7 +89,7 @@ { "path": "../plugins/rollup/tsconfig.json" }, { "path": "../plugins/remote_clusters/tsconfig.json" }, { "path": "../plugins/cross_cluster_replication/tsconfig.json" }, - { "path": "../plugins/index_lifecycle_management/tsconfig.json"}, + { "path": "../plugins/index_lifecycle_management/tsconfig.json" }, { "path": "../plugins/uptime/tsconfig.json" } ] } diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 813811d4a9ce45..b3e47136977e21 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -7,7 +7,6 @@ "plugins/case/**/*", "plugins/lists/**/*", "plugins/logstash/**/*", - "plugins/monitoring/**/*", "plugins/security_solution/**/*", "plugins/xpack_legacy/**/*", "plugins/drilldowns/url_drilldown/**/*" @@ -90,6 +89,7 @@ { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, { "path": "./plugins/maps/tsconfig.json" }, { "path": "./plugins/ml/tsconfig.json" }, + { "path": "./plugins/monitoring/tsconfig.json" }, { "path": "./plugins/observability/tsconfig.json" }, { "path": "./plugins/osquery/tsconfig.json" }, { "path": "./plugins/painless_lab/tsconfig.json" }, @@ -111,8 +111,8 @@ { "path": "./plugins/watcher/tsconfig.json" }, { "path": "./plugins/rollup/tsconfig.json" }, { "path": "./plugins/remote_clusters/tsconfig.json" }, - { "path": "./plugins/cross_cluster_replication/tsconfig.json"}, - { "path": "./plugins/index_lifecycle_management/tsconfig.json"}, + { "path": "./plugins/cross_cluster_replication/tsconfig.json" }, + { "path": "./plugins/index_lifecycle_management/tsconfig.json" }, { "path": "./plugins/uptime/tsconfig.json" } ] } From 0ecac0cabb1867183bd3466f167683f3914a9feb Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 16 Feb 2021 11:47:04 -0700 Subject: [PATCH 088/175] [Maps] fix reporting jobs fail when Elastic Maps Service (EMS) is unavailable (#90834) * [Maps] fix Reporting jobs fail when Elastic Maps Service (EMS) is unavailable * clean up test case names * make tests more explicit Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/selectors/map_selectors.test.ts | 79 ++++++++++++++++++- .../maps/public/selectors/map_selectors.ts | 7 +- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts index c2f5fc02c5df20..89cd80f4daab50 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts @@ -26,10 +26,12 @@ jest.mock('../kibana_services', () => ({ })); import { DEFAULT_MAP_STORE_STATE } from '../reducers/store'; -import { getTimeFilters } from './map_selectors'; +import { areLayersLoaded, getTimeFilters } from './map_selectors'; +import { LayerDescriptor } from '../../common/descriptor_types'; +import { ILayer } from '../classes/layers/layer'; describe('getTimeFilters', () => { - it('should return timeFilters when contained in state', () => { + test('should return timeFilters when contained in state', () => { const state = { ...DEFAULT_MAP_STORE_STATE, map: { @@ -46,7 +48,7 @@ describe('getTimeFilters', () => { expect(getTimeFilters(state)).toEqual({ to: '2001-01-01', from: '2001-12-31' }); }); - it('should return kibana time filters when not contained in state', () => { + test('should return kibana time filters when not contained in state', () => { const state = { ...DEFAULT_MAP_STORE_STATE, map: { @@ -60,3 +62,74 @@ describe('getTimeFilters', () => { expect(getTimeFilters(state)).toEqual({ to: 'now', from: 'now-15m' }); }); }); + +describe('areLayersLoaded', () => { + function createLayerMock({ + hasErrors = false, + isDataLoaded = false, + isVisible = true, + showAtZoomLevel = true, + }: { + hasErrors?: boolean; + isDataLoaded?: boolean; + isVisible?: boolean; + showAtZoomLevel?: boolean; + }) { + return ({ + hasErrors: () => { + return hasErrors; + }, + isDataLoaded: () => { + return isDataLoaded; + }, + isVisible: () => { + return isVisible; + }, + showAtZoomLevel: () => { + return showAtZoomLevel; + }, + } as unknown) as ILayer; + } + + test('layers waiting for map to load should not be counted loaded', () => { + const layerList: ILayer[] = []; + const waitingForMapReadyLayerList: LayerDescriptor[] = [({} as unknown) as LayerDescriptor]; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(false); + }); + + test('layer should not be counted as loaded if it has not loaded', () => { + const layerList = [createLayerMock({ isDataLoaded: false })]; + const waitingForMapReadyLayerList: LayerDescriptor[] = []; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(false); + }); + + test('layer should be counted as loaded if its not visible', () => { + const layerList = [createLayerMock({ isVisible: false, isDataLoaded: false })]; + const waitingForMapReadyLayerList: LayerDescriptor[] = []; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(true); + }); + + test('layer should be counted as loaded if its not shown at zoom level', () => { + const layerList = [createLayerMock({ showAtZoomLevel: false, isDataLoaded: false })]; + const waitingForMapReadyLayerList: LayerDescriptor[] = []; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(true); + }); + + test('layer should be counted as loaded if it has a loading error', () => { + const layerList = [createLayerMock({ hasErrors: true, isDataLoaded: false })]; + const waitingForMapReadyLayerList: LayerDescriptor[] = []; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(true); + }); + + test('layer should be counted as loaded if its loaded', () => { + const layerList = [createLayerMock({ isDataLoaded: true })]; + const waitingForMapReadyLayerList: LayerDescriptor[] = []; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(true); + }); +}); diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index f53f39ad2fc0cc..b16ac704c3715b 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -428,7 +428,12 @@ export const areLayersLoaded = createSelector( for (let i = 0; i < layerList.length; i++) { const layer = layerList[i]; - if (layer.isVisible() && layer.showAtZoomLevel(zoom) && !layer.isDataLoaded()) { + if ( + layer.isVisible() && + layer.showAtZoomLevel(zoom) && + !layer.hasErrors() && + !layer.isDataLoaded() + ) { return false; } } From dc4e94e0bac5970b00f55ce96304c66320cb2997 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Tue, 16 Feb 2021 13:47:25 -0500 Subject: [PATCH 089/175] [Time to Visualize] Unsaved Changes Badge (#91073) * Added unsaved changes badge to dashboards. Removed (unsaved) from the dashboard title --- .../public/application/dashboard_app.tsx | 10 ++++- .../application/dashboard_state.test.ts | 2 + .../application/dashboard_state_manager.ts | 16 +++++++ .../hooks/use_dashboard_breadcrumbs.ts | 1 - .../hooks/use_dashboard_container.test.tsx | 14 ++++--- .../hooks/use_dashboard_container.ts | 30 +++++++++---- .../hooks/use_dashboard_state_manager.ts | 3 +- .../application/top_nav/dashboard_top_nav.tsx | 15 ++++++- .../dashboard/public/dashboard_strings.ts | 42 ++++++++----------- .../public/top_nav_menu/_index.scss | 9 ++++ .../public/top_nav_menu/top_nav_menu.tsx | 26 ++++++++++-- .../apps/dashboard/copy_panel_to.ts | 2 +- .../apps/dashboard/dashboard_unsaved_state.ts | 25 ++++++++++- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 15 files changed, 148 insertions(+), 49 deletions(-) diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index d060327563b25b..f659fa002e922b 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -67,7 +67,13 @@ export function DashboardApp({ savedDashboard, history ); - const dashboardContainer = useDashboardContainer(dashboardStateManager, history, false); + const [unsavedChanges, setUnsavedChanges] = useState(false); + const dashboardContainer = useDashboardContainer({ + timeFilter: data.query.timefilter.timefilter, + dashboardStateManager, + setUnsavedChanges, + history, + }); const searchSessionIdQuery$ = useMemo( () => createQueryParamObservable(history, DashboardConstants.SEARCH_SESSION_ID), [history] @@ -200,6 +206,7 @@ export function DashboardApp({ ); dashboardStateManager.registerChangeListener(() => { + setUnsavedChanges(dashboardStateManager?.hasUnsavedPanelState()); // we aren't checking dirty state because there are changes the container needs to know about // that won't make the dashboard "dirty" - like a view mode change. triggerRefresh$.next(); @@ -281,6 +288,7 @@ export function DashboardApp({ embedSettings, indexPatterns, savedDashboard, + unsavedChanges, dashboardContainer, dashboardStateManager, }} diff --git a/src/plugins/dashboard/public/application/dashboard_state.test.ts b/src/plugins/dashboard/public/application/dashboard_state.test.ts index 04112d10ae7e3b..c5bda98c31b700 100644 --- a/src/plugins/dashboard/public/application/dashboard_state.test.ts +++ b/src/plugins/dashboard/public/application/dashboard_state.test.ts @@ -17,6 +17,7 @@ import { createKbnUrlStateStorage } from '../services/kibana_utils'; import { InputTimeRange, TimefilterContract, TimeRange } from '../services/data'; import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { coreMock } from '../../../../core/public/mocks'; describe('DashboardState', function () { let dashboardState: DashboardStateManager; @@ -45,6 +46,7 @@ describe('DashboardState', function () { kibanaVersion: '7.0.0', kbnUrlStateStorage: createKbnUrlStateStorage(), history: createBrowserHistory(), + toasts: coreMock.createStart().notifications.toasts, hasTaggingCapabilities: mockHasTaggingCapabilities, }); } diff --git a/src/plugins/dashboard/public/application/dashboard_state_manager.ts b/src/plugins/dashboard/public/application/dashboard_state_manager.ts index 8494900ea79c7e..e4b2afa8a46ea3 100644 --- a/src/plugins/dashboard/public/application/dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/dashboard_state_manager.ts @@ -43,6 +43,8 @@ import { syncState, } from '../services/kibana_utils'; import { STATE_STORAGE_KEY } from '../url_generator'; +import { NotificationsStart } from '../services/core'; +import { getMigratedToastText } from '../dashboard_strings'; /** * Dashboard state manager handles connecting angular and redux state between the angular and react portions of the @@ -59,10 +61,12 @@ export class DashboardStateManager { query: Query; }; private stateDefaults: DashboardAppStateDefaults; + private toasts: NotificationsStart['toasts']; private hideWriteControls: boolean; private kibanaVersion: string; public isDirty: boolean; private changeListeners: Array<(status: { dirty: boolean }) => void>; + private hasShownMigrationToast = false; public get appState(): DashboardAppState { return this.stateContainer.get(); @@ -93,6 +97,7 @@ export class DashboardStateManager { * @param */ constructor({ + toasts, history, kibanaVersion, savedDashboard, @@ -108,11 +113,13 @@ export class DashboardStateManager { hideWriteControls: boolean; allowByValueEmbeddables: boolean; savedDashboard: DashboardSavedObject; + toasts: NotificationsStart['toasts']; usageCollection?: UsageCollectionSetup; kbnUrlStateStorage: IKbnUrlStateStorage; dashboardPanelStorage?: DashboardPanelStorage; hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard; }) { + this.toasts = toasts; this.kibanaVersion = kibanaVersion; this.savedDashboard = savedDashboard; this.hideWriteControls = hideWriteControls; @@ -283,6 +290,10 @@ export class DashboardStateManager { if (dirty) { this.stateContainer.transitions.set('panels', Object.values(convertedPanelStateMap)); if (dirtyBecauseOfInitialStateMigration) { + if (this.getIsEditMode() && !this.hasShownMigrationToast) { + this.toasts.addSuccess(getMigratedToastText()); + this.hasShownMigrationToast = true; + } this.saveState({ replace: true }); } @@ -693,6 +704,11 @@ export class DashboardStateManager { this.dashboardPanelStorage.clearPanels(this.savedDashboard?.id); } + public hasUnsavedPanelState(): boolean { + const panels = this.dashboardPanelStorage?.getPanels(this.savedDashboard?.id); + return panels !== undefined && panels.length > 0; + } + private getUnsavedPanelState(): { panels?: SavedDashboardPanel[] } { if (!this.allowByValueEmbeddables || this.getIsViewMode() || !this.dashboardPanelStorage) { return {}; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts index 6eb1c0bf75b240..50465cc4ab58b2 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts @@ -45,7 +45,6 @@ export const useDashboardBreadcrumbs = ( text: getDashboardTitle( dashboardStateManager.getTitle(), dashboardStateManager.getViewMode(), - dashboardStateManager.getIsDirty(timefilter), dashboardStateManager.isNew() ), }, diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.test.tsx b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.test.tsx index d14b4056a64c67..6a6dc58db78157 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.test.tsx @@ -20,6 +20,7 @@ import { DashboardCapabilities } from '../types'; import { EmbeddableFactory } from '../../../../embeddable/public'; import { HelloWorldEmbeddable } from '../../../../embeddable/public/tests/fixtures'; import { DashboardContainer } from '../embeddable'; +import { coreMock } from 'src/core/public/mocks'; const savedDashboard = getSavedDashboardMock(); @@ -32,12 +33,13 @@ const history = createBrowserHistory(); const createDashboardState = () => new DashboardStateManager({ savedDashboard, + kibanaVersion: '7.0.0', hideWriteControls: false, allowByValueEmbeddables: false, - kibanaVersion: '7.0.0', - kbnUrlStateStorage: createKbnUrlStateStorage(), history: createBrowserHistory(), + kbnUrlStateStorage: createKbnUrlStateStorage(), hasTaggingCapabilities: mockHasTaggingCapabilities, + toasts: coreMock.createStart().notifications.toasts, }); const defaultCapabilities: DashboardCapabilities = { @@ -83,9 +85,9 @@ const setupEmbeddableFactory = () => { test('container is destroyed on unmount', async () => { const { createEmbeddable, destroySpy, embeddable } = setupEmbeddableFactory(); - const state = createDashboardState(); + const dashboardStateManager = createDashboardState(); const { result, unmount, waitForNextUpdate } = renderHook( - () => useDashboardContainer(state, history, false), + () => useDashboardContainer({ dashboardStateManager, history }), { wrapper: ({ children }) => ( {children} @@ -113,7 +115,7 @@ test('old container is destroyed on new dashboardStateManager', async () => { const { result, waitForNextUpdate, rerender } = renderHook< DashboardStateManager, DashboardContainer | null - >((dashboardState) => useDashboardContainer(dashboardState, history, false), { + >((dashboardStateManager) => useDashboardContainer({ dashboardStateManager, history }), { wrapper: ({ children }) => ( {children} ), @@ -148,7 +150,7 @@ test('destroyed if rerendered before resolved', async () => { const { result, waitForNextUpdate, rerender } = renderHook< DashboardStateManager, DashboardContainer | null - >((dashboardState) => useDashboardContainer(dashboardState, history, false), { + >((dashboardStateManager) => useDashboardContainer({ dashboardStateManager, history }), { wrapper: ({ children }) => ( {children} ), diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts index d12fea07bdd418..f4fe55f8774004 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts @@ -24,12 +24,21 @@ import { getDashboardContainerInput, getSearchSessionIdFromURL } from '../dashbo import { DashboardConstants, DashboardContainer, DashboardContainerInput } from '../..'; import { DashboardAppServices } from '../types'; import { DASHBOARD_CONTAINER_TYPE } from '..'; - -export const useDashboardContainer = ( - dashboardStateManager: DashboardStateManager | null, - history: History, - isEmbeddedExternally: boolean -) => { +import { TimefilterContract } from '../../services/data'; + +export const useDashboardContainer = ({ + history, + timeFilter, + setUnsavedChanges, + dashboardStateManager, + isEmbeddedExternally, +}: { + history: History; + isEmbeddedExternally?: boolean; + timeFilter?: TimefilterContract; + setUnsavedChanges?: (dirty: boolean) => void; + dashboardStateManager: DashboardStateManager | null; +}) => { const { dashboardCapabilities, data, @@ -72,15 +81,20 @@ export const useDashboardContainer = ( .getStateTransfer() .getIncomingEmbeddablePackage(DashboardConstants.DASHBOARDS_ID, true); + // when dashboard state manager initially loads, determine whether or not there are unsaved changes + setUnsavedChanges?.( + Boolean(incomingEmbeddable) || dashboardStateManager.hasUnsavedPanelState() + ); + let canceled = false; let pendingContainer: DashboardContainer | ErrorEmbeddable | null | undefined; (async function createContainer() { pendingContainer = await dashboardFactory.create( getDashboardContainerInput({ + isEmbeddedExternally: Boolean(isEmbeddedExternally), dashboardCapabilities, dashboardStateManager, incomingEmbeddable, - isEmbeddedExternally, query, searchSessionId: searchSessionIdFromURL ?? searchSession.start(), }) @@ -141,8 +155,10 @@ export const useDashboardContainer = ( dashboardCapabilities, dashboardStateManager, isEmbeddedExternally, + setUnsavedChanges, searchSession, scopedHistory, + timeFilter, embeddable, history, query, diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts index ed14223bb0a830..effd598cc3ee87 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts @@ -87,6 +87,7 @@ export const useDashboardStateManager = ( }); const stateManager = new DashboardStateManager({ + toasts: core.notifications.toasts, hasTaggingCapabilities, dashboardPanelStorage, hideWriteControls, @@ -160,7 +161,6 @@ export const useDashboardStateManager = ( const dashboardTitle = getDashboardTitle( stateManager.getTitle(), stateManager.getViewMode(), - stateManager.getIsDirty(timefilter), stateManager.isNew() ); @@ -213,6 +213,7 @@ export const useDashboardStateManager = ( uiSettings, usageCollection, allowByValueEmbeddables, + core.notifications.toasts, dashboardCapabilities.storeSearchSession, ]); diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index e68d371ebb2703..11fb7f0cb56ff4 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -45,7 +45,7 @@ import { ShowShareModal } from './show_share_modal'; import { PanelToolbar } from './panel_toolbar'; import { confirmDiscardOrKeepUnsavedChanges } from '../listing/confirm_overlays'; import { OverlayRef } from '../../../../../core/public'; -import { getNewDashboardTitle } from '../../dashboard_strings'; +import { getNewDashboardTitle, unsavedChangesBadge } from '../../dashboard_strings'; import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage'; import { DashboardContainer } from '..'; @@ -64,6 +64,7 @@ export interface DashboardTopNavProps { timefilter: TimefilterContract; indexPatterns: IndexPattern[]; redirectTo: DashboardRedirect; + unsavedChanges?: boolean; lastDashboardId?: string; viewMode: ViewMode; } @@ -72,6 +73,7 @@ export function DashboardTopNav({ dashboardStateManager, dashboardContainer, lastDashboardId, + unsavedChanges, savedDashboard, onQuerySubmit, embedSettings, @@ -467,7 +469,18 @@ export function DashboardTopNav({ isDirty: dashboardStateManager.isDirty, }); + const badges = unsavedChanges + ? [ + { + 'data-test-subj': 'dashboardUnsavedChangesBadge', + badgeText: unsavedChangesBadge.getUnsavedChangedBadgeText(), + color: 'secondary', + }, + ] + : undefined; + return { + badges, appName: 'dashboard', config: showTopNavMenu ? topNav : undefined, className: isFullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined, diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts index 8588fbc1bbdc7f..dad347b176c7ef 100644 --- a/src/plugins/dashboard/public/dashboard_strings.ts +++ b/src/plugins/dashboard/public/dashboard_strings.ts @@ -12,36 +12,30 @@ import { ViewMode } from './services/embeddable'; /** * @param title {string} the current title of the dashboard * @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title. - * @param isDirty {boolean} if the dashboard is in a dirty state. If in dirty state, adds (unsaved) to the - * end of the title. * @returns {string} A title to display to the user based on the above parameters. */ -export function getDashboardTitle( - title: string, - viewMode: ViewMode, - isDirty: boolean, - isNew: boolean -): string { +export function getDashboardTitle(title: string, viewMode: ViewMode, isNew: boolean): string { const isEditMode = viewMode === ViewMode.EDIT; - let displayTitle: string; const dashboardTitle = isNew ? getNewDashboardTitle() : title; + return isEditMode + ? i18n.translate('dashboard.strings.dashboardEditTitle', { + defaultMessage: 'Editing {title}', + values: { title: dashboardTitle }, + }) + : dashboardTitle; +} - if (isEditMode && isDirty) { - displayTitle = i18n.translate('dashboard.strings.dashboardUnsavedEditTitle', { - defaultMessage: 'Editing {title} (unsaved)', - values: { title: dashboardTitle }, - }); - } else if (isEditMode) { - displayTitle = i18n.translate('dashboard.strings.dashboardEditTitle', { - defaultMessage: 'Editing {title}', - values: { title: dashboardTitle }, - }); - } else { - displayTitle = dashboardTitle; - } +export const unsavedChangesBadge = { + getUnsavedChangedBadgeText: () => + i18n.translate('dashboard.unsavedChangesBadge', { + defaultMessage: 'Unsaved changes', + }), +}; - return displayTitle; -} +export const getMigratedToastText = () => + i18n.translate('dashboard.migratedChanges', { + defaultMessage: 'Some panels have been successfully updated to the latest version.', + }); /* Plugin diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 230be399febda3..bc27cf061eb68a 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,3 +1,12 @@ .kbnTopNavMenu { margin-right: $euiSizeXS; } + +.kbnTopNavMenu__badgeWrapper { + display: flex; + align-items: baseline; +} + +.kbnTopNavMenu__badgeGroup { + margin-right: $euiSizeM; +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 70bc3b10b30adc..22edf9c454466c 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -7,7 +7,7 @@ */ import React, { ReactElement } from 'react'; -import { EuiHeaderLinks } from '@elastic/eui'; +import { EuiBadge, EuiBadgeGroup, EuiBadgeProps, EuiHeaderLinks } from '@elastic/eui'; import classNames from 'classnames'; import { MountPoint } from '../../../../core/public'; @@ -23,6 +23,7 @@ import { TopNavMenuItem } from './top_nav_menu_item'; export type TopNavMenuProps = StatefulSearchBarProps & Omit & { config?: TopNavMenuData[]; + badges?: Array; showSearchBar?: boolean; showQueryBar?: boolean; showQueryInput?: boolean; @@ -61,12 +62,28 @@ export type TopNavMenuProps = StatefulSearchBarProps & **/ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { - const { config, showSearchBar, ...searchBarProps } = props; + const { config, badges, showSearchBar, ...searchBarProps } = props; if ((!config || config.length === 0) && (!showSearchBar || !props.data)) { return null; } + function renderBadges(): ReactElement | null { + if (!badges || badges.length === 0) return null; + return ( + + {badges.map((badge: EuiBadgeProps & { badgeText: string }, i: number) => { + const { badgeText, ...badgeProps } = badge; + return ( + + {badgeText} + + ); + })} + + ); + } + function renderItems(): ReactElement[] | null { if (!config || config.length === 0) return null; return config.map((menuItem: TopNavMenuData, i: number) => { @@ -98,7 +115,10 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { return ( <> - {renderMenu(menuClassName)} + + {renderBadges()} + {renderMenu(menuClassName)} + {renderSearchBar()} diff --git a/test/functional/apps/dashboard/copy_panel_to.ts b/test/functional/apps/dashboard/copy_panel_to.ts index bb02bfee49f006..9abdc2ceffc013 100644 --- a/test/functional/apps/dashboard/copy_panel_to.ts +++ b/test/functional/apps/dashboard/copy_panel_to.ts @@ -115,7 +115,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('confirmCopyToButton'); await PageObjects.dashboard.waitForRenderComplete(); - await PageObjects.dashboard.expectOnDashboard(`Editing New Dashboard (unsaved)`); + await PageObjects.dashboard.expectOnDashboard(`Editing New Dashboard`); }); it('it always appends new panels instead of overwriting', async () => { diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts index eaf0d2f2a97df2..e6cc91880010ae 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); @@ -29,10 +30,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.preserveCrossAppState(); await PageObjects.dashboard.loadSavedDashboard('few panels'); - await PageObjects.dashboard.switchToEditMode(); - + await PageObjects.header.waitUntilLoadingHasFinished(); originalPanelCount = await PageObjects.dashboard.getPanelCount(); + }); + it('does not show unsaved changes badge when there are no unsaved changes', async () => { + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); + + it('shows the unsaved changes badge after adding panels', async () => { + await PageObjects.dashboard.switchToEditMode(); // add an area chart by value await dashboardAddPanel.clickCreateNewLink(); await PageObjects.visualize.clickAggBasedVisualizations(); @@ -42,6 +49,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // add a metric by reference await dashboardAddPanel.addVisualization('Rendering-Test: metric'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); }); it('has correct number of panels', async () => { @@ -73,10 +83,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('resets to original panel count upon entering view mode', async () => { await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.header.waitUntilLoadingHasFinished(); const currentPanelCount = await PageObjects.dashboard.getPanelCount(); expect(currentPanelCount).to.eql(originalPanelCount); }); + it('shows unsaved changes badge in view mode if changes have not been discarded', async () => { + await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); + }); + it('retains unsaved panel count after returning to edit mode', async () => { await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.switchToEditMode(); @@ -84,5 +99,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const currentPanelCount = await PageObjects.dashboard.getPanelCount(); expect(currentPanelCount).to.eql(unsavedPanelCount); }); + + it('does not show unsaved changes badge after saving', async () => { + await PageObjects.dashboard.saveDashboard('Unsaved State Test'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); }); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9e5e0ad057ef56..697cc18ad41d12 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -646,7 +646,6 @@ "dashboard.savedDashboard.newDashboardTitle": "新規ダッシュボード", "dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "このダッシュボードに時刻が保存されていないため、同期できません。", "dashboard.strings.dashboardEditTitle": "{title}を編集中", - "dashboard.strings.dashboardUnsavedEditTitle": "{title}を編集中(未保存)", "dashboard.topNav.cloneModal.cancelButtonLabel": "キャンセル", "dashboard.topNav.cloneModal.cloneDashboardModalHeaderTitle": "ダッシュボードのクローンを作成", "dashboard.topNav.cloneModal.confirmButtonLabel": "クローンの確認", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d230d4847df3d9..7c622613173349 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -646,7 +646,6 @@ "dashboard.savedDashboard.newDashboardTitle": "新建仪表板", "dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "时间未随此仪表板保存,因此无法同步。", "dashboard.strings.dashboardEditTitle": "正在编辑 {title}", - "dashboard.strings.dashboardUnsavedEditTitle": "正在编辑 {title}(未保存)", "dashboard.topNav.cloneModal.cancelButtonLabel": "取消", "dashboard.topNav.cloneModal.cloneDashboardModalHeaderTitle": "克隆仪表板", "dashboard.topNav.cloneModal.confirmButtonLabel": "确认克隆", From ea9f1e3dfb3fdd2e49b547fd1bcf2f0df6062b57 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 16 Feb 2021 13:52:43 -0500 Subject: [PATCH 090/175] [Security Solution] Format large numbers in timeline kpis and display value in a tooltip (#91263) * Format large numbers in timeline kpis and display actual value in a tooltip * Use more descriptive timelineId for additional test id Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/types/timeline/index.ts | 2 +- .../events_viewer/events_viewer.test.tsx | 2 +- .../components/flyout/header/index.test.tsx | 40 ++++++- .../components/flyout/header/kpis.tsx | 108 ++++++++++++------ 4 files changed, 110 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index cee8ccdea3e9e1..58e3b9824d8fdb 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -281,7 +281,7 @@ export enum TimelineId { active = 'timeline-1', casePage = 'timeline-case', test = 'test', // Reserved for testing purposes - test2 = 'test2', + alternateTest = 'alternateTest', } export const TimelineIdLiteralRt = runtimeTypes.union([ diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index a37528fcb24d7c..3ecc17589fe084 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -201,7 +201,7 @@ describe('EventsViewer', () => { testProps = { ...testProps, // Update with a new id, to force columns back to default. - id: TimelineId.test2, + id: TimelineId.alternateTest, }; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx index 35dcd88b77e04e..6713be176586cc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { useKibana } from '../../../../common/lib/kibana'; import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock'; +import { TimelineId } from '../../../../../common/types/timeline'; import { useTimelineKpis } from '../../../containers/kpis'; import { FlyoutHeader } from '.'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; @@ -33,6 +34,14 @@ const mockUseTimelineKpiResponse = { hostCount: 1, destinationIpCount: 1, }; + +const mockUseTimelineLargeKpiResponse = { + processCount: 1000, + userCount: 1000000, + sourceIpCount: 1000000000, + hostCount: 999, + destinationIpCount: 1, +}; const defaultMocks = { browserFields: mockBrowserFields, docValueFields: mockDocValueFields, @@ -65,7 +74,7 @@ describe('Timeline KPIs', () => { it('renders the component, labels and values succesfully', async () => { const wrapper = mount( - + ); expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true); @@ -87,7 +96,7 @@ describe('Timeline KPIs', () => { it('renders a loading indicator for values', async () => { const wrapper = mount( - + ); expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( @@ -103,7 +112,7 @@ describe('Timeline KPIs', () => { it('renders labels and the default empty string', async () => { const wrapper = mount( - + ); @@ -115,4 +124,29 @@ describe('Timeline KPIs', () => { ); }); }); + + describe('when the response contains numbers larger than one thousand', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]); + }); + it('formats the numbers correctly', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('1k') + ); + expect(wrapper.find('[data-test-subj="siem-timeline-user-kpi"]').first().text()).toEqual( + expect.stringContaining('1m') + ); + expect(wrapper.find('[data-test-subj="siem-timeline-source-ip-kpi"]').first().text()).toEqual( + expect.stringContaining('1b') + ); + expect(wrapper.find('[data-test-subj="siem-timeline-host-kpi"]').first().text()).toEqual( + expect.stringContaining('999') + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx index 3b0a86432aa969..e487fe70fdc94c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx @@ -5,61 +5,95 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; -import { EuiStat, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { EuiStat, EuiFlexItem, EuiFlexGroup, EuiToolTip } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants'; +import { useUiSetting$ } from '../../../../common/lib/kibana'; import { TimelineKpiStrategyResponse } from '../../../../../common/search_strategy'; import { getEmptyValue } from '../../../../common/components/empty_value'; import * as i18n from './translations'; export const TimelineKPIs = React.memo( ({ kpis, isLoading }: { kpis: TimelineKpiStrategyResponse | null; isLoading: boolean }) => { + const kpiFormat = '0,0.[000]a'; + const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const formattedKpis = useMemo(() => { + return { + process: kpis === null ? getEmptyValue() : numeral(kpis.processCount).format(kpiFormat), + user: kpis === null ? getEmptyValue() : numeral(kpis.userCount).format(kpiFormat), + host: kpis === null ? getEmptyValue() : numeral(kpis.hostCount).format(kpiFormat), + sourceIp: kpis === null ? getEmptyValue() : numeral(kpis.sourceIpCount).format(kpiFormat), + destinationIp: + kpis === null ? getEmptyValue() : numeral(kpis.destinationIpCount).format(kpiFormat), + }; + }, [kpis]); + const formattedKpiToolTips = useMemo(() => { + return { + process: numeral(kpis?.processCount).format(defaultNumberFormat), + user: numeral(kpis?.userCount).format(defaultNumberFormat), + host: numeral(kpis?.hostCount).format(defaultNumberFormat), + sourceIp: numeral(kpis?.sourceIpCount).format(defaultNumberFormat), + destinationIp: numeral(kpis?.destinationIpCount).format(defaultNumberFormat), + }; + }, [kpis, defaultNumberFormat]); return ( - + + + - + + + - + + + - + + + - + + + ); From a99ccc27d7a9d2ad7be162c21f545e8eebf59c69 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 16 Feb 2021 13:54:54 -0500 Subject: [PATCH 091/175] [Fleet] Setup fleet server indices in Kibana without packages (#90658) --- .../plugins/fleet/common/constants/index.ts | 2 + .../server/collectors/agent_collectors.ts | 3 +- x-pack/plugins/fleet/server/plugin.ts | 17 +- .../fleet/server/services/app_context.ts | 4 + .../fleet_server/elastic_index.test.ts | 156 +++++++++++++ .../services/fleet_server/elastic_index.ts | 117 ++++++++++ .../elasticsearch/fleet_actions.json | 30 +++ .../elasticsearch/fleet_agents.json | 220 ++++++++++++++++++ .../fleet_enrollment_api_keys.json | 32 +++ .../elasticsearch/fleet_policies.json | 27 +++ .../elasticsearch/fleet_policies_leader.json | 21 ++ .../elasticsearch/fleet_servers.json | 47 ++++ .../server/services/fleet_server/index.ts | 57 +++++ .../saved_object_migrations.ts} | 36 +-- x-pack/plugins/fleet/server/services/setup.ts | 31 +-- x-pack/plugins/fleet/tsconfig.json | 3 +- 16 files changed, 729 insertions(+), 74 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts create mode 100644 x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts create mode 100644 x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_actions.json create mode 100644 x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json create mode 100644 x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_enrollment_api_keys.json create mode 100644 x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies.json create mode 100644 x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies_leader.json create mode 100644 x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_servers.json create mode 100644 x-pack/plugins/fleet/server/services/fleet_server/index.ts rename x-pack/plugins/fleet/server/services/{fleet_server_migration.ts => fleet_server/saved_object_migrations.ts} (84%) diff --git a/x-pack/plugins/fleet/common/constants/index.ts b/x-pack/plugins/fleet/common/constants/index.ts index dcddbe3539abd1..d95bc9cf736a6b 100644 --- a/x-pack/plugins/fleet/common/constants/index.ts +++ b/x-pack/plugins/fleet/common/constants/index.ts @@ -22,6 +22,8 @@ export * from './settings'; // setting in the future? export const SO_SEARCH_LIMIT = 10000; +export const FLEET_SERVER_INDICES_VERSION = 1; + export const FLEET_SERVER_INDICES = [ '.fleet-actions', '.fleet-agents', diff --git a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts index 8aa66c4ae5f4ac..154e78feae2832 100644 --- a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts +++ b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts @@ -7,7 +7,8 @@ import { ElasticsearchClient, SavedObjectsClient } from 'kibana/server'; import * as AgentService from '../services/agents'; -import { isFleetServerSetup } from '../services/fleet_server_migration'; +import { isFleetServerSetup } from '../services/fleet_server'; + export interface AgentUsage { total: number; online: number; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index d89db7f1ac3415..d4cd39b274f052 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -83,7 +83,7 @@ import { agentCheckinState } from './services/agents/checkin/state'; import { registerFleetUsageCollector } from './collectors/register'; import { getInstallation } from './services/epm/packages'; import { makeRouterEnforcingSuperuser } from './routes/security'; -import { isFleetServerSetup } from './services/fleet_server_migration'; +import { startFleetServerSetup } from './services/fleet_server'; export interface FleetSetupDeps { licensing: LicensingPluginSetup; @@ -297,18 +297,9 @@ export class FleetPlugin licenseService.start(this.licensing$); agentCheckinState.start(); - const fleetServerEnabled = appContextService.getConfig()?.agents?.fleetServerEnabled; - if (fleetServerEnabled) { - // We need licence to be initialized before using the SO service. - await this.licensing$.pipe(first()).toPromise(); - - const fleetSetup = await isFleetServerSetup(); - - if (!fleetSetup) { - this.logger?.warn( - 'Extra setup is needed to be able to use central management for agent, please visit the Fleet app in Kibana.' - ); - } + if (appContextService.getConfig()?.agents?.fleetServerEnabled) { + // Break the promise chain, the error handling is done in startFleetServerSetup + startFleetServerSetup(); } return { diff --git a/x-pack/plugins/fleet/server/services/app_context.ts b/x-pack/plugins/fleet/server/services/app_context.ts index cc4be6b31734a3..1ada940dd793c9 100644 --- a/x-pack/plugins/fleet/server/services/app_context.ts +++ b/x-pack/plugins/fleet/server/services/app_context.ts @@ -80,6 +80,10 @@ class AppContextService { return this.security; } + public hasSecurity() { + return !!this.security; + } + public getCloud() { return this.cloud; } diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts new file mode 100644 index 00000000000000..96e642ba9884e8 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts @@ -0,0 +1,156 @@ +/* + * 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 { elasticsearchServiceMock } from 'src/core/server/mocks'; +import hash from 'object-hash'; +import { setupFleetServerIndexes } from './elastic_index'; +import ESFleetAgentIndex from './elasticsearch/fleet_agents.json'; +import ESFleetPoliciesIndex from './elasticsearch/fleet_policies.json'; +import ESFleetPoliciesLeaderIndex from './elasticsearch/fleet_policies_leader.json'; +import ESFleetServersIndex from './elasticsearch/fleet_servers.json'; +import ESFleetEnrollmentApiKeysIndex from './elasticsearch/fleet_enrollment_api_keys.json'; +import EsFleetActionsIndex from './elasticsearch/fleet_actions.json'; + +const FLEET_INDEXES_MIGRATION_HASH = { + '.fleet-actions': hash(EsFleetActionsIndex), + '.fleet-agents': hash(ESFleetAgentIndex), + '.fleet-enrollment-apy-keys': hash(ESFleetEnrollmentApiKeysIndex), + '.fleet-policies': hash(ESFleetPoliciesIndex), + '.fleet-policies-leader': hash(ESFleetPoliciesLeaderIndex), + '.fleet-servers': hash(ESFleetServersIndex), +}; + +describe('setupFleetServerIndexes ', () => { + it('should create all the indices and aliases if nothings exists', async () => { + const esMock = elasticsearchServiceMock.createInternalClient(); + await setupFleetServerIndexes(esMock); + + const indexesCreated = esMock.indices.create.mock.calls.map((call) => call[0].index).sort(); + expect(indexesCreated).toEqual([ + '.fleet-actions_1', + '.fleet-agents_1', + '.fleet-enrollment-api-keys_1', + '.fleet-policies-leader_1', + '.fleet-policies_1', + '.fleet-servers_1', + ]); + const aliasesCreated = esMock.indices.updateAliases.mock.calls + .map((call) => (call[0].body as any)?.actions[0].add.alias) + .sort(); + + expect(aliasesCreated).toEqual([ + '.fleet-actions', + '.fleet-agents', + '.fleet-enrollment-api-keys', + '.fleet-policies', + '.fleet-policies-leader', + '.fleet-servers', + ]); + }); + + it('should not create any indices and create aliases if indices exists but not the aliases', async () => { + const esMock = elasticsearchServiceMock.createInternalClient(); + // @ts-expect-error + esMock.indices.exists.mockResolvedValue({ body: true }); + // @ts-expect-error + esMock.indices.getMapping.mockImplementation((params: { index: string }) => { + return { + body: { + [params.index]: { + mappings: { + _meta: { + // @ts-expect-error + migrationHash: FLEET_INDEXES_MIGRATION_HASH[params.index.replace(/_1$/, '')], + }, + }, + }, + }, + }; + }); + + await setupFleetServerIndexes(esMock); + + expect(esMock.indices.create).not.toBeCalled(); + const aliasesCreated = esMock.indices.updateAliases.mock.calls + .map((call) => (call[0].body as any)?.actions[0].add.alias) + .sort(); + + expect(aliasesCreated).toEqual([ + '.fleet-actions', + '.fleet-agents', + '.fleet-enrollment-api-keys', + '.fleet-policies', + '.fleet-policies-leader', + '.fleet-servers', + ]); + }); + + it('should put new indices mapping if the mapping has been updated ', async () => { + const esMock = elasticsearchServiceMock.createInternalClient(); + // @ts-expect-error + esMock.indices.exists.mockResolvedValue({ body: true }); + // @ts-expect-error + esMock.indices.getMapping.mockImplementation((params: { index: string }) => { + return { + body: { + [params.index]: { + mappings: { + _meta: { + migrationHash: 'NOT_VALID_HASH', + }, + }, + }, + }, + }; + }); + + await setupFleetServerIndexes(esMock); + + expect(esMock.indices.create).not.toBeCalled(); + const indexesMappingUpdated = esMock.indices.putMapping.mock.calls + .map((call) => call[0].index) + .sort(); + + expect(indexesMappingUpdated).toEqual([ + '.fleet-actions_1', + '.fleet-agents_1', + '.fleet-enrollment-api-keys_1', + '.fleet-policies-leader_1', + '.fleet-policies_1', + '.fleet-servers_1', + ]); + }); + + it('should not create any indices or aliases if indices and aliases already exists', async () => { + const esMock = elasticsearchServiceMock.createInternalClient(); + + // @ts-expect-error + esMock.indices.exists.mockResolvedValue({ body: true }); + // @ts-expect-error + esMock.indices.getMapping.mockImplementation((params: { index: string }) => { + return { + body: { + [params.index]: { + mappings: { + _meta: { + // @ts-expect-error + migrationHash: FLEET_INDEXES_MIGRATION_HASH[params.index.replace(/_1$/, '')], + }, + }, + }, + }, + }; + }); + // @ts-expect-error + esMock.indices.existsAlias.mockResolvedValue({ body: true }); + + await setupFleetServerIndexes(esMock); + + expect(esMock.indices.create).not.toBeCalled(); + expect(esMock.indices.updateAliases).not.toBeCalled(); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts new file mode 100644 index 00000000000000..15672be756fe2d --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'kibana/server'; +import hash from 'object-hash'; + +import { FLEET_SERVER_INDICES, FLEET_SERVER_INDICES_VERSION } from '../../../common'; +import { appContextService } from '../app_context'; +import ESFleetAgentIndex from './elasticsearch/fleet_agents.json'; +import ESFleetPoliciesIndex from './elasticsearch/fleet_policies.json'; +import ESFleetPoliciesLeaderIndex from './elasticsearch/fleet_policies_leader.json'; +import ESFleetServersIndex from './elasticsearch/fleet_servers.json'; +import ESFleetEnrollmentApiKeysIndex from './elasticsearch/fleet_enrollment_api_keys.json'; +import EsFleetActionsIndex from './elasticsearch/fleet_actions.json'; + +const FLEET_INDEXES: Array<[typeof FLEET_SERVER_INDICES[number], any]> = [ + ['.fleet-actions', EsFleetActionsIndex], + ['.fleet-agents', ESFleetAgentIndex], + ['.fleet-enrollment-api-keys', ESFleetEnrollmentApiKeysIndex], + ['.fleet-policies', ESFleetPoliciesIndex], + ['.fleet-policies-leader', ESFleetPoliciesLeaderIndex], + ['.fleet-servers', ESFleetServersIndex], +]; + +export async function setupFleetServerIndexes( + esClient = appContextService.getInternalUserESClient() +) { + await Promise.all( + FLEET_INDEXES.map(async ([indexAlias, indexData]) => { + const index = `${indexAlias}_${FLEET_SERVER_INDICES_VERSION}`; + await createOrUpdateIndex(esClient, index, indexData); + await createAliasIfDoNotExists(esClient, indexAlias, index); + }) + ); +} + +export async function createAliasIfDoNotExists( + esClient: ElasticsearchClient, + alias: string, + index: string +) { + const { body: exists } = await esClient.indices.existsAlias({ + name: alias, + }); + + if (exists === true) { + return; + } + await esClient.indices.updateAliases({ + body: { + actions: [ + { + add: { index, alias }, + }, + ], + }, + }); +} + +async function createOrUpdateIndex( + esClient: ElasticsearchClient, + indexName: string, + indexData: any +) { + const resExists = await esClient.indices.exists({ + index: indexName, + }); + + // Support non destructive migration only (adding new field) + if (resExists.body === true) { + return updateIndex(esClient, indexName, indexData); + } + + return createIndex(esClient, indexName, indexData); +} + +async function updateIndex(esClient: ElasticsearchClient, indexName: string, indexData: any) { + const res = await esClient.indices.getMapping({ + index: indexName, + }); + + const migrationHash = hash(indexData); + if (res.body[indexName].mappings?._meta?.migrationHash !== migrationHash) { + await esClient.indices.putMapping({ + index: indexName, + body: Object.assign({ + ...indexData.mappings, + _meta: { ...(indexData.mappings._meta || {}), migrationHash }, + }), + }); + } +} + +async function createIndex(esClient: ElasticsearchClient, indexName: string, indexData: any) { + try { + const migrationHash = hash(indexData); + await esClient.indices.create({ + index: indexName, + body: { + ...indexData, + mappings: Object.assign({ + ...indexData.mappings, + _meta: { ...(indexData.mappings._meta || {}), migrationHash }, + }), + }, + }); + } catch (err) { + // Swallow already exists errors as concurent Kibana can try to create that indice + if (err?.body?.error?.type !== 'resource_already_exists_exception') { + throw err; + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_actions.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_actions.json new file mode 100644 index 00000000000000..3008ee74ab50c8 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_actions.json @@ -0,0 +1,30 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "action_id": { + "type": "keyword" + }, + "agents": { + "type": "keyword" + }, + "data": { + "enabled": false, + "type": "object" + }, + "expiration": { + "type": "date" + }, + "input_type": { + "type": "keyword" + }, + "@timestamp": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json new file mode 100644 index 00000000000000..9937e9ad66e56f --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json @@ -0,0 +1,220 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "action_seq_no": { + "type": "integer" + }, + "active": { + "type": "boolean" + }, + "agent": { + "properties": { + "id": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "default_api_key": { + "type": "keyword" + }, + "default_api_key_id": { + "type": "keyword" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_checkin_status": { + "type": "keyword" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "properties": { + "elastic": { + "properties": { + "agent": { + "properties": { + "build": { + "properties": { + "original": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "id": { + "type": "keyword" + }, + "log_level": { + "type": "keyword" + }, + "snapshot": { + "type": "boolean" + }, + "upgradeable": { + "type": "boolean" + }, + "version": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 16 + } + } + } + } + } + } + }, + "host": { + "properties": { + "architecture": { + "type": "keyword" + }, + "hostname": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "id": { + "type": "keyword" + }, + "ip": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 64 + } + } + }, + "mac": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 17 + } + } + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "os": { + "properties": { + "family": { + "type": "keyword" + }, + "full": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 128 + } + } + }, + "kernel": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 128 + } + } + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "platform": { + "type": "keyword" + }, + "version": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 32 + } + } + } + } + } + } + }, + "packages": { + "type": "keyword" + }, + "policy_coordinator_idx": { + "type": "integer" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision_idx": { + "type": "integer" + }, + "shared_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "unenrolled_at": { + "type": "date" + }, + "unenrollment_started_at": { + "type": "date" + }, + "updated_at": { + "type": "date" + }, + "upgrade_started_at": { + "type": "date" + }, + "upgraded_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "object", + "enabled": false + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_enrollment_api_keys.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_enrollment_api_keys.json new file mode 100644 index 00000000000000..fc3898aff55c66 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_enrollment_api_keys.json @@ -0,0 +1,32 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "keyword" + }, + "api_key_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies.json new file mode 100644 index 00000000000000..50078aaa5ea988 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies.json @@ -0,0 +1,27 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "coordinator_idx": { + "type": "integer" + }, + "data": { + "enabled": false, + "type": "object" + }, + "default_fleet_server": { + "type": "boolean" + }, + "policy_id": { + "type": "keyword" + }, + "revision_idx": { + "type": "integer" + }, + "@timestamp": { + "type": "date" + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies_leader.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies_leader.json new file mode 100644 index 00000000000000..ad3dfe64df57c3 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies_leader.json @@ -0,0 +1,21 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "server": { + "properties": { + "id": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "@timestamp": { + "type": "date" + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_servers.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_servers.json new file mode 100644 index 00000000000000..9ee68735d5b6fc --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_servers.json @@ -0,0 +1,47 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "agent": { + "properties": { + "id": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "ip": { + "type": "keyword" + }, + "name": { + "type": "keyword" + } + } + }, + "server": { + "properties": { + "id": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "@timestamp": { + "type": "date" + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/index.ts b/x-pack/plugins/fleet/server/services/fleet_server/index.ts new file mode 100644 index 00000000000000..0b54dc0d168b4f --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { first } from 'rxjs/operators'; +import { appContextService } from '../app_context'; +import { licenseService } from '../license'; +import { setupFleetServerIndexes } from './elastic_index'; +import { runFleetServerMigration } from './saved_object_migrations'; + +let _isFleetServerSetup = false; +let _isPending = false; +let _status: Promise | undefined; +let _onResolve: (arg?: any) => void; + +export function isFleetServerSetup() { + return _isFleetServerSetup; +} + +export function awaitIfFleetServerSetupPending() { + if (!_isPending) { + return; + } + + return _status; +} + +export async function startFleetServerSetup() { + _isPending = true; + _status = new Promise((resolve) => { + _onResolve = resolve; + }); + const logger = appContextService.getLogger(); + if (!appContextService.hasSecurity()) { + // Fleet will not work if security is not enabled + logger?.warn('Fleet requires the security plugin to be enabled.'); + return; + } + + try { + // We need licence to be initialized before using the SO service. + await licenseService.getLicenseInformation$()?.pipe(first())?.toPromise(); + await setupFleetServerIndexes(); + await runFleetServerMigration(); + _isFleetServerSetup = true; + } catch (err) { + logger?.error('Setup for central management of agents failed.'); + logger?.error(err); + } + _isPending = false; + if (_onResolve) { + _onResolve(); + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts b/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts similarity index 84% rename from x-pack/plugins/fleet/server/services/fleet_server_migration.ts rename to x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts index 170bec54983c0e..84e6b06e59844f 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts @@ -17,38 +17,12 @@ import { AgentSOAttributes, FleetServerAgent, SO_SEARCH_LIMIT, - FLEET_SERVER_PACKAGE, - FLEET_SERVER_INDICES, -} from '../../common'; -import { listEnrollmentApiKeys, getEnrollmentAPIKey } from './api_keys/enrollment_api_key_so'; -import { appContextService } from './app_context'; -import { getInstallation } from './epm/packages'; - -import { isAgentsSetup } from './agents'; -import { agentPolicyService } from './agent_policy'; - -export async function isFleetServerSetup() { - const pkgInstall = await getInstallation({ - savedObjectsClient: getInternalUserSOClient(), - pkgName: FLEET_SERVER_PACKAGE, - }); - - if (!pkgInstall) { - return false; - } +} from '../../../common'; +import { listEnrollmentApiKeys, getEnrollmentAPIKey } from '../api_keys/enrollment_api_key_so'; +import { appContextService } from '../app_context'; - const esClient = appContextService.getInternalUserESClient(); - const exists = await Promise.all( - FLEET_SERVER_INDICES.map(async (index) => { - const res = await esClient.indices.exists({ - index, - }); - return res.statusCode !== 404; - }) - ); - - return exists.every((exist) => exist === true); -} +import { isAgentsSetup } from '../agents'; +import { agentPolicyService } from '../agent_policy'; export async function runFleetServerMigration() { // If Agents are not setup skip as there is nothing to migrate diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 94c3c606f9f8f0..2a3166e9dc7296 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -23,7 +23,6 @@ import { Output, DEFAULT_AGENT_POLICIES_PACKAGES, FLEET_SERVER_PACKAGE, - FLEET_SERVER_INDICES, } from '../../common'; import { SO_SEARCH_LIMIT } from '../constants'; import { getPackageInfo } from './epm/packages'; @@ -34,7 +33,7 @@ import { awaitIfPending } from './setup_utils'; import { createDefaultSettings } from './settings'; import { ensureAgentActionPolicyChangeExists } from './agents'; import { appContextService } from './app_context'; -import { runFleetServerMigration } from './fleet_server_migration'; +import { awaitIfFleetServerSetupPending } from './fleet_server'; const FLEET_ENROLL_USERNAME = 'fleet_enroll'; const FLEET_ENROLL_ROLE = 'fleet_enroll'; @@ -88,24 +87,15 @@ async function createSetupSideEffects( // By moving this outside of the Promise.all, the upgrade will occur first, and then we'll attempt to reinstall any // packages that are stuck in the installing state. await ensurePackagesCompletedInstall(soClient, callCluster); + if (isFleetServerEnabled) { - await ensureInstalledPackage({ - savedObjectsClient: soClient, - pkgName: FLEET_SERVER_PACKAGE, - callCluster, - }); - await ensureFleetServerIndicesCreated(esClient); - await runFleetServerMigration(); - } + await awaitIfFleetServerSetupPending(); - if (appContextService.getConfig()?.agents?.fleetServerEnabled) { const fleetServerPackage = await ensureInstalledPackage({ savedObjectsClient: soClient, pkgName: FLEET_SERVER_PACKAGE, callCluster, }); - await ensureFleetServerIndicesCreated(esClient); - await runFleetServerMigration(); if (defaultFleetServerPolicyCreated) { await addPackageToAgentPolicy( @@ -187,21 +177,6 @@ async function updateFleetRoleIfExists(callCluster: CallESAsCurrentUser) { return putFleetRole(callCluster); } -async function ensureFleetServerIndicesCreated(esClient: ElasticsearchClient) { - await Promise.all( - FLEET_SERVER_INDICES.map(async (index) => { - const res = await esClient.indices.exists({ - index, - }); - if (res.statusCode === 404) { - await esClient.indices.create({ - index, - }); - } - }) - ); -} - async function putFleetRole(callCluster: CallESAsCurrentUser) { return callCluster('transport.request', { method: 'PUT', diff --git a/x-pack/plugins/fleet/tsconfig.json b/x-pack/plugins/fleet/tsconfig.json index 152fb2e132f626..e6dc206912c4bd 100644 --- a/x-pack/plugins/fleet/tsconfig.json +++ b/x-pack/plugins/fleet/tsconfig.json @@ -5,13 +5,14 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, }, "include": [ // add all the folders containg files to be compiled "common/**/*", "public/**/*", "server/**/*", + "server/**/*.json", "scripts/**/*", "package.json", "../../typings/**/*" From db6cd8665c8992b0647624a07fdd4b29cdc6c24b Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 16 Feb 2021 13:21:16 -0600 Subject: [PATCH 092/175] [CI] Detect architecture when determining node download url (#91497) Currently CI is setup to always download the x64 Node.js architecture. When runing builds on ARM machines we'll want to make sure the architecture matches the machine. --- src/dev/ci_setup/setup_env.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/dev/ci_setup/setup_env.sh b/src/dev/ci_setup/setup_env.sh index 2deafaaf35a94d..b9898960135fcd 100644 --- a/src/dev/ci_setup/setup_env.sh +++ b/src/dev/ci_setup/setup_env.sh @@ -56,7 +56,13 @@ export WORKSPACE="${WORKSPACE:-$PARENT_DIR}" nodeVersion="$(cat "$dir/.node-version")" nodeDir="$cacheDir/node/$nodeVersion" nodeBin="$nodeDir/bin" -classifier="x64.tar.gz" +hostArch="$(command uname -m)" +case "${hostArch}" in + x86_64 | amd64) nodeArch="x64" ;; + aarch64) nodeArch="arm64" ;; + *) nodeArch="${hostArch}" ;; +esac +classifier="$nodeArch.tar.gz" UNAME=$(uname) OS="linux" From 312351c52c1acc7707813e6b8547df4f379b0cba Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 16 Feb 2021 20:27:25 +0100 Subject: [PATCH 093/175] Unify Kibana & Elasticsearch logging config keys (#90764) * align logging config with ES. rename kind to type. * rename file "path" to "fileName" * rename logger "context" to "name" * update audit log docs and tests * update docs * fix integration tests * update deprecations for audit appender * add tests for audit logging deprecations * fix eslint problem Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...re-server.loggingservicesetup.configure.md | 2 +- docs/settings/security-settings.asciidoc | 22 ++-- ...gacy_object_to_config_adapter.test.ts.snap | 4 +- .../legacy/legacy_object_to_config_adapter.ts | 2 +- .../core_usage_data_service.ts | 2 +- .../http/integration_tests/logging.test.ts | 12 +- .../legacy/integration_tests/logging.test.ts | 6 +- .../logging/appenders/legacy_appender.test.ts | 6 +- .../logging/appenders/legacy_appender.ts | 4 +- src/core/server/logging/README.md | 120 +++++++++--------- .../__snapshots__/logging_system.test.ts.snap | 2 +- .../logging/appenders/appenders.test.mocks.ts | 2 +- .../logging/appenders/appenders.test.ts | 50 ++++---- .../server/logging/appenders/appenders.ts | 4 +- .../console/console_appender.test.ts | 12 +- .../appenders/console/console_appender.ts | 4 +- .../file/file_appender.test.mocks.ts | 2 +- .../appenders/file/file_appender.test.ts | 18 +-- .../logging/appenders/file/file_appender.ts | 8 +- .../appenders/rolling_file/policies/index.ts | 4 +- .../size_limit/size_limit_policy.test.ts | 2 +- .../policies/size_limit/size_limit_policy.ts | 4 +- .../time_interval_policy.test.ts | 2 +- .../time_interval/time_interval_policy.ts | 4 +- .../rolling_file_appender.test.ts | 12 +- .../rolling_file/rolling_file_appender.ts | 10 +- .../rolling_file/strategies/index.ts | 2 +- .../numeric/numeric_strategy.test.ts | 4 +- .../strategies/numeric/numeric_strategy.ts | 14 +- .../logging/integration_tests/logging.test.ts | 34 ++--- .../rolling_file_appender.test.ts | 32 ++--- .../logging/layouts/json_layout.test.ts | 2 +- .../server/logging/layouts/json_layout.ts | 4 +- .../server/logging/layouts/layouts.test.ts | 18 +-- src/core/server/logging/layouts/layouts.ts | 2 +- .../logging/layouts/pattern_layout.test.ts | 12 +- .../server/logging/layouts/pattern_layout.ts | 4 +- .../server/logging/logging_config.test.ts | 84 ++++++------ src/core/server/logging/logging_config.ts | 23 ++-- .../server/logging/logging_service.test.ts | 10 +- src/core/server/logging/logging_service.ts | 2 +- .../server/logging/logging_system.test.ts | 62 ++++----- src/core/server/logging/logging_system.ts | 6 +- .../integration_tests/migration.test.ts | 8 +- .../migration_7.7.2_xpack_100k.test.ts | 8 +- .../server/audit/audit_service.test.ts | 26 ++-- .../security/server/audit/audit_service.ts | 6 +- x-pack/plugins/security/server/config.test.ts | 16 +-- .../server/config_deprecations.test.ts | 111 ++++++++++++++++ .../security/server/config_deprecations.ts | 7 + .../security_api_integration/audit.config.ts | 6 +- 51 files changed, 468 insertions(+), 355 deletions(-) diff --git a/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md b/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md index 04a3cf9aff6448..52ab5f1098457c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md +++ b/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md @@ -34,7 +34,7 @@ Customize the configuration for the plugins.data.search context. core.logging.configure( of({ appenders: new Map(), - loggers: [{ context: 'search', appenders: ['default'] }] + loggers: [{ name: 'search', appenders: ['default'] }] }) ) diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index afcb7bc21b66b6..7ffb6b66f5a2b4 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -356,26 +356,26 @@ To enable the <>, specify wh [source,yaml] ---------------------------------------- xpack.security.audit.appender: - kind: rolling-file - path: ./audit.log + type: rolling-file + fileName: ./audit.log policy: - kind: time-interval + type: time-interval interval: 24h <1> strategy: - kind: numeric + type: numeric max: 10 <2> layout: - kind: json + type: json ---------------------------------------- <1> Rotates log files every 24 hours. <2> Keeps maximum of 10 log files before deleting older ones. -| `xpack.security.audit.appender.kind` +| `xpack.security.audit.appender.type` | Required. Specifies where audit logs should be written to. Allowed values are `console`, `file`, or `rolling-file`. Refer to <> and <> for appender specific settings. -| `xpack.security.audit.appender.layout.kind` +| `xpack.security.audit.appender.layout.type` | Required. Specifies how audit logs should be formatted. Allowed values are `json` or `pattern`. Refer to <> for layout specific settings. @@ -396,7 +396,7 @@ The `file` appender writes to a file and can be configured using the following s [cols="2*<"] |====== -| `xpack.security.audit.appender.path` +| `xpack.security.audit.appender.fileName` | Required. Full file path the log file should be written to. |====== @@ -408,14 +408,14 @@ The `rolling-file` appender writes to a file and rotates it using a rolling stra [cols="2*<"] |====== -| `xpack.security.audit.appender.path` +| `xpack.security.audit.appender.fileName` | Required. Full file path the log file should be written to. -| `xpack.security.audit.appender.policy.kind` +| `xpack.security.audit.appender.policy.type` | Specifies when a rollover should occur. Allowed values are `size-limit` and `time-interval`. *Default:* `time-interval`. Refer to <> and <> for policy specific settings. -| `xpack.security.audit.appender.strategy.kind` +| `xpack.security.audit.appender.strategy.type` | Specifies how the rollover should occur. Only allowed value is currently `numeric`. *Default:* `numeric` Refer to <> for strategy specific settings. diff --git a/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap b/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap index 6351a227ff90b8..2801e0a0688cc6 100644 --- a/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap +++ b/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap @@ -68,10 +68,10 @@ exports[`#get correctly handles silent logging config. 1`] = ` Object { "appenders": Object { "default": Object { - "kind": "legacy-appender", "legacyLoggingConfig": Object { "silent": true, }, + "type": "legacy-appender", }, }, "loggers": undefined, @@ -85,12 +85,12 @@ exports[`#get correctly handles verbose file logging config with json format. 1` Object { "appenders": Object { "default": Object { - "kind": "legacy-appender", "legacyLoggingConfig": Object { "dest": "/some/path.log", "json": true, "verbose": true, }, + "type": "legacy-appender", }, }, "loggers": undefined, diff --git a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts index 4d877a26b76418..8ec26ff1f8e71c 100644 --- a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts +++ b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts @@ -44,7 +44,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { const loggingConfig = { appenders: { ...appenders, - default: { kind: 'legacy-appender', legacyLoggingConfig }, + default: { type: 'legacy-appender', legacyLoggingConfig }, }, root: { level: 'info', ...root }, loggers, diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index bd5f23b1c09bc7..e57d8d90a02dcc 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -226,7 +226,7 @@ export class CoreUsageDataService implements CoreService acc.add(a.kind), new Set()) + .reduce((acc, a) => acc.add(a.type), new Set()) .values() ), loggersConfiguredCount: this.loggingConfig?.loggers.length ?? 0, diff --git a/src/core/server/http/integration_tests/logging.test.ts b/src/core/server/http/integration_tests/logging.test.ts index ba265c1ff61bc2..fcf2cd2ba3372d 100644 --- a/src/core/server/http/integration_tests/logging.test.ts +++ b/src/core/server/http/integration_tests/logging.test.ts @@ -50,16 +50,16 @@ describe('request logging', () => { silent: true, appenders: { 'test-console': { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', pattern: '%level|%logger|%message|%meta', }, }, }, loggers: [ { - context: 'http.server.response', + name: 'http.server.response', appenders: ['test-console'], level: 'debug', }, @@ -96,16 +96,16 @@ describe('request logging', () => { silent: true, appenders: { 'test-console': { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', pattern: '%level|%logger|%message|%meta', }, }, }, loggers: [ { - context: 'http.server.response', + name: 'http.server.response', appenders: ['test-console'], level: 'debug', }, diff --git a/src/core/server/legacy/integration_tests/logging.test.ts b/src/core/server/legacy/integration_tests/logging.test.ts index 321eb81708f1e7..88c45962ce4a68 100644 --- a/src/core/server/legacy/integration_tests/logging.test.ts +++ b/src/core/server/legacy/integration_tests/logging.test.ts @@ -29,16 +29,16 @@ function createRoot(legacyLoggingConfig: LegacyLoggingConfig = {}) { // platform config appenders: { 'test-console': { - kind: 'console', + type: 'console', layout: { highlight: false, - kind: 'pattern', + type: 'pattern', }, }, }, loggers: [ { - context: 'test-file', + name: 'test-file', appenders: ['test-console'], level: 'info', }, diff --git a/src/core/server/legacy/logging/appenders/legacy_appender.test.ts b/src/core/server/legacy/logging/appenders/legacy_appender.test.ts index 1b76b6748a5bb6..9213403d72d07a 100644 --- a/src/core/server/legacy/logging/appenders/legacy_appender.test.ts +++ b/src/core/server/legacy/logging/appenders/legacy_appender.test.ts @@ -16,13 +16,13 @@ afterEach(() => (LegacyLoggingServer as any).mockClear()); test('`configSchema` creates correct schema.', () => { const appenderSchema = LegacyAppender.configSchema; - const validConfig = { kind: 'legacy-appender', legacyLoggingConfig: { verbose: true } }; + const validConfig = { type: 'legacy-appender', legacyLoggingConfig: { verbose: true } }; expect(appenderSchema.validate(validConfig)).toEqual({ - kind: 'legacy-appender', + type: 'legacy-appender', legacyLoggingConfig: { verbose: true }, }); - const wrongConfig = { kind: 'not-legacy-appender' }; + const wrongConfig = { type: 'not-legacy-appender' }; expect(() => appenderSchema.validate(wrongConfig)).toThrow(); }); diff --git a/src/core/server/legacy/logging/appenders/legacy_appender.ts b/src/core/server/legacy/logging/appenders/legacy_appender.ts index 83e43999eeebf1..a89441a5671b55 100644 --- a/src/core/server/legacy/logging/appenders/legacy_appender.ts +++ b/src/core/server/legacy/logging/appenders/legacy_appender.ts @@ -12,7 +12,7 @@ import { DisposableAppender, LogRecord } from '@kbn/logging'; import { LegacyVars } from '../../types'; export interface LegacyAppenderConfig { - kind: 'legacy-appender'; + type: 'legacy-appender'; legacyLoggingConfig?: any; } @@ -22,7 +22,7 @@ export interface LegacyAppenderConfig { */ export class LegacyAppender implements DisposableAppender { public static configSchema = schema.object({ - kind: schema.literal('legacy-appender'), + type: schema.literal('legacy-appender'), legacyLoggingConfig: schema.any(), }); diff --git a/src/core/server/logging/README.md b/src/core/server/logging/README.md index 9e3da1f3e0d715..385d1fd91a5d75 100644 --- a/src/core/server/logging/README.md +++ b/src/core/server/logging/README.md @@ -24,7 +24,7 @@ Kibana logging system has three main components: _loggers_, _appenders_ and _lay messages according to message type and level, and to control how these messages are formatted and where the final logs will be displayed or stored. -__Loggers__ define what logging settings should be applied at the particular context. +__Loggers__ define what logging settings should be applied at the particular context name. __Appenders__ define where log messages are displayed (eg. stdout or console) and stored (eg. file on the disk). @@ -33,17 +33,17 @@ __Layouts__ define how log messages are formatted and what type of information t ## Logger hierarchy -Every logger has its unique name or context that follows hierarchical naming rule. The logger is considered to be an +Every logger has its unique context name that follows hierarchical naming rule. The logger is considered to be an ancestor of another logger if its name followed by a `.` is a prefix of the descendant logger name. For example logger -with `a.b` context is an ancestor of logger with `a.b.c` context. All top-level loggers are descendants of special -logger with `root` context that resides at the top of the logger hierarchy. This logger always exists and +with `a.b` context name is an ancestor of logger with `a.b.c` context name. All top-level loggers are descendants of special +logger with `root` context name that resides at the top of the logger hierarchy. This logger always exists and fully configured. -Developer can configure _log level_ and _appenders_ that should be used within particular context. If logger configuration +Developer can configure _log level_ and _appenders_ that should be used within particular context name. If logger configuration specifies only _log level_ then _appenders_ configuration will be inherited from the ancestor logger. __Note:__ in the current implementation log messages are only forwarded to appenders configured for a particular logger -context or to appenders of the closest ancestor if current logger doesn't have any appenders configured. That means that +context name or to appenders of the closest ancestor if current logger doesn't have any appenders configured. That means that we __don't support__ so called _appender additivity_ when log messages are forwarded to _every_ distinct appender within ancestor chain including `root`. @@ -55,7 +55,7 @@ A log record is being logged by the logger if its level is higher than or equal the log record is ignored. The _all_ and _off_ levels can be used only in configuration and are just handy shortcuts that allow developer to log every -log record or disable logging entirely for the specific context. +log record or disable logging entirely for the specific context name. ## Layouts @@ -129,7 +129,7 @@ Example of `%date` output: Outputs the process ID. ### JSON layout -With `json` layout log messages will be formatted as JSON strings that include timestamp, log level, context, message +With `json` layout log messages will be formatted as JSON strings that include timestamp, log level, context name, message text and any other metadata that may be associated with the log message itself. ## Appenders @@ -153,15 +153,15 @@ This policy will rotate the file when it reaches a predetermined size. logging: appenders: rolling-file: - kind: rolling-file - path: /var/logs/kibana.log + type: rolling-file + fileName: /var/logs/kibana.log policy: - kind: size-limit + type: size-limit size: 50mb strategy: //... layout: - kind: pattern + type: pattern ``` The options are: @@ -180,16 +180,16 @@ This policy will rotate the file every given interval of time. logging: appenders: rolling-file: - kind: rolling-file - path: /var/logs/kibana.log + type: rolling-file + fileName: /var/logs/kibana.log policy: - kind: time-interval + type: time-interval interval: 10s modulate: true strategy: //... layout: - kind: pattern + type: pattern ``` The options are: @@ -225,16 +225,16 @@ and will retains a fixed amount of rolled files. logging: appenders: rolling-file: - kind: rolling-file - path: /var/logs/kibana.log + type: rolling-file + fileName: /var/logs/kibana.log policy: // ... strategy: - kind: numeric + type: numeric pattern: '-%i' max: 2 layout: - kind: pattern + type: pattern ``` For example, with this configuration: @@ -253,7 +253,7 @@ The options are: The suffix to append to the file path when rolling. Must include `%i`, as this is the value that will be converted to the file index. -for example, with `path: /var/logs/kibana.log` and `pattern: '-%i'`, the created rolling files +for example, with `fileName: /var/logs/kibana.log` and `pattern: '-%i'`, the created rolling files will be `/var/logs/kibana-1.log`, `/var/logs/kibana-2.log`, and so on. The default value is `-%i` @@ -278,49 +278,49 @@ Here is the configuration example that can be used to configure _loggers_, _appe logging: appenders: console: - kind: console + type: console layout: - kind: pattern + type: pattern highlight: true file: - kind: file - path: /var/log/kibana.log + type: file + fileName: /var/log/kibana.log layout: - kind: pattern + type: pattern custom: - kind: console + type: console layout: - kind: pattern + type: pattern pattern: "[%date][%level] %message" json-file-appender: - kind: file - path: /var/log/kibana-json.log + type: file + fileName: /var/log/kibana-json.log root: appenders: [console, file] level: error loggers: - - context: plugins + - name: plugins appenders: [custom] level: warn - - context: plugins.myPlugin + - name: plugins.myPlugin level: info - - context: server + - name: server level: fatal - - context: optimize + - name: optimize appenders: [console] - - context: telemetry + - name: telemetry level: all appenders: [json-file-appender] - - context: metrics.ops + - name: metrics.ops level: debug appenders: [console] ``` Here is what we get with the config above: -| Context | Appenders | Level | +| Context name | Appenders | Level | | ---------------- |:------------------------:| -----:| | root | console, file | error | | plugins | custom | warn | @@ -331,7 +331,7 @@ Here is what we get with the config above: | metrics.ops | console | debug | -The `root` logger has a dedicated configuration node since this context is special and should always exist. By +The `root` logger has a dedicated configuration node since this context name is special and should always exist. By default `root` is configured with `info` level and `default` appender that is also always available. This is the configuration that all custom loggers will use unless they're re-configured explicitly. @@ -391,7 +391,7 @@ The message contains some high-level information, and the corresponding log meta ## Usage -Usage is very straightforward, one should just get a logger for a specific context and use it to log messages with +Usage is very straightforward, one should just get a logger for a specific context name and use it to log messages with different log level. ```typescript @@ -409,7 +409,7 @@ loggerWithNestedContext.trace('Message with `trace` log level.'); loggerWithNestedContext.debug('Message with `debug` log level.'); ``` -And assuming logger for `server` context with `console` appender and `trace` level was used, console output will look like this: +And assuming logger for `server` name with `console` appender and `trace` level was used, console output will look like this: ```bash [2017-07-25T11:54:41.639-07:00][TRACE][server] Message with `trace` log level. [2017-07-25T11:54:41.639-07:00][DEBUG][server] Message with `debug` log level. @@ -422,7 +422,7 @@ And assuming logger for `server` context with `console` appender and `trace` lev [2017-07-25T11:54:41.639-07:00][DEBUG][server.http] Message with `debug` log level. ``` -The log will be less verbose with `warn` level for the `server` context: +The log will be less verbose with `warn` level for the `server` context name: ```bash [2017-07-25T11:54:41.639-07:00][WARN ][server] Message with `warn` log level. [2017-07-25T11:54:41.639-07:00][ERROR][server] Message with `error` log level. @@ -433,7 +433,7 @@ The log will be less verbose with `warn` level for the `server` context: Compatibility with the legacy logging system is assured until the end of the `v7` version. All log messages handled by `root` context are forwarded to the legacy logging service. If you re-write root appenders, make sure that it contains `default` appender to provide backward compatibility. -**Note**: If you define an appender for a context, the log messages aren't handled by the +**Note**: If you define an appender for a context name, the log messages aren't handled by the `root` context anymore and not forwarded to the legacy logging service. #### logging.dest @@ -442,21 +442,21 @@ define a custom one. ```yaml logging: loggers: - - context: plugins.myPlugin + - name: plugins.myPlugin appenders: [console] ``` -Logs in a *file* if given file path. You should define a custom appender with `kind: file` +Logs in a *file* if given file path. You should define a custom appender with `type: file` ```yaml logging: appenders: file: - kind: file - path: /var/log/kibana.log + type: file + fileName: /var/log/kibana.log layout: - kind: pattern + type: pattern loggers: - - context: plugins.myPlugin + - name: plugins.myPlugin appenders: [file] ``` #### logging.json @@ -468,7 +468,7 @@ Suppresses all logging output other than error messages. With new logging, confi with adjusting minimum required [logging level](#log-level). ```yaml loggers: - - context: plugins.myPlugin + - name: plugins.myPlugin appenders: [console] level: error # or for all output @@ -494,32 +494,32 @@ to [specify timezone](#date) for `layout: pattern`. Defaults to host timezone wh logging: appenders: custom-console: - kind: console + type: console layout: - kind: pattern + type: pattern highlight: true pattern: "[%level] [%date{ISO8601_TZ}{America/Los_Angeles}][%logger] %message" ``` #### logging.events -Define a custom logger for a specific context. +Define a custom logger for a specific context name. **`logging.events.ops`** outputs sample system and process information at a regular interval. -With the new logging config, these are provided by a dedicated [context](#logger-hierarchy), +With the new logging config, these are provided by a dedicated [context name](#logger-hierarchy), and you can enable them by adjusting the minimum required [logging level](#log-level) to `debug`: ```yaml loggers: - - context: metrics.ops + - name: metrics.ops appenders: [console] level: debug ``` **`logging.events.request` and `logging.events.response`** provide logs for each request handled -by the http service. With the new logging config, these are provided by a dedicated [context](#logger-hierarchy), +by the http service. With the new logging config, these are provided by a dedicated [context name](#logger-hierarchy), and you can enable them by adjusting the minimum required [logging level](#log-level) to `debug`: ```yaml loggers: - - context: http.server.response + - name: http.server.response appenders: [console] level: debug ``` @@ -532,7 +532,7 @@ TBD | Parameter | Platform log record in **pattern** format | Legacy Platform log record **text** format | | --------------- | ------------------------------------------ | ------------------------------------------ | | @timestamp | ISO8601_TZ `2012-01-31T23:33:22.011-05:00` | Absolute `23:33:22.011` | -| context | `parent.child` | `['parent', 'child']` | +| context name | `parent.child` | `['parent', 'child']` | | level | `DEBUG` | `['debug']` | | meta | stringified JSON object `{"to": "v8"}` | N/A | | pid | can be configured as `%pid` | N/A | @@ -540,9 +540,9 @@ TBD | Parameter | Platform log record in **json** format | Legacy Platform log record **json** format | | --------------- | ------------------------------------------ | -------------------------------------------- | | @timestamp | ISO8601_TZ `2012-01-31T23:33:22.011-05:00` | ISO8601 `2012-01-31T23:33:22.011Z` | -| context | `context: parent.child` | `tags: ['parent', 'child']` | -| level | `level: DEBUG` | `tags: ['debug']` | +| context name | `log.logger: parent.child` | `tags: ['parent', 'child']` | +| level | `log.level: DEBUG` | `tags: ['debug']` | | meta | separate property `"meta": {"to": "v8"}` | merged in log record `{... "to": "v8"}` | -| pid | `pid: 12345` | `pid: 12345` | +| pid | `process.pid: 12345` | `pid: 12345` | | type | N/A | `type: log` | | error | `{ message, name, stack }` | `{ message, name, stack, code, signal }` | diff --git a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap index 8013aec4a06fd3..81321a3b1fe44c 100644 --- a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap +++ b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap @@ -84,7 +84,7 @@ Object { } `; -exports[`uses \`root\` logger if context is not specified. 1`] = ` +exports[`uses \`root\` logger if context name is not specified. 1`] = ` Array [ Array [ "[2012-01-31T03:33:22.011-05:00][INFO ][root] This message goes to a root context.", diff --git a/src/core/server/logging/appenders/appenders.test.mocks.ts b/src/core/server/logging/appenders/appenders.test.mocks.ts index 85a86ff9306c7b..1427cd7220de71 100644 --- a/src/core/server/logging/appenders/appenders.test.mocks.ts +++ b/src/core/server/logging/appenders/appenders.test.mocks.ts @@ -12,7 +12,7 @@ jest.mock('../layouts/layouts', () => { const { schema } = require('@kbn/config-schema'); return { Layouts: { - configSchema: schema.object({ kind: schema.literal('mock') }), + configSchema: schema.object({ type: schema.literal('mock') }), create: mockCreateLayout, }, }; diff --git a/src/core/server/logging/appenders/appenders.test.ts b/src/core/server/logging/appenders/appenders.test.ts index 8e1c18ae3ded66..bd32e4061049bd 100644 --- a/src/core/server/logging/appenders/appenders.test.ts +++ b/src/core/server/logging/appenders/appenders.test.ts @@ -21,33 +21,33 @@ beforeEach(() => { test('`configSchema` creates correct schema.', () => { const appendersSchema = Appenders.configSchema; - const validConfig1 = { kind: 'file', layout: { kind: 'mock' }, path: 'path' }; + const validConfig1 = { type: 'file', layout: { type: 'mock' }, fileName: 'path' }; expect(appendersSchema.validate(validConfig1)).toEqual({ - kind: 'file', - layout: { kind: 'mock' }, - path: 'path', + type: 'file', + layout: { type: 'mock' }, + fileName: 'path', }); - const validConfig2 = { kind: 'console', layout: { kind: 'mock' } }; + const validConfig2 = { type: 'console', layout: { type: 'mock' } }; expect(appendersSchema.validate(validConfig2)).toEqual({ - kind: 'console', - layout: { kind: 'mock' }, + type: 'console', + layout: { type: 'mock' }, }); const wrongConfig1 = { - kind: 'console', - layout: { kind: 'mock' }, - path: 'path', + type: 'console', + layout: { type: 'mock' }, + fileName: 'path', }; expect(() => appendersSchema.validate(wrongConfig1)).toThrow(); - const wrongConfig2 = { kind: 'file', layout: { kind: 'mock' } }; + const wrongConfig2 = { type: 'file', layout: { type: 'mock' } }; expect(() => appendersSchema.validate(wrongConfig2)).toThrow(); const wrongConfig3 = { - kind: 'console', - layout: { kind: 'mock' }, - path: 'path', + type: 'console', + layout: { type: 'mock' }, + fileName: 'path', }; expect(() => appendersSchema.validate(wrongConfig3)).toThrow(); }); @@ -56,31 +56,31 @@ test('`create()` creates correct appender.', () => { mockCreateLayout.mockReturnValue({ format: () => '' }); const consoleAppender = Appenders.create({ - kind: 'console', - layout: { highlight: true, kind: 'pattern', pattern: '' }, + type: 'console', + layout: { highlight: true, type: 'pattern', pattern: '' }, }); expect(consoleAppender).toBeInstanceOf(ConsoleAppender); const fileAppender = Appenders.create({ - kind: 'file', - layout: { highlight: true, kind: 'pattern', pattern: '' }, - path: 'path', + type: 'file', + layout: { highlight: true, type: 'pattern', pattern: '' }, + fileName: 'path', }); expect(fileAppender).toBeInstanceOf(FileAppender); const legacyAppender = Appenders.create({ - kind: 'legacy-appender', + type: 'legacy-appender', legacyLoggingConfig: { verbose: true }, }); expect(legacyAppender).toBeInstanceOf(LegacyAppender); const rollingFileAppender = Appenders.create({ - kind: 'rolling-file', - path: 'path', - layout: { highlight: true, kind: 'pattern', pattern: '' }, - strategy: { kind: 'numeric', max: 5, pattern: '%i' }, - policy: { kind: 'size-limit', size: ByteSizeValue.parse('15b') }, + type: 'rolling-file', + fileName: 'path', + layout: { highlight: true, type: 'pattern', pattern: '' }, + strategy: { type: 'numeric', max: 5, pattern: '%i' }, + policy: { type: 'size-limit', size: ByteSizeValue.parse('15b') }, }); expect(rollingFileAppender).toBeInstanceOf(RollingFileAppender); }); diff --git a/src/core/server/logging/appenders/appenders.ts b/src/core/server/logging/appenders/appenders.ts index 564def5251c132..a41a6a2f68fa1b 100644 --- a/src/core/server/logging/appenders/appenders.ts +++ b/src/core/server/logging/appenders/appenders.ts @@ -52,11 +52,11 @@ export class Appenders { * @returns Fully constructed `Appender` instance. */ public static create(config: AppenderConfigType): DisposableAppender { - switch (config.kind) { + switch (config.type) { case 'console': return new ConsoleAppender(Layouts.create(config.layout)); case 'file': - return new FileAppender(Layouts.create(config.layout), config.path); + return new FileAppender(Layouts.create(config.layout), config.fileName); case 'rolling-file': return new RollingFileAppender(config); case 'legacy-appender': diff --git a/src/core/server/logging/appenders/console/console_appender.test.ts b/src/core/server/logging/appenders/console/console_appender.test.ts index f5ad853775eea3..1e8f742c1ecda6 100644 --- a/src/core/server/logging/appenders/console/console_appender.test.ts +++ b/src/core/server/logging/appenders/console/console_appender.test.ts @@ -12,7 +12,7 @@ jest.mock('../../layouts/layouts', () => { return { Layouts: { configSchema: schema.object({ - kind: schema.literal('mock'), + type: schema.literal('mock'), }), }, }; @@ -23,16 +23,16 @@ import { ConsoleAppender } from './console_appender'; test('`configSchema` creates correct schema.', () => { const appenderSchema = ConsoleAppender.configSchema; - const validConfig = { kind: 'console', layout: { kind: 'mock' } }; + const validConfig = { type: 'console', layout: { type: 'mock' } }; expect(appenderSchema.validate(validConfig)).toEqual({ - kind: 'console', - layout: { kind: 'mock' }, + type: 'console', + layout: { type: 'mock' }, }); - const wrongConfig1 = { kind: 'not-console', layout: { kind: 'mock' } }; + const wrongConfig1 = { type: 'not-console', layout: { type: 'mock' } }; expect(() => appenderSchema.validate(wrongConfig1)).toThrow(); - const wrongConfig2 = { kind: 'file', layout: { kind: 'mock' }, path: 'path' }; + const wrongConfig2 = { type: 'file', layout: { type: 'mock' }, fileName: 'path' }; expect(() => appenderSchema.validate(wrongConfig2)).toThrow(); }); diff --git a/src/core/server/logging/appenders/console/console_appender.ts b/src/core/server/logging/appenders/console/console_appender.ts index 00d26d0836ee38..739068ff0a126c 100644 --- a/src/core/server/logging/appenders/console/console_appender.ts +++ b/src/core/server/logging/appenders/console/console_appender.ts @@ -13,7 +13,7 @@ import { Layouts, LayoutConfigType } from '../../layouts/layouts'; const { literal, object } = schema; export interface ConsoleAppenderConfig { - kind: 'console'; + type: 'console'; layout: LayoutConfigType; } @@ -24,7 +24,7 @@ export interface ConsoleAppenderConfig { */ export class ConsoleAppender implements DisposableAppender { public static configSchema = object({ - kind: literal('console'), + type: literal('console'), layout: Layouts.configSchema, }); diff --git a/src/core/server/logging/appenders/file/file_appender.test.mocks.ts b/src/core/server/logging/appenders/file/file_appender.test.mocks.ts index 0f87829dbbaf16..2c2a2015b6fd38 100644 --- a/src/core/server/logging/appenders/file/file_appender.test.mocks.ts +++ b/src/core/server/logging/appenders/file/file_appender.test.mocks.ts @@ -12,7 +12,7 @@ jest.mock('../../layouts/layouts', () => { return { Layouts: { configSchema: schema.object({ - kind: schema.literal('mock'), + type: schema.literal('mock'), }), }, }; diff --git a/src/core/server/logging/appenders/file/file_appender.test.ts b/src/core/server/logging/appenders/file/file_appender.test.ts index 5ef91b98e92f42..081cb16afd2ff3 100644 --- a/src/core/server/logging/appenders/file/file_appender.test.ts +++ b/src/core/server/logging/appenders/file/file_appender.test.ts @@ -20,24 +20,24 @@ beforeEach(() => { test('`createConfigSchema()` creates correct schema.', () => { const appenderSchema = FileAppender.configSchema; - const validConfig = { kind: 'file', layout: { kind: 'mock' }, path: 'path' }; + const validConfig = { type: 'file', layout: { type: 'mock' }, fileName: 'path' }; expect(appenderSchema.validate(validConfig)).toEqual({ - kind: 'file', - layout: { kind: 'mock' }, - path: 'path', + type: 'file', + layout: { type: 'mock' }, + fileName: 'path', }); const wrongConfig1 = { - kind: 'not-file', - layout: { kind: 'mock' }, - path: 'path', + type: 'not-file', + layout: { type: 'mock' }, + fileName: 'path', }; expect(() => appenderSchema.validate(wrongConfig1)).toThrow(); - const wrongConfig2 = { kind: 'file', layout: { kind: 'mock' } }; + const wrongConfig2 = { type: 'file', layout: { type: 'mock' } }; expect(() => appenderSchema.validate(wrongConfig2)).toThrow(); - const wrongConfig3 = { kind: 'console', layout: { kind: 'mock' } }; + const wrongConfig3 = { type: 'console', layout: { type: 'mock' } }; expect(() => appenderSchema.validate(wrongConfig3)).toThrow(); }); diff --git a/src/core/server/logging/appenders/file/file_appender.ts b/src/core/server/logging/appenders/file/file_appender.ts index 0f1cb71c76e9fb..be46c261dc9965 100644 --- a/src/core/server/logging/appenders/file/file_appender.ts +++ b/src/core/server/logging/appenders/file/file_appender.ts @@ -13,9 +13,9 @@ import { createWriteStream, WriteStream } from 'fs'; import { Layouts, LayoutConfigType } from '../../layouts/layouts'; export interface FileAppenderConfig { - kind: 'file'; + type: 'file'; layout: LayoutConfigType; - path: string; + fileName: string; } /** @@ -24,9 +24,9 @@ export interface FileAppenderConfig { */ export class FileAppender implements DisposableAppender { public static configSchema = schema.object({ - kind: schema.literal('file'), + type: schema.literal('file'), layout: Layouts.configSchema, - path: schema.string(), + fileName: schema.string(), }); /** diff --git a/src/core/server/logging/appenders/rolling_file/policies/index.ts b/src/core/server/logging/appenders/rolling_file/policies/index.ts index 20038d31eee8bf..e3e33c6cbfdef6 100644 --- a/src/core/server/logging/appenders/rolling_file/policies/index.ts +++ b/src/core/server/logging/appenders/rolling_file/policies/index.ts @@ -34,7 +34,7 @@ export type TriggeringPolicyConfig = | TimeIntervalTriggeringPolicyConfig; const defaultPolicy: TimeIntervalTriggeringPolicyConfig = { - kind: 'time-interval', + type: 'time-interval', interval: moment.duration(24, 'hour'), modulate: true, }; @@ -48,7 +48,7 @@ export const createTriggeringPolicy = ( config: TriggeringPolicyConfig, context: RollingFileContext ): TriggeringPolicy => { - switch (config.kind) { + switch (config.type) { case 'size-limit': return new SizeLimitTriggeringPolicy(config, context); case 'time-interval': diff --git a/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.test.ts b/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.test.ts index 3780bb69a8341a..ee9c96de8a940c 100644 --- a/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.test.ts +++ b/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.test.ts @@ -15,7 +15,7 @@ describe('SizeLimitTriggeringPolicy', () => { let context: RollingFileContext; const createPolicy = (size: ByteSizeValue) => - new SizeLimitTriggeringPolicy({ kind: 'size-limit', size }, context); + new SizeLimitTriggeringPolicy({ type: 'size-limit', size }, context); const createLogRecord = (parts: Partial = {}): LogRecord => ({ timestamp: new Date(), diff --git a/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.ts b/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.ts index 77f0a60b0e95c8..82fee352da8df7 100644 --- a/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.ts +++ b/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.ts @@ -12,7 +12,7 @@ import { RollingFileContext } from '../../rolling_file_context'; import { TriggeringPolicy } from '../policy'; export interface SizeLimitTriggeringPolicyConfig { - kind: 'size-limit'; + type: 'size-limit'; /** * The minimum size the file must have to roll over. @@ -21,7 +21,7 @@ export interface SizeLimitTriggeringPolicyConfig { } export const sizeLimitTriggeringPolicyConfigSchema = schema.object({ - kind: schema.literal('size-limit'), + type: schema.literal('size-limit'), size: schema.byteSize({ min: '1b', defaultValue: '100mb' }), }); diff --git a/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.test.ts b/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.test.ts index 25c5cef65c8851..03f457277b7926 100644 --- a/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.test.ts +++ b/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.test.ts @@ -42,7 +42,7 @@ describe('TimeIntervalTriggeringPolicy', () => { interval: string = '15m', modulate: boolean = false ): TimeIntervalTriggeringPolicyConfig => ({ - kind: 'time-interval', + type: 'time-interval', interval: schema.duration().validate(interval), modulate, }); diff --git a/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.ts b/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.ts index 892dd54672f146..7c4d18d929cb0d 100644 --- a/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.ts +++ b/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.ts @@ -15,7 +15,7 @@ import { getNextRollingTime } from './get_next_rolling_time'; import { isValidRolloverInterval } from './utils'; export interface TimeIntervalTriggeringPolicyConfig { - kind: 'time-interval'; + type: 'time-interval'; /** * How often a rollover should occur. @@ -38,7 +38,7 @@ export interface TimeIntervalTriggeringPolicyConfig { } export const timeIntervalTriggeringPolicyConfigSchema = schema.object({ - kind: schema.literal('time-interval'), + type: schema.literal('time-interval'), interval: schema.duration({ defaultValue: '24h', validate: (interval) => { diff --git a/src/core/server/logging/appenders/rolling_file/rolling_file_appender.test.ts b/src/core/server/logging/appenders/rolling_file/rolling_file_appender.test.ts index bc28e9137b2fd7..a95d995885d8b2 100644 --- a/src/core/server/logging/appenders/rolling_file/rolling_file_appender.test.ts +++ b/src/core/server/logging/appenders/rolling_file/rolling_file_appender.test.ts @@ -20,20 +20,20 @@ import { LogLevel, LogRecord } from '@kbn/logging'; import { RollingFileAppender, RollingFileAppenderConfig } from './rolling_file_appender'; const config: RollingFileAppenderConfig = { - kind: 'rolling-file', - path: '/var/log/kibana.log', + type: 'rolling-file', + fileName: '/var/log/kibana.log', layout: { - kind: 'pattern', + type: 'pattern', pattern: '%message', highlight: false, }, policy: { - kind: 'time-interval', + type: 'time-interval', interval: moment.duration(4, 'hour'), modulate: true, }, strategy: { - kind: 'numeric', + type: 'numeric', max: 5, pattern: '-%i', }, @@ -99,7 +99,7 @@ describe('RollingFileAppender', () => { it('constructs its delegates with the correct parameters', () => { expect(RollingFileContextMock).toHaveBeenCalledTimes(1); - expect(RollingFileContextMock).toHaveBeenCalledWith(config.path); + expect(RollingFileContextMock).toHaveBeenCalledWith(config.fileName); expect(RollingFileManagerMock).toHaveBeenCalledTimes(1); expect(RollingFileManagerMock).toHaveBeenCalledWith(context); diff --git a/src/core/server/logging/appenders/rolling_file/rolling_file_appender.ts b/src/core/server/logging/appenders/rolling_file/rolling_file_appender.ts index 748f47504f00ad..452d9493359544 100644 --- a/src/core/server/logging/appenders/rolling_file/rolling_file_appender.ts +++ b/src/core/server/logging/appenders/rolling_file/rolling_file_appender.ts @@ -26,7 +26,7 @@ import { RollingFileManager } from './rolling_file_manager'; import { RollingFileContext } from './rolling_file_context'; export interface RollingFileAppenderConfig { - kind: 'rolling-file'; + type: 'rolling-file'; /** * The layout to use when writing log entries */ @@ -34,7 +34,7 @@ export interface RollingFileAppenderConfig { /** * The absolute path of the file to write to. */ - path: string; + fileName: string; /** * The {@link TriggeringPolicy | policy} to use to determine if a rollover should occur. */ @@ -51,9 +51,9 @@ export interface RollingFileAppenderConfig { */ export class RollingFileAppender implements DisposableAppender { public static configSchema = schema.object({ - kind: schema.literal('rolling-file'), + type: schema.literal('rolling-file'), layout: Layouts.configSchema, - path: schema.string(), + fileName: schema.string(), policy: triggeringPolicyConfigSchema, strategy: rollingStrategyConfigSchema, }); @@ -70,7 +70,7 @@ export class RollingFileAppender implements DisposableAppender { private readonly buffer: BufferAppender; constructor(config: RollingFileAppenderConfig) { - this.context = new RollingFileContext(config.path); + this.context = new RollingFileContext(config.fileName); this.context.refreshFileInfo(); this.fileManager = new RollingFileManager(this.context); this.layout = Layouts.create(config.layout); diff --git a/src/core/server/logging/appenders/rolling_file/strategies/index.ts b/src/core/server/logging/appenders/rolling_file/strategies/index.ts index f63b68e4b92af9..c8364b0e590c67 100644 --- a/src/core/server/logging/appenders/rolling_file/strategies/index.ts +++ b/src/core/server/logging/appenders/rolling_file/strategies/index.ts @@ -19,7 +19,7 @@ export { RollingStrategy } from './strategy'; export type RollingStrategyConfig = NumericRollingStrategyConfig; const defaultStrategy: NumericRollingStrategyConfig = { - kind: 'numeric', + type: 'numeric', pattern: '-%i', max: 7, }; diff --git a/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.test.ts b/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.test.ts index d2e65f3880b87f..b4ca0131156a38 100644 --- a/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.test.ts +++ b/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.test.ts @@ -27,8 +27,8 @@ describe('NumericRollingStrategy', () => { let context: ReturnType; let strategy: NumericRollingStrategy; - const createStrategy = (config: Omit) => - new NumericRollingStrategy({ ...config, kind: 'numeric' }, context); + const createStrategy = (config: Omit) => + new NumericRollingStrategy({ ...config, type: 'numeric' }, context); beforeEach(() => { context = rollingFileAppenderMocks.createContext(logFilePath); diff --git a/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.ts b/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.ts index 5ee75bf6fda52a..13a19a40fa561d 100644 --- a/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.ts +++ b/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.ts @@ -19,10 +19,10 @@ import { } from './rolling_tasks'; export interface NumericRollingStrategyConfig { - kind: 'numeric'; + type: 'numeric'; /** * The suffix pattern to apply when renaming a file. The suffix will be applied - * after the `appender.path` file name, but before the file extension. + * after the `appender.fileName` file name, but before the file extension. * * Must include `%i`, as it is the value that will be converted to the file index * @@ -31,8 +31,8 @@ export interface NumericRollingStrategyConfig { * logging: * appenders: * rolling-file: - * kind: rolling-file - * path: /var/logs/kibana.log + * type: rolling-file + * fileName: /var/logs/kibana.log * strategy: * type: default * pattern: "-%i" @@ -52,7 +52,7 @@ export interface NumericRollingStrategyConfig { } export const numericRollingStrategyConfigSchema = schema.object({ - kind: schema.literal('numeric'), + type: schema.literal('numeric'), pattern: schema.string({ defaultValue: '-%i', validate: (pattern) => { @@ -73,8 +73,8 @@ export const numericRollingStrategyConfigSchema = schema.object({ * logging: * appenders: * rolling-file: - * kind: rolling-file - * path: /kibana.log + * type: rolling-file + * fileName: /kibana.log * strategy: * type: numeric * pattern: "-%i" diff --git a/src/core/server/logging/integration_tests/logging.test.ts b/src/core/server/logging/integration_tests/logging.test.ts index 0af6dbfc8611ec..b4eb98546de21b 100644 --- a/src/core/server/logging/integration_tests/logging.test.ts +++ b/src/core/server/logging/integration_tests/logging.test.ts @@ -17,22 +17,22 @@ function createRoot() { silent: true, // set "true" in kbnTestServer appenders: { 'test-console': { - kind: 'console', + type: 'console', layout: { highlight: false, - kind: 'pattern', + type: 'pattern', pattern: '%level|%logger|%message', }, }, }, loggers: [ { - context: 'parent', + name: 'parent', appenders: ['test-console'], level: 'warn', }, { - context: 'parent.child', + name: 'parent.child', appenders: ['test-console'], level: 'error', }, @@ -42,7 +42,7 @@ function createRoot() { } describe('logging service', () => { - describe('logs according to context hierarchy', () => { + describe('logs according to context name hierarchy', () => { let root: ReturnType; let mockConsoleLog: jest.SpyInstance; beforeAll(async () => { @@ -61,7 +61,7 @@ describe('logging service', () => { await root.shutdown(); }); - it('uses the most specific context', () => { + it('uses the most specific context name', () => { const logger = root.logger.get('parent.child'); logger.error('error from "parent.child" context'); @@ -74,7 +74,7 @@ describe('logging service', () => { ); }); - it('uses parent context', () => { + it('uses parent context name', () => { const logger = root.logger.get('parent.another-child'); logger.error('error from "parent.another-child" context'); @@ -104,31 +104,31 @@ describe('logging service', () => { }); }); - describe('custom context configuration', () => { + describe('custom context name configuration', () => { const CUSTOM_LOGGING_CONFIG: LoggerContextConfigInput = { appenders: { customJsonConsole: { - kind: 'console', + type: 'console', layout: { - kind: 'json', + type: 'json', }, }, customPatternConsole: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', pattern: 'CUSTOM - PATTERN [%logger][%level] %message', }, }, }, loggers: [ - { context: 'debug_json', appenders: ['customJsonConsole'], level: 'debug' }, - { context: 'debug_pattern', appenders: ['customPatternConsole'], level: 'debug' }, - { context: 'info_json', appenders: ['customJsonConsole'], level: 'info' }, - { context: 'info_pattern', appenders: ['customPatternConsole'], level: 'info' }, + { name: 'debug_json', appenders: ['customJsonConsole'], level: 'debug' }, + { name: 'debug_pattern', appenders: ['customPatternConsole'], level: 'debug' }, + { name: 'info_json', appenders: ['customJsonConsole'], level: 'info' }, + { name: 'info_pattern', appenders: ['customPatternConsole'], level: 'info' }, { - context: 'all', + name: 'all', appenders: ['customJsonConsole', 'customPatternConsole'], level: 'debug', }, diff --git a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts index fb2a714adb687a..b40ce7a4e7b0e3 100644 --- a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts +++ b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts @@ -25,7 +25,7 @@ function createRoot(appenderConfig: any) { }, loggers: [ { - context: 'test.rolling.file', + name: 'test.rolling.file', appenders: ['rolling-file'], level: 'debug', }, @@ -63,18 +63,18 @@ describe('RollingFileAppender', () => { describe('`size-limit` policy with `numeric` strategy', () => { it('rolls the log file in the correct order', async () => { root = createRoot({ - kind: 'rolling-file', - path: logFile, + type: 'rolling-file', + fileName: logFile, layout: { - kind: 'pattern', + type: 'pattern', pattern: '%message', }, policy: { - kind: 'size-limit', + type: 'size-limit', size: '100b', }, strategy: { - kind: 'numeric', + type: 'numeric', max: 5, pattern: '.%i', }, @@ -108,18 +108,18 @@ describe('RollingFileAppender', () => { it('only keep the correct number of files', async () => { root = createRoot({ - kind: 'rolling-file', - path: logFile, + type: 'rolling-file', + fileName: logFile, layout: { - kind: 'pattern', + type: 'pattern', pattern: '%message', }, policy: { - kind: 'size-limit', + type: 'size-limit', size: '60b', }, strategy: { - kind: 'numeric', + type: 'numeric', max: 2, pattern: '-%i', }, @@ -157,19 +157,19 @@ describe('RollingFileAppender', () => { describe('`time-interval` policy with `numeric` strategy', () => { it('rolls the log file at the given interval', async () => { root = createRoot({ - kind: 'rolling-file', - path: logFile, + type: 'rolling-file', + fileName: logFile, layout: { - kind: 'pattern', + type: 'pattern', pattern: '%message', }, policy: { - kind: 'time-interval', + type: 'time-interval', interval: '1s', modulate: true, }, strategy: { - kind: 'numeric', + type: 'numeric', max: 2, pattern: '-%i', }, diff --git a/src/core/server/logging/layouts/json_layout.test.ts b/src/core/server/logging/layouts/json_layout.test.ts index 2504ad476576fb..e55f69daab1100 100644 --- a/src/core/server/logging/layouts/json_layout.test.ts +++ b/src/core/server/logging/layouts/json_layout.test.ts @@ -63,7 +63,7 @@ const records: LogRecord[] = [ test('`createConfigSchema()` creates correct schema.', () => { const layoutSchema = JsonLayout.configSchema; - expect(layoutSchema.validate({ kind: 'json' })).toEqual({ kind: 'json' }); + expect(layoutSchema.validate({ type: 'json' })).toEqual({ type: 'json' }); }); test('`format()` correctly formats record.', () => { diff --git a/src/core/server/logging/layouts/json_layout.ts b/src/core/server/logging/layouts/json_layout.ts index 9e81303bedea07..bb8423f8240af9 100644 --- a/src/core/server/logging/layouts/json_layout.ts +++ b/src/core/server/logging/layouts/json_layout.ts @@ -14,12 +14,12 @@ import { LogRecord, Layout } from '@kbn/logging'; const { literal, object } = schema; const jsonLayoutSchema = object({ - kind: literal('json'), + type: literal('json'), }); /** @internal */ export interface JsonLayoutConfigType { - kind: 'json'; + type: 'json'; } /** diff --git a/src/core/server/logging/layouts/layouts.test.ts b/src/core/server/logging/layouts/layouts.test.ts index df91994564da17..3ff2fe23aae343 100644 --- a/src/core/server/logging/layouts/layouts.test.ts +++ b/src/core/server/logging/layouts/layouts.test.ts @@ -12,43 +12,43 @@ import { PatternLayout } from './pattern_layout'; test('`configSchema` creates correct schema for `pattern` layout.', () => { const layoutsSchema = Layouts.configSchema; - const validConfigWithOptional = { kind: 'pattern' }; + const validConfigWithOptional = { type: 'pattern' }; expect(layoutsSchema.validate(validConfigWithOptional)).toEqual({ highlight: undefined, - kind: 'pattern', + type: 'pattern', pattern: undefined, }); const validConfig = { highlight: true, - kind: 'pattern', + type: 'pattern', pattern: '%message', }; expect(layoutsSchema.validate(validConfig)).toEqual({ highlight: true, - kind: 'pattern', + type: 'pattern', pattern: '%message', }); - const wrongConfig2 = { kind: 'pattern', pattern: 1 }; + const wrongConfig2 = { type: 'pattern', pattern: 1 }; expect(() => layoutsSchema.validate(wrongConfig2)).toThrow(); }); test('`createConfigSchema()` creates correct schema for `json` layout.', () => { const layoutsSchema = Layouts.configSchema; - const validConfig = { kind: 'json' }; - expect(layoutsSchema.validate(validConfig)).toEqual({ kind: 'json' }); + const validConfig = { type: 'json' }; + expect(layoutsSchema.validate(validConfig)).toEqual({ type: 'json' }); }); test('`create()` creates correct layout.', () => { const patternLayout = Layouts.create({ highlight: false, - kind: 'pattern', + type: 'pattern', pattern: '[%date][%level][%logger] %message', }); expect(patternLayout).toBeInstanceOf(PatternLayout); - const jsonLayout = Layouts.create({ kind: 'json' }); + const jsonLayout = Layouts.create({ type: 'json' }); expect(jsonLayout).toBeInstanceOf(JsonLayout); }); diff --git a/src/core/server/logging/layouts/layouts.ts b/src/core/server/logging/layouts/layouts.ts index d6c14f3713b2c6..9abc8cd753f97b 100644 --- a/src/core/server/logging/layouts/layouts.ts +++ b/src/core/server/logging/layouts/layouts.ts @@ -27,7 +27,7 @@ export class Layouts { * @returns Fully constructed `Layout` instance. */ public static create(config: LayoutConfigType): Layout { - switch (config.kind) { + switch (config.type) { case 'json': return new JsonLayout(); diff --git a/src/core/server/logging/layouts/pattern_layout.test.ts b/src/core/server/logging/layouts/pattern_layout.test.ts index 7dd3c7c51f833c..abdc2f4fb929cb 100644 --- a/src/core/server/logging/layouts/pattern_layout.test.ts +++ b/src/core/server/logging/layouts/pattern_layout.test.ts @@ -66,28 +66,28 @@ expect.addSnapshotSerializer(stripAnsiSnapshotSerializer); test('`createConfigSchema()` creates correct schema.', () => { const layoutSchema = PatternLayout.configSchema; - const validConfigWithOptional = { kind: 'pattern' }; + const validConfigWithOptional = { type: 'pattern' }; expect(layoutSchema.validate(validConfigWithOptional)).toEqual({ highlight: undefined, - kind: 'pattern', + type: 'pattern', pattern: undefined, }); const validConfig = { highlight: true, - kind: 'pattern', + type: 'pattern', pattern: '%message', }; expect(layoutSchema.validate(validConfig)).toEqual({ highlight: true, - kind: 'pattern', + type: 'pattern', pattern: '%message', }); - const wrongConfig1 = { kind: 'json' }; + const wrongConfig1 = { type: 'json' }; expect(() => layoutSchema.validate(wrongConfig1)).toThrow(); - const wrongConfig2 = { kind: 'pattern', pattern: 1 }; + const wrongConfig2 = { type: 'pattern', pattern: 1 }; expect(() => layoutSchema.validate(wrongConfig2)).toThrow(); }); diff --git a/src/core/server/logging/layouts/pattern_layout.ts b/src/core/server/logging/layouts/pattern_layout.ts index a5e9c0be8409bd..a5dc41786c4400 100644 --- a/src/core/server/logging/layouts/pattern_layout.ts +++ b/src/core/server/logging/layouts/pattern_layout.ts @@ -32,7 +32,7 @@ export const patternSchema = schema.string({ const patternLayoutSchema = schema.object({ highlight: schema.maybe(schema.boolean()), - kind: schema.literal('pattern'), + type: schema.literal('pattern'), pattern: schema.maybe(patternSchema), }); @@ -47,7 +47,7 @@ const conversions: Conversion[] = [ /** @internal */ export interface PatternLayoutConfigType { - kind: 'pattern'; + type: 'pattern'; highlight?: boolean; pattern?: string; } diff --git a/src/core/server/logging/logging_config.test.ts b/src/core/server/logging/logging_config.test.ts index e494ae2413229a..2cb5831a8fb4ce 100644 --- a/src/core/server/logging/logging_config.test.ts +++ b/src/core/server/logging/logging_config.test.ts @@ -51,12 +51,12 @@ test('correctly fills in default config.', () => { expect(configValue.appenders.size).toBe(2); expect(configValue.appenders.get('default')).toEqual({ - kind: 'console', - layout: { kind: 'pattern', highlight: true }, + type: 'console', + layout: { type: 'pattern', highlight: true }, }); expect(configValue.appenders.get('console')).toEqual({ - kind: 'console', - layout: { kind: 'pattern', highlight: true }, + type: 'console', + layout: { type: 'pattern', highlight: true }, }); }); @@ -65,8 +65,8 @@ test('correctly fills in custom `appenders` config.', () => { config.schema.validate({ appenders: { console: { - kind: 'console', - layout: { kind: 'pattern' }, + type: 'console', + layout: { type: 'pattern' }, }, }, }) @@ -75,13 +75,13 @@ test('correctly fills in custom `appenders` config.', () => { expect(configValue.appenders.size).toBe(2); expect(configValue.appenders.get('default')).toEqual({ - kind: 'console', - layout: { kind: 'pattern', highlight: true }, + type: 'console', + layout: { type: 'pattern', highlight: true }, }); expect(configValue.appenders.get('console')).toEqual({ - kind: 'console', - layout: { kind: 'pattern' }, + type: 'console', + layout: { type: 'pattern' }, }); }); @@ -91,7 +91,7 @@ test('correctly fills in default `loggers` config.', () => { expect(configValue.loggers.size).toBe(1); expect(configValue.loggers.get('root')).toEqual({ appenders: ['default'], - context: 'root', + name: 'root', level: 'info', }); }); @@ -101,24 +101,24 @@ test('correctly fills in custom `loggers` config.', () => { config.schema.validate({ appenders: { file: { - kind: 'file', - layout: { kind: 'pattern' }, - path: 'path', + type: 'file', + layout: { type: 'pattern' }, + fileName: 'path', }, }, loggers: [ { appenders: ['file'], - context: 'plugins', + name: 'plugins', level: 'warn', }, { - context: 'plugins.pid', + name: 'plugins.pid', level: 'trace', }, { appenders: ['default'], - context: 'http', + name: 'http', level: 'error', }, ], @@ -128,22 +128,22 @@ test('correctly fills in custom `loggers` config.', () => { expect(configValue.loggers.size).toBe(4); expect(configValue.loggers.get('root')).toEqual({ appenders: ['default'], - context: 'root', + name: 'root', level: 'info', }); expect(configValue.loggers.get('plugins')).toEqual({ appenders: ['file'], - context: 'plugins', + name: 'plugins', level: 'warn', }); expect(configValue.loggers.get('plugins.pid')).toEqual({ appenders: ['file'], - context: 'plugins.pid', + name: 'plugins.pid', level: 'trace', }); expect(configValue.loggers.get('http')).toEqual({ appenders: ['default'], - context: 'http', + name: 'http', level: 'error', }); }); @@ -153,7 +153,7 @@ test('fails if loggers use unknown appenders.', () => { loggers: [ { appenders: ['unknown'], - context: 'some.nested.context', + name: 'some.nested.context', }, ], }); @@ -167,9 +167,9 @@ describe('extend', () => { config.schema.validate({ appenders: { file1: { - kind: 'file', - layout: { kind: 'pattern' }, - path: 'path', + type: 'file', + layout: { type: 'pattern' }, + fileName: 'path', }, }, }) @@ -179,9 +179,9 @@ describe('extend', () => { config.schema.validate({ appenders: { file2: { - kind: 'file', - layout: { kind: 'pattern' }, - path: 'path', + type: 'file', + layout: { type: 'pattern' }, + fileName: 'path', }, }, }) @@ -200,9 +200,9 @@ describe('extend', () => { config.schema.validate({ appenders: { file1: { - kind: 'file', - layout: { kind: 'pattern' }, - path: 'path', + type: 'file', + layout: { type: 'pattern' }, + fileName: 'path', }, }, }) @@ -212,18 +212,18 @@ describe('extend', () => { config.schema.validate({ appenders: { file1: { - kind: 'file', - layout: { kind: 'json' }, - path: 'updatedPath', + type: 'file', + layout: { type: 'json' }, + fileName: 'updatedPath', }, }, }) ); expect(mergedConfigValue.appenders.get('file1')).toEqual({ - kind: 'file', - layout: { kind: 'json' }, - path: 'updatedPath', + type: 'file', + layout: { type: 'json' }, + fileName: 'updatedPath', }); }); @@ -232,7 +232,7 @@ describe('extend', () => { config.schema.validate({ loggers: [ { - context: 'plugins', + name: 'plugins', level: 'warn', }, ], @@ -243,7 +243,7 @@ describe('extend', () => { config.schema.validate({ loggers: [ { - context: 'plugins.pid', + name: 'plugins.pid', level: 'trace', }, ], @@ -258,7 +258,7 @@ describe('extend', () => { config.schema.validate({ loggers: [ { - context: 'plugins', + name: 'plugins', level: 'warn', }, ], @@ -270,7 +270,7 @@ describe('extend', () => { loggers: [ { appenders: ['console'], - context: 'plugins', + name: 'plugins', level: 'trace', }, ], @@ -279,7 +279,7 @@ describe('extend', () => { expect(mergedConfigValue.loggers.get('plugins')).toEqual({ appenders: ['console'], - context: 'plugins', + name: 'plugins', level: 'trace', }); }); diff --git a/src/core/server/logging/logging_config.ts b/src/core/server/logging/logging_config.ts index 5b79b4e8e15d5b..24496289fb4c84 100644 --- a/src/core/server/logging/logging_config.ts +++ b/src/core/server/logging/logging_config.ts @@ -51,7 +51,7 @@ const levelSchema = schema.oneOf( */ export const loggerSchema = schema.object({ appenders: schema.arrayOf(schema.string(), { defaultValue: [] }), - context: schema.string(), + name: schema.string(), level: levelSchema, }); @@ -148,15 +148,15 @@ export class LoggingConfig { [ 'default', { - kind: 'console', - layout: { kind: 'pattern', highlight: true }, + type: 'console', + layout: { type: 'pattern', highlight: true }, } as AppenderConfigType, ], [ 'console', { - kind: 'console', - layout: { kind: 'pattern', highlight: true }, + type: 'console', + layout: { type: 'pattern', highlight: true }, } as AppenderConfigType, ], ]); @@ -182,8 +182,8 @@ export class LoggingConfig { public extend(contextConfig: LoggerContextConfigType) { // Use a Map to de-dupe any loggers for the same context. contextConfig overrides existing config. const mergedLoggers = new Map([ - ...this.configType.loggers.map((l) => [l.context, l] as [string, LoggerConfigType]), - ...contextConfig.loggers.map((l) => [l.context, l] as [string, LoggerConfigType]), + ...this.configType.loggers.map((l) => [l.name, l] as [string, LoggerConfigType]), + ...contextConfig.loggers.map((l) => [l.name, l] as [string, LoggerConfigType]), ]); const mergedConfig: LoggingConfigType = { @@ -204,13 +204,10 @@ export class LoggingConfig { private fillLoggersConfig(loggingConfig: LoggingConfigType) { // Include `root` logger into common logger list so that it can easily be a part // of the logger hierarchy and put all the loggers in map for easier retrieval. - const loggers = [ - { context: ROOT_CONTEXT_NAME, ...loggingConfig.root }, - ...loggingConfig.loggers, - ]; + const loggers = [{ name: ROOT_CONTEXT_NAME, ...loggingConfig.root }, ...loggingConfig.loggers]; const loggerConfigByContext = new Map( - loggers.map((loggerConfig) => toTuple(loggerConfig.context, loggerConfig)) + loggers.map((loggerConfig) => toTuple(loggerConfig.name, loggerConfig)) ); for (const [loggerContext, loggerConfig] of loggerConfigByContext) { @@ -247,7 +244,7 @@ function getAppenders( loggerConfig: LoggerConfigType, loggerConfigByContext: Map ) { - let currentContext = loggerConfig.context; + let currentContext = loggerConfig.name; let appenders = loggerConfig.appenders; while (appenders.length === 0) { diff --git a/src/core/server/logging/logging_service.test.ts b/src/core/server/logging/logging_service.test.ts index 66f1c67f114024..341a04736b87a4 100644 --- a/src/core/server/logging/logging_service.test.ts +++ b/src/core/server/logging/logging_service.test.ts @@ -30,11 +30,11 @@ describe('LoggingService', () => { it('forwards configuration changes to logging system', () => { const config1: LoggerContextConfigType = { appenders: new Map(), - loggers: [{ context: 'subcontext', appenders: ['console'], level: 'warn' }], + loggers: [{ name: 'subcontext', appenders: ['console'], level: 'warn' }], }; const config2: LoggerContextConfigType = { appenders: new Map(), - loggers: [{ context: 'subcontext', appenders: ['default'], level: 'all' }], + loggers: [{ name: 'subcontext', appenders: ['default'], level: 'all' }], }; setup.configure(['test', 'context'], of(config1, config2)); @@ -54,11 +54,11 @@ describe('LoggingService', () => { const updates$ = new Subject(); const config1: LoggerContextConfigType = { appenders: new Map(), - loggers: [{ context: 'subcontext', appenders: ['console'], level: 'warn' }], + loggers: [{ name: 'subcontext', appenders: ['console'], level: 'warn' }], }; const config2: LoggerContextConfigType = { appenders: new Map(), - loggers: [{ context: 'subcontext', appenders: ['default'], level: 'all' }], + loggers: [{ name: 'subcontext', appenders: ['default'], level: 'all' }], }; setup.configure(['test', 'context'], updates$); @@ -78,7 +78,7 @@ describe('LoggingService', () => { const updates$ = new Subject(); const config1: LoggerContextConfigType = { appenders: new Map(), - loggers: [{ context: 'subcontext', appenders: ['console'], level: 'warn' }], + loggers: [{ name: 'subcontext', appenders: ['console'], level: 'warn' }], }; setup.configure(['test', 'context'], updates$); diff --git a/src/core/server/logging/logging_service.ts b/src/core/server/logging/logging_service.ts index f76533dadd5c84..f5a4717fdbfaf4 100644 --- a/src/core/server/logging/logging_service.ts +++ b/src/core/server/logging/logging_service.ts @@ -31,7 +31,7 @@ export interface LoggingServiceSetup { * core.logging.configure( * of({ * appenders: new Map(), - * loggers: [{ context: 'search', appenders: ['default'] }] + * loggers: [{ name: 'search', appenders: ['default'] }] * }) * ) * ``` diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index 4d4ed191e60f8e..f68d6c6a97fbc4 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -46,7 +46,7 @@ test('uses default memory buffer logger until config is provided', () => { const logger = system.get('test', 'context'); logger.trace('trace message'); - // We shouldn't create new buffer appender for another context. + // We shouldn't create new buffer appender for another context name. const anotherLogger = system.get('test', 'context2'); anotherLogger.fatal('fatal message', { some: 'value' }); @@ -69,7 +69,7 @@ test('flushes memory buffer logger and switches to real logger once config is pr // Switch to console appender with `info` level, so that `trace` message won't go through. await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -102,12 +102,12 @@ test('appends records via multiple appenders.', async () => { await system.upgrade( config.schema.validate({ appenders: { - default: { kind: 'console', layout: { kind: 'pattern' } }, - file: { kind: 'file', layout: { kind: 'pattern' }, path: 'path' }, + default: { type: 'console', layout: { type: 'pattern' } }, + file: { type: 'file', layout: { type: 'pattern' }, fileName: 'path' }, }, loggers: [ - { appenders: ['file'], context: 'tests', level: 'warn' }, - { context: 'tests.child', level: 'error' }, + { appenders: ['file'], name: 'tests', level: 'warn' }, + { name: 'tests.child', level: 'error' }, ], }) ); @@ -121,10 +121,10 @@ test('appends records via multiple appenders.', async () => { expect(mockStreamWrite.mock.calls[1][0]).toMatchSnapshot('file logs'); }); -test('uses `root` logger if context is not specified.', async () => { +test('uses `root` logger if context name is not specified.', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'pattern' } } }, + appenders: { default: { type: 'console', layout: { type: 'pattern' } } }, }) ); @@ -137,7 +137,7 @@ test('uses `root` logger if context is not specified.', async () => { test('`stop()` disposes all appenders.', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -156,7 +156,7 @@ test('asLoggerFactory() only allows to create new loggers.', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'all' }, }) ); @@ -180,7 +180,7 @@ test('setContextConfig() updates config with relative contexts', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -189,10 +189,10 @@ test('setContextConfig() updates config with relative contexts', async () => { appenders: new Map([ [ 'custom', - { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + { type: 'console', layout: { type: 'pattern', pattern: '[%level][%logger] %message' } }, ], ]), - loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + loggers: [{ name: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], }); testsLogger.warn('tests log to default!'); @@ -235,7 +235,7 @@ test('setContextConfig() updates config for a root context', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -244,10 +244,10 @@ test('setContextConfig() updates config for a root context', async () => { appenders: new Map([ [ 'custom', - { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + { type: 'console', layout: { type: 'pattern', pattern: '[%level][%logger] %message' } }, ], ]), - loggers: [{ context: '', appenders: ['custom'], level: 'debug' }], + loggers: [{ name: '', appenders: ['custom'], level: 'debug' }], }); testsLogger.warn('tests log to default!'); @@ -273,21 +273,21 @@ test('setContextConfig() updates config for a root context', async () => { ); }); -test('custom context configs are applied on subsequent calls to update()', async () => { +test('custom context name configs are applied on subsequent calls to update()', async () => { await system.setContextConfig(['tests', 'child'], { appenders: new Map([ [ 'custom', - { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + { type: 'console', layout: { type: 'pattern', pattern: '[%level][%logger] %message' } }, ], ]), - loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + loggers: [{ name: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], }); // Calling upgrade after setContextConfig should not throw away the context-specific config await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -310,10 +310,10 @@ test('custom context configs are applied on subsequent calls to update()', async ); }); -test('subsequent calls to setContextConfig() for the same context override the previous config', async () => { +test('subsequent calls to setContextConfig() for the same context name override the previous config', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -322,10 +322,10 @@ test('subsequent calls to setContextConfig() for the same context override the p appenders: new Map([ [ 'custom', - { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + { type: 'console', layout: { type: 'pattern', pattern: '[%level][%logger] %message' } }, ], ]), - loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + loggers: [{ name: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], }); // Call again, this time with level: 'warn' and a different pattern @@ -334,12 +334,12 @@ test('subsequent calls to setContextConfig() for the same context override the p [ 'custom', { - kind: 'console', - layout: { kind: 'pattern', pattern: '[%level][%logger] second pattern! %message' }, + type: 'console', + layout: { type: 'pattern', pattern: '[%level][%logger] second pattern! %message' }, }, ], ]), - loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'warn' }], + loggers: [{ name: 'grandchild', appenders: ['default', 'custom'], level: 'warn' }], }); const logger = system.get('tests', 'child', 'grandchild'); @@ -360,10 +360,10 @@ test('subsequent calls to setContextConfig() for the same context override the p ); }); -test('subsequent calls to setContextConfig() for the same context can disable the previous config', async () => { +test('subsequent calls to setContextConfig() for the same context name can disable the previous config', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -372,10 +372,10 @@ test('subsequent calls to setContextConfig() for the same context can disable th appenders: new Map([ [ 'custom', - { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + { type: 'console', layout: { type: 'pattern', pattern: '[%level][%logger] %message' } }, ], ]), - loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + loggers: [{ name: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], }); // Call again, this time no customizations (effectively disabling) diff --git a/src/core/server/logging/logging_system.ts b/src/core/server/logging/logging_system.ts index 9c22cea23720b7..9ae434aff41d3c 100644 --- a/src/core/server/logging/logging_system.ts +++ b/src/core/server/logging/logging_system.ts @@ -79,7 +79,7 @@ export class LoggingSystem implements LoggerFactory { * loggingSystem.setContextConfig( * ['plugins', 'data'], * { - * loggers: [{ context: 'search', appenders: ['default'] }] + * loggers: [{ name: 'search', appenders: ['default'] }] * } * ) * ``` @@ -95,9 +95,7 @@ export class LoggingSystem implements LoggerFactory { // Automatically prepend the base context to the logger sub-contexts loggers: contextConfig.loggers.map((l) => ({ ...l, - context: LoggingConfig.getLoggerContext( - l.context.length > 0 ? [context, l.context] : [context] - ), + name: LoggingConfig.getLoggerContext(l.name.length > 0 ? [context, l.name] : [context]), })), }); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index 317bfe33b3a199..95a867934307a4 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -45,16 +45,16 @@ describe('migration v2', () => { logging: { appenders: { file: { - kind: 'file', - path: join(__dirname, 'migration_test_kibana.log'), + type: 'file', + fileName: join(__dirname, 'migration_test_kibana.log'), layout: { - kind: 'json', + type: 'json', }, }, }, loggers: [ { - context: 'root', + name: 'root', appenders: ['file'], }, ], diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts index 16ba0c855867ce..c26d4593bede19 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts @@ -47,16 +47,16 @@ describe.skip('migration from 7.7.2-xpack with 100k objects', () => { logging: { appenders: { file: { - kind: 'file', - path: join(__dirname, 'migration_test_kibana.log'), + type: 'file', + fileName: join(__dirname, 'migration_test_kibana.log'), layout: { - kind: 'json', + type: 'json', }, }, }, loggers: [ { - context: 'root', + name: 'root', appenders: ['file'], }, ], diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts index 3b07d766d7cb46..f59fd6ecdec919 100644 --- a/x-pack/plugins/security/server/audit/audit_service.test.ts +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -76,9 +76,9 @@ describe('#setup', () => { config: { enabled: true, appender: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', }, }, }, @@ -102,9 +102,9 @@ describe('#setup', () => { config: { enabled: true, appender: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', }, }, }, @@ -251,9 +251,9 @@ describe('#createLoggingConfig', () => { createLoggingConfig({ enabled: true, appender: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', }, }, }) @@ -264,10 +264,10 @@ describe('#createLoggingConfig', () => { Object { "appenders": Object { "auditTrailAppender": Object { - "kind": "console", "layout": Object { - "kind": "pattern", + "type": "pattern", }, + "type": "console", }, }, "loggers": Array [ @@ -275,8 +275,8 @@ describe('#createLoggingConfig', () => { "appenders": Array [ "auditTrailAppender", ], - "context": "audit.ecs", "level": "info", + "name": "audit.ecs", }, ], } @@ -293,9 +293,9 @@ describe('#createLoggingConfig', () => { createLoggingConfig({ enabled: false, appender: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', }, }, }) @@ -331,9 +331,9 @@ describe('#createLoggingConfig', () => { createLoggingConfig({ enabled: true, appender: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', }, }, }) diff --git a/x-pack/plugins/security/server/audit/audit_service.ts b/x-pack/plugins/security/server/audit/audit_service.ts index 42e36e50d6d42d..99dd2c82ec9fe5 100644 --- a/x-pack/plugins/security/server/audit/audit_service.ts +++ b/x-pack/plugins/security/server/audit/audit_service.ts @@ -224,16 +224,16 @@ export const createLoggingConfig = (config: ConfigType['audit']) => map, LoggerContextConfigInput>((features) => ({ appenders: { auditTrailAppender: config.appender ?? { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', highlight: true, }, }, }, loggers: [ { - context: 'audit.ecs', + name: 'audit.ecs', level: config.enabled && config.appender && features.allowAuditLogging ? 'info' : 'off', appenders: ['auditTrailAppender'], }, diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index d4dcca8bebb0c1..53e4152b3c8fbf 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -1558,21 +1558,21 @@ describe('createConfig()', () => { ConfigSchema.validate({ audit: { appender: { - kind: 'file', - path: '/path/to/file.txt', + type: 'file', + fileName: '/path/to/file.txt', layout: { - kind: 'json', + type: 'json', }, }, }, }).audit.appender ).toMatchInlineSnapshot(` Object { - "kind": "file", + "fileName": "/path/to/file.txt", "layout": Object { - "kind": "json", + "type": "json", }, - "path": "/path/to/file.txt", + "type": "file", } `); }); @@ -1583,12 +1583,12 @@ describe('createConfig()', () => { audit: { // no layout configured appender: { - kind: 'file', + type: 'file', path: '/path/to/file.txt', }, }, }) - ).toThrow('[audit.appender.2.kind]: expected value to equal [legacy-appender]'); + ).toThrow('[audit.appender.2.type]: expected value to equal [legacy-appender]'); }); it('rejects an ignore_filter when no appender is configured', () => { diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts index bdb02d8ed99750..c4c7f399e7b5d3 100644 --- a/x-pack/plugins/security/server/config_deprecations.test.ts +++ b/x-pack/plugins/security/server/config_deprecations.test.ts @@ -52,6 +52,117 @@ describe('Config Deprecations', () => { `); }); + it('renames audit.appender.kind to audit.appender.type', () => { + const config = { + xpack: { + security: { + audit: { + appender: { + kind: 'console', + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.audit.appender.kind).not.toBeDefined(); + expect(migrated.xpack.security.audit.appender.type).toEqual('console'); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.audit.appender.kind\\" is deprecated and has been replaced by \\"xpack.security.audit.appender.type\\"", + ] + `); + }); + + it('renames audit.appender.layout.kind to audit.appender.layout.type', () => { + const config = { + xpack: { + security: { + audit: { + appender: { + layout: { kind: 'pattern' }, + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.audit.appender.layout.kind).not.toBeDefined(); + expect(migrated.xpack.security.audit.appender.layout.type).toEqual('pattern'); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.audit.appender.layout.kind\\" is deprecated and has been replaced by \\"xpack.security.audit.appender.layout.type\\"", + ] + `); + }); + + it('renames audit.appender.policy.kind to audit.appender.policy.type', () => { + const config = { + xpack: { + security: { + audit: { + appender: { + policy: { kind: 'time-interval' }, + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.audit.appender.policy.kind).not.toBeDefined(); + expect(migrated.xpack.security.audit.appender.policy.type).toEqual('time-interval'); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.audit.appender.policy.kind\\" is deprecated and has been replaced by \\"xpack.security.audit.appender.policy.type\\"", + ] + `); + }); + + it('renames audit.appender.strategy.kind to audit.appender.strategy.type', () => { + const config = { + xpack: { + security: { + audit: { + appender: { + strategy: { kind: 'numeric' }, + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.audit.appender.strategy.kind).not.toBeDefined(); + expect(migrated.xpack.security.audit.appender.strategy.type).toEqual('numeric'); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.audit.appender.strategy.kind\\" is deprecated and has been replaced by \\"xpack.security.audit.appender.strategy.type\\"", + ] + `); + }); + + it('renames audit.appender.path to audit.appender.fileName', () => { + const config = { + xpack: { + security: { + audit: { + appender: { + type: 'file', + path: './audit.log', + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.audit.appender.path).not.toBeDefined(); + expect(migrated.xpack.security.audit.appender.fileName).toEqual('./audit.log'); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.audit.appender.path\\" is deprecated and has been replaced by \\"xpack.security.audit.appender.fileName\\"", + ] + `); + }); + it(`warns that 'authorization.legacyFallback.enabled' is unused`, () => { const config = { xpack: { diff --git a/x-pack/plugins/security/server/config_deprecations.ts b/x-pack/plugins/security/server/config_deprecations.ts index 65d18f0a4e7eb3..a7bb5e09fb919d 100644 --- a/x-pack/plugins/security/server/config_deprecations.ts +++ b/x-pack/plugins/security/server/config_deprecations.ts @@ -12,6 +12,13 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ unused, }) => [ rename('sessionTimeout', 'session.idleTimeout'), + + rename('audit.appender.kind', 'audit.appender.type'), + rename('audit.appender.layout.kind', 'audit.appender.layout.type'), + rename('audit.appender.policy.kind', 'audit.appender.policy.type'), + rename('audit.appender.strategy.kind', 'audit.appender.strategy.type'), + rename('audit.appender.path', 'audit.appender.fileName'), + unused('authorization.legacyFallback.enabled'), unused('authc.saml.maxRedirectURLSize'), // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. diff --git a/x-pack/test/security_api_integration/audit.config.ts b/x-pack/test/security_api_integration/audit.config.ts index adf36bdc99b5a9..60b1c0bf1fa808 100644 --- a/x-pack/test/security_api_integration/audit.config.ts +++ b/x-pack/test/security_api_integration/audit.config.ts @@ -29,9 +29,9 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), `--plugin-path=${auditLogPlugin}`, '--xpack.security.audit.enabled=true', - '--xpack.security.audit.appender.kind=file', - `--xpack.security.audit.appender.path=${auditLogPath}`, - '--xpack.security.audit.appender.layout.kind=json', + '--xpack.security.audit.appender.type=file', + `--xpack.security.audit.appender.fileName=${auditLogPath}`, + '--xpack.security.audit.appender.layout.type=json', ], }, }; From e81b5c1e400c06537adb780e36f666ea40d2ef1f Mon Sep 17 00:00:00 2001 From: Bohdan Tsymbala Date: Tue, 16 Feb 2021 20:31:36 +0100 Subject: [PATCH 094/175] [Security Solution][Artifacts] implemented policy specific trusted apps support in the manifest manager (#90991) * Implemented policy specific trusted apps support in the manifest manager. --- .../fleet/common/types/rest_spec/common.ts | 7 + x-pack/plugins/fleet/server/mocks.ts | 1 + .../routes/package_policy/handlers.test.ts | 1 + .../fleet/server/services/package_policy.ts | 27 +- x-pack/plugins/lists/common/shared_exports.ts | 2 +- .../server/saved_objects/migrations.test.ts | 249 +++-- .../lists/server/saved_objects/migrations.ts | 24 + .../common/shared_imports.ts | 1 + .../endpoint/lib/artifacts/lists.test.ts | 918 ++++++++++-------- .../server/endpoint/lib/artifacts/lists.ts | 40 +- .../routes/trusted_apps/mapping.test.ts | 4 +- .../endpoint/routes/trusted_apps/mapping.ts | 4 +- .../endpoint/routes/trusted_apps/service.ts | 2 +- .../schemas/artifacts/saved_objects.mock.ts | 6 +- .../manifest_manager/manifest_manager.mock.ts | 21 +- .../manifest_manager/manifest_manager.test.ts | 139 ++- .../manifest_manager/manifest_manager.ts | 273 +++--- 17 files changed, 1084 insertions(+), 635 deletions(-) diff --git a/x-pack/plugins/fleet/common/types/rest_spec/common.ts b/x-pack/plugins/fleet/common/types/rest_spec/common.ts index d03129efd8fad0..de5e87d2e59a5d 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/common.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/common.ts @@ -14,3 +14,10 @@ export interface ListWithKuery extends HttpFetchQuery { sortOrder?: 'desc' | 'asc'; kuery?: string; } + +export interface ListResult { + items: T[]; + total: number; + page: number; + perPage: number; +} diff --git a/x-pack/plugins/fleet/server/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index c650995c809cbb..430e38bd1bc3ef 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -53,6 +53,7 @@ export const createPackagePolicyServiceMock = () => { get: jest.fn(), getByIDs: jest.fn(), list: jest.fn(), + listIds: jest.fn(), update: jest.fn(), runExternalCallbacks: jest.fn(), } as jest.Mocked; diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index 2b44975cc3b4de..813279f2a800fc 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -47,6 +47,7 @@ jest.mock('../../services/package_policy', (): { get: jest.fn(), getByIDs: jest.fn(), list: jest.fn(), + listIds: jest.fn(), update: jest.fn(), runExternalCallbacks: jest.fn((callbackType, newPackagePolicy, context, request) => Promise.resolve(newPackagePolicy) diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index a882ceb0037f24..335cd7c956faf9 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -20,6 +20,7 @@ import { PackagePolicyInputStream, PackageInfo, ListWithKuery, + ListResult, packageToPackagePolicy, isPackageLimited, doesAgentPolicyAlreadyIncludePackage, @@ -248,7 +249,7 @@ class PackagePolicyService { public async list( soClient: SavedObjectsClientContract, options: ListWithKuery - ): Promise<{ items: PackagePolicy[]; total: number; page: number; perPage: number }> { + ): Promise> { const { page = 1, perPage = 20, sortField = 'updated_at', sortOrder = 'desc', kuery } = options; const packagePolicies = await soClient.find({ @@ -272,6 +273,30 @@ class PackagePolicyService { }; } + public async listIds( + soClient: SavedObjectsClientContract, + options: ListWithKuery + ): Promise> { + const { page = 1, perPage = 20, sortField = 'updated_at', sortOrder = 'desc', kuery } = options; + + const packagePolicies = await soClient.find<{}>({ + type: SAVED_OBJECT_TYPE, + sortField, + sortOrder, + page, + perPage, + fields: [], + filter: kuery ? normalizeKuery(SAVED_OBJECT_TYPE, kuery) : undefined, + }); + + return { + items: packagePolicies.saved_objects.map((packagePolicySO) => packagePolicySO.id), + total: packagePolicies.total, + page, + perPage, + }; + } + public async update( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts index ba5891092fe124..6dcda5d1f8c24d 100644 --- a/x-pack/plugins/lists/common/shared_exports.ts +++ b/x-pack/plugins/lists/common/shared_exports.ts @@ -47,4 +47,4 @@ export { OsTypeArray, } from './schemas'; -export { ENDPOINT_LIST_ID } from './constants'; +export { ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID } from './constants'; diff --git a/x-pack/plugins/lists/server/saved_objects/migrations.test.ts b/x-pack/plugins/lists/server/saved_objects/migrations.test.ts index 143443b9320923..f71109b9bb85dc 100644 --- a/x-pack/plugins/lists/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/lists/server/saved_objects/migrations.test.ts @@ -6,61 +6,102 @@ */ import { SavedObjectUnsanitizedDoc } from 'kibana/server'; +import uuid from 'uuid'; -import { ENDPOINT_LIST_ID } from '../../common/constants'; +import { ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../common/constants'; +import { ExceptionListSoSchema } from '../../common/schemas/saved_objects'; import { OldExceptionListSoSchema, migrations } from './migrations'; +const DEFAULT_EXCEPTION_LIST_SO: ExceptionListSoSchema = { + comments: undefined, + created_at: '2020-06-09T20:18:20.349Z', + created_by: 'user', + description: 'description', + entries: undefined, + immutable: false, + item_id: undefined, + list_id: 'some_list', + list_type: 'list', + meta: undefined, + name: 'name', + os_types: [], + tags: [], + tie_breaker_id: uuid.v4(), + type: 'endpoint', + updated_by: 'user', + version: undefined, +}; + +const DEFAULT_OLD_EXCEPTION_LIST_SO: OldExceptionListSoSchema = { + ...DEFAULT_EXCEPTION_LIST_SO, + _tags: [], +}; + +const createOldExceptionListSoSchemaSavedObject = ( + attributes: Partial +): SavedObjectUnsanitizedDoc => ({ + attributes: { ...DEFAULT_OLD_EXCEPTION_LIST_SO, ...attributes }, + id: 'abcd', + migrationVersion: {}, + references: [], + type: 'so-type', + updated_at: '2020-06-09T20:18:20.349Z', +}); + +const createExceptionListSoSchemaSavedObject = ( + attributes: Partial +): SavedObjectUnsanitizedDoc => ({ + attributes: { ...DEFAULT_EXCEPTION_LIST_SO, ...attributes }, + id: 'abcd', + migrationVersion: {}, + references: [], + type: 'so-type', + updated_at: '2020-06-09T20:18:20.349Z', +}); + describe('7.10.0 lists migrations', () => { const migration = migrations['7.10.0']; test('properly converts .text fields to .caseless', () => { - const doc = { - attributes: { - entries: [ - { - field: 'file.path.text', - operator: 'included', - type: 'match', - value: 'C:\\Windows\\explorer.exe', - }, - { - field: 'host.os.name', - operator: 'included', - type: 'match', - value: 'my-host', - }, - { - entries: [ - { - field: 'process.command_line.text', - operator: 'included', - type: 'match', - value: '/usr/bin/bash', - }, - { - field: 'process.parent.command_line.text', - operator: 'included', - type: 'match', - value: '/usr/bin/bash', - }, - ], - field: 'nested.field', - type: 'nested', - }, - ], - list_id: ENDPOINT_LIST_ID, - }, - id: 'abcd', - migrationVersion: {}, - references: [], - type: 'so-type', - updated_at: '2020-06-09T20:18:20.349Z', - }; - expect( - migration((doc as unknown) as SavedObjectUnsanitizedDoc) - ).toEqual({ - attributes: { + const doc = createOldExceptionListSoSchemaSavedObject({ + entries: [ + { + field: 'file.path.text', + operator: 'included', + type: 'match', + value: 'C:\\Windows\\explorer.exe', + }, + { + field: 'host.os.name', + operator: 'included', + type: 'match', + value: 'my-host', + }, + { + entries: [ + { + field: 'process.command_line.text', + operator: 'included', + type: 'match', + value: '/usr/bin/bash', + }, + { + field: 'process.parent.command_line.text', + operator: 'included', + type: 'match', + value: '/usr/bin/bash', + }, + ], + field: 'nested.field', + type: 'nested', + }, + ], + list_id: ENDPOINT_LIST_ID, + }); + + expect(migration(doc)).toEqual( + createOldExceptionListSoSchemaSavedObject({ entries: [ { field: 'file.path.caseless', @@ -94,40 +135,98 @@ describe('7.10.0 lists migrations', () => { }, ], list_id: ENDPOINT_LIST_ID, - }, - id: 'abcd', - migrationVersion: {}, - references: [], - type: 'so-type', - updated_at: '2020-06-09T20:18:20.349Z', - }); + }) + ); }); test('properly copies os tags to os_types', () => { - const doc = { - attributes: { - _tags: ['1234', 'os:windows'], - comments: [], - }, - id: 'abcd', - migrationVersion: {}, - references: [], - type: 'so-type', - updated_at: '2020-06-09T20:18:20.349Z', - }; - expect( - migration((doc as unknown) as SavedObjectUnsanitizedDoc) - ).toEqual({ - attributes: { + const doc = createOldExceptionListSoSchemaSavedObject({ + _tags: ['1234', 'os:windows'], + comments: [], + }); + + expect(migration(doc)).toEqual( + createOldExceptionListSoSchemaSavedObject({ _tags: ['1234', 'os:windows'], comments: [], os_types: ['windows'], - }, - id: 'abcd', - migrationVersion: {}, - references: [], - type: 'so-type', - updated_at: '2020-06-09T20:18:20.349Z', + }) + ); + }); +}); + +describe('7.12.0 lists migrations', () => { + const migration = migrations['7.12.0']; + + test('should not convert non trusted apps lists', () => { + const doc = createExceptionListSoSchemaSavedObject({ list_id: ENDPOINT_LIST_ID, tags: [] }); + + expect(migration(doc)).toEqual( + createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_LIST_ID, + tags: [], + tie_breaker_id: expect.anything(), + }) + ); + }); + + test('converts empty tags to contain list containing "policy:all" tag', () => { + const doc = createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: [], + }); + + expect(migration(doc)).toEqual( + createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['policy:all'], + tie_breaker_id: expect.anything(), + }) + ); + }); + + test('preserves existing non policy related tags', () => { + const doc = createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['tag1', 'tag2'], + }); + + expect(migration(doc)).toEqual( + createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['tag1', 'tag2', 'policy:all'], + tie_breaker_id: expect.anything(), + }) + ); + }); + + test('preserves existing "policy:all" tag and does not add another one', () => { + const doc = createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['policy:all', 'tag1', 'tag2'], + }); + + expect(migration(doc)).toEqual( + createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['policy:all', 'tag1', 'tag2'], + tie_breaker_id: expect.anything(), + }) + ); + }); + + test('preserves existing policy reference tag and does not add "policy:all" tag', () => { + const doc = createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['policy:056d2d4645421fb92e5cd39f33d70856', 'tag1', 'tag2'], }); + + expect(migration(doc)).toEqual( + createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['policy:056d2d4645421fb92e5cd39f33d70856', 'tag1', 'tag2'], + tie_breaker_id: expect.anything(), + }) + ); }); }); diff --git a/x-pack/plugins/lists/server/saved_objects/migrations.ts b/x-pack/plugins/lists/server/saved_objects/migrations.ts index 43faa7a5e8fb64..2fa19a6810a8ad 100644 --- a/x-pack/plugins/lists/server/saved_objects/migrations.ts +++ b/x-pack/plugins/lists/server/saved_objects/migrations.ts @@ -40,6 +40,9 @@ const reduceOsTypes = (acc: string[], tag: string): string[] => { return [...acc]; }; +const containsPolicyTags = (tags: string[]): boolean => + tags.some((tag) => tag.startsWith('policy:')); + export type OldExceptionListSoSchema = ExceptionListSoSchema & { _tags: string[]; }; @@ -64,4 +67,25 @@ export const migrations = { }, references: doc.references || [], }), + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + if (doc.attributes.list_id === ENDPOINT_TRUSTED_APPS_LIST_ID) { + return { + ...doc, + ...{ + attributes: { + ...doc.attributes, + tags: [ + ...(doc.attributes.tags || []), + ...(containsPolicyTags(doc.attributes.tags) ? [] : ['policy:all']), + ], + }, + }, + references: doc.references || [], + }; + } else { + return { ...doc, references: doc.references || [] }; + } + }, }; diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts index d6ec668e1b0f9f..988f0ad0c125d4 100644 --- a/x-pack/plugins/security_solution/common/shared_imports.ts +++ b/x-pack/plugins/security_solution/common/shared_imports.ts @@ -43,6 +43,7 @@ export { ExceptionListType, Type, ENDPOINT_LIST_ID, + ENDPOINT_TRUSTED_APPS_LIST_ID, osTypeArray, OsTypeArray, } from '../../lists/common'; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index 2833a5ad24f2a3..88bf7941c84643 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -10,12 +10,17 @@ import { listMock } from '../../../../../lists/server/mocks'; import { getFoundExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { EntriesArray, EntryList } from '../../../../../lists/common/schemas/types'; -import { buildArtifact, getFullEndpointExceptionList } from './lists'; +import { + buildArtifact, + getEndpointExceptionList, + getEndpointTrustedAppsList, + getFilteredEndpointExceptionList, +} from './lists'; import { TranslatedEntry, TranslatedExceptionListItem } from '../../schemas/artifacts'; import { ArtifactConstants } from './common'; -import { ENDPOINT_LIST_ID } from '../../../../../lists/common'; +import { ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common'; -describe('buildEventTypeSignal', () => { +describe('artifacts lists', () => { let mockExceptionClient: ExceptionListClient; beforeEach(() => { @@ -23,214 +28,384 @@ describe('buildEventTypeSignal', () => { mockExceptionClient = listMock.getExceptionListClient(); }); - test('it should convert the exception lists response to the proper endpoint format', async () => { - const expectedEndpointExceptions = { - type: 'simple', - entries: [ - { - entries: [ - { - field: 'nested.field', - operator: 'included', - type: 'exact_cased', - value: 'some value', - }, - ], - field: 'some.parentField', - type: 'nested', - }, - { - field: 'some.not.nested.field', - operator: 'included', - type: 'exact_cased', - value: 'some value', - }, - ], - }; - - const first = getFoundExceptionListItemSchemaMock(); - mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFullEndpointExceptionList( - mockExceptionClient, - 'linux', - 'v1', - ENDPOINT_LIST_ID - ); - expect(resp).toEqual({ - entries: [expectedEndpointExceptions], + describe('getFilteredEndpointExceptionList', () => { + const TEST_FILTER = 'exception-list-agnostic.attributes.os_types:"linux"'; + + test('it should convert the exception lists response to the proper endpoint format', async () => { + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'some.not.nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + const resp = await getFilteredEndpointExceptionList( + mockExceptionClient, + 'v1', + TEST_FILTER, + ENDPOINT_LIST_ID + ); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); }); - }); - test('it should convert simple fields', async () => { - const testEntries: EntriesArray = [ - { field: 'host.os.full', operator: 'included', type: 'match', value: 'windows' }, - { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, - { field: 'host.hostname', operator: 'included', type: 'match', value: 'estc' }, - ]; + test('it should convert simple fields', async () => { + const testEntries: EntriesArray = [ + { field: 'host.os.full', operator: 'included', type: 'match', value: 'windows' }, + { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, + { field: 'host.hostname', operator: 'included', type: 'match', value: 'estc' }, + ]; - const expectedEndpointExceptions = { - type: 'simple', - entries: [ - { - field: 'host.os.full', - operator: 'included', - type: 'exact_cased', - value: 'windows', - }, + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'host.os.full', + operator: 'included', + type: 'exact_cased', + value: 'windows', + }, + { + field: 'server.ip', + operator: 'included', + type: 'exact_cased', + value: '192.168.1.1', + }, + { + field: 'host.hostname', + operator: 'included', + type: 'exact_cased', + value: 'estc', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList( + mockExceptionClient, + 'v1', + TEST_FILTER, + ENDPOINT_LIST_ID + ); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should convert fields case sensitive', async () => { + const testEntries: EntriesArray = [ + { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, + { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, { - field: 'server.ip', + field: 'host.hostname.caseless', operator: 'included', - type: 'exact_cased', - value: '192.168.1.1', + type: 'match_any', + value: ['estc', 'kibana'], }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'host.os.full', + operator: 'included', + type: 'exact_caseless', + value: 'windows', + }, + { + field: 'server.ip', + operator: 'included', + type: 'exact_cased', + value: '192.168.1.1', + }, + { + field: 'host.hostname', + operator: 'included', + type: 'exact_caseless_any', + value: ['estc', 'kibana'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList( + mockExceptionClient, + 'v1', + TEST_FILTER, + ENDPOINT_LIST_ID + ); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should deduplicate exception entries', async () => { + const testEntries: EntriesArray = [ + { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, + { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, + { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, + { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, { field: 'host.hostname', operator: 'included', - type: 'exact_cased', - value: 'estc', + type: 'match_any', + value: ['estc', 'kibana'], }, - ], - }; - - const first = getFoundExceptionListItemSchemaMock(); - first.data[0].entries = testEntries; - mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - - const resp = await getFullEndpointExceptionList( - mockExceptionClient, - 'linux', - 'v1', - ENDPOINT_LIST_ID - ); - expect(resp).toEqual({ - entries: [expectedEndpointExceptions], - }); - }); + ]; - test('it should convert fields case sensitive', async () => { - const testEntries: EntriesArray = [ - { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, - { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, - { - field: 'host.hostname.caseless', - operator: 'included', - type: 'match_any', - value: ['estc', 'kibana'], - }, - ]; + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'host.os.full', + operator: 'included', + type: 'exact_caseless', + value: 'windows', + }, + { + field: 'server.ip', + operator: 'included', + type: 'exact_cased', + value: '192.168.1.1', + }, + { + field: 'host.hostname', + operator: 'included', + type: 'exact_cased_any', + value: ['estc', 'kibana'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList( + mockExceptionClient, + 'v1', + TEST_FILTER, + ENDPOINT_LIST_ID + ); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); - const expectedEndpointExceptions = { - type: 'simple', - entries: [ - { - field: 'host.os.full', - operator: 'included', - type: 'exact_caseless', - value: 'windows', - }, - { - field: 'server.ip', - operator: 'included', - type: 'exact_cased', - value: '192.168.1.1', - }, + test('it should not deduplicate exception entries across nested boundaries', async () => { + const testEntries: EntriesArray = [ { - field: 'host.hostname', - operator: 'included', - type: 'exact_caseless_any', - value: ['estc', 'kibana'], + entries: [ + { field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }, + ], + field: 'some.parentField', + type: 'nested', }, - ], - }; - - const first = getFoundExceptionListItemSchemaMock(); - first.data[0].entries = testEntries; - mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - - const resp = await getFullEndpointExceptionList( - mockExceptionClient, - 'linux', - 'v1', - ENDPOINT_LIST_ID - ); - expect(resp).toEqual({ - entries: [expectedEndpointExceptions], + // Same as above but not inside the nest + { field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList( + mockExceptionClient, + 'v1', + TEST_FILTER, + ENDPOINT_LIST_ID + ); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); }); - }); - test('it should deduplicate exception entries', async () => { - const testEntries: EntriesArray = [ - { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, - { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, - { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, - { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, - { - field: 'host.hostname', - operator: 'included', - type: 'match_any', - value: ['estc', 'kibana'], - }, - ]; + test('it should deduplicate exception items', async () => { + const testEntries: EntriesArray = [ + { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, + { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, + ]; - const expectedEndpointExceptions = { - type: 'simple', - entries: [ + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'host.os.full', + operator: 'included', + type: 'exact_caseless', + value: 'windows', + }, + { + field: 'server.ip', + operator: 'included', + type: 'exact_cased', + value: '192.168.1.1', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + + // Create a second exception item with the same entries + first.data[1] = getExceptionListItemSchemaMock(); + first.data[1].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList( + mockExceptionClient, + 'v1', + TEST_FILTER, + ENDPOINT_LIST_ID + ); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should ignore unsupported entries', async () => { + // Lists and exists are not supported by the Endpoint + const testEntries: EntriesArray = [ + { field: 'host.os.full', operator: 'included', type: 'match', value: 'windows' }, { field: 'host.os.full', operator: 'included', - type: 'exact_caseless', - value: 'windows', - }, - { - field: 'server.ip', - operator: 'included', - type: 'exact_cased', - value: '192.168.1.1', - }, - { - field: 'host.hostname', - operator: 'included', - type: 'exact_cased_any', - value: ['estc', 'kibana'], - }, - ], - }; - - const first = getFoundExceptionListItemSchemaMock(); - first.data[0].entries = testEntries; - mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - - const resp = await getFullEndpointExceptionList( - mockExceptionClient, - 'linux', - 'v1', - ENDPOINT_LIST_ID - ); - expect(resp).toEqual({ - entries: [expectedEndpointExceptions], - }); - }); + type: 'list', + list: { + id: 'lists_not_supported', + type: 'keyword', + }, + } as EntryList, + { field: 'server.ip', operator: 'included', type: 'exists' }, + ]; - test('it should not deduplicate exception entries across nested boundaries', async () => { - const testEntries: EntriesArray = [ - { + const expectedEndpointExceptions = { + type: 'simple', entries: [ - { field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }, + { + field: 'host.os.full', + operator: 'included', + type: 'exact_cased', + value: 'windows', + }, ], - field: 'some.parentField', - type: 'nested', - }, - // Same as above but not inside the nest - { field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }, - ]; + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList( + mockExceptionClient, + 'v1', + TEST_FILTER, + ENDPOINT_LIST_ID + ); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should convert the exception lists response to the proper endpoint format while paging', async () => { + // The first call returns two exceptions + const first = getFoundExceptionListItemSchemaMock(); + first.per_page = 2; + first.total = 4; + first.data.push(getExceptionListItemSchemaMock()); + + // The second call returns two exceptions + const second = getFoundExceptionListItemSchemaMock(); + second.per_page = 2; + second.total = 4; + second.data.push(getExceptionListItemSchemaMock()); + + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(first) + .mockReturnValueOnce(second); + + const resp = await getFilteredEndpointExceptionList( + mockExceptionClient, + 'v1', + TEST_FILTER, + ENDPOINT_LIST_ID + ); + + // Expect 2 exceptions, the first two calls returned the same exception list items + expect(resp.entries.length).toEqual(2); + }); + + test('it should handle no exceptions', async () => { + const exceptionsResponse = getFoundExceptionListItemSchemaMock(); + exceptionsResponse.data = []; + exceptionsResponse.total = 0; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(exceptionsResponse); + const resp = await getFilteredEndpointExceptionList( + mockExceptionClient, + 'v1', + TEST_FILTER, + ENDPOINT_LIST_ID + ); + expect(resp.entries.length).toEqual(0); + }); - const expectedEndpointExceptions = { - type: 'simple', - entries: [ + test('it should return a stable hash regardless of order of entries', async () => { + const translatedEntries: TranslatedEntry[] = [ { entries: [ { - field: 'nested.field', + field: 'some.nested.field', operator: 'included', type: 'exact_cased', value: 'some value', @@ -245,218 +420,107 @@ describe('buildEventTypeSignal', () => { type: 'exact_cased', value: 'some value', }, - ], - }; - - const first = getFoundExceptionListItemSchemaMock(); - first.data[0].entries = testEntries; - mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - - const resp = await getFullEndpointExceptionList( - mockExceptionClient, - 'linux', - 'v1', - ENDPOINT_LIST_ID - ); - expect(resp).toEqual({ - entries: [expectedEndpointExceptions], - }); - }); - - test('it should deduplicate exception items', async () => { - const testEntries: EntriesArray = [ - { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, - { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, - ]; - - const expectedEndpointExceptions = { - type: 'simple', - entries: [ - { - field: 'host.os.full', - operator: 'included', - type: 'exact_caseless', - value: 'windows', - }, - { - field: 'server.ip', - operator: 'included', - type: 'exact_cased', - value: '192.168.1.1', - }, - ], - }; - - const first = getFoundExceptionListItemSchemaMock(); - first.data[0].entries = testEntries; - - // Create a second exception item with the same entries - first.data[1] = getExceptionListItemSchemaMock(); - first.data[1].entries = testEntries; - mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - - const resp = await getFullEndpointExceptionList( - mockExceptionClient, - 'linux', - 'v1', - ENDPOINT_LIST_ID - ); - expect(resp).toEqual({ - entries: [expectedEndpointExceptions], - }); - }); - - test('it should ignore unsupported entries', async () => { - // Lists and exists are not supported by the Endpoint - const testEntries: EntriesArray = [ - { field: 'host.os.full', operator: 'included', type: 'match', value: 'windows' }, - { - field: 'host.os.full', - operator: 'included', - type: 'list', - list: { - id: 'lists_not_supported', - type: 'keyword', - }, - } as EntryList, - { field: 'server.ip', operator: 'included', type: 'exists' }, - ]; - - const expectedEndpointExceptions = { - type: 'simple', - entries: [ - { - field: 'host.os.full', - operator: 'included', - type: 'exact_cased', - value: 'windows', - }, - ], - }; - - const first = getFoundExceptionListItemSchemaMock(); - first.data[0].entries = testEntries; - mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - - const resp = await getFullEndpointExceptionList( - mockExceptionClient, - 'linux', - 'v1', - ENDPOINT_LIST_ID - ); - expect(resp).toEqual({ - entries: [expectedEndpointExceptions], - }); - }); + ]; + const translatedEntriesReversed = translatedEntries.reverse(); - test('it should convert the exception lists response to the proper endpoint format while paging', async () => { - // The first call returns two exceptions - const first = getFoundExceptionListItemSchemaMock(); - first.per_page = 2; - first.total = 4; - first.data.push(getExceptionListItemSchemaMock()); - - // The second call returns two exceptions - const second = getFoundExceptionListItemSchemaMock(); - second.per_page = 2; - second.total = 4; - second.data.push(getExceptionListItemSchemaMock()); - - mockExceptionClient.findExceptionListItem = jest - .fn() - .mockReturnValueOnce(first) - .mockReturnValueOnce(second); - - const resp = await getFullEndpointExceptionList( - mockExceptionClient, - 'linux', - 'v1', - ENDPOINT_LIST_ID - ); - - // Expect 2 exceptions, the first two calls returned the same exception list items - expect(resp.entries.length).toEqual(2); - }); - - test('it should handle no exceptions', async () => { - const exceptionsResponse = getFoundExceptionListItemSchemaMock(); - exceptionsResponse.data = []; - exceptionsResponse.total = 0; - mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(exceptionsResponse); - const resp = await getFullEndpointExceptionList( - mockExceptionClient, - 'linux', - 'v1', - ENDPOINT_LIST_ID - ); - expect(resp.entries.length).toEqual(0); - }); + const translatedExceptionList = { + entries: [ + { + type: 'simple', + entries: translatedEntries, + }, + ], + }; - test('it should return a stable hash regardless of order of entries', async () => { - const translatedEntries: TranslatedEntry[] = [ - { + const translatedExceptionListReversed = { entries: [ { - field: 'some.nested.field', - operator: 'included', - type: 'exact_cased', - value: 'some value', + type: 'simple', + entries: translatedEntriesReversed, }, ], - field: 'some.parentField', - type: 'nested', - }, - { - field: 'nested.field', - operator: 'included', - type: 'exact_cased', - value: 'some value', - }, - ]; - const translatedEntriesReversed = translatedEntries.reverse(); + }; + + const artifact1 = await buildArtifact( + translatedExceptionList, + 'v1', + 'linux', + ArtifactConstants.GLOBAL_ALLOWLIST_NAME + ); + const artifact2 = await buildArtifact( + translatedExceptionListReversed, + 'v1', + 'linux', + ArtifactConstants.GLOBAL_ALLOWLIST_NAME + ); + expect(artifact1.decodedSha256).toEqual(artifact2.decodedSha256); + }); - const translatedExceptionList = { - entries: [ + test('it should return a stable hash regardless of order of items', async () => { + const translatedItems: TranslatedExceptionListItem[] = [ { type: 'simple', - entries: translatedEntries, + entries: [ + { + entries: [ + { + field: 'some.nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + ], }, - ], - }; - - const translatedExceptionListReversed = { - entries: [ { type: 'simple', - entries: translatedEntriesReversed, + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], }, - ], - }; - - const artifact1 = await buildArtifact( - translatedExceptionList, - 'linux', - 'v1', - ArtifactConstants.GLOBAL_ALLOWLIST_NAME - ); - const artifact2 = await buildArtifact( - translatedExceptionListReversed, - 'linux', - 'v1', - ArtifactConstants.GLOBAL_ALLOWLIST_NAME - ); - expect(artifact1.decodedSha256).toEqual(artifact2.decodedSha256); + ]; + + const translatedExceptionList = { + entries: translatedItems, + }; + + const translatedExceptionListReversed = { + entries: translatedItems.reverse(), + }; + + const artifact1 = await buildArtifact( + translatedExceptionList, + 'v1', + 'linux', + ArtifactConstants.GLOBAL_ALLOWLIST_NAME + ); + const artifact2 = await buildArtifact( + translatedExceptionListReversed, + 'v1', + 'linux', + ArtifactConstants.GLOBAL_ALLOWLIST_NAME + ); + expect(artifact1.decodedSha256).toEqual(artifact2.decodedSha256); + }); }); - test('it should return a stable hash regardless of order of items', async () => { - const translatedItems: TranslatedExceptionListItem[] = [ + const TEST_EXCEPTION_LIST_ITEM = { + entries: [ { type: 'simple', entries: [ { entries: [ { - field: 'some.nested.field', + field: 'nested.field', operator: 'included', type: 'exact_cased', value: 'some value', @@ -465,41 +529,87 @@ describe('buildEventTypeSignal', () => { field: 'some.parentField', type: 'nested', }, - ], - }, - { - type: 'simple', - entries: [ { - field: 'nested.field', + field: 'some.not.nested.field', operator: 'included', type: 'exact_cased', value: 'some value', }, ], }, - ]; - - const translatedExceptionList = { - entries: translatedItems, - }; - - const translatedExceptionListReversed = { - entries: translatedItems.reverse(), - }; - - const artifact1 = await buildArtifact( - translatedExceptionList, - 'linux', - 'v1', - ArtifactConstants.GLOBAL_ALLOWLIST_NAME - ); - const artifact2 = await buildArtifact( - translatedExceptionListReversed, - 'linux', - 'v1', - ArtifactConstants.GLOBAL_ALLOWLIST_NAME - ); - expect(artifact1.decodedSha256).toEqual(artifact2.decodedSha256); + ], + }; + + describe('getEndpointExceptionList', () => { + test('it should build proper kuery', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList(mockExceptionClient, 'v1', 'windows'); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_LIST_ID, + namespaceType: 'agnostic', + filter: 'exception-list-agnostic.attributes.os_types:"windows"', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + }); + + describe('getEndpointTrustedAppsList', () => { + test('it should build proper kuery without policy', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointTrustedAppsList(mockExceptionClient, 'v1', 'macos'); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and (exception-list-agnostic.attributes.tags:"policy:all")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + + test('it should build proper kuery with policy', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointTrustedAppsList( + mockExceptionClient, + 'v1', + 'macos', + 'c6d16e42-c32d-4dce-8a88-113cfe276ad1' + ); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and ' + + '(exception-list-agnostic.attributes.tags:"policy:all" or ' + + 'exception-list-agnostic.attributes.tags:"policy:c6d16e42-c32d-4dce-8a88-113cfe276ad1")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 6cc6a821eba334..322bb2ca47a45c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -12,7 +12,7 @@ import { validate } from '../../../../common/validate'; import { Entry, EntryNested } from '../../../../../lists/common/schemas/types'; import { ExceptionListClient } from '../../../../../lists/server'; -import { ENDPOINT_LIST_ID } from '../../../../common/shared_imports'; +import { ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../common/shared_imports'; import { InternalArtifactSchema, TranslatedEntry, @@ -28,12 +28,11 @@ import { internalArtifactCompleteSchema, InternalArtifactCompleteSchema, } from '../../schemas'; -import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants'; export async function buildArtifact( exceptions: WrappedTranslatedExceptionList, - os: string, schemaVersion: string, + os: string, name: string ): Promise { const exceptionsBuffer = Buffer.from(JSON.stringify(exceptions)); @@ -74,10 +73,10 @@ export function isCompressed(artifact: InternalArtifactSchema) { return artifact.compressionAlgorithm === 'zlib'; } -export async function getFullEndpointExceptionList( +export async function getFilteredEndpointExceptionList( eClient: ExceptionListClient, - os: string, schemaVersion: string, + filter: string, listId: typeof ENDPOINT_LIST_ID | typeof ENDPOINT_TRUSTED_APPS_LIST_ID ): Promise { const exceptions: WrappedTranslatedExceptionList = { entries: [] }; @@ -88,7 +87,7 @@ export async function getFullEndpointExceptionList( const response = await eClient.findExceptionListItem({ listId, namespaceType: 'agnostic', - filter: `exception-list-agnostic.attributes.os_types:\"${os}\"`, + filter, perPage: 100, page, sortField: 'created_at', @@ -114,6 +113,35 @@ export async function getFullEndpointExceptionList( return validated as WrappedTranslatedExceptionList; } +export async function getEndpointExceptionList( + eClient: ExceptionListClient, + schemaVersion: string, + os: string +): Promise { + const filter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; + + return getFilteredEndpointExceptionList(eClient, schemaVersion, filter, ENDPOINT_LIST_ID); +} + +export async function getEndpointTrustedAppsList( + eClient: ExceptionListClient, + schemaVersion: string, + os: string, + policyId?: string +): Promise { + const osFilter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; + const policyFilter = `(exception-list-agnostic.attributes.tags:\"policy:all\"${ + policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' + })`; + + return getFilteredEndpointExceptionList( + eClient, + schemaVersion, + `${osFilter} and ${policyFilter}`, + ENDPOINT_TRUSTED_APPS_LIST_ID + ); +} + /** * Translates Exception list items to Exceptions the endpoint can understand * @param exceptions diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts index 972c4f3153a1c8..b8b1e13f2052b5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.test.ts @@ -35,7 +35,7 @@ const createExceptionListItemOptions = ( name: '', namespaceType: 'agnostic', osTypes: [], - tags: [], + tags: ['policy:all'], type: 'simple', ...options, }); @@ -56,7 +56,7 @@ const exceptionListItemSchema = ( name: '', namespace_type: 'agnostic', os_types: [], - tags: [], + tags: ['policy:all'], type: 'simple', tie_breaker_id: '123', updated_at: '11/11/2011T11:11:11.111', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts index 4d2238ea96ee15..41b4b7b1d55fdd 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts @@ -15,7 +15,7 @@ import { ExceptionListItemSchema, NestedEntriesArray, } from '../../../../../lists/common/shared_exports'; -import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common'; import { CreateExceptionListItemOptions } from '../../../../../lists/server'; import { ConditionEntry, @@ -184,7 +184,7 @@ export const newTrustedAppToCreateExceptionListItemOptions = ({ name, namespaceType: 'agnostic', osTypes: [OPERATING_SYSTEM_TO_OS_TYPE[os]], - tags: [], + tags: ['policy:all'], type: 'simple', }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts index dc3c369494d4e2..97a8451bf25d83 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts @@ -6,7 +6,7 @@ */ import { ExceptionListClient } from '../../../../../lists/server'; -import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common'; import { DeleteTrustedAppsRequestParams, diff --git a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts index dedbcc25e2373e..1975c2a92cc16f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts +++ b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts @@ -36,8 +36,8 @@ export const getInternalArtifactMock = async ( ): Promise => { const artifact = await buildArtifact( getTranslatedExceptionListMock(), - os, schemaVersion, + os, artifactName ); return opts?.compress ? compressArtifact(artifact) : artifact; @@ -49,7 +49,7 @@ export const getEmptyInternalArtifactMock = async ( opts?: { compress: boolean }, artifactName: string = ArtifactConstants.GLOBAL_ALLOWLIST_NAME ): Promise => { - const artifact = await buildArtifact({ entries: [] }, os, schemaVersion, artifactName); + const artifact = await buildArtifact({ entries: [] }, schemaVersion, os, artifactName); return opts?.compress ? compressArtifact(artifact) : artifact; }; @@ -62,8 +62,8 @@ export const getInternalArtifactMockWithDiffs = async ( mock.entries.pop(); const artifact = await buildArtifact( mock, - os, schemaVersion, + os, ArtifactConstants.GLOBAL_ALLOWLIST_NAME ); return opts?.compress ? compressArtifact(artifact) : artifact; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts index a8bbfca0d41e58..b0e0d5d8ebfbea 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts @@ -33,17 +33,24 @@ export const createExceptionListResponse = (data: ExceptionListItemSchema[], tot type FindExceptionListItemOptions = Parameters[0]; -const FILTER_REGEXP = /^exception-list-agnostic\.attributes\.os_types:"(\w+)"$/; +const FILTER_PROPERTY_PREFIX = 'exception-list-agnostic\\.attributes'; +const FILTER_REGEXP = new RegExp( + `^${FILTER_PROPERTY_PREFIX}\.os_types:"([^"]+)"( and \\(${FILTER_PROPERTY_PREFIX}\.tags:"policy:all"( or ${FILTER_PROPERTY_PREFIX}\.tags:"policy:([^"]+)")?\\))?$` +); export const mockFindExceptionListItemResponses = ( responses: Record> ) => { return jest.fn().mockImplementation((options: FindExceptionListItemOptions) => { - const os = FILTER_REGEXP.test(options.filter || '') - ? options.filter!.match(FILTER_REGEXP)![1] - : ''; - - return createExceptionListResponse(responses[options.listId]?.[os] || []); + const matches = options.filter!.match(FILTER_REGEXP) || []; + + if (matches[4] && responses[options.listId]?.[`${matches![1]}-${matches[4]}`]) { + return createExceptionListResponse( + responses[options.listId]?.[`${matches![1]}-${matches[4]}`] || [] + ); + } else { + return createExceptionListResponse(responses[options.listId]?.[matches![1] || ''] || []); + } }); }; @@ -118,7 +125,7 @@ export const getManifestManagerMock = ( context.exceptionListClient.findExceptionListItem = jest .fn() .mockRejectedValue(new Error('unexpected thing happened')); - return super.buildExceptionListArtifacts('v1'); + return super.buildExceptionListArtifacts(); case ManifestManagerMockType.NormalFlow: return getMockArtifactsWithDiff(); } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index 52897f473189fb..26db49be459fa2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -8,8 +8,7 @@ import { inflateSync } from 'zlib'; import { SavedObjectsErrorHelpers } from 'src/core/server'; import { savedObjectsClientMock } from 'src/core/server/mocks'; -import { ENDPOINT_LIST_ID } from '../../../../../../lists/common'; -import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../../lists/common/constants'; +import { ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../../lists/common'; import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { PackagePolicy } from '../../../../../../fleet/common/types/models'; import { getEmptyInternalArtifactMock } from '../../../schemas/artifacts/saved_objects.mock'; @@ -211,10 +210,19 @@ describe('ManifestManager', () => { ARTIFACT_NAME_TRUSTED_APPS_LINUX, ]; - const getArtifactIds = (artifacts: InternalArtifactSchema[]) => - artifacts.map((artifact) => artifact.identifier); + const getArtifactIds = (artifacts: InternalArtifactSchema[]) => [ + ...new Set(artifacts.map((artifact) => artifact.identifier)).values(), + ]; + + const mockPolicyListIdsResponse = (items: string[]) => + jest.fn().mockResolvedValue({ + items, + page: 1, + per_page: 100, + total: items.length, + }); - test('Fails when exception list list client fails', async () => { + test('Fails when exception list client fails', async () => { const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); @@ -228,6 +236,7 @@ describe('ManifestManager', () => { const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({}); + context.packagePolicyService.listIds = mockPolicyListIdsResponse([TEST_POLICY_ID_1]); const manifest = await manifestManager.buildNewManifest(); @@ -237,11 +246,16 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); + expect(artifacts.length).toBe(5); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(artifacts.every(isCompressed)).toBe(true); for (const artifact of artifacts) { expect(await uncompressArtifact(artifact)).toStrictEqual({ entries: [] }); + expect(manifest.isDefaultArtifact(artifact)).toBe(true); + expect(manifest.getArtifactTargetPolicies(artifact)).toStrictEqual( + new Set([TEST_POLICY_ID_1]) + ); } }); @@ -255,6 +269,7 @@ describe('ManifestManager', () => { [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, [ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] }, }); + context.packagePolicyService.listIds = mockPolicyListIdsResponse([TEST_POLICY_ID_1]); const manifest = await manifestManager.buildNewManifest(); @@ -264,21 +279,25 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); + expect(artifacts.length).toBe(5); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(artifacts.every(isCompressed)).toBe(true); + expect(await uncompressArtifact(artifacts[0])).toStrictEqual({ + entries: translateToEndpointExceptions([exceptionListItem], 'v1'), + }); + expect(await uncompressArtifact(artifacts[1])).toStrictEqual({ entries: [] }); + expect(await uncompressArtifact(artifacts[2])).toStrictEqual({ entries: [] }); + expect(await uncompressArtifact(artifacts[3])).toStrictEqual({ entries: [] }); + expect(await uncompressArtifact(artifacts[4])).toStrictEqual({ + entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + }); + for (const artifact of artifacts) { - if (artifact.identifier === ARTIFACT_NAME_EXCEPTIONS_MACOS) { - expect(await uncompressArtifact(artifact)).toStrictEqual({ - entries: translateToEndpointExceptions([exceptionListItem], 'v1'), - }); - } else if (artifact.identifier === 'endpoint-trustlist-linux-v1') { - expect(await uncompressArtifact(artifact)).toStrictEqual({ - entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), - }); - } else { - expect(await uncompressArtifact(artifact)).toStrictEqual({ entries: [] }); - } + expect(manifest.isDefaultArtifact(artifact)).toBe(true); + expect(manifest.getArtifactTargetPolicies(artifact)).toStrictEqual( + new Set([TEST_POLICY_ID_1]) + ); } }); @@ -291,6 +310,7 @@ describe('ManifestManager', () => { context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, }); + context.packagePolicyService.listIds = mockPolicyListIdsResponse([TEST_POLICY_ID_1]); const oldManifest = await manifestManager.buildNewManifest(); @@ -307,20 +327,89 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); + expect(artifacts.length).toBe(5); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(artifacts.every(isCompressed)).toBe(true); + expect(artifacts[0]).toStrictEqual(oldManifest.getAllArtifacts()[0]); + expect(await uncompressArtifact(artifacts[1])).toStrictEqual({ entries: [] }); + expect(await uncompressArtifact(artifacts[2])).toStrictEqual({ entries: [] }); + expect(await uncompressArtifact(artifacts[3])).toStrictEqual({ entries: [] }); + expect(await uncompressArtifact(artifacts[4])).toStrictEqual({ + entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + }); + for (const artifact of artifacts) { - if (artifact.identifier === ARTIFACT_NAME_EXCEPTIONS_MACOS) { - expect(artifact).toStrictEqual(oldManifest.getAllArtifacts()[0]); - } else if (artifact.identifier === 'endpoint-trustlist-linux-v1') { - expect(await uncompressArtifact(artifact)).toStrictEqual({ - entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), - }); - } else { - expect(await uncompressArtifact(artifact)).toStrictEqual({ entries: [] }); - } + expect(manifest.isDefaultArtifact(artifact)).toBe(true); + expect(manifest.getArtifactTargetPolicies(artifact)).toStrictEqual( + new Set([TEST_POLICY_ID_1]) + ); + } + }); + + test('Builds manifest with policy specific exception list items for trusted apps', async () => { + const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); + const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const trustedAppListItemPolicy2 = getExceptionListItemSchemaMock({ + os_types: ['linux'], + entries: [ + { field: 'other.field', operator: 'included', type: 'match', value: 'other value' }, + ], + }); + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + + context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ + [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, + [ENDPOINT_TRUSTED_APPS_LIST_ID]: { + linux: [trustedAppListItem], + [`linux-${TEST_POLICY_ID_2}`]: [trustedAppListItem, trustedAppListItemPolicy2], + }, + }); + context.packagePolicyService.listIds = mockPolicyListIdsResponse([ + TEST_POLICY_ID_1, + TEST_POLICY_ID_2, + ]); + + const manifest = await manifestManager.buildNewManifest(); + + expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); + expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); + expect(manifest?.getSavedObjectVersion()).toBeUndefined(); + + const artifacts = manifest.getAllArtifacts(); + + expect(artifacts.length).toBe(6); + expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); + expect(artifacts.every(isCompressed)).toBe(true); + + expect(await uncompressArtifact(artifacts[0])).toStrictEqual({ + entries: translateToEndpointExceptions([exceptionListItem], 'v1'), + }); + expect(await uncompressArtifact(artifacts[1])).toStrictEqual({ entries: [] }); + expect(await uncompressArtifact(artifacts[2])).toStrictEqual({ entries: [] }); + expect(await uncompressArtifact(artifacts[3])).toStrictEqual({ entries: [] }); + expect(await uncompressArtifact(artifacts[4])).toStrictEqual({ + entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + }); + expect(await uncompressArtifact(artifacts[5])).toStrictEqual({ + entries: translateToEndpointExceptions( + [trustedAppListItem, trustedAppListItemPolicy2], + 'v1' + ), + }); + + for (const artifact of artifacts.slice(0, 4)) { + expect(manifest.isDefaultArtifact(artifact)).toBe(true); + expect(manifest.getArtifactTargetPolicies(artifact)).toStrictEqual( + new Set([TEST_POLICY_ID_1, TEST_POLICY_ID_2]) + ); } + + expect(manifest.isDefaultArtifact(artifacts[5])).toBe(false); + expect(manifest.getArtifactTargetPolicies(artifacts[5])).toStrictEqual( + new Set([TEST_POLICY_ID_2]) + ); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 6b9cbb55415a01..f49f2a3e226eef 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -9,6 +9,7 @@ import semver from 'semver'; import LRU from 'lru-cache'; import { isEqual } from 'lodash'; import { Logger, SavedObjectsClientContract } from 'src/core/server'; +import { ListResult } from '../../../../../../fleet/common'; import { PackagePolicyServiceInterface } from '../../../../../../fleet/server'; import { ExceptionListClient } from '../../../../../../lists/server'; import { ManifestSchemaVersion } from '../../../../../common/endpoint/schema/common'; @@ -21,7 +22,8 @@ import { ArtifactConstants, buildArtifact, getArtifactId, - getFullEndpointExceptionList, + getEndpointExceptionList, + getEndpointTrustedAppsList, isCompressed, Manifest, maybeCompressArtifact, @@ -32,9 +34,45 @@ import { } from '../../../schemas/artifacts'; import { ArtifactClient } from '../artifact_client'; import { ManifestClient } from '../manifest_client'; -import { ENDPOINT_LIST_ID } from '../../../../../../lists/common'; -import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../../lists/common/constants'; -import { PackagePolicy } from '../../../../../../fleet/common/types/models'; + +interface ArtifactsBuildResult { + defaultArtifacts: InternalArtifactCompleteSchema[]; + policySpecificArtifacts: Record; +} + +const iterateArtifactsBuildResult = async ( + result: ArtifactsBuildResult, + callback: (artifact: InternalArtifactCompleteSchema, policyId?: string) => Promise +) => { + for (const artifact of result.defaultArtifacts) { + await callback(artifact); + } + + for (const policyId of Object.keys(result.policySpecificArtifacts)) { + for (const artifact of result.policySpecificArtifacts[policyId]) { + await callback(artifact, policyId); + } + } +}; + +const iterateAllListItems = async ( + pageSupplier: (page: number) => Promise>, + itemCallback: (item: T) => void +) => { + let paging = true; + let page = 1; + + while (paging) { + const { items, total } = await pageSupplier(page); + + for (const item of items) { + await itemCallback(item); + } + + paging = (page - 1) * 20 + items.length < total; + page++; + } +}; export interface ManifestManagerContext { savedObjectsClient: SavedObjectsClientContract; @@ -81,6 +119,19 @@ export class ManifestManager { return new ManifestClient(this.savedObjectsClient, this.schemaVersion); } + /** + * Builds an artifact (one per supported OS) based on the current + * state of exception-list-agnostic SOs. + */ + protected async buildExceptionListArtifact(os: string): Promise { + return buildArtifact( + await getEndpointExceptionList(this.exceptionListClient, this.schemaVersion, os), + this.schemaVersion, + os, + ArtifactConstants.GLOBAL_ALLOWLIST_NAME + ); + } + /** * Builds an array of artifacts (one per supported OS) based on the current * state of exception-list-agnostic SOs. @@ -88,54 +139,60 @@ export class ManifestManager { * @returns {Promise} An array of uncompressed artifacts built from exception-list-agnostic SOs. * @throws Throws/rejects if there are errors building the list. */ - protected async buildExceptionListArtifacts( - artifactSchemaVersion?: string - ): Promise { - const artifacts: InternalArtifactCompleteSchema[] = []; + protected async buildExceptionListArtifacts(): Promise { + const defaultArtifacts: InternalArtifactCompleteSchema[] = []; + const policySpecificArtifacts: Record = {}; + for (const os of ArtifactConstants.SUPPORTED_OPERATING_SYSTEMS) { - const exceptionList = await getFullEndpointExceptionList( - this.exceptionListClient, - os, - artifactSchemaVersion ?? 'v1', - ENDPOINT_LIST_ID - ); - const artifact = await buildArtifact( - exceptionList, - os, - artifactSchemaVersion ?? 'v1', - ArtifactConstants.GLOBAL_ALLOWLIST_NAME - ); - artifacts.push(artifact); + defaultArtifacts.push(await this.buildExceptionListArtifact(os)); } - return artifacts; + + await iterateAllListItems( + (page) => this.listEndpointPolicyIds(page), + async (policyId) => { + policySpecificArtifacts[policyId] = defaultArtifacts; + } + ); + + return { defaultArtifacts, policySpecificArtifacts }; + } + + /** + * Builds an artifact (one per supported OS) based on the current state of the + * Trusted Apps list (which uses the `exception-list-agnostic` SO type) + */ + protected async buildTrustedAppsArtifact(os: string, policyId?: string) { + return buildArtifact( + await getEndpointTrustedAppsList(this.exceptionListClient, this.schemaVersion, os, policyId), + this.schemaVersion, + os, + ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME + ); } /** * Builds an array of artifacts (one per supported OS) based on the current state of the * Trusted Apps list (which uses the `exception-list-agnostic` SO type) - * @param artifactSchemaVersion */ - protected async buildTrustedAppsArtifacts( - artifactSchemaVersion?: string - ): Promise { - const artifacts: InternalArtifactCompleteSchema[] = []; + protected async buildTrustedAppsArtifacts(): Promise { + const defaultArtifacts: InternalArtifactCompleteSchema[] = []; + const policySpecificArtifacts: Record = {}; for (const os of ArtifactConstants.SUPPORTED_TRUSTED_APPS_OPERATING_SYSTEMS) { - const trustedApps = await getFullEndpointExceptionList( - this.exceptionListClient, - os, - artifactSchemaVersion ?? 'v1', - ENDPOINT_TRUSTED_APPS_LIST_ID - ); - const artifact = await buildArtifact( - trustedApps, - os, - 'v1', - ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME - ); - artifacts.push(artifact); + defaultArtifacts.push(await this.buildTrustedAppsArtifact(os)); } - return artifacts; + + await iterateAllListItems( + (page) => this.listEndpointPolicyIds(page), + async (policyId) => { + for (const os of ArtifactConstants.SUPPORTED_TRUSTED_APPS_OPERATING_SYSTEMS) { + policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; + policySpecificArtifacts[policyId].push(await this.buildTrustedAppsArtifact(os, policyId)); + } + } + ); + + return { defaultArtifacts, policySpecificArtifacts }; } /** @@ -251,32 +308,33 @@ export class ManifestManager { public async buildNewManifest( baselineManifest: Manifest = Manifest.getDefault(this.schemaVersion) ): Promise { - // Build new exception list artifacts - const artifacts = ( - await Promise.all([this.buildExceptionListArtifacts(), this.buildTrustedAppsArtifacts()]) - ).flat(); + const results = await Promise.all([ + this.buildExceptionListArtifacts(), + this.buildTrustedAppsArtifacts(), + ]); - // Build new manifest const manifest = new Manifest({ schemaVersion: this.schemaVersion, semanticVersion: baselineManifest.getSemanticVersion(), soVersion: baselineManifest.getSavedObjectVersion(), }); - for (const artifact of artifacts) { - let artifactToAdd = baselineManifest.getArtifact(getArtifactId(artifact)) || artifact; - - if (!isCompressed(artifactToAdd)) { - artifactToAdd = await maybeCompressArtifact(artifactToAdd); + for (const result of results) { + await iterateArtifactsBuildResult(result, async (artifact, policyId) => { + let artifactToAdd = baselineManifest.getArtifact(getArtifactId(artifact)) || artifact; if (!isCompressed(artifactToAdd)) { - throw new Error(`Unable to compress artifact: ${getArtifactId(artifactToAdd)}`); - } else if (!internalArtifactCompleteSchema.is(artifactToAdd)) { - throw new Error(`Incomplete artifact detected: ${getArtifactId(artifactToAdd)}`); + artifactToAdd = await maybeCompressArtifact(artifactToAdd); + + if (!isCompressed(artifactToAdd)) { + throw new Error(`Unable to compress artifact: ${getArtifactId(artifactToAdd)}`); + } else if (!internalArtifactCompleteSchema.is(artifactToAdd)) { + throw new Error(`Incomplete artifact detected: ${getArtifactId(artifactToAdd)}`); + } } - } - manifest.addEntry(artifactToAdd); + manifest.addEntry(artifactToAdd, policyId); + }); } return manifest; @@ -292,49 +350,52 @@ export class ManifestManager { public async tryDispatch(manifest: Manifest): Promise { const errors: Error[] = []; - await this.forEachPolicy(async (packagePolicy) => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { id, revision, updated_at, updated_by, ...newPackagePolicy } = packagePolicy; - if (newPackagePolicy.inputs.length > 0 && newPackagePolicy.inputs[0].config !== undefined) { - const oldManifest = newPackagePolicy.inputs[0].config.artifact_manifest ?? { - value: {}, - }; - - const newManifestVersion = manifest.getSemanticVersion(); - if (semver.gt(newManifestVersion, oldManifest.value.manifest_version)) { - const serializedManifest = manifest.toPackagePolicyManifest(packagePolicy.id); - - if (!manifestDispatchSchema.is(serializedManifest)) { - errors.push(new Error(`Invalid manifest for policy ${packagePolicy.id}`)); - } else if (!manifestsEqual(serializedManifest, oldManifest.value)) { - newPackagePolicy.inputs[0].config.artifact_manifest = { value: serializedManifest }; - - try { - await this.packagePolicyService.update( - this.savedObjectsClient, - // @ts-ignore - undefined, - id, - newPackagePolicy - ); + await iterateAllListItems( + (page) => this.listEndpointPolicies(page), + async (packagePolicy) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { id, revision, updated_at, updated_by, ...newPackagePolicy } = packagePolicy; + if (newPackagePolicy.inputs.length > 0 && newPackagePolicy.inputs[0].config !== undefined) { + const oldManifest = newPackagePolicy.inputs[0].config.artifact_manifest ?? { + value: {}, + }; + + const newManifestVersion = manifest.getSemanticVersion(); + if (semver.gt(newManifestVersion, oldManifest.value.manifest_version)) { + const serializedManifest = manifest.toPackagePolicyManifest(packagePolicy.id); + + if (!manifestDispatchSchema.is(serializedManifest)) { + errors.push(new Error(`Invalid manifest for policy ${packagePolicy.id}`)); + } else if (!manifestsEqual(serializedManifest, oldManifest.value)) { + newPackagePolicy.inputs[0].config.artifact_manifest = { value: serializedManifest }; + + try { + await this.packagePolicyService.update( + this.savedObjectsClient, + // @ts-ignore + undefined, + id, + newPackagePolicy + ); + this.logger.debug( + `Updated package policy ${id} with manifest version ${manifest.getSemanticVersion()}` + ); + } catch (err) { + errors.push(err); + } + } else { this.logger.debug( - `Updated package policy ${id} with manifest version ${manifest.getSemanticVersion()}` + `No change in manifest content for package policy: ${id}. Staying on old version` ); - } catch (err) { - errors.push(err); } } else { - this.logger.debug( - `No change in manifest content for package policy: ${id}. Staying on old version` - ); + this.logger.debug(`No change in manifest version for package policy: ${id}`); } } else { - this.logger.debug(`No change in manifest version for package policy: ${id}`); + errors.push(new Error(`Package Policy ${id} has no config.`)); } - } else { - errors.push(new Error(`Package Policy ${id} has no config.`)); } - }); + ); return errors; } @@ -363,23 +424,19 @@ export class ManifestManager { this.logger.info(`Committed manifest ${manifest.getSemanticVersion()}`); } - private async forEachPolicy(callback: (policy: PackagePolicy) => Promise) { - let paging = true; - let page = 1; - - while (paging) { - const { items, total } = await this.packagePolicyService.list(this.savedObjectsClient, { - page, - perPage: 20, - kuery: 'ingest-package-policies.package.name:endpoint', - }); - - for (const packagePolicy of items) { - await callback(packagePolicy); - } + private async listEndpointPolicies(page: number) { + return this.packagePolicyService.list(this.savedObjectsClient, { + page, + perPage: 20, + kuery: 'ingest-package-policies.package.name:endpoint', + }); + } - paging = (page - 1) * 20 + items.length < total; - page++; - } + private async listEndpointPolicyIds(page: number) { + return this.packagePolicyService.listIds(this.savedObjectsClient, { + page, + perPage: 20, + kuery: 'ingest-package-policies.package.name:endpoint', + }); } } From 3a003d9b79c31a516c1dc33d113c829a379110dc Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 16 Feb 2021 15:02:47 -0500 Subject: [PATCH 095/175] [ML] Anomaly Detection: when no anomalies present for time range show no results message (#91151) * single metric viewer callout color to blue.show empty results in explorer. * update snapshot for empty results view * check if selected job still running * update resultsWithAnomalies check * update no overall data message. remove unnecessary component prop --- .../anomaly_detection_jobs/summary_job.ts | 1 + .../explorer_no_results_found.test.js.snap | 10 +-- .../explorer_no_results_found.js | 68 ++++++++++++------- .../explorer/components/no_overall_data.tsx | 2 +- .../public/application/explorer/explorer.js | 13 +++- .../application/routing/routes/explorer.tsx | 4 ++ .../timeseriesexplorer/timeseriesexplorer.js | 1 - 7 files changed, 62 insertions(+), 37 deletions(-) diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts index bb6b331c10fc18..09f5c37ac9aeaf 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts @@ -49,6 +49,7 @@ export type MlSummaryJobs = MlSummaryJob[]; export interface MlJobWithTimeRange extends CombinedJobWithStats { id: string; + isRunning?: boolean; isNotSingleMetricViewerJobMessage?: string; timeRange: { from: number; diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap index dc7e567380fdf9..388e2f590edf28 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap @@ -14,14 +14,6 @@ exports[`ExplorerNoInfluencersFound snapshot 1`] = ` } iconType="iInCircle" - title={ -

- -

- } + title={

} /> `; diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js index 6e058a8fc8c610..799437e1799f00 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js @@ -14,26 +14,48 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiEmptyPrompt } from '@elastic/eui'; -export const ExplorerNoResultsFound = () => ( - - -

- } - body={ - -

- -

-
- } - /> -); +export const ExplorerNoResultsFound = ({ hasResults, selectedJobsRunning }) => { + const resultsHaveNoAnomalies = hasResults === true; + const noResults = hasResults === false; + return ( + + {resultsHaveNoAnomalies && ( + + )} + {noResults && ( + + )} + + } + body={ + + {selectedJobsRunning && noResults && ( +

+ +

+ )} + {!selectedJobsRunning && ( +

+ +

+ )} +
+ } + /> + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx b/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx index fe77fdf235b58d..65935050ee218a 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx +++ b/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx @@ -12,7 +12,7 @@ export const NoOverallData: FC = () => { return ( ); }; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 9f77260ab3320f..abf8197f51634d 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -142,6 +142,7 @@ export class Explorer extends React.Component { setSelectedCells: PropTypes.func.isRequired, severity: PropTypes.number.isRequired, showCharts: PropTypes.bool.isRequired, + selectedJobsRunning: PropTypes.bool.isRequired, }; state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG }; @@ -223,7 +224,7 @@ export class Explorer extends React.Component { updateLanguage = (language) => this.setState({ language }); render() { - const { showCharts, severity, stoppedPartitions } = this.props; + const { showCharts, severity, stoppedPartitions, selectedJobsRunning } = this.props; const { annotations, @@ -248,6 +249,9 @@ export class Explorer extends React.Component { const noJobsFound = selectedJobs === null || selectedJobs.length === 0; const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0; + const hasResultsWithAnomalies = + (hasResults && overallSwimlaneData.points.some((v) => v.value > 0)) || + tableData.anomalies?.length > 0; if (noJobsFound && !loading) { return ( @@ -257,10 +261,13 @@ export class Explorer extends React.Component { ); } - if (noJobsFound && hasResults === false && !loading) { + if (hasResultsWithAnomalies === false && !loading) { return ( - + ); } diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 052be41ca1eb70..e65ca22effd768 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -87,6 +87,9 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const { jobIds } = useJobSelection(jobsWithTimeRange); + const selectedJobsRunning = jobsWithTimeRange.some( + (job) => jobIds.includes(job.id) && job.isRunning === true + ); const explorerAppState = useObservable(explorerService.appState$); const explorerState = useObservable(explorerService.state$); @@ -261,6 +264,7 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim severity: tableSeverity.val, stoppedPartitions, invalidTimeRangeError, + selectedJobsRunning, }} />
diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 33e5183fa79493..06a0f7e17e1649 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -1000,7 +1000,6 @@ export class TimeSeriesExplorer extends React.Component { }} /> } - color="warning" iconType="help" size="s" /> From 8126488021b2efd674ea1ddec3c99c24029879f5 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Tue, 16 Feb 2021 14:06:25 -0600 Subject: [PATCH 096/175] Upgrade EUI to v31.7.0 (#91210) * eui to 31.6.0 * flyout, collapsible snapshot updates * initial overlaymask removal * undo jest * overlaymask src snapshot updates * more overlaymask removals * overlaymask removal xpack test updates * saved objects modal form * eui to 31.7.0 * code, codeblock types * snapshot update * tooltip * remove ownFocus from ConfirmModal * remove fragments --- package.json | 2 +- .../collapsible_nav.test.tsx.snap | 18 + .../header/__snapshots__/header.test.tsx.snap | 3 + .../flyout_service.test.tsx.snap | 4 +- .../__snapshots__/modal_service.test.tsx.snap | 248 +++--- .../public/overlays/modal/modal_service.tsx | 22 +- .../application/components/settings_modal.tsx | 181 +++-- .../__snapshots__/clone_modal.test.js.snap | 112 ++- .../application/top_nav/clone_modal.tsx | 117 ++- .../ui/saved_query_form/save_query_form.tsx | 63 +- .../saved_query_list_item.tsx | 60 +- .../confirmation_modal.test.tsx.snap | 18 +- .../confirmation_modal/confirmation_modal.tsx | 20 +- .../confirmation_modal.test.tsx.snap | 62 +- .../confirmation_modal/confirmation_modal.tsx | 58 +- .../components/field_editor/field_editor.tsx | 71 +- .../table_list_view/table_list_view.tsx | 71 +- .../saved_object_save_modal.test.tsx.snap | 704 +++++++++--------- .../save_modal/saved_object_save_modal.tsx | 85 +-- .../components/delete_confirm_modal.tsx | 166 ++--- .../objects_table/components/export_modal.tsx | 143 ++-- .../components/overwrite_modal.tsx | 54 +- .../public/wizard/new_vis_modal.tsx | 4 +- .../List/ConfirmDeleteModal.tsx | 68 +- .../table/controls/action_control.tsx | 62 +- .../public/pages/overview/enrolled_beats.tsx | 101 ++- .../asset_manager/asset_manager.component.tsx | 127 ++-- .../confirm_modal/confirm_modal.tsx | 30 +- .../datasource_preview/datasource_preview.js | 81 +- .../keyboard_shortcuts_doc.stories.storyshot | 2 +- .../saved_elements_modal.component.tsx | 81 +- .../components/toolbar/toolbar.component.tsx | 19 +- .../var_config.stories.storyshot | 3 + .../edit_menu/edit_menu.component.tsx | 14 +- .../__snapshots__/pdf_panel.stories.storyshot | 3 + .../auto_follow_pattern_delete_provider.js | 78 +- .../follower_index_pause_provider.js | 110 ++- .../follower_index_resume_provider.js | 130 ++-- .../follower_index_unfollow_provider.js | 96 ++- .../follower_index_edit.js | 77 +- .../components/actions/delete_button.tsx | 36 +- .../components/actions/extend_button.tsx | 40 +- .../search_experience/customization_modal.tsx | 162 ++-- .../log_retention_confirmation_modal.tsx | 6 +- .../shared/schema/schema_add_field_modal.tsx | 123 ++- .../display_settings/field_editor_modal.tsx | 81 +- .../components/source_settings.tsx | 39 +- .../views/content_sources/sources_view.tsx | 122 ++- .../components/add_group_modal.test.tsx | 3 +- .../groups/components/add_group_modal.tsx | 59 +- .../components/group_manager_modal.test.tsx | 3 +- .../groups/components/group_manager_modal.tsx | 17 +- .../groups/components/group_overview.tsx | 23 +- .../views/security/security.tsx | 23 +- .../settings/components/oauth_application.tsx | 42 +- .../settings/components/source_config.tsx | 31 +- .../components/agent_policy_copy_provider.tsx | 118 ++- .../agent_policy_delete_provider.tsx | 114 ++- .../components/agent_policy_yaml_flyout.tsx | 2 - .../components/confirm_deploy_modal.tsx | 100 ++- .../package_policy_delete_provider.tsx | 128 ++-- .../components/agent_unenroll_modal/index.tsx | 154 ++-- .../components/agent_upgrade_modal/index.tsx | 154 ++-- .../components/confirm_delete_modal.tsx | 54 +- .../settings/confirm_package_install.tsx | 80 +- .../settings/confirm_package_uninstall.tsx | 88 ++- .../add_policy_to_template_confirm_modal.tsx | 79 +- .../components/confirm_delete.tsx | 54 +- .../add_lifecycle_confirm_modal.tsx | 99 ++- .../remove_lifecycle_confirm_modal.tsx | 84 +-- .../component_template_list/delete_modal.tsx | 80 +- .../modal_confirmation_delete_fields.tsx | 98 ++- .../load_mappings/load_mappings_provider.tsx | 109 ++- .../runtime_fields/delete_field_provider.tsx | 32 +- .../constants/data_types_definition.tsx | 4 +- .../components/template_delete_modal.tsx | 166 ++--- .../delete_data_stream_confirmation_modal.tsx | 118 ++- .../index_actions_context_menu.js | 483 ++++++------ .../components/saved_views/create_modal.tsx | 119 ++- .../components/saved_views/update_modal.tsx | 119 ++- .../saved_views/view_list_modal.tsx | 98 ++- .../logs/stream/page_view_log_in_context.tsx | 48 +- .../load_from_json/modal_provider.tsx | 108 ++- .../processor_form/processors/csv.tsx | 4 +- .../processor_form/processors/date.tsx | 6 +- .../processors/date_index_name.tsx | 8 +- .../processor_form/processors/dissect.tsx | 2 +- .../processor_form/processors/geoip.tsx | 4 +- .../processor_form/processors/inference.tsx | 2 +- .../processor_form/processors/user_agent.tsx | 2 +- .../components/processor_remove_modal.tsx | 68 +- .../shared/map_processor_type_to_form.tsx | 2 +- .../tab_documents/reset_documents_modal.tsx | 26 +- .../sections/pipelines_list/delete_modal.tsx | 80 +- .../upload_license.test.tsx.snap | 230 +++--- .../revert_to_basic/revert_to_basic.js | 78 +- .../start_trial/start_trial.tsx | 277 ++++--- .../sections/upload_license/upload_license.js | 69 +- ...confirm_delete_pipeline_modal.test.js.snap | 70 +- .../confirm_delete_pipeline_modal.js | 58 +- .../confirm_delete_modal.test.js.snap | 162 ++-- .../pipeline_list/confirm_delete_modal.js | 36 +- .../layer_control/layer_toc/toc_entry/view.js | 28 +- .../delete_annotation_modal/index.tsx | 54 +- .../delete_job_check_modal.tsx | 119 ++- .../close_job_confirm/close_job_confirm.tsx | 93 ++- .../edit_model_snapshot_flyout.tsx | 33 +- .../revert_model_snapshot_flyout.tsx | 55 +- .../delete_rule_modal.test.js.snap | 56 +- .../select_rule_action/delete_rule_modal.js | 52 +- .../validate_job/validate_job_view.js | 32 +- .../action_delete/delete_action_modal.tsx | 120 ++- .../action_start/start_action_modal.tsx | 60 +- .../action_stop/stop_action_modal.tsx | 62 +- .../models_management/delete_models_modal.tsx | 91 ++- .../source_selection/source_selection.tsx | 120 ++- .../explorer/add_to_dashboard_control.tsx | 181 +++-- .../delete_job_modal/delete_job_modal.tsx | 125 ++-- .../edit_job_flyout/edit_job_flyout.js | 63 +- .../edit_job_flyout/tabs/custom_urls.tsx | 43 +- .../start_datafeed_modal.js | 139 ++-- .../components/reset_query/reset_query.tsx | 60 +- .../advanced_detector_modal/modal_wrapper.tsx | 71 +- .../settings/calendars/edit/new_calendar.js | 18 +- .../settings/calendars/list/calendars_list.js | 61 +- .../delete_filter_list_modal.test.js.snap | 68 +- .../delete_filter_list_modal.js | 46 +- .../components/forecasting_modal/modal.js | 73 +- .../remove_cluster_button_provider.js | 6 +- .../report_info_button.test.tsx.snap | 8 +- .../buttons/report_delete_button.tsx | 26 +- .../confirm_delete_modal.js | 44 +- .../public/components/confirm_modal.tsx | 88 +-- .../invalidate_provider.tsx | 102 ++- .../delete_provider/delete_provider.tsx | 104 ++- .../rule_editor_panel/rule_editor_panel.tsx | 67 +- .../rule_editor_panel/rule_group_title.tsx | 77 +- .../roles/edit_role/delete_role_button.tsx | 76 +- .../confirm_delete/confirm_delete.tsx | 109 ++- .../confirm_delete_users.tsx | 80 +- .../users/edit_user/confirm_delete_users.tsx | 1 - .../users/edit_user/confirm_disable_users.tsx | 1 - .../users/edit_user/confirm_enable_users.tsx | 1 - .../cypress/tasks/timeline.ts | 2 +- .../components/confirm_delete_case/index.tsx | 28 +- .../use_all_cases_modal/all_cases_modal.tsx | 26 +- .../create_case_modal.tsx | 36 +- .../exceptions/add_exception_modal/index.tsx | 241 +++--- .../exceptions/edit_exception_modal/index.tsx | 233 +++--- .../__snapshots__/index.test.tsx.snap | 116 ++- .../components/import_data_modal/index.tsx | 87 ++- .../common/components/inspect/modal.tsx | 37 +- .../__snapshots__/index.test.tsx.snap | 8 + .../modal_all_errors.test.tsx.snap | 90 ++- .../components/toasters/modal_all_errors.tsx | 55 +- .../value_lists_management_modal/modal.tsx | 5 +- .../reference_error_modal.tsx | 44 +- .../rules/all/rules_tables.tsx | 23 +- .../pages/policy/view/policy_details.tsx | 102 ++- .../view/trusted_app_deletion_dialog.tsx | 53 +- .../delete_timeline_modal/index.tsx | 20 +- .../open_timeline_modal/index.tsx | 32 +- .../__snapshots__/index.test.tsx.snap | 6 +- .../timeline/header/title_and_description.tsx | 146 ++-- .../components/policy_delete_provider.tsx | 94 ++- .../components/policy_execute_provider.tsx | 52 +- .../components/repository_delete_provider.tsx | 130 ++-- .../retention_execute_modal_provider.tsx | 50 +- .../retention_update_modal_provider.tsx | 295 ++++---- .../components/snapshot_delete_provider.tsx | 159 ++-- .../repository_details/repository_details.tsx | 4 +- .../confirm_delete_modal.test.tsx.snap | 164 ++-- .../confirm_delete_modal.tsx | 151 ++-- ...irm_alter_active_space_modal.test.tsx.snap | 46 +- .../confirm_alter_active_space_modal.tsx | 56 +- .../components/switch_modal/switch_modal.tsx | 30 +- .../action_delete/delete_action_modal.tsx | 33 +- .../action_start/start_action_modal.tsx | 42 +- .../transform_management_section.tsx | 17 +- .../components/delete_modal_confirmation.tsx | 98 ++- .../connector_add_modal.tsx | 172 ++--- .../alert_form/confirm_alert_close.tsx | 66 +- .../alert_form/confirm_alert_save.tsx | 66 +- .../components/manage_license_modal.tsx | 64 +- .../__snapshots__/donut_chart.test.tsx.snap | 8 + .../confirm_delete.test.tsx.snap | 96 ++- .../ml/__snapshots__/ml_flyout.test.tsx.snap | 2 +- .../monitor/ml/confirm_alert_delete.tsx | 38 +- .../components/monitor/ml/confirm_delete.tsx | 78 +- .../components/confirm_watches_modal.tsx | 48 +- .../components/delete_watches_modal.tsx | 84 +-- yarn.lock | 8 +- 192 files changed, 6996 insertions(+), 7510 deletions(-) diff --git a/package.json b/package.json index 67f1f019e73a3e..33d8f6a9c52806 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@elastic/datemath": "link:packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary", "@elastic/ems-client": "7.12.0", - "@elastic/eui": "31.4.0", + "@elastic/eui": "31.7.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/node-crypto": "1.2.1", diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 80e23a32ca5570..575a247ffeccb5 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -715,8 +715,11 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
Flyout content
"`; +exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
Flyout content
"`; exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 1`] = ` Array [ @@ -59,4 +59,4 @@ Array [ ] `; -exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
Flyout content 2
"`; +exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
Flyout content 2
"`; diff --git a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap index 7e79725c20307b..d52cc090d5d195 100644 --- a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap +++ b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap @@ -11,21 +11,19 @@ Array [ exports[`ModalService openConfirm() renders a mountpoint confirm message 1`] = ` Array [ Array [ - - - - - - - , + + + + + ,
, ], ] @@ -36,18 +34,16 @@ exports[`ModalService openConfirm() renders a mountpoint confirm message 2`] = ` exports[`ModalService openConfirm() renders a string confirm message 1`] = ` Array [ Array [ - - - - Some message - - - , + + + Some message + + ,
, ], ] @@ -58,33 +54,29 @@ exports[`ModalService openConfirm() renders a string confirm message 2`] = `" - - - confirm 1 - - - , + + + confirm 1 + + ,
, ], Array [ - - - - some confirm - - - , + + + some confirm + + ,
, ], ] @@ -93,33 +85,29 @@ Array [ exports[`ModalService openConfirm() with a currently active modal replaces the current modal with the new confirm 1`] = ` Array [ Array [ - - - - - - - , + + + + + ,
, ], Array [ - - - - some confirm - - - , + + + some confirm + + ,
, ], ] @@ -128,18 +116,16 @@ Array [ exports[`ModalService openModal() renders a modal to the DOM 1`] = ` Array [ Array [ - - - - - - - , + + + + + ,
, ], ] @@ -150,33 +136,29 @@ exports[`ModalService openModal() renders a modal to the DOM 2`] = `"
- - - confirm 1 - - - , + + + confirm 1 + + ,
, ], Array [ - - - - some confirm - - - , + + + some confirm + + ,
, ], ] @@ -185,33 +167,29 @@ Array [ exports[`ModalService openModal() with a currently active modal replaces the current modal with a new one 1`] = ` Array [ Array [ - - - - - - - , + + + + + ,
, ], Array [ - - - - - - - , + + + + + ,
, ], ] diff --git a/src/core/public/overlays/modal/modal_service.tsx b/src/core/public/overlays/modal/modal_service.tsx index 1f96e00fef0f89..7e4aee94c958ec 100644 --- a/src/core/public/overlays/modal/modal_service.tsx +++ b/src/core/public/overlays/modal/modal_service.tsx @@ -9,7 +9,7 @@ /* eslint-disable max-classes-per-file */ import { i18n as t } from '@kbn/i18n'; -import { EuiModal, EuiConfirmModal, EuiOverlayMask, EuiConfirmModalProps } from '@elastic/eui'; +import { EuiModal, EuiConfirmModal, EuiConfirmModalProps } from '@elastic/eui'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; @@ -137,13 +137,11 @@ export class ModalService { this.activeModal = modal; render( - - - modal.close()}> - - - - , + + modal.close()}> + + + , targetDomElement ); @@ -199,11 +197,9 @@ export class ModalService { }; render( - - - - - , + + + , targetDomElement ); }); diff --git a/src/plugins/console/public/application/components/settings_modal.tsx b/src/plugins/console/public/application/components/settings_modal.tsx index f3c8954d01254b..161b67500b47c7 100644 --- a/src/plugins/console/public/application/components/settings_modal.tsx +++ b/src/plugins/console/public/application/components/settings_modal.tsx @@ -22,7 +22,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSwitch, } from '@elastic/eui'; @@ -151,115 +150,107 @@ export function DevToolsSettingsModal(props: Props) { ) : undefined; return ( - - - - - - - + + + + + + + + + + } + > + { + const val = parseInt(e.target.value, 10); + if (!val) return; + setFontSize(val); + }} + /> + - - + } - > - { - const val = parseInt(e.target.value, 10); - if (!val) return; - setFontSize(val); - }} - /> - + onChange={(e) => setWrapMode(e.target.checked)} + /> + - - - } - onChange={(e) => setWrapMode(e.target.checked)} + - - - + } - > - - } - onChange={(e) => setTripleQuotes(e.target.checked)} - /> - + onChange={(e) => setTripleQuotes(e.target.checked)} + /> + - - } - > - { - const { stateSetter, ...rest } = opts; - return rest; - })} - idToSelectedMap={checkboxIdToSelectedMap} - onChange={(e: any) => { - onAutocompleteChange(e as AutocompleteOptions); - }} + - + } + > + { + const { stateSetter, ...rest } = opts; + return rest; + })} + idToSelectedMap={checkboxIdToSelectedMap} + onChange={(e: any) => { + onAutocompleteChange(e as AutocompleteOptions); + }} + /> + - {pollingFields} - + {pollingFields} + - - - - + + + + - - - - - - + + + + + ); } diff --git a/src/plugins/dashboard/public/application/top_nav/__snapshots__/clone_modal.test.js.snap b/src/plugins/dashboard/public/application/top_nav/__snapshots__/clone_modal.test.js.snap index d289d267a2fd6d..1e029e6960cdfa 100644 --- a/src/plugins/dashboard/public/application/top_nav/__snapshots__/clone_modal.test.js.snap +++ b/src/plugins/dashboard/public/application/top_nav/__snapshots__/clone_modal.test.js.snap @@ -1,65 +1,63 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders DashboardCloneModal 1`] = ` - - - - - - - - - -

- -

-
- - + + + -
- - - - - + + + + +

- - - - +

+
+ + +
+ + + + + + + + +
`; diff --git a/src/plugins/dashboard/public/application/top_nav/clone_modal.tsx b/src/plugins/dashboard/public/application/top_nav/clone_modal.tsx index c1bcad51babf9f..3af186f841a5d0 100644 --- a/src/plugins/dashboard/public/application/top_nav/clone_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/clone_modal.tsx @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, EuiText, EuiCallOut, @@ -138,69 +137,67 @@ export class DashboardCloneModal extends React.Component { render() { return ( - - - - - - - - - - -

- -

-
- - - - + + + + + - {this.renderDuplicateTitleCallout()} -
- - - + + +

- - - - - - - - +

+
+ + + + + + {this.renderDuplicateTitleCallout()} +
+ + + + + + + + + + +
); } } diff --git a/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx b/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx index 7873886432cbec..077b9ac47286d9 100644 --- a/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx +++ b/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx @@ -9,7 +9,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { EuiButtonEmpty, - EuiOverlayMask, EuiModal, EuiButton, EuiModalHeader, @@ -208,37 +207,35 @@ export function SaveQueryForm({ ); return ( - - - - - {i18n.translate('data.search.searchBar.savedQueryFormTitle', { - defaultMessage: 'Save query', - })} - - - - {saveQueryForm} - - - - {i18n.translate('data.search.searchBar.savedQueryFormCancelButtonText', { - defaultMessage: 'Cancel', - })} - - - - {i18n.translate('data.search.searchBar.savedQueryFormSaveButtonText', { - defaultMessage: 'Save', - })} - - - - + + + + {i18n.translate('data.search.searchBar.savedQueryFormTitle', { + defaultMessage: 'Save query', + })} + + + + {saveQueryForm} + + + + {i18n.translate('data.search.searchBar.savedQueryFormCancelButtonText', { + defaultMessage: 'Cancel', + })} + + + + {i18n.translate('data.search.searchBar.savedQueryFormSaveButtonText', { + defaultMessage: 'Save', + })} + + + ); } diff --git a/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx b/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx index 47a2d050a9bfa3..b7ba3215eb5aa3 100644 --- a/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx +++ b/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiListGroupItem, EuiConfirmModal, EuiOverlayMask, EuiIconTip } from '@elastic/eui'; +import { EuiListGroupItem, EuiConfirmModal, EuiIconTip } from '@elastic/eui'; import React, { Fragment, useState } from 'react'; import classNames from 'classnames'; @@ -114,36 +114,34 @@ export const SavedQueryListItem = ({ /> {showDeletionConfirmationModal && ( - - { - onDelete(savedQuery); - setShowDeletionConfirmationModal(false); - }} - buttonColor="danger" - onCancel={() => { - setShowDeletionConfirmationModal(false); - }} - /> - + { + onDelete(savedQuery); + setShowDeletionConfirmationModal(false); + }} + buttonColor="danger" + onCancel={() => { + setShowDeletionConfirmationModal(false); + }} + /> )} ); diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap index 2b320782cb1634..eaaccdb499b0b4 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap @@ -1,14 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DeleteScritpedFieldConfirmationModal should render normally 1`] = ` - - - + `; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx index 5fbd3118b800bb..36069f408f3543 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal } from '@elastic/eui'; import { ScriptedFieldItem } from '../../types'; @@ -42,15 +42,13 @@ export const DeleteScritpedFieldConfirmationModal = ({ ); return ( - - - + ); }; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap b/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap index 9d92a3689b6983..736dbb611dbbdf 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap @@ -1,37 +1,35 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Header should render normally 1`] = ` - - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - onCancel={[Function]} - onConfirm={[Function]} - title={ - + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + onCancel={[Function]} + onConfirm={[Function]} + title={ + - } - /> - + } + /> + } +/> `; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx index 6715d7a6780ae4..fb8d4a38bfe63e 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx @@ -10,7 +10,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiOverlayMask, EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; interface DeleteFilterConfirmationModalProps { filterToDeleteValue: string; @@ -26,35 +26,33 @@ export const DeleteFilterConfirmationModal = ({ onDeleteFilter, }: DeleteFilterConfirmationModalProps) => { return ( - - - } - onCancel={onCancelConfirmationModal} - onConfirm={onDeleteFilter} - cancelButtonText={ - - } - buttonColor="danger" - confirmButtonText={ - - } - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - /> - + + } + onCancel={onCancelConfirmationModal} + onConfirm={onDeleteFilter} + cancelButtonText={ + + } + buttonColor="danger" + confirmButtonText={ + + } + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + /> ); }; diff --git a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx index f22981de857497..829536063a26c9 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx @@ -25,7 +25,6 @@ import { EuiFormRow, EuiIcon, EuiLink, - EuiOverlayMask, EuiSelect, EuiSpacer, EuiText, @@ -643,42 +642,40 @@ export class FieldEditor extends PureComponent - { - this.hideDeleteModal(); - this.deleteField(); - }} - cancelButtonText={i18n.translate('indexPatternManagement.deleteField.cancelButton', { - defaultMessage: 'Cancel', - })} - confirmButtonText={i18n.translate('indexPatternManagement.deleteField.deleteButton', { - defaultMessage: 'Delete', - })} - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - > -

- -
-
- - ), - }} - /> -

-
-
+ { + this.hideDeleteModal(); + this.deleteField(); + }} + cancelButtonText={i18n.translate('indexPatternManagement.deleteField.cancelButton', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('indexPatternManagement.deleteField.deleteButton', { + defaultMessage: 'Delete', + })} + buttonColor="danger" + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + > +

+ +
+
+ + ), + }} + /> +

+
) : null; }; diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index 7b3322f5f6c2d0..fa0a32fc3d542a 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -21,7 +21,6 @@ import { EuiFlexItem, EuiButton, EuiSpacer, - EuiOverlayMask, EuiConfirmModal, EuiCallOut, EuiBasicTableColumn, @@ -238,42 +237,40 @@ class TableListView extends React.Component - - } - buttonColor="danger" - onCancel={this.closeDeleteModal} - onConfirm={this.deleteSelectedItems} - cancelButtonText={ - - } - confirmButtonText={deleteButton} - defaultFocusedButton="cancel" - > -

- -

-
-
+ + } + buttonColor="danger" + onCancel={this.closeDeleteModal} + onConfirm={this.deleteSelectedItems} + cancelButtonText={ + + } + confirmButtonText={deleteButton} + defaultFocusedButton="cancel" + > +

+ +

+
); } diff --git a/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap b/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap index f88039fbda9bad..1f05ed6b944051 100644 --- a/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap +++ b/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap @@ -1,407 +1,399 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SavedObjectSaveModal should render matching snapshot 1`] = ` - +
- - - - - - - - - + + + - + + + + + - - + - } - labelType="label" - > - + + - - - - - - - - - Save - - - + + + + + + + + + Save + +
-
+ `; exports[`SavedObjectSaveModal should render matching snapshot when custom isValid is set 1`] = ` - +
- - - - + + - - - - - - } - labelType="label" - > - + + + + + - - + - } - labelType="label" - > - + + - - - - - - - - - Save - - - + + + + + + + + + Save + +
-
+ `; exports[`SavedObjectSaveModal should render matching snapshot when custom isValid is set 2`] = ` - +
- - - - + + - - - - - - } - labelType="label" - > - + + + + + - - + - } - labelType="label" - > - + + - - - - - - - - - Save - - - + + + + + + + + + Save + +
-
+ `; exports[`SavedObjectSaveModal should render matching snapshot when given options 1`] = ` - +
- - - - + + - - - - - - - - } - labelType="label" - > - + + + + + + + - - + - } - labelType="label" - > - + + - -
- Hello! Main options -
-
- -
- Hey there! Options on the right -
-
-
-
-
- - - - - - Save - - -
+ } + labelType="label" + > + + +
+ Hello! Main options +
+ + +
+ Hey there! Options on the right +
+
+ + + + + + + + + Save + + -
+ `; diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 39c87c9da60c2b..e476d62a0e793b 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -21,7 +21,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, EuiSwitch, EuiSwitchEvent, @@ -123,52 +122,48 @@ export class SavedObjectSaveModal extends React.Component ); return ( - +
- - - - - - - - - {this.renderDuplicateTitleCallout(duplicateWarningId)} - - - {!this.props.showDescription && this.props.description && ( - - {this.props.description} - - )} - {formBody} - {this.renderCopyOnSave()} - - - - - - - - - {this.renderConfirmButton()} - - + + + + + + + + {this.renderDuplicateTitleCallout(duplicateWarningId)} + + + {!this.props.showDescription && this.props.description && ( + + {this.props.description} + + )} + {formBody} + {this.renderCopyOnSave()} + + + + + + + + + {this.renderConfirmButton()} +
-
+ ); } diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx index f67e7bd0b568cd..f6f00c95d9bf19 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx @@ -53,90 +53,88 @@ export const DeleteConfirmModal: FC = ({ // can't use `EuiConfirmModal` here as the confirm modal body is wrapped // inside a `

` element, causing UI glitches with the table. return ( - - - - - - - - -

- -

- - ( - - - - ), - }, - { - field: 'id', - name: i18n.translate( - 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName', - { defaultMessage: 'Id' } - ), - }, - { - field: 'meta.title', - name: i18n.translate( - 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName', - { defaultMessage: 'Title' } - ), - }, - ]} - pagination={true} - sorting={false} + + + + - - - - - - - - - - - - - - - - - - - - - + + + +

+ +

+ + ( + + + + ), + }, + { + field: 'id', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName', + { defaultMessage: 'Id' } + ), + }, + { + field: 'meta.title', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName', + { defaultMessage: 'Title' } + ), + }, + ]} + pagination={true} + sorting={false} + /> +
+ + + + + + + + + + + + + + + + + + + ); }; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/export_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/export_modal.tsx index 693fe00ffedccb..0699f77f575219 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/export_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/export_modal.tsx @@ -8,7 +8,6 @@ import React, { FC } from 'react'; import { - EuiOverlayMask, EuiModal, EuiModalHeader, EuiModalHeaderTitle, @@ -47,80 +46,78 @@ export const ExportModal: FC = ({ onIncludeReferenceChange, }) => { return ( - - - - + + + + + + + + - - - - - } - labelType="legend" - > - { - onSelectedOptionsChange({ - ...selectedOptions, - ...{ - [optionId]: !selectedOptions[optionId], - }, - }); - }} + id="savedObjectsManagement.objectsTable.exportObjectsConfirmModalDescription" + defaultMessage="Select which types to export" /> - - - - } - checked={includeReferences} - onChange={() => onIncludeReferenceChange(!includeReferences)} + } + labelType="legend" + > + { + onSelectedOptionsChange({ + ...selectedOptions, + ...{ + [optionId]: !selectedOptions[optionId], + }, + }); + }} /> - - - - - - - - - - - - - - - - - - - - - + + + + } + checked={includeReferences} + onChange={() => onIncludeReferenceChange(!includeReferences)} + /> + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx index 66753e81ccd3ff..cfe0b2be1d3c05 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx @@ -7,13 +7,7 @@ */ import React, { useState, Fragment, ReactNode } from 'react'; -import { - EuiOverlayMask, - EuiConfirmModal, - EUI_MODAL_CONFIRM_BUTTON, - EuiText, - EuiSuperSelect, -} from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON, EuiText, EuiSuperSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { FailedImportConflict } from '../../../lib/resolve_import_errors'; @@ -98,29 +92,27 @@ export const OverwriteModal = ({ conflict, onFinish }: OverwriteModalProps) => { } ); return ( - - onFinish(false)} - onConfirm={() => onFinish(true, destinationId)} - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - maxWidth="500px" - > -

{bodyText}

- {selectControl} -
-
+ onFinish(false)} + onConfirm={() => onFinish(true, destinationId)} + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + maxWidth="500px" + > +

{bodyText}

+ {selectControl} +
); }; diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx index ff352003609a99..d36b734f75be2e 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx @@ -8,7 +8,7 @@ import React from 'react'; -import { EuiModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; @@ -121,7 +121,7 @@ class NewVisModal extends React.Component ); - return {selectionModal}; + return selectionModal; } private onCloseModal = () => { diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx index 6554f48ea3c2b3..081a3dbc907c5a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx @@ -6,7 +6,7 @@ */ import React, { useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { NotificationsStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { getOptionLabel } from '../../../../../../common/agent_configuration/all_option'; @@ -29,41 +29,39 @@ export function ConfirmDeleteModal({ config, onCancel, onConfirm }: Props) { const { toasts } = useApmPluginContext().core.notifications; return ( - - { + setIsDeleting(true); + await deleteConfig(config, toasts); + setIsDeleting(false); + onConfirm(); + }} + cancelButtonText={i18n.translate( + 'xpack.apm.agentConfig.deleteModal.cancel', + { defaultMessage: `Cancel` } + )} + confirmButtonText={i18n.translate( + 'xpack.apm.agentConfig.deleteModal.confirm', + { defaultMessage: `Delete` } + )} + confirmButtonDisabled={isDeleting} + buttonColor="danger" + defaultFocusedButton="confirm" + > +

+ {i18n.translate('xpack.apm.agentConfig.deleteModal.text', { + defaultMessage: `You are about to delete the configuration for service "{serviceName}" and environment "{environment}".`, + values: { + serviceName: getOptionLabel(config.service.name), + environment: getOptionLabel(config.service.environment), + }, })} - onCancel={onCancel} - onConfirm={async () => { - setIsDeleting(true); - await deleteConfig(config, toasts); - setIsDeleting(false); - onConfirm(); - }} - cancelButtonText={i18n.translate( - 'xpack.apm.agentConfig.deleteModal.cancel', - { defaultMessage: `Cancel` } - )} - confirmButtonText={i18n.translate( - 'xpack.apm.agentConfig.deleteModal.confirm', - { defaultMessage: `Delete` } - )} - confirmButtonDisabled={isDeleting} - buttonColor="danger" - defaultFocusedButton="confirm" - > -

- {i18n.translate('xpack.apm.agentConfig.deleteModal.text', { - defaultMessage: `You are about to delete the configuration for service "{serviceName}" and environment "{environment}".`, - values: { - serviceName: getOptionLabel(config.service.name), - environment: getOptionLabel(config.service.environment), - }, - })} -

-
-
+

+ ); } diff --git a/x-pack/plugins/beats_management/public/components/table/controls/action_control.tsx b/x-pack/plugins/beats_management/public/components/table/controls/action_control.tsx index c498f9e06e1d36..5badef9a71fe17 100644 --- a/x-pack/plugins/beats_management/public/components/table/controls/action_control.tsx +++ b/x-pack/plugins/beats_management/public/components/table/controls/action_control.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiButton, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiButton, EuiConfirmModal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { AssignmentActionType } from '../table'; @@ -58,40 +58,38 @@ export class ActionControl extends React.PureComponent {this.state.showModal && ( - - + } + confirmButtonText={ + + } + onConfirm={() => { + actionHandler(action); + this.setState({ showModal: false }); + }} + onCancel={() => this.setState({ showModal: false })} + title={ + warningHeading ? ( + warningHeading + ) : ( - } - confirmButtonText={ - - } - onConfirm={() => { - actionHandler(action); - this.setState({ showModal: false }); - }} - onCancel={() => this.setState({ showModal: false })} - title={ - warningHeading ? ( - warningHeading - ) : ( - - ) - } - > - {warningMessage} - - + ) + } + > + {warningMessage} + )}
); diff --git a/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx b/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx index f09d34eaa6e614..0ab02430e90e61 100644 --- a/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx +++ b/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx @@ -13,7 +13,6 @@ import { EuiModalBody, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; @@ -104,58 +103,56 @@ class BeatsPageComponent extends React.PureComponent { {this.props.location.pathname === '/overview/enrolled_beats/enroll' && ( - - { - this.props.setUrlState({ - enrollmentToken: '', - }); - this.props.goTo(`/overview/enrolled_beats`); - }} - style={{ width: '640px' }} - > - - - - - - - { - const enrollmentTokens = await this.props.libs.tokens.createEnrollmentTokens(); - this.props.setUrlState({ - enrollmentToken: enrollmentTokens[0], - }); - }} - onBeatEnrolled={() => { - this.props.setUrlState({ - enrollmentToken: '', - }); - }} + { + this.props.setUrlState({ + enrollmentToken: '', + }); + this.props.goTo(`/overview/enrolled_beats`); + }} + style={{ width: '640px' }} + > + + + - {!this.props.urlState.enrollmentToken && ( - - { - this.props.goTo('/overview/enrolled_beats'); - }} - > - Done - - - )} - - - + + + + { + const enrollmentTokens = await this.props.libs.tokens.createEnrollmentTokens(); + this.props.setUrlState({ + enrollmentToken: enrollmentTokens[0], + }); + }} + onBeatEnrolled={() => { + this.props.setUrlState({ + enrollmentToken: '', + }); + }} + /> + {!this.props.urlState.enrollmentToken && ( + + { + this.props.goTo('/overview/enrolled_beats'); + }} + > + Done + + + )} + + )} ); diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx index f06c305e47beaa..7795aa9671b83d 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiPanel, EuiProgress, EuiSpacer, @@ -75,71 +74,69 @@ export const AssetManager: FC = (props) => { }; return ( - - onClose()} - className="canvasAssetManager canvasModal--fixedSize" - maxWidth="1000px" - > - - - {strings.getModalTitle()} - - - - {isLoading ? ( - - ) : ( - - )} - - - - - -

{strings.getDescription()}

-
- - {assets.length ? ( - - {assets.map((asset) => ( - - ))} - - ) : ( - emptyAssets - )} -
- - - - onClose()} + className="canvasAssetManager canvasModal--fixedSize" + maxWidth="1000px" + > + + + {strings.getModalTitle()} + + + + {isLoading ? ( + + ) : ( + - - - - {strings.getSpaceUsedText(percentageUsed)} - - - - onClose()}> - {strings.getModalCloseButtonLabel()} - - -
-
+ )} + + + + + +

{strings.getDescription()}

+
+ + {assets.length ? ( + + {assets.map((asset) => ( + + ))} + + ) : ( + emptyAssets + )} +
+ + + + + + + + {strings.getSpaceUsedText(percentageUsed)} + + + + onClose()}> + {strings.getModalCloseButtonLabel()} + + + ); }; diff --git a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx index 38be3b8559af2c..521ced0d731f2c 100644 --- a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx +++ b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import PropTypes from 'prop-types'; import React, { FunctionComponent } from 'react'; @@ -39,21 +39,19 @@ export const ConfirmModal: FunctionComponent = (props) => { } return ( - - - {message} - - + + {message} + ); }; diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js index 4a5861b41d06cc..a55f73a0874676 100644 --- a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js @@ -8,7 +8,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import { - EuiOverlayMask, EuiModal, EuiModalBody, EuiModalHeader, @@ -27,48 +26,46 @@ const { DatasourceDatasourcePreview: strings } = ComponentStrings; const { DatasourceDatasourceComponent: datasourceStrings } = ComponentStrings; export const DatasourcePreview = ({ done, datatable }) => ( - - - - {strings.getModalTitle()} - - - -

- {datasourceStrings.getSaveButtonLabel()}, - }} + + + {strings.getModalTitle()} + + + +

+ {datasourceStrings.getSaveButtonLabel()}, + }} + /> +

+
+ + {datatable.type === 'error' ? ( + + ) : ( + + {datatable.rows.length > 0 ? ( + + ) : ( + {strings.getEmptyTitle()}} + titleSize="s" + body={ +

+ {strings.getEmptyFirstLineDescription()} +
+ {strings.getEmptySecondLineDescription()} +

+ } /> -

- - - {datatable.type === 'error' ? ( - - ) : ( - - {datatable.rows.length > 0 ? ( - - ) : ( - {strings.getEmptyTitle()}} - titleSize="s" - body={ -

- {strings.getEmptyFirstLineDescription()} -
- {strings.getEmptySecondLineDescription()} -

- } - /> - )} -
- )} -
-
-
+ )} + + )} + + ); DatasourcePreview.propTypes = { diff --git a/x-pack/plugins/canvas/public/components/keyboard_shortcuts_doc/__stories__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot b/x-pack/plugins/canvas/public/components/keyboard_shortcuts_doc/__stories__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot index aea9626d7b57ad..a28986c0418a29 100644 --- a/x-pack/plugins/canvas/public/components/keyboard_shortcuts_doc/__stories__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/keyboard_shortcuts_doc/__stories__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot @@ -5,7 +5,7 @@ exports[`Storyshots components/KeyboardShortcutsDoc default 1`] = ` data-eui="EuiFocusTrap" >
diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx index e99cc60dfcaa4b..bc0039245f4322 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx @@ -23,7 +23,6 @@ import { EuiEmptyPrompt, EuiFieldSearch, EuiSpacer, - EuiOverlayMask, EuiButton, } from '@elastic/eui'; import { sortBy } from 'lodash'; @@ -117,16 +116,14 @@ export const SavedElementsModal: FunctionComponent = ({ } return ( - - - + ); }; @@ -176,40 +173,34 @@ export const SavedElementsModal: FunctionComponent = ({ return ( - - - - - {strings.getModalTitle()} - - - - - - - {customElementContent} - - - - {strings.getSavedElementsModalCloseButtonLabel()} - - - - + + + + {strings.getModalTitle()} + + + + + + + {customElementContent} + + + + {strings.getSavedElementsModalCloseButtonLabel()} + + + {renderDeleteModal()} {renderEditModal()} diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx index edf7d33eff79c4..6e5c936a113bf8 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx @@ -12,7 +12,6 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, - EuiOverlayMask, EuiModal, EuiModalFooter, EuiButton, @@ -93,16 +92,14 @@ export const Toolbar: FC = ({ const openWorkpadManager = () => setShowWorkpadManager(true); const workpadManager = ( - - - - - - {strings.getWorkpadManagerCloseButtonLabel()} - - - - + + + + + {strings.getWorkpadManagerCloseButtonLabel()} + + + ); const trays = { diff --git a/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot index bf5629596d6b67..6277c599032c1e 100644 --- a/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot @@ -77,8 +77,11 @@ exports[`Storyshots components/Variables/VarConfig default 1`] = `
= ({ )} {isModalVisible ? ( - - - + ) : null} ); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot index 4e84a9d5a0d211..010037bee4a0fd 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot @@ -150,8 +150,11 @@ exports[`Storyshots components/WorkpadHeader/ShareMenu/PDFPanel default 1`] = `
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js index b07583837636ed..034e08b5c6ab84 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js @@ -9,7 +9,7 @@ import React, { PureComponent, Fragment } from 'react'; import { connect } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { deleteAutoFollowPattern } from '../store/actions'; import { arrify } from '../../../common/services/utils'; @@ -61,45 +61,43 @@ class AutoFollowPatternDeleteProviderUi extends PureComponent { ); return ( - - {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} - - {!isSingle && ( - -

- -

-
    - {ids.map((id) => ( -
  • {id}
  • - ))} -
-
- )} -
-
+ // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events + + {!isSingle && ( + +

+ +

+
    + {ids.map((id) => ( +
  • {id}
  • + ))} +
+
+ )} +
); }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js index e84816d0d71af9..34697a80121ccf 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { pauseFollowerIndex } from '../../store/actions'; import { arrify } from '../../../../common/services/utils'; @@ -69,64 +69,62 @@ class FollowerIndexPauseProviderUi extends PureComponent { const hasCustomSettings = indices.some((index) => !areAllSettingsDefault(index)); return ( - - {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} - - {hasCustomSettings && ( -

- {isSingle ? ( - + {hasCustomSettings && ( +

+ {isSingle ? ( + - ) : ( - - )} + /> + )} +

+ )} + + {!isSingle && ( + +

+

- )} - - {!isSingle && ( - -

- -

- -
    - {indices.map((index) => ( -
  • {index.name}
  • - ))} -
-
- )} -
-
+ +
    + {indices.map((index) => ( +
  • {index.name}
  • + ))} +
+ + )} + ); }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js index 0517f841599122..91c6cb6e243acd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiLink, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal, EuiLink } from '@elastic/eui'; import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; import { routing } from '../../services/routing'; import { resumeFollowerIndex } from '../../store/actions'; @@ -68,77 +68,75 @@ class FollowerIndexResumeProviderUi extends PureComponent { ); return ( - - {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} - - {isSingle ? ( + // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events + + {isSingle ? ( +

+ + + + ), + }} + /> +

+ ) : ( +

- - - ), - }} + id="xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.multipleResumeDescriptionWithSettingWarning" + defaultMessage="Replication resumes using the default advanced settings." />

- ) : ( - -

- -

-

- -

+

+ +

-
    - {ids.map((id) => ( -
  • {id}
  • - ))} -
-
- )} -
-
+
    + {ids.map((id) => ( +
  • {id}
  • + ))} +
+ + )} + ); }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js index 9b0f0ad3111e03..72d262bcf7af32 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { unfollowLeaderIndex } from '../../store/actions'; import { arrify } from '../../../../common/services/utils'; @@ -67,58 +67,56 @@ class FollowerIndexUnfollowProviderUi extends PureComponent { ); return ( - - {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} - - {isSingle ? ( - -

- + {isSingle ? ( + +

+ -

-
- ) : ( - -

- -

-
    - {ids.map((id) => ( -
  • {id}
  • - ))} -
-
- )} -
-
+ /> +

+
    + {ids.map((id) => ( +
  • {id}
  • + ))} +
+ + )} + ); }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js index 8fb4fb27006cb9..8d6e47d4004b6e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -15,7 +15,6 @@ import { EuiConfirmModal, EuiFlexGroup, EuiFlexItem, - EuiOverlayMask, EuiPageContent, EuiSpacer, } from '@elastic/eui'; @@ -182,47 +181,45 @@ export class FollowerIndexEdit extends PureComponent { ); return ( - - - ) : ( - - ) + -

- {isPaused ? ( - - ) : ( - + ) : ( + + ) + } + > +

+ {isPaused ? ( + + ) : ( + - )} -

-
-
+ /> + )} +

+ ); }; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx index d505752ec3fad9..6a952d2f8d9d74 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useState } from 'react'; @@ -44,24 +44,22 @@ const DeleteConfirm = ({ }); return ( - - { - setIsLoading(true); - await api.sendCancel(id); - onActionComplete(); - }} - confirmButtonText={confirm} - confirmButtonDisabled={isLoading} - cancelButtonText={cancel} - defaultFocusedButton="confirm" - buttonColor="danger" - > - {message} - - + { + setIsLoading(true); + await api.sendCancel(id); + onActionComplete(); + }} + confirmButtonText={confirm} + confirmButtonDisabled={isLoading} + cancelButtonText={cancel} + defaultFocusedButton="confirm" + buttonColor="danger" + > + {message} + ); }; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx index 381c44b1bf7bef..856e7c8d434835 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useState } from 'react'; @@ -52,26 +52,24 @@ const ExtendConfirm = ({ }); return ( - - { - setIsLoading(true); - await api.sendExtend(id, `${newExpiration.toISOString()}`); - setIsLoading(false); - onConfirmDismiss(); - onActionComplete(); - }} - confirmButtonText={confirm} - confirmButtonDisabled={isLoading} - cancelButtonText={extend} - defaultFocusedButton="confirm" - buttonColor="primary" - > - {message} - - + { + setIsLoading(true); + await api.sendExtend(id, `${newExpiration.toISOString()}`); + setIsLoading(false); + onConfirmDismiss(); + onActionComplete(); + }} + confirmButtonText={confirm} + confirmButtonDisabled={isLoading} + cancelButtonText={extend} + defaultFocusedButton="confirm" + buttonColor="primary" + > + {message} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx index e05fc10053ff1c..9bc838c01f636e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx @@ -20,7 +20,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -62,97 +61,94 @@ export const CustomizationModal: React.FC = ({ ); return ( - - - - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.title', + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.title', + { + defaultMessage: 'Customize document search', + } + )} + + + + + - - - - - - - - - - - - - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.cancel', + fullWidth + helpText={i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.filterFields', { - defaultMessage: 'Cancel', + defaultMessage: + 'Faceted values rendered as filters and available as query refinement', } )} - - { - onSave({ - filterFields: selectedFilterFields.map(comboBoxOptionToFieldName), - sortFields: selectedSortFields.map(comboBoxOptionToFieldName), - }); - }} > - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.save', + + + - - - + > + + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.cancel', + { + defaultMessage: 'Cancel', + } + )} + + { + onSave({ + filterFields: selectedFilterFields.map(comboBoxOptionToFieldName), + sortFields: selectedSortFields.map(comboBoxOptionToFieldName), + }); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.save', + { + defaultMessage: 'Save', + } + )} + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx index ca1fa9a8d0737b..ba79d62cfe615a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useActions, useValues } from 'kea'; -import { EuiTextColor, EuiOverlayMask } from '@elastic/eui'; +import { EuiTextColor } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { LogRetentionLogic, LogRetentionOptions } from '../../log_retention'; @@ -40,7 +40,7 @@ export const LogRetentionConfirmationModal: React.FC = () => { } return ( - + <> {openedModal === LogRetentionOptions.Analytics && ( { onSave={() => saveLogRetention(LogRetentionOptions.API, false)} /> )} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.tsx index bbde6c5d3b55de..bd9b6b51a43b1b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.tsx @@ -20,7 +20,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSelect, EuiSpacer, } from '@elastic/eui'; @@ -79,71 +78,69 @@ export const SchemaAddFieldModal: React.FC = ({ ); return ( - +
- - - {FIELD_NAME_MODAL_TITLE} - - -

{FIELD_NAME_MODAL_DESCRIPTION}

- - - - - + {FIELD_NAME_MODAL_TITLE} + + +

{FIELD_NAME_MODAL_DESCRIPTION}

+ + + + + + - - - - - - updateNewFieldType(e.target.value)} - data-test-subj="SchemaSelect" - /> - - - - -
- - {FIELD_NAME_MODAL_CANCEL} - - {FIELD_NAME_MODAL_ADD_FIELD} - - -
+ autoFocus + isLoading={loading} + data-test-subj="SchemaAddFieldNameField" + /> + + + + + updateNewFieldType(e.target.value)} + data-test-subj="SchemaSelect" + /> + + + + + + + {FIELD_NAME_MODAL_CANCEL} + + {FIELD_NAME_MODAL_ADD_FIELD} + +
-
+ ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx index 9a6af035c1c8d8..717eebf5cf8733 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx @@ -20,7 +20,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSelect, } from '@elastic/eui'; @@ -59,48 +58,46 @@ export const FieldEditorModal: React.FC = () => { const ACTION_LABEL = isEditing ? UPDATE_LABEL : ADD_LABEL; return ( - +
- - - - {ACTION_LABEL} {FIELD_LABEL} - - - - - - setName(e.target.value)} - /> - - - setLabel(e.target.value)} - /> - - - - - {CANCEL_BUTTON} - - {ACTION_LABEL} {FIELD_LABEL} - - - + + + {ACTION_LABEL} {FIELD_LABEL} + + + + + + setName(e.target.value)} + /> + + + setLabel(e.target.value)} + /> + + + + + {CANCEL_BUTTON} + + {ACTION_LABEL} {FIELD_LABEL} + +
-
+ ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index 75a1779a1fda8e..d99f9a4cb1a463 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -15,7 +15,6 @@ import { EuiButton, EuiButtonEmpty, EuiConfirmModal, - EuiOverlayMask, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -101,26 +100,24 @@ export const SourceSettings: React.FC = () => { }; const confirmModal = ( - - - , - }} - /> - - + + , + }} + /> + ); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx index c62f0b00258d65..247df5556ada01 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -53,70 +52,65 @@ export const SourcesView: React.FC = ({ children }) => { addedSourceName: string; serviceType: string; }) => ( - - - - - - - - - - {i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sourcesView.modal.heading', - { - defaultMessage: '{addedSourceName} requires additional configuration', - values: { addedSourceName }, - } - )} - - - - - - -

- - {EXTERNAL_IDENTITIES_LINK} - - ), - }} - /> -

+ + + + + + + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.sourcesView.modal.heading', { + defaultMessage: '{addedSourceName} requires additional configuration', + values: { addedSourceName }, + })} + + + + + + +

+ + {EXTERNAL_IDENTITIES_LINK} + + ), + }} + /> +

-

- - {DOCUMENT_PERMISSIONS_LINK} - - ), - }} - /> -

-
-
- - - {UNDERSTAND_BUTTON} - - -
-
+

+ + {DOCUMENT_PERMISSIONS_LINK} + + ), + }} + /> +

+ + + + + {UNDERSTAND_BUTTON} + + + ); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx index 26ac5e484f0d77..784544b0001fa0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiModal } from '@elastic/eui'; import { AddGroupModal } from './add_group_modal'; @@ -36,7 +36,6 @@ describe('AddGroupModal', () => { const wrapper = shallow(); expect(wrapper.find(EuiModal)).toHaveLength(1); - expect(wrapper.find(EuiOverlayMask)).toHaveLength(1); }); it('updates the input value', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx index fb82e9393f2a22..2c5732b4b71573 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -49,37 +48,35 @@ export const AddGroupModal: React.FC<{}> = () => { }; return ( - - -
- - {ADD_GROUP_HEADER} - + + + + {ADD_GROUP_HEADER} + - - - setNewGroupName(e.target.value)} - /> - - + + + setNewGroupName(e.target.value)} + /> + + - - {CANCEL_BUTTON} - - {ADD_GROUP_SUBMIT} - - - -
-
+ + {CANCEL_BUTTON} + + {ADD_GROUP_SUBMIT} + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx index 949ae9d502e73a..7c39414f158eff 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiOverlayMask, EuiModal, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiModal, EuiEmptyPrompt } from '@elastic/eui'; import { GroupManagerModal } from './group_manager_modal'; @@ -46,7 +46,6 @@ describe('GroupManagerModal', () => { const wrapper = shallow(); expect(wrapper.find(EuiModal)).toHaveLength(1); - expect(wrapper.find(EuiOverlayMask)).toHaveLength(1); }); it('renders empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx index b4317ed9bd417c..1b051394dcdcf6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx @@ -21,7 +21,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -161,14 +160,12 @@ export const GroupManagerModal: React.FC = ({ ); return ( - - - {showEmptyState ? emptyState : modalContent} - - + + {showEmptyState ? emptyState : modalContent} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx index df9c0b5db9b7d1..375ac7476f9b69 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx @@ -12,7 +12,6 @@ import { useActions, useValues } from 'kea'; import { EuiButton, EuiConfirmModal, - EuiOverlayMask, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -226,18 +225,16 @@ export const GroupOverview: React.FC = () => { {confirmDeleteModalVisible && ( - - - {CONFIRM_REMOVE_DESCRIPTION} - - + + {CONFIRM_REMOVE_DESCRIPTION} + )} { ); const confirmModal = ( - - - {PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT} - - + + {PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT} + ); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx index 28e7e2a33eaa18..3f2e55d23722c2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx @@ -18,7 +18,6 @@ import { EuiSwitch, EuiCode, EuiSpacer, - EuiOverlayMask, EuiLink, EuiModal, EuiModalBody, @@ -93,25 +92,28 @@ export const OauthApplication: React.FC = () => { }; const licenseModal = ( - - - - - - - -

{LICENSE_MODAL_TITLE}

-
- - {LICENSE_MODAL_DESCRIPTION} - - - {LICENSE_MODAL_LINK} - - -
-
-
+ + + + + + +

{LICENSE_MODAL_TITLE}

+
+ + {LICENSE_MODAL_DESCRIPTION} + + + {LICENSE_MODAL_LINK} + + +
+
); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index 4ed223931d6a46..47a24e7912c3c9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useState } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Loading } from '../../../../shared/loading'; @@ -56,22 +56,19 @@ export const SourceConfig: React.FC = ({ sourceIndex }) => { header={header} /> {confirmModalVisible && ( - - deleteSourceConfig(serviceType, name)} - onCancel={hideConfirmModal} - buttonColor="danger" - > - {i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.settings.confirmRemoveConfig.message', - { - defaultMessage: - 'Are you sure you want to remove the OAuth configuration for {name}?', - values: { name }, - } - )} - - + deleteSourceConfig(serviceType, name)} + onCancel={hideConfirmModal} + buttonColor="danger" + > + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.confirmRemoveConfig.message', + { + defaultMessage: 'Are you sure you want to remove the OAuth configuration for {name}?', + values: { name }, + } + )} + )} ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx index d161dfcc5894c1..2b7ecc75195b0c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useRef, useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask, EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { EuiConfirmModal, EuiFormRow, EuiFieldText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AgentPolicy } from '../../../types'; @@ -92,75 +92,71 @@ export const AgentPolicyCopyProvider: React.FunctionComponent = ({ childr } return ( - - - - - } - onCancel={closeModal} - onConfirm={copyAgentPolicy} - cancelButtonText={ + - } - confirmButtonText={ + + } + onCancel={closeModal} + onConfirm={copyAgentPolicy} + cancelButtonText={ + + } + confirmButtonText={ + + } + confirmButtonDisabled={isLoading || !newAgentPolicy.name.trim()} + > +

+ +

+ } - confirmButtonDisabled={isLoading || !newAgentPolicy.name.trim()} + fullWidth > -

- -

- - } + - setNewAgentPolicy({ ...newAgentPolicy, name: e.target.value })} + value={newAgentPolicy.name} + onChange={(e) => setNewAgentPolicy({ ...newAgentPolicy, name: e.target.value })} + /> + + - - - } + } + fullWidth + > + - - setNewAgentPolicy({ ...newAgentPolicy, description: e.target.value }) - } - /> - -
-
+ value={newAgentPolicy.description} + onChange={(e) => setNewAgentPolicy({ ...newAgentPolicy, description: e.target.value })} + /> + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx index b03d70a78c51a3..014af7f54d020b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useRef, useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask, EuiCallOut } from '@elastic/eui'; +import { EuiConfirmModal, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; @@ -110,69 +110,67 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent = ({ chil } return ( - - - } - onCancel={closeModal} - onConfirm={deleteAgentPolicy} - cancelButtonText={ + + } + onCancel={closeModal} + onConfirm={deleteAgentPolicy} + cancelButtonText={ + + } + confirmButtonText={ + isLoading || isLoadingAgentsCount ? ( - } - confirmButtonText={ - isLoading || isLoadingAgentsCount ? ( - - ) : ( - - ) - } - buttonColor="danger" - confirmButtonDisabled={isLoading || isLoadingAgentsCount || !!agentsCount} - > - {isLoadingAgentsCount ? ( + ) : ( - ) : agentsCount ? ( - - - - ) : ( + ) + } + buttonColor="danger" + confirmButtonDisabled={isLoading || isLoadingAgentsCount || !!agentsCount} + > + {isLoadingAgentsCount ? ( + + ) : agentsCount ? ( + - )} - - + + ) : ( + + )} + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx index 63fb1f5b4b6385..9ed4bb6ff6ff4f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx @@ -52,8 +52,6 @@ export const AgentPolicyYamlFlyout = memo<{ policyId: string; onClose: () => voi {error.message} ) : ( - // Property 'whiteSpace' does not exist on type 'IntrinsicAttributes & CommonProps & OwnProps & HTMLAttributes & { children?: ReactNode; }'. - // @ts-expect-error linter complains whiteSpace isn't available but docs show it on EuiCodeBlockImpl {fullAgentPolicyToYaml(yamlData!.item)} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/confirm_deploy_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/confirm_deploy_modal.tsx index 02d7a6423edc84..f3d01e6b528cae 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/confirm_deploy_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/confirm_deploy_modal.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiCallOut, EuiOverlayMask, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { AgentPolicy } from '../../../types'; @@ -18,58 +18,56 @@ export const ConfirmDeployAgentPolicyModal: React.FunctionComponent<{ agentPolicy: AgentPolicy; }> = ({ onConfirm, onCancel, agentCount, agentPolicy }) => { return ( - - - } - onCancel={onCancel} - onConfirm={onConfirm} - cancelButtonText={ - - } - confirmButtonText={ - - } - buttonColor="primary" + + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="primary" + > + - -
- + {agentPolicy.name}, - }} - /> -
-
- - -
-
+ values={{ + policyName: {agentPolicy.name}, + }} + /> +
+ + + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx index 2ea94e88ed8c61..80952fee05bb4d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useMemo, useRef, useState } from 'react'; -import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { useStartServices, sendRequest, sendDeletePackagePolicy, useConfig } from '../../../hooks'; @@ -142,78 +142,76 @@ export const PackagePolicyDeleteProvider: React.FunctionComponent = ({ } return ( - - + } + onCancel={closeModal} + onConfirm={deletePackagePolicies} + cancelButtonText={ + + } + confirmButtonText={ + isLoading || isLoadingAgentsCount ? ( - } - onCancel={closeModal} - onConfirm={deletePackagePolicies} - cancelButtonText={ + ) : ( - } - confirmButtonText={ - isLoading || isLoadingAgentsCount ? ( - - ) : ( + ) + } + buttonColor="danger" + confirmButtonDisabled={isLoading || isLoadingAgentsCount} + > + {isLoadingAgentsCount ? ( + + ) : agentsCount ? ( + <> + + } + > {agentPolicy.name}, }} /> - ) - } - buttonColor="danger" - confirmButtonDisabled={isLoading || isLoadingAgentsCount} - > - {isLoadingAgentsCount ? ( - - ) : agentsCount ? ( - <> - - } - > - {agentPolicy.name}, - }} - /> - - - - ) : null} - {!isLoadingAgentsCount && ( - - )} - - + + + + ) : null} + {!isLoadingAgentsCount && ( + + )} + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx index 69bff78d604132..a50cc18d46f550 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiOverlayMask, EuiFormFieldset, EuiCheckbox } from '@elastic/eui'; +import { EuiConfirmModal, EuiFormFieldset, EuiCheckbox } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Agent } from '../../../../types'; import { @@ -81,90 +81,88 @@ export const AgentUnenrollAgentModal: React.FunctionComponent = ({ } return ( - - - ) : ( - - ) - } - onCancel={onClose} - onConfirm={onSubmit} - cancelButtonText={ + - } - confirmButtonDisabled={isSubmitting} - confirmButtonText={ - isSingleAgent ? ( - - ) : ( + ) : ( + + ) + } + onCancel={onClose} + onConfirm={onSubmit} + cancelButtonText={ + + } + confirmButtonDisabled={isSubmitting} + confirmButtonText={ + isSingleAgent ? ( + + ) : ( + + ) + } + buttonColor="danger" + > +

+ {isSingleAgent ? ( + + ) : ( + + )} +

+ - ) - } - buttonColor="danger" + ), + }} > -

- {isSingleAgent ? ( + - ) : ( - - )} -

- - ), - }} - > - - } - checked={forceUnenroll} - onChange={(e) => setForceUnenroll(e.target.checked)} - disabled={useForceUnenroll} - /> - -
-
+ values={{ count: agentCount }} + /> + } + checked={forceUnenroll} + onChange={(e) => setForceUnenroll(e.target.checked)} + disabled={useForceUnenroll} + /> + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx index a836e3ec3149bc..57f4007a002740 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx @@ -7,13 +7,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiConfirmModal, - EuiOverlayMask, - EuiBetaBadge, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; +import { EuiConfirmModal, EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Agent } from '../../../../types'; import { @@ -74,85 +68,83 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ } return ( - - - - {isSingleAgent ? ( + + + {isSingleAgent ? ( + + ) : ( + + )} + + + - ) : ( + } + tooltipContent={ - )} - - - - } - tooltipContent={ - - } - /> - - - } - onCancel={onClose} - onConfirm={onSubmit} - cancelButtonText={ + } + /> + + + } + onCancel={onClose} + onConfirm={onSubmit} + cancelButtonText={ + + } + confirmButtonDisabled={isSubmitting} + confirmButtonText={ + isSingleAgent ? ( - } - confirmButtonDisabled={isSubmitting} - confirmButtonText={ - isSingleAgent ? ( - - ) : ( - - ) - } - > -

- {isSingleAgent ? ( - - ) : ( - - )} -

-
-
+ ) : ( + + ) + } + > +

+ {isSingleAgent ? ( + + ) : ( + + )} +

+ ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/confirm_delete_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/confirm_delete_modal.tsx index 22e2e68e6d83e9..565657c70e17f6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/confirm_delete_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/confirm_delete_modal.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiCallOut, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal, EuiCallOut } from '@elastic/eui'; import { EnrollmentAPIKey } from '../../../../types'; interface Props { @@ -19,33 +19,31 @@ interface Props { export const ConfirmEnrollmentTokenDelete = (props: Props) => { const { onCancel, onConfirm, enrollmentKey } = props; return ( - - + - - - + color="danger" + /> + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_install.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_install.tsx index ef3ca3ce664c11..5144b2a6487862 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_install.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_install.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -18,50 +18,48 @@ interface ConfirmPackageInstallProps { export const ConfirmPackageInstall = (props: ConfirmPackageInstallProps) => { const { onCancel, onConfirm, packageName, numOfAssets } = props; return ( - - + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + > + - } - onCancel={onCancel} - onConfirm={onConfirm} - cancelButtonText={ - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - > - - } + /> + +

+ - -

- -

-
-
+

+ ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_uninstall.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_uninstall.tsx index 7688c0269d3588..2def57b0409447 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_uninstall.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_uninstall.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -18,58 +18,56 @@ interface ConfirmPackageUninstallProps { export const ConfirmPackageUninstall = (props: ConfirmPackageUninstallProps) => { const { onCancel, onConfirm, packageName, numOfAssets } = props; return ( - - + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + buttonColor="danger" + > + } - onCancel={onCancel} - onConfirm={onConfirm} - cancelButtonText={ - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - buttonColor="danger" > - - } - > -

- -

-
-

-
-
+ + +

+ +

+ ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx index f20ea0f5d1bf46..8971f18ef8e5fe 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx @@ -13,7 +13,6 @@ import { EuiComboBox, EuiForm, EuiFormRow, - EuiOverlayMask, EuiConfirmModal, EuiFieldText, EuiSpacer, @@ -257,46 +256,44 @@ export const AddPolicyToTemplateConfirmModal: React.FunctionComponent = ( ); return ( - - - -

- {' '} - - } - /> -

-
- - {renderForm()} -
-
+ />{' '} + + } + /> +

+ + + {renderForm()} + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx index 80039a18ef17a9..e42aa97a10d4f1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx @@ -8,7 +8,7 @@ import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { PolicyFromES } from '../../../../../common/types'; import { toasts } from '../../../services/notification'; @@ -50,33 +50,31 @@ export class ConfirmDelete extends Component { values: { name: policyToDelete.name }, }); return ( - - - } - confirmButtonText={ - - } - buttonColor="danger" - > -
- -
-
-
+ + } + confirmButtonText={ + + } + buttonColor="danger" + > +
+ +
+
); } } diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx index 550cefb488f667..36df4d9527a5c8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx @@ -16,7 +16,6 @@ import { EuiSelect, EuiForm, EuiFormRow, - EuiOverlayMask, EuiConfirmModal, EuiModal, EuiModalBody, @@ -246,63 +245,59 @@ export class AddLifecyclePolicyConfirmModal extends Component { ); if (!policies.length) { return ( - - - - {title} - + + + {title} + - - + + } + color="warning" + > +

+ - } - color="warning" - > -

- - - -

-
-
-
-
+ +

+ + + ); } return ( - - - } - confirmButtonText={ - - } - > - {this.renderForm()} - - + + } + confirmButtonText={ + + } + > + {this.renderForm()} + ); } } diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx index 8ce4ac052fce23..2f22a0b347db95 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx @@ -8,7 +8,7 @@ import React, { Component, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { removeLifecycleForIndex } from '../../application/services/api'; import { showApiError } from '../../application/services/api_errors'; @@ -57,54 +57,52 @@ export class RemoveLifecyclePolicyConfirmModal extends Component { const { closeModal, indexNames } = this.props; return ( - - + } + onCancel={closeModal} + onConfirm={this.removePolicy} + cancelButtonText={ + + } + buttonColor="danger" + confirmButtonText={ + + } + > + +

- } - onCancel={closeModal} - onConfirm={this.removePolicy} - cancelButtonText={ - - } - buttonColor="danger" - confirmButtonText={ - - } - > - -

- -

+

-
    - {indexNames.map((indexName) => ( -
  • {indexName}
  • - ))} -
-
-
-
+
    + {indexNames.map((indexName) => ( +
  • {indexName}
  • + ))} +
+ + ); } } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx index cac26d948b11aa..0b20bebf431436 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -81,49 +81,47 @@ export const ComponentTemplatesDeleteModal = ({ }; return ( - - + } + onCancel={handleOnCancel} + onConfirm={handleDeleteComponentTemplates} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + <> +

- } - onCancel={handleOnCancel} - onConfirm={handleDeleteComponentTemplates} - cancelButtonText={ - - } - confirmButtonText={ - - } - > - <> -

- -

+

-
    - {componentTemplatesToDelete.map((name) => ( -
  • {name}
  • - ))} -
- -
-
+
    + {componentTemplatesToDelete.map((name) => ( +
  • {name}
  • + ))} +
+ + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/modal_confirmation_delete_fields.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/modal_confirmation_delete_fields.tsx index e6a7e42c089365..2a65906ea56b40 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/modal_confirmation_delete_fields.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/modal_confirmation_delete_fields.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EuiOverlayMask, EuiBadge, EuiCode } from '@elastic/eui'; +import { EuiConfirmModal, EuiBadge, EuiCode } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { NormalizedFields, NormalizedField } from '../../../types'; @@ -59,55 +59,53 @@ export const ModalConfirmationDeleteFields = ({ : null; return ( - - + <> + {fieldsTree && ( + <> +

+ {i18n.translate( + 'xpack.idxMgmt.mappingsEditor.confirmationModal.deleteFieldsDescription', + { + defaultMessage: 'This will also delete the following fields.', + } + )} +

+ + + )} + {aliases && ( + <> +

+ {i18n.translate( + 'xpack.idxMgmt.mappingsEditor.confirmationModal.deleteAliasesDescription', + { + defaultMessage: 'The following aliases will also be deleted.', + } + )} +

+
    + {aliases.map((aliasPath) => ( +
  • + {aliasPath} +
  • + ))} +
+ )} - buttonColor="danger" - confirmButtonText={confirmButtonText} - > - <> - {fieldsTree && ( - <> -

- {i18n.translate( - 'xpack.idxMgmt.mappingsEditor.confirmationModal.deleteFieldsDescription', - { - defaultMessage: 'This will also delete the following fields.', - } - )} -

- - - )} - {aliases && ( - <> -

- {i18n.translate( - 'xpack.idxMgmt.mappingsEditor.confirmationModal.deleteAliasesDescription', - { - defaultMessage: 'The following aliases will also be deleted.', - } - )} -

-
    - {aliases.map((aliasPath) => ( -
  • - {aliasPath} -
  • - ))} -
- - )} - -
-
+ + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.tsx index a4c88b1e61b8b4..8f023156456dcb 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.tsx @@ -8,14 +8,7 @@ import React, { useState, useRef, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiConfirmModal, - EuiOverlayMask, - EuiCallOut, - EuiText, - EuiSpacer, - EuiButtonEmpty, -} from '@elastic/eui'; +import { EuiConfirmModal, EuiCallOut, EuiText, EuiSpacer, EuiButtonEmpty } from '@elastic/eui'; import { JsonEditor, OnJsonEditorUpdateHandler } from '../../shared_imports'; import { validateMappings, MappingsValidationError } from '../../lib'; @@ -220,61 +213,55 @@ export const LoadMappingsProvider = ({ onJson, children }: Props) => { {children(openModal)} {state.isModalOpen && ( - - - {view === 'json' ? ( - // The CSS override for the EuiCodeEditor requires a parent .application css class -
- - mappings, - }} - /> - - - - - + {view === 'json' ? ( + // The CSS override for the EuiCodeEditor requires a parent .application css class +
+ + mappings, }} /> -
- ) : ( - <> - - -

{i18nTexts.validationErrors.description}

-
- -
    - {state.errors!.slice(0, totalErrorsToDisplay).map((error, i) => ( -
  1. {getErrorMessage(error)}
  2. - ))} -
- {state.errors!.length > MAX_ERRORS_TO_DISPLAY && renderErrorsFilterButton()} -
- - )} - - + + + + + +
+ ) : ( + <> + + +

{i18nTexts.validationErrors.description}

+
+ +
    + {state.errors!.slice(0, totalErrorsToDisplay).map((error, i) => ( +
  1. {getErrorMessage(error)}
  2. + ))} +
+ {state.errors!.length > MAX_ERRORS_TO_DISPLAY && renderErrorsFilterButton()} +
+ + )} +
)} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx index e48172c417d0ad..f9ecca1f8cb61b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { useDispatch } from '../../mappings_state_context'; import { NormalizedRuntimeField } from '../../types'; @@ -68,22 +68,20 @@ export const DeleteRuntimeFieldProvider = ({ children }: Props) => { {children(deleteField)} {state.isModalOpen && ( - - - + )} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx index 2f49e95a1bd62a..d7db98731427db 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx @@ -86,7 +86,7 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = { id="xpack.idxMgmt.mappingsEditor.dataType.constantKeywordLongDescription" defaultMessage="Constant keyword fields are a special type of keyword fields for fields that contain the same keyword across all documents in the index. Supports the same queries and aggregations as {keyword} fields." values={{ - keyword: {'keyword'}, + keyword: {'keyword'}, }} />

@@ -836,7 +836,7 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = { id="xpack.idxMgmt.mappingsEditor.dataType.pointLongDescription" defaultMessage="Point fields enable searching of {code} pairs that fall in a 2-dimensional planar coordinate system." values={{ - code: {'x,y'}, + code: {'x,y'}, }} />

diff --git a/x-pack/plugins/index_management/public/application/components/template_delete_modal.tsx b/x-pack/plugins/index_management/public/application/components/template_delete_modal.tsx index 0dc2407d22c294..f22fa2a3b4f8a5 100644 --- a/x-pack/plugins/index_management/public/application/components/template_delete_modal.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_delete_modal.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask, EuiCallOut, EuiCheckbox, EuiBadge } from '@elastic/eui'; +import { EuiConfirmModal, EuiCallOut, EuiCheckbox, EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -81,95 +81,93 @@ export const TemplateDeleteModal = ({ }; return ( - - - } - onCancel={handleOnCancel} - onConfirm={handleDeleteTemplates} - cancelButtonText={ - - } - confirmButtonText={ + + } + onCancel={handleOnCancel} + onConfirm={handleDeleteTemplates} + cancelButtonText={ + + } + confirmButtonText={ + + } + confirmButtonDisabled={hasSystemTemplate ? !isDeleteConfirmed : false} + > + +

- } - confirmButtonDisabled={hasSystemTemplate ? !isDeleteConfirmed : false} - > - -

- -

+

-
    - {templatesToDelete.map(({ name }) => ( -
  • - {name} - {name.startsWith('.') ? ( - - {' '} - - - - - ) : null} -
  • - ))} -
- {hasSystemTemplate && ( - + {templatesToDelete.map(({ name }) => ( +
  • + {name} + {name.startsWith('.') ? ( + + {' '} + + + + + ) : null} +
  • + ))} + + {hasSystemTemplate && ( + + } + color="danger" + iconType="alert" + data-test-subj="deleteSystemTemplateCallOut" + > +

    + +

    + } - color="danger" - iconType="alert" - data-test-subj="deleteSystemTemplateCallOut" - > -

    - -

    - - } - checked={isDeleteConfirmed} - onChange={(e) => setIsDeleteConfirmed(e.target.checked)} - /> -
    - )} -
    -
    -
    + checked={isDeleteConfirmed} + onChange={(e) => setIsDeleteConfirmed(e.target.checked)} + /> + + )} + +
    ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx index 7475a87ca24d99..f555706a28cdd4 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment } from 'react'; -import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -82,69 +82,67 @@ export const DeleteDataStreamConfirmationModal: React.FunctionComponent = }; return ( - - - } - onCancel={() => onClose()} - onConfirm={handleDeleteDataStreams} - cancelButtonText={ - - } - confirmButtonText={ - - } - > - - - } - color="danger" - iconType="alert" - > -

    - -

    -
    - - - + + } + onCancel={() => onClose()} + onConfirm={handleDeleteDataStreams} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + + + } + color="danger" + iconType="alert" + >

    +
    + + + +

    + +

    -
      - {dataStreams.map((name) => ( -
    • {name}
    • - ))} -
    -
    -
    -
    +
      + {dataStreams.map((name) => ( +
    • {name}
    • + ))} +
    + + ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js index 6282469b092669..20a4af59bab111 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js @@ -20,7 +20,6 @@ import { EuiPopover, EuiSpacer, EuiConfirmModal, - EuiOverlayMask, EuiCheckbox, } from '@elastic/eui'; @@ -301,102 +300,97 @@ export class IndexActionsContextMenu extends Component { const selectedIndexCount = indexNames.length; return ( - - { - if (!this.forcemergeSegmentsError()) { - this.closePopoverAndExecute(() => { - forcemergeIndices(this.state.forcemergeSegments); - this.setState({ - forcemergeSegments: null, - showForcemergeSegmentsModal: null, - }); + { + if (!this.forcemergeSegmentsError()) { + this.closePopoverAndExecute(() => { + forcemergeIndices(this.state.forcemergeSegments); + this.setState({ + forcemergeSegments: null, + showForcemergeSegmentsModal: null, }); - } - }} - cancelButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.forceMerge.confirmModal.cancelButtonText', - { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.forceMerge.confirmModal.confirmButtonText', + }); + } + }} + cancelButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.forceMerge.confirmModal.cancelButtonText', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.forceMerge.confirmModal.confirmButtonText', + { + defaultMessage: 'Force merge', + } + )} + > +

    + +

    + +
      + {indexNames.map((indexName) => ( +
    • {indexName}
    • + ))} +
    + +

    -

    - -
      - {indexNames.map((indexName) => ( -
    • {indexName}
    • - ))} -
    - - -

    - -

    -
    + /> +

    +
    - + - + - - { - this.setState({ forcemergeSegments: event.target.value }); - }} - min={1} - name="maxNumberSegments" - /> - - -
    -
    + { + this.setState({ forcemergeSegments: event.target.value }); + }} + min={1} + name="maxNumberSegments" + /> + + + ); }; @@ -494,39 +488,37 @@ export class IndexActionsContextMenu extends Component { ); return ( - - { - this.confirmAction(false); - this.closeConfirmModal(); - }} - onConfirm={() => this.closePopoverAndExecute(deleteIndices)} - buttonColor="danger" - confirmButtonDisabled={hasSystemIndex ? !isActionConfirmed : false} - cancelButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.cancelButtonText', - { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.confirmButtonText', - { - defaultMessage: 'Delete {selectedIndexCount, plural, one {index} other {indices} }', - values: { selectedIndexCount }, - } - )} - > - {hasSystemIndex ? systemIndexModalBody : standardIndexModalBody} - - + { + this.confirmAction(false); + this.closeConfirmModal(); + }} + onConfirm={() => this.closePopoverAndExecute(deleteIndices)} + buttonColor="danger" + confirmButtonDisabled={hasSystemIndex ? !isActionConfirmed : false} + cancelButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.cancelButtonText', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.confirmButtonText', + { + defaultMessage: 'Delete {selectedIndexCount, plural, one {index} other {indices} }', + values: { selectedIndexCount }, + } + )} + > + {hasSystemIndex ? systemIndexModalBody : standardIndexModalBody} + ); }; @@ -536,96 +528,91 @@ export class IndexActionsContextMenu extends Component { const selectedIndexCount = indexNames.length; return ( - - { + this.confirmAction(false); + this.closeConfirmModal(); + }} + onConfirm={() => this.closePopoverAndExecute(closeIndices)} + buttonColor="danger" + confirmButtonDisabled={!isActionConfirmed} + cancelButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.cancelButtonText', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.closeIndex.confirmModal.confirmButtonText', + { + defaultMessage: 'Close {selectedIndexCount, plural, one {index} other {indices} }', + values: { selectedIndexCount }, + } + )} + > +

    + +

    + +
      + {indexNames.map((indexName) => ( +
    • + {indexName} + {isSystemIndexByName[indexName] ? ( + + {' '} + + + + + ) : ( + '' + )} +
    • + ))} +
    + + { - this.confirmAction(false); - this.closeConfirmModal(); - }} - onConfirm={() => this.closePopoverAndExecute(closeIndices)} - buttonColor="danger" - confirmButtonDisabled={!isActionConfirmed} - cancelButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.cancelButtonText', + 'xpack.idxMgmt.indexActionsMenu.closeIndex.proceedWithCautionCallOutTitle', { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.closeIndex.confirmModal.confirmButtonText', - { - defaultMessage: 'Close {selectedIndexCount, plural, one {index} other {indices} }', - values: { selectedIndexCount }, + defaultMessage: 'Closing a system index can break Kibana', } )} + color="danger" + iconType="alert" >

    - -
      - {indexNames.map((indexName) => ( -
    • - {indexName} - {isSystemIndexByName[indexName] ? ( - - {' '} - - - - - ) : ( - '' - )} -
    • - ))} -
    - - -

    + -

    - - } - checked={isActionConfirmed} - onChange={(e) => this.confirmAction(e.target.checked)} - /> -
    -
    -
    + } + checked={isActionConfirmed} + onChange={(e) => this.confirmAction(e.target.checked)} + /> + + ); }; @@ -633,71 +620,69 @@ export class IndexActionsContextMenu extends Component { const { freezeIndices, indexNames } = this.props; return ( - - this.closePopoverAndExecute(freezeIndices)} + cancelButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.freezeEntity.confirmModal.cancelButtonText', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.freezeEntity.confirmModal.confirmButtonText', + { + defaultMessage: 'Freeze {count, plural, one {index} other {indices}}', + values: { + count: indexNames.length, + }, + } + )} + > +

    + +

    + +
      + {indexNames.map((indexName) => ( +
    • {indexName}
    • + ))} +
    + + this.closePopoverAndExecute(freezeIndices)} - cancelButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.freezeEntity.confirmModal.cancelButtonText', - { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.freezeEntity.confirmModal.confirmButtonText', + 'xpack.idxMgmt.indexActionsMenu.freezeEntity.proceedWithCautionCallOutTitle', { - defaultMessage: 'Freeze {count, plural, one {index} other {indices}}', - values: { - count: indexNames.length, - }, + defaultMessage: 'Proceed with caution', } )} + color="warning" + iconType="help" >

    -

    - -
      - {indexNames.map((indexName) => ( -
    • {indexName}
    • - ))} -
    - - -

    - -

    -
    -
    -
    + /> +

    + + ); }; diff --git a/x-pack/plugins/infra/public/components/saved_views/create_modal.tsx b/x-pack/plugins/infra/public/components/saved_views/create_modal.tsx index 985db9872ef3f6..654cba0721bb8a 100644 --- a/x-pack/plugins/infra/public/components/saved_views/create_modal.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/create_modal.tsx @@ -16,7 +16,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiFieldText, EuiSpacer, EuiSwitch, @@ -40,69 +39,67 @@ export const SavedViewCreateModal = ({ close, save, isInvalid }: Props) => { }, [includeTime, save, viewName]); return ( - - - - - - - - - - + + + - - - } - checked={includeTime} - onChange={onCheckChange} - /> - - - - - + + - - + + + + - - - - - - - + } + checked={includeTime} + onChange={onCheckChange} + /> + + + + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx b/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx index 15d0d162604a4b..c6d87d9a8ca158 100644 --- a/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx @@ -16,7 +16,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiFieldText, EuiSpacer, EuiSwitch, @@ -46,69 +45,67 @@ export function SavedViewUpdateModal - - - - - - - - - + + + - - - } - checked={includeTime} - onChange={onCheckChange} - /> - - - - - + + - - + + + + - - - - - - -
    + } + checked={includeTime} + onChange={onCheckChange} + /> + + + + + + + + + + + + + + + ); } diff --git a/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx b/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx index 9ab742d720eb5e..aad50c4dcb45d0 100644 --- a/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx @@ -9,13 +9,7 @@ import React, { useCallback, useState, useMemo } from 'react'; import { EuiButtonEmpty, EuiModalFooter, EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiOverlayMask, - EuiModal, - EuiModalHeader, - EuiModalHeaderTitle, - EuiModalBody, -} from '@elastic/eui'; +import { EuiModal, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody } from '@elastic/eui'; import { EuiSelectable } from '@elastic/eui'; import { EuiSelectableOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -64,51 +58,49 @@ export function SavedViewListModal - - - - - - - - - {(list, search) => ( - <> - {search} -
    {list}
    - - )} -
    -
    - - - - - - - - -
    - + + + + + + + + + {(list, search) => ( + <> + {search} +
    {list}
    + + )} +
    +
    + + + + + + + + +
    ); } diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx index df21a66a5226f4..5537ef9541f892 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx @@ -9,7 +9,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiModal, - EuiOverlayMask, EuiText, EuiTextColor, EuiToolTip, @@ -51,33 +50,26 @@ export const PageViewLogInContext: React.FC = () => { } return ( - - - - - - - - - - - - - - + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx index 10da326322ad4d..c0f9c758fc2785 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { FunctionComponent, useRef, useState, useCallback } from 'react'; -import { EuiConfirmModal, EuiOverlayMask, EuiSpacer, EuiText, EuiCallOut } from '@elastic/eui'; +import { EuiConfirmModal, EuiSpacer, EuiText, EuiCallOut } from '@elastic/eui'; import { JsonEditor, OnJsonEditorUpdateHandler } from '../../../../../shared_imports'; @@ -78,64 +78,62 @@ export const ModalProvider: FunctionComponent = ({ onDone, children }) => <> {children(() => setIsModalVisible(true))} {isModalVisible ? ( - - { + { + setIsModalVisible(false); + }} + onConfirm={async () => { + try { + const json = jsonContent.current.data.format(); + const { processors, on_failure: onFailure } = json; + // This function will throw if it cannot parse the pipeline object + deserialize({ processors, onFailure }); + onDone(json as any); setIsModalVisible(false); - }} - onConfirm={async () => { - try { - const json = jsonContent.current.data.format(); - const { processors, on_failure: onFailure } = json; - // This function will throw if it cannot parse the pipeline object - deserialize({ processors, onFailure }); - onDone(json as any); - setIsModalVisible(false); - } catch (e) { - setError(e); - } - }} - cancelButtonText={i18nTexts.buttons.cancel} - confirmButtonDisabled={!isValidJson} - confirmButtonText={i18nTexts.buttons.confirm} - maxWidth={600} - > -
    - - - + } catch (e) { + setError(e); + } + }} + cancelButtonText={i18nTexts.buttons.cancel} + confirmButtonDisabled={!isValidJson} + confirmButtonText={i18nTexts.buttons.confirm} + maxWidth={600} + > +
    + + + - + - {error && ( - <> - - {i18nTexts.error.body} - - - - )} + {error && ( + <> + + {i18nTexts.error.body} + + + + )} - -
    - - + +
    +
    ) : undefined} ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/csv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/csv.tsx index d6cf2d0ae05e89..19176a27a07781 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/csv.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/csv.tsx @@ -85,7 +85,7 @@ const fieldsConfig: FieldsConfig = { {','} }} + values={{ value: {','} }} /> ), }, @@ -104,7 +104,7 @@ const fieldsConfig: FieldsConfig = { {'"'} }} + values={{ value: {'"'} }} /> ), }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx index 17af47f1569e04..e8e956daff2074 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx @@ -61,7 +61,7 @@ const fieldsConfig: FieldsConfig = { {'UTC'} }} + values={{ timezone: {'UTC'} }} /> ), }, @@ -75,7 +75,7 @@ const fieldsConfig: FieldsConfig = { {'ENGLISH'} }} + values={{ timezone: {'ENGLISH'} }} /> ), }, @@ -102,7 +102,7 @@ export const DateProcessor: FunctionComponent = () => { id="xpack.ingestPipelines.pipelineEditor.dateForm.targetFieldHelpText" defaultMessage="Output field. If empty, the input field is updated in place. Defaults to {defaultField}." values={{ - defaultField: {'@timestamp'}, + defaultField: {'@timestamp'}, }} /> } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx index d4b1ec876bfd50..182b9ecd845e92 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx @@ -82,7 +82,7 @@ const fieldsConfig: FieldsConfig = { {'yyyy-MM-dd'} }} + values={{ value: {'yyyy-MM-dd'} }} /> ), }, @@ -102,7 +102,7 @@ const fieldsConfig: FieldsConfig = { {"yyyy-MM-dd'T'HH:mm:ss.SSSXX"} }} + values={{ value: {"yyyy-MM-dd'T'HH:mm:ss.SSSXX"} }} /> ), }, @@ -119,7 +119,7 @@ const fieldsConfig: FieldsConfig = { {'UTC'} }} + values={{ timezone: {'UTC'} }} /> ), }, @@ -136,7 +136,7 @@ const fieldsConfig: FieldsConfig = { {'ENGLISH'} }} + values={{ locale: {'ENGLISH'} }} /> ), }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx index 609ce8a1f8ae63..641a6e73d90251 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx @@ -82,7 +82,7 @@ const getFieldsConfig = (esDocUrl: string): Record => { {'""'} }} + values={{ value: {'""'} }} /> ), }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geoip.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geoip.tsx index 2f0699fac729d8..7848872800df45 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geoip.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geoip.tsx @@ -31,8 +31,8 @@ const fieldsConfig: FieldsConfig = { id="xpack.ingestPipelines.pipelineEditor.geoIPForm.databaseFileHelpText" defaultMessage="GeoIP2 database file in the {ingestGeoIP} configuration directory. Defaults to {databaseFile}." values={{ - databaseFile: {'GeoLite2-City.mmdb'}, - ingestGeoIP: {'ingest-geoip'}, + databaseFile: {'GeoLite2-City.mmdb'}, + ingestGeoIP: {'ingest-geoip'}, }} /> ), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/inference.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/inference.tsx index 2b14a79afb8df1..9575e6d690e006 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/inference.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/inference.tsx @@ -168,7 +168,7 @@ export const Inference: FunctionComponent = () => { {'ml.inference.'} }} + values={{ targetField: {'ml.inference.'} }} /> } /> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx index 4309df214410b2..d14048c4e00dce 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx @@ -66,7 +66,7 @@ export const UserAgent: FunctionComponent = () => { id="xpack.ingestPipelines.pipelineEditor.userAgentForm.targetFieldHelpText" defaultMessage="Output field. Defaults to {defaultField}." values={{ - defaultField: {'user_agent'}, + defaultField: {'user_agent'}, }} /> } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_remove_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_remove_modal.tsx index eb5a1baac78fb9..26ae69ead3b5b6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_remove_modal.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_remove_modal.tsx @@ -7,7 +7,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { ProcessorInternal, ProcessorSelector } from '../types'; interface Props { @@ -18,39 +18,37 @@ interface Props { export const ProcessorRemoveModal = ({ processor, onResult, selector }: Props) => { return ( - - - } - onCancel={() => onResult({ confirmed: false, selector })} - onConfirm={() => onResult({ confirmed: true, selector })} - cancelButtonText={ - - } - confirmButtonText={ - - } - > -

    - -

    -
    -
    + + } + onCancel={() => onResult({ confirmed: false, selector })} + onConfirm={() => onResult({ confirmed: true, selector })} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

    + +

    +
    ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx index aac4da7c16bbf0..9095ab1927cb98 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx @@ -135,7 +135,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { {'my-index-yyyy-MM-dd'} }} + values={{ value: {'my-index-yyyy-MM-dd'} }} /> ), }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx index 305ccce4e31b5a..d71a6fb80bde1e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import React, { FunctionComponent } from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; interface Props { confirmResetTestOutput: () => void; @@ -46,18 +46,16 @@ export const ResetDocumentsModal: FunctionComponent = ({ closeModal, }) => { return ( - - -

    {i18nTexts.modalDescription}

    -
    -
    + +

    {i18nTexts.modalDescription}

    +
    ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx index 230cc52a1c1696..63cf7af2737aa3 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -78,49 +78,47 @@ export const PipelineDeleteModal = ({ }; return ( - - + } + onCancel={handleOnCancel} + onConfirm={handleDeletePipelines} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + <> +

    - } - onCancel={handleOnCancel} - onConfirm={handleDeletePipelines} - cancelButtonText={ - - } - confirmButtonText={ - - } - > - <> -

    - -

    +

    -
      - {pipelinesToDelete.map((name) => ( -
    • {name}
    • - ))} -
    - -
    -
    +
      + {pipelinesToDelete.map((name) => ( +
    • {name}
    • + ))} +
    + +
    ); }; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index 9db44bd8225ea2..bc69ab5352a4f5 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -296,140 +296,140 @@ exports[`UploadLicense should display a modal when license requires acknowledgem className="euiSpacer euiSpacer--l" />
    - - -
    + + } + confirmButtonText={ + + } + onCancel={[Function]} + onConfirm={[Function]} + title={ + + } + > + + + -
    -
    - Confirm License Upload -
    -
    -
    +
    -
    -
    - } - > - - } - confirmButtonText={ - - } - onCancel={[Function]} - onConfirm={[Function]} - title={ - - } - > -
    -
    -
    - - + + + +
    - - } - onCancel={cancelStartBasicLicense} - onConfirm={() => startBasicLicense(licenseType, true)} - cancelButtonText={ - - } - confirmButtonText={ - - } - > -
    - {firstLine} - -
      - {messages.map((message) => ( -
    • {message}
    • - ))} -
    -
    -
    -
    - + + } + onCancel={cancelStartBasicLicense} + onConfirm={() => startBasicLicense(licenseType, true)} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +
    + {firstLine} + +
      + {messages.map((message) => ( +
    • {message}
    • + ))} +
    +
    +
    +
    ); } render() { diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx b/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx index 4f7d1ab4365b68..36af5c3b9c7adc 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx @@ -14,7 +14,6 @@ import { EuiFlexItem, EuiCard, EuiLink, - EuiOverlayMask, EuiText, EuiModal, EuiModalFooter, @@ -78,154 +77,152 @@ export class StartTrial extends Component { } return ( - - - - - - - - - -
    - -

    - + + + + + + + +

    + +

    + - - - ), - }} + values={{ + subscriptionFeaturesLinkText: ( + + + + ), + }} + /> +

    +
      +
    • + -

      -
        -
      • - -
      • -
      • - -
      • -
      • - -
      • -
      • - -
      • -
      -

      +

    • +
    • - - - ), - }} + id="xpack.licenseMgmt.licenseDashboard.startTrial.confirmModalDescription.alertingFeatureTitle" + defaultMessage="Alerting" + /> +
    • +
    • + -

      -

      +

    • +
    • - - - ), + jdbcStandard: 'JDBC', + odbcStandard: 'ODBC', + sqlDataBase: 'SQL', }} /> -

      - -
    -
    - - - - - - {shouldShowTelemetryOptIn(telemetry) && ( - + +

    + + + + ), + }} + /> +

    +

    + + + + ), + }} /> - )} - - - - - - - - - - - - - - - - - - - +

    + +
    +
    +
    + + + + + {shouldShowTelemetryOptIn(telemetry) && ( + + )} + + + + + + + + + + + + + + + + + +
    ); } diff --git a/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js b/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js index 77efe30bbb71ea..4d639ec3123dff 100644 --- a/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js +++ b/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js @@ -13,7 +13,6 @@ import { EuiForm, EuiSpacer, EuiConfirmModal, - EuiOverlayMask, EuiText, EuiTitle, EuiFlexGroup, @@ -62,41 +61,39 @@ export class UploadLicense extends React.PureComponent { return null; } return ( - - - } - onCancel={this.cancel} - onConfirm={() => this.send(true)} - cancelButtonText={ - - } - confirmButtonText={ - - } - > -
    - {firstLine} - -
      - {messages.map((message) => ( -
    • {message}
    • - ))} -
    -
    -
    -
    -
    + + } + onCancel={this.cancel} + onConfirm={() => this.send(true)} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +
    + {firstLine} + +
      + {messages.map((message) => ( +
    • {message}
    • + ))} +
    +
    +
    +
    ); } errorMessage() { diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_editor/__snapshots__/confirm_delete_pipeline_modal.test.js.snap b/x-pack/plugins/logstash/public/application/components/pipeline_editor/__snapshots__/confirm_delete_pipeline_modal.test.js.snap index 8fc0ecacd4a3c1..31b8be8aab9ce1 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_editor/__snapshots__/confirm_delete_pipeline_modal.test.js.snap +++ b/x-pack/plugins/logstash/public/application/components/pipeline_editor/__snapshots__/confirm_delete_pipeline_modal.test.js.snap @@ -1,41 +1,39 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ConfirmDeletePipelineModal component renders as expected 1`] = ` - - - } - confirmButtonText={ - - } - defaultFocusedButton="cancel" - onCancel={[MockFunction]} - onConfirm={[MockFunction]} - title={ - + } + confirmButtonText={ + + } + defaultFocusedButton="cancel" + onCancel={[MockFunction]} + onConfirm={[MockFunction]} + title={ + - } - > -

    - You cannot recover a deleted pipeline. -

    -
    -
    + } + /> + } +> +

    + You cannot recover a deleted pipeline. +

    + `; diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_editor/confirm_delete_pipeline_modal.js b/x-pack/plugins/logstash/public/application/components/pipeline_editor/confirm_delete_pipeline_modal.js index 37ce05f42073af..d8cf85919bd425 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_editor/confirm_delete_pipeline_modal.js +++ b/x-pack/plugins/logstash/public/application/components/pipeline_editor/confirm_delete_pipeline_modal.js @@ -7,41 +7,39 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { EuiConfirmModal, EUI_MODAL_CANCEL_BUTTON, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { PIPELINE_EDITOR } from './constants'; export function ConfirmDeletePipelineModal({ id, cancelDeleteModal, confirmDeletePipeline }) { return ( - - - } - confirmButtonText={ - - } - defaultFocusedButton={EUI_MODAL_CANCEL_BUTTON} - onCancel={cancelDeleteModal} - onConfirm={confirmDeletePipeline} - title={ - - } - > -

    {PIPELINE_EDITOR.DELETE_PIPELINE_MODAL_MESSAGE}

    -
    -
    + + } + confirmButtonText={ + + } + defaultFocusedButton={EUI_MODAL_CANCEL_BUTTON} + onCancel={cancelDeleteModal} + onConfirm={confirmDeletePipeline} + title={ + + } + > +

    {PIPELINE_EDITOR.DELETE_PIPELINE_MODAL_MESSAGE}

    +
    ); } diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_list/__snapshots__/confirm_delete_modal.test.js.snap b/x-pack/plugins/logstash/public/application/components/pipeline_list/__snapshots__/confirm_delete_modal.test.js.snap index c58337612f2871..9eabf4120ef233 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_list/__snapshots__/confirm_delete_modal.test.js.snap +++ b/x-pack/plugins/logstash/public/application/components/pipeline_list/__snapshots__/confirm_delete_modal.test.js.snap @@ -1,93 +1,89 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ConfirmDeleteModal component confirms delete for multiple pipelines 1`] = ` - - - } - confirmButtonText={ - + } + confirmButtonText={ + - } - defaultFocusedButton="cancel" - onCancel={[MockFunction]} - onConfirm={[MockFunction]} - title={ - + } + defaultFocusedButton="cancel" + onCancel={[MockFunction]} + onConfirm={[MockFunction]} + title={ + - } - > -

    - -

    -
    -
    + } + /> + } +> +

    + +

    + `; exports[`ConfirmDeleteModal component confirms delete for single pipeline 1`] = ` - - - } - confirmButtonText={ - - } - defaultFocusedButton="cancel" - onCancel={[MockFunction]} - onConfirm={[MockFunction]} - title={ - + } + confirmButtonText={ + + } + defaultFocusedButton="cancel" + onCancel={[MockFunction]} + onConfirm={[MockFunction]} + title={ + - } - > -

    - -

    -
    -
    + } + /> + } +> +

    + +

    + `; diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_list/confirm_delete_modal.js b/x-pack/plugins/logstash/public/application/components/pipeline_list/confirm_delete_modal.js index c20db3d3fc5796..5dbefd2ae58e89 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_list/confirm_delete_modal.js +++ b/x-pack/plugins/logstash/public/application/components/pipeline_list/confirm_delete_modal.js @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EUI_MODAL_CANCEL_BUTTON, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; export function ConfirmDeleteModal({ @@ -67,23 +67,21 @@ export function ConfirmDeleteModal({ }; return ( - - - } - confirmButtonText={confirmText.button} - defaultFocusedButton={EUI_MODAL_CANCEL_BUTTON} - onCancel={cancelDeletePipelines} - onConfirm={deleteSelectedPipelines} - title={confirmText.title} - > -

    {confirmText.message}

    -
    -
    + + } + confirmButtonText={confirmText.button} + defaultFocusedButton={EUI_MODAL_CANCEL_BUTTON} + onCancel={cancelDeletePipelines} + onConfirm={deleteSelectedPipelines} + title={confirmText.title} + > +

    {confirmText.message}

    +
    ); } diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js index 89eef907b22593..9e5a6080c830d8 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js @@ -8,7 +8,7 @@ import React from 'react'; import classNames from 'classnames'; -import { EuiIcon, EuiOverlayMask, EuiButtonIcon, EuiConfirmModal } from '@elastic/eui'; +import { EuiIcon, EuiButtonIcon, EuiConfirmModal } from '@elastic/eui'; import { TOCEntryActionsPopover } from './toc_entry_actions_popover'; import { i18n } from '@kbn/i18n'; @@ -100,20 +100,18 @@ export class TOCEntry extends React.Component { }; return ( - - -

    There are unsaved changes to your layer.

    -

    Are you sure you want to proceed?

    -
    -
    + +

    There are unsaved changes to your layer.

    +

    Are you sure you want to proceed?

    +
    ); } diff --git a/x-pack/plugins/ml/public/application/components/annotations/delete_annotation_modal/index.tsx b/x-pack/plugins/ml/public/application/components/annotations/delete_annotation_modal/index.tsx index 8469d42c16c519..9999fad89d0e1c 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/delete_annotation_modal/index.tsx +++ b/x-pack/plugins/ml/public/application/components/annotations/delete_annotation_modal/index.tsx @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import React, { Fragment } from 'react'; -import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,33 +26,31 @@ export const DeleteAnnotationModal: React.FC = ({ return ( {isVisible === true && ( - - - } - onCancel={cancelAction} - onConfirm={deleteAction} - cancelButtonText={ - - } - confirmButtonText={ - - } - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - className="eui-textBreakWord" - /> - + + } + onCancel={cancelAction} + onConfirm={deleteAction} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="danger" + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + className="eui-textBreakWord" + /> )} ); diff --git a/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx b/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx index eda0509d417ca7..972ed06ba13859 100644 --- a/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx +++ b/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx @@ -13,7 +13,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiModal, - EuiOverlayMask, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, @@ -230,69 +229,67 @@ export const DeleteJobCheckModal: FC = ({ }; return ( - - - {isLoading === true && ( - <> - - - - - - - - - )} - {isLoading === false && ( - <> - - - - - + + {isLoading === true && ( + <> + + + + + + + + + )} + {isLoading === false && ( + <> + + + + + - {modalContent} + {modalContent} - - - - {!hasUntagged && + + + + {!hasUntagged && + jobCheckRespSummary?.canTakeAnyAction && + jobCheckRespSummary?.canRemoveFromSpace && + jobCheckRespSummary?.canDelete && ( + + {shouldUnTagLabel} + + )} + + + - {shouldUnTagLabel} - - )} - - - - {buttonContent} - - - - - - )} - - + !jobCheckRespSummary?.canDelete + ? onUntagClick + : onClick + } + fill + > + {buttonContent} + + + + + + )} + ); }; diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx index 1d2bda90516b99..8fc4a0d636bce7 100644 --- a/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx +++ b/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { COMBINED_JOB_STATE } from '../model_snapshots_table'; @@ -25,56 +25,51 @@ export const CloseJobConfirm: FC = ({ forceCloseJob, }) => { return ( - - +

    + {combinedJobState === COMBINED_JOB_STATE.OPEN_AND_RUNNING && ( + )} - confirmButtonText={ - combinedJobState === COMBINED_JOB_STATE.OPEN_AND_RUNNING - ? i18n.translate('xpack.ml.modelSnapshotTable.closeJobConfirm.stopAndClose.button', { - defaultMessage: 'Force stop and close', - }) - : i18n.translate('xpack.ml.modelSnapshotTable.closeJobConfirm.close.button', { - defaultMessage: 'Force close', - }) - } - defaultFocusedButton="confirm" - > -

    - {combinedJobState === COMBINED_JOB_STATE.OPEN_AND_RUNNING && ( - - )} - {combinedJobState === COMBINED_JOB_STATE.OPEN_AND_STOPPED && ( - - )} -
    + {combinedJobState === COMBINED_JOB_STATE.OPEN_AND_STOPPED && ( -

    -
    -
    + )} +
    + +

    + ); }; diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx index 833e70fc86f4c4..20c98255930b4d 100644 --- a/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx @@ -22,7 +22,6 @@ import { EuiFormRow, EuiSwitch, EuiConfirmModal, - EuiOverlayMask, EuiCallOut, } from '@elastic/eui'; @@ -190,23 +189,21 @@ export const EditModelSnapshotFlyout: FC = ({ snapshot, job, closeFlyout {deleteModalVisible && ( - - - + )} ); diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx index 1929cddaca6b56..6dd4e6c14589b2 100644 --- a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx @@ -22,7 +22,6 @@ import { EuiFormRow, EuiSwitch, EuiConfirmModal, - EuiOverlayMask, EuiCallOut, EuiHorizontalRule, EuiSuperSelect, @@ -368,34 +367,32 @@ export const RevertModelSnapshotFlyout: FC = ({ {revertModalVisible && ( - - - - - + + + )} ); diff --git a/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/__snapshots__/delete_rule_modal.test.js.snap b/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/__snapshots__/delete_rule_modal.test.js.snap index a132e6682ee250..3a11531f6c4bc1 100644 --- a/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/__snapshots__/delete_rule_modal.test.js.snap +++ b/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/__snapshots__/delete_rule_modal.test.js.snap @@ -42,34 +42,32 @@ exports[`DeleteRuleModal renders modal after clicking delete rule link 1`] = ` values={Object {}} /> - - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - onCancel={[Function]} - onConfirm={[Function]} - title={ - - } - /> - + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + onCancel={[Function]} + onConfirm={[Function]} + title={ + + } + /> `; diff --git a/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/delete_rule_modal.js b/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/delete_rule_modal.js index 809bb780c33239..6caa6592e96c1e 100644 --- a/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/delete_rule_modal.js +++ b/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/delete_rule_modal.js @@ -12,7 +12,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { EuiConfirmModal, EuiLink, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiConfirmModal, EuiLink, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; export class DeleteRuleModal extends Component { @@ -43,32 +43,30 @@ export class DeleteRuleModal extends Component { if (this.state.isModalVisible) { modal = ( - - - } - onCancel={this.closeModal} - onConfirm={this.deleteRule} - buttonColor="danger" - cancelButtonText={ - - } - confirmButtonText={ - - } - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - /> - + + } + onCancel={this.closeModal} + onConfirm={this.deleteRule} + buttonColor="danger" + cancelButtonText={ + + } + confirmButtonText={ + + } + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + /> ); } diff --git a/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js b/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js index a93264c852dd15..2b7c89db15e2e1 100644 --- a/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js +++ b/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, EuiText, EuiFlexGroup, @@ -161,24 +160,19 @@ const LoadingSpinner = () => ( ); const Modal = ({ close, title, children }) => ( - - - - {title} - - - {children} - - - - - - - - + + + {title} + + + {children} + + + + + + + ); Modal.propType = { close: PropTypes.func.isRequired, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx index bc99330a444ae5..e2e1ec852d1a95 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx @@ -9,7 +9,6 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiConfirmModal, - EuiOverlayMask, EuiSwitch, EuiFlexGroup, EuiFlexItem, @@ -37,67 +36,62 @@ export const DeleteActionModal: FC = ({ const indexName = item.config.dest.index; return ( - - - - - {userCanDeleteIndex && ( - - )} - - - {userCanDeleteIndex && indexPatternExists && ( - - )} - - - - + + + + {userCanDeleteIndex && ( + + )} + + + {userCanDeleteIndex && indexPatternExists && ( + + )} + + + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx index 2a19068ca6f4e1..d63e60e43e9094 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { StartAction } from './use_start_action'; @@ -15,37 +15,35 @@ export const StartActionModal: FC = ({ closeModal, item, startAndCl return ( <> {item !== undefined && ( - - +

    + {i18n.translate('xpack.ml.dataframe.analyticsList.startModalBody', { + defaultMessage: + 'A data frame analytics job increases search and indexing load in your cluster. If excessive load occurs, stop the job.', })} - onCancel={closeModal} - onConfirm={startAndCloseModal} - cancelButtonText={i18n.translate( - 'xpack.ml.dataframe.analyticsList.startModalCancelButton', - { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.ml.dataframe.analyticsList.startModalStartButton', - { - defaultMessage: 'Start', - } - )} - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - buttonColor="primary" - > -

    - {i18n.translate('xpack.ml.dataframe.analyticsList.startModalBody', { - defaultMessage: - 'A data frame analytics job increases search and indexing load in your cluster. If excessive load occurs, stop the job.', - })} -

    -
    -
    +

    +
    )} ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_action_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_action_modal.tsx index a10c0c59abd973..8ee7350245be43 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_action_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_action_modal.tsx @@ -8,7 +8,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { StopAction } from './use_stop_action'; @@ -16,37 +16,35 @@ export const StopActionModal: FC = ({ closeModal, item, forceStopAnd return ( <> {item !== undefined && ( - - -

    - -

    -
    -
    + +

    + +

    +
    )} ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx index 7ff77f21a8623e..d93baee97c5330 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx @@ -8,7 +8,6 @@ import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiOverlayMask, EuiModal, EuiModalHeader, EuiModalHeaderTitle, @@ -31,57 +30,55 @@ export const DeleteModelsModal: FC = ({ models, onClose .map((model) => model.model_id); return ( - - - - + + + + + + + + + {modelsWithPipelines.length > 0 && ( + - - - - - {modelsWithPipelines.length > 0 && ( - - - - )} - + + )} + - - - - + + + + - - - - - - + + + + + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx index c9f78e9b0dab1a..40f97690d7790b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx @@ -9,13 +9,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiModal, - EuiModalBody, - EuiModalHeader, - EuiModalHeaderTitle, - EuiOverlayMask, -} from '@elastic/eui'; +import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { SavedObjectFinderUi } from '../../../../../../../../../../src/plugins/saved_objects/public'; import { useMlKibana, useNavigateToPath } from '../../../../../contexts/kibana'; @@ -41,66 +35,62 @@ export const SourceSelection: FC = ({ onClose }) => { }; return ( - <> - - - - - {' '} - /{' '} - - - - - + + + {' '} + /{' '} + + + + + 'search', + name: i18n.translate( + 'xpack.ml.dataFrame.analytics.create.searchSelection.savedObjectType.search', { - defaultMessage: 'No matching indices or saved searches found.', + defaultMessage: 'Saved search', } - )} - savedObjectMetaData={[ + ), + }, + { + type: 'index-pattern', + getIconForSavedObject: () => 'indexPatternApp', + name: i18n.translate( + 'xpack.ml.dataFrame.analytics.create.searchSelection.savedObjectType.indexPattern', { - type: 'search', - getIconForSavedObject: () => 'search', - name: i18n.translate( - 'xpack.ml.dataFrame.analytics.create.searchSelection.savedObjectType.search', - { - defaultMessage: 'Saved search', - } - ), - }, - { - type: 'index-pattern', - getIconForSavedObject: () => 'indexPatternApp', - name: i18n.translate( - 'xpack.ml.dataFrame.analytics.create.searchSelection.savedObjectType.indexPattern', - { - defaultMessage: 'Index pattern', - } - ), - }, - ]} - fixedPageSize={fixedPageSize} - uiSettings={uiSettings} - savedObjects={savedObjects} - /> - - - - + defaultMessage: 'Index pattern', + } + ), + }, + ]} + fixedPageSize={fixedPageSize} + uiSettings={uiSettings} + savedObjects={savedObjects} + /> + + ); }; diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx index 3401c72a3b8549..2330eafd87825e 100644 --- a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx +++ b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx @@ -14,7 +14,6 @@ import { EuiModal, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, EuiButtonEmpty, EuiButton, @@ -215,103 +214,99 @@ export const AddToDashboardControl: FC = ({ const noSwimlaneSelected = Object.values(selectedSwimlanes).every((isSelected) => !isSelected); return ( - - - - + + + + + + + + - - - - - } - > - { - const newSelection = { - ...selectedSwimlanes, - [optionId]: !selectedSwimlanes[optionId as SwimlaneType], - }; - setSelectedSwimlanes(newSelection); - }} - data-test-subj="mlAddToDashboardSwimlaneTypeSelector" - /> - + } + > + { + const newSelection = { + ...selectedSwimlanes, + [optionId]: !selectedSwimlanes[optionId as SwimlaneType], + }; + setSelectedSwimlanes(newSelection); + }} + data-test-subj="mlAddToDashboardSwimlaneTypeSelector" + /> + - + - - } - data-test-subj="mlDashboardSelectionContainer" - > - - - - - - - - { - onClose(async () => { - const selectedDashboardId = selectedItems[0].id; - await addSwimlaneToDashboardCallback(); - await navigateToUrl( - await dashboardService.getDashboardEditUrl(selectedDashboardId) - ); - }); - }} - data-test-subj="mlAddAndEditDashboardButton" - > - - - + - - - - + } + data-test-subj="mlDashboardSelectionContainer" + > + + + + + + + + { + onClose(async () => { + const selectedDashboardId = selectedItems[0].id; + await addSwimlaneToDashboardCallback(); + await navigateToUrl(await dashboardService.getDashboardEditUrl(selectedDashboardId)); + }); + }} + data-test-subj="mlAddAndEditDashboardButton" + > + + + + + + + ); }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx index 3bec404276ca26..a67863ea5f803b 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx @@ -10,7 +10,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiSpacer, EuiModal, - EuiOverlayMask, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, @@ -77,74 +76,72 @@ export const DeleteJobModal: FC = ({ setShowFunction, unsetShowFunction, if (canDelete) { return ( - - - - - - - - -

    - {deleting === true ? ( -

    - - -
    - -
    + + + + + + + +

    + {deleting === true ? ( +

    + + +
    +
    - ) : ( - - - - )} -

    - - <> - - - - - + + )} +

    + + <> + + + + + - - - - - - - + + + +
    + + ); } else { return ( diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js index 8769c2c3cca20e..b23bbedb7413a2 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js @@ -21,7 +21,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiTabbedContent, - EuiOverlayMask, EuiConfirmModal, } from '@elastic/eui'; @@ -443,38 +442,36 @@ export class EditJobFlyoutUI extends Component { if (this.state.isConfirmationModalVisible) { confirmationModal = ( - - - } - onCancel={() => this.closeFlyout(true)} - onConfirm={() => this.save()} - cancelButtonText={ - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - > -

    - -

    -
    -
    + + } + onCancel={() => this.closeFlyout(true)} + onConfirm={() => this.save()} + cancelButtonText={ + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + > +

    + +

    +
    ); } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/custom_urls.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/custom_urls.tsx index a1bac4b6a35979..da4c9b0b0cc004 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/custom_urls.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/custom_urls.tsx @@ -14,7 +14,6 @@ import { EuiFlexItem, EuiPanel, EuiSpacer, - EuiOverlayMask, EuiModal, EuiModalBody, EuiModalHeader, @@ -282,30 +281,28 @@ class CustomUrlsUI extends Component { ) : ( - - - - - - - + + + + + + - {editor} + {editor} - - {testButton} - {addButton} - - - + + {testButton} + {addButton} + + ); } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js index 5f5759e49208cb..361e8956c714e3 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js @@ -16,7 +16,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiHorizontalRule, EuiCheckbox, } from '@elastic/eui'; @@ -138,78 +137,76 @@ export class StartDatafeedModal extends Component { if (this.state.isModalVisible) { modal = ( - - - - - - - - - - + + + - {this.state.endTime === undefined && ( -
    - - - } - checked={createAlert} - onChange={this.setCreateAlert} - /> -
    - )} -
    - - - - - - - - + + + + + {this.state.endTime === undefined && ( +
    + + + } + checked={createAlert} + onChange={this.setCreateAlert} /> - - - - +
    + )} +
    + + + + + + + + + + +
    ); } return
    {modal}
    ; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/reset_query/reset_query.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/reset_query/reset_query.tsx index 02f53c77c088c0..e42ec414e9641f 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/reset_query/reset_query.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/reset_query/reset_query.tsx @@ -8,13 +8,7 @@ import React, { FC, useContext, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButtonEmpty, - EuiConfirmModal, - EuiOverlayMask, - EuiCodeBlock, - EuiSpacer, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiConfirmModal, EuiCodeBlock, EuiSpacer } from '@elastic/eui'; import { JobCreatorContext } from '../../../job_creator_context'; import { getDefaultDatafeedQuery } from '../../../../../utils/new_job_utils'; @@ -34,35 +28,33 @@ export const ResetQueryButton: FC = () => { return ( <> {confirmModalVisible && ( - - - + + - + - - {defaultQueryString} - - - + + {defaultQueryString} + + )} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/modal_wrapper.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/modal_wrapper.tsx index 3f4a0f6ea6b3d2..aaed47cc7a02bb 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/modal_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/modal_wrapper.tsx @@ -9,7 +9,6 @@ import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, - EuiOverlayMask, EuiModal, EuiModalHeader, EuiModalHeaderTitle, @@ -28,44 +27,42 @@ interface Props { export const ModalWrapper: FC = ({ onCreateClick, closeModal, saveEnabled, children }) => { return ( - - - - - - - + + + + + + - {children} + {children} - - - - + + + + - - - - - - + + + + + ); }; diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js index 7f3c8ce9a1a6e5..42d8b32691c205 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js @@ -10,7 +10,7 @@ import { PropTypes } from 'prop-types'; import { i18n } from '@kbn/i18n'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiOverlayMask } from '@elastic/eui'; +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; import { NavigationMenu } from '../../../components/navigation_menu'; @@ -336,19 +336,13 @@ class NewCalendarUI extends Component { let modal = ''; if (isNewEventModalVisible) { - modal = ( - - - - ); + modal = ; } else if (isImportModalVisible) { modal = ( - - - + ); } diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js b/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js index afd1433b7ae698..bba28ab481ea11 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js @@ -10,7 +10,6 @@ import { PropTypes } from 'prop-types'; import { EuiConfirmModal, - EuiOverlayMask, EuiPage, EuiPageBody, EuiPageContent, @@ -111,37 +110,35 @@ export class CalendarsListUI extends Component { if (this.state.isDestroyModalVisible) { destroyModal = ( - - c.calendar_id).join(', '), - }} - /> - } - onCancel={this.closeDestroyModal} - onConfirm={this.deleteCalendars} - cancelButtonText={ - - } - confirmButtonText={ - - } - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - /> - + c.calendar_id).join(', '), + }} + /> + } + onCancel={this.closeDestroyModal} + onConfirm={this.deleteCalendars} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="danger" + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + /> ); } diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/__snapshots__/delete_filter_list_modal.test.js.snap b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/__snapshots__/delete_filter_list_modal.test.js.snap index 93ca044cb0c830..8cadb8270f680a 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/__snapshots__/delete_filter_list_modal.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/__snapshots__/delete_filter_list_modal.test.js.snap @@ -92,41 +92,39 @@ exports[`DeleteFilterListModal renders modal after clicking delete button 1`] = values={Object {}} /> - - - } - className="eui-textBreakWord" - confirmButtonText={ - - } - data-test-subj="mlFilterListDeleteConfirmation" - defaultFocusedButton="confirm" - onCancel={[Function]} - onConfirm={[Function]} - title={ - + } + className="eui-textBreakWord" + confirmButtonText={ + + } + data-test-subj="mlFilterListDeleteConfirmation" + defaultFocusedButton="confirm" + onCancel={[Function]} + onConfirm={[Function]} + title={ + - } - /> - + } + /> + } + />
    `; diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js index bed0e7ca281e5b..20b716586b97d1 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js @@ -9,7 +9,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButton, EuiConfirmModal, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiButton, EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { deleteFilterLists } from './delete_filter_lists'; @@ -67,29 +67,27 @@ export class DeleteFilterListModal extends Component { /> ); modal = ( - - - } - confirmButtonText={ - - } - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - data-test-subj={'mlFilterListDeleteConfirmation'} - /> - + + } + confirmButtonText={ + + } + buttonColor="danger" + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + data-test-subj={'mlFilterListDeleteConfirmation'} + /> ); } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/modal.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/modal.js index 613bd51bc16c3c..3261846a5fdd5b 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/modal.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/modal.js @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, } from '@elastic/eui'; @@ -31,48 +30,42 @@ import { FormattedMessage } from '@kbn/i18n/react'; export function Modal(props) { return ( - - - - - - - + + + + + + - - {props.messages.map((message, i) => ( - - - - - ))} + + {props.messages.map((message, i) => ( + + + + + ))} - {props.forecasts.length > 0 && ( - - - - - )} - - + {props.forecasts.length > 0 && ( + + + + + )} + + - - - - - - - + + + + + +
    ); } diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js index fd9f4e3503d106..4db5d1b333b7cf 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; export class RemoveClusterButtonProvider extends Component { static propTypes = { @@ -84,7 +84,7 @@ export class RemoveClusterButtonProvider extends Component { ); modal = ( - + <> {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} {!isSingleCluster && content} - + ); } diff --git a/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap b/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap index 09e487591c164a..692b410bd7e5f3 100644 --- a/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap +++ b/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap @@ -58,7 +58,7 @@ Array [ >
    ,
    ,
    { }); return ( - - this.hideConfirm()} - onConfirm={() => this.props.performDelete()} - confirmButtonText={confirmButtonText} - cancelButtonText={cancelButtonText} - defaultFocusedButton="confirm" - buttonColor="danger" - > - {message} - - + this.hideConfirm()} + onConfirm={() => this.props.performDelete()} + confirmButtonText={confirmButtonText} + cancelButtonText={cancelButtonText} + defaultFocusedButton="confirm" + buttonColor="danger" + > + {message} + ); } diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js index 650d63f38eeb52..ec7473c69dec19 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; export class ConfirmDeleteModal extends Component { static propTypes = { @@ -91,28 +91,26 @@ export class ConfirmDeleteModal extends Component { } return ( - - - {content} - - + + {content} + ); } } diff --git a/x-pack/plugins/security/public/components/confirm_modal.tsx b/x-pack/plugins/security/public/components/confirm_modal.tsx index d0ca1de07314e2..3802ee368d735b 100644 --- a/x-pack/plugins/security/public/components/confirm_modal.tsx +++ b/x-pack/plugins/security/public/components/confirm_modal.tsx @@ -18,7 +18,6 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiModalProps, - EuiOverlayMask, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -29,13 +28,11 @@ export interface ConfirmModalProps extends Omit = ({ children, @@ -45,51 +42,42 @@ export const ConfirmModal: FunctionComponent = ({ isDisabled, onCancel, onConfirm, - ownFocus = true, title, ...rest -}) => { - const modal = ( - - - {title} - - {children} - - - - - - - - - - {confirmButtonText} - - - - - - ); - - return ownFocus ? ( - {modal} - ) : ( - modal - ); -}; +}) => ( + + + {title} + + {children} + + + + + + + + + + {confirmButtonText} + + + + + +); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx index ae142e76877cef..232847b63cb1ab 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useRef, useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'src/core/public'; @@ -127,58 +127,56 @@ export const InvalidateProvider: React.FunctionComponent = ({ const isSingle = apiKeys.length === 1; return ( - - - {!isSingle ? ( - -

    - {i18n.translate( - 'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription', - { defaultMessage: 'You are about to invalidate these API keys:' } - )} -

    -
      - {apiKeys.map(({ name, id }) => ( -
    • {name}
    • - ))} -
    -
    - ) : null} -
    -
    + )} + buttonColor="danger" + data-test-subj="invalidateApiKeyConfirmationModal" + > + {!isSingle ? ( + +

    + {i18n.translate( + 'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription', + { defaultMessage: 'You are about to invalidate these API keys:' } + )} +

    +
      + {apiKeys.map(({ name, id }) => ( +
    • {name}
    • + ))} +
    +
    + ) : null} + ); }; diff --git a/x-pack/plugins/security/public/management/role_mappings/components/delete_provider/delete_provider.tsx b/x-pack/plugins/security/public/management/role_mappings/components/delete_provider/delete_provider.tsx index c5e6e3cb9860d0..680a4a40a7d9a1 100644 --- a/x-pack/plugins/security/public/management/role_mappings/components/delete_provider/delete_provider.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/components/delete_provider/delete_provider.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useRef, useState, ReactElement } from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { NotificationsStart } from 'src/core/public'; @@ -140,59 +140,57 @@ export const DeleteProvider: React.FunctionComponent = ({ const isSingle = roleMappings.length === 1; return ( - - - {!isSingle ? ( - -

    - {i18n.translate( - 'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.deleteMultipleListDescription', - { defaultMessage: 'You are about to delete these role mappings:' } - )} -

    -
      - {roleMappings.map(({ name }) => ( -
    • {name}
    • - ))} -
    -
    - ) : null} -
    -
    + )} + confirmButtonDisabled={isDeleteInProgress} + buttonColor="danger" + data-test-subj="deleteRoleMappingConfirmationModal" + > + {!isSingle ? ( + +

    + {i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.deleteMultipleListDescription', + { defaultMessage: 'You are about to delete these role mappings:' } + )} +

    +
      + {roleMappings.map(({ name }) => ( +
    • {name}
    • + ))} +
    +
    + ) : null} + ); }; diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.tsx index b094f78a53e778..d027a1aeb7e1fc 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.tsx @@ -9,7 +9,6 @@ import React, { Component, Fragment } from 'react'; import { EuiSpacer, EuiConfirmModal, - EuiOverlayMask, EuiCallOut, EuiErrorBoundary, EuiIcon, @@ -228,40 +227,38 @@ export class RuleEditorPanel extends Component { return null; } return ( - - - } - onCancel={() => this.setState({ showConfirmModeChange: false })} - onConfirm={() => { - this.setState({ mode: 'visual', showConfirmModeChange: false }); - this.onValidityChange(true); - }} - cancelButtonText={ - - } - confirmButtonText={ - - } - > -

    - -

    -
    -
    + + } + onCancel={() => this.setState({ showConfirmModeChange: false })} + onConfirm={() => { + this.setState({ mode: 'visual', showConfirmModeChange: false }); + this.onValidityChange(true); + }} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

    + +

    +
    ); }; diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_group_title.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_group_title.tsx index 6e94abfb3f4a20..478e8d87abf95c 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_group_title.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_group_title.tsx @@ -12,7 +12,6 @@ import { EuiContextMenuItem, EuiLink, EuiIcon, - EuiOverlayMask, EuiConfirmModal, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -87,45 +86,43 @@ export const RuleGroupTitle = (props: Props) => { ); const confirmChangeModal = showConfirmChangeModal ? ( - - - } - onCancel={() => { - setShowConfirmChangeModal(false); - setPendingNewRule(null); - }} - onConfirm={() => { - setShowConfirmChangeModal(false); - changeRuleDiscardingSubRules(pendingNewRule!); - setPendingNewRule(null); - }} - cancelButtonText={ - - } - confirmButtonText={ - - } - > -

    - -

    -
    -
    + + } + onCancel={() => { + setShowConfirmChangeModal(false); + setPendingNewRule(null); + }} + onConfirm={() => { + setShowConfirmChangeModal(false); + changeRuleDiscardingSubRules(pendingNewRule!); + setPendingNewRule(null); + }} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

    + +

    +
    ) : null; return ( diff --git a/x-pack/plugins/security/public/management/roles/edit_role/delete_role_button.tsx b/x-pack/plugins/security/public/management/roles/edit_role/delete_role_button.tsx index bd3c86575c61a4..1b3a7fa024dd16 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/delete_role_button.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/delete_role_button.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiButtonEmpty, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiButtonEmpty, EuiConfirmModal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment } from 'react'; @@ -46,44 +46,42 @@ export class DeleteRoleButton extends Component { return null; } return ( - - - } - onCancel={this.closeModal} - onConfirm={this.onConfirmDelete} - cancelButtonText={ - - } - confirmButtonText={ - - } - buttonColor={'danger'} - > -

    - -

    -

    - -

    -
    -
    + + } + onCancel={this.closeModal} + onConfirm={this.onConfirmDelete} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor={'danger'} + > +

    + +

    +

    + +

    +
    ); }; diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/confirm_delete/confirm_delete.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/confirm_delete/confirm_delete.tsx index dbbb09f1598b63..81302465bb3732 100644 --- a/x-pack/plugins/security/public/management/roles/roles_grid/confirm_delete/confirm_delete.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_grid/confirm_delete/confirm_delete.tsx @@ -14,7 +14,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -55,65 +54,61 @@ export class ConfirmDelete extends Component { // to disable the buttons since this could be a long-running operation return ( - - - - - {title} - - - - - {moreThanOne ? ( - -

    - -

    -
      - {rolesToDelete.map((roleName) => ( -
    • {roleName}
    • - ))} -
    -
    - ) : null} -

    - -

    -
    -
    - - + + + {title} + + + + {moreThanOne ? ( + +

    + +

    +
      + {rolesToDelete.map((roleName) => ( +
    • {roleName}
    • + ))} +
    +
    + ) : null} +

    - +

    +
    +
    + + + + - - - - -
    -
    + + + + + ); } diff --git a/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx b/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx index c670a9ce99f5bb..38adca145dfc5d 100644 --- a/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx +++ b/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx @@ -6,7 +6,7 @@ */ import React, { Component, Fragment } from 'react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import type { PublicMethodsOf } from '@kbn/utility-types'; @@ -35,46 +35,44 @@ export class ConfirmDeleteUsers extends Component { values: { userLength: usersToDelete[0] }, }); return ( - - -
    - {moreThanOne ? ( - -

    - -

    -
      - {usersToDelete.map((username) => ( -
    • {username}
    • - ))} -
    -
    - ) : null} -

    - -

    -
    -
    -
    + +
    + {moreThanOne ? ( + +

    + +

    +
      + {usersToDelete.map((username) => ( +
    • {username}
    • + ))} +
    +
    + ) : null} +

    + +

    +
    +
    ); } diff --git a/x-pack/plugins/security/public/management/users/edit_user/confirm_delete_users.tsx b/x-pack/plugins/security/public/management/users/edit_user/confirm_delete_users.tsx index 9e8745538e0ed3..189f0c3845d635 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/confirm_delete_users.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/confirm_delete_users.tsx @@ -68,7 +68,6 @@ export const ConfirmDeleteUsers: FunctionComponent = ({ )} confirmButtonColor="danger" isLoading={state.loading} - ownFocus >

    diff --git a/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx b/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx index 793f0e6c2a420b..e0fb4e554ee3c4 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx @@ -80,7 +80,6 @@ export const ConfirmDisableUsers: FunctionComponent = } confirmButtonColor={isSystemUser ? 'danger' : undefined} isLoading={state.loading} - ownFocus > {isSystemUser ? ( diff --git a/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx b/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx index a1aac5bc0a8cb4..2cb4cf8b4a9e2c 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx @@ -67,7 +67,6 @@ export const ConfirmEnableUsers: FunctionComponent = ({ } )} isLoading={state.loading} - ownFocus >

    diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index ca4c869e0f2d38..c001f1fc2bc47d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -99,7 +99,7 @@ export const goToQueryTab = () => { export const addNotesToTimeline = (notes: string) => { goToNotesTab(); cy.get(NOTES_TEXT_AREA).type(notes); - cy.get(ADD_NOTE_BUTTON).click(); + cy.get(ADD_NOTE_BUTTON).click({ force: true }); cy.get(QUERY_TAB_BUTTON).click(); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/index.tsx b/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/index.tsx index 1b67aaeb795dd1..eb75d896ae7788 100644 --- a/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/index.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import * as i18n from './translations'; interface ConfirmDeleteCaseModalProps { @@ -28,20 +28,18 @@ const ConfirmDeleteCaseModalComp: React.FC = ({ return null; } return ( - - - {isPlural ? i18n.CONFIRM_QUESTION_PLURAL : i18n.CONFIRM_QUESTION} - - + + {isPlural ? i18n.CONFIRM_QUESTION_PLURAL : i18n.CONFIRM_QUESTION} + ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx index 1dfabda8068f17..eda8ed8cdfbcd5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx @@ -6,13 +6,7 @@ */ import React, { memo } from 'react'; -import { - EuiModal, - EuiModalBody, - EuiModalHeader, - EuiModalHeaderTitle, - EuiOverlayMask, -} from '@elastic/eui'; +import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; import { Case } from '../../containers/types'; @@ -34,16 +28,14 @@ const AllCasesModalComponent: React.FC = ({ const userCanCrud = userPermissions?.crud ?? false; return isModalOpen ? ( - - - - {i18n.SELECT_CASE_TITLE} - - - - - - + + + {i18n.SELECT_CASE_TITLE} + + + + + ) : null; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx index 3595f2c916af71..8dd5080666cb38 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx @@ -7,13 +7,7 @@ import React, { memo } from 'react'; import styled from 'styled-components'; -import { - EuiModal, - EuiModalBody, - EuiModalHeader, - EuiModalHeaderTitle, - EuiOverlayMask, -} from '@elastic/eui'; +import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { FormContext } from '../create/form_context'; import { CreateCaseForm } from '../create/form'; @@ -40,21 +34,19 @@ const CreateModalComponent: React.FC = ({ onSuccess, }) => { return isModalOpen ? ( - - - - {i18n.CREATE_TITLE} - - - - - - - - - - - + + + {i18n.CREATE_TITLE} + + + + + + + + + + ) : null; }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index dc7388438c012a..5ea11f61f9a7e5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -14,7 +14,6 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiModalFooter, - EuiOverlayMask, EuiButton, EuiButtonEmpty, EuiHorizontalRule, @@ -348,133 +347,129 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }, [maybeRule]); return ( - - - - {addExceptionMessage} - - {ruleName} - - - - {fetchOrCreateListError != null && ( - - - + + + {addExceptionMessage} + + {ruleName} + + + + {fetchOrCreateListError != null && ( + + + + )} + {fetchOrCreateListError == null && + (isLoadingExceptionList || + isIndexPatternLoading || + isSignalIndexLoading || + isSignalIndexPatternLoading) && ( + )} - {fetchOrCreateListError == null && - (isLoadingExceptionList || - isIndexPatternLoading || - isSignalIndexLoading || - isSignalIndexPatternLoading) && ( - - )} - {fetchOrCreateListError == null && - !isSignalIndexLoading && - !isSignalIndexPatternLoading && - !isLoadingExceptionList && - !isIndexPatternLoading && - !isRuleLoading && - !mlJobLoading && - ruleExceptionList && ( - <> - - {isRuleEQLSequenceStatement && ( - <> - - - - )} - {i18n.EXCEPTION_BUILDER_INFO} - - - - - - - - - - {alertData !== undefined && alertStatus !== 'closed' && ( - - - - )} + {fetchOrCreateListError == null && + !isSignalIndexLoading && + !isSignalIndexPatternLoading && + !isLoadingExceptionList && + !isIndexPatternLoading && + !isRuleLoading && + !mlJobLoading && + ruleExceptionList && ( + <> + + {isRuleEQLSequenceStatement && ( + <> + + + + )} + {i18n.EXCEPTION_BUILDER_INFO} + + + + + + + + + + {alertData !== undefined && alertStatus !== 'closed' && ( - {exceptionListType === 'endpoint' && ( - <> - - - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - - - )} - {fetchOrCreateListError == null && ( - - {i18n.CANCEL} - - - {addExceptionMessage} - - + )} + + + + {exceptionListType === 'endpoint' && ( + <> + + + {i18n.ENDPOINT_QUARANTINE_TEXT} + + + )} + + )} - - + {fetchOrCreateListError == null && ( + + {i18n.CANCEL} + + + {addExceptionMessage} + + + )} + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 75b7bf2aabd7fd..336732016e9369 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -12,7 +12,6 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiModalFooter, - EuiOverlayMask, EuiButton, EuiButtonEmpty, EuiHorizontalRule, @@ -281,125 +280,121 @@ export const EditExceptionModal = memo(function EditExceptionModal({ }, [maybeRule]); return ( - - - - - {exceptionListType === 'endpoint' - ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE - : i18n.EDIT_EXCEPTION_TITLE} - - - {ruleName} - - - {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( - - )} - {!isSignalIndexLoading && - !addExceptionIsLoading && - !isIndexPatternLoading && - !isRuleLoading && - !mlJobLoading && ( - <> - - {isRuleEQLSequenceStatement && ( - <> - - - - )} - {i18n.EXCEPTION_BUILDER_INFO} - - - - - - - - - - - + + + {exceptionListType === 'endpoint' + ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + : i18n.EDIT_EXCEPTION_TITLE} + + + {ruleName} + + + {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( + + )} + {!isSignalIndexLoading && + !addExceptionIsLoading && + !isIndexPatternLoading && + !isRuleLoading && + !mlJobLoading && ( + <> + + {isRuleEQLSequenceStatement && ( + <> + - - {exceptionListType === 'endpoint' && ( - <> - - - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - - - )} - {updateError != null && ( - - - - )} - {hasVersionConflict && ( - - -

    {i18n.VERSION_CONFLICT_ERROR_DESCRIPTION}

    - - - )} - {updateError == null && ( - - {i18n.CANCEL} - - - {i18n.EDIT_EXCEPTION_SAVE_BUTTON} - - + + + )} + {i18n.EXCEPTION_BUILDER_INFO} + + + + + + + + + + + + + {exceptionListType === 'endpoint' && ( + <> + + + {i18n.ENDPOINT_QUARANTINE_TEXT} + + + )} + + )} - - + {updateError != null && ( + + + + )} + {hasVersionConflict && ( + + +

    {i18n.VERSION_CONFLICT_ERROR_DESCRIPTION}

    +
    +
    + )} + {updateError == null && ( + + {i18n.CANCEL} + + + {i18n.EDIT_EXCEPTION_SAVE_BUTTON} + + + )} + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap index 6503dd8dfb5086..d1a41b1c32c102 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap @@ -2,64 +2,62 @@ exports[`ImportDataModal renders correctly against snapshot 1`] = ` - - - - - title - - - - -

    - description -

    -
    - - - - -
    - - - Cancel - - - submitBtnText - - -
    -
    + + + + title + + + + +

    + description +

    +
    + + + + +
    + + + Cancel + + + submitBtnText + + +
    `; diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx index 8a29ce3799321f..4c3dc2a249b4ff 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx @@ -15,7 +15,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, EuiText, } from '@elastic/eui'; @@ -132,51 +131,49 @@ export const ImportDataModalComponent = ({ return ( <> {showModal && ( - - - - {title} - - - - -

    {description}

    -
    - - - { - setSelectedFiles(files && files.length > 0 ? files : null); - }} - display={'large'} - fullWidth={true} - isLoading={isImporting} + + + {title} + + + + +

    {description}

    +
    + + + { + setSelectedFiles(files && files.length > 0 ? files : null); + }} + display={'large'} + fullWidth={true} + isLoading={isImporting} + /> + + {showCheckBox && ( + setOverwrite(!overwrite)} /> - - {showCheckBox && ( - setOverwrite(!overwrite)} - /> - )} -
    - - - {i18n.CANCEL_BUTTON} - - {submitBtnText} - - -
    -
    + )} + + + + {i18n.CANCEL_BUTTON} + + {submitBtnText} + + + )} ); diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx index ece29cd360ce71..a5c0144531110a 100644 --- a/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx +++ b/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx @@ -15,7 +15,6 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiModalFooter, - EuiOverlayMask, EuiSpacer, EuiTabbedContent, } from '@elastic/eui'; @@ -211,24 +210,22 @@ export const ModalInspectQuery = ({ ]; return ( - - - - - {i18n.INSPECT} {title} - - - - - - - - - - {i18n.CLOSE} - - - - + + + + {i18n.INSPECT} {title} + + + + + + + + + + {i18n.CLOSE} + + + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index 778916ad2d07ac..be5702550a44c8 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -246,6 +246,12 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, "euiFilePickerTallHeight": "128px", "euiFlyoutBorder": "1px solid #343741", + "euiFlyoutPaddingModifiers": Object { + "paddingLarge": "24px", + "paddingMedium": "16px", + "paddingNone": 0, + "paddingSmall": "8px", + }, "euiFocusBackgroundColor": "#08334a", "euiFocusRingAnimStartColor": "rgba(27, 169, 245, 0)", "euiFocusRingAnimStartSize": "6px", @@ -357,6 +363,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, "euiMarkdownEditorMinHeight": "150px", "euiPageBackgroundColor": "#1a1b20", + "euiPageDefaultMaxWidth": "1000px", "euiPaletteColorBlind": Object { "euiColorVis0": Object { "behindText": "#6dccb1", @@ -534,6 +541,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiSwitchWidthCompressed": "28px", "euiSwitchWidthMini": "22px", "euiTabFontSize": "16px", + "euiTabFontSizeL": "18px", "euiTabFontSizeS": "14px", "euiTableActionsAreaWidth": "40px", "euiTableActionsBorderColor": "rgba(83, 89, 102, 0.09999999999999998)", diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap index f7924f37d2c173..5e008e28073de1 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap @@ -1,50 +1,48 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Modal all errors rendering it renders the default all errors modal when isShowing is positive 1`] = ` - - - - - Your visualization has error(s) - - - - - - - - Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - - - - - - Close - - - - + + + + Your visualization has error(s) + + + + + + + + Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + + + + + Close + + + `; diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx index 873ebe97317f4f..0a78139f5fe3a1 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx @@ -7,7 +7,6 @@ import { EuiButton, - EuiOverlayMask, EuiModal, EuiModalHeader, EuiModalHeaderTitle, @@ -36,36 +35,34 @@ const ModalAllErrorsComponent: React.FC = ({ isShowing, toast, t if (!isShowing || toast == null) return null; return ( - - - - {i18n.TITLE_ERROR_MODAL} - + + + {i18n.TITLE_ERROR_MODAL} + - - - - {toast.errors != null && - toast.errors.map((error, index) => ( - 100 ? `${error.substring(0, 100)} ...` : error} - data-test-subj="modal-all-errors-accordion" - > - {error} - - ))} - + + + + {toast.errors != null && + toast.errors.map((error, index) => ( + 100 ? `${error.substring(0, 100)} ...` : error} + data-test-subj="modal-all-errors-accordion" + > + {error} + + ))} + - - - {i18n.CLOSE_ERROR_MODAL} - - - - + + + {i18n.CLOSE_ERROR_MODAL} + + + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx index adc46f08272d7e..aefa447269f46c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx @@ -15,7 +15,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiPanel, EuiSpacer, EuiText, @@ -211,7 +210,7 @@ export const ValueListsModalComponent: React.FC = ({ const columns = buildColumns(handleExport, handleDelete); return ( - + <> {i18n.MODAL_TITLE} @@ -255,7 +254,7 @@ export const ValueListsModalComponent: React.FC = ({ name={exportDownload.name} onDownload={() => setExportDownload({})} /> - + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/reference_error_modal/reference_error_modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/reference_error_modal/reference_error_modal.tsx index 20744c3a22515f..e4d8e2cee32685 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/reference_error_modal/reference_error_modal.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/reference_error_modal/reference_error_modal.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EuiListGroup, EuiListGroupItem, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal, EuiListGroup, EuiListGroupItem } from '@elastic/eui'; import styled from 'styled-components'; import { rgba } from 'polished'; @@ -59,28 +59,26 @@ export const ReferenceErrorModalComponent: React.FC = } return ( - - -

    {contentText}

    - - - {references.map((r, index) => ( - - ))} - - -
    -
    + +

    {contentText}

    + + + {references.map((r, index) => ( + + ))} + + +
    ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index 89785efbb5047a..04bf3c544030a4 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -9,7 +9,6 @@ import { EuiBasicTable, EuiLoadingContent, EuiProgress, - EuiOverlayMask, EuiConfirmModal, EuiWindowEvent, } from '@elastic/eui'; @@ -490,18 +489,16 @@ export const RulesTables = React.memo( )} {showIdleModal && ( - - -

    {i18n.REFRESH_PROMPT_BODY}

    -
    -
    + +

    {i18n.REFRESH_PROMPT_BODY}

    +
    )} {shouldShowRulesTable && ( <> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index ec0198de585589..e14f56881d6733 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -12,7 +12,6 @@ import { EuiButton, EuiButtonEmpty, EuiSpacer, - EuiOverlayMask, EuiConfirmModal, EuiCallOut, EuiLoadingSpinner, @@ -234,59 +233,54 @@ const ConfirmUpdate = React.memo<{ onCancel: () => void; }>(({ hostCount, onCancel, onConfirm }) => { return ( - - - {hostCount > 0 && ( - <> - - - - - - )} -

    - -

    -
    -
    + + {hostCount > 0 && ( + <> + + + + + + )} +

    + +

    +
    ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx index 4e3dc953b539e2..bffd9806103721 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiText, } from '@elastic/eui'; @@ -100,36 +99,34 @@ export const TrustedAppDeletionDialog = memo(() => { if (useTrustedAppsSelector(isDeletionDialogOpen)) { return ( - - - - {translations.title} - + + + {translations.title} + - - -

    {translations.mainMessage}

    -

    {translations.subMessage}

    -
    -
    + + +

    {translations.mainMessage}

    +

    {translations.subMessage}

    +
    +
    - - - {translations.cancelButton} - + + + {translations.cancelButton} + - - {translations.confirmButton} - - -
    -
    + + {translations.confirmButton} + + + ); } else { return <>; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx index a87f486a9d5d1f..7dde3fbe4cd2a6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiModal } from '@elastic/eui'; import React, { useCallback } from 'react'; import { createGlobalStyle } from 'styled-components'; @@ -46,16 +46,14 @@ export const DeleteTimelineModalOverlay = React.memo( <> {isModalOpen && } {isModalOpen ? ( - - - - - + + + ) : null} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/index.tsx index 5b7fbcffd14ad7..c23cffa854514f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiModal } from '@elastic/eui'; import React from 'react'; import { TimelineModel } from '../../../../timelines/store/timeline/model'; @@ -26,22 +26,20 @@ const OPEN_TIMELINE_MODAL_WIDTH = 1100; // px export const OpenTimelineModal = React.memo( ({ hideActions = [], modalTitle, onClose, onOpen }) => ( - - - - - + + + ) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 124c8012fd533c..aece377ee4f2dc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -287,7 +287,7 @@ Array [ data-eui="EuiFocusTrap" >
    - - {isSaving && ( - + + {isSaving && ( + + )} + {modalHeader} + + + {showWarning && ( + + + + )} - {modalHeader} - - - {showWarning && ( - - - - - )} -
    - - - - - - - - - - - - - {closeModalText} - - - - - {saveButtonTitle} - - - - -
    -
    -
    - +
    + + + + + + + + + + + + + {closeModalText} + + + + + {saveButtonTitle} + + + + +
    + +
    ); } ); diff --git a/x-pack/plugins/snapshot_restore/public/application/components/policy_delete_provider.tsx b/x-pack/plugins/snapshot_restore/public/application/components/policy_delete_provider.tsx index 0638d3349206d6..792538a730ebe8 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/policy_delete_provider.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/policy_delete_provider.tsx @@ -7,7 +7,7 @@ import React, { Fragment, useRef, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { useServices, useToastNotifications } from '../app_context'; import { deletePolicies } from '../services/http'; @@ -96,58 +96,56 @@ export const PolicyDeleteProvider: React.FunctionComponent = ({ children const isSingle = policyNames.length === 1; return ( - - - ) : ( - - ) - } - onCancel={closeModal} - onConfirm={deletePolicy} - cancelButtonText={ + - } - confirmButtonText={ + ) : ( - } - buttonColor="danger" - data-test-subj="srdeletePolicyConfirmationModal" - > - {!isSingle ? ( - -

    - -

    -
      - {policyNames.map((name) => ( -
    • {name}
    • - ))} -
    -
    - ) : null} -
    -
    + ) + } + onCancel={closeModal} + onConfirm={deletePolicy} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="danger" + data-test-subj="srdeletePolicyConfirmationModal" + > + {!isSingle ? ( + +

    + +

    +
      + {policyNames.map((name) => ( +
    • {name}
    • + ))} +
    +
    + ) : null} + ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/policy_execute_provider.tsx b/x-pack/plugins/snapshot_restore/public/application/components/policy_execute_provider.tsx index 3fcf5a35b34551..5636ca651b6285 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/policy_execute_provider.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/policy_execute_provider.tsx @@ -7,7 +7,7 @@ import React, { Fragment, useRef, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { useServices, useToastNotifications } from '../app_context'; import { executePolicy as executePolicyRequest } from '../services/http'; @@ -81,32 +81,30 @@ export const PolicyExecuteProvider: React.FunctionComponent = ({ children } return ( - - - } - onCancel={closeModal} - onConfirm={executePolicy} - cancelButtonText={ - - } - confirmButtonText={ - - } - data-test-subj="srExecutePolicyConfirmationModal" - /> - + + } + onCancel={closeModal} + onConfirm={executePolicy} + cancelButtonText={ + + } + confirmButtonText={ + + } + data-test-subj="srExecutePolicyConfirmationModal" + /> ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_delete_provider.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_delete_provider.tsx index 3009413541111c..f02f160958a203 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/repository_delete_provider.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_delete_provider.tsx @@ -7,7 +7,7 @@ import React, { Fragment, useRef, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { Repository } from '../../../common/types'; import { useServices, useToastNotifications } from '../app_context'; @@ -109,79 +109,77 @@ export const RepositoryDeleteProvider: React.FunctionComponent = ({ child const isSingle = repositoryNames.length === 1; return ( - - - ) : ( - - ) - } - onCancel={closeModal} - onConfirm={deleteRepository} - cancelButtonText={ + - } - confirmButtonText={ - isSingle ? ( - - ) : ( + ) : ( + + ) + } + onCancel={closeModal} + onConfirm={deleteRepository} + cancelButtonText={ + + } + confirmButtonText={ + isSingle ? ( + + ) : ( + + ) + } + buttonColor="danger" + data-test-subj="deleteRepositoryConfirmation" + > + {isSingle ? ( +

    + +

    + ) : ( + +

    - ) - } - buttonColor="danger" - data-test-subj="deleteRepositoryConfirmation" - > - {isSingle ? ( +

    +
      + {repositoryNames.map((name) => ( +
    • {name}
    • + ))} +

    - ) : ( - -

    - -

    -
      - {repositoryNames.map((name) => ( -
    • {name}
    • - ))} -
    -

    - -

    -
    - )} -
    -
    + + )} +
    ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/retention_execute_modal_provider.tsx b/x-pack/plugins/snapshot_restore/public/application/components/retention_execute_modal_provider.tsx index 9366815a0256e2..4ce1d93955952f 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/retention_execute_modal_provider.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/retention_execute_modal_provider.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { useServices, useToastNotifications } from '../app_context'; import { executeRetention as executeRetentionRequest } from '../services/http'; @@ -58,31 +58,29 @@ export const RetentionExecuteModalProvider: React.FunctionComponent = ({ } return ( - - - } - onCancel={closeModal} - onConfirm={executeRetention} - cancelButtonText={ - - } - confirmButtonText={ - - } - data-test-subj="executeRetentionModal" - /> - + + } + onCancel={closeModal} + onConfirm={executeRetention} + cancelButtonText={ + + } + confirmButtonText={ + + } + data-test-subj="executeRetentionModal" + /> ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/retention_update_modal_provider.tsx b/x-pack/plugins/snapshot_restore/public/application/components/retention_update_modal_provider.tsx index d8916ce9858f80..73e19eee8bf7a7 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/retention_update_modal_provider.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/retention_update_modal_provider.tsx @@ -8,7 +8,6 @@ import React, { Fragment, useRef, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiOverlayMask, EuiModal, EuiModalHeader, EuiModalHeaderTitle, @@ -129,165 +128,161 @@ export const RetentionSettingsUpdateModalProvider: React.FunctionComponent - - - - {isEditing ? ( - - ) : ( - - )} - - - - - {saveError && ( - - - } - color="danger" - iconType="alert" - > - {saveError.data && saveError.data.message ? ( -

    {saveError.data.message}

    - ) : null} -
    - -
    + + + + {isEditing ? ( + + ) : ( + )} - {isAdvancedCronVisible ? ( - - - } - isInvalid={isInvalid} - error={i18n.translate( - 'xpack.snapshotRestore.policyForm.stepRetention.policyUpdateRetentionScheduleFieldErrorMessage', - { - defaultMessage: 'Retention schedule is required.', - } - )} - helpText={ - - - - ), - }} - /> + + + + + {saveError && ( + + + } + color="danger" + iconType="alert" + > + {saveError.data && saveError.data.message ?

    {saveError.data.message}

    : null} +
    + +
    + )} + {isAdvancedCronVisible ? ( + + + } + isInvalid={isInvalid} + error={i18n.translate( + 'xpack.snapshotRestore.policyForm.stepRetention.policyUpdateRetentionScheduleFieldErrorMessage', + { + defaultMessage: 'Retention schedule is required.', } - fullWidth - > - setRetentionSchedule(e.target.value)} + )} + helpText={ + + + + ), + }} /> - + } + fullWidth + > + setRetentionSchedule(e.target.value)} + /> + - + - - { - setIsAdvancedCronVisible(false); - setRetentionSchedule(simpleCron.expression); - }} - data-test-subj="showBasicCronLink" - > - - - - - ) : ( - - { - setSimpleCron({ - expression, - frequency, - }); - setFieldToPreferredValueMap(newFieldToPreferredValueMap); - setRetentionSchedule(expression); + + { + setIsAdvancedCronVisible(false); + setRetentionSchedule(simpleCron.expression); }} - /> - - + data-test-subj="showBasicCronLink" + > + + + + + ) : ( + + { + setSimpleCron({ + expression, + frequency, + }); + setFieldToPreferredValueMap(newFieldToPreferredValueMap); + setRetentionSchedule(expression); + }} + /> - - { - setIsAdvancedCronVisible(true); - }} - data-test-subj="showAdvancedCronLink" - > - - - - - )} -
    + - - + + { + setIsAdvancedCronVisible(true); + }} + data-test-subj="showAdvancedCronLink" + > + + + + + )} +
    + + + + + + + + {isEditing ? ( - - - - {isEditing ? ( - - ) : ( - - )} - - -
    - + ) : ( + + )} + + + ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/snapshot_delete_provider.tsx b/x-pack/plugins/snapshot_restore/public/application/components/snapshot_delete_provider.tsx index 40af1b07a50bc1..74614efb314aae 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/snapshot_delete_provider.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/snapshot_delete_provider.tsx @@ -9,7 +9,6 @@ import React, { Fragment, useRef, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiConfirmModal, - EuiOverlayMask, EuiCallOut, EuiLoadingSpinner, EuiFlexGroup, @@ -118,95 +117,93 @@ export const SnapshotDeleteProvider: React.FunctionComponent = ({ childre const isSingle = snapshotIds.length === 1; return ( - - - ) : ( - - ) - } - onCancel={closeModal} - onConfirm={deleteSnapshot} - cancelButtonText={ + - } - confirmButtonText={ + ) : ( - } - confirmButtonDisabled={isDeleting} - buttonColor="danger" - data-test-subj="srdeleteSnapshotConfirmationModal" - > - {!isSingle ? ( - + ) + } + onCancel={closeModal} + onConfirm={deleteSnapshot} + cancelButtonText={ + + } + confirmButtonText={ + + } + confirmButtonDisabled={isDeleting} + buttonColor="danger" + data-test-subj="srdeleteSnapshotConfirmationModal" + > + {!isSingle ? ( + +

    + +

    +
      + {snapshotIds.map(({ snapshot, repository }) => ( +
    • {snapshot}
    • + ))} +
    +
    + ) : null} +

    + +

    + {!isSingle && isDeleting ? ( + + + + + + + + + + + + } + >

    -
      - {snapshotIds.map(({ snapshot, repository }) => ( -
    • {snapshot}
    • - ))} -
    -
    - ) : null} -

    - -

    - {!isSingle && isDeleting ? ( - - - - - - - - - - - - } - > -

    - -

    - - - ) : null} -
    -
    + + + ) : null} +
    ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/repository_details.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/repository_details.tsx index e7bdde2984d6f4..823ce3a122ef12 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/repository_details.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/repository_details.tsx @@ -281,7 +281,7 @@ export const RepositoryDetails: React.FunctionComponent = ({ {verification ? ( - + {JSON.stringify( verification.valid ? verification.response : verification.error, null, @@ -350,7 +350,7 @@ export const RepositoryDetails: React.FunctionComponent = ({ /> - + {JSON.stringify(cleanup.response, null, 2)}
    diff --git a/x-pack/plugins/spaces/public/management/components/confirm_delete_modal/__snapshots__/confirm_delete_modal.test.tsx.snap b/x-pack/plugins/spaces/public/management/components/confirm_delete_modal/__snapshots__/confirm_delete_modal.test.tsx.snap index b0d0933614d125..5bf93a1021c054 100644 --- a/x-pack/plugins/spaces/public/management/components/confirm_delete_modal/__snapshots__/confirm_delete_modal.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/components/confirm_delete_modal/__snapshots__/confirm_delete_modal.test.tsx.snap @@ -1,95 +1,93 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ConfirmDeleteModal renders as expected 1`] = ` - - - - + + + + + + + + +

    + + , } } /> - - - - -

    - - - , - } - } - /> -

    - - - -
    -
    - - - - - + - - - -
    -
    + + + + + + + + + + + + `; diff --git a/x-pack/plugins/spaces/public/management/components/confirm_delete_modal/confirm_delete_modal.tsx b/x-pack/plugins/spaces/public/management/components/confirm_delete_modal/confirm_delete_modal.tsx index c57bc1cef8fbee..94a5c082834ad7 100644 --- a/x-pack/plugins/spaces/public/management/components/confirm_delete_modal/confirm_delete_modal.tsx +++ b/x-pack/plugins/spaces/public/management/components/confirm_delete_modal/confirm_delete_modal.tsx @@ -20,7 +20,6 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiModalProps, - EuiOverlayMask, EuiSpacer, EuiText, } from '@elastic/eui'; @@ -97,88 +96,86 @@ class ConfirmDeleteModalUI extends Component { }; return ( - - - - + + + + + + + + +

    + + + ), }} /> - - - - -

    - - - - ), - }} - /> -

    - - - - - - {warning} -
    -
    - - + + - - - - - - - -
    -
    + + + {warning} + + + + + + + + + + + + ); } diff --git a/x-pack/plugins/spaces/public/management/edit_space/confirm_alter_active_space_modal/__snapshots__/confirm_alter_active_space_modal.test.tsx.snap b/x-pack/plugins/spaces/public/management/edit_space/confirm_alter_active_space_modal/__snapshots__/confirm_alter_active_space_modal.test.tsx.snap index 750afcfc44e7e3..3eb92de017927b 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/confirm_alter_active_space_modal/__snapshots__/confirm_alter_active_space_modal.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/edit_space/confirm_alter_active_space_modal/__snapshots__/confirm_alter_active_space_modal.test.tsx.snap @@ -1,28 +1,26 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ConfirmAlterActiveSpaceModal renders as expected 1`] = ` - - - } - > -

    - -

    -
    -
    + + } +> +

    + +

    +
    `; diff --git a/x-pack/plugins/spaces/public/management/edit_space/confirm_alter_active_space_modal/confirm_alter_active_space_modal.tsx b/x-pack/plugins/spaces/public/management/edit_space/confirm_alter_active_space_modal/confirm_alter_active_space_modal.tsx index 1839fbdfdda7da..c95bb7250a23e1 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/confirm_alter_active_space_modal/confirm_alter_active_space_modal.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/confirm_alter_active_space_modal/confirm_alter_active_space_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React from 'react'; @@ -16,34 +16,32 @@ interface Props { } const ConfirmAlterActiveSpaceModalUI: React.FC = (props) => ( - - - } - defaultFocusedButton={'confirm'} - cancelButtonText={props.intl.formatMessage({ - id: 'xpack.spaces.management.confirmAlterActiveSpaceModal.cancelButton', - defaultMessage: 'Cancel', - })} - confirmButtonText={props.intl.formatMessage({ - id: 'xpack.spaces.management.confirmAlterActiveSpaceModal.updateSpaceButton', - defaultMessage: 'Update space', - })} - > -

    - -

    -
    -
    + + } + defaultFocusedButton={'confirm'} + cancelButtonText={props.intl.formatMessage({ + id: 'xpack.spaces.management.confirmAlterActiveSpaceModal.cancelButton', + defaultMessage: 'Cancel', + })} + confirmButtonText={props.intl.formatMessage({ + id: 'xpack.spaces.management.confirmAlterActiveSpaceModal.updateSpaceButton', + defaultMessage: 'Update space', + })} + > +

    + +

    +
    ); export const ConfirmAlterActiveSpaceModal = injectI18n(ConfirmAlterActiveSpaceModalUI); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/switch_modal/switch_modal.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/switch_modal/switch_modal.tsx index b215b2af56c3a8..2d2a5b1fcad938 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/switch_modal/switch_modal.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/switch_modal/switch_modal.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; interface Props { onCancel: () => void; @@ -59,19 +59,17 @@ const cancelButtonText = i18n.translate( ); export const SwitchModal: FC = ({ onCancel, onConfirm, type }) => ( - - -

    {type === 'pivot' ? pivotModalMessage : sourceModalMessage}

    -
    -
    + +

    {type === 'pivot' ? pivotModalMessage : sourceModalMessage}

    +
    ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx index 148e6c1a3bac0c..d82f0769c8b74c 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx @@ -12,7 +12,6 @@ import { EuiConfirmModal, EuiFlexGroup, EuiFlexItem, - EuiOverlayMask, EuiSpacer, EuiSwitch, } from '@elastic/eui'; @@ -123,22 +122,20 @@ export const DeleteActionModal: FC = ({ ); return ( - - - {isBulkAction ? bulkDeleteModalContent : deleteModalContent} - - + + {isBulkAction ? bulkDeleteModalContent : deleteModalContent} + ); }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_modal.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_modal.tsx index c3967dd687a632..bb01fe355a33ec 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_modal.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_action_modal.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { StartAction } from './use_start_action'; @@ -24,27 +24,25 @@ export const StartActionModal: FC = ({ closeModal, items, startAndC }); return ( - - +

    + {i18n.translate('xpack.transform.transformList.startModalBody', { + defaultMessage: + 'A transform increases search and indexing load in your cluster. If excessive load is experienced, stop the transform.', })} - confirmButtonText={i18n.translate('xpack.transform.transformList.startModalStartButton', { - defaultMessage: 'Start', - })} - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - buttonColor="primary" - > -

    - {i18n.translate('xpack.transform.transformList.startModalBody', { - defaultMessage: - 'A transform increases search and indexing load in your cluster. If excessive load is experienced, stop the transform.', - })} -

    -
    -
    +

    + ); }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index b84d7fc433dfc1..bcb07c8069ab2f 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -14,7 +14,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiModal, - EuiOverlayMask, EuiPageContent, EuiPageContentBody, EuiSpacer, @@ -124,15 +123,13 @@ export const TransformManagement: FC = () => { {isSearchSelectionVisible && ( - - - - - + + + )} ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx index 952ea07ba05c33..b98db1178f4623 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; import { HttpSetup } from 'kibana/public'; @@ -73,56 +73,54 @@ export const DeleteModalConfirmation = ({ } ); return ( - - { - setDeleteModalVisibility(false); - onCancel(); - }} - onConfirm={async () => { - setDeleteModalVisibility(false); - setIsLoadingState(true); - const { successes, errors } = await apiDeleteCall({ ids: idsToDelete, http }); - setIsLoadingState(false); + { + setDeleteModalVisibility(false); + onCancel(); + }} + onConfirm={async () => { + setDeleteModalVisibility(false); + setIsLoadingState(true); + const { successes, errors } = await apiDeleteCall({ ids: idsToDelete, http }); + setIsLoadingState(false); - const numSuccesses = successes.length; - const numErrors = errors.length; - if (numSuccesses > 0) { - toasts.addSuccess( - i18n.translate( - 'xpack.triggersActionsUI.components.deleteSelectedIdsSuccessNotification.descriptionText', - { - defaultMessage: - 'Deleted {numSuccesses, number} {numSuccesses, plural, one {{singleTitle}} other {{multipleTitle}}}', - values: { numSuccesses, singleTitle, multipleTitle }, - } - ) - ); - } + const numSuccesses = successes.length; + const numErrors = errors.length; + if (numSuccesses > 0) { + toasts.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.components.deleteSelectedIdsSuccessNotification.descriptionText', + { + defaultMessage: + 'Deleted {numSuccesses, number} {numSuccesses, plural, one {{singleTitle}} other {{multipleTitle}}}', + values: { numSuccesses, singleTitle, multipleTitle }, + } + ) + ); + } - if (numErrors > 0) { - toasts.addDanger( - i18n.translate( - 'xpack.triggersActionsUI.components.deleteSelectedIdsErrorNotification.descriptionText', - { - defaultMessage: - 'Failed to delete {numErrors, number} {numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}', - values: { numErrors, singleTitle, multipleTitle }, - } - ) - ); - await onErrors(); - } - await onDeleted(successes); - }} - cancelButtonText={cancelButtonText} - confirmButtonText={confirmButtonText} - > - {confirmModalText} - - + if (numErrors > 0) { + toasts.addDanger( + i18n.translate( + 'xpack.triggersActionsUI.components.deleteSelectedIdsErrorNotification.descriptionText', + { + defaultMessage: + 'Failed to delete {numErrors, number} {numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}', + values: { numErrors, singleTitle, multipleTitle }, + } + ) + ); + await onErrors(); + } + await onDeleted(successes); + }} + cancelButtonText={cancelButtonText} + confirmButtonText={confirmButtonText} + > + {confirmModalText} + ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index b7450d742bc45b..8732727b9a77a8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -7,17 +7,19 @@ import React, { useCallback, useMemo, useReducer, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiTitle, EuiFlexItem, EuiIcon, EuiFlexGroup } from '@elastic/eui'; import { EuiModal, EuiButton, + EuiButtonEmpty, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, EuiModalFooter, + EuiTitle, + EuiFlexItem, + EuiIcon, + EuiFlexGroup, } from '@elastic/eui'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { EuiOverlayMask } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ActionConnectorForm, getConnectorErrors } from './action_connector_form'; import { createConnectorReducer, InitialConnector, ConnectorReducer } from './connector_reducer'; @@ -127,92 +129,90 @@ export const ConnectorAddModal = ({ }); return ( - - - - - - {actionTypeModel && actionTypeModel.iconClass ? ( - - - - ) : null} - - -

    - -

    -
    + + + + + {actionTypeModel && actionTypeModel.iconClass ? ( + + - - - + ) : null} + + +

    + +

    +
    +
    +
    +
    +
    - - - - - - {i18n.translate( - 'xpack.triggersActionsUI.sections.addModalConnectorForm.cancelButtonLabel', - { - defaultMessage: 'Cancel', + + + + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.addModalConnectorForm.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + )} + + {canSave ? ( + { + if (hasErrors) { + setConnector( + getConnectorWithInvalidatedFields( + connector, + configErrors, + secretsErrors, + connectorBaseErrors + ) + ); + return; } - )} - - {canSave ? ( - { - if (hasErrors) { - setConnector( - getConnectorWithInvalidatedFields( - connector, - configErrors, - secretsErrors, - connectorBaseErrors - ) - ); - return; - } - setIsSaving(true); - const savedAction = await onActionConnectorSave(); - setIsSaving(false); - if (savedAction) { - if (postSaveEventHandler) { - postSaveEventHandler(savedAction); - } - closeModal(); + setIsSaving(true); + const savedAction = await onActionConnectorSave(); + setIsSaving(false); + if (savedAction) { + if (postSaveEventHandler) { + postSaveEventHandler(savedAction); } - }} - > - - - ) : null} - -
    -
    + closeModal(); + } + }} + > + + + ) : null} + + ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/confirm_alert_close.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/confirm_alert_close.tsx index 9ef7e414d505e9..6d71fe858f1c12 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/confirm_alert_close.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/confirm_alert_close.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -17,38 +17,36 @@ interface Props { export const ConfirmAlertClose: React.FC = ({ onConfirm, onCancel }) => { return ( - - -

    - -

    -
    -
    + +

    + +

    +
    ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/confirm_alert_save.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/confirm_alert_save.tsx index 48d4229bb9b303..c406ec7c802837 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/confirm_alert_save.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/confirm_alert_save.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -17,38 +17,36 @@ interface Props { export const ConfirmAlertSave: React.FC = ({ onConfirm, onCancel }) => { return ( - - -

    - -

    -
    -
    + +

    + +

    +
    ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/manage_license_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/manage_license_modal.tsx index f13e5fd96d2ad8..4a5739c8b44309 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/manage_license_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/manage_license_modal.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { capitalize } from 'lodash'; interface Props { @@ -26,37 +26,35 @@ export const ManageLicenseModal: React.FC = ({ }) => { const licenseRequired = capitalize(licenseType); return ( - - -

    - -

    -
    -
    + +

    + +

    +
    ); }; diff --git a/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap index 31d4322210e2b7..b689ca7ff56f0e 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap @@ -342,6 +342,14 @@ exports[`DonutChart component passes correct props without errors for valid prop "band": Object { "fill": "rgba(245, 247, 250, 1)", }, + "crossLine": Object { + "dash": Array [ + 4, + 4, + ], + "stroke": "rgba(105, 112, 125, 1)", + "strokeWidth": 1, + }, "line": Object { "dash": Array [ 4, diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/confirm_delete.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/confirm_delete.test.tsx.snap index d83e45fea1aece..9d670158bc53a7 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/confirm_delete.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/confirm_delete.test.tsx.snap @@ -1,58 +1,54 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ML Confirm Job Delete shallow renders without errors 1`] = ` - - -

    - -

    -

    - -

    -
    -
    + +

    + +

    +

    + +

    +
    `; exports[`ML Confirm Job Delete shallow renders without errors while loading 1`] = ` - - -

    - - ) -

    - +

    + - - + ) +

    + +
    `; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/ml_flyout.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/ml_flyout.test.tsx.snap index fd59b14520ce17..23feec1e5181c7 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/ml_flyout.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__snapshots__/ml_flyout.test.tsx.snap @@ -84,7 +84,7 @@ exports[`ML Flyout component shows license info if no ml available 1`] = ` data-eui="EuiFocusTrap" >