From f9afe67f1e554cf3b295c4c43bf2b3f68c103120 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Tue, 19 Oct 2021 04:10:14 -0600 Subject: [PATCH 01/30] [Security Solution] Improves the formatting of array values and JSON in the Event and Alert Details panels (#115141) ## [Security Solution] Improves the formatting of array values and JSON in the Event and Alert Details panels This PR improves the formatting of array values and JSON in the Event and Alert details panels by: - in the `Table` tab, formatting array values such that each value appears on a separate line, (instead of joining the values on a single line) - in the `JSON` tab, displaying the raw search hit JSON, instead displaying a JSON representation based on the `Fields` API ### Table value formatting In the Event and Alert details `Table` tab, array values were joined on a single line, as shown in the _before_ screenshot below: ![event-details-value-formatting-before](https://user-images.githubusercontent.com/4459398/137524968-6450cd73-3154-457d-b850-32a3e7faaab2.png) _Above: (before) array values were joined on a single line_ Array values are now formatted such that each value appears on a separate line, as shown in the _after_ screenshot below: ![event-details-value-formatting-after](https://user-images.githubusercontent.com/4459398/137436705-b0bec735-5a83-402e-843a-2776e1c80da9.png) _Above: (after) array values each appear on a separte line_ ### JSON formatting The `JSON` tab previously displayed a JSON representation based on the `Fields` API. Array values were previously represented as a joined string, as shown in the _before_ screenshot below: ![event-details-json-formatting-before](https://user-images.githubusercontent.com/4459398/137525039-d1b14f21-5f9c-4201-905e-8b08f00bb5a0.png) _Above: (before) array values were previously represented as a joined string_ The `JSON` tab now displays the raw search hit JSON, per the _after_ screenshot below: ![event-details-json-formatting-after](https://user-images.githubusercontent.com/4459398/137437257-330c5b49-a4ad-418e-a976-923f7a35c0cf.png) _Above: (after) the `JSON` tab displays the raw search hit_ CC @monina-n @paulewing --- .../detection_alerts/alerts_details.spec.ts | 20 +- .../detection_alerts/cti_enrichments.spec.ts | 49 ++- .../cypress/screens/alerts_details.ts | 2 + .../alert_summary_view.test.tsx.snap | 120 ++++-- .../__snapshots__/json_view.test.tsx.snap | 343 ++++++++++++++++-- .../event_details/event_details.test.tsx | 3 +- .../event_details/event_details.tsx | 6 +- .../event_details/json_view.test.tsx | 49 +-- .../components/event_details/json_view.tsx | 23 +- .../table/field_value_cell.test.tsx | 193 ++++++++++ .../event_details/table/field_value_cell.tsx | 26 +- .../public/common/mock/mock_detail_item.ts | 188 ++++++++++ .../event_details/expandable_event.tsx | 3 + .../side_panel/event_details/index.tsx | 4 +- .../timelines/containers/details/index.tsx | 7 +- .../timeline/events/details/index.ts | 1 + .../timeline/factory/events/details/index.ts | 4 + 17 files changed, 872 insertions(+), 169 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts index 674114188632b..7b792f8d560f1 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { ALERT_FLYOUT, CELL_TEXT, JSON_LINES, TABLE_ROWS } from '../../screens/alerts_details'; +import { ALERT_FLYOUT, CELL_TEXT, JSON_TEXT, TABLE_ROWS } from '../../screens/alerts_details'; import { expandFirstAlert, waitForAlertsIndexToBeCreated, waitForAlertsPanelToBeLoaded, } from '../../tasks/alerts'; -import { openJsonView, openTable, scrollJsonViewToBottom } from '../../tasks/alerts_details'; +import { openJsonView, openTable } from '../../tasks/alerts_details'; import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; import { cleanKibana } from '../../tasks/common'; import { esArchiverLoad } from '../../tasks/es_archiver'; @@ -36,20 +36,14 @@ describe('Alert details with unmapped fields', () => { }); it('Displays the unmapped field on the JSON view', () => { - const expectedUnmappedField = { line: 2, text: ' "unmapped": "This is the unmapped field"' }; + const expectedUnmappedValue = 'This is the unmapped field'; openJsonView(); - scrollJsonViewToBottom(); - cy.get(ALERT_FLYOUT) - .find(JSON_LINES) - .then((elements) => { - const length = elements.length; - cy.wrap(elements) - .eq(length - expectedUnmappedField.line) - .invoke('text') - .should('include', expectedUnmappedField.text); - }); + cy.get(JSON_TEXT).then((x) => { + const parsed = JSON.parse(x.text()); + expect(parsed._source.unmapped).to.equal(expectedUnmappedValue); + }); }); it('Displays the unmapped field on the table', () => { diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts index b3c6abcd8e426..f15e7adbbca44 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts @@ -10,7 +10,7 @@ import { cleanKibana, reload } from '../../tasks/common'; import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { - JSON_LINES, + JSON_TEXT, TABLE_CELL, TABLE_ROWS, THREAT_DETAILS_VIEW, @@ -28,11 +28,7 @@ import { viewThreatIntelTab, } from '../../tasks/alerts'; import { createCustomIndicatorRule } from '../../tasks/api_calls/rules'; -import { - openJsonView, - openThreatIndicatorDetails, - scrollJsonViewToBottom, -} from '../../tasks/alerts_details'; +import { openJsonView, openThreatIndicatorDetails } from '../../tasks/alerts_details'; import { ALERTS_URL } from '../../urls/navigation'; import { addsFieldsToTimeline } from '../../tasks/rule_details'; @@ -76,26 +72,39 @@ describe('CTI Enrichment', () => { it('Displays persisted enrichments on the JSON view', () => { const expectedEnrichment = [ - { line: 4, text: ' "threat": {' }, { - line: 3, - text: ' "enrichments": "{\\"indicator\\":{\\"first_seen\\":\\"2021-03-10T08:02:14.000Z\\",\\"file\\":{\\"size\\":80280,\\"pe\\":{},\\"type\\":\\"elf\\",\\"hash\\":{\\"sha256\\":\\"a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3\\",\\"tlsh\\":\\"6D7312E017B517CC1371A8353BED205E9128223972AE35302E97528DF957703BAB2DBE\\",\\"ssdeep\\":\\"1536:87vbq1lGAXSEYQjbChaAU2yU23M51DjZgSQAvcYkFtZTjzBht5:8D+CAXFYQChaAUk5ljnQssL\\",\\"md5\\":\\"9b6c3518a91d23ed77504b5416bfb5b3\\"}},\\"type\\":\\"file\\"},\\"matched\\":{\\"atomic\\":\\"a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3\\",\\"field\\":\\"myhash.mysha256\\",\\"id\\":\\"84cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f\\",\\"index\\":\\"logs-ti_abusech.malware\\",\\"type\\":\\"indicator_match_rule\\"}}"', + indicator: { + first_seen: '2021-03-10T08:02:14.000Z', + file: { + size: 80280, + pe: {}, + type: 'elf', + hash: { + sha256: 'a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3', + tlsh: '6D7312E017B517CC1371A8353BED205E9128223972AE35302E97528DF957703BAB2DBE', + ssdeep: + '1536:87vbq1lGAXSEYQjbChaAU2yU23M51DjZgSQAvcYkFtZTjzBht5:8D+CAXFYQChaAUk5ljnQssL', + md5: '9b6c3518a91d23ed77504b5416bfb5b3', + }, + }, + type: 'file', + }, + matched: { + atomic: 'a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3', + field: 'myhash.mysha256', + id: '84cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f', + index: 'logs-ti_abusech.malware', + type: 'indicator_match_rule', + }, }, - { line: 2, text: ' }' }, ]; expandFirstAlert(); openJsonView(); - scrollJsonViewToBottom(); - - cy.get(JSON_LINES).then((elements) => { - const length = elements.length; - expectedEnrichment.forEach((enrichment) => { - cy.wrap(elements) - .eq(length - enrichment.line) - .invoke('text') - .should('include', enrichment.text); - }); + + cy.get(JSON_TEXT).then((x) => { + const parsed = JSON.parse(x.text()); + expect(parsed._source.threat.enrichments).to.deep.equal(expectedEnrichment); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts index c740a669d059a..584fba05452f0 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts @@ -28,6 +28,8 @@ export const JSON_LINES = '.euiCodeBlock__line'; export const JSON_VIEW_TAB = '[data-test-subj="jsonViewTab"]'; +export const JSON_TEXT = '[data-test-subj="jsonView"]'; + export const TABLE_CELL = '.euiTableRowCell'; export const TABLE_TAB = '[data-test-subj="tableTab"]'; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap index d367c68586be1..930e1282ebca5 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap @@ -138,12 +138,17 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1` class="euiTableCellContent flyoutOverviewDescription euiTableCellContent--overflowingContent" >
- open +
+ open +
- xxx +
+ xxx +
- low +
+ low +
- 21 +
+ 21 +
- windows-native +
+ windows-native +
- administrator +
+ administrator +
- open +
+ open +
- xxx +
+ xxx +
- low +
+ low +
- 21 +
+ 21 +
- windows-native +
+ windows-native +
- administrator +
+ administrator +
{ - "_id": "pEMaMmkBUV60JmNWmWVi", - "_index": "filebeat-8.0.0-2019.02.19-000001", + "_index": ".ds-logs-endpoint.events.network-default-2021.09.28-000001", + "_id": "TUWyf3wBFCFU0qRJTauW", "_score": 1, - "_type": "_doc", - "@timestamp": "2019-02-28T16:50:54.621Z", - "agent": { - "ephemeral_id": "9d391ef2-a734-4787-8891-67031178c641", - "hostname": "siem-kibana", - "id": "5de03d5f-52f3-482e-91d4-853c7de073c3", - "type": "filebeat", - "version": "8.0.0" - }, - "cloud": { - "availability_zone": "projects/189716325846/zones/us-east1-b", - "instance": { - "id": "5412578377715150143", - "name": "siem-kibana" + "_source": { + "agent": { + "id": "2ac9e9b3-f6d5-4ce6-915d-8f1f8f413624", + "type": "endpoint", + "version": "8.0.0-SNAPSHOT" }, - "machine": { - "type": "projects/189716325846/machineTypes/n1-standard-1" + "process": { + "Ext": { + "ancestry": [ + "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyMzY0LTEzMjc4NjA2NTAyLjA=", + "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTEtMTMyNzA3Njg2OTIuMA==" + ] + }, + "name": "filebeat", + "pid": 22535, + "entity_id": "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyNTM1LTEzMjc4NjA2NTI4LjA=", + "executable": "/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat" }, - "project": { - "id": "elastic-beats" + "destination": { + "address": "127.0.0.1", + "port": 9200, + "ip": "127.0.0.1" }, - "provider": "gce" - }, - "destination": { - "bytes": 584, - "ip": "10.47.8.200", - "packets": 4, - "port": 902 + "source": { + "address": "127.0.0.1", + "port": 54146, + "ip": "127.0.0.1" + }, + "message": "Endpoint network event", + "network": { + "transport": "tcp", + "type": "ipv4" + }, + "@timestamp": "2021-10-14T16:45:58.0310772Z", + "ecs": { + "version": "1.11.0" + }, + "data_stream": { + "namespace": "default", + "type": "logs", + "dataset": "endpoint.events.network" + }, + "elastic": { + "agent": { + "id": "12345" + } + }, + "host": { + "hostname": "test-linux-1", + "os": { + "Ext": { + "variant": "Debian" + }, + "kernel": "4.19.0-17-cloud-amd64 #1 SMP Debian 4.19.194-2 (2021-06-21)", + "name": "Linux", + "family": "debian", + "type": "linux", + "version": "10", + "platform": "debian", + "full": "Debian 10" + }, + "ip": [ + "127.0.0.1", + "::1", + "10.1.2.3", + "2001:0DB8:AC10:FE01::" + ], + "name": "test-linux-1", + "id": "76ea303129f249aa7382338e4263eac1", + "mac": [ + "aa:bb:cc:dd:ee:ff" + ], + "architecture": "x86_64" + }, + "event": { + "agent_id_status": "verified", + "sequence": 44872, + "ingested": "2021-10-14T16:46:04Z", + "created": "2021-10-14T16:45:58.0310772Z", + "kind": "event", + "module": "endpoint", + "action": "connection_attempted", + "id": "MKPXftjGeHiQzUNj++++nn6R", + "category": [ + "network" + ], + "type": [ + "start" + ], + "dataset": "endpoint.events.network", + "outcome": "unknown" + }, + "user": { + "Ext": { + "real": { + "name": "root", + "id": 0 + } + }, + "name": "root", + "id": 0 + }, + "group": { + "Ext": { + "real": { + "name": "root", + "id": 0 + } + }, + "name": "root", + "id": 0 + } }, - "event": { - "kind": "event" + "fields": { + "host.os.full.text": [ + "Debian 10" + ], + "event.category": [ + "network" + ], + "process.name.text": [ + "filebeat" + ], + "host.os.name.text": [ + "Linux" + ], + "host.os.full": [ + "Debian 10" + ], + "host.hostname": [ + "test-linux-1" + ], + "process.pid": [ + 22535 + ], + "host.mac": [ + "42:01:0a:c8:00:32" + ], + "elastic.agent.id": [ + "abcdefg-f6d5-4ce6-915d-8f1f8f413624" + ], + "host.os.version": [ + "10" + ], + "host.os.name": [ + "Linux" + ], + "source.ip": [ + "127.0.0.1" + ], + "destination.address": [ + "127.0.0.1" + ], + "host.name": [ + "test-linux-1" + ], + "event.agent_id_status": [ + "verified" + ], + "event.kind": [ + "event" + ], + "event.outcome": [ + "unknown" + ], + "group.name": [ + "root" + ], + "user.id": [ + "0" + ], + "host.os.type": [ + "linux" + ], + "process.Ext.ancestry": [ + "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyMzY0LTEzMjc4NjA2NTAyLjA=", + "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTEtMTMyNzA3Njg2OTIuMA==" + ], + "user.Ext.real.id": [ + "0" + ], + "data_stream.type": [ + "logs" + ], + "host.architecture": [ + "x86_64" + ], + "process.name": [ + "filebeat" + ], + "agent.id": [ + "2ac9e9b3-f6d5-4ce6-915d-8f1f8f413624" + ], + "source.port": [ + 54146 + ], + "ecs.version": [ + "1.11.0" + ], + "event.created": [ + "2021-10-14T16:45:58.031Z" + ], + "agent.version": [ + "8.0.0-SNAPSHOT" + ], + "host.os.family": [ + "debian" + ], + "destination.port": [ + 9200 + ], + "group.id": [ + "0" + ], + "user.name": [ + "root" + ], + "source.address": [ + "127.0.0.1" + ], + "process.entity_id": [ + "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyNTM1LTEzMjc4NjA2NTI4LjA=" + ], + "host.ip": [ + "127.0.0.1", + "::1", + "10.1.2.3", + "2001:0DB8:AC10:FE01::" + ], + "process.executable.caseless": [ + "/opt/elastic/agent/data/elastic-agent-058c40/install/filebeat-8.0.0-snapshot-linux-x86_64/filebeat" + ], + "event.sequence": [ + 44872 + ], + "agent.type": [ + "endpoint" + ], + "process.executable.text": [ + "/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat" + ], + "group.Ext.real.name": [ + "root" + ], + "event.module": [ + "endpoint" + ], + "host.os.kernel": [ + "4.19.0-17-cloud-amd64 #1 SMP Debian 4.19.194-2 (2021-06-21)" + ], + "host.os.full.caseless": [ + "debian 10" + ], + "host.id": [ + "76ea303129f249aa7382338e4263eac1" + ], + "process.name.caseless": [ + "filebeat" + ], + "network.type": [ + "ipv4" + ], + "process.executable": [ + "/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat" + ], + "user.Ext.real.name": [ + "root" + ], + "data_stream.namespace": [ + "default" + ], + "message": [ + "Endpoint network event" + ], + "destination.ip": [ + "127.0.0.1" + ], + "network.transport": [ + "tcp" + ], + "host.os.Ext.variant": [ + "Debian" + ], + "group.Ext.real.id": [ + "0" + ], + "event.ingested": [ + "2021-10-14T16:46:04.000Z" + ], + "event.action": [ + "connection_attempted" + ], + "@timestamp": [ + "2021-10-14T16:45:58.031Z" + ], + "host.os.platform": [ + "debian" + ], + "data_stream.dataset": [ + "endpoint.events.network" + ], + "event.type": [ + "start" + ], + "event.id": [ + "MKPXftjGeHiQzUNj++++nn6R" + ], + "host.os.name.caseless": [ + "linux" + ], + "event.dataset": [ + "endpoint.events.network" + ], + "user.name.text": [ + "root" + ] } } diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index a8ba536a75541..37ca3b0b897a6 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import '../../mock/match_media'; import '../../mock/react_beautiful_dnd'; -import { mockDetailItemData, mockDetailItemDataId, TestProviders } from '../../mock'; +import { mockDetailItemData, mockDetailItemDataId, rawEventData, TestProviders } from '../../mock'; import { EventDetails, EventsViewType } from './event_details'; import { mockBrowserFields } from '../../containers/source/mock'; @@ -48,6 +48,7 @@ describe('EventDetails', () => { timelineId: 'test', eventView: EventsViewType.summaryView, hostRisk: { fields: [], loading: true }, + rawEventData, }; const alertsProps = { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index e7092d9d6f466..a8305a635f157 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -61,6 +61,7 @@ interface Props { id: string; isAlert: boolean; isDraggable?: boolean; + rawEventData: object | undefined; timelineTabType: TimelineTabs | 'flyout'; timelineId: string; hostRisk: HostRisk | null; @@ -106,6 +107,7 @@ const EventDetailsComponent: React.FC = ({ id, isAlert, isDraggable, + rawEventData, timelineId, timelineTabType, hostRisk, @@ -278,12 +280,12 @@ const EventDetailsComponent: React.FC = ({ <> - + ), }), - [data] + [rawEventData] ); const tabs = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx index 696fac6016603..b20270266602d 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx @@ -8,58 +8,15 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { mockDetailItemData } from '../../mock'; +import { rawEventData } from '../../mock'; -import { buildJsonView, JsonView } from './json_view'; +import { JsonView } from './json_view'; describe('JSON View', () => { describe('rendering', () => { test('should match snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); }); - - describe('buildJsonView', () => { - test('should match a json', () => { - const expectedData = { - '@timestamp': '2019-02-28T16:50:54.621Z', - _id: 'pEMaMmkBUV60JmNWmWVi', - _index: 'filebeat-8.0.0-2019.02.19-000001', - _score: 1, - _type: '_doc', - agent: { - ephemeral_id: '9d391ef2-a734-4787-8891-67031178c641', - hostname: 'siem-kibana', - id: '5de03d5f-52f3-482e-91d4-853c7de073c3', - type: 'filebeat', - version: '8.0.0', - }, - cloud: { - availability_zone: 'projects/189716325846/zones/us-east1-b', - instance: { - id: '5412578377715150143', - name: 'siem-kibana', - }, - machine: { - type: 'projects/189716325846/machineTypes/n1-standard-1', - }, - project: { - id: 'elastic-beats', - }, - provider: 'gce', - }, - destination: { - bytes: 584, - ip: '10.47.8.200', - packets: 4, - port: 902, - }, - event: { - kind: 'event', - }, - }; - expect(buildJsonView(mockDetailItemData)).toEqual(expectedData); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx index 0614f131bcd10..0227d44f32305 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx @@ -6,15 +6,13 @@ */ import { EuiCodeBlock } from '@elastic/eui'; -import { set } from '@elastic/safer-lodash-set/fp'; import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; import { omitTypenameAndEmpty } from '../../../timelines/components/timeline/body/helpers'; interface Props { - data: TimelineEventsDetailsItem[]; + rawEventData: object | undefined; } const EuiCodeEditorContainer = styled.div` @@ -23,15 +21,15 @@ const EuiCodeEditorContainer = styled.div` } `; -export const JsonView = React.memo(({ data }) => { +export const JsonView = React.memo(({ rawEventData }) => { const value = useMemo( () => JSON.stringify( - buildJsonView(data), + rawEventData, omitTypenameAndEmpty, 2 // indent level ), - [data] + [rawEventData] ); return ( @@ -50,16 +48,3 @@ export const JsonView = React.memo(({ data }) => { }); JsonView.displayName = 'JsonView'; - -export const buildJsonView = (data: TimelineEventsDetailsItem[]) => - data - .sort((a, b) => a.field.localeCompare(b.field)) - .reduce( - (accumulator, item) => - set( - item.field, - Array.isArray(item.originalValue) ? item.originalValue.join() : item.originalValue, - accumulator - ), - {} - ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx new file mode 100644 index 0000000000000..f6c43da2da8ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx @@ -0,0 +1,193 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { BrowserField } from '../../../containers/source'; +import { FieldValueCell } from './field_value_cell'; +import { TestProviders } from '../../../mock'; +import { EventFieldsData } from '../types'; + +const contextId = 'test'; + +const eventId = 'TUWyf3wBFCFU0qRJTauW'; + +const hostIpData: EventFieldsData = { + aggregatable: true, + ariaRowindex: 35, + category: 'host', + description: 'Host ip addresses.', + example: '127.0.0.1', + field: 'host.ip', + fields: {}, + format: '', + indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], + isObjectArray: false, + name: 'host.ip', + originalValue: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'], + searchable: true, + type: 'ip', + values: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'], +}; +const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', 'fe80::4001:aff:fec8:32']; + +describe('FieldValueCell', () => { + describe('common behavior', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it formats multiple values such that each value is displayed on a single line', () => { + expect(screen.getByTestId(`event-field-${hostIpData.field}`)).toHaveClass( + 'euiFlexGroup--directionColumn' + ); + }); + }); + + describe('when `BrowserField` metadata is NOT available', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it renders each of the expected values when `fieldFromBrowserField` is undefined', () => { + hostIpValues.forEach((value) => { + expect(screen.getByText(value)).toBeInTheDocument(); + }); + }); + + test('it renders values formatted as plain text (without `eventFieldsTable__fieldValue` formatting)', () => { + expect(screen.getByTestId(`event-field-${hostIpData.field}`).firstChild).not.toHaveClass( + 'eventFieldsTable__fieldValue' + ); + }); + }); + + describe('`message` field formatting', () => { + const messageData: EventFieldsData = { + aggregatable: false, + ariaRowindex: 50, + category: 'base', + description: + 'For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.', + example: 'Hello World', + field: 'message', + fields: {}, + format: '', + indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], + isObjectArray: false, + name: 'message', + originalValue: ['Endpoint network event'], + searchable: true, + type: 'string', + values: ['Endpoint network event'], + }; + const messageValues = ['Endpoint network event']; + + const messageFieldFromBrowserField: BrowserField = { + aggregatable: false, + category: 'base', + description: + 'For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.', + example: 'Hello World', + fields: {}, + format: '', + indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], + name: 'message', + searchable: true, + type: 'string', + }; + + beforeEach(() => { + render( + + + + ); + }); + + test('it renders special formatting for the `message` field', () => { + expect(screen.getByTestId('event-field-message')).toBeInTheDocument(); + }); + + test('it renders the expected message value', () => { + messageValues.forEach((value) => { + expect(screen.getByText(value)).toBeInTheDocument(); + }); + }); + }); + + describe('when `BrowserField` metadata IS available', () => { + const hostIpFieldFromBrowserField: BrowserField = { + aggregatable: true, + category: 'host', + description: 'Host ip addresses.', + example: '127.0.0.1', + fields: {}, + format: '', + indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], + name: 'host.ip', + searchable: true, + type: 'ip', + }; + + beforeEach(() => { + render( + + + + ); + }); + + test('it renders values formatted with the expected class', () => { + expect(screen.getByTestId(`event-field-${hostIpData.field}`).firstChild).toHaveClass( + 'eventFieldsTable__fieldValue' + ); + }); + + test('it renders link buttons for each of the host ip addresses', () => { + expect(screen.getAllByRole('button').length).toBe(hostIpValues.length); + }); + + test('it renders each of the expected values when `fieldFromBrowserField` is provided', () => { + hostIpValues.forEach((value) => { + expect(screen.getByText(value)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx index fc20f84d3650d..dc6c84b8138fe 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { BrowserField } from '../../../containers/source'; import { OverflowField } from '../../tables/helpers'; import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field'; @@ -36,18 +36,28 @@ export const FieldValueCell = React.memo( values, }: FieldValueCellProps) => { return ( -
+ {values != null && values.map((value, i) => { if (fieldFromBrowserField == null) { return ( - - {value} - + + + {value} + + ); } return ( -
+ {data.field === MESSAGE_FIELD_NAME ? ( ) : ( @@ -63,10 +73,10 @@ export const FieldValueCell = React.memo( linkValue={(getLinkValue && getLinkValue(data.field)) ?? linkValue} /> )} -
+ ); })} -
+ ); } ); diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts b/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts index 3712d389edeb1..035bdbbceff88 100644 --- a/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts +++ b/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts @@ -139,3 +139,191 @@ export const generateMockDetailItemData = (): TimelineEventsDetailsItem[] => [ ]; export const mockDetailItemData: TimelineEventsDetailsItem[] = generateMockDetailItemData(); + +export const rawEventData = { + _index: '.ds-logs-endpoint.events.network-default-2021.09.28-000001', + _id: 'TUWyf3wBFCFU0qRJTauW', + _score: 1, + _source: { + agent: { + id: '2ac9e9b3-f6d5-4ce6-915d-8f1f8f413624', + type: 'endpoint', + version: '8.0.0-SNAPSHOT', + }, + process: { + Ext: { + ancestry: [ + 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyMzY0LTEzMjc4NjA2NTAyLjA=', + 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTEtMTMyNzA3Njg2OTIuMA==', + ], + }, + name: 'filebeat', + pid: 22535, + entity_id: 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyNTM1LTEzMjc4NjA2NTI4LjA=', + executable: + '/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat', + }, + destination: { + address: '127.0.0.1', + port: 9200, + ip: '127.0.0.1', + }, + source: { + address: '127.0.0.1', + port: 54146, + ip: '127.0.0.1', + }, + message: 'Endpoint network event', + network: { + transport: 'tcp', + type: 'ipv4', + }, + '@timestamp': '2021-10-14T16:45:58.0310772Z', + ecs: { + version: '1.11.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.network', + }, + elastic: { + agent: { + id: '12345', + }, + }, + host: { + hostname: 'test-linux-1', + os: { + Ext: { + variant: 'Debian', + }, + kernel: '4.19.0-17-cloud-amd64 #1 SMP Debian 4.19.194-2 (2021-06-21)', + name: 'Linux', + family: 'debian', + type: 'linux', + version: '10', + platform: 'debian', + full: 'Debian 10', + }, + ip: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'], + name: 'test-linux-1', + id: '76ea303129f249aa7382338e4263eac1', + mac: ['aa:bb:cc:dd:ee:ff'], + architecture: 'x86_64', + }, + event: { + agent_id_status: 'verified', + sequence: 44872, + ingested: '2021-10-14T16:46:04Z', + created: '2021-10-14T16:45:58.0310772Z', + kind: 'event', + module: 'endpoint', + action: 'connection_attempted', + id: 'MKPXftjGeHiQzUNj++++nn6R', + category: ['network'], + type: ['start'], + dataset: 'endpoint.events.network', + outcome: 'unknown', + }, + user: { + Ext: { + real: { + name: 'root', + id: 0, + }, + }, + name: 'root', + id: 0, + }, + group: { + Ext: { + real: { + name: 'root', + id: 0, + }, + }, + name: 'root', + id: 0, + }, + }, + fields: { + 'host.os.full.text': ['Debian 10'], + 'event.category': ['network'], + 'process.name.text': ['filebeat'], + 'host.os.name.text': ['Linux'], + 'host.os.full': ['Debian 10'], + 'host.hostname': ['test-linux-1'], + 'process.pid': [22535], + 'host.mac': ['42:01:0a:c8:00:32'], + 'elastic.agent.id': ['abcdefg-f6d5-4ce6-915d-8f1f8f413624'], + 'host.os.version': ['10'], + 'host.os.name': ['Linux'], + 'source.ip': ['127.0.0.1'], + 'destination.address': ['127.0.0.1'], + 'host.name': ['test-linux-1'], + 'event.agent_id_status': ['verified'], + 'event.kind': ['event'], + 'event.outcome': ['unknown'], + 'group.name': ['root'], + 'user.id': ['0'], + 'host.os.type': ['linux'], + 'process.Ext.ancestry': [ + 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyMzY0LTEzMjc4NjA2NTAyLjA=', + 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTEtMTMyNzA3Njg2OTIuMA==', + ], + 'user.Ext.real.id': ['0'], + 'data_stream.type': ['logs'], + 'host.architecture': ['x86_64'], + 'process.name': ['filebeat'], + 'agent.id': ['2ac9e9b3-f6d5-4ce6-915d-8f1f8f413624'], + 'source.port': [54146], + 'ecs.version': ['1.11.0'], + 'event.created': ['2021-10-14T16:45:58.031Z'], + 'agent.version': ['8.0.0-SNAPSHOT'], + 'host.os.family': ['debian'], + 'destination.port': [9200], + 'group.id': ['0'], + 'user.name': ['root'], + 'source.address': ['127.0.0.1'], + 'process.entity_id': [ + 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyNTM1LTEzMjc4NjA2NTI4LjA=', + ], + 'host.ip': ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'], + 'process.executable.caseless': [ + '/opt/elastic/agent/data/elastic-agent-058c40/install/filebeat-8.0.0-snapshot-linux-x86_64/filebeat', + ], + 'event.sequence': [44872], + 'agent.type': ['endpoint'], + 'process.executable.text': [ + '/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat', + ], + 'group.Ext.real.name': ['root'], + 'event.module': ['endpoint'], + 'host.os.kernel': ['4.19.0-17-cloud-amd64 #1 SMP Debian 4.19.194-2 (2021-06-21)'], + 'host.os.full.caseless': ['debian 10'], + 'host.id': ['76ea303129f249aa7382338e4263eac1'], + 'process.name.caseless': ['filebeat'], + 'network.type': ['ipv4'], + 'process.executable': [ + '/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat', + ], + 'user.Ext.real.name': ['root'], + 'data_stream.namespace': ['default'], + message: ['Endpoint network event'], + 'destination.ip': ['127.0.0.1'], + 'network.transport': ['tcp'], + 'host.os.Ext.variant': ['Debian'], + 'group.Ext.real.id': ['0'], + 'event.ingested': ['2021-10-14T16:46:04.000Z'], + 'event.action': ['connection_attempted'], + '@timestamp': ['2021-10-14T16:45:58.031Z'], + 'host.os.platform': ['debian'], + 'data_stream.dataset': ['endpoint.events.network'], + 'event.type': ['start'], + 'event.id': ['MKPXftjGeHiQzUNj++++nn6R'], + 'host.os.name.caseless': ['linux'], + 'event.dataset': ['endpoint.events.network'], + 'user.name.text': ['root'], + }, +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 17d43d80a5a9a..6a7f0602c3675 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -33,6 +33,7 @@ interface Props { isDraggable?: boolean; loading: boolean; messageHeight?: number; + rawEventData: object | undefined; timelineTabType: TimelineTabs | 'flyout'; timelineId: string; hostRisk: HostRisk | null; @@ -93,6 +94,7 @@ export const ExpandableEvent = React.memo( loading, detailsData, hostRisk, + rawEventData, }) => { if (!event.eventId) { return {i18n.EVENT_DETAILS_PLACEHOLDER}; @@ -111,6 +113,7 @@ export const ExpandableEvent = React.memo( id={event.eventId} isAlert={isAlert} isDraggable={isDraggable} + rawEventData={rawEventData} timelineId={timelineId} timelineTabType={timelineTabType} hostRisk={hostRisk} diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index f8786e0706834..b9d7e0a8c024f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -79,7 +79,7 @@ const EventDetailsPanelComponent: React.FC = ({ tabType, timelineId, }) => { - const [loading, detailsData] = useTimelineEventsDetails({ + const [loading, detailsData, rawEventData] = useTimelineEventsDetails({ docValueFields, entityType, indexName: expandedEvent.indexName ?? '', @@ -195,6 +195,7 @@ const EventDetailsPanelComponent: React.FC = ({ isAlert={isAlert} isDraggable={isDraggable} loading={loading} + rawEventData={rawEventData} timelineId={timelineId} timelineTabType="flyout" hostRisk={hostRisk} @@ -228,6 +229,7 @@ const EventDetailsPanelComponent: React.FC = ({ isAlert={isAlert} isDraggable={isDraggable} loading={loading} + rawEventData={rawEventData} timelineId={timelineId} timelineTabType={tabType} hostRisk={hostRisk} diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index e59eaeed4f2a6..f05966bd97870 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -42,7 +42,7 @@ export const useTimelineEventsDetails = ({ indexName, eventId, skip, -}: UseTimelineEventsDetailsProps): [boolean, EventsArgs['detailsData']] => { +}: UseTimelineEventsDetailsProps): [boolean, EventsArgs['detailsData'], object | undefined] => { const { data } = useKibana().services; const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); @@ -55,6 +55,8 @@ export const useTimelineEventsDetails = ({ const [timelineDetailsResponse, setTimelineDetailsResponse] = useState(null); + const [rawEventData, setRawEventData] = useState(undefined); + const timelineDetailsSearch = useCallback( (request: TimelineEventsDetailsRequestOptions | null) => { if (request == null || skip || isEmpty(request.eventId)) { @@ -78,6 +80,7 @@ export const useTimelineEventsDetails = ({ if (isCompleteResponse(response)) { setLoading(false); setTimelineDetailsResponse(response.data || []); + setRawEventData(response.rawResponse.hits.hits[0]); searchSubscription$.current.unsubscribe(); } else if (isErrorResponse(response)) { setLoading(false); @@ -125,5 +128,5 @@ export const useTimelineEventsDetails = ({ }; }, [timelineDetailsRequest, timelineDetailsSearch]); - return [loading, timelineDetailsResponse]; + return [loading, timelineDetailsResponse, rawEventData]; }; diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts index 5bceb31081687..f9f6a2ea57917 100644 --- a/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts @@ -24,6 +24,7 @@ export interface TimelineEventsDetailsItem { export interface TimelineEventsDetailsStrategyResponse extends IEsSearchResponse { data?: Maybe; inspect?: Maybe; + rawEventData?: Maybe; } export interface TimelineEventsDetailsRequestOptions diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts index c82d9af938a98..b60add2515ec9 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts @@ -57,10 +57,14 @@ export const timelineEventsDetails: TimelineFactory Date: Tue, 19 Oct 2021 12:14:57 +0200 Subject: [PATCH 02/30] Allow elastic/fleet-server to call appropriate Fleet APIs (#113932) --- x-pack/plugins/fleet/server/mocks/index.ts | 12 +- x-pack/plugins/fleet/server/plugin.ts | 55 ++++-- .../fleet/server/routes/agent_policy/index.ts | 27 +-- .../routes/enrollment_api_key/handler.ts | 6 +- .../server/routes/enrollment_api_key/index.ts | 21 ++- .../plugins/fleet/server/routes/epm/index.ts | 39 ++-- .../fleet/server/routes/security.test.ts | 175 ++++++++++++++++++ .../plugins/fleet/server/routes/security.ts | 135 +++++++++++--- .../fleet/server/routes/setup/handlers.ts | 8 +- .../fleet/server/routes/setup/index.ts | 21 +-- .../fleet/server/types/request_context.ts | 7 + .../authorization/check_privileges.test.ts | 108 +++++++++++ .../server/authorization/check_privileges.ts | 31 +++- .../check_privileges_dynamically.test.ts | 22 ++- .../check_privileges_dynamically.ts | 14 +- .../security/server/authorization/types.ts | 26 ++- .../apis/security/privileges.ts | 2 +- .../apis/security/privileges_basic.ts | 2 +- .../apis/agents/services.ts | 26 ++- .../fleet_api_integration/apis/epm/setup.ts | 45 +++++ 20 files changed, 651 insertions(+), 131 deletions(-) create mode 100644 x-pack/plugins/fleet/server/routes/security.test.ts diff --git a/x-pack/plugins/fleet/server/mocks/index.ts b/x-pack/plugins/fleet/server/mocks/index.ts index c7f6b6fefc414..e6577426974a3 100644 --- a/x-pack/plugins/fleet/server/mocks/index.ts +++ b/x-pack/plugins/fleet/server/mocks/index.ts @@ -23,7 +23,17 @@ import type { FleetAppContext } from '../plugin'; // Export all mocks from artifacts export * from '../services/artifacts/mocks'; -export const createAppContextStartContractMock = (): FleetAppContext => { +export interface MockedFleetAppContext extends FleetAppContext { + elasticsearch: ReturnType; + data: ReturnType; + encryptedSavedObjectsStart?: ReturnType; + savedObjects: ReturnType; + securitySetup?: ReturnType; + securityStart?: ReturnType; + logger: ReturnType['get']>; +} + +export const createAppContextStartContractMock = (): MockedFleetAppContext => { const config = { agents: { enabled: true, elasticsearch: {} }, enabled: true, diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index aaee24b39685a..8a95065380b69 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -80,9 +80,10 @@ import { } from './services/agents'; import { registerFleetUsageCollector } from './collectors/register'; import { getInstallation, ensureInstalledPackage } from './services/epm/packages'; -import { makeRouterEnforcingSuperuser } from './routes/security'; +import { RouterWrappers } from './routes/security'; import { startFleetServerSetup } from './services/fleet_server'; import { FleetArtifactsClient } from './services/artifacts'; +import type { FleetRouter } from './types/request_context'; export interface FleetSetupDeps { licensing: LicensingPluginSetup; @@ -206,6 +207,24 @@ export class FleetPlugin category: DEFAULT_APP_CATEGORIES.management, app: [PLUGIN_ID, INTEGRATIONS_PLUGIN_ID, 'kibana'], catalogue: ['fleet'], + reserved: { + description: + 'Privilege to setup Fleet packages and configured policies. Intended for use by the elastic/fleet-server service account only.', + privileges: [ + { + id: 'fleet-setup', + privilege: { + excludeFromBasePrivileges: true, + api: ['fleet-setup'], + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + ], + }, privileges: { all: { api: [`${PLUGIN_ID}-read`, `${PLUGIN_ID}-all`], @@ -245,7 +264,7 @@ export class FleetPlugin }) ); - const router = core.http.createRouter(); + const router: FleetRouter = core.http.createRouter(); // Register usage collection registerFleetUsageCollector(core, config, deps.usageCollection); @@ -254,24 +273,34 @@ export class FleetPlugin registerAppRoutes(router); // Allow read-only users access to endpoints necessary for Integrations UI // Only some endpoints require superuser so we pass a raw IRouter here - registerEPMRoutes(router); // For all the routes we enforce the user to have role superuser - const routerSuperuserOnly = makeRouterEnforcingSuperuser(router); + const superuserRouter = RouterWrappers.require.superuser(router); + const fleetSetupRouter = RouterWrappers.require.fleetSetupPrivilege(router); + + // Some EPM routes use regular rbac to support integrations app + registerEPMRoutes({ rbac: router, superuser: superuserRouter }); + // Register rest of routes only if security is enabled if (deps.security) { - registerSetupRoutes(routerSuperuserOnly, config); - registerAgentPolicyRoutes(routerSuperuserOnly); - registerPackagePolicyRoutes(routerSuperuserOnly); - registerOutputRoutes(routerSuperuserOnly); - registerSettingsRoutes(routerSuperuserOnly); - registerDataStreamRoutes(routerSuperuserOnly); - registerPreconfigurationRoutes(routerSuperuserOnly); + registerSetupRoutes(fleetSetupRouter, config); + registerAgentPolicyRoutes({ + fleetSetup: fleetSetupRouter, + superuser: superuserRouter, + }); + registerPackagePolicyRoutes(superuserRouter); + registerOutputRoutes(superuserRouter); + registerSettingsRoutes(superuserRouter); + registerDataStreamRoutes(superuserRouter); + registerPreconfigurationRoutes(superuserRouter); // Conditional config routes if (config.agents.enabled) { - registerAgentAPIRoutes(routerSuperuserOnly, config); - registerEnrollmentApiKeyRoutes(routerSuperuserOnly); + registerAgentAPIRoutes(superuserRouter, config); + registerEnrollmentApiKeyRoutes({ + fleetSetup: fleetSetupRouter, + superuser: superuserRouter, + }); } } } diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/index.ts b/x-pack/plugins/fleet/server/routes/agent_policy/index.ts index a66a9ab9cadc7..4c20358e15085 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/index.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { IRouter } from 'src/core/server'; - import { PLUGIN_ID, AGENT_POLICY_API_ROUTES } from '../../constants'; import { GetAgentPoliciesRequestSchema, @@ -17,6 +15,7 @@ import { DeleteAgentPolicyRequestSchema, GetFullAgentPolicyRequestSchema, } from '../../types'; +import type { FleetRouter } from '../../types/request_context'; import { getAgentPoliciesHandler, @@ -29,19 +28,21 @@ import { downloadFullAgentPolicy, } from './handlers'; -export const registerRoutes = (router: IRouter) => { - // List - router.get( +export const registerRoutes = (routers: { superuser: FleetRouter; fleetSetup: FleetRouter }) => { + // List - Fleet Server needs access to run setup + routers.fleetSetup.get( { path: AGENT_POLICY_API_ROUTES.LIST_PATTERN, validate: GetAgentPoliciesRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 + // Required to allow elastic/fleet-server to access this API. + // options: { tags: [`access:${PLUGIN_ID}-read`] }, }, getAgentPoliciesHandler ); // Get one - router.get( + routers.superuser.get( { path: AGENT_POLICY_API_ROUTES.INFO_PATTERN, validate: GetOneAgentPolicyRequestSchema, @@ -51,7 +52,7 @@ export const registerRoutes = (router: IRouter) => { ); // Create - router.post( + routers.superuser.post( { path: AGENT_POLICY_API_ROUTES.CREATE_PATTERN, validate: CreateAgentPolicyRequestSchema, @@ -61,7 +62,7 @@ export const registerRoutes = (router: IRouter) => { ); // Update - router.put( + routers.superuser.put( { path: AGENT_POLICY_API_ROUTES.UPDATE_PATTERN, validate: UpdateAgentPolicyRequestSchema, @@ -71,7 +72,7 @@ export const registerRoutes = (router: IRouter) => { ); // Copy - router.post( + routers.superuser.post( { path: AGENT_POLICY_API_ROUTES.COPY_PATTERN, validate: CopyAgentPolicyRequestSchema, @@ -81,7 +82,7 @@ export const registerRoutes = (router: IRouter) => { ); // Delete - router.post( + routers.superuser.post( { path: AGENT_POLICY_API_ROUTES.DELETE_PATTERN, validate: DeleteAgentPolicyRequestSchema, @@ -91,7 +92,7 @@ export const registerRoutes = (router: IRouter) => { ); // Get one full agent policy - router.get( + routers.superuser.get( { path: AGENT_POLICY_API_ROUTES.FULL_INFO_PATTERN, validate: GetFullAgentPolicyRequestSchema, @@ -101,7 +102,7 @@ export const registerRoutes = (router: IRouter) => { ); // Download one full agent policy - router.get( + routers.superuser.get( { path: AGENT_POLICY_API_ROUTES.FULL_INFO_DOWNLOAD_PATTERN, validate: GetFullAgentPolicyRequestSchema, diff --git a/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts b/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts index 0959a9a88704a..9cb07a9050f83 100644 --- a/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts +++ b/x-pack/plugins/fleet/server/routes/enrollment_api_key/handler.ts @@ -27,7 +27,8 @@ export const getEnrollmentApiKeysHandler: RequestHandler< undefined, TypeOf > = async (context, request, response) => { - const esClient = context.core.elasticsearch.client.asCurrentUser; + // Use kibana_system and depend on authz checks on HTTP layer to prevent abuse + const esClient = context.core.elasticsearch.client.asInternalUser; try { const { items, total, page, perPage } = await APIKeyService.listEnrollmentApiKeys(esClient, { @@ -87,7 +88,8 @@ export const deleteEnrollmentApiKeyHandler: RequestHandler< export const getOneEnrollmentApiKeyHandler: RequestHandler< TypeOf > = async (context, request, response) => { - const esClient = context.core.elasticsearch.client.asCurrentUser; + // Use kibana_system and depend on authz checks on HTTP layer to prevent abuse + const esClient = context.core.elasticsearch.client.asInternalUser; try { const apiKey = await APIKeyService.getEnrollmentAPIKey(esClient, request.params.keyId); const body: GetOneEnrollmentAPIKeyResponse = { item: apiKey }; diff --git a/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts b/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts index b37a88e70e085..6429d4d29d5c9 100644 --- a/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts +++ b/x-pack/plugins/fleet/server/routes/enrollment_api_key/index.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { IRouter } from 'src/core/server'; - import { PLUGIN_ID, ENROLLMENT_API_KEY_ROUTES } from '../../constants'; import { GetEnrollmentAPIKeysRequestSchema, @@ -14,6 +12,7 @@ import { DeleteEnrollmentAPIKeyRequestSchema, PostEnrollmentAPIKeyRequestSchema, } from '../../types'; +import type { FleetRouter } from '../../types/request_context'; import { getEnrollmentApiKeysHandler, @@ -22,17 +21,19 @@ import { postEnrollmentApiKeyHandler, } from './handler'; -export const registerRoutes = (router: IRouter) => { - router.get( +export const registerRoutes = (routers: { superuser: FleetRouter; fleetSetup: FleetRouter }) => { + routers.fleetSetup.get( { path: ENROLLMENT_API_KEY_ROUTES.INFO_PATTERN, validate: GetOneEnrollmentAPIKeyRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 + // Required to allow elastic/fleet-server to access this API. + // options: { tags: [`access:${PLUGIN_ID}-read`] }, }, getOneEnrollmentApiKeyHandler ); - router.delete( + routers.superuser.delete( { path: ENROLLMENT_API_KEY_ROUTES.DELETE_PATTERN, validate: DeleteEnrollmentAPIKeyRequestSchema, @@ -41,16 +42,18 @@ export const registerRoutes = (router: IRouter) => { deleteEnrollmentApiKeyHandler ); - router.get( + routers.fleetSetup.get( { path: ENROLLMENT_API_KEY_ROUTES.LIST_PATTERN, validate: GetEnrollmentAPIKeysRequestSchema, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 + // Required to allow elastic/fleet-server to access this API. + // options: { tags: [`access:${PLUGIN_ID}-read`] }, }, getEnrollmentApiKeysHandler ); - router.post( + routers.superuser.post( { path: ENROLLMENT_API_KEY_ROUTES.CREATE_PATTERN, validate: PostEnrollmentAPIKeyRequestSchema, diff --git a/x-pack/plugins/fleet/server/routes/epm/index.ts b/x-pack/plugins/fleet/server/routes/epm/index.ts index 360f2ec1d446e..a2f2df4a00c55 100644 --- a/x-pack/plugins/fleet/server/routes/epm/index.ts +++ b/x-pack/plugins/fleet/server/routes/epm/index.ts @@ -5,10 +5,7 @@ * 2.0. */ -import type { IRouter } from 'src/core/server'; - import { PLUGIN_ID, EPM_API_ROUTES } from '../../constants'; -import type { FleetRequestHandlerContext } from '../../types'; import { GetCategoriesRequestSchema, GetPackagesRequestSchema, @@ -21,7 +18,7 @@ import { GetStatsRequestSchema, UpdatePackageRequestSchema, } from '../../types'; -import { enforceSuperUser } from '../security'; +import type { FleetRouter } from '../../types/request_context'; import { getCategoriesHandler, @@ -39,8 +36,8 @@ import { const MAX_FILE_SIZE_BYTES = 104857600; // 100MB -export const registerRoutes = (router: IRouter) => { - router.get( +export const registerRoutes = (routers: { rbac: FleetRouter; superuser: FleetRouter }) => { + routers.rbac.get( { path: EPM_API_ROUTES.CATEGORIES_PATTERN, validate: GetCategoriesRequestSchema, @@ -49,7 +46,7 @@ export const registerRoutes = (router: IRouter) => { getCategoriesHandler ); - router.get( + routers.rbac.get( { path: EPM_API_ROUTES.LIST_PATTERN, validate: GetPackagesRequestSchema, @@ -58,7 +55,7 @@ export const registerRoutes = (router: IRouter) => { getListHandler ); - router.get( + routers.rbac.get( { path: EPM_API_ROUTES.LIMITED_LIST_PATTERN, validate: false, @@ -67,7 +64,7 @@ export const registerRoutes = (router: IRouter) => { getLimitedListHandler ); - router.get( + routers.rbac.get( { path: EPM_API_ROUTES.STATS_PATTERN, validate: GetStatsRequestSchema, @@ -76,7 +73,7 @@ export const registerRoutes = (router: IRouter) => { getStatsHandler ); - router.get( + routers.rbac.get( { path: EPM_API_ROUTES.FILEPATH_PATTERN, validate: GetFileRequestSchema, @@ -85,7 +82,7 @@ export const registerRoutes = (router: IRouter) => { getFileHandler ); - router.get( + routers.rbac.get( { path: EPM_API_ROUTES.INFO_PATTERN, validate: GetInfoRequestSchema, @@ -94,34 +91,34 @@ export const registerRoutes = (router: IRouter) => { getInfoHandler ); - router.put( + routers.superuser.put( { path: EPM_API_ROUTES.INFO_PATTERN, validate: UpdatePackageRequestSchema, options: { tags: [`access:${PLUGIN_ID}-all`] }, }, - enforceSuperUser(updatePackageHandler) + updatePackageHandler ); - router.post( + routers.superuser.post( { path: EPM_API_ROUTES.INSTALL_FROM_REGISTRY_PATTERN, validate: InstallPackageFromRegistryRequestSchema, options: { tags: [`access:${PLUGIN_ID}-all`] }, }, - enforceSuperUser(installPackageFromRegistryHandler) + installPackageFromRegistryHandler ); - router.post( + routers.superuser.post( { path: EPM_API_ROUTES.BULK_INSTALL_PATTERN, validate: BulkUpgradePackagesFromRegistryRequestSchema, options: { tags: [`access:${PLUGIN_ID}-all`] }, }, - enforceSuperUser(bulkInstallPackagesFromRegistryHandler) + bulkInstallPackagesFromRegistryHandler ); - router.post( + routers.superuser.post( { path: EPM_API_ROUTES.INSTALL_BY_UPLOAD_PATTERN, validate: InstallPackageByUploadRequestSchema, @@ -134,15 +131,15 @@ export const registerRoutes = (router: IRouter) => { }, }, }, - enforceSuperUser(installPackageByUploadHandler) + installPackageByUploadHandler ); - router.delete( + routers.superuser.delete( { path: EPM_API_ROUTES.DELETE_PATTERN, validate: DeletePackageRequestSchema, options: { tags: [`access:${PLUGIN_ID}-all`] }, }, - enforceSuperUser(deletePackageHandler) + deletePackageHandler ); }; diff --git a/x-pack/plugins/fleet/server/routes/security.test.ts b/x-pack/plugins/fleet/server/routes/security.test.ts new file mode 100644 index 0000000000000..80ea142541530 --- /dev/null +++ b/x-pack/plugins/fleet/server/routes/security.test.ts @@ -0,0 +1,175 @@ +/* + * 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 { IRouter, RequestHandler, RouteConfig } from '../../../../../src/core/server'; +import { coreMock } from '../../../../../src/core/server/mocks'; +import type { AuthenticatedUser } from '../../../security/server'; +import type { CheckPrivilegesDynamically } from '../../../security/server/authorization/check_privileges_dynamically'; +import { createAppContextStartContractMock } from '../mocks'; +import { appContextService } from '../services'; + +import type { RouterWrapper } from './security'; +import { RouterWrappers } from './security'; + +describe('RouterWrappers', () => { + const runTest = async ({ + wrapper, + security: { + roles = [], + pluginEnabled = true, + licenseEnabled = true, + checkPrivilegesDynamically, + } = {}, + }: { + wrapper: RouterWrapper; + security?: { + roles?: string[]; + pluginEnabled?: boolean; + licenseEnabled?: boolean; + checkPrivilegesDynamically?: CheckPrivilegesDynamically; + }; + }) => { + const fakeRouter = { + get: jest.fn(), + } as unknown as jest.Mocked; + const fakeHandler: RequestHandler = jest.fn((ctx, req, res) => res.ok()); + + const mockContext = createAppContextStartContractMock(); + // @ts-expect-error type doesn't properly respect deeply mocked keys + mockContext.securityStart?.authz.actions.api.get.mockImplementation((priv) => `api:${priv}`); + + if (!pluginEnabled) { + mockContext.securitySetup = undefined; + mockContext.securityStart = undefined; + } else { + mockContext.securityStart?.authc.getCurrentUser.mockReturnValue({ + username: 'foo', + roles, + } as unknown as AuthenticatedUser); + + mockContext.securitySetup?.license.isEnabled.mockReturnValue(licenseEnabled); + if (licenseEnabled) { + mockContext.securityStart?.authz.mode.useRbacForRequest.mockReturnValue(true); + } + + if (checkPrivilegesDynamically) { + mockContext.securityStart?.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue( + checkPrivilegesDynamically + ); + } + } + + appContextService.start(mockContext); + + const wrappedRouter = wrapper(fakeRouter); + wrappedRouter.get({} as RouteConfig, fakeHandler); + const wrappedHandler = fakeRouter.get.mock.calls[0][1]; + const resFactory = { forbidden: jest.fn(() => 'forbidden'), ok: jest.fn(() => 'ok') }; + const res = await wrappedHandler( + { core: coreMock.createRequestHandlerContext() }, + {} as any, + resFactory as any + ); + + return res as unknown as 'forbidden' | 'ok'; + }; + + describe('require.superuser', () => { + it('allow users with the superuser role', async () => { + expect( + await runTest({ + wrapper: RouterWrappers.require.superuser, + security: { roles: ['superuser'] }, + }) + ).toEqual('ok'); + }); + + it('does not allow users without the superuser role', async () => { + expect( + await runTest({ + wrapper: RouterWrappers.require.superuser, + security: { roles: ['foo'] }, + }) + ).toEqual('forbidden'); + }); + + it('does not allow security plugin to be disabled', async () => { + expect( + await runTest({ + wrapper: RouterWrappers.require.superuser, + security: { pluginEnabled: false }, + }) + ).toEqual('forbidden'); + }); + + it('does not allow security license to be disabled', async () => { + expect( + await runTest({ + wrapper: RouterWrappers.require.superuser, + security: { licenseEnabled: false }, + }) + ).toEqual('forbidden'); + }); + }); + + describe('require.fleetSetupPrivilege', () => { + const mockCheckPrivileges: jest.Mock< + ReturnType, + Parameters + > = jest.fn().mockResolvedValue({ hasAllRequested: true }); + + it('executes custom authz check', async () => { + await runTest({ + wrapper: RouterWrappers.require.fleetSetupPrivilege, + security: { checkPrivilegesDynamically: mockCheckPrivileges }, + }); + expect(mockCheckPrivileges).toHaveBeenCalledWith( + { kibana: ['api:fleet-setup'] }, + { + requireLoginAction: false, + } + ); + }); + + it('allow users with required privileges', async () => { + expect( + await runTest({ + wrapper: RouterWrappers.require.fleetSetupPrivilege, + security: { checkPrivilegesDynamically: mockCheckPrivileges }, + }) + ).toEqual('ok'); + }); + + it('does not allow users without required privileges', async () => { + mockCheckPrivileges.mockResolvedValueOnce({ hasAllRequested: false } as any); + expect( + await runTest({ + wrapper: RouterWrappers.require.fleetSetupPrivilege, + security: { checkPrivilegesDynamically: mockCheckPrivileges }, + }) + ).toEqual('forbidden'); + }); + + it('does not allow security plugin to be disabled', async () => { + expect( + await runTest({ + wrapper: RouterWrappers.require.fleetSetupPrivilege, + security: { pluginEnabled: false }, + }) + ).toEqual('forbidden'); + }); + + it('does not allow security license to be disabled', async () => { + expect( + await runTest({ + wrapper: RouterWrappers.require.fleetSetupPrivilege, + security: { licenseEnabled: false }, + }) + ).toEqual('forbidden'); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/routes/security.ts b/x-pack/plugins/fleet/server/routes/security.ts index 33a510c27f04e..8a67a7066742a 100644 --- a/x-pack/plugins/fleet/server/routes/security.ts +++ b/x-pack/plugins/fleet/server/routes/security.ts @@ -5,56 +5,137 @@ * 2.0. */ -import type { IRouter, RequestHandler, RequestHandlerContext } from 'src/core/server'; +import type { + IRouter, + KibanaRequest, + RequestHandler, + RequestHandlerContext, +} from 'src/core/server'; import { appContextService } from '../services'; -export function enforceSuperUser( +const SUPERUSER_AUTHZ_MESSAGE = + 'Access to Fleet API requires the superuser role and for stack security features to be enabled.'; + +function checkSecurityEnabled() { + return appContextService.hasSecurity() && appContextService.getSecurityLicense().isEnabled(); +} + +function checkSuperuser(req: KibanaRequest) { + if (!checkSecurityEnabled()) { + return false; + } + + const security = appContextService.getSecurity(); + const user = security.authc.getCurrentUser(req); + if (!user) { + return false; + } + + const userRoles = user.roles || []; + if (!userRoles.includes('superuser')) { + return false; + } + + return true; +} + +function enforceSuperuser( handler: RequestHandler ): RequestHandler { return function enforceSuperHandler(context, req, res) { - if (!appContextService.hasSecurity() || !appContextService.getSecurityLicense().isEnabled()) { + const isSuperuser = checkSuperuser(req); + if (!isSuperuser) { return res.forbidden({ body: { - message: `Access to this API requires that security is enabled`, + message: SUPERUSER_AUTHZ_MESSAGE, }, }); } - const security = appContextService.getSecurity(); - const user = security.authc.getCurrentUser(req); - if (!user) { - return res.forbidden({ - body: { - message: - 'Access to Fleet API require the superuser role, and for stack security features to be enabled.', - }, - }); - } + return handler(context, req, res); + }; +} - const userRoles = user.roles || []; - if (!userRoles.includes('superuser')) { - return res.forbidden({ - body: { - message: 'Access to Fleet API require the superuser role.', - }, - }); +function makeRouterEnforcingSuperuser( + router: IRouter +): IRouter { + return { + get: (options, handler) => router.get(options, enforceSuperuser(handler)), + delete: (options, handler) => router.delete(options, enforceSuperuser(handler)), + post: (options, handler) => router.post(options, enforceSuperuser(handler)), + put: (options, handler) => router.put(options, enforceSuperuser(handler)), + patch: (options, handler) => router.patch(options, enforceSuperuser(handler)), + handleLegacyErrors: (handler) => router.handleLegacyErrors(handler), + getRoutes: () => router.getRoutes(), + routerPath: router.routerPath, + }; +} + +async function checkFleetSetupPrivilege(req: KibanaRequest) { + if (!checkSecurityEnabled()) { + return false; + } + + const security = appContextService.getSecurity(); + + if (security.authz.mode.useRbacForRequest(req)) { + const checkPrivileges = security.authz.checkPrivilegesDynamicallyWithRequest(req); + const { hasAllRequested } = await checkPrivileges( + { kibana: [security.authz.actions.api.get('fleet-setup')] }, + { requireLoginAction: false } // exclude login access requirement + ); + + return !!hasAllRequested; + } + + return true; +} + +function enforceFleetSetupPrivilege( + handler: RequestHandler +): RequestHandler { + return async (context, req, res) => { + const hasFleetSetupPrivilege = await checkFleetSetupPrivilege(req); + if (!hasFleetSetupPrivilege) { + return res.forbidden({ body: { message: SUPERUSER_AUTHZ_MESSAGE } }); } + return handler(context, req, res); }; } -export function makeRouterEnforcingSuperuser( +function makeRouterEnforcingFleetSetupPrivilege( router: IRouter ): IRouter { return { - get: (options, handler) => router.get(options, enforceSuperUser(handler)), - delete: (options, handler) => router.delete(options, enforceSuperUser(handler)), - post: (options, handler) => router.post(options, enforceSuperUser(handler)), - put: (options, handler) => router.put(options, enforceSuperUser(handler)), - patch: (options, handler) => router.patch(options, enforceSuperUser(handler)), + get: (options, handler) => router.get(options, enforceFleetSetupPrivilege(handler)), + delete: (options, handler) => router.delete(options, enforceFleetSetupPrivilege(handler)), + post: (options, handler) => router.post(options, enforceFleetSetupPrivilege(handler)), + put: (options, handler) => router.put(options, enforceFleetSetupPrivilege(handler)), + patch: (options, handler) => router.patch(options, enforceFleetSetupPrivilege(handler)), handleLegacyErrors: (handler) => router.handleLegacyErrors(handler), getRoutes: () => router.getRoutes(), routerPath: router.routerPath, }; } + +export type RouterWrapper = (route: IRouter) => IRouter; + +interface RouterWrappersSetup { + require: { + superuser: RouterWrapper; + fleetSetupPrivilege: RouterWrapper; + }; +} + +export const RouterWrappers: RouterWrappersSetup = { + require: { + superuser: (router) => { + return makeRouterEnforcingSuperuser(router); + }, + fleetSetupPrivilege: (router) => { + return makeRouterEnforcingFleetSetupPrivilege(router); + }, + }, +}; diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.ts index c5b2ef0ade26f..fad5d93c3f5d5 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { RequestHandler } from 'src/core/server'; - import { appContextService } from '../../services'; import type { GetFleetStatusResponse, PostFleetSetupResponse } from '../../../common'; import { setupFleet } from '../../services/setup'; @@ -14,12 +12,14 @@ import { hasFleetServers } from '../../services/fleet_server'; import { defaultIngestErrorHandler } from '../../errors'; import type { FleetRequestHandler } from '../../types'; -export const getFleetStatusHandler: RequestHandler = async (context, request, response) => { +export const getFleetStatusHandler: FleetRequestHandler = async (context, request, response) => { try { const isApiKeysEnabled = await appContextService .getSecurity() .authc.apiKeys.areAPIKeysEnabled(); - const isFleetServerSetup = await hasFleetServers(appContextService.getInternalUserESClient()); + const isFleetServerSetup = await hasFleetServers( + context.core.elasticsearch.client.asInternalUser + ); const missingRequirements: GetFleetStatusResponse['missing_requirements'] = []; if (!isApiKeysEnabled) { diff --git a/x-pack/plugins/fleet/server/routes/setup/index.ts b/x-pack/plugins/fleet/server/routes/setup/index.ts index 591b9c832172d..d191f1b78e9ae 100644 --- a/x-pack/plugins/fleet/server/routes/setup/index.ts +++ b/x-pack/plugins/fleet/server/routes/setup/index.ts @@ -5,55 +5,48 @@ * 2.0. */ -import type { IRouter } from 'src/core/server'; - import { PLUGIN_ID, AGENTS_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants'; import type { FleetConfigType } from '../../../common'; -import type { FleetRequestHandlerContext } from '../../types/request_context'; +import type { FleetRouter } from '../../types/request_context'; import { getFleetStatusHandler, fleetSetupHandler } from './handlers'; -export const registerFleetSetupRoute = (router: IRouter) => { +export const registerFleetSetupRoute = (router: FleetRouter) => { router.post( { path: SETUP_API_ROUTE, validate: false, - // if this route is set to `-all`, a read-only user get a 404 for this route - // and will see `Unable to initialize Ingest Manager` in the UI - options: { tags: [`access:${PLUGIN_ID}-read`] }, }, fleetSetupHandler ); }; // That route is used by agent to setup Fleet -export const registerCreateFleetSetupRoute = (router: IRouter) => { +export const registerCreateFleetSetupRoute = (router: FleetRouter) => { router.post( { path: AGENTS_SETUP_API_ROUTES.CREATE_PATTERN, validate: false, - options: { tags: [`access:${PLUGIN_ID}-all`] }, }, fleetSetupHandler ); }; -export const registerGetFleetStatusRoute = (router: IRouter) => { +export const registerGetFleetStatusRoute = (router: FleetRouter) => { router.get( { path: AGENTS_SETUP_API_ROUTES.INFO_PATTERN, validate: false, + // Disable this tag and the automatic RBAC support until elastic/fleet-server access is removed in 8.0 + // Required to allow elastic/fleet-server to access this API. options: { tags: [`access:${PLUGIN_ID}-read`] }, }, getFleetStatusHandler ); }; -export const registerRoutes = ( - router: IRouter, - config: FleetConfigType -) => { +export const registerRoutes = (router: FleetRouter, config: FleetConfigType) => { // Ingest manager setup registerFleetSetupRoute(router); diff --git a/x-pack/plugins/fleet/server/types/request_context.ts b/x-pack/plugins/fleet/server/types/request_context.ts index a3b414119b685..0d0da9145f073 100644 --- a/x-pack/plugins/fleet/server/types/request_context.ts +++ b/x-pack/plugins/fleet/server/types/request_context.ts @@ -11,6 +11,7 @@ import type { RequestHandlerContext, RouteMethod, SavedObjectsClientContract, + IRouter, } from '../../../../../src/core/server'; /** @internal */ @@ -37,3 +38,9 @@ export type FleetRequestHandler< Method extends RouteMethod = any, ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory > = RequestHandler; + +/** + * Convenience type for routers in Fleet that includes the FleetRequestHandlerContext type + * @internal + */ +export type FleetRouter = IRouter; 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 75c8229bb37d6..d8906d91f152b 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -878,6 +878,42 @@ describe('#atSpace', () => { `); }); }); + + test('omits login privilege when requireLoginAction: false', async () => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({ + has_all_requested: true, + username: 'foo-username', + index: {}, + application: { + [application]: { + 'space:space_1': { + [mockActions.version]: true, + }, + }, + }, + }); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + () => Promise.resolve(mockClusterClient), + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); + await checkPrivileges.atSpace('space_1', {}, { requireLoginAction: false }); + + expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({ + body: { + index: [], + application: [ + { + application, + resources: [`space:space_1`], + privileges: [mockActions.version], + }, + ], + }, + }); + }); }); describe('#atSpaces', () => { @@ -2083,6 +2119,42 @@ describe('#atSpaces', () => { `); }); }); + + test('omits login privilege when requireLoginAction: false', async () => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({ + has_all_requested: true, + username: 'foo-username', + index: {}, + application: { + [application]: { + 'space:space_1': { + [mockActions.version]: true, + }, + }, + }, + }); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + () => Promise.resolve(mockClusterClient), + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); + await checkPrivileges.atSpaces(['space_1'], {}, { requireLoginAction: false }); + + expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({ + body: { + index: [], + application: [ + { + application, + resources: [`space:space_1`], + privileges: [mockActions.version], + }, + ], + }, + }); + }); }); describe('#globally', () => { @@ -2937,4 +3009,40 @@ describe('#globally', () => { `); }); }); + + test('omits login privilege when requireLoginAction: false', async () => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({ + has_all_requested: true, + username: 'foo-username', + index: {}, + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.version]: true, + }, + }, + }, + }); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + () => Promise.resolve(mockClusterClient), + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); + await checkPrivileges.globally({}, { requireLoginAction: false }); + + expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({ + body: { + index: [], + application: [ + { + application, + resources: [GLOBAL_RESOURCE], + privileges: [mockActions.version], + }, + ], + }, + }); + }); }); diff --git a/x-pack/plugins/security/server/authorization/check_privileges.ts b/x-pack/plugins/security/server/authorization/check_privileges.ts index 3a35cf164ad85..36c364f1ff7da 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.ts @@ -13,6 +13,7 @@ import { GLOBAL_RESOURCE } from '../../common/constants'; import { ResourceSerializer } from './resource_serializer'; import type { CheckPrivileges, + CheckPrivilegesOptions, CheckPrivilegesPayload, CheckPrivilegesResponse, HasPrivilegesResponse, @@ -41,14 +42,20 @@ export function checkPrivilegesWithRequestFactory( return function checkPrivilegesWithRequest(request: KibanaRequest): CheckPrivileges { const checkPrivilegesAtResources = async ( resources: string[], - privileges: CheckPrivilegesPayload + privileges: CheckPrivilegesPayload, + { requireLoginAction = true }: CheckPrivilegesOptions = {} ): Promise => { const kibanaPrivileges = Array.isArray(privileges.kibana) ? privileges.kibana : privileges.kibana ? [privileges.kibana] : []; - const allApplicationPrivileges = uniq([actions.version, actions.login, ...kibanaPrivileges]); + + const allApplicationPrivileges = uniq([ + actions.version, + ...(requireLoginAction ? [actions.login] : []), + ...kibanaPrivileges, + ]); const clusterClient = await getClusterClient(); const { body } = await clusterClient.asScoped(request).asCurrentUser.security.hasPrivileges({ @@ -135,18 +142,26 @@ export function checkPrivilegesWithRequestFactory( }; return { - async atSpace(spaceId: string, privileges: CheckPrivilegesPayload) { + async atSpace( + spaceId: string, + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions + ) { const spaceResource = ResourceSerializer.serializeSpaceResource(spaceId); - return await checkPrivilegesAtResources([spaceResource], privileges); + return await checkPrivilegesAtResources([spaceResource], privileges, options); }, - async atSpaces(spaceIds: string[], privileges: CheckPrivilegesPayload) { + async atSpaces( + spaceIds: string[], + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions + ) { const spaceResources = spaceIds.map((spaceId) => ResourceSerializer.serializeSpaceResource(spaceId) ); - return await checkPrivilegesAtResources(spaceResources, privileges); + return await checkPrivilegesAtResources(spaceResources, privileges, options); }, - async globally(privileges: CheckPrivilegesPayload) { - return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privileges); + async globally(privileges: CheckPrivilegesPayload, options?: CheckPrivilegesOptions) { + return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privileges, options); }, }; }; diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts index 547782bbd1ba1..9fd14c6d29806 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts @@ -8,6 +8,7 @@ import { httpServerMock } from 'src/core/server/mocks'; import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; +import type { CheckPrivilegesOptions } from './types'; test(`checkPrivileges.atSpace when spaces is enabled`, async () => { const expectedResult = Symbol(); @@ -25,13 +26,18 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { namespaceToSpaceId: jest.fn(), }) )(request); - const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }); + const options: CheckPrivilegesOptions = { requireLoginAction: true }; + const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }, options); expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, { - kibana: privilegeOrPrivileges, - }); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith( + spaceId, + { + kibana: privilegeOrPrivileges, + }, + options + ); }); test(`checkPrivileges.globally when spaces is disabled`, async () => { @@ -46,9 +52,13 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { mockCheckPrivilegesWithRequest, () => undefined )(request); - const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }); + const options: CheckPrivilegesOptions = { requireLoginAction: true }; + const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }, options); expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: privilegeOrPrivileges }); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith( + { kibana: privilegeOrPrivileges }, + options + ); }); diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts index 4ce59c8706270..d4e335ba04058 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts @@ -9,13 +9,15 @@ import type { KibanaRequest } from 'src/core/server'; import type { SpacesService } from '../plugin'; import type { + CheckPrivilegesOptions, CheckPrivilegesPayload, CheckPrivilegesResponse, CheckPrivilegesWithRequest, } from './types'; export type CheckPrivilegesDynamically = ( - privileges: CheckPrivilegesPayload + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions ) => Promise; export type CheckPrivilegesDynamicallyWithRequest = ( @@ -28,11 +30,15 @@ export function checkPrivilegesDynamicallyWithRequestFactory( ): CheckPrivilegesDynamicallyWithRequest { return function checkPrivilegesDynamicallyWithRequest(request: KibanaRequest) { const checkPrivileges = checkPrivilegesWithRequest(request); - return async function checkPrivilegesDynamically(privileges: CheckPrivilegesPayload) { + + return async function checkPrivilegesDynamically( + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions + ) { const spacesService = getSpacesService(); return spacesService - ? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privileges) - : await checkPrivileges.globally(privileges); + ? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privileges, options) + : await checkPrivileges.globally(privileges, options); }; }; } diff --git a/x-pack/plugins/security/server/authorization/types.ts b/x-pack/plugins/security/server/authorization/types.ts index 8bfe892840637..aee059fb8becb 100644 --- a/x-pack/plugins/security/server/authorization/types.ts +++ b/x-pack/plugins/security/server/authorization/types.ts @@ -29,6 +29,18 @@ export interface HasPrivilegesResponse { }; } +/** + * Options to influce the privilege checks. + */ +export interface CheckPrivilegesOptions { + /** + * Whether or not the `login` action should be required (default: true). + * Setting this to false is not advised except for special circumstances, when you do not require + * the request to belong to a user capable of logging into Kibana. + */ + requireLoginAction?: boolean; +} + export interface CheckPrivilegesResponse { hasAllRequested: boolean; username: string; @@ -59,12 +71,20 @@ export interface CheckPrivilegesResponse { export type CheckPrivilegesWithRequest = (request: KibanaRequest) => CheckPrivileges; export interface CheckPrivileges { - atSpace(spaceId: string, privileges: CheckPrivilegesPayload): Promise; + atSpace( + spaceId: string, + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions + ): Promise; atSpaces( spaceIds: string[], - privileges: CheckPrivilegesPayload + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions + ): Promise; + globally( + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions ): Promise; - globally(privileges: CheckPrivilegesPayload): Promise; } export interface CheckPrivilegesPayload { diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 762fc1642a87a..f234855b84e17 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -73,7 +73,7 @@ export default function ({ getService }: FtrProviderContext) { 'packs_read', ], }, - reserved: ['ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], + reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], }; await supertest diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 0efaa25ee57da..ac69bfcd9d5d4 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -45,7 +45,7 @@ export default function ({ getService }: FtrProviderContext) { }, global: ['all', 'read'], space: ['all', 'read'], - reserved: ['ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], + reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], }; await supertest diff --git a/x-pack/test/fleet_api_integration/apis/agents/services.ts b/x-pack/test/fleet_api_integration/apis/agents/services.ts index be5d2d438f76f..0e28ad647bbc4 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/services.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/services.ts @@ -32,12 +32,30 @@ export function getEsClientForAPIKey({ getService }: FtrProviderContext, esApiKe }); } -export function setupFleetAndAgents({ getService }: FtrProviderContext) { +export function setupFleetAndAgents(providerContext: FtrProviderContext) { before(async () => { - await getService('supertest').post(`/api/fleet/setup`).set('kbn-xsrf', 'xxx').send(); - await getService('supertest') + // Use elastic/fleet-server service account to execute setup to verify privilege configuration + const es = providerContext.getService('es'); + const { + body: { token }, + // @ts-expect-error SecurityCreateServiceTokenRequest should not require `name` + } = await es.security.createServiceToken({ + namespace: 'elastic', + service: 'fleet-server', + }); + const supetestWithoutAuth = getSupertestWithoutAuth(providerContext); + + await supetestWithoutAuth + .post(`/api/fleet/setup`) + .set('kbn-xsrf', 'xxx') + .set('Authorization', `Bearer ${token.value}`) + .send() + .expect(200); + await supetestWithoutAuth .post(`/api/fleet/agents/setup`) .set('kbn-xsrf', 'xxx') - .send({ forceRecreate: true }); + .set('Authorization', `Bearer ${token.value}`) + .send({ forceRecreate: true }) + .expect(200); }); } diff --git a/x-pack/test/fleet_api_integration/apis/epm/setup.ts b/x-pack/test/fleet_api_integration/apis/epm/setup.ts index 8567cf8069c58..051636ad11f5a 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/setup.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/setup.ts @@ -14,7 +14,9 @@ import { setupFleetAndAgents } from '../agents/services'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); const log = getService('log'); + const es = getService('es'); describe('setup api', async () => { skipIfNoDockerRegistry(providerContext); @@ -47,5 +49,48 @@ export default function (providerContext: FtrProviderContext) { ); }); }); + + it('allows elastic/fleet-server user to call required APIs', async () => { + const { + body: { token }, + // @ts-expect-error SecurityCreateServiceTokenRequest should not require `name` + } = await es.security.createServiceToken({ + namespace: 'elastic', + service: 'fleet-server', + }); + + // elastic/fleet-server needs access to these APIs: + // POST /api/fleet/setup + // POST /api/fleet/agents/setup + // GET /api/fleet/agent_policies + // GET /api/fleet/enrollment-api-keys + // GET /api/fleet/enrollment-api-keys/ + await supertestWithoutAuth + .post('/api/fleet/setup') + .set('Authorization', `Bearer ${token.value}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + await supertestWithoutAuth + .post('/api/fleet/agents/setup') + .set('Authorization', `Bearer ${token.value}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + await supertestWithoutAuth + .get('/api/fleet/agent_policies') + .set('Authorization', `Bearer ${token.value}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + const response = await supertestWithoutAuth + .get('/api/fleet/enrollment-api-keys') + .set('Authorization', `Bearer ${token.value}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + const enrollmentApiKeyId = response.body.list[0].id; + await supertestWithoutAuth + .get(`/api/fleet/enrollment-api-keys/${enrollmentApiKeyId}`) + .set('Authorization', `Bearer ${token.value}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + }); }); } From f6a9afea6165c6072bd0c3fdf00439b7a98de1fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 19 Oct 2021 11:33:57 +0100 Subject: [PATCH 03/30] [Stack management apps] Deprecate "enabled" Kibana setting (#114768) --- docs/dev-tools/console/console.asciidoc | 9 + docs/setup/settings.asciidoc | 34 +++ src/plugins/console/public/index.ts | 7 +- src/plugins/console/public/plugin.ts | 122 ++++++----- src/plugins/console/public/types/config.ts | 13 ++ src/plugins/console/public/types/index.ts | 2 + src/plugins/console/public/types/locator.ts | 12 ++ .../public/types/plugin_dependencies.ts | 8 +- src/plugins/console/server/config.ts | 199 ++++++++++++++---- src/plugins/console/server/index.ts | 1 + src/plugins/console/server/plugin.ts | 10 +- .../components/manage_data/manage_data.tsx | 3 +- .../components/details/req_code_viewer.tsx | 12 +- .../common/constants/index.ts | 2 + .../server/config.ts | 96 ++++++++- .../cross_cluster_replication/server/index.ts | 13 +- .../common/constants/index.ts | 2 + .../server/config.ts | 96 ++++++++- .../server/index.ts | 13 +- .../plugins/index_management/public/plugin.ts | 50 +++-- .../plugins/index_management/public/types.ts | 6 + .../plugins/index_management/server/config.ts | 89 +++++++- .../plugins/index_management/server/index.ts | 10 +- .../common/constants/index.ts | 2 +- .../common/constants/plugin.ts | 2 + .../license_management/server/config.ts | 90 +++++++- .../license_management/server/index.ts | 13 +- .../remote_clusters/common/constants.ts | 2 + .../plugins/remote_clusters/server/config.ts | 89 +++++++- .../plugins/remote_clusters/server/plugin.ts | 4 +- x-pack/plugins/rollup/common/index.ts | 2 + x-pack/plugins/rollup/public/index.ts | 3 +- x-pack/plugins/rollup/public/plugin.ts | 57 ++--- x-pack/plugins/rollup/public/types.ts | 12 ++ x-pack/plugins/rollup/server/config.ts | 89 +++++++- x-pack/plugins/rollup/server/index.ts | 10 +- .../snapshot_restore/common/constants.ts | 2 + .../plugins/snapshot_restore/public/plugin.ts | 84 ++++---- .../plugins/snapshot_restore/public/types.ts | 1 + .../plugins/snapshot_restore/server/config.ts | 98 ++++++++- .../plugins/snapshot_restore/server/index.ts | 13 +- .../plugins/snapshot_restore/server/plugin.ts | 9 +- .../client_integration/helpers/index.ts | 2 +- .../helpers/setup_environment.tsx | 12 +- .../overview/overview.test.tsx | 5 +- .../upgrade_assistant/common/config.ts | 20 -- .../upgrade_assistant/common/constants.ts | 6 +- .../reindex/flyout/warning_step.test.tsx | 18 +- .../upgrade_assistant/public/plugin.ts | 88 ++++---- .../plugins/upgrade_assistant/public/types.ts | 7 + .../upgrade_assistant/server/config.ts | 107 ++++++++++ .../plugins/upgrade_assistant/server/index.ts | 13 +- .../server/lib/__fixtures__/version.ts | 8 +- .../server/lib/es_version_precheck.test.ts | 4 +- .../lib/reindexing/index_settings.test.ts | 8 +- .../lib/reindexing/reindex_actions.test.ts | 4 +- .../lib/reindexing/reindex_service.test.ts | 4 +- 57 files changed, 1279 insertions(+), 418 deletions(-) create mode 100644 src/plugins/console/public/types/config.ts create mode 100644 src/plugins/console/public/types/locator.ts create mode 100644 x-pack/plugins/rollup/public/types.ts delete mode 100644 x-pack/plugins/upgrade_assistant/common/config.ts create mode 100644 x-pack/plugins/upgrade_assistant/server/config.ts diff --git a/docs/dev-tools/console/console.asciidoc b/docs/dev-tools/console/console.asciidoc index 48fe936dd2db5..21334c31011f4 100644 --- a/docs/dev-tools/console/console.asciidoc +++ b/docs/dev-tools/console/console.asciidoc @@ -129,3 +129,12 @@ image::dev-tools/console/images/console-settings.png["Console Settings", width=6 For a list of available keyboard shortcuts, click *Help*. + +[float] +[[console-settings]] +=== Disable Console + +If you don’t want to use *Console*, you can disable it by setting `console.ui.enabled` +to `false` in your `kibana.yml` configuration file. Changing this setting +causes the server to regenerate assets on the next startup, +which might cause a delay before pages start being served. \ No newline at end of file diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 4802a4da8182c..af22ad4ad157f 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -20,6 +20,11 @@ configuration using `${MY_ENV_VAR}` syntax. [cols="2*<"] |=== +| `console.ui.enabled:` +Toggling this causes the server to regenerate assets on the next startup, +which may cause a delay before pages start being served. +Set to `false` to disable Console. *Default: `true`* + | `csp.rules:` | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] A https://w3c.github.io/webappsec-csp/[Content Security Policy] template @@ -681,6 +686,10 @@ out through *Advanced Settings*. *Default: `true`* | Set this value to true to allow Vega to use any URL to access external data sources and images. When false, Vega can only get data from {es}. *Default: `false`* +| `xpack.ccr.ui.enabled` +Set this value to false to disable the Cross-Cluster Replication UI. +*Default: `true`* + |[[settings-explore-data-in-context]] `xpack.discoverEnhanced.actions.` `exploreDataInContextMenu.enabled` | Enables the *Explore underlying data* option that allows you to open *Discover* from a dashboard panel and view the panel data. *Default: `false`* @@ -689,6 +698,31 @@ sources and images. When false, Vega can only get data from {es}. *Default: `fal `exploreDataInChart.enabled` | Enables you to view the underlying documents in a data series from a dashboard panel. *Default: `false`* +| `xpack.ilm.ui.enabled` +Set this value to false to disable the Index Lifecycle Policies UI. +*Default: `true`* + +| `xpack.index_management.ui.enabled` +Set this value to false to disable the Index Management UI. +*Default: `true`* + +| `xpack.license_management.ui.enabled` +Set this value to false to disable the License Management UI. +*Default: `true`* + +| `xpack.remote_clusters.ui.enabled` +Set this value to false to disable the Remote Clusters UI. +*Default: `true`* + +| `xpack.rollup.ui.enabled:` +Set this value to false to disable the Rollup Jobs UI. *Default: true* + +| `xpack.snapshot_restore.ui.enabled:` +Set this value to false to disable the Snapshot and Restore UI. *Default: true* + +| `xpack.upgrade_assistant.ui.enabled:` +Set this value to false to disable the Upgrade Assistant UI. *Default: true* + | `i18n.locale` {ess-icon} | Set this value to change the {kib} interface language. Valid locales are: `en`, `zh-CN`, `ja-JP`. *Default: `en`* diff --git a/src/plugins/console/public/index.ts b/src/plugins/console/public/index.ts index 8c4a107108565..9a9c5896cd26d 100644 --- a/src/plugins/console/public/index.ts +++ b/src/plugins/console/public/index.ts @@ -7,13 +7,14 @@ */ import './index.scss'; +import { PluginInitializerContext } from 'src/core/public'; import { ConsoleUIPlugin } from './plugin'; -export type { ConsoleUILocatorParams } from './plugin'; +export type { ConsoleUILocatorParams, ConsolePluginSetup } from './types'; export { ConsoleUIPlugin as Plugin }; -export function plugin() { - return new ConsoleUIPlugin(); +export function plugin(ctx: PluginInitializerContext) { + return new ConsoleUIPlugin(ctx); } diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index e3791df6a2db6..d61769c23dfe0 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -7,77 +7,87 @@ */ import { i18n } from '@kbn/i18n'; -import { SerializableRecord } from '@kbn/utility-types'; -import { Plugin, CoreSetup } from 'src/core/public'; +import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/public'; import { FeatureCatalogueCategory } from '../../home/public'; -import { AppSetupUIPluginDependencies } from './types'; - -export interface ConsoleUILocatorParams extends SerializableRecord { - loadFrom?: string; -} +import { + AppSetupUIPluginDependencies, + ClientConfigType, + ConsolePluginSetup, + ConsoleUILocatorParams, +} from './types'; export class ConsoleUIPlugin implements Plugin { + constructor(private ctx: PluginInitializerContext) {} + public setup( { notifications, getStartServices, http }: CoreSetup, { devTools, home, share, usageCollection }: AppSetupUIPluginDependencies - ) { - if (home) { - home.featureCatalogue.register({ + ): ConsolePluginSetup { + const { + ui: { enabled: isConsoleUiEnabled }, + } = this.ctx.config.get(); + + if (isConsoleUiEnabled) { + if (home) { + home.featureCatalogue.register({ + id: 'console', + title: i18n.translate('console.devToolsTitle', { + defaultMessage: 'Interact with the Elasticsearch API', + }), + description: i18n.translate('console.devToolsDescription', { + defaultMessage: 'Skip cURL and use a JSON interface to work with your data in Console.', + }), + icon: 'consoleApp', + path: '/app/dev_tools#/console', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + } + + devTools.register({ id: 'console', - title: i18n.translate('console.devToolsTitle', { - defaultMessage: 'Interact with the Elasticsearch API', - }), - description: i18n.translate('console.devToolsDescription', { - defaultMessage: 'Skip cURL and use a JSON interface to work with your data in Console.', + order: 1, + title: i18n.translate('console.consoleDisplayName', { + defaultMessage: 'Console', }), - icon: 'consoleApp', - path: '/app/dev_tools#/console', - showOnHomePage: false, - category: FeatureCatalogueCategory.ADMIN, - }); - } + enableRouting: false, + mount: async ({ element }) => { + const [core] = await getStartServices(); - devTools.register({ - id: 'console', - order: 1, - title: i18n.translate('console.consoleDisplayName', { - defaultMessage: 'Console', - }), - enableRouting: false, - mount: async ({ element }) => { - const [core] = await getStartServices(); + const { + i18n: { Context: I18nContext }, + docLinks: { DOC_LINK_VERSION }, + } = core; - const { - i18n: { Context: I18nContext }, - docLinks: { DOC_LINK_VERSION }, - } = core; + const { renderApp } = await import('./application'); - const { renderApp } = await import('./application'); + return renderApp({ + http, + docLinkVersion: DOC_LINK_VERSION, + I18nContext, + notifications, + usageCollection, + element, + }); + }, + }); - return renderApp({ - http, - docLinkVersion: DOC_LINK_VERSION, - I18nContext, - notifications, - usageCollection, - element, - }); - }, - }); + const locator = share.url.locators.create({ + id: 'CONSOLE_APP_LOCATOR', + getLocation: async ({ loadFrom }) => { + return { + app: 'dev_tools', + path: `#/console${loadFrom ? `?load_from=${loadFrom}` : ''}`, + state: { loadFrom }, + }; + }, + }); - const locator = share.url.locators.create({ - id: 'CONSOLE_APP_LOCATOR', - getLocation: async ({ loadFrom }) => { - return { - app: 'dev_tools', - path: `#/console${loadFrom ? `?load_from=${loadFrom}` : ''}`, - state: { loadFrom }, - }; - }, - }); + return { locator }; + } - return { locator }; + return {}; } public start() {} diff --git a/src/plugins/console/public/types/config.ts b/src/plugins/console/public/types/config.ts new file mode 100644 index 0000000000000..da41eef6f5484 --- /dev/null +++ b/src/plugins/console/public/types/config.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 interface ClientConfigType { + ui: { + enabled: boolean; + }; +} diff --git a/src/plugins/console/public/types/index.ts b/src/plugins/console/public/types/index.ts index b98adbf5610cd..d8b6aaf7b12c4 100644 --- a/src/plugins/console/public/types/index.ts +++ b/src/plugins/console/public/types/index.ts @@ -11,3 +11,5 @@ export * from './core_editor'; export * from './token'; export * from './tokens_provider'; export * from './common'; +export { ClientConfigType } from './config'; +export { ConsoleUILocatorParams } from './locator'; diff --git a/src/plugins/console/public/types/locator.ts b/src/plugins/console/public/types/locator.ts new file mode 100644 index 0000000000000..f3a42338aaadc --- /dev/null +++ b/src/plugins/console/public/types/locator.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { SerializableRecord } from '@kbn/utility-types'; + +export interface ConsoleUILocatorParams extends SerializableRecord { + loadFrom?: string; +} diff --git a/src/plugins/console/public/types/plugin_dependencies.ts b/src/plugins/console/public/types/plugin_dependencies.ts index 444776f47ea13..afc49f9a5a986 100644 --- a/src/plugins/console/public/types/plugin_dependencies.ts +++ b/src/plugins/console/public/types/plugin_dependencies.ts @@ -9,7 +9,9 @@ import { HomePublicPluginSetup } from '../../../home/public'; import { DevToolsSetup } from '../../../dev_tools/public'; import { UsageCollectionSetup } from '../../../usage_collection/public'; -import { SharePluginSetup } from '../../../share/public'; +import { SharePluginSetup, LocatorPublic } from '../../../share/public'; + +import { ConsoleUILocatorParams } from './locator'; export interface AppSetupUIPluginDependencies { home?: HomePublicPluginSetup; @@ -17,3 +19,7 @@ export interface AppSetupUIPluginDependencies { share: SharePluginSetup; usageCollection?: UsageCollectionSetup; } + +export interface ConsolePluginSetup { + locator?: LocatorPublic; +} diff --git a/src/plugins/console/server/config.ts b/src/plugins/console/server/config.ts index 6d667fed081e8..024777aa8d252 100644 --- a/src/plugins/console/server/config.ts +++ b/src/plugins/console/server/config.ts @@ -7,6 +7,8 @@ */ import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; import { PluginConfigDescriptor } from 'kibana/server'; @@ -14,62 +16,171 @@ import { MAJOR_VERSION } from '../common/constants'; const kibanaVersion = new SemVer(MAJOR_VERSION); -const baseSettings = { - enabled: schema.boolean({ defaultValue: true }), - ssl: schema.object({ verify: schema.boolean({ defaultValue: false }) }, {}), -}; - -// Settings only available in 7.x -const deprecatedSettings = { - proxyFilter: schema.arrayOf(schema.string(), { defaultValue: ['.*'] }), - proxyConfig: schema.arrayOf( - schema.object({ - match: schema.object({ - protocol: schema.string({ defaultValue: '*' }), - host: schema.string({ defaultValue: '*' }), - port: schema.string({ defaultValue: '*' }), - path: schema.string({ defaultValue: '*' }), - }), - - timeout: schema.number(), - ssl: schema.object( - { - verify: schema.boolean(), - ca: schema.arrayOf(schema.string()), - cert: schema.string(), - key: schema.string(), - }, - { defaultValue: undefined } - ), - }), - { defaultValue: [] } - ), -}; - -const configSchema = schema.object( +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( { - ...baseSettings, + ssl: schema.object({ verify: schema.boolean({ defaultValue: false }) }, {}), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), }, { defaultValue: undefined } ); -const configSchema7x = schema.object( +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; + +export type ConsoleConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( { - ...baseSettings, - ...deprecatedSettings, + enabled: schema.boolean({ defaultValue: true }), + proxyFilter: schema.arrayOf(schema.string(), { defaultValue: ['.*'] }), + proxyConfig: schema.arrayOf( + schema.object({ + match: schema.object({ + protocol: schema.string({ defaultValue: '*' }), + host: schema.string({ defaultValue: '*' }), + port: schema.string({ defaultValue: '*' }), + path: schema.string({ defaultValue: '*' }), + }), + + timeout: schema.number(), + ssl: schema.object( + { + verify: schema.boolean(), + ca: schema.arrayOf(schema.string()), + cert: schema.string(), + key: schema.string(), + }, + { defaultValue: undefined } + ), + }), + { defaultValue: [] } + ), + ssl: schema.object({ verify: schema.boolean({ defaultValue: false }) }, {}), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), }, { defaultValue: undefined } ); -export type ConfigType = TypeOf; -export type ConfigType7x = TypeOf; +export type ConsoleConfig7x = TypeOf; -export const config: PluginConfigDescriptor = { - schema: kibanaVersion.major < 8 ? configSchema7x : configSchema, +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schema7x, deprecations: ({ deprecate, unused }) => [ - deprecate('enabled', '8.0.0'), - deprecate('proxyFilter', '8.0.0'), - deprecate('proxyConfig', '8.0.0'), unused('ssl'), + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'console.enabled') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'console.enabled', + level: 'critical', + title: i18n.translate('console.deprecations.enabledTitle', { + defaultMessage: 'Setting "console.enabled" is deprecated', + }), + message: i18n.translate('console.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Console UI, use the "console.ui.enabled" setting instead of "console.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('console.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('console.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: 'Change the "console.enabled" setting to "console.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'console.proxyConfig') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'console.proxyConfig', + level: 'critical', + title: i18n.translate('console.deprecations.proxyConfigTitle', { + defaultMessage: 'Setting "console.proxyConfig" is deprecated', + }), + message: i18n.translate('console.deprecations.proxyConfigMessage', { + defaultMessage: + 'Configuring "console.proxyConfig" is deprecated and will be removed in 8.0.0. To secure your connection between Kibana and Elasticsearch use the standard "server.ssl.*" settings instead.', + }), + documentationUrl: 'https://ela.st/encrypt-kibana-browser', + correctiveActions: { + manualSteps: [ + i18n.translate('console.deprecations.proxyConfig.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('console.deprecations.proxyConfig.manualStepTwoMessage', { + defaultMessage: 'Remove the "console.proxyConfig" setting.', + }), + i18n.translate('console.deprecations.proxyConfig.manualStepThreeMessage', { + defaultMessage: + 'Configure the secure connection between Kibana and Elasticsearch using the "server.ssl.*" settings.', + }), + ], + }, + }); + return completeConfig; + }, + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'console.proxyFilter') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'console.proxyFilter', + level: 'critical', + title: i18n.translate('console.deprecations.proxyFilterTitle', { + defaultMessage: 'Setting "console.proxyFilter" is deprecated', + }), + message: i18n.translate('console.deprecations.proxyFilterMessage', { + defaultMessage: + 'Configuring "console.proxyFilter" is deprecated and will be removed in 8.0.0. To secure your connection between Kibana and Elasticsearch use the standard "server.ssl.*" settings instead.', + }), + documentationUrl: 'https://ela.st/encrypt-kibana-browser', + correctiveActions: { + manualSteps: [ + i18n.translate('console.deprecations.proxyFilter.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('console.deprecations.proxyFilter.manualStepTwoMessage', { + defaultMessage: 'Remove the "console.proxyFilter" setting.', + }), + i18n.translate('console.deprecations.proxyFilter.manualStepThreeMessage', { + defaultMessage: + 'Configure the secure connection between Kibana and Elasticsearch using the "server.ssl.*" settings.', + }), + ], + }, + }); + return completeConfig; + }, ], }; + +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/src/plugins/console/server/index.ts b/src/plugins/console/server/index.ts index 6ae518f5dc796..b270b89a3d45a 100644 --- a/src/plugins/console/server/index.ts +++ b/src/plugins/console/server/index.ts @@ -11,6 +11,7 @@ import { PluginInitializerContext } from 'kibana/server'; import { ConsoleServerPlugin } from './plugin'; export { ConsoleSetup, ConsoleStart } from './types'; + export { config } from './config'; export const plugin = (ctx: PluginInitializerContext) => new ConsoleServerPlugin(ctx); diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index 613337b286fbf..5543c40d03cb0 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -11,7 +11,7 @@ import { SemVer } from 'semver'; import { ProxyConfigCollection } from './lib'; import { SpecDefinitionsService, EsLegacyConfigService } from './services'; -import { ConfigType, ConfigType7x } from './config'; +import { ConsoleConfig, ConsoleConfig7x } from './config'; import { registerRoutes } from './routes'; @@ -24,11 +24,11 @@ export class ConsoleServerPlugin implements Plugin { esLegacyConfigService = new EsLegacyConfigService(); - constructor(private readonly ctx: PluginInitializerContext) { + constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } - setup({ http, capabilities, getStartServices, elasticsearch }: CoreSetup) { + setup({ http, capabilities, elasticsearch }: CoreSetup) { capabilities.registerProvider(() => ({ dev_tools: { show: true, @@ -43,8 +43,8 @@ export class ConsoleServerPlugin implements Plugin { let proxyConfigCollection: ProxyConfigCollection | undefined; if (kibanaVersion.major < 8) { // "pathFilters" and "proxyConfig" are only used in 7.x - pathFilters = (config as ConfigType7x).proxyFilter.map((str: string) => new RegExp(str)); - proxyConfigCollection = new ProxyConfigCollection((config as ConfigType7x).proxyConfig); + pathFilters = (config as ConsoleConfig7x).proxyFilter.map((str: string) => new RegExp(str)); + proxyConfigCollection = new ProxyConfigCollection((config as ConsoleConfig7x).proxyConfig); } this.esLegacyConfigService.setup(elasticsearch.legacy.config$); diff --git a/src/plugins/home/public/application/components/manage_data/manage_data.tsx b/src/plugins/home/public/application/components/manage_data/manage_data.tsx index b374bdd2e1612..0f465dfcf965f 100644 --- a/src/plugins/home/public/application/components/manage_data/manage_data.tsx +++ b/src/plugins/home/public/application/components/manage_data/manage_data.tsx @@ -61,7 +61,8 @@ export const ManageData: FC = ({ addBasePath, application, features }) => {isDevToolsEnabled || isManagementEnabled ? ( - {isDevToolsEnabled ? ( + {/* Check if both the Dev Tools UI and the Console UI are enabled. */} + {isDevToolsEnabled && consoleHref !== undefined ? ( (); const navigateToUrl = services.application?.navigateToUrl; - const canShowDevTools = services.application?.capabilities?.dev_tools.show; const devToolsDataUri = compressToEncodedURIComponent(`GET ${indexPattern}/_search\n${json}`); - const devToolsHref = services.share.url.locators + const consoleHref = services.share.url.locators .get('CONSOLE_APP_LOCATOR') ?.useUrl({ loadFrom: `data:text/plain,${devToolsDataUri}` }); + // Check if both the Dev Tools UI and the Console UI are enabled. + const canShowDevTools = + services.application?.capabilities?.dev_tools.show && consoleHref !== undefined; const shouldShowDevToolsLink = !!(indexPattern && canShowDevTools); const handleDevToolsLinkClick = useCallback( - () => devToolsHref && navigateToUrl && navigateToUrl(devToolsHref), - [devToolsHref, navigateToUrl] + () => consoleHref && navigateToUrl && navigateToUrl(consoleHref), + [consoleHref, navigateToUrl] ); return ( @@ -79,7 +81,7 @@ export const RequestCodeViewer = ({ indexPattern, json }: RequestCodeViewerProps size="xs" flush="right" iconType="wrench" - href={devToolsHref} + href={consoleHref} onClick={handleDevToolsLinkClick} data-test-subj="inspectorRequestOpenInConsoleButton" > diff --git a/x-pack/plugins/cross_cluster_replication/common/constants/index.ts b/x-pack/plugins/cross_cluster_replication/common/constants/index.ts index f1b327aed6389..a800afcf77ae4 100644 --- a/x-pack/plugins/cross_cluster_replication/common/constants/index.ts +++ b/x-pack/plugins/cross_cluster_replication/common/constants/index.ts @@ -19,6 +19,8 @@ export const PLUGIN = { minimumLicenseType: platinumLicense, }; +export const MAJOR_VERSION = '8.0.0'; + export const APPS = { CCR_APP: 'ccr', REMOTE_CLUSTER_APP: 'remote_cluster', diff --git a/x-pack/plugins/cross_cluster_replication/server/config.ts b/x-pack/plugins/cross_cluster_replication/server/config.ts index 50cca903f8a2b..732137e308a0d 100644 --- a/x-pack/plugins/cross_cluster_replication/server/config.ts +++ b/x-pack/plugins/cross_cluster_replication/server/config.ts @@ -4,14 +4,96 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common/constants'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), - ui: schema.object({ +export type CrossClusterReplicationConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { enabled: schema.boolean({ defaultValue: true }), - }), -}); + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +export type CrossClusterReplicationConfig7x = TypeOf; + +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.ccr.enabled') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'xpack.ccr.enabled', + level: 'critical', + title: i18n.translate('xpack.crossClusterReplication.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.ccr.enabled" is deprecated', + }), + message: i18n.translate('xpack.crossClusterReplication.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Cross-Cluster Replication UI, use the "xpack.ccr.ui.enabled" setting instead of "xpack.ccr.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate( + 'xpack.crossClusterReplication.deprecations.enabled.manualStepOneMessage', + { + defaultMessage: 'Open the kibana.yml config file.', + } + ), + i18n.translate( + 'xpack.crossClusterReplication.deprecations.enabled.manualStepTwoMessage', + { + defaultMessage: 'Change the "xpack.ccr.enabled" setting to "xpack.ccr.ui.enabled".', + } + ), + ], + }, + }); + return completeConfig; + }, + ], +}; -export type CrossClusterReplicationConfig = TypeOf; +export const config: PluginConfigDescriptor< + CrossClusterReplicationConfig | CrossClusterReplicationConfig7x +> = kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/cross_cluster_replication/server/index.ts b/x-pack/plugins/cross_cluster_replication/server/index.ts index a6a3ec0fe5753..7a0984a6117bf 100644 --- a/x-pack/plugins/cross_cluster_replication/server/index.ts +++ b/x-pack/plugins/cross_cluster_replication/server/index.ts @@ -5,17 +5,10 @@ * 2.0. */ -import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { PluginInitializerContext } from 'src/core/server'; import { CrossClusterReplicationServerPlugin } from './plugin'; -import { configSchema, CrossClusterReplicationConfig } from './config'; + +export { config } from './config'; export const plugin = (pluginInitializerContext: PluginInitializerContext) => new CrossClusterReplicationServerPlugin(pluginInitializerContext); - -export const config: PluginConfigDescriptor = { - schema: configSchema, - exposeToBrowser: { - ui: true, - }, - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], -}; diff --git a/x-pack/plugins/index_lifecycle_management/common/constants/index.ts b/x-pack/plugins/index_lifecycle_management/common/constants/index.ts index 7107489f4e2ba..329f479e128e2 100644 --- a/x-pack/plugins/index_lifecycle_management/common/constants/index.ts +++ b/x-pack/plugins/index_lifecycle_management/common/constants/index.ts @@ -19,6 +19,8 @@ export const PLUGIN = { }), }; +export const MAJOR_VERSION = '8.0.0'; + export const API_BASE_PATH = '/api/index_lifecycle_management'; export { MIN_SEARCHABLE_SNAPSHOT_LICENSE, MIN_PLUGIN_LICENSE }; diff --git a/x-pack/plugins/index_lifecycle_management/server/config.ts b/x-pack/plugins/index_lifecycle_management/server/config.ts index f3fdf59cec55b..691cc06708bb5 100644 --- a/x-pack/plugins/index_lifecycle_management/server/config.ts +++ b/x-pack/plugins/index_lifecycle_management/server/config.ts @@ -4,16 +4,94 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common/constants'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + // Cloud requires the ability to hide internal node attributes from users. + filteredNodeAttributes: schema.arrayOf(schema.string(), { defaultValue: [] }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), - ui: schema.object({ +export type IndexLifecycleManagementConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { enabled: schema.boolean({ defaultValue: true }), - }), - // Cloud requires the ability to hide internal node attributes from users. - filteredNodeAttributes: schema.arrayOf(schema.string(), { defaultValue: [] }), -}); + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + // Cloud requires the ability to hide internal node attributes from users. + filteredNodeAttributes: schema.arrayOf(schema.string(), { defaultValue: [] }), + }, + { defaultValue: undefined } +); + +export type IndexLifecycleManagementConfig7x = TypeOf; + +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.ilm.enabled') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'xpack.ilm.enabled', + level: 'critical', + title: i18n.translate('xpack.indexLifecycleMgmt.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.ilm.enabled" is deprecated', + }), + message: i18n.translate('xpack.indexLifecycleMgmt.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Index Lifecycle Policies UI, use the "xpack.ilm.ui.enabled" setting instead of "xpack.ilm.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.indexLifecycleMgmt.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('xpack.indexLifecycleMgmt.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: 'Change the "xpack.ilm.enabled" setting to "xpack.ilm.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + ], +}; -export type IndexLifecycleManagementConfig = TypeOf; +export const config: PluginConfigDescriptor< + IndexLifecycleManagementConfig | IndexLifecycleManagementConfig7x +> = kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/index_lifecycle_management/server/index.ts b/x-pack/plugins/index_lifecycle_management/server/index.ts index 1f8b01913fd3e..6a74b4c80b2d3 100644 --- a/x-pack/plugins/index_lifecycle_management/server/index.ts +++ b/x-pack/plugins/index_lifecycle_management/server/index.ts @@ -5,17 +5,10 @@ * 2.0. */ -import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; +import { PluginInitializerContext } from 'kibana/server'; import { IndexLifecycleManagementServerPlugin } from './plugin'; -import { configSchema, IndexLifecycleManagementConfig } from './config'; + +export { config } from './config'; export const plugin = (ctx: PluginInitializerContext) => new IndexLifecycleManagementServerPlugin(ctx); - -export const config: PluginConfigDescriptor = { - schema: configSchema, - exposeToBrowser: { - ui: true, - }, - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], -}; diff --git a/x-pack/plugins/index_management/public/plugin.ts b/x-pack/plugins/index_management/public/plugin.ts index 4e123b6f474f8..2394167ca61b2 100644 --- a/x-pack/plugins/index_management/public/plugin.ts +++ b/x-pack/plugins/index_management/public/plugin.ts @@ -13,7 +13,12 @@ import { setExtensionsService } from './application/store/selectors/extension_se import { ExtensionsService } from './services'; -import { IndexManagementPluginSetup, SetupDependencies, StartDependencies } from './types'; +import { + IndexManagementPluginSetup, + SetupDependencies, + StartDependencies, + ClientConfigType, +} from './types'; // avoid import from index files in plugin.ts, use specific import paths import { PLUGIN } from '../common/constants/plugin'; @@ -31,25 +36,30 @@ export class IndexMgmtUIPlugin { coreSetup: CoreSetup, plugins: SetupDependencies ): IndexManagementPluginSetup { - const { fleet, usageCollection, management } = plugins; - const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version); - - management.sections.section.data.registerApp({ - id: PLUGIN.id, - title: i18n.translate('xpack.idxMgmt.appTitle', { defaultMessage: 'Index Management' }), - order: 0, - mount: async (params) => { - const { mountManagementSection } = await import('./application/mount_management_section'); - return mountManagementSection( - coreSetup, - usageCollection, - params, - this.extensionsService, - Boolean(fleet), - kibanaVersion - ); - }, - }); + const { + ui: { enabled: isIndexManagementUiEnabled }, + } = this.ctx.config.get(); + + if (isIndexManagementUiEnabled) { + const { fleet, usageCollection, management } = plugins; + const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version); + management.sections.section.data.registerApp({ + id: PLUGIN.id, + title: i18n.translate('xpack.idxMgmt.appTitle', { defaultMessage: 'Index Management' }), + order: 0, + mount: async (params) => { + const { mountManagementSection } = await import('./application/mount_management_section'); + return mountManagementSection( + coreSetup, + usageCollection, + params, + this.extensionsService, + Boolean(fleet), + kibanaVersion + ); + }, + }); + } return { extensionsService: this.extensionsService.setup(), diff --git a/x-pack/plugins/index_management/public/types.ts b/x-pack/plugins/index_management/public/types.ts index 05c486e299c7a..e0af6b160cf11 100644 --- a/x-pack/plugins/index_management/public/types.ts +++ b/x-pack/plugins/index_management/public/types.ts @@ -23,3 +23,9 @@ export interface SetupDependencies { export interface StartDependencies { share: SharePluginStart; } + +export interface ClientConfigType { + ui: { + enabled: boolean; + }; +} diff --git a/x-pack/plugins/index_management/server/config.ts b/x-pack/plugins/index_management/server/config.ts index 0a314c7654b16..88a714db5edca 100644 --- a/x-pack/plugins/index_management/server/config.ts +++ b/x-pack/plugins/index_management/server/config.ts @@ -4,11 +4,90 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common/constants'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; + +export type IndexManagementConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { + enabled: schema.boolean({ defaultValue: true }), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +export type IndexManagementConfig7x = TypeOf; + +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.index_management.enabled') === undefined) { + return completeConfig; + } -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), -}); + addDeprecation({ + configPath: 'xpack.index_management.enabled', + level: 'critical', + title: i18n.translate('xpack.idxMgmt.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.index_management.enabled" is deprecated', + }), + message: i18n.translate('xpack.idxMgmt.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Index Management UI, use the "xpack.index_management.ui.enabled" setting instead of "xpack.index_management.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.idxMgmt.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('xpack.idxMgmt.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: + 'Change the "xpack.index_management.enabled" setting to "xpack.index_management.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + ], +}; -export type IndexManagementConfig = TypeOf; +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/index_management/server/index.ts b/x-pack/plugins/index_management/server/index.ts index 14b67e2ffd581..29291116e44fc 100644 --- a/x-pack/plugins/index_management/server/index.ts +++ b/x-pack/plugins/index_management/server/index.ts @@ -5,17 +5,13 @@ * 2.0. */ -import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { PluginInitializerContext } from 'src/core/server'; import { IndexMgmtServerPlugin } from './plugin'; -import { configSchema } from './config'; -export const plugin = (context: PluginInitializerContext) => new IndexMgmtServerPlugin(context); +export { config } from './config'; -export const config: PluginConfigDescriptor = { - schema: configSchema, - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], -}; +export const plugin = (context: PluginInitializerContext) => new IndexMgmtServerPlugin(context); /** @public */ export { Dependencies } from './types'; diff --git a/x-pack/plugins/license_management/common/constants/index.ts b/x-pack/plugins/license_management/common/constants/index.ts index 0567b0008f0c8..9735eabeb1e40 100644 --- a/x-pack/plugins/license_management/common/constants/index.ts +++ b/x-pack/plugins/license_management/common/constants/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -export { PLUGIN } from './plugin'; +export { PLUGIN, MAJOR_VERSION } from './plugin'; export { API_BASE_PATH } from './base_path'; export { EXTERNAL_LINKS } from './external_links'; export { APP_PERMISSION } from './permissions'; diff --git a/x-pack/plugins/license_management/common/constants/plugin.ts b/x-pack/plugins/license_management/common/constants/plugin.ts index ae7fd0f6e8a2e..76f4d94a0188a 100644 --- a/x-pack/plugins/license_management/common/constants/plugin.ts +++ b/x-pack/plugins/license_management/common/constants/plugin.ts @@ -13,3 +13,5 @@ export const PLUGIN = { }), id: 'license_management', }; + +export const MAJOR_VERSION = '8.0.0'; diff --git a/x-pack/plugins/license_management/server/config.ts b/x-pack/plugins/license_management/server/config.ts index 0e4de29b718be..e378a10191684 100644 --- a/x-pack/plugins/license_management/server/config.ts +++ b/x-pack/plugins/license_management/server/config.ts @@ -4,14 +4,90 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common/constants'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), - ui: schema.object({ +export type LicenseManagementConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { enabled: schema.boolean({ defaultValue: true }), - }), -}); + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +export type LicenseManagementConfig7x = TypeOf; + +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.license_management.enabled') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'xpack.license_management.enabled', + level: 'critical', + title: i18n.translate('xpack.licenseMgmt.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.license_management.enabled" is deprecated', + }), + message: i18n.translate('xpack.licenseMgmt.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the License Management UI, use the "xpack.license_management.ui.enabled" setting instead of "xpack.license_management.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.licenseMgmt.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('xpack.licenseMgmt.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: + 'Change the "xpack.license_management.enabled" setting to "xpack.license_management.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + ], +}; -export type LicenseManagementConfig = TypeOf; +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/license_management/server/index.ts b/x-pack/plugins/license_management/server/index.ts index e78ffe07b50c0..7aa6bfb06d54d 100644 --- a/x-pack/plugins/license_management/server/index.ts +++ b/x-pack/plugins/license_management/server/index.ts @@ -5,17 +5,10 @@ * 2.0. */ -import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { PluginInitializerContext } from 'src/core/server'; import { LicenseManagementServerPlugin } from './plugin'; -import { configSchema, LicenseManagementConfig } from './config'; -export const plugin = (ctx: PluginInitializerContext) => new LicenseManagementServerPlugin(); +export { config } from './config'; -export const config: PluginConfigDescriptor = { - schema: configSchema, - exposeToBrowser: { - ui: true, - }, - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], -}; +export const plugin = (ctx: PluginInitializerContext) => new LicenseManagementServerPlugin(); diff --git a/x-pack/plugins/remote_clusters/common/constants.ts b/x-pack/plugins/remote_clusters/common/constants.ts index b11292141672d..5a36924b26433 100644 --- a/x-pack/plugins/remote_clusters/common/constants.ts +++ b/x-pack/plugins/remote_clusters/common/constants.ts @@ -20,6 +20,8 @@ export const PLUGIN = { }, }; +export const MAJOR_VERSION = '8.0.0'; + export const API_BASE_PATH = '/api/remote_clusters'; export const SNIFF_MODE = 'sniff'; diff --git a/x-pack/plugins/remote_clusters/server/config.ts b/x-pack/plugins/remote_clusters/server/config.ts index 8f379ec5613c8..5b4972f0a5259 100644 --- a/x-pack/plugins/remote_clusters/server/config.ts +++ b/x-pack/plugins/remote_clusters/server/config.ts @@ -4,23 +4,90 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; -import { PluginConfigDescriptor } from 'kibana/server'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common/constants'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; + +export type RemoteClustersConfig = TypeOf; -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), - ui: schema.object({ +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { enabled: schema.boolean({ defaultValue: true }), - }), -}); + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); -export type ConfigType = TypeOf; +export type RemoteClustersConfig7x = TypeOf; -export const config: PluginConfigDescriptor = { - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], - schema: configSchema, +const config7x: PluginConfigDescriptor = { exposeToBrowser: { ui: true, }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.remote_clusters.enabled') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'xpack.remote_clusters.enabled', + level: 'critical', + title: i18n.translate('xpack.remoteClusters.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.remote_clusters.enabled" is deprecated', + }), + message: i18n.translate('xpack.remoteClusters.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Remote Clusters UI, use the "xpack.remote_clusters.ui.enabled" setting instead of "xpack.remote_clusters.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.remoteClusters.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('xpack.remoteClusters.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: + 'Change the "xpack.remote_clusters.enabled" setting to "xpack.remote_clusters.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + ], }; + +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/remote_clusters/server/plugin.ts b/x-pack/plugins/remote_clusters/server/plugin.ts index b13773c27034a..fde71677b8448 100644 --- a/x-pack/plugins/remote_clusters/server/plugin.ts +++ b/x-pack/plugins/remote_clusters/server/plugin.ts @@ -11,7 +11,7 @@ import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'src/core/se import { PLUGIN } from '../common/constants'; import { Dependencies, LicenseStatus, RouteDependencies } from './types'; -import { ConfigType } from './config'; +import { RemoteClustersConfig, RemoteClustersConfig7x } from './config'; import { registerGetRoute, registerAddRoute, @@ -30,7 +30,7 @@ export class RemoteClustersServerPlugin { licenseStatus: LicenseStatus; log: Logger; - config: ConfigType; + config: RemoteClustersConfig | RemoteClustersConfig7x; constructor({ logger, config }: PluginInitializerContext) { this.log = logger.get(); diff --git a/x-pack/plugins/rollup/common/index.ts b/x-pack/plugins/rollup/common/index.ts index dffbfbd182092..c912a905d061d 100644 --- a/x-pack/plugins/rollup/common/index.ts +++ b/x-pack/plugins/rollup/common/index.ts @@ -14,6 +14,8 @@ export const PLUGIN = { minimumLicenseType: basicLicense, }; +export const MAJOR_VERSION = '8.0.0'; + export const CONFIG_ROLLUPS = 'rollups:enableIndexPatterns'; export const API_BASE_PATH = '/api/rollup'; diff --git a/x-pack/plugins/rollup/public/index.ts b/x-pack/plugins/rollup/public/index.ts index b70ce86493382..f740971b4bcb0 100644 --- a/x-pack/plugins/rollup/public/index.ts +++ b/x-pack/plugins/rollup/public/index.ts @@ -4,7 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { PluginInitializerContext } from 'src/core/public'; import { RollupPlugin } from './plugin'; -export const plugin = () => new RollupPlugin(); +export const plugin = (ctx: PluginInitializerContext) => new RollupPlugin(ctx); diff --git a/x-pack/plugins/rollup/public/plugin.ts b/x-pack/plugins/rollup/public/plugin.ts index 0d345e326193c..e458a13ee0e0e 100644 --- a/x-pack/plugins/rollup/public/plugin.ts +++ b/x-pack/plugins/rollup/public/plugin.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, CoreStart, Plugin } from 'kibana/public'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'kibana/public'; import { rollupBadgeExtension, rollupToggleExtension } from './extend_index_management'; // @ts-ignore import { RollupIndexPatternCreationConfig } from './index_pattern_creation/rollup_index_pattern_creation_config'; @@ -23,6 +23,7 @@ import { IndexManagementPluginSetup } from '../../index_management/public'; import { setHttp, init as initDocumentation } from './crud_app/services/index'; import { setNotifications, setFatalErrors, setUiStatsReporter } from './kibana_services'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; +import { ClientConfigType } from './types'; export interface RollupPluginSetupDependencies { home?: HomePublicPluginSetup; @@ -32,10 +33,16 @@ export interface RollupPluginSetupDependencies { } export class RollupPlugin implements Plugin { + constructor(private ctx: PluginInitializerContext) {} + setup( core: CoreSetup, { home, management, indexManagement, usageCollection }: RollupPluginSetupDependencies ) { + const { + ui: { enabled: isRollupUiEnabled }, + } = this.ctx.config.get(); + setFatalErrors(core.fatalErrors); if (usageCollection) { setUiStatsReporter(usageCollection.reportUiCounter.bind(usageCollection, UIM_APP_NAME)); @@ -46,7 +53,7 @@ export class RollupPlugin implements Plugin { indexManagement.extensionsService.addToggle(rollupToggleExtension); } - if (home) { + if (home && isRollupUiEnabled) { home.featureCatalogue.register({ id: 'rollup_jobs', title: 'Rollups', @@ -61,33 +68,35 @@ export class RollupPlugin implements Plugin { }); } - const pluginName = i18n.translate('xpack.rollupJobs.appTitle', { - defaultMessage: 'Rollup Jobs', - }); + if (isRollupUiEnabled) { + const pluginName = i18n.translate('xpack.rollupJobs.appTitle', { + defaultMessage: 'Rollup Jobs', + }); - management.sections.section.data.registerApp({ - id: 'rollup_jobs', - title: pluginName, - order: 4, - async mount(params) { - const [coreStart] = await core.getStartServices(); + management.sections.section.data.registerApp({ + id: 'rollup_jobs', + title: pluginName, + order: 4, + async mount(params) { + const [coreStart] = await core.getStartServices(); - const { - chrome: { docTitle }, - } = coreStart; + const { + chrome: { docTitle }, + } = coreStart; - docTitle.change(pluginName); - params.setBreadcrumbs([{ text: pluginName }]); + docTitle.change(pluginName); + params.setBreadcrumbs([{ text: pluginName }]); - const { renderApp } = await import('./application'); - const unmountAppCallback = await renderApp(core, params); + const { renderApp } = await import('./application'); + const unmountAppCallback = await renderApp(core, params); - return () => { - docTitle.reset(); - unmountAppCallback(); - }; - }, - }); + return () => { + docTitle.reset(); + unmountAppCallback(); + }; + }, + }); + } } start(core: CoreStart) { diff --git a/x-pack/plugins/rollup/public/types.ts b/x-pack/plugins/rollup/public/types.ts new file mode 100644 index 0000000000000..dc5e55e9268f8 --- /dev/null +++ b/x-pack/plugins/rollup/public/types.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. + */ + +export interface ClientConfigType { + ui: { + enabled: boolean; + }; +} diff --git a/x-pack/plugins/rollup/server/config.ts b/x-pack/plugins/rollup/server/config.ts index d20b317422107..c0cca4bbb4d33 100644 --- a/x-pack/plugins/rollup/server/config.ts +++ b/x-pack/plugins/rollup/server/config.ts @@ -4,11 +4,90 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; + +export type RollupConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { + enabled: schema.boolean({ defaultValue: true }), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +export type RollupConfig7x = TypeOf; + +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.rollup.enabled') === undefined) { + return completeConfig; + } -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), -}); + addDeprecation({ + configPath: 'xpack.rollup.enabled', + level: 'critical', + title: i18n.translate('xpack.rollupJobs.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.rollup.enabled" is deprecated', + }), + message: i18n.translate('xpack.rollupJobs.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Rollup Jobs UI, use the "xpack.rollup.ui.enabled" setting instead of "xpack.rollup.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.rollupJobs.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('xpack.rollupJobs.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: + 'Change the "xpack.rollup.enabled" setting to "xpack.rollup.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + ], +}; -export type RollupConfig = TypeOf; +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/rollup/server/index.ts b/x-pack/plugins/rollup/server/index.ts index e77e0e6f15d72..6ae1d9f24b8b9 100644 --- a/x-pack/plugins/rollup/server/index.ts +++ b/x-pack/plugins/rollup/server/index.ts @@ -5,14 +5,10 @@ * 2.0. */ -import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { PluginInitializerContext } from 'src/core/server'; import { RollupPlugin } from './plugin'; -import { configSchema, RollupConfig } from './config'; + +export { config } from './config'; export const plugin = (pluginInitializerContext: PluginInitializerContext) => new RollupPlugin(pluginInitializerContext); - -export const config: PluginConfigDescriptor = { - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], - schema: configSchema, -}; diff --git a/x-pack/plugins/snapshot_restore/common/constants.ts b/x-pack/plugins/snapshot_restore/common/constants.ts index b18e118dc5ff6..df13bd4c2f1f0 100644 --- a/x-pack/plugins/snapshot_restore/common/constants.ts +++ b/x-pack/plugins/snapshot_restore/common/constants.ts @@ -20,6 +20,8 @@ export const PLUGIN = { }, }; +export const MAJOR_VERSION = '8.0.0'; + export const API_BASE_PATH = '/api/snapshot_restore/'; export enum REPOSITORY_TYPES { diff --git a/x-pack/plugins/snapshot_restore/public/plugin.ts b/x-pack/plugins/snapshot_restore/public/plugin.ts index bb091a1fd1831..0351716fad5b5 100644 --- a/x-pack/plugins/snapshot_restore/public/plugin.ts +++ b/x-pack/plugins/snapshot_restore/public/plugin.ts @@ -42,52 +42,58 @@ export class SnapshotRestoreUIPlugin { public setup(coreSetup: CoreSetup, plugins: PluginsDependencies): void { const config = this.initializerContext.config.get(); - const { http } = coreSetup; - const { home, management, usageCollection } = plugins; + const { + ui: { enabled: isSnapshotRestoreUiEnabled }, + } = config; - // Initialize services - this.uiMetricService.setup(usageCollection); - textService.setup(i18n); - httpService.setup(http); + if (isSnapshotRestoreUiEnabled) { + const { http } = coreSetup; + const { home, management, usageCollection } = plugins; - management.sections.section.data.registerApp({ - id: PLUGIN.id, - title: i18n.translate('xpack.snapshotRestore.appTitle', { - defaultMessage: 'Snapshot and Restore', - }), - order: 3, - mount: async (params) => { - const { mountManagementSection } = await import('./application/mount_management_section'); - const services = { - uiMetricService: this.uiMetricService, - }; - return await mountManagementSection(coreSetup, services, config, params); - }, - }); + // Initialize services + this.uiMetricService.setup(usageCollection); + textService.setup(i18n); + httpService.setup(http); - if (home) { - home.featureCatalogue.register({ + management.sections.section.data.registerApp({ id: PLUGIN.id, - title: i18n.translate('xpack.snapshotRestore.featureCatalogueTitle', { - defaultMessage: 'Back up and restore', + title: i18n.translate('xpack.snapshotRestore.appTitle', { + defaultMessage: 'Snapshot and Restore', }), - description: i18n.translate('xpack.snapshotRestore.featureCatalogueDescription', { - defaultMessage: - 'Save snapshots to a backup repository, and restore to recover index and cluster state.', - }), - icon: 'storage', - path: '/app/management/data/snapshot_restore', - showOnHomePage: true, - category: FeatureCatalogueCategory.ADMIN, - order: 630, + order: 3, + mount: async (params) => { + const { mountManagementSection } = await import('./application/mount_management_section'); + const services = { + uiMetricService: this.uiMetricService, + }; + return await mountManagementSection(coreSetup, services, config, params); + }, }); - } - plugins.share.url.locators.create( - new SnapshotRestoreLocatorDefinition({ - managementAppLocator: plugins.management.locator, - }) - ); + if (home) { + home.featureCatalogue.register({ + id: PLUGIN.id, + title: i18n.translate('xpack.snapshotRestore.featureCatalogueTitle', { + defaultMessage: 'Back up and restore', + }), + description: i18n.translate('xpack.snapshotRestore.featureCatalogueDescription', { + defaultMessage: + 'Save snapshots to a backup repository, and restore to recover index and cluster state.', + }), + icon: 'storage', + path: '/app/management/data/snapshot_restore', + showOnHomePage: true, + category: FeatureCatalogueCategory.ADMIN, + order: 630, + }); + } + + plugins.share.url.locators.create( + new SnapshotRestoreLocatorDefinition({ + managementAppLocator: plugins.management.locator, + }) + ); + } } public start() {} diff --git a/x-pack/plugins/snapshot_restore/public/types.ts b/x-pack/plugins/snapshot_restore/public/types.ts index b73170ad9d578..c58c942b4bc16 100644 --- a/x-pack/plugins/snapshot_restore/public/types.ts +++ b/x-pack/plugins/snapshot_restore/public/types.ts @@ -7,4 +7,5 @@ export interface ClientConfigType { slm_ui: { enabled: boolean }; + ui: { enabled: boolean }; } diff --git a/x-pack/plugins/snapshot_restore/server/config.ts b/x-pack/plugins/snapshot_restore/server/config.ts index f0ca416ef2032..cc430f4756610 100644 --- a/x-pack/plugins/snapshot_restore/server/config.ts +++ b/x-pack/plugins/snapshot_restore/server/config.ts @@ -4,14 +4,98 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common/constants'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + slm_ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + slm_ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), - slm_ui: schema.object({ +export type SnapshotRestoreConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { enabled: schema.boolean({ defaultValue: true }), - }), -}); + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + slm_ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +export type SnapshotRestoreConfig7x = TypeOf; + +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + slm_ui: true, + }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.snapshot_restore.enabled') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'xpack.snapshot_restore.enabled', + level: 'critical', + title: i18n.translate('xpack.snapshotRestore.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.snapshot_restore.enabled" is deprecated', + }), + message: i18n.translate('xpack.snapshotRestore.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Snapshot and Restore UI, use the "xpack.snapshot_restore.ui.enabled" setting instead of "xpack.snapshot_restore.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.snapshotRestore.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('xpack.snapshotRestore.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: + 'Change the "xpack.snapshot_restore.enabled" setting to "xpack.snapshot_restore.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + ], +}; -export type SnapshotRestoreConfig = TypeOf; +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/snapshot_restore/server/index.ts b/x-pack/plugins/snapshot_restore/server/index.ts index e10bffd6073d2..1e9d2b55aa20b 100644 --- a/x-pack/plugins/snapshot_restore/server/index.ts +++ b/x-pack/plugins/snapshot_restore/server/index.ts @@ -5,16 +5,9 @@ * 2.0. */ -import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; +import { PluginInitializerContext } from 'kibana/server'; import { SnapshotRestoreServerPlugin } from './plugin'; -import { configSchema, SnapshotRestoreConfig } from './config'; -export const plugin = (ctx: PluginInitializerContext) => new SnapshotRestoreServerPlugin(ctx); +export { config } from './config'; -export const config: PluginConfigDescriptor = { - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], - schema: configSchema, - exposeToBrowser: { - slm_ui: true, - }, -}; +export const plugin = (ctx: PluginInitializerContext) => new SnapshotRestoreServerPlugin(ctx); diff --git a/x-pack/plugins/snapshot_restore/server/plugin.ts b/x-pack/plugins/snapshot_restore/server/plugin.ts index 4414e3735959b..d737807ec8dad 100644 --- a/x-pack/plugins/snapshot_restore/server/plugin.ts +++ b/x-pack/plugins/snapshot_restore/server/plugin.ts @@ -28,16 +28,9 @@ export class SnapshotRestoreServerPlugin implements Plugin this.license = new License(); } - public setup( - { http, getStartServices }: CoreSetup, - { licensing, features, security, cloud }: Dependencies - ): void { + public setup({ http }: CoreSetup, { licensing, features, security, cloud }: Dependencies): void { const pluginConfig = this.context.config.get(); - if (!pluginConfig.enabled) { - return; - } - const router = http.createRouter(); this.license.setup( diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts index b19c8b3d0f082..b2a1c4e80ec7d 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts @@ -9,4 +9,4 @@ export { setup as setupOverviewPage, OverviewTestBed } from './overview.helpers' export { setup as setupElasticsearchPage, ElasticsearchTestBed } from './elasticsearch.helpers'; export { setup as setupKibanaPage, KibanaTestBed } from './kibana.helpers'; -export { setupEnvironment } from './setup_environment'; +export { setupEnvironment, kibanaVersion } from './setup_environment'; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx index a1cdfaa3446cb..fbbbc0e07853c 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx @@ -9,7 +9,7 @@ import React from 'react'; import axios from 'axios'; // @ts-ignore import axiosXhrAdapter from 'axios/lib/adapters/xhr'; - +import SemVer from 'semver/classes/semver'; import { deprecationsServiceMock, docLinksServiceMock, @@ -19,7 +19,7 @@ import { import { HttpSetup } from 'src/core/public'; import { KibanaContextProvider } from '../../../public/shared_imports'; -import { mockKibanaSemverVersion } from '../../../common/constants'; +import { MAJOR_VERSION } from '../../../common/constants'; import { AppContextProvider } from '../../../public/application/app_context'; import { apiService } from '../../../public/application/lib/api'; import { breadcrumbService } from '../../../public/application/lib/breadcrumbs'; @@ -31,6 +31,8 @@ const { GlobalFlyoutProvider } = GlobalFlyout; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); +export const kibanaVersion = new SemVer(MAJOR_VERSION); + export const WithAppDependencies = (Comp: any, overrides: Record = {}) => (props: Record) => { @@ -41,9 +43,9 @@ export const WithAppDependencies = http: mockHttpClient as unknown as HttpSetup, docLinks: docLinksServiceMock.createStartContract(), kibanaVersionInfo: { - currentMajor: mockKibanaSemverVersion.major, - prevMajor: mockKibanaSemverVersion.major - 1, - nextMajor: mockKibanaSemverVersion.major + 1, + currentMajor: kibanaVersion.major, + prevMajor: kibanaVersion.major - 1, + nextMajor: kibanaVersion.major + 1, }, notifications: notificationServiceMock.createStartContract(), isReadOnlyMode: false, diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx index 0acf5ae65c6cc..7831ab0110e4f 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import { mockKibanaSemverVersion } from '../../../common/constants'; -import { OverviewTestBed, setupOverviewPage, setupEnvironment } from '../helpers'; +import { OverviewTestBed, setupOverviewPage, setupEnvironment, kibanaVersion } from '../helpers'; describe('Overview Page', () => { let testBed: OverviewTestBed; @@ -24,7 +23,7 @@ describe('Overview Page', () => { describe('Documentation links', () => { test('Has a whatsNew link and it references nextMajor version', () => { const { exists, find } = testBed; - const nextMajor = mockKibanaSemverVersion.major + 1; + const nextMajor = kibanaVersion.major + 1; expect(exists('whatsNewLink')).toBe(true); expect(find('whatsNewLink').text()).toContain(`${nextMajor}.0`); diff --git a/x-pack/plugins/upgrade_assistant/common/config.ts b/x-pack/plugins/upgrade_assistant/common/config.ts deleted file mode 100644 index e74fe5cc1bf16..0000000000000 --- a/x-pack/plugins/upgrade_assistant/common/config.ts +++ /dev/null @@ -1,20 +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 { schema, TypeOf } from '@kbn/config-schema'; - -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), - /* - * This will default to true up until the last minor before the next major. - * In readonly mode, the user will not be able to perform any actions in the UI - * and will be presented with a message indicating as such. - */ - readonly: schema.boolean({ defaultValue: true }), -}); - -export type Config = TypeOf; diff --git a/x-pack/plugins/upgrade_assistant/common/constants.ts b/x-pack/plugins/upgrade_assistant/common/constants.ts index 893d61d329534..68a6b9e9cdb83 100644 --- a/x-pack/plugins/upgrade_assistant/common/constants.ts +++ b/x-pack/plugins/upgrade_assistant/common/constants.ts @@ -4,15 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import SemVer from 'semver/classes/semver'; - /* - * These constants are used only in tests to add conditional logic based on Kibana version * On master, the version should represent the next major version (e.g., master --> 8.0.0) * The release branch should match the release version (e.g., 7.x --> 7.0.0) */ -export const mockKibanaVersion = '8.0.0'; -export const mockKibanaSemverVersion = new SemVer(mockKibanaVersion); +export const MAJOR_VERSION = '8.0.0'; /* * Map of 7.0 --> 8.0 index setting deprecation log messages and associated settings diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/warning_step.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/warning_step.test.tsx index ff11b9f1a8450..d2cafd69e94eb 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/warning_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/warning_step.test.tsx @@ -8,12 +8,20 @@ import { I18nProvider } from '@kbn/i18n/react'; import { mount, shallow } from 'enzyme'; import React from 'react'; +import SemVer from 'semver/classes/semver'; import { ReindexWarning } from '../../../../../../../common/types'; -import { mockKibanaSemverVersion } from '../../../../../../../common/constants'; +import { MAJOR_VERSION } from '../../../../../../../common/constants'; import { idForWarning, WarningsFlyoutStep } from './warnings_step'; +const kibanaVersion = new SemVer(MAJOR_VERSION); +const mockKibanaVersionInfo = { + currentMajor: kibanaVersion.major, + prevMajor: kibanaVersion.major - 1, + nextMajor: kibanaVersion.major + 1, +}; + jest.mock('../../../../../app_context', () => { const { docLinksServiceMock } = jest.requireActual( '../../../../../../../../../../src/core/public/doc_links/doc_links_service.mock' @@ -23,11 +31,7 @@ jest.mock('../../../../../app_context', () => { useAppContext: () => { return { docLinks: docLinksServiceMock.createStartContract(), - kibanaVersionInfo: { - currentMajor: mockKibanaSemverVersion.major, - prevMajor: mockKibanaSemverVersion.major - 1, - nextMajor: mockKibanaSemverVersion.major + 1, - }, + kibanaVersionInfo: mockKibanaVersionInfo, }; }, }; @@ -45,7 +49,7 @@ describe('WarningsFlyoutStep', () => { expect(shallow()).toMatchSnapshot(); }); - if (mockKibanaSemverVersion.major === 7) { + if (kibanaVersion.major === 7) { it('does not allow proceeding until all are checked', () => { const defaultPropsWithWarnings = { ...defaultProps, diff --git a/x-pack/plugins/upgrade_assistant/public/plugin.ts b/x-pack/plugins/upgrade_assistant/public/plugin.ts index 5edb638e1bc5b..32e825fbdc20d 100644 --- a/x-pack/plugins/upgrade_assistant/public/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/public/plugin.ts @@ -9,59 +9,69 @@ import SemVer from 'semver/classes/semver'; import { i18n } from '@kbn/i18n'; import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/public'; -import { SetupDependencies, StartDependencies, AppServicesContext } from './types'; -import { Config } from '../common/config'; +import { + SetupDependencies, + StartDependencies, + AppServicesContext, + ClientConfigType, +} from './types'; export class UpgradeAssistantUIPlugin implements Plugin { constructor(private ctx: PluginInitializerContext) {} + setup(coreSetup: CoreSetup, { management, cloud }: SetupDependencies) { - const { readonly } = this.ctx.config.get(); + const { + readonly, + ui: { enabled: isUpgradeAssistantUiEnabled }, + } = this.ctx.config.get(); - const appRegistrar = management.sections.section.stack; - const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version); + if (isUpgradeAssistantUiEnabled) { + const appRegistrar = management.sections.section.stack; + const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version); - const kibanaVersionInfo = { - currentMajor: kibanaVersion.major, - prevMajor: kibanaVersion.major - 1, - nextMajor: kibanaVersion.major + 1, - }; + const kibanaVersionInfo = { + currentMajor: kibanaVersion.major, + prevMajor: kibanaVersion.major - 1, + nextMajor: kibanaVersion.major + 1, + }; - const pluginName = i18n.translate('xpack.upgradeAssistant.appTitle', { - defaultMessage: '{version} Upgrade Assistant', - values: { version: `${kibanaVersionInfo.nextMajor}.0` }, - }); + const pluginName = i18n.translate('xpack.upgradeAssistant.appTitle', { + defaultMessage: '{version} Upgrade Assistant', + values: { version: `${kibanaVersionInfo.nextMajor}.0` }, + }); - appRegistrar.registerApp({ - id: 'upgrade_assistant', - title: pluginName, - order: 1, - async mount(params) { - const [coreStart, { discover, data }] = await coreSetup.getStartServices(); - const services: AppServicesContext = { discover, data, cloud }; + appRegistrar.registerApp({ + id: 'upgrade_assistant', + title: pluginName, + order: 1, + async mount(params) { + const [coreStart, { discover, data }] = await coreSetup.getStartServices(); + const services: AppServicesContext = { discover, data, cloud }; - const { - chrome: { docTitle }, - } = coreStart; + const { + chrome: { docTitle }, + } = coreStart; - docTitle.change(pluginName); + docTitle.change(pluginName); - const { mountManagementSection } = await import('./application/mount_management_section'); - const unmountAppCallback = await mountManagementSection( - coreSetup, - params, - kibanaVersionInfo, - readonly, - services - ); + const { mountManagementSection } = await import('./application/mount_management_section'); + const unmountAppCallback = await mountManagementSection( + coreSetup, + params, + kibanaVersionInfo, + readonly, + services + ); - return () => { - docTitle.reset(); - unmountAppCallback(); - }; - }, - }); + return () => { + docTitle.reset(); + unmountAppCallback(); + }; + }, + }); + } } start() {} diff --git a/x-pack/plugins/upgrade_assistant/public/types.ts b/x-pack/plugins/upgrade_assistant/public/types.ts index a2b49305c32d4..cbeaf22bb095b 100644 --- a/x-pack/plugins/upgrade_assistant/public/types.ts +++ b/x-pack/plugins/upgrade_assistant/public/types.ts @@ -26,3 +26,10 @@ export interface StartDependencies { discover: DiscoverStart; data: DataPublicPluginStart; } + +export interface ClientConfigType { + readonly: boolean; + ui: { + enabled: boolean; + }; +} diff --git a/x-pack/plugins/upgrade_assistant/server/config.ts b/x-pack/plugins/upgrade_assistant/server/config.ts new file mode 100644 index 0000000000000..4183ea337def1 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/config.ts @@ -0,0 +1,107 @@ +/* + * 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 { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common/constants'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + /* + * This will default to true up until the last minor before the next major. + * In readonly mode, the user will not be able to perform any actions in the UI + * and will be presented with a message indicating as such. + */ + readonly: schema.boolean({ defaultValue: true }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + readonly: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; + +export type UpgradeAssistantConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { + enabled: schema.boolean({ defaultValue: true }), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + /* + * This will default to true up until the last minor before the next major. + * In readonly mode, the user will not be able to perform any actions in the UI + * and will be presented with a message indicating as such. + */ + readonly: schema.boolean({ defaultValue: true }), + }, + { defaultValue: undefined } +); + +export type UpgradeAssistantConfig7x = TypeOf; + +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + readonly: true, + }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.upgrade_assistant.enabled') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'xpack.upgrade_assistant.enabled', + level: 'critical', + title: i18n.translate('xpack.upgradeAssistant.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.upgrade_assistant.enabled" is deprecated', + }), + message: i18n.translate('xpack.upgradeAssistant.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Upgrade Assistant UI, use the "xpack.upgrade_assistant.ui.enabled" setting instead of "xpack.upgrade_assistant.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.upgradeAssistant.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('xpack.upgradeAssistant.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: + 'Change the "xpack.upgrade_assistant.enabled" setting to "xpack.upgrade_assistant.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + ], +}; + +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/upgrade_assistant/server/index.ts b/x-pack/plugins/upgrade_assistant/server/index.ts index 5591276b2fa34..660aa107292e8 100644 --- a/x-pack/plugins/upgrade_assistant/server/index.ts +++ b/x-pack/plugins/upgrade_assistant/server/index.ts @@ -5,18 +5,11 @@ * 2.0. */ -import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { PluginInitializerContext } from 'src/core/server'; import { UpgradeAssistantServerPlugin } from './plugin'; -import { configSchema, Config } from '../common/config'; + +export { config } from './config'; export const plugin = (ctx: PluginInitializerContext) => { return new UpgradeAssistantServerPlugin(ctx); }; - -export const config: PluginConfigDescriptor = { - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], - schema: configSchema, - exposeToBrowser: { - readonly: true, - }, -}; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts index d93fe7920f1d7..5f39e902c75d9 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts @@ -4,14 +4,16 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { SemVer } from 'semver'; +import { MAJOR_VERSION } from '../../../common/constants'; -import { mockKibanaSemverVersion } from '../../../common/constants'; +const kibanaVersion = new SemVer(MAJOR_VERSION); export const getMockVersionInfo = () => { - const currentMajor = mockKibanaSemverVersion.major; + const currentMajor = kibanaVersion.major; return { - currentVersion: mockKibanaSemverVersion, + currentVersion: kibanaVersion, currentMajor, prevMajor: currentMajor - 1, nextMajor: currentMajor + 1, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts index e1817ef63927d..1785491e5da45 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts @@ -9,7 +9,7 @@ import { SemVer } from 'semver'; import { IScopedClusterClient, kibanaResponseFactory } from 'src/core/server'; import { coreMock } from 'src/core/server/mocks'; import { licensingMock } from '../../../../plugins/licensing/server/mocks'; -import { mockKibanaVersion } from '../../common/constants'; +import { MAJOR_VERSION } from '../../common/constants'; import { getMockVersionInfo } from './__fixtures__/version'; import { @@ -98,7 +98,7 @@ describe('verifyAllMatchKibanaVersion', () => { describe('EsVersionPrecheck', () => { beforeEach(() => { - versionService.setup(mockKibanaVersion); + versionService.setup(MAJOR_VERSION); }); it('returns a 403 when callCluster fails with a 403', async () => { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts index 30093a9fb6e50..957198cde8da9 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { mockKibanaSemverVersion, mockKibanaVersion } from '../../../common/constants'; +import { MAJOR_VERSION } from '../../../common/constants'; import { versionService } from '../version'; import { getMockVersionInfo } from '../__fixtures__/version'; @@ -131,7 +131,7 @@ describe('transformFlatSettings', () => { describe('sourceNameForIndex', () => { beforeEach(() => { - versionService.setup(mockKibanaVersion); + versionService.setup(MAJOR_VERSION); }); it('parses internal indices', () => { @@ -152,7 +152,7 @@ describe('transformFlatSettings', () => { describe('generateNewIndexName', () => { beforeEach(() => { - versionService.setup(mockKibanaVersion); + versionService.setup(MAJOR_VERSION); }); it('parses internal indices', () => { @@ -186,7 +186,7 @@ describe('transformFlatSettings', () => { ).toEqual([]); }); - if (mockKibanaSemverVersion.major === 7) { + if (currentMajor === 7) { describe('[7.x] customTypeName warning', () => { it('returns customTypeName warning for non-_doc mapping types', () => { expect( diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts index 3cfdb1fdd3167..ce1e8e11eb2d1 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts @@ -19,7 +19,7 @@ import { ReindexStatus, ReindexStep, } from '../../../common/types'; -import { mockKibanaVersion } from '../../../common/constants'; +import { MAJOR_VERSION } from '../../../common/constants'; import { versionService } from '../version'; import { LOCK_WINDOW, ReindexActions, reindexActionsFactory } from './reindex_actions'; import { getMockVersionInfo } from '../__fixtures__/version'; @@ -54,7 +54,7 @@ describe('ReindexActions', () => { describe('createReindexOp', () => { beforeEach(() => { - versionService.setup(mockKibanaVersion); + versionService.setup(MAJOR_VERSION); client.create.mockResolvedValue(); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts index 7a5bf1c187698..6017691a9328d 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts @@ -20,7 +20,7 @@ import { ReindexStatus, ReindexStep, } from '../../../common/types'; -import { mockKibanaVersion } from '../../../common/constants'; +import { MAJOR_VERSION } from '../../../common/constants'; import { licensingMock } from '../../../../licensing/server/mocks'; import { LicensingPluginSetup } from '../../../../licensing/server'; @@ -89,7 +89,7 @@ describe('reindexService', () => { licensingPluginSetup ); - versionService.setup(mockKibanaVersion); + versionService.setup(MAJOR_VERSION); }); describe('hasRequiredPrivileges', () => { From c1b0565acdbbcf7432a46a0664a91c34f299dab3 Mon Sep 17 00:00:00 2001 From: Tre Date: Tue, 19 Oct 2021 06:56:35 -0400 Subject: [PATCH 04/30] [QA][refactor] Use ui settings - sample data (#114530) --- test/functional/apps/home/_sample_data.ts | 21 +++++++++------------ test/functional/page_objects/common_page.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/test/functional/apps/home/_sample_data.ts b/test/functional/apps/home/_sample_data.ts index 3cf387133bc9c..e0a96940337e2 100644 --- a/test/functional/apps/home/_sample_data.ts +++ b/test/functional/apps/home/_sample_data.ts @@ -31,6 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await security.testUser.restoreDefaults(); + await PageObjects.common.unsetTime(); }); it('should display registered flights sample data sets', async () => { @@ -74,6 +75,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard', () => { beforeEach(async () => { + await time(); await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { useActualUrl: true, }); @@ -84,10 +86,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.home.launchSampleDashboard('flights'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const todayYearMonthDay = moment().format('MMM D, YYYY'); - const fromTime = `${todayYearMonthDay} @ 00:00:00.000`; - const toTime = `${todayYearMonthDay} @ 23:59:59.999`; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.be(17); }); @@ -112,10 +110,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.home.launchSampleDashboard('logs'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const todayYearMonthDay = moment().format('MMM D, YYYY'); - const fromTime = `${todayYearMonthDay} @ 00:00:00.000`; - const toTime = `${todayYearMonthDay} @ 23:59:59.999`; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.be(13); }); @@ -124,10 +118,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.home.launchSampleDashboard('ecommerce'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const todayYearMonthDay = moment().format('MMM D, YYYY'); - const fromTime = `${todayYearMonthDay} @ 00:00:00.000`; - const toTime = `${todayYearMonthDay} @ 23:59:59.999`; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.be(15); }); @@ -160,5 +150,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(isInstalled).to.be(false); }); }); + + async function time() { + const today = moment().format('MMM D, YYYY'); + const from = `${today} @ 00:00:00.000`; + const to = `${today} @ 23:59:59.999`; + await PageObjects.common.setTime({ from, to }); + } }); } diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 64fb184f40e48..a40465b00dbeb 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -30,6 +30,7 @@ export class CommonPageObject extends FtrService { private readonly globalNav = this.ctx.getService('globalNav'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly loginPage = this.ctx.getPageObject('login'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); private readonly defaultTryTimeout = this.config.get('timeouts.try'); private readonly defaultFindTimeout = this.config.get('timeouts.find'); @@ -500,4 +501,12 @@ export class CommonPageObject extends FtrService { await this.testSubjects.exists(validator); } } + + async setTime(time: { from: string; to: string }) { + await this.kibanaServer.uiSettings.replace({ 'timepicker:timeDefaults': JSON.stringify(time) }); + } + + async unsetTime() { + await this.kibanaServer.uiSettings.unset('timepicker:timeDefaults'); + } } From f8041e6005a10b73fd771b9b8e2c8d9a22bfce84 Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Tue, 19 Oct 2021 11:57:10 +0100 Subject: [PATCH 05/30] [ML] Delete annotation directly from the index it is stored in (#115328) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../ml/common/constants/index_patterns.ts | 1 - .../ml/server/lib/check_annotations/index.ts | 11 ++----- .../annotation_service/annotation.test.ts | 3 +- .../models/annotation_service/annotation.ts | 33 ++++++++++++++++--- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/ml/common/constants/index_patterns.ts b/x-pack/plugins/ml/common/constants/index_patterns.ts index d7d6c343e282b..9a8e5c1b8ae78 100644 --- a/x-pack/plugins/ml/common/constants/index_patterns.ts +++ b/x-pack/plugins/ml/common/constants/index_patterns.ts @@ -7,7 +7,6 @@ export const ML_ANNOTATIONS_INDEX_ALIAS_READ = '.ml-annotations-read'; export const ML_ANNOTATIONS_INDEX_ALIAS_WRITE = '.ml-annotations-write'; -export const ML_ANNOTATIONS_INDEX_PATTERN = '.ml-annotations-6'; export const ML_RESULTS_INDEX_PATTERN = '.ml-anomalies-*'; export const ML_NOTIFICATION_INDEX_PATTERN = '.ml-notifications*'; diff --git a/x-pack/plugins/ml/server/lib/check_annotations/index.ts b/x-pack/plugins/ml/server/lib/check_annotations/index.ts index a388a24d082a6..e64b4658588cb 100644 --- a/x-pack/plugins/ml/server/lib/check_annotations/index.ts +++ b/x-pack/plugins/ml/server/lib/check_annotations/index.ts @@ -11,22 +11,15 @@ import { mlLog } from '../../lib/log'; import { ML_ANNOTATIONS_INDEX_ALIAS_READ, ML_ANNOTATIONS_INDEX_ALIAS_WRITE, - ML_ANNOTATIONS_INDEX_PATTERN, } from '../../../common/constants/index_patterns'; // Annotations Feature is available if: -// - ML_ANNOTATIONS_INDEX_PATTERN index is present // - ML_ANNOTATIONS_INDEX_ALIAS_READ alias is present // - ML_ANNOTATIONS_INDEX_ALIAS_WRITE alias is present +// Note there is no need to check for the existence of the indices themselves as aliases are stored +// in the metadata of the indices they point to, so it's impossible to have an alias that doesn't point to any index. export async function isAnnotationsFeatureAvailable({ asInternalUser }: IScopedClusterClient) { try { - const indexParams = { index: ML_ANNOTATIONS_INDEX_PATTERN }; - - const { body: annotationsIndexExists } = await asInternalUser.indices.exists(indexParams); - if (!annotationsIndexExists) { - return false; - } - const { body: annotationsReadAliasExists } = await asInternalUser.indices.existsAlias({ index: ML_ANNOTATIONS_INDEX_ALIAS_READ, name: ML_ANNOTATIONS_INDEX_ALIAS_READ, diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts index 725e0ac494944..975070e92a7ec 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts @@ -9,7 +9,6 @@ import getAnnotationsRequestMock from './__mocks__/get_annotations_request.json' import getAnnotationsResponseMock from './__mocks__/get_annotations_response.json'; import { ANNOTATION_TYPE } from '../../../common/constants/annotations'; -import { ML_ANNOTATIONS_INDEX_ALIAS_WRITE } from '../../../common/constants/index_patterns'; import { Annotation, isAnnotations } from '../../../common/types/annotations'; import { DeleteParams, GetResponse, IndexAnnotationArgs } from './annotation'; @@ -42,7 +41,7 @@ describe('annotation_service', () => { const annotationMockId = 'mockId'; const deleteParamsMock: DeleteParams = { - index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, + index: '.ml-annotations-6', id: annotationMockId, refresh: 'wait_for', }; diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index c6ed72de18d05..5807d181cc566 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -71,6 +71,7 @@ export interface IndexParams { index: string; body: Annotation; refresh: boolean | 'wait_for' | undefined; + require_alias?: boolean; id?: string; } @@ -99,6 +100,7 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, body: annotation, refresh: 'wait_for', + require_alias: true, }; if (typeof annotation._id !== 'undefined') { @@ -407,14 +409,37 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { } async function deleteAnnotation(id: string) { - const params: DeleteParams = { - index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, + // Find the index the annotation is stored in. + const searchParams: estypes.SearchRequest = { + index: ML_ANNOTATIONS_INDEX_ALIAS_READ, + size: 1, + body: { + query: { + ids: { + values: [id], + }, + }, + }, + }; + + const { body } = await asInternalUser.search(searchParams); + const totalCount = + typeof body.hits.total === 'number' ? body.hits.total : body.hits.total.value; + + if (totalCount === 0) { + throw Boom.notFound(`Cannot find annotation with ID ${id}`); + } + + const index = body.hits.hits[0]._index; + + const deleteParams: DeleteParams = { + index, id, refresh: 'wait_for', }; - const { body } = await asInternalUser.delete(params); - return body; + const { body: deleteResponse } = await asInternalUser.delete(deleteParams); + return deleteResponse; } return { From 0e5f2524b46da0fe147b4726b741c38283789ed7 Mon Sep 17 00:00:00 2001 From: Diana Derevyankina <54894989+DziyanaDzeraviankina@users.noreply.github.com> Date: Tue, 19 Oct 2021 15:08:05 +0300 Subject: [PATCH 06/30] Respect external URL allow list in TSVB (#114093) * Respect external URL allow list in TSVB * Remove showExternalUrlErrorModal and onContextMenu handler for table * Update modal message Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../lib/external_url_error_modal.tsx | 60 +++++++++++++++++++ .../components/vis_types/table/vis.js | 57 ++++++++++++++---- .../components/vis_types/top_n/vis.js | 21 ++++++- 3 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 src/plugins/vis_types/timeseries/public/application/components/lib/external_url_error_modal.tsx diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/external_url_error_modal.tsx b/src/plugins/vis_types/timeseries/public/application/components/lib/external_url_error_modal.tsx new file mode 100644 index 0000000000000..ebb806387d9cf --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/external_url_error_modal.tsx @@ -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 React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiTextColor, +} from '@elastic/eui'; + +interface ExternalUrlErrorModalProps { + url: string; + handleClose: () => void; +} + +export const ExternalUrlErrorModal = ({ url, handleClose }: ExternalUrlErrorModalProps) => ( + + + + + + + + + {url} + + ), + externalUrlPolicy: 'externalUrl.policy', + kibanaConfigFileName: 'kibana.yml', + }} + /> + + + + + + + +); diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js b/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js index 7b1db4b362647..b3a48a997b301 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js @@ -17,6 +17,7 @@ import { createFieldFormatter } from '../../lib/create_field_formatter'; import { isSortable } from './is_sortable'; import { EuiToolTip, EuiIcon } from '@elastic/eui'; import { replaceVars } from '../../lib/replace_vars'; +import { ExternalUrlErrorModal } from '../../lib/external_url_error_modal'; import { FIELD_FORMAT_IDS } from '../../../../../../../../plugins/field_formats/common'; import { FormattedMessage } from '@kbn/i18n/react'; import { getFieldFormats, getCoreStart } from '../../../../services'; @@ -53,12 +54,26 @@ class TableVis extends Component { const DateFormat = fieldFormatsService.getType(FIELD_FORMAT_IDS.DATE); this.dateFormatter = new DateFormat({}, this.props.getConfig); + + this.state = { + accessDeniedDrilldownUrl: null, + }; } get visibleSeries() { return get(this.props, 'model.series', []).filter((series) => !series.hidden); } + createDrilldownUrlClickHandler = (url) => (event) => { + const validatedUrl = getCoreStart().http.externalUrl.validateUrl(url); + if (validatedUrl) { + this.setState({ accessDeniedDrilldownUrl: null }); + } else { + event.preventDefault(); + this.setState({ accessDeniedDrilldownUrl: url }); + } + }; + renderRow = (row) => { const { model, fieldFormatMap, getConfig } = this.props; @@ -74,7 +89,16 @@ class TableVis extends Component { if (model.drilldown_url) { const url = replaceVars(model.drilldown_url, {}, { key: row.key }); - rowDisplay = {rowDisplay}; + const handleDrilldownUrlClick = this.createDrilldownUrlClickHandler(url); + rowDisplay = ( + + {rowDisplay} + + ); } const columns = row.series @@ -213,8 +237,11 @@ class TableVis extends Component { ); } + closeExternalUrlErrorModal = () => this.setState({ accessDeniedDrilldownUrl: null }); + render() { const { visData, model } = this.props; + const { accessDeniedDrilldownUrl } = this.state; const header = this.renderHeader(); let rows; @@ -239,16 +266,24 @@ class TableVis extends Component { ); } return ( - - - {header} - {rows} -
-
+ <> + + + {header} + {rows} +
+
+ {accessDeniedDrilldownUrl && ( + + )} + ); } } diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_types/top_n/vis.js b/src/plugins/vis_types/timeseries/public/application/components/vis_types/top_n/vis.js index 8176f6ece2805..5eb850a753384 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_types/top_n/vis.js +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_types/top_n/vis.js @@ -15,10 +15,11 @@ import { getLastValue } from '../../../../../common/last_value_utils'; import { isBackgroundInverted } from '../../../lib/set_is_reversed'; import { replaceVars } from '../../lib/replace_vars'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState, useCallback } from 'react'; import { sortBy, first, get } from 'lodash'; import { DATA_FORMATTERS } from '../../../../../common/enums'; import { getOperator, shouldOperate } from '../../../../../common/operators_utils'; +import { ExternalUrlErrorModal } from '../../lib/external_url_error_modal'; function sortByDirection(data, direction, fn) { if (direction === 'desc') { @@ -41,6 +42,8 @@ function sortSeries(visData, model) { } function TopNVisualization(props) { + const [accessDeniedDrilldownUrl, setAccessDeniedDrilldownUrl] = useState(null); + const coreStart = getCoreStart(); const { backgroundColor, model, visData, fieldFormatMap, getConfig } = props; const series = sortSeries(visData, model).map((item) => { @@ -83,13 +86,27 @@ function TopNVisualization(props) { if (model.drilldown_url) { params.onClick = (item) => { const url = replaceVars(model.drilldown_url, {}, { key: item.label }); - getCoreStart().application.navigateToUrl(url); + const validatedUrl = coreStart.http.externalUrl.validateUrl(url); + if (validatedUrl) { + setAccessDeniedDrilldownUrl(null); + coreStart.application.navigateToUrl(url); + } else { + setAccessDeniedDrilldownUrl(url); + } }; } + const closeExternalUrlErrorModal = useCallback(() => setAccessDeniedDrilldownUrl(null), []); + return (
+ {accessDeniedDrilldownUrl && ( + + )}
); } From 340271fba271dab844d0c535d2dd685233535705 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 19 Oct 2021 08:34:15 -0400 Subject: [PATCH 07/30] [Security Solution] Analyze event moved outside of overflow popover (#115478) --- .../timeline_actions/alert_context_menu.tsx | 16 +----- .../timeline/body/actions/index.test.tsx | 29 ++++++++++ .../timeline/body/actions/index.tsx | 54 ++++++++++++++++++- .../__snapshots__/index.test.tsx.snap | 2 +- .../timeline/body/control_columns/index.tsx | 2 +- .../components/timeline/body/helpers.tsx | 6 ++- 6 files changed, 90 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 06d61b3f0b284..a9b6eabecff86 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -34,7 +34,6 @@ import { useExceptionActions } from './use_add_exception_actions'; import { useEventFilterModal } from './use_event_filter_modal'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { useKibana } from '../../../../common/lib/kibana'; -import { useInvestigateInResolverContextItem } from './investigate_in_resolver'; import { ATTACH_ALERT_TO_CASE_FOR_ROW } from '../../../../timelines/components/timeline/body/translations'; import { useEventFilterAction } from './use_event_filter_action'; import { useAddToCaseActions } from './use_add_to_case_actions'; @@ -163,30 +162,19 @@ const AlertContextMenuComponent: React.FC !isEvent && ruleId - ? [ - ...investigateInResolverActionItems, - ...addToCaseActionItems, - ...statusActionItems, - ...exceptionActionItems, - ] - : [...investigateInResolverActionItems, ...addToCaseActionItems, ...eventFilterActionItems], + ? [...addToCaseActionItems, ...statusActionItems, ...exceptionActionItems] + : [...addToCaseActionItems, ...eventFilterActionItems], [ statusActionItems, addToCaseActionItems, eventFilterActionItems, exceptionActionItems, - investigateInResolverActionItems, isEvent, ruleId, ] diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx index 5ed9398a621e8..1da09bcf4e25f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx @@ -185,5 +185,34 @@ describe('Actions', () => { wrapper.find('[data-test-subj="timeline-context-menu-button"]').first().prop('isDisabled') ).toBe(false); }); + test('it shows the analyze event button when the event is from an endpoint', () => { + const ecsData = { + ...mockTimelineData[0].ecs, + event: { kind: ['alert'] }, + agent: { type: ['endpoint'] }, + process: { entity_id: ['1'] }, + }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="view-in-analyzer"]').exists()).toBe(true); + }); + test('it does not render the analyze event button when the event is from an unsupported source', () => { + const ecsData = { + ...mockTimelineData[0].ecs, + event: { kind: ['alert'] }, + agent: { type: ['notendpoint'] }, + }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="view-in-analyzer"]').exists()).toBe(false); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 73650bd320f32..c4dae739cb251 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -21,9 +21,23 @@ import { EventsTdContent } from '../../styles'; import * as i18n from '../translations'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; -import { TimelineId, ActionProps, OnPinEvent } from '../../../../../../common/types/timeline'; +import { + setActiveTabTimeline, + updateTimelineGraphEventId, +} from '../../../../store/timeline/actions'; +import { + useGlobalFullScreen, + useTimelineFullScreen, +} from '../../../../../common/containers/use_full_screen'; +import { + TimelineId, + ActionProps, + OnPinEvent, + TimelineTabs, +} from '../../../../../../common/types/timeline'; import { timelineActions, timelineSelectors } from '../../../../store/timeline'; import { timelineDefaults } from '../../../../store/timeline/defaults'; +import { isInvestigateInResolverActionEnabled } from '../../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'; const ActionsContainer = styled.div` align-items: center; @@ -100,6 +114,24 @@ const ActionsComponent: React.FC = ({ [eventType, ecsData.event?.kind, ecsData.agent?.type] ); + const isDisabled = useMemo(() => !isInvestigateInResolverActionEnabled(ecsData), [ecsData]); + const { setGlobalFullScreen } = useGlobalFullScreen(); + const { setTimelineFullScreen } = useTimelineFullScreen(); + const handleClick = useCallback(() => { + const dataGridIsFullScreen = document.querySelector('.euiDataGrid--fullScreen'); + dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: ecsData._id })); + if (timelineId === TimelineId.active) { + if (dataGridIsFullScreen) { + setTimelineFullScreen(true); + } + dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.graph })); + } else { + if (dataGridIsFullScreen) { + setGlobalFullScreen(true); + } + } + }, [dispatch, ecsData._id, timelineId, setGlobalFullScreen, setTimelineFullScreen]); + return ( {showCheckboxes && !tGridEnabled && ( @@ -171,6 +203,26 @@ const ActionsComponent: React.FC = ({ refetch={refetch ?? noop} onRuleChange={onRuleChange} /> + {isDisabled === false ? ( +
+ + + + + +
+ ) : null}
); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 6bc2dc089494d..25d5104a98d95 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -521,7 +521,7 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` "compare": null, "type": [Function], }, - "width": 108, + "width": 140, }, ] } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx index d38bf2136513e..2cdc8d5f4e284 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx @@ -9,7 +9,7 @@ import { ControlColumnProps } from '../../../../../../common/types/timeline'; import { Actions } from '../actions'; import { HeaderActions } from '../actions/header_actions'; -const DEFAULT_CONTROL_COLUMN_WIDTH = 108; +const DEFAULT_CONTROL_COLUMN_WIDTH = 140; export const defaultControlColumn: ControlColumnProps = { id: 'default-timeline-control-column', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx index 5b993110d38b5..7032319b59333 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx @@ -17,8 +17,10 @@ import { import { OnPinEvent, OnUnPinEvent } from '../events'; import * as i18n from './translations'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const omitTypenameAndEmpty = (k: string, v: any): any | undefined => +export const omitTypenameAndEmpty = ( + k: string, + v: string | object | Array +): string | object | Array | undefined => k !== '__typename' && v != null ? v : undefined; export const stringifyEvent = (ecs: Ecs): string => JSON.stringify(ecs, omitTypenameAndEmpty, 2); From b402bea0658a82d028a3e5c55df4a907a18ab326 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Tue, 19 Oct 2021 09:33:46 -0400 Subject: [PATCH 08/30] Disable the experimental `metrics_entities` plugin by default. (#115460) This was default disabled in 7.15, but we needed a code change to maintain that (consistent) behavior. --- x-pack/plugins/metrics_entities/server/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/x-pack/plugins/metrics_entities/server/index.ts b/x-pack/plugins/metrics_entities/server/index.ts index e61dc8b7dc642..bb80ac8e8be73 100644 --- a/x-pack/plugins/metrics_entities/server/index.ts +++ b/x-pack/plugins/metrics_entities/server/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { schema } from '@kbn/config-schema'; + import { PluginInitializerContext } from '../../../../src/core/server'; import { MetricsEntitiesPlugin } from './plugin'; @@ -17,3 +19,10 @@ export const plugin = (initializerContext: PluginInitializerContext): MetricsEnt }; export { MetricsEntitiesPluginSetup, MetricsEntitiesPluginStart } from './types'; + +export const config = { + schema: schema.object({ + // This plugin is experimental and should be disabled by default. + enabled: schema.boolean({ defaultValue: false }), + }), +}; From e5a918dc7dad47e32bd9abf6b056e99ffaf0db18 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Tue, 19 Oct 2021 07:43:43 -0600 Subject: [PATCH 09/30] [SecuritySolution][Detections] Enables Index Action and Connector for Detection Actions (#111813) ## Summary This PR enables the [Index Connector and Action](https://www.elastic.co/guide/en/kibana/master/index-action-type.html) for the detection engine, addressing https://github.com/elastic/kibana/issues/110550.
Action type available in list:

No Connector UI:

Create Connector UI:

Connector Template:

``` json { "rule_id": "{{context.rule.id}}", "rule_name": "{{context.rule.name}}", "alert_id": "{{alert.id}}", "context_message": "Threshold Results: {{#context.alerts}}{{#signal.threshold_result.terms}}{{value}}, {{/signal.threshold_result.terms}}{{/context.alerts}}" } ```

Documents successfully written:

--- If wanting to store the alert index timestamp, create index first with `timestamp` field and use `Define timefield for each document` option: ``` PUT .homemade-alerts-index { "mappings" : { "dynamic": "true", "properties" : { "@timestamp": { "type": "date" } } } } ```

### Checklist Delete any items that are not applicable to this PR. - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials (will need to update documentation if we proceed with this PR) --- x-pack/plugins/security_solution/common/constants.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 5a7e19e2cdd05..6e8d574c15860 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -296,15 +296,16 @@ export const ML_GROUP_IDS = [ML_GROUP_ID, LEGACY_ML_GROUP_ID]; */ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ '.email', - '.slack', + '.index', + '.jira', '.pagerduty', - '.swimlane', - '.webhook', + '.resilient', '.servicenow', '.servicenow-sir', - '.jira', - '.resilient', + '.slack', + '.swimlane', '.teams', + '.webhook', ]; if (ENABLE_CASE_CONNECTOR) { From 5a0002fae834965e7924b448b1036ff91cbc698d Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Tue, 19 Oct 2021 09:51:55 -0400 Subject: [PATCH 10/30] [App Search] Update "overrides" badge (#115437) --- .../components/curations/components/suggestions_table.test.tsx | 2 +- .../components/curations/components/suggestions_table.tsx | 2 +- .../applications/app_search/components/curations/types.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx index 28c368d942c1f..439f9dabadee6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx @@ -88,7 +88,7 @@ describe('SuggestionsTable', () => { wrapper = renderColumn(0)('test', {}); expect(wrapper.find(EuiBadge)).toHaveLength(0); - wrapper = renderColumn(0)('test', { override_curation_id: '1-2-3' }); + wrapper = renderColumn(0)('test', { override_manual_curation: true }); expect(wrapper.find(EuiBadge).prop('children')).toEqual('Overrides'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx index b7a731c1654ca..c91468e67529e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx @@ -42,7 +42,7 @@ const columns: Array> = [ render: (query: string, curation: CurationSuggestion) => ( {query} - {curation.override_curation_id && ( + {curation.override_manual_curation && ( <> {i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts index f00da5deec7e3..7479505ea86da 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts @@ -15,7 +15,7 @@ export interface CurationSuggestion { status: 'pending' | 'applied' | 'automated' | 'rejected' | 'disabled'; curation_id?: string; operation: 'create' | 'update' | 'delete'; - override_curation_id?: string; + override_manual_curation?: boolean; } export interface Curation { From 44d0150ae19ed3f01dca913285f834eccede3dd1 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 19 Oct 2021 15:58:59 +0200 Subject: [PATCH 11/30] :bug: Fix single percentile case when ES is returning no buckets (#115214) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/search/aggs/metrics/single_percentile.test.ts | 5 +++++ .../data/common/search/aggs/metrics/single_percentile.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/plugins/data/common/search/aggs/metrics/single_percentile.test.ts b/src/plugins/data/common/search/aggs/metrics/single_percentile.test.ts index c2ba6ee1a403a..967e1b1f624aa 100644 --- a/src/plugins/data/common/search/aggs/metrics/single_percentile.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/single_percentile.test.ts @@ -73,6 +73,11 @@ describe('AggTypeMetricSinglePercentileProvider class', () => { ).toEqual(123); }); + it('should not throw error for empty buckets', () => { + const agg = aggConfigs.getResponseAggs()[0]; + expect(agg.getValue({})).toEqual(NaN); + }); + it('produces the expected expression ast', () => { const agg = aggConfigs.getResponseAggs()[0]; expect(agg.toExpressionAst()).toMatchInlineSnapshot(` diff --git a/src/plugins/data/common/search/aggs/metrics/single_percentile.ts b/src/plugins/data/common/search/aggs/metrics/single_percentile.ts index 4bdafcae327cd..954576e2bbe1f 100644 --- a/src/plugins/data/common/search/aggs/metrics/single_percentile.ts +++ b/src/plugins/data/common/search/aggs/metrics/single_percentile.ts @@ -57,7 +57,9 @@ export const getSinglePercentileMetricAgg = () => { if (Number.isInteger(agg.params.percentile)) { valueKey += '.0'; } - return bucket[agg.id].values[valueKey]; + const { values } = bucket[agg.id] ?? {}; + + return values ? values[valueKey] : NaN; }, }); }; From 7420cc228c3462b9b5b3ba34fd18166e54718d8f Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Tue, 19 Oct 2021 09:59:29 -0400 Subject: [PATCH 12/30] [Fleet] Add telemetry for integration cards (#115413) --- x-pack/plugins/fleet/kibana.json | 2 +- .../applications/integrations/index.tsx | 23 +++++---- .../sections/epm/components/package_card.tsx | 47 +++++++++++-------- .../epm/screens/home/available_packages.tsx | 2 +- .../sections/epm/screens/home/index.tsx | 2 +- x-pack/plugins/fleet/public/plugin.ts | 16 ++++++- 6 files changed, 58 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/fleet/kibana.json b/x-pack/plugins/fleet/kibana.json index 9de538ee91b8c..1ca88cac1cc11 100644 --- a/x-pack/plugins/fleet/kibana.json +++ b/x-pack/plugins/fleet/kibana.json @@ -11,5 +11,5 @@ "requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations", "share"], "optionalPlugins": ["security", "features", "cloud", "usageCollection", "home", "globalSearch"], "extraPublicDirs": ["common"], - "requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils"] + "requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils", "usageCollection"] } diff --git a/x-pack/plugins/fleet/public/applications/integrations/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/index.tsx index 0abb78f850076..4099879538afa 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/index.tsx @@ -70,18 +70,21 @@ export function renderApp( { element, appBasePath, history, setHeaderActionMenu }: AppMountParameters, config: FleetConfigType, kibanaVersion: string, - extensions: UIExtensionsStorage + extensions: UIExtensionsStorage, + UsageTracker: React.FC ) { ReactDOM.render( - , + + + , element ); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index 091eb4c97183d..7181241776dda 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -9,6 +9,8 @@ import React from 'react'; import styled from 'styled-components'; import { EuiCard, EuiFlexItem, EuiBadge, EuiToolTip, EuiSpacer } from '@elastic/eui'; +import { TrackApplicationView } from '../../../../../../../../../src/plugins/usage_collection/public'; + import { CardIcon } from '../../../../../components/package_icon'; import type { IntegrationCardItem } from '../../../../../../common/types/models/epm'; @@ -31,6 +33,7 @@ export function PackageCard({ integration, url, release, + id, }: PackageCardProps) { let releaseBadge: React.ReactNode | null = null; @@ -47,26 +50,30 @@ export function PackageCard({ ); } + const testid = `integration-card:${id}`; return ( - - } - href={url} - target={url.startsWith('http') || url.startsWith('https') ? '_blank' : undefined} - > - {releaseBadge} - + + + } + href={url} + target={url.startsWith('http') || url.startsWith('https') ? '_blank' : undefined} + > + {releaseBadge} + + ); } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx index f5c521ebacf16..73de0e51bea65 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx @@ -79,7 +79,7 @@ const packageListToIntegrationsList = (packages: PackageList): PackageList => { const allCategories = [...topCategories, ...categories]; return { ...restOfPackage, - id: `${restOfPackage}-${name}`, + id: `${restOfPackage.id}-${name}`, integration: name, title, description, diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx index 4270d360b9294..e3fc5e15488e6 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx @@ -74,7 +74,7 @@ export const mapToCard = ( } return { - id: `${item.type === 'ui_link' ? 'ui_link' : 'epr'}-${item.id}`, + id: `${item.type === 'ui_link' ? 'ui_link' : 'epr'}:${item.id}`, description: item.description, icons: !item.icons || !item.icons.length ? [] : item.icons, title: item.title, diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index 4a2a6900cc78c..b0e4e56aa344a 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -5,6 +5,7 @@ * 2.0. */ +import React from 'react'; import type { AppMountParameters, CoreSetup, @@ -23,6 +24,8 @@ import type { import type { SharePluginStart } from 'src/plugins/share/public'; +import type { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; + import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '../../../../src/core/public'; import type { @@ -73,6 +76,7 @@ export interface FleetSetupDeps { cloud?: CloudSetup; globalSearch?: GlobalSearchPluginSetup; customIntegrations: CustomIntegrationsSetup; + usageCollection?: UsageCollectionSetup; } export interface FleetStartDeps { @@ -137,7 +141,17 @@ export class FleetPlugin implements Plugin { unmount(); From 1a917674a4569c2f61b567d2096827c385000bca Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Tue, 19 Oct 2021 10:07:45 -0400 Subject: [PATCH 13/30] [Security Solution] [Platform] Migrate legacy actions whenever user interacts with the rule (#115101) Migrate legacy actions whenever user interacts with the rule (#115101) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../rules/add_prepackaged_rules_route.ts | 1 + .../routes/rules/import_rules_route.ts | 9 ++- .../routes/rules/patch_rules_bulk_route.ts | 10 ++- .../routes/rules/patch_rules_route.ts | 10 ++- .../rules/update_rules_bulk_route.test.ts | 2 + .../routes/rules/update_rules_bulk_route.ts | 16 +++++ .../routes/rules/update_rules_route.test.ts | 2 +- .../routes/rules/update_rules_route.ts | 16 +++++ .../rules/patch_rules.mock.ts | 3 + .../lib/detection_engine/rules/patch_rules.ts | 7 ++- .../lib/detection_engine/rules/types.ts | 17 ++++- .../rules/update_prepacked_rules.test.ts | 4 ++ .../rules/update_prepacked_rules.ts | 14 ++++- .../rules/update_rules.mock.ts | 3 + .../detection_engine/rules/update_rules.ts | 20 +++++- .../lib/detection_engine/rules/utils.ts | 62 +++++++++++++++++++ 16 files changed, 183 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index fed34743e220a..ddf4e956beac4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -171,6 +171,7 @@ export const createPrepackagedRules = async ( ); await updatePrepackagedRules( rulesClient, + savedObjectsClient, context.securitySolution.getSpaceId(), ruleStatusClient, rulesToUpdate, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 8269fe8b36132..b09ef1a215747 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -40,6 +40,7 @@ import { } from '../utils'; import { patchRules } from '../../rules/patch_rules'; +import { legacyMigrate } from '../../rules/utils'; import { getTupleDuplicateErrorsAndUniqueRules } from './utils'; import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; @@ -271,8 +272,14 @@ export const importRulesRoute = ( status_code: 200, }); } else if (rule != null && request.query.overwrite) { + const migratedRule = await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule, + }); await patchRules({ rulesClient, + savedObjectsClient, author, buildingBlockType, spaceId: context.securitySolution.getSpaceId(), @@ -291,7 +298,7 @@ export const importRulesRoute = ( timelineTitle, meta, filters, - rule, + rule: migratedRule, index, interval, maxSignals, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index 67d68221d846f..2b514ba911091 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -24,6 +24,7 @@ import { transformValidateBulkError } from './validate'; import { patchRules } from '../../rules/patch_rules'; import { readRules } from '../../rules/read_rules'; import { PartialFilter } from '../../types'; +import { legacyMigrate } from '../../rules/utils'; export const patchRulesBulkRoute = ( router: SecuritySolutionPluginRouter, @@ -133,9 +134,16 @@ export const patchRulesBulkRoute = ( throwHttpError(await mlAuthz.validateRuleType(existingRule?.params.type)); } - const rule = await patchRules({ + const migratedRule = await legacyMigrate({ + rulesClient, + savedObjectsClient, rule: existingRule, + }); + + const rule = await patchRules({ + rule: migratedRule, rulesClient, + savedObjectsClient, author, buildingBlockType, description, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts index cf140f22289de..0096cd2e38180 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -24,6 +24,7 @@ import { buildSiemResponse } from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { readRules } from '../../rules/read_rules'; +import { legacyMigrate } from '../../rules/utils'; import { PartialFilter } from '../../types'; export const patchRulesRoute = ( @@ -134,8 +135,15 @@ export const patchRulesRoute = ( throwHttpError(await mlAuthz.validateRuleType(existingRule?.params.type)); } + const migratedRule = await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule: existingRule, + }); + const rule = await patchRules({ rulesClient, + savedObjectsClient, author, buildingBlockType, description, @@ -154,7 +162,7 @@ export const patchRulesRoute = ( timelineTitle, meta, filters, - rule: existingRule, + rule: migratedRule, index, interval, maxSignals, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index f7bef76944a97..22e8f6543eb7c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -41,6 +41,8 @@ describe.each([ getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) ); + clients.appClient.getSignalsIndex.mockReturnValue('.siem-signals-test-index'); + updateRulesBulkRoute(server.router, ml, isRuleRegistryEnabled); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 6138690070b62..d8b7e8cb2b724 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -19,6 +19,8 @@ import { getIdBulkError } from './utils'; import { transformValidateBulkError } from './validate'; import { transformBulkError, buildSiemResponse, createBulkErrorObject } from '../utils'; import { updateRules } from '../../rules/update_rules'; +import { legacyMigrate } from '../../rules/utils'; +import { readRules } from '../../rules/read_rules'; export const updateRulesBulkRoute = ( router: SecuritySolutionPluginRouter, @@ -69,10 +71,24 @@ export const updateRulesBulkRoute = ( throwHttpError(await mlAuthz.validateRuleType(payloadRule.type)); + const existingRule = await readRules({ + isRuleRegistryEnabled, + rulesClient, + ruleId: payloadRule.rule_id, + id: payloadRule.id, + }); + + await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule: existingRule, + }); + const rule = await updateRules({ spaceId: context.securitySolution.getSpaceId(), rulesClient, ruleStatusClient, + savedObjectsClient, defaultOutputIndex: siemClient.getSignalsIndex(), ruleUpdate: payloadRule, isRuleRegistryEnabled, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index 7d611f3cccbf2..37df792b421b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -44,7 +44,7 @@ describe.each([ getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) ); // successful update clients.ruleExecutionLogClient.find.mockResolvedValue([]); // successful transform: ; - + clients.appClient.getSignalsIndex.mockReturnValue('.siem-signals-test-index'); updateRulesRoute(server.router, ml, isRuleRegistryEnabled); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts index 7cfe83093a549..cf443e3293510 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -19,6 +19,8 @@ import { getIdError } from './utils'; import { transformValidate } from './validate'; import { updateRules } from '../../rules/update_rules'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; +import { legacyMigrate } from '../../rules/utils'; +import { readRules } from '../../rules/read_rules'; export const updateRulesRoute = ( router: SecuritySolutionPluginRouter, @@ -59,11 +61,25 @@ export const updateRulesRoute = ( throwHttpError(await mlAuthz.validateRuleType(request.body.type)); const ruleStatusClient = context.securitySolution.getExecutionLogClient(); + + const existingRule = await readRules({ + isRuleRegistryEnabled, + rulesClient, + ruleId: request.body.rule_id, + id: request.body.id, + }); + + await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule: existingRule, + }); const rule = await updateRules({ defaultOutputIndex: siemClient.getSignalsIndex(), isRuleRegistryEnabled, rulesClient, ruleStatusClient, + savedObjectsClient, ruleUpdate: request.body, spaceId: context.securitySolution.getSpaceId(), }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index 1d09e4ca5c508..3626bcd5f127e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -7,6 +7,7 @@ import { PatchRulesOptions } from './types'; import { rulesClientMock } from '../../../../../alerting/server/mocks'; +import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { getAlertMock } from '../routes/__mocks__/request_responses'; import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; import { ruleExecutionLogClientMock } from '../rule_execution_log/__mocks__/rule_execution_log_client'; @@ -15,6 +16,7 @@ export const getPatchRulesOptionsMock = (isRuleRegistryEnabled: boolean): PatchR author: ['Elastic'], buildingBlockType: undefined, rulesClient: rulesClientMock.create(), + savedObjectsClient: savedObjectsClientMock.create(), spaceId: 'default', ruleStatusClient: ruleExecutionLogClientMock.create(), anomalyThreshold: undefined, @@ -68,6 +70,7 @@ export const getPatchMlRulesOptionsMock = (isRuleRegistryEnabled: boolean): Patc author: ['Elastic'], buildingBlockType: undefined, rulesClient: rulesClientMock.create(), + savedObjectsClient: savedObjectsClientMock.create(), spaceId: 'default', ruleStatusClient: ruleExecutionLogClientMock.create(), anomalyThreshold: 55, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index c3b7e7288dc57..fd48cd4eebc2c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -37,6 +37,7 @@ class PatchError extends Error { export const patchRules = async ({ rulesClient, + savedObjectsClient, author, buildingBlockType, ruleStatusClient, @@ -191,14 +192,14 @@ export const patchRules = async ({ const newRule = { tags: addTags(tags ?? rule.tags, rule.params.ruleId, rule.params.immutable), - throttle: throttle !== undefined ? transformToAlertThrottle(throttle) : rule.throttle, - notifyWhen: throttle !== undefined ? transformToNotifyWhen(throttle) : rule.notifyWhen, name: calculateName({ updatedName: name, originalName: rule.name }), schedule: { interval: calculateInterval(interval, rule.schedule.interval), }, - actions: actions?.map(transformRuleToAlertAction) ?? rule.actions, params: removeUndefined(nextParams), + actions: actions?.map(transformRuleToAlertAction) ?? rule.actions, + throttle: throttle !== undefined ? transformToAlertThrottle(throttle) : rule.throttle, + notifyWhen: throttle !== undefined ? transformToNotifyWhen(throttle) : rule.notifyWhen, }; const [validated, errors] = validate(newRule, internalRuleUpdate); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 53a83d61da78d..a4ef081154010 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -8,7 +8,12 @@ import { get } from 'lodash/fp'; import { Readable } from 'stream'; -import { SavedObject, SavedObjectAttributes, SavedObjectsFindResult } from 'kibana/server'; +import { + SavedObject, + SavedObjectAttributes, + SavedObjectsClientContract, + SavedObjectsFindResult, +} from 'kibana/server'; import type { MachineLearningJobIdOrUndefined, From, @@ -271,12 +276,14 @@ export interface UpdateRulesOptions { rulesClient: RulesClient; defaultOutputIndex: string; ruleUpdate: UpdateRulesSchema; + savedObjectsClient: SavedObjectsClientContract; } export interface PatchRulesOptions { spaceId: string; ruleStatusClient: IRuleExecutionLogClient; rulesClient: RulesClient; + savedObjectsClient: SavedObjectsClientContract; anomalyThreshold: AnomalyThresholdOrUndefined; author: AuthorOrUndefined; buildingBlockType: BuildingBlockTypeOrUndefined; @@ -323,7 +330,7 @@ export interface PatchRulesOptions { version: VersionOrUndefined; exceptionsList: ListArrayOrUndefined; actions: RuleAlertAction[] | undefined; - rule: SanitizedAlert | null; + rule: SanitizedAlert | null | undefined; namespace?: NamespaceOrUndefined; } @@ -351,3 +358,9 @@ export interface FindRuleOptions { fields: FieldsOrUndefined; sortOrder: SortOrderOrUndefined; } + +export interface LegacyMigrateParams { + rulesClient: RulesClient; + savedObjectsClient: SavedObjectsClientContract; + rule: SanitizedAlert | null | undefined; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts index 7c9f0c9ec67a3..9bd0fe3cef59a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts @@ -6,6 +6,7 @@ */ import { rulesClientMock } from '../../../../../alerting/server/mocks'; +import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; import { updatePrepackagedRules } from './update_prepacked_rules'; import { patchRules } from './patch_rules'; @@ -19,10 +20,12 @@ describe.each([ ])('updatePrepackagedRules - %s', (_, isRuleRegistryEnabled) => { let rulesClient: ReturnType; let ruleStatusClient: ReturnType; + let savedObjectsClient: ReturnType; beforeEach(() => { rulesClient = rulesClientMock.create(); ruleStatusClient = ruleExecutionLogClientMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); }); it('should omit actions and enabled when calling patchRules', async () => { @@ -40,6 +43,7 @@ describe.each([ await updatePrepackagedRules( rulesClient, + savedObjectsClient, 'default', ruleStatusClient, [{ ...prepackagedRule, actions }], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index d9c2ecd1b5732..dcf43d41e8d78 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -6,6 +6,7 @@ */ import { chunk } from 'lodash/fp'; +import { SavedObjectsClientContract } from 'kibana/server'; import { AddPrepackagedRulesSchemaDecoded } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema'; import { RulesClient, PartialAlert } from '../../../../../alerting/server'; import { patchRules } from './patch_rules'; @@ -13,6 +14,7 @@ import { readRules } from './read_rules'; import { PartialFilter } from '../types'; import { RuleParams } from '../schemas/rule_schemas'; import { IRuleExecutionLogClient } from '../rule_execution_log/types'; +import { legacyMigrate } from './utils'; /** * How many rules to update at a time is set to 50 from errors coming from @@ -51,6 +53,7 @@ export const UPDATE_CHUNK_SIZE = 50; */ export const updatePrepackagedRules = async ( rulesClient: RulesClient, + savedObjectsClient: SavedObjectsClientContract, spaceId: string, ruleStatusClient: IRuleExecutionLogClient, rules: AddPrepackagedRulesSchemaDecoded[], @@ -61,6 +64,7 @@ export const updatePrepackagedRules = async ( for (const ruleChunk of ruleChunks) { const rulePromises = createPromises( rulesClient, + savedObjectsClient, spaceId, ruleStatusClient, ruleChunk, @@ -82,6 +86,7 @@ export const updatePrepackagedRules = async ( */ export const createPromises = ( rulesClient: RulesClient, + savedObjectsClient: SavedObjectsClientContract, spaceId: string, ruleStatusClient: IRuleExecutionLogClient, rules: AddPrepackagedRulesSchemaDecoded[], @@ -146,10 +151,17 @@ export const createPromises = ( // TODO: Fix these either with an is conversion or by better typing them within io-ts const filters: PartialFilter[] | undefined = filtersObject as PartialFilter[]; + const migratedRule = await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule: existingRule, + }); + // Note: we do not pass down enabled as we do not want to suddenly disable // or enable rules on the user when they were not expecting it if a rule updates return patchRules({ rulesClient, + savedObjectsClient, author, buildingBlockType, description, @@ -160,7 +172,7 @@ export const createPromises = ( language, license, outputIndex, - rule: existingRule, + rule: migratedRule, savedId, spaceId, ruleStatusClient, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts index 58d6cf1fd5e6b..9a7711fcc8987 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts @@ -6,6 +6,7 @@ */ import { rulesClientMock } from '../../../../../alerting/server/mocks'; +import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { getUpdateMachineLearningSchemaMock, getUpdateRulesSchemaMock, @@ -16,6 +17,7 @@ export const getUpdateRulesOptionsMock = (isRuleRegistryEnabled: boolean) => ({ spaceId: 'default', rulesClient: rulesClientMock.create(), ruleStatusClient: ruleExecutionLogClientMock.create(), + savedObjectsClient: savedObjectsClientMock.create(), defaultOutputIndex: '.siem-signals-default', ruleUpdate: getUpdateRulesSchemaMock(), isRuleRegistryEnabled, @@ -25,6 +27,7 @@ export const getUpdateMlRulesOptionsMock = (isRuleRegistryEnabled: boolean) => ( spaceId: 'default', rulesClient: rulesClientMock.create(), ruleStatusClient: ruleExecutionLogClientMock.create(), + savedObjectsClient: savedObjectsClientMock.create(), defaultOutputIndex: '.siem-signals-default', ruleUpdate: getUpdateMachineLearningSchemaMock(), isRuleRegistryEnabled, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index f4060f7f831a9..4268ed9014066 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -6,7 +6,7 @@ */ /* eslint-disable complexity */ - +import { validate } from '@kbn/securitysolution-io-ts-utils'; import { DEFAULT_MAX_SIGNALS } from '../../../../common/constants'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { PartialAlert } from '../../../../../alerting/server'; @@ -14,10 +14,18 @@ import { readRules } from './read_rules'; import { UpdateRulesOptions } from './types'; import { addTags } from './add_tags'; import { typeSpecificSnakeToCamel } from '../schemas/rule_converters'; -import { RuleParams } from '../schemas/rule_schemas'; +import { internalRuleUpdate, RuleParams } from '../schemas/rule_schemas'; import { enableRule } from './enable_rule'; import { maybeMute, transformToAlertThrottle, transformToNotifyWhen } from './utils'; +class UpdateError extends Error { + public readonly statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} + export const updateRules = async ({ isRuleRegistryEnabled, spaceId, @@ -25,6 +33,7 @@ export const updateRules = async ({ ruleStatusClient, defaultOutputIndex, ruleUpdate, + savedObjectsClient, }: UpdateRulesOptions): Promise | null> => { const existingRule = await readRules({ isRuleRegistryEnabled, @@ -82,9 +91,14 @@ export const updateRules = async ({ notifyWhen: transformToNotifyWhen(ruleUpdate.throttle), }; + const [validated, errors] = validate(newInternalRule, internalRuleUpdate); + if (errors != null || validated === null) { + throw new UpdateError(`Applying update would create invalid rule: ${errors}`, 400); + } + const update = await rulesClient.update({ id: existingRule.id, - data: newInternalRule, + data: validated, }); await maybeMute({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index 4647a4a9951df..a558024a73e34 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -65,6 +65,9 @@ import { RulesClient } from '../../../../../alerting/server'; import { LegacyRuleActions } from '../rule_actions/legacy_types'; import { FullResponseSchema } from '../../../../common/detection_engine/schemas/request'; import { transformAlertToRuleAction } from '../../../../common/detection_engine/transform_actions'; +// eslint-disable-next-line no-restricted-imports +import { legacyRuleActionsSavedObjectType } from '../rule_actions/legacy_saved_object_mappings'; +import { LegacyMigrateParams } from './types'; export const calculateInterval = ( interval: string | undefined, @@ -296,3 +299,62 @@ export const maybeMute = async ({ // Do nothing, no-operation } }; + +/** + * Determines if rule needs to be migrated from legacy actions + * and returns necessary pieces for the updated rule + */ +export const legacyMigrate = async ({ + rulesClient, + savedObjectsClient, + rule, +}: LegacyMigrateParams): Promise | null | undefined> => { + if (rule == null || rule.id == null) { + return rule; + } + /** + * On update / patch I'm going to take the actions as they are, better off taking rules client.find (siem.notification) result + * and putting that into the actions array of the rule, then set the rules onThrottle property, notifyWhen and throttle from null -> actualy value (1hr etc..) + * Then use the rules client to delete the siem.notification + * Then with the legacy Rule Actions saved object type, just delete it. + */ + + // find it using the references array, not params.ruleAlertId + const [siemNotification, legacyRuleActionsSO] = await Promise.all([ + rulesClient.find({ + options: { + hasReference: { + type: 'alert', + id: rule.id, + }, + }, + }), + savedObjectsClient.find({ + type: legacyRuleActionsSavedObjectType, + }), + ]); + + if (siemNotification != null && siemNotification.data.length > 0) { + await Promise.all([ + rulesClient.delete({ id: siemNotification.data[0].id }), + legacyRuleActionsSO != null && legacyRuleActionsSO.saved_objects.length > 0 + ? savedObjectsClient.delete( + legacyRuleActionsSavedObjectType, + legacyRuleActionsSO.saved_objects[0].id + ) + : null, + ]); + const migratedRule = { + ...rule, + actions: siemNotification.data[0].actions, + throttle: siemNotification.data[0].schedule.interval, + notifyWhen: transformToNotifyWhen(siemNotification.data[0].throttle), + }; + await rulesClient.update({ + id: rule.id, + data: migratedRule, + }); + return migratedRule; + } + return rule; +}; From c2c08be709429270139cae7b9674d349a4b61e86 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 19 Oct 2021 08:24:42 -0600 Subject: [PATCH 14/30] [Security Solutions] Adds security detection rule actions as importable and exportable (#115243) ## Summary Adds the security detection rule actions as being exportable and importable. * Adds exportable actions for legacy notification system * Adds exportable actions for the new throttle notification system * Adds importable but only imports into the new throttle notification system. * Updates unit tests In your `ndjson` file when you have actions exported you will see them like so: ```json "actions": [ { "group": "default", "id": "b55117e0-2df9-11ec-b789-7f03e3cdd668", "params": { "message": "Rule {{context.rule.name}} generated {{state.signals_count}} alerts" }, "action_type_id": ".slack" } ] ``` where before it was `actions: []` and was not provided. **Caveats** If you delete your connector and have an invalid connector then the rule(s) that were referring to that invalid connector will not import and you will get an error like this: Screen Shot 2021-10-15 at 2 47 10 PM This does _not_ export your connectors at this point in time. You have to export your connector through the Saved Object Management separate like so: Screen Shot 2021-10-15 at 2 58 03 PM However, if remove everything and import your connector without changing its saved object ID and then go to import the rules everything should import ok and you will get your actions working. **Manual Testing**: * You can create normal actions on an alert and then do exports and you should see the actions in your ndjson file * You can create legacy notifications from 7.14.0 and then upgrade and export and you should see the actions in your ndjson file * You can manually create legacy notifications by: By getting an alert id first and ensuring that your `legacy_notifications/one_action.json` contains a valid action then running this command: ```ts ./post_legacy_notification.sh 3403c0d0-2d44-11ec-b147-3b0c6d563a60 ``` * You can export your connector and remove everything and then do an import and you will have everything imported and working with your actions and connector wired up correctly. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added --- .../routes/rules/export_rules_route.ts | 13 ++++- .../routes/rules/import_rules_route.ts | 5 +- .../rules/perform_bulk_action_route.test.ts | 5 +- .../routes/rules/perform_bulk_action_route.ts | 5 ++ .../routes/rules/utils.test.ts | 6 +-- .../detection_engine/routes/rules/utils.ts | 7 ++- .../rules/get_export_all.test.ts | 26 +++++++++- .../detection_engine/rules/get_export_all.ts | 17 ++++++- .../rules/get_export_by_object_ids.test.ts | 49 +++++++++++++++++-- .../rules/get_export_by_object_ids.ts | 36 +++++++++++--- .../security_solution/server/routes/index.ts | 4 +- 11 files changed, 145 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts index e4b99e63cb6c6..c84dd8147ebcc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts @@ -6,6 +6,7 @@ */ import { transformError } from '@kbn/securitysolution-es-utils'; +import { Logger } from 'src/core/server'; import { exportRulesQuerySchema, ExportRulesQuerySchemaDecoded, @@ -24,6 +25,7 @@ import { buildSiemResponse } from '../utils'; export const exportRulesRoute = ( router: SecuritySolutionPluginRouter, config: ConfigType, + logger: Logger, isRuleRegistryEnabled: boolean ) => { router.post( @@ -44,6 +46,7 @@ export const exportRulesRoute = ( async (context, request, response) => { const siemResponse = buildSiemResponse(response); const rulesClient = context.alerting?.getRulesClient(); + const savedObjectsClient = context.core.savedObjects.client; if (!rulesClient) { return siemResponse.error({ statusCode: 404 }); @@ -71,8 +74,14 @@ export const exportRulesRoute = ( const exported = request.body?.objects != null - ? await getExportByObjectIds(rulesClient, request.body.objects, isRuleRegistryEnabled) - : await getExportAll(rulesClient, isRuleRegistryEnabled); + ? await getExportByObjectIds( + rulesClient, + savedObjectsClient, + request.body.objects, + logger, + isRuleRegistryEnabled + ) + : await getExportAll(rulesClient, savedObjectsClient, logger, isRuleRegistryEnabled); const responseBody = request.query.exclude_export_details ? exported.rulesNdjson diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index b09ef1a215747..3752128d3daa3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -194,6 +194,7 @@ export const importRulesRoute = ( throttle, version, exceptions_list: exceptionsList, + actions, } = parsedRule; try { @@ -265,7 +266,7 @@ export const importRulesRoute = ( note, version, exceptionsList, - actions: [], // Actions are not imported nor exported at this time + actions, }); resolve({ rule_id: ruleId, @@ -328,7 +329,7 @@ export const importRulesRoute = ( exceptionsList, anomalyThreshold, machineLearningJobId, - actions: undefined, + actions, }); resolve({ rule_id: ruleId, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts index 41b909bd718c0..3e85b4898d01c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts @@ -17,6 +17,7 @@ import { import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { performBulkActionRoute } from './perform_bulk_action_route'; import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; +import { loggingSystemMock } from 'src/core/server/mocks'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -27,15 +28,17 @@ describe.each([ let server: ReturnType; let { clients, context } = requestContextMock.createTools(); let ml: ReturnType; + let logger: ReturnType; beforeEach(() => { server = serverMock.create(); + logger = loggingSystemMock.createLogger(); ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); - performBulkActionRoute(server.router, ml, isRuleRegistryEnabled); + performBulkActionRoute(server.router, ml, logger, isRuleRegistryEnabled); }); describe('status codes', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts index 0eba5af4e063a..fb5a2315479da 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts @@ -6,6 +6,8 @@ */ import { transformError } from '@kbn/securitysolution-es-utils'; +import { Logger } from 'src/core/server'; + import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants'; import { BulkAction } from '../../../../../common/detection_engine/schemas/common/schemas'; import { performBulkActionSchema } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema'; @@ -26,6 +28,7 @@ const BULK_ACTION_RULES_LIMIT = 10000; export const performBulkActionRoute = ( router: SecuritySolutionPluginRouter, ml: SetupPlugins['ml'], + logger: Logger, isRuleRegistryEnabled: boolean ) => { router.post( @@ -133,7 +136,9 @@ export const performBulkActionRoute = ( case BulkAction.export: const exported = await getExportByObjectIds( rulesClient, + savedObjectsClient, rules.data.map(({ params }) => ({ rule_id: params.ruleId })), + logger, isRuleRegistryEnabled ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts index c5a30c349d497..366ae607f0ba8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts @@ -469,12 +469,12 @@ describe.each([ describe('transformAlertsToRules', () => { test('given an empty array returns an empty array', () => { - expect(transformAlertsToRules([])).toEqual([]); + expect(transformAlertsToRules([], {})).toEqual([]); }); test('given single alert will return the alert transformed', () => { const result1 = getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()); - const transformed = transformAlertsToRules([result1]); + const transformed = transformAlertsToRules([result1], {}); const expected = getOutputRuleAlertForRest(); expect(transformed).toEqual([expected]); }); @@ -485,7 +485,7 @@ describe.each([ result2.id = 'some other id'; result2.params.ruleId = 'some other id'; - const transformed = transformAlertsToRules([result1, result2]); + const transformed = transformAlertsToRules([result1, result2], {}); const expected1 = getOutputRuleAlertForRest(); const expected2 = getOutputRuleAlertForRest(); expected2.id = 'some other id'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts index afc48386a2986..bb2e35d189ca1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts @@ -103,8 +103,11 @@ export const transformAlertToRule = ( return internalRuleToAPIResponse(alert, ruleStatus?.attributes, legacyRuleActions); }; -export const transformAlertsToRules = (alerts: RuleAlertType[]): Array> => { - return alerts.map((alert) => transformAlertToRule(alert)); +export const transformAlertsToRules = ( + alerts: RuleAlertType[], + legacyRuleActions: Record +): Array> => { + return alerts.map((alert) => transformAlertToRule(alert, undefined, legacyRuleActions[alert.id])); }; export const transformFindAlerts = ( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts index 3ca5960d7d4e1..92e4f0bbb4a5e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts @@ -9,21 +9,33 @@ import { getAlertMock, getFindResultWithSingleHit, FindHit, + getEmptySavedObjectsResponse, } from '../routes/__mocks__/request_responses'; import { rulesClientMock } from '../../../../../alerting/server/mocks'; import { getExportAll } from './get_export_all'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; + import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { requestContextMock } from '../routes/__mocks__/request_context'; describe.each([ ['Legacy', false], ['RAC', true], ])('getExportAll - %s', (_, isRuleRegistryEnabled) => { + let logger: ReturnType; + const { clients } = requestContextMock.createTools(); + + beforeEach(async () => { + clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse()); + }); + test('it exports everything from the alerts client', async () => { const rulesClient = rulesClientMock.create(); const result = getFindResultWithSingleHit(isRuleRegistryEnabled); const alert = getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()); + alert.params = { ...alert.params, filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], @@ -35,7 +47,12 @@ describe.each([ result.data = [alert]; rulesClient.find.mockResolvedValue(result); - const exports = await getExportAll(rulesClient, isRuleRegistryEnabled); + const exports = await getExportAll( + rulesClient, + clients.savedObjectsClient, + logger, + isRuleRegistryEnabled + ); const rulesJson = JSON.parse(exports.rulesNdjson); const detailsJson = JSON.parse(exports.exportDetails); expect(rulesJson).toEqual({ @@ -97,7 +114,12 @@ describe.each([ rulesClient.find.mockResolvedValue(findResult); - const exports = await getExportAll(rulesClient, isRuleRegistryEnabled); + const exports = await getExportAll( + rulesClient, + clients.savedObjectsClient, + logger, + isRuleRegistryEnabled + ); expect(exports).toEqual({ rulesNdjson: '', exportDetails: '{"exported_count":0,"missing_rules":[],"missing_rules_count":0}\n', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts index 71079ccefc97a..cbbda5df7e2bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts @@ -7,20 +7,33 @@ import { transformDataToNdjson } from '@kbn/securitysolution-utils'; -import { RulesClient } from '../../../../../alerting/server'; +import { Logger } from 'src/core/server'; +import { RulesClient, AlertServices } from '../../../../../alerting/server'; import { getNonPackagedRules } from './get_existing_prepackaged_rules'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { transformAlertsToRules } from '../routes/rules/utils'; +// eslint-disable-next-line no-restricted-imports +import { legacyGetBulkRuleActionsSavedObject } from '../rule_actions/legacy_get_bulk_rule_actions_saved_object'; + export const getExportAll = async ( rulesClient: RulesClient, + savedObjectsClient: AlertServices['savedObjectsClient'], + logger: Logger, isRuleRegistryEnabled: boolean ): Promise<{ rulesNdjson: string; exportDetails: string; }> => { const ruleAlertTypes = await getNonPackagedRules({ rulesClient, isRuleRegistryEnabled }); - const rules = transformAlertsToRules(ruleAlertTypes); + const alertIds = ruleAlertTypes.map((rule) => rule.id); + const legacyActions = await legacyGetBulkRuleActionsSavedObject({ + alertIds, + savedObjectsClient, + logger, + }); + + const rules = transformAlertsToRules(ruleAlertTypes, legacyActions); // We do not support importing/exporting actions. When we do, delete this line of code const rulesWithoutActions = rules.map((rule) => ({ ...rule, actions: [] })); const rulesNdjson = transformDataToNdjson(rulesWithoutActions); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index 740427e44b560..961f2c6a41866 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -10,28 +10,43 @@ import { getAlertMock, getFindResultWithSingleHit, FindHit, + getEmptySavedObjectsResponse, } from '../routes/__mocks__/request_responses'; import { rulesClientMock } from '../../../../../alerting/server/mocks'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { requestContextMock } from '../routes/__mocks__/request_context'; describe.each([ ['Legacy', false], ['RAC', true], ])('get_export_by_object_ids - %s', (_, isRuleRegistryEnabled) => { + let logger: ReturnType; + const { clients } = requestContextMock.createTools(); + beforeEach(() => { jest.resetAllMocks(); jest.restoreAllMocks(); jest.clearAllMocks(); + + clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse()); }); + describe('getExportByObjectIds', () => { test('it exports object ids into an expected string with new line characters', async () => { const rulesClient = rulesClientMock.create(); rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); const objects = [{ rule_id: 'rule-1' }]; - const exports = await getExportByObjectIds(rulesClient, objects, isRuleRegistryEnabled); + const exports = await getExportByObjectIds( + rulesClient, + clients.savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); const exportsObj = { rulesNdjson: JSON.parse(exports.rulesNdjson), exportDetails: JSON.parse(exports.exportDetails), @@ -102,7 +117,13 @@ describe.each([ rulesClient.find.mockResolvedValue(findResult); const objects = [{ rule_id: 'rule-1' }]; - const exports = await getExportByObjectIds(rulesClient, objects, isRuleRegistryEnabled); + const exports = await getExportByObjectIds( + rulesClient, + clients.savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); expect(exports).toEqual({ rulesNdjson: '', exportDetails: @@ -117,7 +138,13 @@ describe.each([ rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); const objects = [{ rule_id: 'rule-1' }]; - const exports = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled); + const exports = await getRulesFromObjects( + rulesClient, + clients.savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); const expected: RulesErrors = { exportedCount: 1, missingRules: [], @@ -192,7 +219,13 @@ describe.each([ rulesClient.find.mockResolvedValue(findResult); const objects = [{ rule_id: 'rule-1' }]; - const exports = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled); + const exports = await getRulesFromObjects( + rulesClient, + clients.savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); const expected: RulesErrors = { exportedCount: 0, missingRules: [{ rule_id: 'rule-1' }], @@ -215,7 +248,13 @@ describe.each([ rulesClient.find.mockResolvedValue(findResult); const objects = [{ rule_id: 'rule-1' }]; - const exports = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled); + const exports = await getRulesFromObjects( + rulesClient, + clients.savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); const expected: RulesErrors = { exportedCount: 0, missingRules: [{ rule_id: 'rule-1' }], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts index 4cf3ad9133a71..8233fe6d4948c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts @@ -8,14 +8,20 @@ import { chunk } from 'lodash'; import { transformDataToNdjson } from '@kbn/securitysolution-utils'; +import { Logger } from 'src/core/server'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; -import { RulesClient } from '../../../../../alerting/server'; +import { RulesClient, AlertServices } from '../../../../../alerting/server'; + import { getExportDetailsNdjson } from './get_export_details_ndjson'; + import { isAlertType } from '../rules/types'; import { transformAlertToRule } from '../routes/rules/utils'; import { INTERNAL_RULE_ID_KEY } from '../../../../common/constants'; import { findRules } from './find_rules'; +// eslint-disable-next-line no-restricted-imports +import { legacyGetBulkRuleActionsSavedObject } from '../rule_actions/legacy_get_bulk_rule_actions_saved_object'; + interface ExportSuccessRule { statusCode: 200; rule: Partial; @@ -34,23 +40,32 @@ export interface RulesErrors { export const getExportByObjectIds = async ( rulesClient: RulesClient, + savedObjectsClient: AlertServices['savedObjectsClient'], objects: Array<{ rule_id: string }>, + logger: Logger, isRuleRegistryEnabled: boolean ): Promise<{ rulesNdjson: string; exportDetails: string; }> => { - const rulesAndErrors = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled); - // We do not support importing/exporting actions. When we do, delete this line of code - const rulesWithoutActions = rulesAndErrors.rules.map((rule) => ({ ...rule, actions: [] })); - const rulesNdjson = transformDataToNdjson(rulesWithoutActions); - const exportDetails = getExportDetailsNdjson(rulesWithoutActions, rulesAndErrors.missingRules); + const rulesAndErrors = await getRulesFromObjects( + rulesClient, + savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); + + const rulesNdjson = transformDataToNdjson(rulesAndErrors.rules); + const exportDetails = getExportDetailsNdjson(rulesAndErrors.rules, rulesAndErrors.missingRules); return { rulesNdjson, exportDetails }; }; export const getRulesFromObjects = async ( rulesClient: RulesClient, + savedObjectsClient: AlertServices['savedObjectsClient'], objects: Array<{ rule_id: string }>, + logger: Logger, isRuleRegistryEnabled: boolean ): Promise => { // If we put more than 1024 ids in one block like "alert.attributes.tags: (id1 OR id2 OR ... OR id1100)" @@ -78,6 +93,13 @@ export const getRulesFromObjects = async ( sortField: undefined, sortOrder: undefined, }); + const alertIds = rules.data.map((rule) => rule.id); + const legacyActions = await legacyGetBulkRuleActionsSavedObject({ + alertIds, + savedObjectsClient, + logger, + }); + const alertsAndErrors = objects.map(({ rule_id: ruleId }) => { const matchingRule = rules.data.find((rule) => rule.params.ruleId === ruleId); if ( @@ -87,7 +109,7 @@ export const getRulesFromObjects = async ( ) { return { statusCode: 200, - rule: transformAlertToRule(matchingRule), + rule: transformAlertToRule(matchingRule, undefined, legacyActions[matchingRule.id]), }; } else { return { diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index d045c6b129e43..148580d5c4477 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -91,12 +91,12 @@ export const initRoutes = ( updateRulesBulkRoute(router, ml, isRuleRegistryEnabled); patchRulesBulkRoute(router, ml, isRuleRegistryEnabled); deleteRulesBulkRoute(router, isRuleRegistryEnabled); - performBulkActionRoute(router, ml, isRuleRegistryEnabled); + performBulkActionRoute(router, ml, logger, isRuleRegistryEnabled); createTimelinesRoute(router, config, security); patchTimelinesRoute(router, config, security); importRulesRoute(router, config, ml, isRuleRegistryEnabled); - exportRulesRoute(router, config, isRuleRegistryEnabled); + exportRulesRoute(router, config, logger, isRuleRegistryEnabled); importTimelinesRoute(router, config, security); exportTimelinesRoute(router, config, security); From 6a1af300f5a08de7b1287e52d04a6b304cc3186b Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Tue, 19 Oct 2021 16:35:40 +0200 Subject: [PATCH 15/30] [Discover] Improve doc viewer code in Discover (#114759) Co-authored-by: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> --- .../public/__mocks__/index_patterns.ts | 8 +++-- .../apps/context/context_app_route.tsx | 26 +++++++++++++- .../apps/doc/components/doc.test.tsx | 21 ++--------- .../application/apps/doc/components/doc.tsx | 16 ++++----- .../application/apps/doc/single_doc_route.tsx | 35 ++++++++++++++----- .../doc_viewer/doc_viewer_tab.test.tsx | 3 ++ .../__snapshots__/source_viewer.test.tsx.snap | 18 ++++++++-- .../source_viewer/source_viewer.test.tsx | 14 ++++---- .../source_viewer/source_viewer.tsx | 17 ++++----- .../application/components/table/table.tsx | 2 +- .../application/doc_views/doc_views_types.ts | 2 +- .../helpers/use_index_pattern.test.tsx | 20 ++++++++--- .../application/helpers/use_index_pattern.tsx | 13 ++++--- .../services/use_es_doc_search.test.tsx | 24 ++++--------- .../application/services/use_es_doc_search.ts | 15 +++----- src/plugins/discover/public/plugin.tsx | 2 +- 16 files changed, 135 insertions(+), 101 deletions(-) diff --git a/src/plugins/discover/public/__mocks__/index_patterns.ts b/src/plugins/discover/public/__mocks__/index_patterns.ts index 88447eacc884d..b90338e895623 100644 --- a/src/plugins/discover/public/__mocks__/index_patterns.ts +++ b/src/plugins/discover/public/__mocks__/index_patterns.ts @@ -10,12 +10,14 @@ import { IndexPatternsService } from '../../../data/common'; import { indexPatternMock } from './index_pattern'; export const indexPatternsMock = { - getCache: () => { + getCache: async () => { return [indexPatternMock]; }, - get: (id: string) => { + get: async (id: string) => { if (id === 'the-index-pattern-id') { - return indexPatternMock; + return Promise.resolve(indexPatternMock); + } else if (id === 'invalid-index-pattern-id') { + return Promise.reject('Invald'); } }, updateSavedObject: jest.fn(), diff --git a/src/plugins/discover/public/application/apps/context/context_app_route.tsx b/src/plugins/discover/public/application/apps/context/context_app_route.tsx index d124fd6cfa395..6c4722418be14 100644 --- a/src/plugins/discover/public/application/apps/context/context_app_route.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app_route.tsx @@ -8,6 +8,8 @@ import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; +import { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { DiscoverServices } from '../../../build_services'; import { ContextApp } from './context_app'; import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; @@ -43,7 +45,29 @@ export function ContextAppRoute(props: ContextAppProps) { ]); }, [chrome]); - const indexPattern = useIndexPattern(services.indexPatterns, indexPatternId); + const { indexPattern, error } = useIndexPattern(services.indexPatterns, indexPatternId); + + if (error) { + return ( + + } + body={ + + } + /> + ); + } if (!indexPattern) { return ; diff --git a/src/plugins/discover/public/application/apps/doc/components/doc.test.tsx b/src/plugins/discover/public/application/apps/doc/components/doc.test.tsx index 31ff39ea6b577..68c012ddd92e9 100644 --- a/src/plugins/discover/public/application/apps/doc/components/doc.test.tsx +++ b/src/plugins/discover/public/application/apps/doc/components/doc.test.tsx @@ -14,6 +14,7 @@ import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import { Doc, DocProps } from './doc'; import { SEARCH_FIELDS_FROM_SOURCE as mockSearchFieldsFromSource } from '../../../../../common'; +import { indexPatternMock } from '../../../../__mocks__/index_pattern'; const mockSearchApi = jest.fn(); @@ -74,21 +75,11 @@ const waitForPromises = async () => * this works but logs ugly error messages until we're using React 16.9 * should be adapted when we upgrade */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function mountDoc(update = false, indexPatternGetter: any = null) { - const indexPattern = { - getComputedFields: () => [], - }; - const indexPatternService = { - get: indexPatternGetter ? indexPatternGetter : jest.fn(() => Promise.resolve(indexPattern)), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - +async function mountDoc(update = false) { const props = { id: '1', index: 'index1', - indexPatternId: 'xyz', - indexPatternService, + indexPattern: indexPatternMock, } as DocProps; let comp!: ReactWrapper; await act(async () => { @@ -108,12 +99,6 @@ describe('Test of of Discover', () => { expect(findTestSubject(comp, 'doc-msg-loading').length).toBe(1); }); - test('renders IndexPattern notFound msg', async () => { - const indexPatternGetter = jest.fn(() => Promise.reject({ savedObjectId: '007' })); - const comp = await mountDoc(true, indexPatternGetter); - expect(findTestSubject(comp, 'doc-msg-notFoundIndexPattern').length).toBe(1); - }); - test('renders notFound msg', async () => { mockSearchApi.mockImplementation(() => throwError({ status: 404 })); const comp = await mountDoc(true); diff --git a/src/plugins/discover/public/application/apps/doc/components/doc.tsx b/src/plugins/discover/public/application/apps/doc/components/doc.tsx index f33ffe561e490..c6cfad3953e95 100644 --- a/src/plugins/discover/public/application/apps/doc/components/doc.tsx +++ b/src/plugins/discover/public/application/apps/doc/components/doc.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent, EuiPage } from '@elastic/eui'; -import { IndexPatternsContract } from 'src/plugins/data/public'; +import { IndexPattern } from 'src/plugins/data/public'; import { getServices } from '../../../../kibana_services'; import { DocViewer } from '../../../components/doc_viewer/doc_viewer'; import { ElasticRequestState } from '../types'; @@ -25,14 +25,9 @@ export interface DocProps { */ index: string; /** - * IndexPattern ID used to get IndexPattern entity - * that's used for adding additional fields (stored_fields, script_fields, docvalue_fields) + * IndexPattern entity */ - indexPatternId: string; - /** - * IndexPatternService to get a given index pattern by ID - */ - indexPatternService: IndexPatternsContract; + indexPattern: IndexPattern; /** * If set, will always request source, regardless of the global `fieldsFromSource` setting */ @@ -40,7 +35,8 @@ export interface DocProps { } export function Doc(props: DocProps) { - const [reqState, hit, indexPattern] = useEsDocSearch(props); + const { indexPattern } = props; + const [reqState, hit] = useEsDocSearch(props); const indexExistsLink = getServices().docLinks.links.apis.indexExists; return ( @@ -54,7 +50,7 @@ export function Doc(props: DocProps) { } /> diff --git a/src/plugins/discover/public/application/apps/doc/single_doc_route.tsx b/src/plugins/discover/public/application/apps/doc/single_doc_route.tsx index 8398f6255e0f9..aef928d523515 100644 --- a/src/plugins/discover/public/application/apps/doc/single_doc_route.tsx +++ b/src/plugins/discover/public/application/apps/doc/single_doc_route.tsx @@ -7,6 +7,8 @@ */ import React, { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; +import { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { DiscoverServices } from '../../../build_services'; import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; import { Doc } from './components/doc'; @@ -31,7 +33,7 @@ function useQuery() { export function SingleDocRoute(props: SingleDocRouteProps) { const { services } = props; - const { chrome, timefilter, indexPatterns } = services; + const { chrome, timefilter } = services; const { indexPatternId, index } = useParams(); @@ -52,7 +54,29 @@ export function SingleDocRoute(props: SingleDocRouteProps) { timefilter.disableTimeRangeSelector(); }); - const indexPattern = useIndexPattern(services.indexPatterns, indexPatternId); + const { indexPattern, error } = useIndexPattern(services.indexPatterns, indexPatternId); + + if (error) { + return ( + + } + body={ + + } + /> + ); + } if (!indexPattern) { return ; @@ -60,12 +84,7 @@ export function SingleDocRoute(props: SingleDocRouteProps) { return (
- +
); } diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx index a2434170acdd7..188deba755445 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { DocViewerTab } from './doc_viewer_tab'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; +import { indexPatternMock } from '../../../__mocks__/index_pattern'; describe('DocViewerTab', () => { test('changing columns triggers an update', () => { @@ -21,6 +22,7 @@ describe('DocViewerTab', () => { renderProps: { hit: {} as ElasticSearchHit, columns: ['test'], + indexPattern: indexPatternMock, }, }; @@ -31,6 +33,7 @@ describe('DocViewerTab', () => { renderProps: { hit: {} as ElasticSearchHit, columns: ['test2'], + indexPattern: indexPatternMock, }, }; diff --git a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap index 82d9183f3d394..761263ee861b9 100644 --- a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap +++ b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap @@ -5,7 +5,11 @@ exports[`Source Viewer component renders error state 1`] = ` hasLineNumbers={true} id="1" index="index1" - indexPatternId="xyz" + indexPattern={ + Object { + "getComputedFields": [Function], + } + } intl={ Object { "defaultFormats": Object {}, @@ -264,7 +268,11 @@ exports[`Source Viewer component renders json code editor 1`] = ` hasLineNumbers={true} id="1" index="index1" - indexPatternId="xyz" + indexPattern={ + Object { + "getComputedFields": [Function], + } + } intl={ Object { "defaultFormats": Object {}, @@ -619,7 +627,11 @@ exports[`Source Viewer component renders loading state 1`] = ` hasLineNumbers={true} id="1" index="index1" - indexPatternId="xyz" + indexPattern={ + Object { + "getComputedFields": [Function], + } + } intl={ Object { "defaultFormats": Object {}, diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx b/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx index 7895c1025dda9..a98c2de6197d8 100644 --- a/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx @@ -43,13 +43,13 @@ const mockIndexPatternService = { })); describe('Source Viewer component', () => { test('renders loading state', () => { - jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [0, null, null, () => {}]); + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [0, null, () => {}]); const comp = mountWithIntl( @@ -60,13 +60,13 @@ describe('Source Viewer component', () => { }); test('renders error state', () => { - jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [3, null, null, () => {}]); + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [3, null, () => {}]); const comp = mountWithIntl( @@ -97,9 +97,7 @@ describe('Source Viewer component', () => { _underscore: 123, }, } as never; - jest - .spyOn(hooks, 'useEsDocSearch') - .mockImplementation(() => [2, mockHit, mockIndexPattern, () => {}]); + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [2, mockHit, () => {}]); jest.spyOn(useUiSettingHook, 'useUiSetting').mockImplementation(() => { return false; }); @@ -107,7 +105,7 @@ describe('Source Viewer component', () => { diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx b/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx index 9e37ae8f8bf93..31d4d866df21e 100644 --- a/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx @@ -17,11 +17,12 @@ import { getServices } from '../../../kibana_services'; import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; import { ElasticRequestState } from '../../apps/doc/types'; import { useEsDocSearch } from '../../services/use_es_doc_search'; +import { IndexPattern } from '../../../../../data_views/common'; interface SourceViewerProps { id: string; index: string; - indexPatternId: string; + indexPattern: IndexPattern; hasLineNumbers: boolean; width?: number; } @@ -29,19 +30,17 @@ interface SourceViewerProps { export const SourceViewer = ({ id, index, - indexPatternId, + indexPattern, width, hasLineNumbers, }: SourceViewerProps) => { const [editor, setEditor] = useState(); const [jsonValue, setJsonValue] = useState(''); - const indexPatternService = getServices().data.indexPatterns; const useNewFieldsApi = !getServices().uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); - const [reqState, hit, , requestData] = useEsDocSearch({ + const [reqState, hit, requestData] = useEsDocSearch({ id, index, - indexPatternId, - indexPatternService, + indexPattern, requestSource: useNewFieldsApi, }); @@ -106,11 +105,7 @@ export const SourceViewer = ({ ); - if ( - reqState === ElasticRequestState.Error || - reqState === ElasticRequestState.NotFound || - reqState === ElasticRequestState.NotFoundIndexPattern - ) { + if (reqState === ElasticRequestState.Error || reqState === ElasticRequestState.NotFound) { return errorState; } diff --git a/src/plugins/discover/public/application/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx index 7f597d846f88f..e64dbd10f7855 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -25,7 +25,7 @@ export interface DocViewerTableProps { columns?: string[]; filter?: DocViewFilterFn; hit: ElasticSearchHit; - indexPattern?: IndexPattern; + indexPattern: IndexPattern; onAddColumn?: (columnName: string) => void; onRemoveColumn?: (columnName: string) => void; } diff --git a/src/plugins/discover/public/application/doc_views/doc_views_types.ts b/src/plugins/discover/public/application/doc_views/doc_views_types.ts index 48bebec22b9b5..d3e482c0f2e1d 100644 --- a/src/plugins/discover/public/application/doc_views/doc_views_types.ts +++ b/src/plugins/discover/public/application/doc_views/doc_views_types.ts @@ -32,7 +32,7 @@ export interface DocViewRenderProps { columns?: string[]; filter?: DocViewFilterFn; hit: ElasticSearchHit; - indexPattern?: IndexPattern; + indexPattern: IndexPattern; onAddColumn?: (columnName: string) => void; onRemoveColumn?: (columnName: string) => void; } diff --git a/src/plugins/discover/public/application/helpers/use_index_pattern.test.tsx b/src/plugins/discover/public/application/helpers/use_index_pattern.test.tsx index 85282afb6fc37..dfc54d8630742 100644 --- a/src/plugins/discover/public/application/helpers/use_index_pattern.test.tsx +++ b/src/plugins/discover/public/application/helpers/use_index_pattern.test.tsx @@ -8,12 +8,24 @@ import { useIndexPattern } from './use_index_pattern'; import { indexPatternMock } from '../../__mocks__/index_pattern'; import { indexPatternsMock } from '../../__mocks__/index_patterns'; -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; describe('Use Index Pattern', () => { test('returning a valid index pattern', async () => { - const { result } = renderHook(() => useIndexPattern(indexPatternsMock, 'the-index-pattern-id')); - await act(() => Promise.resolve()); - expect(result.current).toBe(indexPatternMock); + const { result, waitForNextUpdate } = renderHook(() => + useIndexPattern(indexPatternsMock, 'the-index-pattern-id') + ); + await waitForNextUpdate(); + expect(result.current.indexPattern).toBe(indexPatternMock); + expect(result.current.error).toBe(undefined); + }); + + test('returning an error', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useIndexPattern(indexPatternsMock, 'invalid-index-pattern-id') + ); + await waitForNextUpdate(); + expect(result.current.indexPattern).toBe(undefined); + expect(result.current.error).toBeTruthy(); }); }); diff --git a/src/plugins/discover/public/application/helpers/use_index_pattern.tsx b/src/plugins/discover/public/application/helpers/use_index_pattern.tsx index f53d131920c5c..374f83cbbfe72 100644 --- a/src/plugins/discover/public/application/helpers/use_index_pattern.tsx +++ b/src/plugins/discover/public/application/helpers/use_index_pattern.tsx @@ -10,13 +10,18 @@ import { IndexPattern, IndexPatternsContract } from '../../../../data/common'; export const useIndexPattern = (indexPatterns: IndexPatternsContract, indexPatternId: string) => { const [indexPattern, setIndexPattern] = useState(undefined); + const [error, setError] = useState(); useEffect(() => { async function loadIndexPattern() { - const ip = await indexPatterns.get(indexPatternId); - setIndexPattern(ip); + try { + const item = await indexPatterns.get(indexPatternId); + setIndexPattern(item); + } catch (e) { + setError(e); + } } loadIndexPattern(); - }); - return indexPattern; + }, [indexPatternId, indexPatterns]); + return { indexPattern, error }; }; diff --git a/src/plugins/discover/public/application/services/use_es_doc_search.test.tsx b/src/plugins/discover/public/application/services/use_es_doc_search.test.tsx index af7d189e62882..ca57b470b471a 100644 --- a/src/plugins/discover/public/application/services/use_es_doc_search.test.tsx +++ b/src/plugins/discover/public/application/services/use_es_doc_search.test.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; import { buildSearchBody, useEsDocSearch } from './use_es_doc_search'; import { Observable } from 'rxjs'; import { IndexPattern } from 'src/plugins/data/common'; @@ -175,26 +175,14 @@ describe('Test of helper / hook', () => { const indexPattern = { getComputedFields: () => [], }; - const getMock = jest.fn(() => Promise.resolve(indexPattern)); - const indexPatternService = { - get: getMock, - } as unknown as IndexPattern; const props = { id: '1', index: 'index1', - indexPatternId: 'xyz', - indexPatternService, - } as unknown as DocProps; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let hook: any; - await act(async () => { - hook = renderHook((p: DocProps) => useEsDocSearch(p), { initialProps: props }); - }); - expect(hook.result.current.slice(0, 3)).toEqual([ - ElasticRequestState.Loading, - null, indexPattern, - ]); - expect(getMock).toHaveBeenCalled(); + } as unknown as DocProps; + + const { result } = renderHook((p: DocProps) => useEsDocSearch(p), { initialProps: props }); + + expect(result.current.slice(0, 2)).toEqual([ElasticRequestState.Loading, null]); }); }); diff --git a/src/plugins/discover/public/application/services/use_es_doc_search.ts b/src/plugins/discover/public/application/services/use_es_doc_search.ts index a2f0cd6f8442b..16a24ff27292b 100644 --- a/src/plugins/discover/public/application/services/use_es_doc_search.ts +++ b/src/plugins/discover/public/application/services/use_es_doc_search.ts @@ -64,11 +64,9 @@ export function buildSearchBody( export function useEsDocSearch({ id, index, - indexPatternId, - indexPatternService, + indexPattern, requestSource, -}: DocProps): [ElasticRequestState, ElasticSearchHit | null, IndexPattern | null, () => void] { - const [indexPattern, setIndexPattern] = useState(null); +}: DocProps): [ElasticRequestState, ElasticSearchHit | null | null, () => void] { const [status, setStatus] = useState(ElasticRequestState.Loading); const [hit, setHit] = useState(null); const { data, uiSettings } = useMemo(() => getServices(), []); @@ -76,14 +74,11 @@ export function useEsDocSearch({ const requestData = useCallback(async () => { try { - const indexPatternEntity = await indexPatternService.get(indexPatternId); - setIndexPattern(indexPatternEntity); - const { rawResponse } = await data.search .search({ params: { index, - body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi, requestSource)?.body, + body: buildSearchBody(id, indexPattern, useNewFieldsApi, requestSource)?.body, }, }) .toPromise(); @@ -105,11 +100,11 @@ export function useEsDocSearch({ setStatus(ElasticRequestState.Error); } } - }, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi, requestSource]); + }, [id, index, indexPattern, data.search, useNewFieldsApi, requestSource]); useEffect(() => { requestData(); }, [requestData]); - return [status, hit, indexPattern, requestData]; + return [status, hit, requestData]; } diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index d86e5f363630c..6d30e6fd9e8a9 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -267,7 +267,7 @@ export class DiscoverPlugin From 9dcf5bf1b70632dc511e48c2da2ef53d899b3df8 Mon Sep 17 00:00:00 2001 From: David Roberts Date: Tue, 19 Oct 2021 15:42:49 +0100 Subject: [PATCH 16/30] [ML] Stop reading the ml.max_open_jobs node attribute (#115524) The ml.max_open_jobs node attribute is going away in version 8, as the maximum number of open jobs has been defined by a dynamic cluster-wide setting during the 7 series and there is no chance of version 8 needing to run in a mixed version cluster with version 6. The ml.machine_memory attribute will still be available, so this can be checked instead as a way of detecting ML nodes. --- x-pack/plugins/ml/server/lib/node_utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/ml/server/lib/node_utils.ts b/x-pack/plugins/ml/server/lib/node_utils.ts index 82e5d7f469849..a13e44b307a7b 100644 --- a/x-pack/plugins/ml/server/lib/node_utils.ts +++ b/x-pack/plugins/ml/server/lib/node_utils.ts @@ -17,8 +17,8 @@ export async function getMlNodeCount(client: IScopedClusterClient): Promise { if (body.nodes[k].attributes !== undefined) { - const maxOpenJobs = +body.nodes[k].attributes['ml.max_open_jobs']; - if (maxOpenJobs !== null && maxOpenJobs > 0) { + const machineMemory = +body.nodes[k].attributes['ml.machine_memory']; + if (machineMemory !== null && machineMemory > 0) { count++; } } From e8663d4ea420cf8b01793e2a61d8963ff45c2b6a Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Tue, 19 Oct 2021 16:43:23 +0200 Subject: [PATCH 17/30] [Discover] Show ignored field values (#115040) * WIP replacing indexPattern.flattenHit by tabify * Fix jest tests * Read metaFields from index pattern * Remove old test code * remove unnecessary changes * Remove flattenHitWrapper APIs * Fix imports * Fix missing metaFields * Add all meta fields to allowlist * Improve inline comments * Move flattenHit test to new implementation * Add deprecation comment to implementation * WIP - Show ignored field values * Disable filters in doc_table * remove redundant comments * No, it wasn't * start warning message * Enable ignored values in CSV reports * Add help tooltip * Better styling with warning plus collapsible button * Disable filtering within table for ignored values * Fix jest tests * Fix types in tests * Add more tests and documentation * Remove comment * Move dangerouslySetInnerHTML into helper method * Extract document formatting into common utility * Remove HTML source field formatter * Move formatHit to Discover * Change wording of ignored warning * Add cache for formatted hits * Remove dead type * Fix row_formatter for objects * Improve mobile layout * Fix jest tests * Fix typo * Remove additional span again * Change mock to revert test * Improve tests * More jest tests * Fix typo * Change wording * Remove dead comment Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/search/tabify/tabify_docs.test.ts | 49 ++++++++ .../data/common/search/tabify/tabify_docs.ts | 38 ++++++- .../data_views/common/data_views/data_view.ts | 13 --- .../common/data_views/format_hit.ts | 74 ------------ .../data_views/common/data_views/index.ts | 1 - src/plugins/data_views/public/index.ts | 2 +- src/plugins/discover/kibana.json | 3 +- .../public/__mocks__/index_pattern.ts | 18 ++- .../__mocks__/index_pattern_with_timefield.ts | 12 +- .../discover/public/__mocks__/services.ts | 11 +- .../apps/context/context_app.test.tsx | 4 + .../doc_table/components/table_row.tsx | 42 +++++-- .../doc_table/lib/row_formatter.test.ts | 86 ++++++++------ .../doc_table/lib/row_formatter.tsx | 36 ++---- .../layout/discover_documents.test.tsx | 5 + .../layout/discover_layout.test.tsx | 13 +++ ...ver_index_pattern_management.test.tsx.snap | 39 +------ .../sidebar/lib/field_calculator.js | 2 +- .../apps/main/utils/calc_field_counts.ts | 2 +- .../discover_grid/discover_grid.test.tsx | 5 + .../discover_grid/discover_grid.tsx | 6 +- .../get_render_cell_value.test.tsx | 98 ++++++++++++++-- .../discover_grid/get_render_cell_value.tsx | 55 ++++----- .../components/table/table.test.tsx | 34 +----- .../application/components/table/table.tsx | 12 +- .../components/table/table_cell_actions.tsx | 6 +- .../components/table/table_cell_value.tsx | 107 ++++++++++++++++-- .../components/table/table_columns.tsx | 17 ++- .../table/table_row_btn_filter_add.tsx | 2 +- .../table/table_row_btn_filter_remove.tsx | 2 +- .../application/helpers/format_hit.test.ts | 96 ++++++++++++++++ .../public/application/helpers/format_hit.ts | 67 +++++++++++ .../application/helpers/format_value.test.ts | 69 +++++++++++ .../application/helpers/format_value.ts | 39 +++++++ .../helpers/get_ignored_reason.test.ts | 54 +++++++++ .../application/helpers/get_ignored_reason.ts | 52 +++++++++ src/plugins/discover/public/build_services.ts | 3 + src/plugins/discover/public/plugin.tsx | 2 + .../common/converters/source.test.ts | 21 +--- .../common/converters/source.tsx | 55 +-------- src/plugins/field_formats/common/types.ts | 4 - .../generate_csv/generate_csv.ts | 2 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 44 files changed, 858 insertions(+), 402 deletions(-) delete mode 100644 src/plugins/data_views/common/data_views/format_hit.ts create mode 100644 src/plugins/discover/public/application/helpers/format_hit.test.ts create mode 100644 src/plugins/discover/public/application/helpers/format_hit.ts create mode 100644 src/plugins/discover/public/application/helpers/format_value.test.ts create mode 100644 src/plugins/discover/public/application/helpers/format_value.ts create mode 100644 src/plugins/discover/public/application/helpers/get_ignored_reason.test.ts create mode 100644 src/plugins/discover/public/application/helpers/get_ignored_reason.ts diff --git a/src/plugins/data/common/search/tabify/tabify_docs.test.ts b/src/plugins/data/common/search/tabify/tabify_docs.test.ts index a2910a1be4a9a..1964247b09585 100644 --- a/src/plugins/data/common/search/tabify/tabify_docs.test.ts +++ b/src/plugins/data/common/search/tabify/tabify_docs.test.ts @@ -41,6 +41,11 @@ function create(id: string) { }); } +const meta = { + _index: 'index-name', + _id: '1', +}; + describe('tabify_docs', () => { describe('flattenHit', () => { let indexPattern: DataView; @@ -70,6 +75,50 @@ describe('tabify_docs', () => { expect(Object.keys(response)).toEqual(expectedOrder); expect(Object.entries(response).map(([key]) => key)).toEqual(expectedOrder); }); + + it('does merge values from ignored_field_values and fields correctly', () => { + const flatten = flattenHit( + { + ...meta, + fields: { 'extension.keyword': ['foo'], extension: ['foo', 'ignored'] }, + ignored_field_values: { + 'extension.keyword': ['ignored'], + fully_ignored: ['some', 'value'], + }, + }, + indexPattern, + { includeIgnoredValues: true } + ); + expect(flatten).toHaveProperty(['extension.keyword'], ['foo', 'ignored']); + expect(flatten).toHaveProperty('extension', ['foo', 'ignored']); + expect(flatten).toHaveProperty('fully_ignored', ['some', 'value']); + }); + + it('does not merge values from ignored_field_values into _source', () => { + const flatten = flattenHit( + { + ...meta, + _source: { 'extension.keyword': ['foo', 'ignored'] }, + ignored_field_values: { 'extension.keyword': ['ignored'] }, + }, + indexPattern, + { includeIgnoredValues: true, source: true } + ); + expect(flatten).toHaveProperty(['extension.keyword'], ['foo', 'ignored']); + }); + + it('does merge ignored_field_values when no _source was present, even when parameter was on', () => { + const flatten = flattenHit( + { + ...meta, + fields: { 'extension.keyword': ['foo'] }, + ignored_field_values: { 'extension.keyword': ['ignored'] }, + }, + indexPattern, + { includeIgnoredValues: true, source: true } + ); + expect(flatten).toHaveProperty(['extension.keyword'], ['foo', 'ignored']); + }); }); describe('tabifyDocs', () => { diff --git a/src/plugins/data/common/search/tabify/tabify_docs.ts b/src/plugins/data/common/search/tabify/tabify_docs.ts index 4259488771761..353a0c10ba12a 100644 --- a/src/plugins/data/common/search/tabify/tabify_docs.ts +++ b/src/plugins/data/common/search/tabify/tabify_docs.ts @@ -55,8 +55,18 @@ export interface TabifyDocsOptions { * merged into the flattened document. */ source?: boolean; + /** + * If set to `true` values that have been ignored in ES (ignored_field_values) + * will be merged into the flattened document. This will only have an effect if + * the `hit` has been retrieved using the `fields` option. + */ + includeIgnoredValues?: boolean; } +// This is an overwrite of the SearchHit type to add the ignored_field_values. +// Can be removed once the estypes.SearchHit knows about ignored_field_values +type Hit = estypes.SearchHit & { ignored_field_values?: Record }; + /** * Flattens an individual hit (from an ES response) into an object. This will * create flattened field names, like `user.name`. @@ -65,11 +75,7 @@ export interface TabifyDocsOptions { * @param indexPattern The index pattern for the requested index if available. * @param params Parameters how to flatten the hit */ -export function flattenHit( - hit: estypes.SearchHit, - indexPattern?: IndexPattern, - params?: TabifyDocsOptions -) { +export function flattenHit(hit: Hit, indexPattern?: IndexPattern, params?: TabifyDocsOptions) { const flat = {} as Record; function flatten(obj: Record, keyPrefix: string = '') { @@ -109,6 +115,28 @@ export function flattenHit( flatten(hit.fields || {}); if (params?.source !== false && hit._source) { flatten(hit._source as Record); + } else if (params?.includeIgnoredValues && hit.ignored_field_values) { + // If enabled merge the ignored_field_values into the flattened hit. This will + // merge values that are not actually indexed by ES (i.e. ignored), e.g. because + // they were above the `ignore_above` limit or malformed for specific types. + // This API will only contain the values that were actually ignored, i.e. for the same + // field there might exist another value in the `fields` response, why this logic + // merged them both together. We do not merge this (even if enabled) in case source has been + // merged, since we would otherwise duplicate values, since ignore_field_values and _source + // contain the same values. + Object.entries(hit.ignored_field_values).forEach(([fieldName, fieldValue]) => { + if (flat[fieldName]) { + // If there was already a value from the fields API, make sure we're merging both together + if (Array.isArray(flat[fieldName])) { + flat[fieldName] = [...flat[fieldName], ...fieldValue]; + } else { + flat[fieldName] = [flat[fieldName], ...fieldValue]; + } + } else { + // If no previous value was assigned we can simply use the value from `ignored_field_values` as it is + flat[fieldName] = fieldValue; + } + }); } // Merge all valid meta fields into the flattened object diff --git a/src/plugins/data_views/common/data_views/data_view.ts b/src/plugins/data_views/common/data_views/data_view.ts index 57db127208dc3..b7823677b70f9 100644 --- a/src/plugins/data_views/common/data_views/data_view.ts +++ b/src/plugins/data_views/common/data_views/data_view.ts @@ -17,7 +17,6 @@ import { DuplicateField } from '../../../kibana_utils/common'; import { IIndexPattern, IFieldType } from '../../common'; import { DataViewField, IIndexPatternFieldList, fieldList } from '../fields'; -import { formatHitProvider } from './format_hit'; import { flattenHitWrapper } from './flatten_hit'; import { FieldFormatsStartCommon, @@ -45,8 +44,6 @@ interface SavedObjectBody { type?: string; } -type FormatFieldFn = (hit: Record, fieldName: string) => any; - export class DataView implements IIndexPattern { public id?: string; public title: string = ''; @@ -67,11 +64,6 @@ export class DataView implements IIndexPattern { * Type is used to identify rollup index patterns */ public type: string | undefined; - public formatHit: { - (hit: Record, type?: string): any; - formatField: FormatFieldFn; - }; - public formatField: FormatFieldFn; /** * @deprecated Use `flattenHit` utility method exported from data plugin instead. */ @@ -103,11 +95,6 @@ export class DataView implements IIndexPattern { this.fields = fieldList([], this.shortDotsEnable); this.flattenHit = flattenHitWrapper(this, metaFields); - this.formatHit = formatHitProvider( - this, - fieldFormats.getDefaultInstance(KBN_FIELD_TYPES.STRING) - ); - this.formatField = this.formatHit.formatField; // set values this.id = spec.id; diff --git a/src/plugins/data_views/common/data_views/format_hit.ts b/src/plugins/data_views/common/data_views/format_hit.ts deleted file mode 100644 index c8e6e8e337155..0000000000000 --- a/src/plugins/data_views/common/data_views/format_hit.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import _ from 'lodash'; -import { DataView } from './data_view'; -import { FieldFormatsContentType } from '../../../field_formats/common'; - -const formattedCache = new WeakMap(); -const partialFormattedCache = new WeakMap(); - -// Takes a hit, merges it with any stored/scripted fields, and with the metaFields -// returns a formatted version -export function formatHitProvider(dataView: DataView, defaultFormat: any) { - function convert( - hit: Record, - val: any, - fieldName: string, - type: FieldFormatsContentType = 'html' - ) { - const field = dataView.fields.getByName(fieldName); - const format = field ? dataView.getFormatterForField(field) : defaultFormat; - - return format.convert(val, type, { field, hit, indexPattern: dataView }); - } - - function formatHit(hit: Record, type: string = 'html') { - const cached = formattedCache.get(hit); - if (cached) { - return cached; - } - - // use and update the partial cache, but don't rewrite it. - // _source is stored in partialFormattedCache but not formattedCache - const partials = partialFormattedCache.get(hit) || {}; - partialFormattedCache.set(hit, partials); - - const cache: Record = {}; - formattedCache.set(hit, cache); - - _.forOwn(dataView.flattenHit(hit), function (val: any, fieldName?: string) { - // sync the formatted and partial cache - if (!fieldName) { - return; - } - const formatted = - partials[fieldName] == null ? convert(hit, val, fieldName) : partials[fieldName]; - cache[fieldName] = partials[fieldName] = formatted; - }); - - return cache; - } - - formatHit.formatField = function (hit: Record, fieldName: string) { - let partials = partialFormattedCache.get(hit); - if (partials && partials[fieldName] != null) { - return partials[fieldName]; - } - - if (!partials) { - partials = {}; - partialFormattedCache.set(hit, partials); - } - - const val = fieldName === '_source' ? hit._source : dataView.flattenHit(hit)[fieldName]; - return convert(hit, val, fieldName); - }; - - return formatHit; -} diff --git a/src/plugins/data_views/common/data_views/index.ts b/src/plugins/data_views/common/data_views/index.ts index 7c94dff961c9c..d925d42fbea0d 100644 --- a/src/plugins/data_views/common/data_views/index.ts +++ b/src/plugins/data_views/common/data_views/index.ts @@ -8,6 +8,5 @@ export * from './_pattern_cache'; export * from './flatten_hit'; -export * from './format_hit'; export * from './data_view'; export * from './data_views'; diff --git a/src/plugins/data_views/public/index.ts b/src/plugins/data_views/public/index.ts index 5c810ec1fd4c8..3a6b5ccb237f2 100644 --- a/src/plugins/data_views/public/index.ts +++ b/src/plugins/data_views/public/index.ts @@ -13,7 +13,7 @@ export { ILLEGAL_CHARACTERS, validateDataView, } from '../common/lib'; -export { formatHitProvider, onRedirectNoIndexPattern } from './data_views'; +export { onRedirectNoIndexPattern } from './data_views'; export { IndexPatternField, IIndexPatternFieldList, TypeMeta } from '../common'; diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json index 3d5fdefd276d3..791ce54a0cb1b 100644 --- a/src/plugins/discover/kibana.json +++ b/src/plugins/discover/kibana.json @@ -8,6 +8,7 @@ "data", "embeddable", "inspector", + "fieldFormats", "kibanaLegacy", "urlForwarding", "navigation", @@ -16,7 +17,7 @@ "indexPatternFieldEditor" ], "optionalPlugins": ["home", "share", "usageCollection", "spaces"], - "requiredBundles": ["kibanaUtils", "home", "kibanaReact", "fieldFormats", "dataViews"], + "requiredBundles": ["kibanaUtils", "home", "kibanaReact", "dataViews"], "extraPublicDirs": ["common"], "owner": { "name": "Data Discovery", diff --git a/src/plugins/discover/public/__mocks__/index_pattern.ts b/src/plugins/discover/public/__mocks__/index_pattern.ts index 2acb512617a6b..d33445baa0a2b 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import type { estypes } from '@elastic/elasticsearch'; -import { flattenHit, IIndexPatternFieldList } from '../../../data/common'; +import { IIndexPatternFieldList } from '../../../data/common'; import { IndexPattern } from '../../../data/common'; const fields = [ @@ -28,6 +27,7 @@ const fields = [ { name: 'message', type: 'string', + displayName: 'message', scripted: false, filterable: false, aggregatable: false, @@ -35,6 +35,7 @@ const fields = [ { name: 'extension', type: 'string', + displayName: 'extension', scripted: false, filterable: true, aggregatable: true, @@ -42,6 +43,7 @@ const fields = [ { name: 'bytes', type: 'number', + displayName: 'bytesDisplayName', scripted: false, filterable: true, aggregatable: true, @@ -49,12 +51,14 @@ const fields = [ { name: 'scripted', type: 'number', + displayName: 'scripted', scripted: true, filterable: false, }, { name: 'object.value', type: 'number', + displayName: 'object.value', scripted: false, filterable: true, aggregatable: true, @@ -73,23 +77,15 @@ const indexPattern = { id: 'the-index-pattern-id', title: 'the-index-pattern-title', metaFields: ['_index', '_score'], - formatField: jest.fn(), - flattenHit: undefined, - formatHit: jest.fn((hit) => (hit.fields ? hit.fields : hit._source)), fields, getComputedFields: () => ({ docvalueFields: [], scriptFields: {}, storedFields: ['*'] }), getSourceFiltering: () => ({}), getFieldByName: jest.fn(() => ({})), timeFieldName: '', docvalueFields: [], - getFormatterForField: () => ({ convert: () => 'formatted' }), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), } as unknown as IndexPattern; indexPattern.isTimeBased = () => !!indexPattern.timeFieldName; -indexPattern.formatField = (hit: Record, fieldName: string) => { - return fieldName === '_source' - ? hit._source - : flattenHit(hit as unknown as estypes.SearchHit, indexPattern)[fieldName]; -}; export const indexPatternMock = indexPattern; diff --git a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts index 6cf8e8b3485ff..906ebdebdd06a 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts @@ -6,9 +6,8 @@ * Side Public License, v 1. */ -import { flattenHit, IIndexPatternFieldList } from '../../../data/common'; +import { IIndexPatternFieldList } from '../../../data/common'; import { IndexPattern } from '../../../data/common'; -import type { estypes } from '@elastic/elasticsearch'; const fields = [ { @@ -64,23 +63,16 @@ const indexPattern = { id: 'index-pattern-with-timefield-id', title: 'index-pattern-with-timefield', metaFields: ['_index', '_score'], - flattenHit: undefined, - formatHit: jest.fn((hit) => hit._source), fields, getComputedFields: () => ({}), getSourceFiltering: () => ({}), getFieldByName: (name: string) => fields.getByName(name), timeFieldName: 'timestamp', - getFormatterForField: () => ({ convert: () => 'formatted' }), + getFormatterForField: () => ({ convert: (value: unknown) => value }), isTimeNanosBased: () => false, popularizeField: () => {}, } as unknown as IndexPattern; indexPattern.isTimeBased = () => !!indexPattern.timeFieldName; -indexPattern.formatField = (hit: Record, fieldName: string) => { - return fieldName === '_source' - ? hit._source - : flattenHit(hit as unknown as estypes.SearchHit, indexPattern)[fieldName]; -}; export const indexPatternWithTimefieldMock = indexPattern; diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 8cc5ccf5aa121..6a90ed42417e6 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -13,6 +13,7 @@ import { CONTEXT_STEP_SETTING, DEFAULT_COLUMNS_SETTING, DOC_HIDE_TIME_COLUMN_SETTING, + MAX_DOC_FIELDS_DISPLAYED, SAMPLE_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING, } from '../../common'; @@ -43,9 +44,13 @@ export const discoverServiceMock = { save: true, }, }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, filterManager: dataPlugin.query.filterManager, uiSettings: { - get: (key: string) => { + get: jest.fn((key: string) => { if (key === 'fields:popularLimit') { return 5; } else if (key === DEFAULT_COLUMNS_SETTING) { @@ -62,8 +67,10 @@ export const discoverServiceMock = { return false; } else if (key === SAMPLE_SIZE_SETTING) { return 250; + } else if (key === MAX_DOC_FIELDS_DISPLAYED) { + return 50; } - }, + }), isDefault: (key: string) => { return true; }, diff --git a/src/plugins/discover/public/application/apps/context/context_app.test.tsx b/src/plugins/discover/public/application/apps/context/context_app.test.tsx index 0e50f8f714a2c..d1c557f2839bc 100644 --- a/src/plugins/discover/public/application/apps/context/context_app.test.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app.test.tsx @@ -62,6 +62,10 @@ describe('ContextApp test', () => { navigation: mockNavigationPlugin, core: { notifications: { toasts: [] } }, history: () => {}, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, filterManager: mockFilterManager, uiSettings: uiSettingsMock, } as unknown as DiscoverServices); diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_row.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_row.tsx index d91735460af08..0bf4a36555d16 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_row.tsx +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_row.tsx @@ -10,6 +10,7 @@ import React, { Fragment, useCallback, useMemo, useState } from 'react'; import classNames from 'classnames'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiIcon } from '@elastic/eui'; +import { formatFieldValue } from '../../../../../helpers/format_value'; import { flattenHit } from '../../../../../../../../data/common'; import { DocViewer } from '../../../../../components/doc_viewer/doc_viewer'; import { FilterManager, IndexPattern } from '../../../../../../../../data/public'; @@ -58,7 +59,10 @@ export const TableRow = ({ }); const anchorDocTableRowSubj = row.isAnchor ? ' docTableAnchorRow' : ''; - const flattenedRow = useMemo(() => flattenHit(row, indexPattern), [indexPattern, row]); + const flattenedRow = useMemo( + () => flattenHit(row, indexPattern, { includeIgnoredValues: true }), + [indexPattern, row] + ); const mapping = useMemo(() => indexPattern.fields.getByName, [indexPattern]); // toggle display of the rows details, a full list of the fields from each row @@ -68,13 +72,24 @@ export const TableRow = ({ * Fill an element with the value of a field */ const displayField = (fieldName: string) => { - const formattedField = indexPattern.formatField(row, fieldName); - - // field formatters take care of escaping - // eslint-disable-next-line react/no-danger - const fieldElement = ; + // If we're formatting the _source column, don't use the regular field formatter, + // but our Discover mechanism to format a hit in a better human-readable way. + if (fieldName === '_source') { + return formatRow(row, indexPattern, fieldsToShow); + } + + const formattedField = formatFieldValue( + flattenedRow[fieldName], + row, + indexPattern, + mapping(fieldName) + ); - return
{fieldElement}
; + return ( + // formatFieldValue always returns sanitized HTML + // eslint-disable-next-line react/no-danger +
+ ); }; const inlineFilter = useCallback( (column: string, type: '+' | '-') => { @@ -141,10 +156,9 @@ export const TableRow = ({ ); } else { columns.forEach(function (column: string) { - // when useNewFieldsApi is true, addressing to the fields property is safe - if (useNewFieldsApi && !mapping(column) && !row.fields![column]) { + if (useNewFieldsApi && !mapping(column) && row.fields && !row.fields[column]) { const innerColumns = Object.fromEntries( - Object.entries(row.fields!).filter(([key]) => { + Object.entries(row.fields).filter(([key]) => { return key.indexOf(`${column}.`) === 0; }) ); @@ -161,7 +175,13 @@ export const TableRow = ({ /> ); } else { - const isFilterable = Boolean(mapping(column)?.filterable && filter); + // Check whether the field is defined as filterable in the mapping and does + // NOT have ignored values in it to determine whether we want to allow filtering. + // We should improve this and show a helpful tooltip why the filter buttons are not + // there/disabled when there are ignored values. + const isFilterable = Boolean( + mapping(column)?.filterable && filter && !row._ignored?.includes(column) + ); rowCells.push( { const hit = { _id: 'a', + _index: 'foo', _type: 'doc', _score: 1, _source: { @@ -39,7 +40,7 @@ describe('Row formatter', () => { spec: { id, type, version, timeFieldName, fields: JSON.parse(fields), title }, fieldFormats: fieldFormatsMock, shortDotsEnable: false, - metaFields: [], + metaFields: ['_id', '_type', '_score'], }); }; @@ -47,26 +48,15 @@ describe('Row formatter', () => { const fieldsToShow = indexPattern.fields.getAll().map((fld) => fld.name); - // Realistic response with alphabetical insertion order - const formatHitReturnValue = { - also: 'with \\"quotes\\" or 'single qoutes'', - foo: 'bar', - number: '42', - hello: '<h1>World</h1>', - _id: 'a', - _type: 'doc', - _score: 1, - }; - - const formatHitMock = jest.fn().mockReturnValue(formatHitReturnValue); - beforeEach(() => { - // @ts-expect-error - indexPattern.formatHit = formatHitMock; setServices({ uiSettings: { get: () => 100, }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, } as unknown as DiscoverServices); }); @@ -77,32 +67,32 @@ describe('Row formatter', () => { Array [ Array [ "also", - "with \\\\"quotes\\\\" or 'single qoutes'", + "with \\"quotes\\" or 'single quotes'", ], Array [ "foo", "bar", ], Array [ - "number", - "42", + "hello", + "

World

", ], Array [ - "hello", - "<h1>World</h1>", + "number", + 42, ], Array [ "_id", "a", ], - Array [ - "_type", - "doc", - ], Array [ "_score", 1, ], + Array [ + "_type", + "doc", + ], ] } /> @@ -114,6 +104,10 @@ describe('Row formatter', () => { uiSettings: { get: () => 1, }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, } as unknown as DiscoverServices); expect(formatRow(hit, indexPattern, [])).toMatchInlineSnapshot(` { Array [ Array [ "also", - "with \\\\"quotes\\\\" or 'single qoutes'", + "with \\"quotes\\" or 'single quotes'", + ], + Array [ + "foo", + "bar", + ], + Array [ + "hello", + "

World

", + ], + Array [ + "number", + 42, + ], + Array [ + "_id", + "a", + ], + Array [ + "_score", + 1, + ], + Array [ + "_type", + "doc", ], ] } @@ -130,18 +148,18 @@ describe('Row formatter', () => { }); it('formats document with highlighted fields first', () => { - expect(formatRow({ ...hit, highlight: { number: '42' } }, indexPattern, fieldsToShow)) + expect(formatRow({ ...hit, highlight: { number: ['42'] } }, indexPattern, fieldsToShow)) .toMatchInlineSnapshot(` { ], Array [ "hello", - "<h1>World</h1>", + "

World

", ], Array [ "_id", "a", ], - Array [ - "_type", - "doc", - ], Array [ "_score", 1, ], + Array [ + "_type", + "doc", + ], ] } /> diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/lib/row_formatter.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/lib/row_formatter.tsx index 14cf1839107e7..2702a232f21ef 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/lib/row_formatter.tsx +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/lib/row_formatter.tsx @@ -6,15 +6,17 @@ * Side Public License, v 1. */ +import { estypes } from '@elastic/elasticsearch'; import React, { Fragment } from 'react'; import type { IndexPattern } from 'src/plugins/data/common'; import { MAX_DOC_FIELDS_DISPLAYED } from '../../../../../../../common'; import { getServices } from '../../../../../../kibana_services'; +import { formatHit } from '../../../../../helpers/format_hit'; import './row_formatter.scss'; interface Props { - defPairs: Array<[string, unknown]>; + defPairs: Array<[string, string]>; } const TemplateComponent = ({ defPairs }: Props) => { return ( @@ -24,8 +26,8 @@ const TemplateComponent = ({ defPairs }: Props) => {
{pair[0]}:
{' '} ))} @@ -34,30 +36,12 @@ const TemplateComponent = ({ defPairs }: Props) => { }; export const formatRow = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hit: Record, + hit: estypes.SearchHit, indexPattern: IndexPattern, fieldsToShow: string[] ) => { - const highlights = hit?.highlight ?? {}; - // Keys are sorted in the hits object - const formatted = indexPattern.formatHit(hit); - const fields = indexPattern.fields; - const highlightPairs: Array<[string, unknown]> = []; - const sourcePairs: Array<[string, unknown]> = []; - Object.entries(formatted).forEach(([key, val]) => { - const displayKey = fields.getByName ? fields.getByName(key)?.displayName : undefined; - const pairs = highlights[key] ? highlightPairs : sourcePairs; - if (displayKey) { - if (fieldsToShow.includes(displayKey)) { - pairs.push([displayKey, val]); - } - } else { - pairs.push([key, val]); - } - }); - const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); - return ; + const pairs = formatHit(hit, indexPattern, fieldsToShow); + return ; }; export const formatTopLevelObject = ( @@ -68,8 +52,8 @@ export const formatTopLevelObject = ( indexPattern: IndexPattern ) => { const highlights = row.highlight ?? {}; - const highlightPairs: Array<[string, unknown]> = []; - const sourcePairs: Array<[string, unknown]> = []; + const highlightPairs: Array<[string, string]> = []; + const sourcePairs: Array<[string, string]> = []; const sorted = Object.entries(fields).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)); sorted.forEach(([key, values]) => { const field = indexPattern.getFieldByName(key); diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx index e5212e877e8ba..60540268dcd7f 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx @@ -20,6 +20,11 @@ import { DiscoverDocuments } from './discover_documents'; import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; import { indexPatternMock } from '../../../../../__mocks__/index_pattern'; +jest.mock('../../../../../kibana_services', () => ({ + ...jest.requireActual('../../../../../kibana_services'), + getServices: () => jest.requireActual('../../../../../__mocks__/services').discoverServiceMock, +})); + setHeaderActionMenuMounter(jest.fn()); function getProps(fetchStatus: FetchStatus, hits: ElasticSearchHit[]) { diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx index 6ebed3185e2f1..7e3252dce1ef5 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx @@ -33,6 +33,19 @@ import { RequestAdapter } from '../../../../../../../inspector'; import { Chart } from '../chart/point_series'; import { DiscoverSidebar } from '../sidebar/discover_sidebar'; +jest.mock('../../../../../kibana_services', () => ({ + ...jest.requireActual('../../../../../kibana_services'), + getServices: () => ({ + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, + uiSettings: { + get: jest.fn((key: string) => key === 'discover:maxDocFieldsDisplayed' && 50), + }, + }), +})); + setHeaderActionMenuMounter(jest.fn()); function getProps(indexPattern: IndexPattern, wasSidebarClosed?: boolean): DiscoverLayoutProps { diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap b/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap index ebb06e0b2ecd3..02e2879476a5e 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap @@ -115,42 +115,7 @@ exports[`Discover IndexPattern Management renders correctly 1`] = ` "deserialize": [MockFunction], "getByFieldType": [MockFunction], "getDefaultConfig": [MockFunction], - "getDefaultInstance": [MockFunction] { - "calls": Array [ - Array [ - "string", - ], - Array [ - "string", - ], - Array [ - "string", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Object { - "convert": [MockFunction], - "getConverterFor": [MockFunction], - }, - }, - Object { - "type": "return", - "value": Object { - "convert": [MockFunction], - "getConverterFor": [MockFunction], - }, - }, - Object { - "type": "return", - "value": Object { - "convert": [MockFunction], - "getConverterFor": [MockFunction], - }, - }, - ], - }, + "getDefaultInstance": [MockFunction], "getDefaultInstanceCacheResolver": [MockFunction], "getDefaultInstancePlain": [MockFunction], "getDefaultType": [MockFunction], @@ -651,8 +616,6 @@ exports[`Discover IndexPattern Management renders correctly 1`] = ` }, ], "flattenHit": [Function], - "formatField": [Function], - "formatHit": [Function], "getFieldAttrs": [Function], "getOriginalSavedObjectBody": [Function], "id": "logstash-*", diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/field_calculator.js b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/field_calculator.js index be7e9c616273d..c709f3311105d 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/field_calculator.js +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/field_calculator.js @@ -13,7 +13,7 @@ import { flattenHit } from '../../../../../../../../data/common'; function getFieldValues(hits, field, indexPattern) { const name = field.name; return map(hits, function (hit) { - return flattenHit(hit, indexPattern)[name]; + return flattenHit(hit, indexPattern, { includeIgnoredValues: true })[name]; }); } diff --git a/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts b/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts index 211c4e5c8b069..2198d2f66b6b4 100644 --- a/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts +++ b/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts @@ -22,7 +22,7 @@ export function calcFieldCounts( return {}; } for (const hit of rows) { - const fields = Object.keys(flattenHit(hit, indexPattern)); + const fields = Object.keys(flattenHit(hit, indexPattern, { includeIgnoredValues: true })); for (const fieldName of fields) { counts[fieldName] = (counts[fieldName] || 0) + 1; } diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx index b2be40c008200..22284480afc05 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx @@ -19,6 +19,11 @@ import { DiscoverServices } from '../../../build_services'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; import { getDocId } from './discover_grid_document_selection'; +jest.mock('../../../kibana_services', () => ({ + ...jest.requireActual('../../../kibana_services'), + getServices: () => jest.requireActual('../../../__mocks__/services').discoverServiceMock, +})); + function getProps() { const servicesMock = { uiSettings: uiSettingsMock, diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx index 11323080274a9..ca403c813010b 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx @@ -271,7 +271,11 @@ export const DiscoverGrid = ({ getRenderCellValueFn( indexPattern, displayedRows, - displayedRows ? displayedRows.map((hit) => flattenHit(hit, indexPattern)) : [], + displayedRows + ? displayedRows.map((hit) => + flattenHit(hit, indexPattern, { includeIgnoredValues: true }) + ) + : [], useNewFieldsApi, fieldsToShow, services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED) 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 6556876217953..3fb96ba9e9daa 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 @@ -25,6 +25,9 @@ jest.mock('../../../kibana_services', () => ({ uiSettings: { get: jest.fn(), }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => (value ? value : '-') })), + }, }), })); @@ -102,7 +105,7 @@ describe('Discover grid cell rendering', function () { rowsSource, rowsSource.map(flatten), false, - [], + ['extension', 'bytes'], 100 ); const component = shallow( @@ -133,7 +136,7 @@ describe('Discover grid cell rendering', function () { } /> - bytes + bytesDisplayName + + _index + + + + _score + + `); }); @@ -196,7 +221,7 @@ describe('Discover grid cell rendering', function () { rowsFields, rowsFields.map(flatten), true, - [], + ['extension', 'bytes'], 100 ); const component = shallow( @@ -229,7 +254,7 @@ describe('Discover grid cell rendering', function () { } /> - bytes + bytesDisplayName + + _index + + + + _score + + `); }); @@ -251,7 +298,7 @@ describe('Discover grid cell rendering', function () { rowsFields, rowsFields.map(flatten), true, - [], + ['extension', 'bytes'], // this is the number of rendered items 1 ); @@ -284,6 +331,41 @@ describe('Discover grid cell rendering', function () { } } /> + + bytesDisplayName + + + + _index + + + + _score + + `); }); @@ -342,7 +424,7 @@ describe('Discover grid cell rendering', function () { rowsFieldsWithTopLevelObject, rowsFieldsWithTopLevelObject.map(flatten), true, - [], + ['object.value', 'extension', 'bytes'], 100 ); const component = shallow( @@ -368,7 +450,7 @@ describe('Discover grid cell rendering', function () { className="dscDiscoverGrid__descriptionListDescription" dangerouslySetInnerHTML={ Object { - "__html": "formatted", + "__html": "100", } } /> @@ -383,7 +465,7 @@ describe('Discover grid cell rendering', function () { rowsFieldsWithTopLevelObject, rowsFieldsWithTopLevelObject.map(flatten), true, - [], + ['extension', 'bytes', 'object.value'], 100 ); const component = shallow( 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 a052971580666..4066c13f6391e 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 @@ -22,6 +22,8 @@ import { DiscoverGridContext } from './discover_grid_context'; import { JsonCodeEditor } from '../json_code_editor/json_code_editor'; import { defaultMonacoEditorWidth } from './constants'; import { EsHitRecord } from '../../types'; +import { formatFieldValue } from '../../helpers/format_value'; +import { formatHit } from '../../helpers/format_hit'; export const getRenderCellValueFn = ( @@ -145,39 +147,19 @@ export const getRenderCellValueFn = // eslint-disable-next-line @typescript-eslint/no-explicit-any return ; } - const formatted = indexPattern.formatHit(row); - - // Put the most important fields first - const highlights: Record = (row.highlight as Record) ?? {}; - const highlightPairs: Array<[string, string]> = []; - const sourcePairs: Array<[string, string]> = []; - Object.entries(formatted).forEach(([key, val]) => { - const pairs = highlights[key] ? highlightPairs : sourcePairs; - const displayKey = indexPattern.fields.getByName - ? indexPattern.fields.getByName(key)?.displayName - : undefined; - if (displayKey) { - if (fieldsToShow.includes(displayKey)) { - pairs.push([displayKey, val as string]); - } - } else { - pairs.push([key, val as string]); - } - }); + const pairs = formatHit(row, indexPattern, fieldsToShow); return ( - {[...highlightPairs, ...sourcePairs] - .slice(0, maxDocFieldsDisplayed) - .map(([key, value]) => ( - - {key} - - - ))} + {pairs.map(([key, value]) => ( + + {key} + + + ))} ); } @@ -191,12 +173,13 @@ export const getRenderCellValueFn = return {JSON.stringify(rowFlattened[columnId])}; } - const valueFormatted = indexPattern.formatField(row, columnId); - if (typeof valueFormatted === 'undefined') { - return -; - } return ( - // eslint-disable-next-line react/no-danger - + ); }; diff --git a/src/plugins/discover/public/application/components/table/table.test.tsx b/src/plugins/discover/public/application/components/table/table.test.tsx index ce914edcec703..e61333cce1166 100644 --- a/src/plugins/discover/public/application/components/table/table.test.tsx +++ b/src/plugins/discover/public/application/components/table/table.test.tsx @@ -27,6 +27,10 @@ import { getServices } from '../../../kibana_services'; } }, }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, })); const indexPattern = { @@ -65,8 +69,7 @@ const indexPattern = { ], }, metaFields: ['_index', '_score'], - flattenHit: jest.fn(), - formatHit: jest.fn((hit) => hit._source), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), } as unknown as IndexPattern; indexPattern.fields.getByName = (name: string) => { @@ -359,32 +362,7 @@ describe('DocViewTable at Discover Doc with Fields API', () => { ], }, metaFields: ['_index', '_type', '_score', '_id'], - flattenHit: jest.fn((hit) => { - const result = {} as Record; - Object.keys(hit).forEach((key) => { - if (key !== 'fields') { - result[key] = hit[key]; - } else { - Object.keys(hit.fields).forEach((field) => { - result[field] = hit.fields[field]; - }); - } - }); - return result; - }), - formatHit: jest.fn((hit) => { - const result = {} as Record; - Object.keys(hit).forEach((key) => { - if (key !== 'fields') { - result[key] = hit[key]; - } else { - Object.keys(hit.fields).forEach((field) => { - result[field] = hit.fields[field]; - }); - } - }); - return result; - }), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), } as unknown as IndexPattern; indexPatterneCommerce.fields.getByName = (name: string) => { diff --git a/src/plugins/discover/public/application/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx index e64dbd10f7855..78a6d9ddd3237 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -20,6 +20,8 @@ import { } from '../../doc_views/doc_views_types'; import { ACTIONS_COLUMN, MAIN_COLUMNS } from './table_columns'; import { getFieldsToShow } from '../../helpers/get_fields_to_show'; +import { getIgnoredReason, IgnoredReason } from '../../helpers/get_ignored_reason'; +import { formatFieldValue } from '../../helpers/format_value'; export interface DocViewerTableProps { columns?: string[]; @@ -46,6 +48,7 @@ export interface FieldRecord { }; value: { formattedValue: string; + ignored?: IgnoredReason; }; } @@ -64,8 +67,6 @@ export const DocViewerTable = ({ [indexPattern?.fields] ); - const formattedHit = useMemo(() => indexPattern?.formatHit(hit, 'html'), [hit, indexPattern]); - const tableColumns = useMemo(() => { return filter ? [ACTIONS_COLUMN, ...MAIN_COLUMNS] : MAIN_COLUMNS; }, [filter]); @@ -96,7 +97,7 @@ export const DocViewerTable = ({ return null; } - const flattened = flattenHit(hit, indexPattern, { source: true }); + const flattened = flattenHit(hit, indexPattern, { source: true, includeIgnoredValues: true }); const fieldsToShow = getFieldsToShow(Object.keys(flattened), indexPattern, showMultiFields); const items: FieldRecord[] = Object.keys(flattened) @@ -115,6 +116,8 @@ export const DocViewerTable = ({ const displayName = fieldMapping?.displayName ?? field; const fieldType = isNestedFieldParent(field, indexPattern) ? 'nested' : fieldMapping?.type; + const ignored = getIgnoredReason(fieldMapping ?? field, hit._ignored); + return { action: { onToggleColumn, @@ -130,7 +133,8 @@ export const DocViewerTable = ({ scripted: Boolean(fieldMapping?.scripted), }, value: { - formattedValue: formattedHit[field], + formattedValue: formatFieldValue(flattened[field], hit, indexPattern, fieldMapping), + ignored, }, }; }); diff --git a/src/plugins/discover/public/application/components/table/table_cell_actions.tsx b/src/plugins/discover/public/application/components/table/table_cell_actions.tsx index 7f2f87e7c296c..e43a17448de2e 100644 --- a/src/plugins/discover/public/application/components/table/table_cell_actions.tsx +++ b/src/plugins/discover/public/application/components/table/table_cell_actions.tsx @@ -21,6 +21,7 @@ interface TableActionsProps { fieldMapping?: IndexPatternField; onFilter: DocViewFilterFn; onToggleColumn: (field: string) => void; + ignoredValue: boolean; } export const TableActions = ({ @@ -30,15 +31,16 @@ export const TableActions = ({ flattenedField, onToggleColumn, onFilter, + ignoredValue, }: TableActionsProps) => { return (
onFilter(fieldMapping, flattenedField, '+')} /> onFilter(fieldMapping, flattenedField, '-')} /> ; +interface IgnoreWarningProps { + reason: IgnoredReason; + rawValue: unknown; +} -export const TableFieldValue = ({ formattedValue, field }: TableFieldValueProps) => { +const IgnoreWarning: React.FC = React.memo(({ rawValue, reason }) => { + const multiValue = Array.isArray(rawValue) && rawValue.length > 1; + + const getToolTipContent = (): string => { + switch (reason) { + case IgnoredReason.IGNORE_ABOVE: + return multiValue + ? i18n.translate('discover.docView.table.ignored.multiAboveTooltip', { + defaultMessage: `One or more values in this field are too long and can't be searched or filtered.`, + }) + : i18n.translate('discover.docView.table.ignored.singleAboveTooltip', { + defaultMessage: `The value in this field is too long and can't be searched or filtered.`, + }); + case IgnoredReason.MALFORMED: + return multiValue + ? i18n.translate('discover.docView.table.ignored.multiMalformedTooltip', { + defaultMessage: `This field has one or more malformed values that can't be searched or filtered.`, + }) + : i18n.translate('discover.docView.table.ignored.singleMalformedTooltip', { + defaultMessage: `The value in this field is malformed and can't be searched or filtered.`, + }); + case IgnoredReason.UNKNOWN: + return multiValue + ? i18n.translate('discover.docView.table.ignored.multiUnknownTooltip', { + defaultMessage: `One or more values in this field were ignored by Elasticsearch and can't be searched or filtered.`, + }) + : i18n.translate('discover.docView.table.ignored.singleUnknownTooltip', { + defaultMessage: `The value in this field was ignored by Elasticsearch and can't be searched or filtered.`, + }); + } + }; + + return ( + + + + + + + + {multiValue + ? i18n.translate('discover.docViews.table.ignored.multiValueLabel', { + defaultMessage: 'Contains ignored values', + }) + : i18n.translate('discover.docViews.table.ignored.singleValueLabel', { + defaultMessage: 'Ignored value', + })} + + + + + ); +}); + +type TableFieldValueProps = Pick & { + formattedValue: FieldRecord['value']['formattedValue']; + rawValue: unknown; + ignoreReason?: IgnoredReason; +}; + +export const TableFieldValue = ({ + formattedValue, + field, + rawValue, + ignoreReason, +}: TableFieldValueProps) => { const [fieldOpen, setFieldOpen] = useState(false); - const value = String(formattedValue); + const value = String(rawValue); const isCollapsible = value.length > COLLAPSE_LINE_LENGTH; const isCollapsed = isCollapsible && !fieldOpen; @@ -32,18 +111,26 @@ export const TableFieldValue = ({ formattedValue, field }: TableFieldValueProps) return ( - {isCollapsible && ( - + {(isCollapsible || ignoreReason) && ( + + {isCollapsible && ( + + + + )} + {ignoreReason && ( + + + + )} + )}
); diff --git a/src/plugins/discover/public/application/components/table/table_columns.tsx b/src/plugins/discover/public/application/components/table/table_columns.tsx index 5bd92fe9166e9..5944f9bede646 100644 --- a/src/plugins/discover/public/application/components/table/table_columns.tsx +++ b/src/plugins/discover/public/application/components/table/table_columns.tsx @@ -31,7 +31,7 @@ export const ACTIONS_COLUMN: EuiBasicTableColumn = { ), render: ( { flattenedField, isActive, onFilter, onToggleColumn }: FieldRecord['action'], - { field: { field, fieldMapping } }: FieldRecord + { field: { field, fieldMapping }, value: { ignored } }: FieldRecord ) => { return ( = { flattenedField={flattenedField} onFilter={onFilter!} onToggleColumn={onToggleColumn} + ignoredValue={!!ignored} /> ); }, @@ -82,8 +83,18 @@ export const MAIN_COLUMNS: Array> = [ ), - render: ({ formattedValue }: FieldRecord['value'], { field: { field } }: FieldRecord) => { - return ; + render: ( + { formattedValue, ignored }: FieldRecord['value'], + { field: { field }, action: { flattenedField } }: FieldRecord + ) => { + return ( + + ); }, }, ]; diff --git a/src/plugins/discover/public/application/components/table/table_row_btn_filter_add.tsx b/src/plugins/discover/public/application/components/table/table_row_btn_filter_add.tsx index 5fe1b4dc33342..de56d733442d6 100644 --- a/src/plugins/discover/public/application/components/table/table_row_btn_filter_add.tsx +++ b/src/plugins/discover/public/application/components/table/table_row_btn_filter_add.tsx @@ -20,7 +20,7 @@ export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props const tooltipContent = disabled ? ( ) : ( ) : ( ({ + getServices: () => jest.requireActual('../../__mocks__/services').discoverServiceMock, +})); + +describe('formatHit', () => { + let hit: estypes.SearchHit; + beforeEach(() => { + hit = { + _id: '1', + _index: 'logs', + fields: { + message: ['foobar'], + extension: ['png'], + 'object.value': [42, 13], + bytes: [123], + }, + }; + (dataViewMock.getFormatterForField as jest.Mock).mockReturnValue({ + convert: (value: unknown) => `formatted:${value}`, + }); + }); + + afterEach(() => { + (discoverServiceMock.uiSettings.get as jest.Mock).mockReset(); + }); + + it('formats a document as expected', () => { + const formatted = formatHit(hit, dataViewMock, ['message', 'extension', 'object.value']); + expect(formatted).toEqual([ + ['extension', 'formatted:png'], + ['message', 'formatted:foobar'], + ['object.value', 'formatted:42,13'], + ['_index', 'formatted:logs'], + ['_score', undefined], + ]); + }); + + it('orders highlighted fields first', () => { + const formatted = formatHit({ ...hit, highlight: { message: ['%%'] } }, dataViewMock, [ + 'message', + 'extension', + 'object.value', + ]); + expect(formatted.map(([fieldName]) => fieldName)).toEqual([ + 'message', + 'extension', + 'object.value', + '_index', + '_score', + ]); + }); + + it('only limits count of pairs based on advanced setting', () => { + (discoverServiceMock.uiSettings.get as jest.Mock).mockImplementation( + (key) => key === MAX_DOC_FIELDS_DISPLAYED && 2 + ); + const formatted = formatHit(hit, dataViewMock, ['message', 'extension', 'object.value']); + expect(formatted).toEqual([ + ['extension', 'formatted:png'], + ['message', 'formatted:foobar'], + ]); + }); + + it('should not include fields not mentioned in fieldsToShow', () => { + const formatted = formatHit(hit, dataViewMock, ['message', 'object.value']); + expect(formatted).toEqual([ + ['message', 'formatted:foobar'], + ['object.value', 'formatted:42,13'], + ['_index', 'formatted:logs'], + ['_score', undefined], + ]); + }); + + it('should filter fields based on their real name not displayName', () => { + const formatted = formatHit(hit, dataViewMock, ['bytes']); + expect(formatted).toEqual([ + ['bytesDisplayName', 'formatted:123'], + ['_index', 'formatted:logs'], + ['_score', undefined], + ]); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/format_hit.ts b/src/plugins/discover/public/application/helpers/format_hit.ts new file mode 100644 index 0000000000000..3890973a3f3e4 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/format_hit.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { estypes } from '@elastic/elasticsearch'; +import { DataView, flattenHit } from '../../../../data/common'; +import { MAX_DOC_FIELDS_DISPLAYED } from '../../../common'; +import { getServices } from '../../kibana_services'; +import { formatFieldValue } from './format_value'; + +const formattedHitCache = new WeakMap(); + +type FormattedHit = Array<[fieldName: string, formattedValue: string]>; + +/** + * Returns a formatted document in form of key/value pairs of the fields name and a formatted value. + * The value returned in each pair is an HTML string which is safe to be applied to the DOM, since + * it's formatted using field formatters. + * @param hit The hit to format + * @param dataView The corresponding data view + * @param fieldsToShow A list of fields that should be included in the document summary. + */ +export function formatHit( + hit: estypes.SearchHit, + dataView: DataView, + fieldsToShow: string[] +): FormattedHit { + const cached = formattedHitCache.get(hit); + if (cached) { + return cached; + } + + const highlights = hit.highlight ?? {}; + // Flatten the object using the flattenHit implementation we use across Discover for flattening documents. + const flattened = flattenHit(hit, dataView, { includeIgnoredValues: true, source: true }); + + const highlightPairs: Array<[fieldName: string, formattedValue: string]> = []; + const sourcePairs: Array<[fieldName: string, formattedValue: string]> = []; + + // Add each flattened field into the corresponding array for highlighted or other fields, + // depending on whether the original hit had a highlight for it. That way we can later + // put highlighted fields first in the document summary. + Object.entries(flattened).forEach(([key, val]) => { + // Retrieve the (display) name of the fields, if it's a mapped field on the data view + const displayKey = dataView.fields.getByName(key)?.displayName; + const pairs = highlights[key] ? highlightPairs : sourcePairs; + // Format the raw value using the regular field formatters for that field + const formattedValue = formatFieldValue(val, hit, dataView, dataView.fields.getByName(key)); + // If the field was a mapped field, we validate it against the fieldsToShow list, if not + // we always include it into the result. + if (displayKey) { + if (fieldsToShow.includes(key)) { + pairs.push([displayKey, formattedValue]); + } + } else { + pairs.push([key, formattedValue]); + } + }); + const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); + const formatted = [...highlightPairs, ...sourcePairs].slice(0, maxEntries); + formattedHitCache.set(hit, formatted); + return formatted; +} diff --git a/src/plugins/discover/public/application/helpers/format_value.test.ts b/src/plugins/discover/public/application/helpers/format_value.test.ts new file mode 100644 index 0000000000000..76d95c08e4a19 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/format_value.test.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 type { FieldFormat } from '../../../../field_formats/common'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; +import { formatFieldValue } from './format_value'; + +import { getServices } from '../../kibana_services'; + +jest.mock('../../kibana_services', () => { + const services = { + fieldFormats: { + getDefaultInstance: jest.fn( + () => ({ convert: (value: unknown) => value } as FieldFormat) + ), + }, + }; + return { getServices: () => services }; +}); + +const hit = { + _id: '1', + _index: 'index', + fields: { + message: 'foo', + }, +}; + +describe('formatFieldValue', () => { + afterEach(() => { + (indexPatternMock.getFormatterForField as jest.Mock).mockReset(); + (getServices().fieldFormats.getDefaultInstance as jest.Mock).mockReset(); + }); + + it('should call correct fieldFormatter for field', () => { + const formatterForFieldMock = indexPatternMock.getFormatterForField as jest.Mock; + const convertMock = jest.fn((value: unknown) => `formatted:${value}`); + formatterForFieldMock.mockReturnValue({ convert: convertMock }); + const field = indexPatternMock.fields.getByName('message'); + expect(formatFieldValue('foo', hit, indexPatternMock, field)).toBe('formatted:foo'); + expect(indexPatternMock.getFormatterForField).toHaveBeenCalledWith(field); + expect(convertMock).toHaveBeenCalledWith('foo', 'html', { field, hit }); + }); + + it('should call default string formatter if no field specified', () => { + const convertMock = jest.fn((value: unknown) => `formatted:${value}`); + (getServices().fieldFormats.getDefaultInstance as jest.Mock).mockReturnValue({ + convert: convertMock, + }); + expect(formatFieldValue('foo', hit, indexPatternMock)).toBe('formatted:foo'); + expect(getServices().fieldFormats.getDefaultInstance).toHaveBeenCalledWith('string'); + expect(convertMock).toHaveBeenCalledWith('foo', 'html', { field: undefined, hit }); + }); + + it('should call default string formatter if no indexPattern is specified', () => { + const convertMock = jest.fn((value: unknown) => `formatted:${value}`); + (getServices().fieldFormats.getDefaultInstance as jest.Mock).mockReturnValue({ + convert: convertMock, + }); + expect(formatFieldValue('foo', hit)).toBe('formatted:foo'); + expect(getServices().fieldFormats.getDefaultInstance).toHaveBeenCalledWith('string'); + expect(convertMock).toHaveBeenCalledWith('foo', 'html', { field: undefined, hit }); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/format_value.ts b/src/plugins/discover/public/application/helpers/format_value.ts new file mode 100644 index 0000000000000..cc33276790372 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/format_value.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { estypes } from '@elastic/elasticsearch'; +import { DataView, DataViewField, KBN_FIELD_TYPES } from '../../../../data/common'; +import { getServices } from '../../kibana_services'; + +/** + * Formats the value of a specific field using the appropriate field formatter if available + * or the default string field formatter otherwise. + * + * @param value The value to format + * @param hit The actual search hit (required to get highlight information from) + * @param dataView The data view if available + * @param field The field that value was from if available + * @returns An sanitized HTML string, that is safe to be applied via dangerouslySetInnerHTML + */ +export function formatFieldValue( + value: unknown, + hit: estypes.SearchHit, + dataView?: DataView, + field?: DataViewField +): string { + if (!dataView || !field) { + // If either no field is available or no data view, we'll use the default + // string formatter to format that field. + return getServices() + .fieldFormats.getDefaultInstance(KBN_FIELD_TYPES.STRING) + .convert(value, 'html', { hit, field }); + } + + // If we have a data view and field we use that fields field formatter + return dataView.getFormatterForField(field).convert(value, 'html', { hit, field }); +} diff --git a/src/plugins/discover/public/application/helpers/get_ignored_reason.test.ts b/src/plugins/discover/public/application/helpers/get_ignored_reason.test.ts new file mode 100644 index 0000000000000..13632ca5ed901 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_ignored_reason.test.ts @@ -0,0 +1,54 @@ +/* + * 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 { getIgnoredReason, IgnoredReason } from './get_ignored_reason'; +import { DataViewField, KBN_FIELD_TYPES } from '../../../../data/common'; + +function field(params: Partial): DataViewField { + return { + name: 'text', + type: 'keyword', + ...params, + } as unknown as DataViewField; +} + +describe('getIgnoredReason', () => { + it('will correctly return undefined when no value was ignored', () => { + expect(getIgnoredReason(field({ name: 'foo' }), undefined)).toBeUndefined(); + expect(getIgnoredReason(field({ name: 'foo' }), ['bar', 'baz'])).toBeUndefined(); + }); + + it('will return UNKNOWN if the field passed in was only a name, and thus no type information is present', () => { + expect(getIgnoredReason('foo', ['foo'])).toBe(IgnoredReason.UNKNOWN); + }); + + it('will return IGNORE_ABOVE for string types', () => { + expect(getIgnoredReason(field({ name: 'foo', type: KBN_FIELD_TYPES.STRING }), ['foo'])).toBe( + IgnoredReason.IGNORE_ABOVE + ); + }); + + // Each type that can have malformed values + [ + KBN_FIELD_TYPES.DATE, + KBN_FIELD_TYPES.IP, + KBN_FIELD_TYPES.GEO_POINT, + KBN_FIELD_TYPES.GEO_SHAPE, + KBN_FIELD_TYPES.NUMBER, + ].forEach((type) => { + it(`will return MALFORMED for ${type} fields`, () => { + expect(getIgnoredReason(field({ name: 'foo', type }), ['foo'])).toBe(IgnoredReason.MALFORMED); + }); + }); + + it('will return unknown reasons if it does not know what the reason was', () => { + expect(getIgnoredReason(field({ name: 'foo', type: 'range' }), ['foo'])).toBe( + IgnoredReason.UNKNOWN + ); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/get_ignored_reason.ts b/src/plugins/discover/public/application/helpers/get_ignored_reason.ts new file mode 100644 index 0000000000000..4d2fb85bdb2c4 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_ignored_reason.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 { estypes } from '@elastic/elasticsearch'; +import { DataViewField, KBN_FIELD_TYPES } from '../../../../data/common'; + +export enum IgnoredReason { + IGNORE_ABOVE = 'ignore_above', + MALFORMED = 'malformed', + UNKNOWN = 'unknown', +} + +/** + * Returns the reason why a specific field was ignored in the response. + * Will return undefined if the field had no ignored values in it. + * This implementation will make some assumptions based on specific types + * of ignored values can only happen with specific field types in Elasticsearch. + * + * @param field Either the data view field or the string name of it. + * @param ignoredFields The hit._ignored value of the hit to validate. + */ +export function getIgnoredReason( + field: DataViewField | string, + ignoredFields: estypes.SearchHit['_ignored'] +): IgnoredReason | undefined { + const fieldName = typeof field === 'string' ? field : field.name; + if (!ignoredFields?.includes(fieldName)) { + return undefined; + } + + if (typeof field === 'string') { + return IgnoredReason.UNKNOWN; + } + + switch (field.type) { + case KBN_FIELD_TYPES.STRING: + return IgnoredReason.IGNORE_ABOVE; + case KBN_FIELD_TYPES.NUMBER: + case KBN_FIELD_TYPES.DATE: + case KBN_FIELD_TYPES.GEO_POINT: + case KBN_FIELD_TYPES.GEO_SHAPE: + case KBN_FIELD_TYPES.IP: + return IgnoredReason.MALFORMED; + default: + return IgnoredReason.UNKNOWN; + } +} diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index ab2484abee892..ac16b6b3cc2ba 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -36,6 +36,7 @@ import { KibanaLegacyStart } from '../../kibana_legacy/public'; import { UrlForwardingStart } from '../../url_forwarding/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; import { IndexPatternFieldEditorStart } from '../../index_pattern_field_editor/public'; +import { FieldFormatsStart } from '../../field_formats/public'; import type { SpacesApi } from '../../../../x-pack/plugins/spaces/public'; @@ -49,6 +50,7 @@ export interface DiscoverServices { history: () => History; theme: ChartsPluginStart['theme']; filterManager: FilterManager; + fieldFormats: FieldFormatsStart; indexPatterns: IndexPatternsContract; inspector: InspectorPublicPluginStart; metadata: { branch: string }; @@ -82,6 +84,7 @@ export function buildServices( data: plugins.data, docLinks: core.docLinks, theme: plugins.charts.theme, + fieldFormats: plugins.fieldFormats, filterManager: plugins.data.query.filterManager, history: getHistory, indexPatterns: plugins.data.indexPatterns, diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 6d30e6fd9e8a9..e170e61f7ebc5 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -61,6 +61,7 @@ import { IndexPatternFieldEditorStart } from '../../../plugins/index_pattern_fie import { DeferredSpinner } from './shared'; import { ViewSavedSearchAction } from './application/embeddable/view_saved_search_action'; import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; +import { FieldFormatsStart } from '../../field_formats/public'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -180,6 +181,7 @@ export interface DiscoverStartPlugins { navigation: NavigationStart; charts: ChartsPluginStart; data: DataPublicPluginStart; + fieldFormats: FieldFormatsStart; share?: SharePluginStart; kibanaLegacy: KibanaLegacyStart; urlForwarding: UrlForwardingStart; diff --git a/src/plugins/field_formats/common/converters/source.test.ts b/src/plugins/field_formats/common/converters/source.test.ts index 298c93dac8c4e..6f9e96a136d0b 100644 --- a/src/plugins/field_formats/common/converters/source.test.ts +++ b/src/plugins/field_formats/common/converters/source.test.ts @@ -19,7 +19,7 @@ describe('Source Format', () => { convertHtml = source.getConverterFor(HTML_CONTEXT_TYPE) as HtmlContextTypeConvert; }); - test('should use the text content type if a field is not passed', () => { + test('should render stringified object', () => { const hit = { foo: 'bar', number: 42, @@ -27,23 +27,8 @@ describe('Source Format', () => { also: 'with "quotes" or \'single quotes\'', }; - expect(convertHtml(hit)).toBe( - '{"foo":"bar","number":42,"hello":"<h1>World</h1>","also":"with \\"quotes\\" or 'single quotes'"}' - ); - }); - - test('should render a description list if a field is passed', () => { - const hit = { - foo: 'bar', - number: 42, - hello: '

World

', - also: 'with "quotes" or \'single quotes\'', - }; - - expect( - convertHtml(hit, { field: 'field', indexPattern: { formatHit: (h: string) => h }, hit }) - ).toMatchInlineSnapshot( - `"
foo:
bar
number:
42
hello:

World

also:
with \\"quotes\\" or 'single quotes'
"` + expect(convertHtml(hit, { field: 'field', hit })).toMatchInlineSnapshot( + `"{"foo":"bar","number":42,"hello":"<h1>World</h1>","also":"with \\\\"quotes\\\\" or 'single quotes'"}"` ); }); }); diff --git a/src/plugins/field_formats/common/converters/source.tsx b/src/plugins/field_formats/common/converters/source.tsx index 1caffb5bfb9a8..f92027ec07451 100644 --- a/src/plugins/field_formats/common/converters/source.tsx +++ b/src/plugins/field_formats/common/converters/source.tsx @@ -7,33 +7,8 @@ */ import { KBN_FIELD_TYPES } from '@kbn/field-types'; -import React, { Fragment } from 'react'; -import ReactDOM from 'react-dom/server'; -import { escape, keys } from 'lodash'; -import { shortenDottedString } from '../utils'; import { FieldFormat } from '../field_format'; -import { TextContextTypeConvert, HtmlContextTypeConvert, FIELD_FORMAT_IDS } from '../types'; -import { FORMATS_UI_SETTINGS } from '../constants/ui_settings'; - -interface Props { - defPairs: Array<[string, string]>; -} -const TemplateComponent = ({ defPairs }: Props) => { - return ( -
- {defPairs.map((pair, idx) => ( - -
-
{' '} - - ))} -
- ); -}; +import { TextContextTypeConvert, FIELD_FORMAT_IDS } from '../types'; /** @public */ export class SourceFormat extends FieldFormat { @@ -42,32 +17,4 @@ export class SourceFormat extends FieldFormat { static fieldType = KBN_FIELD_TYPES._SOURCE; textConvert: TextContextTypeConvert = (value: string) => JSON.stringify(value); - - htmlConvert: HtmlContextTypeConvert = (value: string, options = {}) => { - const { field, hit, indexPattern } = options; - - if (!field) { - const converter = this.getConverterFor('text') as Function; - - return escape(converter(value)); - } - - const highlights: Record = (hit && hit.highlight) || {}; - // TODO: remove index pattern dependency - const formatted = hit ? indexPattern!.formatHit(hit) : {}; - const highlightPairs: Array<[string, string]> = []; - const sourcePairs: Array<[string, string]> = []; - const isShortDots = this.getConfig!(FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE); - - keys(formatted).forEach((key) => { - const pairs = highlights[key] ? highlightPairs : sourcePairs; - const newField = isShortDots ? shortenDottedString(key) : key; - const val = formatted![key]; - pairs.push([newField as string, val]); - }, []); - - return ReactDOM.renderToStaticMarkup( - - ); - }; } diff --git a/src/plugins/field_formats/common/types.ts b/src/plugins/field_formats/common/types.ts index 00f9f5d707e89..6f0efebe389a1 100644 --- a/src/plugins/field_formats/common/types.ts +++ b/src/plugins/field_formats/common/types.ts @@ -17,10 +17,6 @@ export type FieldFormatsContentType = 'html' | 'text'; */ export interface HtmlContextTypeOptions { field?: { name: string }; - // TODO: get rid of indexPattern dep completely - indexPattern?: { - formatHit: (hit: { highlight: Record }) => Record; - }; hit?: { highlight: Record }; } diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index 6c2989d54309d..77ad4fba1ab60 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -356,7 +356,7 @@ export class CsvGenerator { let table: Datatable | undefined; try { - table = tabifyDocs(results, index, { shallow: true }); + table = tabifyDocs(results, index, { shallow: true, includeIgnoredValues: true }); } catch (err) { this.logger.error(err); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index aac0f651b8dee..64258d42a4cc1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2424,7 +2424,6 @@ "discover.docViews.table.toggleFieldDetails": "フィールド詳細を切り替える", "discover.docViews.table.unableToFilterForPresenceOfMetaFieldsTooltip": "メタフィールドの有無でフィルタリングできません", "discover.docViews.table.unableToFilterForPresenceOfScriptedFieldsTooltip": "スクリプトフィールドの有無でフィルタリングできません", - "discover.docViews.table.unindexedFieldsCanNotBeSearchedTooltip": "インデックスされていないフィールドは検索できません", "discover.embeddable.inspectorRequestDataTitle": "データ", "discover.embeddable.inspectorRequestDescription": "このリクエストはElasticsearchにクエリをかけ、検索データを取得します。", "discover.embeddable.search.displayName": "検索", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 50d90f5144585..ecc27bbe9dea8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2449,7 +2449,6 @@ "discover.docViews.table.toggleFieldDetails": "切换字段详细信息", "discover.docViews.table.unableToFilterForPresenceOfMetaFieldsTooltip": "无法筛选元数据字段是否存在", "discover.docViews.table.unableToFilterForPresenceOfScriptedFieldsTooltip": "无法筛选脚本字段是否存在", - "discover.docViews.table.unindexedFieldsCanNotBeSearchedTooltip": "无法搜索未索引字段", "discover.embeddable.inspectorRequestDataTitle": "数据", "discover.embeddable.inspectorRequestDescription": "此请求将查询 Elasticsearch 以获取搜索的数据。", "discover.embeddable.search.displayName": "搜索", From 96c89e0fcab42366078c35ecadbc31681a8bf834 Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Tue, 19 Oct 2021 16:43:51 +0200 Subject: [PATCH 18/30] [Unified Integrations] Remove and cleanup add data views (#115424) Co-authored-by: cchaos Co-authored-by: Dave Snider Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Thomas Neirynck --- .../__snapshots__/home.test.tsx.snap | 422 +++++------------- .../application/components/home.test.tsx | 26 +- .../public/application/components/home.tsx | 8 +- .../public/application/components/home_app.js | 12 +- .../components/tutorial/tutorial.js | 13 +- .../components/tutorial_directory.js | 24 +- .../elastic_agent_card.test.tsx.snap | 246 ++++++---- .../no_data_card/elastic_agent_card.tsx | 39 +- .../apps/home/{_add_data.js => _add_data.ts} | 13 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 11 files changed, 356 insertions(+), 449 deletions(-) rename test/functional/apps/home/{_add_data.js => _add_data.ts} (59%) diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap index b6679dd7ba493..f38bdb9ac53f0 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap @@ -21,10 +21,28 @@ exports[`home change home route should render a link to change the default route /> - - - -`; - -exports[`home welcome should show the normal home page if loading fails 1`] = ` -, - } - } - template="empty" -> - - - - - -`; - -exports[`home welcome should show the normal home page if welcome screen is disabled locally 1`] = ` -, + application={ + Object { + "capabilities": Object { + "navLinks": Object { + "integrations": true, + }, + }, + } } - } - template="empty" -> - - - - -`; - -exports[`home welcome should show the welcome screen if enabled, and there are no index patterns defined 1`] = ` - -`; - -exports[`home welcome stores skip welcome setting if skipped 1`] = ` -, - } - } - template="empty" -> - - - ({ getServices: () => ({ getBasePath: () => 'path', @@ -22,6 +24,13 @@ jest.mock('../kibana_services', () => ({ chrome: { setBreadcrumbs: () => {}, }, + application: { + capabilities: { + navLinks: { + integrations: mockHasIntegrationsPermission, + }, + }, + }, }), })); @@ -35,6 +44,7 @@ describe('home', () => { let defaultProps: HomeProps; beforeEach(() => { + mockHasIntegrationsPermission = true; defaultProps = { directories: [], solutions: [], @@ -182,7 +192,7 @@ describe('home', () => { expect(defaultProps.localStorage.getItem).toHaveBeenCalledTimes(1); - expect(component).toMatchSnapshot(); + expect(component.find(Welcome).exists()).toBe(true); }); test('stores skip welcome setting if skipped', async () => { @@ -196,7 +206,7 @@ describe('home', () => { expect(defaultProps.localStorage.setItem).toHaveBeenCalledWith('home:welcome:show', 'false'); - expect(component).toMatchSnapshot(); + expect(component.find(Welcome).exists()).toBe(false); }); test('should show the normal home page if loading fails', async () => { @@ -205,7 +215,7 @@ describe('home', () => { const hasUserIndexPattern = jest.fn(() => Promise.reject('Doh!')); const component = await renderHome({ hasUserIndexPattern }); - expect(component).toMatchSnapshot(); + expect(component.find(Welcome).exists()).toBe(false); }); test('should show the normal home page if welcome screen is disabled locally', async () => { @@ -213,7 +223,15 @@ describe('home', () => { const component = await renderHome(); - expect(component).toMatchSnapshot(); + expect(component.find(Welcome).exists()).toBe(false); + }); + + test("should show the normal home page if user doesn't have access to integrations", async () => { + mockHasIntegrationsPermission = false; + + const component = await renderHome(); + + expect(component.find(Welcome).exists()).toBe(false); }); }); diff --git a/src/plugins/home/public/application/components/home.tsx b/src/plugins/home/public/application/components/home.tsx index d398311d30255..2a08754889c28 100644 --- a/src/plugins/home/public/application/components/home.tsx +++ b/src/plugins/home/public/application/components/home.tsx @@ -45,10 +45,10 @@ export class Home extends Component { constructor(props: HomeProps) { super(props); - const isWelcomeEnabled = !( - getServices().homeConfig.disableWelcomeScreen || - props.localStorage.getItem(KEY_ENABLE_WELCOME) === 'false' - ); + const isWelcomeEnabled = + !getServices().homeConfig.disableWelcomeScreen && + getServices().application.capabilities.navLinks.integrations && + props.localStorage.getItem(KEY_ENABLE_WELCOME) !== 'false'; const body = document.querySelector('body')!; body.classList.add('isHomPage'); diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index b0ba4d46646d0..1dbcaa6f50fa1 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -17,8 +17,11 @@ import { getTutorial } from '../load_tutorials'; import { replaceTemplateStrings } from './tutorial/replace_template_strings'; import { getServices } from '../kibana_services'; +const REDIRECT_TO_INTEGRATIONS_TAB_IDS = ['all', 'logging', 'metrics', 'security']; + export function HomeApp({ directories, solutions }) { const { + application, savedObjectsClient, getBasePath, addBasePath, @@ -30,10 +33,17 @@ export function HomeApp({ directories, solutions }) { const isCloudEnabled = environment.cloud; const renderTutorialDirectory = (props) => { + // Redirect to integrations app unless a specific tab that is still supported was specified. + const tabId = props.match.params.tab; + if (!tabId || REDIRECT_TO_INTEGRATIONS_TAB_IDS.includes(tabId)) { + application.navigateToApp('integrations', { replace: true }); + return null; + } + return ( ); diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.js b/src/plugins/home/public/application/components/tutorial/tutorial.js index 508a236bf45d4..4af5e362baca9 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.js @@ -26,9 +26,8 @@ const INSTRUCTIONS_TYPE = { ON_PREM_ELASTIC_CLOUD: 'onPremElasticCloud', }; -const homeTitle = i18n.translate('home.breadcrumbs.homeTitle', { defaultMessage: 'Home' }); -const addDataTitle = i18n.translate('home.breadcrumbs.addDataTitle', { - defaultMessage: 'Add data', +const integrationsTitle = i18n.translate('home.breadcrumbs.integrationsAppTitle', { + defaultMessage: 'Integrations', }); class TutorialUi extends React.Component { @@ -80,12 +79,8 @@ class TutorialUi extends React.Component { getServices().chrome.setBreadcrumbs([ { - text: homeTitle, - href: '#/', - }, - { - text: addDataTitle, - href: '#/tutorial_directory', + text: integrationsTitle, + href: this.props.addBasePath('/app/integrations/browse'), }, { text: tutorial ? tutorial.name : this.props.tutorialId, diff --git a/src/plugins/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js index 83e629a7c891e..ac0d1524145a1 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.js +++ b/src/plugins/home/public/application/components/tutorial_directory.js @@ -18,12 +18,10 @@ import { getServices } from '../kibana_services'; import { KibanaPageTemplate } from '../../../../kibana_react/public'; import { getTutorials } from '../load_tutorials'; -const ALL_TAB_ID = 'all'; const SAMPLE_DATA_TAB_ID = 'sampleData'; -const homeTitle = i18n.translate('home.breadcrumbs.homeTitle', { defaultMessage: 'Home' }); -const addDataTitle = i18n.translate('home.breadcrumbs.addDataTitle', { - defaultMessage: 'Add data', +const integrationsTitle = i18n.translate('home.breadcrumbs.integrationsAppTitle', { + defaultMessage: 'Integrations', }); class TutorialDirectoryUi extends React.Component { @@ -48,7 +46,7 @@ class TutorialDirectoryUi extends React.Component { })), ]; - let openTab = ALL_TAB_ID; + let openTab = SAMPLE_DATA_TAB_ID; if ( props.openTab && this.tabs.some((tab) => { @@ -72,10 +70,9 @@ class TutorialDirectoryUi extends React.Component { getServices().chrome.setBreadcrumbs([ { - text: homeTitle, - href: '#/', + text: integrationsTitle, + href: this.props.addBasePath(`/app/integrations/browse`), }, - { text: addDataTitle }, ]); const tutorialConfigs = await getTutorials(); @@ -155,6 +152,15 @@ class TutorialDirectoryUi extends React.Component { renderTabContent = () => { const tab = this.tabs.find(({ id }) => id === this.state.selectedTabId); if (tab?.content) { + getServices().chrome.setBreadcrumbs([ + { + text: integrationsTitle, + href: this.props.addBasePath(`/app/integrations/browse`), + }, + { + text: tab.name, + }, + ]); return tab.content; } @@ -163,7 +169,7 @@ class TutorialDirectoryUi extends React.Component { {this.state.tutorialCards .filter((tutorial) => { return ( - this.state.selectedTabId === ALL_TAB_ID || + this.state.selectedTabId === SAMPLE_DATA_TAB_ID || this.state.selectedTabId === tutorial.category ); }) diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap index f66d05140b2e9..8e1d0cb92e006 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap @@ -1,117 +1,177 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ElasticAgentCard props button 1`] = ` - - Button - + - - Add Elastic Agent - - - } -/> +> + + Button + + } + href="/app/integrations/browse" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } + /> + `; exports[`ElasticAgentCard props category 1`] = ` - - Add Elastic Agent - + - +> + Add Elastic Agent - - - } -/> + + } + href="/app/integrations/browse/custom" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } + /> + `; exports[`ElasticAgentCard props href 1`] = ` - - Button - + - - Add Elastic Agent - - - } -/> +> + + Button + + } + href="#" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } + /> + `; exports[`ElasticAgentCard props recommended 1`] = ` - - Add Elastic Agent - + - +> + Add Elastic Agent - - - } -/> + + } + href="/app/integrations/browse" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } + /> + `; exports[`ElasticAgentCard renders 1`] = ` - - Add Elastic Agent - + - +> + Add Elastic Agent - - - } -/> + + } + href="/app/integrations/browse" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } + /> + `; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx index 5a91e568471d1..b9d412fe4df89 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx @@ -12,6 +12,7 @@ import { CoreStart } from 'kibana/public'; import { EuiButton, EuiCard, EuiTextColor, EuiScreenReaderOnly } from '@elastic/eui'; import { useKibana } from '../../../context'; import { NoDataPageActions, NO_DATA_RECOMMENDED } from '../no_data_page'; +import { RedirectAppLinks } from '../../../app_links'; export type ElasticAgentCardProps = NoDataPageActions & { solution: string; @@ -76,23 +77,25 @@ export const ElasticAgentCard: FunctionComponent = ({ ); return ( - - {defaultCTAtitle} - - } - description={i18n.translate('kibana-react.noDataPage.elasticAgentCard.description', { - defaultMessage: `Use Elastic Agent for a simple, unified way to collect data from your machines.`, - })} - betaBadgeLabel={recommended ? NO_DATA_RECOMMENDED : undefined} - footer={footer} - layout={layout as 'vertical' | undefined} - {...cardRest} - /> + + + {defaultCTAtitle} + + } + description={i18n.translate('kibana-react.noDataPage.elasticAgentCard.description', { + defaultMessage: `Use Elastic Agent for a simple, unified way to collect data from your machines.`, + })} + betaBadgeLabel={recommended ? NO_DATA_RECOMMENDED : undefined} + footer={footer} + layout={layout as 'vertical' | undefined} + {...cardRest} + /> + ); }; diff --git a/test/functional/apps/home/_add_data.js b/test/functional/apps/home/_add_data.ts similarity index 59% rename from test/functional/apps/home/_add_data.js rename to test/functional/apps/home/_add_data.ts index c69e0a02c26e4..3fd69c1a488f4 100644 --- a/test/functional/apps/home/_add_data.js +++ b/test/functional/apps/home/_add_data.ts @@ -6,20 +6,15 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { - const retry = getService('retry'); +export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'header', 'home', 'dashboard']); describe('add data tutorials', function describeIndexTests() { - it('directory should display registered tutorials', async () => { + it('directory should redirect to integrations app', async () => { await PageObjects.common.navigateToUrl('home', 'tutorial_directory', { useActualUrl: true }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await retry.try(async () => { - const tutorialExists = await PageObjects.home.doesSynopsisExist('netflowlogs'); - expect(tutorialExists).to.be(true); - }); + await PageObjects.common.waitUntilUrlIncludes('/app/integrations'); }); }); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 64258d42a4cc1..f831f3b91eaa5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2914,7 +2914,6 @@ "home.addData.sampleDataButtonLabel": "サンプルデータを試す", "home.addData.sectionTitle": "データを追加して開始する", "home.addData.text": "データの操作を開始するには、多数の取り込みオプションのいずれかを使用します。アプリまたはサービスからデータを収集するか、ファイルをアップロードします。独自のデータを使用する準備ができていない場合は、サンプルデータセットを追加してください。", - "home.breadcrumbs.addDataTitle": "データの追加", "home.breadcrumbs.homeTitle": "ホーム", "home.dataManagementDisableCollection": " 収集を停止するには、", "home.dataManagementDisableCollectionLink": "ここで使用状況データを無効にします。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ecc27bbe9dea8..5fbcd26340be3 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2943,7 +2943,6 @@ "home.addData.sampleDataButtonLabel": "试用样例数据", "home.addData.sectionTitle": "首先添加您的数据", "home.addData.text": "要开始使用您的数据,请使用我们众多采集选项中的一个选项。从应用或服务收集数据,或上传文件。如果未准备好使用自己的数据,请添加示例数据集。", - "home.breadcrumbs.addDataTitle": "添加数据", "home.breadcrumbs.homeTitle": "主页", "home.dataManagementDisableCollection": " 要停止收集,", "home.dataManagementDisableCollectionLink": "请在此禁用使用情况数据。", From e4fb118fee9302d65e696c40adc3c44ff44c5a27 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 19 Oct 2021 10:44:15 -0400 Subject: [PATCH 19/30] Change deleteByNamespace to include legacy URL aliases (#115459) --- .../object_types/registration.ts | 1 + .../service/lib/repository.test.js | 7 ++- .../saved_objects/service/lib/repository.ts | 16 +++++- .../common/lib/space_test_utils.ts | 2 + .../common/suites/delete.ts | 53 +++++++++---------- 5 files changed, 47 insertions(+), 32 deletions(-) diff --git a/src/core/server/saved_objects/object_types/registration.ts b/src/core/server/saved_objects/object_types/registration.ts index 6ef4f79ef77c9..ce10896747178 100644 --- a/src/core/server/saved_objects/object_types/registration.ts +++ b/src/core/server/saved_objects/object_types/registration.ts @@ -17,6 +17,7 @@ const legacyUrlAliasType: SavedObjectsType = { properties: { sourceId: { type: 'keyword' }, targetType: { type: 'keyword' }, + targetNamespace: { type: 'keyword' }, resolveCounter: { type: 'long' }, disabled: { type: 'boolean' }, // other properties exist, but we aren't querying or aggregating on those, so we don't need to specify them (because we use `dynamic: false` above) 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 82a0dd71700f6..84359147fccbc 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -26,6 +26,7 @@ import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { DocumentMigrator } from '../../migrations/core/document_migrator'; import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock'; +import { LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import * as esKuery from '@kbn/es-query'; import { errors as EsErrors } from '@elastic/elasticsearch'; @@ -2714,7 +2715,11 @@ describe('SavedObjectsRepository', () => { const allTypes = registry.getAllTypes().map((type) => type.name); expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { namespaces: [namespace], - type: allTypes.filter((type) => !registry.isNamespaceAgnostic(type)), + type: [ + ...allTypes.filter((type) => !registry.isNamespaceAgnostic(type)), + LEGACY_URL_ALIAS_TYPE, + ], + kueryNode: expect.anything(), }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index e522d770b3f58..c081c59911405 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -8,6 +8,7 @@ import { omit, isObject } from 'lodash'; import type { estypes } from '@elastic/elasticsearch'; +import * as esKuery from '@kbn/es-query'; import type { ElasticsearchClient } from '../../../elasticsearch/'; import { isSupportedEsServer, isNotFoundFromUnsupportedServer } from '../../../elasticsearch'; import type { Logger } from '../../../logging'; @@ -55,6 +56,7 @@ import { SavedObjectsBulkResolveObject, SavedObjectsBulkResolveResponse, } from '../saved_objects_client'; +import { LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { SavedObject, SavedObjectsBaseOptions, @@ -780,7 +782,16 @@ export class SavedObjectsRepository { } const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); - const typesToUpdate = allTypes.filter((type) => !this._registry.isNamespaceAgnostic(type)); + const typesToUpdate = [ + ...allTypes.filter((type) => !this._registry.isNamespaceAgnostic(type)), + LEGACY_URL_ALIAS_TYPE, + ]; + + // Construct kueryNode to filter legacy URL aliases (these space-agnostic objects do not use root-level "namespace/s" fields) + const { buildNode } = esKuery.nodeTypes.function; + const match1 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.targetNamespace`, namespace); + const match2 = buildNode('not', buildNode('is', 'type', LEGACY_URL_ALIAS_TYPE)); + const kueryNode = buildNode('or', [match1, match2]); const { body, statusCode, headers } = await this.client.updateByQuery( { @@ -803,8 +814,9 @@ export class SavedObjectsRepository { }, conflicts: 'proceed', ...getSearchDsl(this._mappings, this._registry, { - namespaces: namespace ? [namespace] : undefined, + namespaces: [namespace], type: typesToUpdate, + kueryNode, }), }, }, diff --git a/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts b/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts index 28b19d5db20b6..c047a741e35da 100644 --- a/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts +++ b/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts @@ -50,6 +50,8 @@ export function getAggregatedSpaceData(es: KibanaClient, objectTypes: string[]) emit(doc["namespaces"].value); } else if (doc["namespace"].size() > 0) { emit(doc["namespace"].value); + } else if (doc["legacy-url-alias.targetNamespace"].size() > 0) { + emit(doc["legacy-url-alias.targetNamespace"].value); } `, }, diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts index aaca4fa843d67..4bf44d88db8e0 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -48,6 +48,7 @@ export function deleteTestSuiteFactory( 'dashboard', 'space', 'index-pattern', + 'legacy-url-alias', // TODO: add assertions for config objects -- these assertions were removed because of flaky behavior in #92358, but we should // consider adding them again at some point, especially if we convert config objects to `namespaceType: 'multiple-isolated'` in // the future. @@ -56,6 +57,10 @@ export function deleteTestSuiteFactory( // @ts-expect-error @elastic/elasticsearch doesn't defined `count.buckets`. const buckets = response.aggregations?.count.buckets; + // The test fixture contains three legacy URL aliases: + // (1) one for "space_1", (2) one for "space_2", and (3) one for "other_space", which is a non-existent space. + // Each test deletes "space_2", so the agg buckets should reflect that aliases (1) and (3) still exist afterwards. + // Space 2 deleted, all others should exist const expectedBuckets = [ { @@ -65,47 +70,37 @@ export function deleteTestSuiteFactory( doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ - { - key: 'visualization', - doc_count: 3, - }, - { - key: 'dashboard', - doc_count: 2, - }, - { - key: 'space', - doc_count: 2, - }, - { - key: 'index-pattern', - doc_count: 1, - }, + { key: 'visualization', doc_count: 3 }, + { key: 'dashboard', doc_count: 2 }, + { key: 'space', doc_count: 2 }, // since space objects are namespace-agnostic, they appear in the "default" agg bucket + { key: 'index-pattern', doc_count: 1 }, + // legacy-url-alias objects cannot exist for the default space ], }, }, { - doc_count: 6, + doc_count: 7, key: 'space_1', countByType: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ - { - key: 'visualization', - doc_count: 3, - }, - { - key: 'dashboard', - doc_count: 2, - }, - { - key: 'index-pattern', - doc_count: 1, - }, + { key: 'visualization', doc_count: 3 }, + { key: 'dashboard', doc_count: 2 }, + { key: 'index-pattern', doc_count: 1 }, + { key: 'legacy-url-alias', doc_count: 1 }, // alias (1) ], }, }, + { + doc_count: 1, + key: 'other_space', + countByType: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'legacy-url-alias', doc_count: 1 }], // alias (3) + }, + }, ]; expect(buckets).to.eql(expectedBuckets); From 92e1cd25b7fd8145689cb68d3b21a70f80c6b504 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 19 Oct 2021 15:51:30 +0100 Subject: [PATCH 20/30] [ML] Adding ability to change data view in advanced job wizard (#115191) * [ML] Adding ability to change data view in advanced job wizard * updating translation ids * type and text changes * code clean up * route id change * text changes * text change * changing data view to index pattern * adding api tests * text updates * removing first step * renaming temp variable * adding permission checks Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/ml/common/types/job_validation.ts | 14 + .../plugins/ml/common/types/saved_objects.ts | 2 +- .../contexts/kibana/use_navigate_to_path.ts | 2 +- .../new_job/common/job_creator/job_creator.ts | 4 + .../common/job_creator/util/general.ts | 8 +- .../json_editor_flyout/json_editor_flyout.tsx | 2 +- .../components/data_view/change_data_view.tsx | 326 ++++++++++++++++++ .../data_view/change_data_view_button.tsx | 36 ++ .../components/data_view/description.tsx | 32 ++ .../components/data_view/index.ts | 8 + .../components/datafeed_step/datafeed.tsx | 2 + .../services/ml_api_service/index.ts | 12 +- .../ml/server/models/job_validation/index.ts | 4 + .../models/job_validation/job_validation.ts | 6 +- .../validate_datafeed_preview.ts | 29 +- x-pack/plugins/ml/server/routes/apidoc.json | 2 + .../ml/server/routes/job_validation.ts | 44 ++- .../routes/schemas/job_validation_schema.ts | 7 + .../apis/ml/data_frame_analytics/delete.ts | 4 +- .../datafeed_preview_validation.ts | 175 ++++++++++ .../apis/ml/job_validation/index.ts | 1 + x-pack/test/functional/services/ml/api.ts | 10 +- 22 files changed, 711 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/ml/common/types/job_validation.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/change_data_view.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/change_data_view_button.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/description.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/index.ts create mode 100644 x-pack/test/api_integration/apis/ml/job_validation/datafeed_preview_validation.ts diff --git a/x-pack/plugins/ml/common/types/job_validation.ts b/x-pack/plugins/ml/common/types/job_validation.ts new file mode 100644 index 0000000000000..0c1db63ff3762 --- /dev/null +++ b/x-pack/plugins/ml/common/types/job_validation.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ErrorType } from '../util/errors'; + +export interface DatafeedValidationResponse { + valid: boolean; + documentsFound: boolean; + error?: ErrorType; +} diff --git a/x-pack/plugins/ml/common/types/saved_objects.ts b/x-pack/plugins/ml/common/types/saved_objects.ts index 0e48800dd845d..e376fddbe6272 100644 --- a/x-pack/plugins/ml/common/types/saved_objects.ts +++ b/x-pack/plugins/ml/common/types/saved_objects.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ErrorType } from '../util/errors'; +import type { ErrorType } from '../util/errors'; export type JobType = 'anomaly-detector' | 'data-frame-analytics'; export const ML_SAVED_OBJECT_TYPE = 'ml-job'; export const ML_MODULE_SAVED_OBJECT_TYPE = 'ml-module'; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts b/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts index 951d9d6dfded9..00050803b97c6 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts @@ -23,7 +23,7 @@ export const useNavigateToPath = () => { const location = useLocation(); return useCallback( - async (path: string | undefined, preserveSearch = false) => { + async (path: string | undefined, preserveSearch: boolean = false) => { if (path === undefined) return; const modifiedPath = `${path}${preserveSearch === true ? location.search : ''}`; /** 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 a44b4bdef60c4..607a4fcf9a73c 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 @@ -502,6 +502,10 @@ export class JobCreator { return this._datafeed_config.indices; } + public set indices(indics: string[]) { + this._datafeed_config.indices = indics; + } + public get scriptFields(): Field[] { return this._scriptFields; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 78903e64686f5..46315ac3b02d8 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -258,17 +258,21 @@ export function convertToMultiMetricJob( jobCreator.createdBy = CREATED_BY_LABEL.MULTI_METRIC; jobCreator.modelPlot = false; stashJobForCloning(jobCreator, true, true); - navigateToPath(`jobs/new_job/${JOB_TYPE.MULTI_METRIC}`, true); } export function convertToAdvancedJob(jobCreator: JobCreatorType, navigateToPath: NavigateToPath) { jobCreator.createdBy = null; stashJobForCloning(jobCreator, true, true); - navigateToPath(`jobs/new_job/${JOB_TYPE.ADVANCED}`, true); } +export function resetAdvancedJob(jobCreator: JobCreatorType, navigateToPath: NavigateToPath) { + jobCreator.createdBy = null; + stashJobForCloning(jobCreator, true, false); + navigateToPath('/jobs/new_job'); +} + export function resetJob(jobCreator: JobCreatorType, navigateToPath: NavigateToPath) { jobCreator.jobId = ''; stashJobForCloning(jobCreator, true, true); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/json_editor_flyout/json_editor_flyout.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/json_editor_flyout/json_editor_flyout.tsx index ce71cd80e45c0..9e5d1ac5eef6f 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/json_editor_flyout/json_editor_flyout.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/json_editor_flyout/json_editor_flyout.tsx @@ -204,7 +204,7 @@ export const JsonEditorFlyout: FC = ({ isDisabled, jobEditorMode, datafee > diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/change_data_view.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/change_data_view.tsx new file mode 100644 index 0000000000000..c402ee4bf9799 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/change_data_view.tsx @@ -0,0 +1,326 @@ +/* + * 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, useCallback, useContext } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiModal, + EuiButton, + EuiCallOut, + EuiSpacer, + EuiModalHeader, + EuiLoadingSpinner, + EuiModalHeaderTitle, + EuiModalBody, +} from '@elastic/eui'; + +import { JobCreatorContext } from '../../../job_creator_context'; +import { AdvancedJobCreator } from '../../../../../common/job_creator'; +import { resetAdvancedJob } from '../../../../../common/job_creator/util/general'; +import { + CombinedJob, + Datafeed, +} from '../../../../../../../../../common/types/anomaly_detection_jobs'; +import { extractErrorMessage } from '../../../../../../../../../common/util/errors'; +import type { DatafeedValidationResponse } from '../../../../../../../../../common/types/job_validation'; + +import { SavedObjectFinderUi } from '../../../../../../../../../../../../src/plugins/saved_objects/public'; +import { + useMlKibana, + useMlApiContext, + useNavigateToPath, +} from '../../../../../../../contexts/kibana'; + +const fixedPageSize: number = 8; + +enum STEP { + PICK_DATA_VIEW, + VALIDATE, +} + +interface Props { + onClose: () => void; +} + +export const ChangeDataViewModal: FC = ({ onClose }) => { + const { + services: { + savedObjects, + uiSettings, + data: { dataViews }, + }, + } = useMlKibana(); + const navigateToPath = useNavigateToPath(); + const { validateDatafeedPreview } = useMlApiContext(); + + const { jobCreator: jc } = useContext(JobCreatorContext); + const jobCreator = jc as AdvancedJobCreator; + + const [validating, setValidating] = useState(false); + const [step, setStep] = useState(STEP.PICK_DATA_VIEW); + + const [currentDataViewTitle, setCurrentDataViewTitle] = useState(''); + const [newDataViewTitle, setNewDataViewTitle] = useState(''); + const [validationResponse, setValidationResponse] = useState( + null + ); + + useEffect(function initialPageLoad() { + setCurrentDataViewTitle(jobCreator.indexPatternTitle); + }, []); + + useEffect( + function stepChange() { + if (step === STEP.PICK_DATA_VIEW) { + setValidationResponse(null); + } + }, + [step] + ); + + function onDataViewSelected(dataViewId: string) { + if (validating === false) { + setStep(STEP.VALIDATE); + validate(dataViewId); + } + } + + const validate = useCallback( + async (dataViewId: string) => { + setValidating(true); + + const { title } = await dataViews.get(dataViewId); + setNewDataViewTitle(title); + + const indices = title.split(','); + if (jobCreator.detectors.length) { + const datafeed: Datafeed = { ...jobCreator.datafeedConfig, indices }; + const resp = await validateDatafeedPreview({ + job: { + ...jobCreator.jobConfig, + datafeed_config: datafeed, + } as CombinedJob, + }); + setValidationResponse(resp); + } + setValidating(false); + }, + [dataViews, validateDatafeedPreview, jobCreator] + ); + + const applyDataView = useCallback(() => { + const newIndices = newDataViewTitle.split(','); + jobCreator.indices = newIndices; + resetAdvancedJob(jobCreator, navigateToPath); + }, [jobCreator, newDataViewTitle, navigateToPath]); + + return ( + <> + + + + + + + + + {step === STEP.PICK_DATA_VIEW && ( + <> + + + + + 'indexPatternApp', + name: i18n.translate( + 'xpack.ml.newJob.wizard.datafeedStep.dataView.step1.dataView', + { + defaultMessage: 'Index pattern', + } + ), + }, + ]} + fixedPageSize={fixedPageSize} + uiSettings={uiSettings} + savedObjects={savedObjects} + /> + + )} + {step === STEP.VALIDATE && ( + <> + + + + + {validating === true ? ( + <> + + + + ) : ( + + )} + + + + + + + + + + + applyDataView()} + isDisabled={validating} + data-test-subj="mlJobsImportButton" + > + + + + + + )} + + + + ); +}; + +const ValidationMessage: FC<{ + validationResponse: DatafeedValidationResponse | null; + dataViewTitle: string; +}> = ({ validationResponse, dataViewTitle }) => { + if (validationResponse === null) { + return ( + + + + ); + } + if (validationResponse.valid === true) { + if (validationResponse.documentsFound === true) { + return ( + + + + ); + } else { + return ( + + + + ); + } + } else { + return ( + + + + + + + + + + {validationResponse.error ? extractErrorMessage(validationResponse.error) : null} + + ); + } +}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/change_data_view_button.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/change_data_view_button.tsx new file mode 100644 index 0000000000000..dc9af26236d8c --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/change_data_view_button.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiButtonEmpty } from '@elastic/eui'; +import { Description } from './description'; +import { ChangeDataViewModal } from './change_data_view'; + +export const ChangeDataView: FC<{ isDisabled: boolean }> = ({ isDisabled }) => { + const [showFlyout, setShowFlyout] = useState(false); + + return ( + <> + {showFlyout && } + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/description.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/description.tsx new file mode 100644 index 0000000000000..2632660738a58 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/description.tsx @@ -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 React, { memo, FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +export const Description: FC = memo(({ children }) => { + const title = i18n.translate('xpack.ml.newJob.wizard.datafeedStep.dataView.title', { + defaultMessage: 'Index pattern', + }); + return ( + {title}} + description={ + + } + > + + <>{children} + + + ); +}); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/index.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/index.ts new file mode 100644 index 0000000000000..ef7c451b4889c --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/data_view/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 { ChangeDataView } from './change_data_view_button'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/datafeed.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/datafeed.tsx index 77db2eb2419cd..47e488ab201ec 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/datafeed.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/datafeed.tsx @@ -14,6 +14,7 @@ import { FrequencyInput } from './components/frequency'; import { ScrollSizeInput } from './components/scroll_size'; import { ResetQueryButton } from './components/reset_query'; import { TimeField } from './components/time_field'; +import { ChangeDataView } from './components/data_view'; import { WIZARD_STEPS, StepProps } from '../step_types'; import { JobCreatorContext } from '../job_creator_context'; import { JsonEditorFlyout, EDITOR_MODE } from '../common/json_editor_flyout'; @@ -46,6 +47,7 @@ export const DatafeedStep: FC = ({ setCurrentStep, isCurrentStep }) = + 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 883e5d499c3d4..720e54e386cbc 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 @@ -43,6 +43,7 @@ import type { FieldHistogramRequestConfig } from '../../datavisualizer/index_bas import type { DataRecognizerConfigResponse, Module } from '../../../../common/types/modules'; import { getHttp } from '../../util/dependency_cache'; import type { RuntimeMappings } from '../../../../common/types/fields'; +import type { DatafeedValidationResponse } from '../../../../common/types/job_validation'; export interface MlInfoResponse { defaults: MlServerDefaults; @@ -194,7 +195,7 @@ export function mlApiServicesProvider(httpService: HttpService) { }, validateJob(payload: { - job: Job; + job: CombinedJob; duration: { start?: number; end?: number; @@ -209,6 +210,15 @@ export function mlApiServicesProvider(httpService: HttpService) { }); }, + validateDatafeedPreview(payload: { job: CombinedJob }) { + const body = JSON.stringify(payload); + return httpService.http({ + path: `${basePath()}/validate/datafeed_preview`, + method: 'POST', + body, + }); + }, + validateCardinality$(job: CombinedJob): Observable { const body = JSON.stringify(job); return httpService.http$({ diff --git a/x-pack/plugins/ml/server/models/job_validation/index.ts b/x-pack/plugins/ml/server/models/job_validation/index.ts index 92d3e7d613efc..a527b9dcf3d4b 100644 --- a/x-pack/plugins/ml/server/models/job_validation/index.ts +++ b/x-pack/plugins/ml/server/models/job_validation/index.ts @@ -7,3 +7,7 @@ export { validateJob } from './job_validation'; export { validateCardinality } from './validate_cardinality'; +export { + validateDatafeedPreviewWithMessages, + validateDatafeedPreview, +} from './validate_datafeed_preview'; diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.ts index 838f188455d44..4cd2d8a95ee79 100644 --- a/x-pack/plugins/ml/server/models/job_validation/job_validation.ts +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.ts @@ -17,7 +17,7 @@ import { basicJobValidation, uniqWithIsEqual } from '../../../common/util/job_ut import { validateBucketSpan } from './validate_bucket_span'; import { validateCardinality } from './validate_cardinality'; import { validateInfluencers } from './validate_influencers'; -import { validateDatafeedPreview } from './validate_datafeed_preview'; +import { validateDatafeedPreviewWithMessages } from './validate_datafeed_preview'; import { validateModelMemoryLimit } from './validate_model_memory_limit'; import { validateTimeRange, isValidTimeField } from './validate_time_range'; import { validateJobSchema } from '../../routes/schemas/job_validation_schema'; @@ -111,7 +111,9 @@ export async function validateJob( validationMessages.push({ id: 'missing_summary_count_field_name' }); } - validationMessages.push(...(await validateDatafeedPreview(mlClient, authHeader, job))); + validationMessages.push( + ...(await validateDatafeedPreviewWithMessages(mlClient, authHeader, job)) + ); } else { validationMessages = basicValidation.messages; validationMessages.push({ id: 'skipped_extended_tests' }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_datafeed_preview.ts b/x-pack/plugins/ml/server/models/job_validation/validate_datafeed_preview.ts index 4ae94229a930b..0775de7ae0e13 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_datafeed_preview.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_datafeed_preview.ts @@ -9,12 +9,25 @@ import type { MlClient } from '../../lib/ml_client'; import type { AuthorizationHeader } from '../../lib/request_authorization'; import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import type { JobValidationMessage } from '../../../common/constants/messages'; +import type { DatafeedValidationResponse } from '../../../common/types/job_validation'; -export async function validateDatafeedPreview( +export async function validateDatafeedPreviewWithMessages( mlClient: MlClient, authHeader: AuthorizationHeader, job: CombinedJob ): Promise { + const { valid, documentsFound } = await validateDatafeedPreview(mlClient, authHeader, job); + if (valid) { + return documentsFound ? [] : [{ id: 'datafeed_preview_no_documents' }]; + } + return [{ id: 'datafeed_preview_failed' }]; +} + +export async function validateDatafeedPreview( + mlClient: MlClient, + authHeader: AuthorizationHeader, + job: CombinedJob +): Promise { const { datafeed_config: datafeed, ...tempJob } = job; try { const { body } = (await mlClient.previewDatafeed( @@ -28,11 +41,15 @@ export async function validateDatafeedPreview( // previewDatafeed response type is incorrect )) as unknown as { body: unknown[] }; - if (Array.isArray(body) === false || body.length === 0) { - return [{ id: 'datafeed_preview_no_documents' }]; - } - return []; + return { + valid: true, + documentsFound: Array.isArray(body) && body.length > 0, + }; } catch (error) { - return [{ id: 'datafeed_preview_failed' }]; + return { + valid: false, + documentsFound: false, + error: error.body ?? error, + }; } } diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 7f53ebb92b68a..226b69e06b48a 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -123,11 +123,13 @@ "GetJobAuditMessages", "GetAllJobAuditMessages", "ClearJobAuditMessages", + "JobValidation", "EstimateBucketSpan", "CalculateModelMemoryLimit", "ValidateCardinality", "ValidateJob", + "ValidateDataFeedPreview", "DatafeedService", "CreateDatafeed", diff --git a/x-pack/plugins/ml/server/routes/job_validation.ts b/x-pack/plugins/ml/server/routes/job_validation.ts index b75eab20e7bc0..bceb59fa33fc6 100644 --- a/x-pack/plugins/ml/server/routes/job_validation.ts +++ b/x-pack/plugins/ml/server/routes/job_validation.ts @@ -16,12 +16,18 @@ import { modelMemoryLimitSchema, validateCardinalitySchema, validateJobSchema, + validateDatafeedPreviewSchema, } from './schemas/job_validation_schema'; import { estimateBucketSpanFactory } from '../models/bucket_span_estimator'; import { calculateModelMemoryLimitProvider } from '../models/calculate_model_memory_limit'; -import { validateJob, validateCardinality } from '../models/job_validation'; +import { + validateJob, + validateCardinality, + validateDatafeedPreview, +} from '../models/job_validation'; import { getAuthorizationHeader } from '../lib/request_authorization'; import type { MlClient } from '../lib/ml_client'; +import { CombinedJob } from '../../common/types/anomaly_detection_jobs'; type CalculateModelMemoryLimitPayload = TypeOf; @@ -205,4 +211,40 @@ export function jobValidationRoutes({ router, mlLicense, routeGuard }: RouteInit } }) ); + + /** + * @apiGroup DataFeedPreviewValidation + * + * @api {post} /api/ml/validate/datafeed_preview Validates datafeed preview + * @apiName ValidateDataFeedPreview + * @apiDescription Validates that the datafeed preview runs successfully and produces results + * + * @apiSchema (body) validateDatafeedPreviewSchema + */ + router.post( + { + path: '/api/ml/validate/datafeed_preview', + validate: { + body: validateDatafeedPreviewSchema, + }, + options: { + tags: ['access:ml:canCreateJob'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { + try { + const resp = await validateDatafeedPreview( + mlClient, + getAuthorizationHeader(request), + request.body.job as CombinedJob + ); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); } 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 a83bbbff6cec9..a481713f67359 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 @@ -60,6 +60,13 @@ export const validateJobSchema = schema.object({ }), }); +export const validateDatafeedPreviewSchema = schema.object({ + job: schema.object({ + ...anomalyDetectionJobSchema, + datafeed_config: datafeedConfigSchema, + }), +}); + export const validateCardinalitySchema = schema.object({ ...anomalyDetectionJobSchema, datafeed_config: datafeedConfigSchema, diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts index 055b4b69ab7a6..e7ea71863352e 100644 --- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts @@ -130,7 +130,7 @@ export default ({ getService }: FtrProviderContext) => { const destinationIndex = generateDestinationIndex(analyticsId); before(async () => { - await ml.api.createIndices(destinationIndex); + await ml.api.createIndex(destinationIndex); await ml.api.assertIndicesExist(destinationIndex); }); @@ -189,7 +189,7 @@ export default ({ getService }: FtrProviderContext) => { before(async () => { // Mimic real job by creating target index & index pattern after DFA job is created - await ml.api.createIndices(destinationIndex); + await ml.api.createIndex(destinationIndex); await ml.api.assertIndicesExist(destinationIndex); await ml.testResources.createIndexPatternIfNeeded(destinationIndex); }); diff --git a/x-pack/test/api_integration/apis/ml/job_validation/datafeed_preview_validation.ts b/x-pack/test/api_integration/apis/ml/job_validation/datafeed_preview_validation.ts new file mode 100644 index 0000000000000..c16050e08c886 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/job_validation/datafeed_preview_validation.ts @@ -0,0 +1,175 @@ +/* + * 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. + */ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; + +const farequoteMappings: estypes.MappingTypeMapping = { + properties: { + '@timestamp': { + type: 'date', + }, + airline: { + type: 'keyword', + }, + responsetime: { + type: 'float', + }, + }, +}; + +function getBaseJobConfig() { + return { + job_id: 'test', + description: '', + analysis_config: { + bucket_span: '15m', + detectors: [ + { + function: 'mean', + field_name: 'responsetime', + }, + ], + influencers: [], + }, + analysis_limits: { + model_memory_limit: '11MB', + }, + data_description: { + time_field: '@timestamp', + time_format: 'epoch_ms', + }, + model_plot_config: { + enabled: false, + annotations_enabled: false, + }, + model_snapshot_retention_days: 10, + daily_model_snapshot_retention_after_days: 1, + allow_lazy_open: false, + datafeed_config: { + query: { + bool: { + must: [ + { + match_all: {}, + }, + ], + }, + }, + indices: ['ft_farequote'], + scroll_size: 1000, + delayed_data_check_config: { + enabled: true, + }, + job_id: 'test', + datafeed_id: 'datafeed-test', + }, + }; +} + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + describe('Validate datafeed preview', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.api.createIndex('farequote_empty', farequoteMappings); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.api.deleteIndices('farequote_empty'); + }); + + it(`should validate a job with documents`, async () => { + const job = getBaseJobConfig(); + + const { body } = await supertest + .post('/api/ml/validate/datafeed_preview') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send({ job }) + .expect(200); + + expect(body.valid).to.eql(true, `valid should be true, but got ${body.valid}`); + expect(body.documentsFound).to.eql( + true, + `documentsFound should be true, but got ${body.documentsFound}` + ); + }); + + it(`should fail to validate a job with documents and non-existent field`, async () => { + const job = getBaseJobConfig(); + job.analysis_config.detectors[0].field_name = 'no_such_field'; + + const { body } = await supertest + .post('/api/ml/validate/datafeed_preview') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send({ job }) + .expect(200); + + expect(body.valid).to.eql(false, `valid should be false, but got ${body.valid}`); + expect(body.documentsFound).to.eql( + false, + `documentsFound should be false, but got ${body.documentsFound}` + ); + }); + + it(`should validate a job with no documents`, async () => { + const job = getBaseJobConfig(); + job.datafeed_config.indices = ['farequote_empty']; + + const { body } = await supertest + .post('/api/ml/validate/datafeed_preview') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send({ job }) + .expect(200); + + expect(body.valid).to.eql(true, `valid should be true, but got ${body.valid}`); + expect(body.documentsFound).to.eql( + false, + `documentsFound should be false, but got ${body.documentsFound}` + ); + }); + + it(`should fail for viewer user`, async () => { + const job = getBaseJobConfig(); + + await supertest + .post('/api/ml/validate/datafeed_preview') + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send({ job }) + .expect(403); + }); + + it(`should fail for unauthorized user`, async () => { + const job = getBaseJobConfig(); + + await supertest + .post('/api/ml/validate/datafeed_preview') + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .send({ job }) + .expect(403); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/job_validation/index.ts b/x-pack/test/api_integration/apis/ml/job_validation/index.ts index 4b75102d7b0bf..be07ae3b1852a 100644 --- a/x-pack/test/api_integration/apis/ml/job_validation/index.ts +++ b/x-pack/test/api_integration/apis/ml/job_validation/index.ts @@ -13,5 +13,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./calculate_model_memory_limit')); loadTestFile(require.resolve('./cardinality')); loadTestFile(require.resolve('./validate')); + loadTestFile(require.resolve('./datafeed_preview_validation')); }); } diff --git a/x-pack/test/functional/services/ml/api.ts b/x-pack/test/functional/services/ml/api.ts index abde3bf365384..6ffd95f213c41 100644 --- a/x-pack/test/functional/services/ml/api.ts +++ b/x-pack/test/functional/services/ml/api.ts @@ -126,14 +126,20 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { ); }, - async createIndices(indices: string) { + async createIndex( + indices: string, + mappings?: Record | estypes.MappingTypeMapping + ) { log.debug(`Creating indices: '${indices}'...`); if ((await es.indices.exists({ index: indices, allow_no_indices: false })).body === true) { log.debug(`Indices '${indices}' already exist. Nothing to create.`); return; } - const { body } = await es.indices.create({ index: indices }); + const { body } = await es.indices.create({ + index: indices, + ...(mappings ? { body: { mappings } } : {}), + }); expect(body) .to.have.property('acknowledged') .eql(true, 'Response for create request indices should be acknowledged.'); From b306f8e2c31c96623d310fdd0dc110e7935b8993 Mon Sep 17 00:00:00 2001 From: DeDe Morton Date: Tue, 19 Oct 2021 08:01:49 -0700 Subject: [PATCH 21/30] Update UI links to Fleet and Agent docs (#115295) * Update UI links to Fleet and Agent docs * Update link service * Fix merge problem * Update link service Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/kibana-plugin-core-public.doclinksstart.links.md | 1 + .../core/public/kibana-plugin-core-public.doclinksstart.md | 2 +- src/core/public/doc_links/doc_links_service.ts | 4 +++- src/core/public/public.api.md | 1 + .../components/enrollment_instructions/manual/index.tsx | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index e79bc7a0db026..73efed79324fe 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -237,6 +237,7 @@ readonly links: { elasticAgent: string; datastreams: string; datastreamsNamingScheme: string; + installElasticAgent: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; learnMoreBlog: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index d90972d327041..fdf469f443f28 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly elasticStackGetStarted: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly customLinks: string;
readonly droppedTransactionSpans: string;
readonly upgrading: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
readonly troubleshootGaps: string;
};
readonly securitySolution: {
readonly trustedApps: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Readonly<{
guide: string;
infrastructureThreshold: string;
logsThreshold: string;
metricsThreshold: string;
monitorStatus: string;
monitorUptime: string;
tlsCertificate: string;
uptimeDurationAnomaly: string;
}>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly spaces: Readonly<{
kibanaLegacyUrlAliases: string;
kibanaDisableLegacyUrlAliasesApi: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
learnMoreBlog: string;
apiKeysLearnMore: string;
}>;
readonly ecs: {
readonly guide: string;
};
readonly clients: {
readonly guide: string;
readonly goOverview: string;
readonly javaIndex: string;
readonly jsIntro: string;
readonly netGuide: string;
readonly perlGuide: string;
readonly phpGuide: string;
readonly pythonGuide: string;
readonly rubyOverview: string;
readonly rustGuide: string;
};
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly elasticStackGetStarted: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly customLinks: string;
readonly droppedTransactionSpans: string;
readonly upgrading: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
readonly troubleshootGaps: string;
};
readonly securitySolution: {
readonly trustedApps: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Readonly<{
guide: string;
infrastructureThreshold: string;
logsThreshold: string;
metricsThreshold: string;
monitorStatus: string;
monitorUptime: string;
tlsCertificate: string;
uptimeDurationAnomaly: string;
}>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly spaces: Readonly<{
kibanaLegacyUrlAliases: string;
kibanaDisableLegacyUrlAliasesApi: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
installElasticAgent: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
learnMoreBlog: string;
apiKeysLearnMore: string;
}>;
readonly ecs: {
readonly guide: string;
};
readonly clients: {
readonly guide: string;
readonly goOverview: string;
readonly javaIndex: string;
readonly jsIntro: string;
readonly netGuide: string;
readonly perlGuide: string;
readonly phpGuide: string;
readonly pythonGuide: string;
readonly rubyOverview: string;
readonly rustGuide: string;
};
} | | diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index a07e12eae8d71..91ad185447986 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -477,9 +477,10 @@ export class DocLinksService { settings: `${FLEET_DOCS}fleet-settings.html#fleet-server-hosts-setting`, settingsFleetServerHostSettings: `${FLEET_DOCS}fleet-settings.html#fleet-server-hosts-setting`, troubleshooting: `${FLEET_DOCS}fleet-troubleshooting.html`, - elasticAgent: `${FLEET_DOCS}elastic-agent-installation-configuration.html`, + elasticAgent: `${FLEET_DOCS}elastic-agent-installation.html`, datastreams: `${FLEET_DOCS}data-streams.html`, datastreamsNamingScheme: `${FLEET_DOCS}data-streams.html#data-streams-naming-scheme`, + installElasticAgent: `${FLEET_DOCS}install-fleet-managed-elastic-agent.html`, upgradeElasticAgent: `${FLEET_DOCS}upgrade-elastic-agent.html`, upgradeElasticAgent712lower: `${FLEET_DOCS}upgrade-elastic-agent.html#upgrade-7.12-lower`, learnMoreBlog: `${ELASTIC_WEBSITE_URL}blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic`, @@ -740,6 +741,7 @@ export interface DocLinksStart { elasticAgent: string; datastreams: string; datastreamsNamingScheme: string; + installElasticAgent: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; learnMoreBlog: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 4c7f8aab5b767..508299686b0d9 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -706,6 +706,7 @@ export interface DocLinksStart { elasticAgent: string; datastreams: string; datastreamsNamingScheme: string; + installElasticAgent: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; learnMoreBlog: string; diff --git a/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx index ecbcf309c5992..6d4d6a7172534 100644 --- a/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx @@ -78,7 +78,7 @@ export const ManualInstructions: React.FunctionComponent = ({ defaultMessage="See the {link} for RPM / DEB deploy instructions." values={{ link: ( - + Date: Tue, 19 Oct 2021 17:15:52 +0200 Subject: [PATCH 22/30] [Transform] Add alerting rules management to Transform UI (#115363) * transform alert flyout * fetch alerting rules * show alerting rules indicators * filter continuous transforms * add alert rules to the expanded row * edit alert rule from the list * fix ts issues * fix types * update texts * refactor using context, wip create alert from the list * update unit test * fix ts issue * privilege check --- .../common/api_schemas/transforms.ts | 4 +- .../transform/common/types/alerting.ts | 4 +- .../transform/common/types/transform.ts | 17 ++- .../alerting/transform_alerting_flyout.tsx | 127 ++++++++++++++++++ .../public/app/__mocks__/app_dependencies.tsx | 2 + .../transform/public/app/app_dependencies.tsx | 2 + .../public/app/common/transform_list.ts | 9 +- .../public/app/hooks/use_get_transforms.ts | 1 + .../components/authorization_provider.tsx | 12 +- .../lib/authorization/components/common.ts | 10 ++ .../public/app/mount_management_section.ts | 3 +- .../clone_transform_section.tsx | 4 +- .../step_create/step_create_form.tsx | 38 +++++- .../components/step_details/common.ts | 4 +- .../step_details/step_details_form.tsx | 4 +- .../components/wizard/wizard.tsx | 4 +- .../create_alert_rule_action_name.tsx | 38 ++++++ .../components/action_create_alert/index.ts | 8 ++ .../use_create_alert_rule_action.tsx | 47 +++++++ .../transform_list/expanded_row.test.tsx | 3 +- .../transform_list/expanded_row.tsx | 52 +++++-- .../expanded_row_details_pane.tsx | 5 +- .../transform_list/transform_list.tsx | 14 +- .../transform_list/use_actions.test.tsx | 1 + .../components/transform_list/use_actions.tsx | 3 + .../transform_list/use_columns.test.tsx | 15 ++- .../components/transform_list/use_columns.tsx | 34 +++++ .../transform_management_section.tsx | 20 ++- x-pack/plugins/transform/public/plugin.ts | 4 +- .../transform_health_service.ts | 97 ++++++++++--- .../transform/server/routes/api/transforms.ts | 12 ++ .../transform/server/services/license.ts | 9 +- .../services/endpoint.ts | 6 +- 33 files changed, 539 insertions(+), 74 deletions(-) create mode 100644 x-pack/plugins/transform/public/alerting/transform_alerting_flyout.tsx create mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/create_alert_rule_action_name.tsx create mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/index.ts create mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/use_create_alert_rule_action.tsx diff --git a/x-pack/plugins/transform/common/api_schemas/transforms.ts b/x-pack/plugins/transform/common/api_schemas/transforms.ts index 7fb1a62a67bb8..8867ecb5cc760 100644 --- a/x-pack/plugins/transform/common/api_schemas/transforms.ts +++ b/x-pack/plugins/transform/common/api_schemas/transforms.ts @@ -12,7 +12,7 @@ import type { ES_FIELD_TYPES } from '../../../../../src/plugins/data/common'; import type { Dictionary } from '../types/common'; import type { PivotAggDict } from '../types/pivot_aggs'; import type { PivotGroupByDict } from '../types/pivot_group_by'; -import type { TransformId, TransformPivotConfig } from '../types/transform'; +import type { TransformId, TransformConfigUnion } from '../types/transform'; import { transformStateSchema, runtimeMappingsSchema } from './common'; @@ -33,7 +33,7 @@ export type GetTransformsRequestSchema = TypeOf; + +export type TransformHealthAlertRule = Omit, 'apiKey'>; diff --git a/x-pack/plugins/transform/common/types/transform.ts b/x-pack/plugins/transform/common/types/transform.ts index f1e7efdadca9d..a478946ff917c 100644 --- a/x-pack/plugins/transform/common/types/transform.ts +++ b/x-pack/plugins/transform/common/types/transform.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types'; +import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types'; import type { LatestFunctionConfig, PutTransformsRequestSchema } from '../api_schemas/transforms'; import { isPopulatedObject } from '../shared_imports'; -import { PivotGroupByDict } from './pivot_group_by'; -import { PivotAggDict } from './pivot_aggs'; +import type { PivotGroupByDict } from './pivot_group_by'; +import type { PivotAggDict } from './pivot_aggs'; +import type { TransformHealthAlertRule } from './alerting'; export type IndexName = string; export type IndexPattern = string; @@ -22,6 +23,7 @@ export type TransformBaseConfig = PutTransformsRequestSchema & { id: TransformId; create_time?: number; version?: string; + alerting_rules?: TransformHealthAlertRule[]; }; export interface PivotConfigDefinition { @@ -45,6 +47,11 @@ export type TransformLatestConfig = Omit & { export type TransformConfigUnion = TransformPivotConfig | TransformLatestConfig; +export type ContinuousTransform = Omit & + Required<{ + sync: TransformConfigUnion['sync']; + }>; + export function isPivotTransform(transform: unknown): transform is TransformPivotConfig { return isPopulatedObject(transform, ['pivot']); } @@ -53,6 +60,10 @@ export function isLatestTransform(transform: unknown): transform is TransformLat return isPopulatedObject(transform, ['latest']); } +export function isContinuousTransform(transform: unknown): transform is ContinuousTransform { + return isPopulatedObject(transform, ['sync']); +} + export interface LatestFunctionConfigUI { unique_key: Array> | undefined; sort: EuiComboBoxOptionOption | undefined; diff --git a/x-pack/plugins/transform/public/alerting/transform_alerting_flyout.tsx b/x-pack/plugins/transform/public/alerting/transform_alerting_flyout.tsx new file mode 100644 index 0000000000000..63d00f280f3f3 --- /dev/null +++ b/x-pack/plugins/transform/public/alerting/transform_alerting_flyout.tsx @@ -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 React, { createContext, FC, useContext, useMemo } from 'react'; +import { memoize } from 'lodash'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { pluck } from 'rxjs/operators'; +import useObservable from 'react-use/lib/useObservable'; +import { useAppDependencies } from '../app/app_dependencies'; +import { TransformHealthAlertRule, TransformHealthRuleParams } from '../../common/types/alerting'; +import { TRANSFORM_RULE_TYPE } from '../../common'; + +interface TransformAlertFlyoutProps { + initialAlert?: TransformHealthAlertRule | null; + ruleParams?: TransformHealthRuleParams | null; + onSave?: () => void; + onCloseFlyout: () => void; +} + +export const TransformAlertFlyout: FC = ({ + initialAlert, + ruleParams, + onCloseFlyout, + onSave, +}) => { + const { triggersActionsUi } = useAppDependencies(); + + const AlertFlyout = useMemo(() => { + if (!triggersActionsUi) return; + + const commonProps = { + onClose: () => { + onCloseFlyout(); + }, + onSave: async () => { + if (onSave) { + onSave(); + } + }, + }; + + if (initialAlert) { + return triggersActionsUi.getEditAlertFlyout({ + ...commonProps, + initialAlert, + }); + } + + return triggersActionsUi.getAddAlertFlyout({ + ...commonProps, + consumer: 'stackAlerts', + canChangeTrigger: false, + alertTypeId: TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH, + metadata: {}, + initialValues: { + params: ruleParams!, + }, + }); + // deps on id to avoid re-rendering on auto-refresh + }, [triggersActionsUi, initialAlert, ruleParams, onCloseFlyout, onSave]); + + return <>{AlertFlyout}; +}; + +interface AlertRulesManage { + editAlertRule$: Observable; + createAlertRule$: Observable; + setEditAlertRule: (alertRule: TransformHealthAlertRule) => void; + setCreateAlertRule: (transformId: string) => void; + hideAlertFlyout: () => void; +} + +export const getAlertRuleManageContext = memoize(function (): AlertRulesManage { + const ruleState$ = new BehaviorSubject<{ + editAlertRule: null | TransformHealthAlertRule; + createAlertRule: null | TransformHealthRuleParams; + }>({ + editAlertRule: null, + createAlertRule: null, + }); + return { + editAlertRule$: ruleState$.pipe(pluck('editAlertRule')), + createAlertRule$: ruleState$.pipe(pluck('createAlertRule')), + setEditAlertRule: (initialRule) => { + ruleState$.next({ + createAlertRule: null, + editAlertRule: initialRule, + }); + }, + setCreateAlertRule: (transformId: string) => { + ruleState$.next({ + createAlertRule: { includeTransforms: [transformId] }, + editAlertRule: null, + }); + }, + hideAlertFlyout: () => { + ruleState$.next({ + createAlertRule: null, + editAlertRule: null, + }); + }, + }; +}); + +export const AlertRulesManageContext = createContext(getAlertRuleManageContext()); + +export function useAlertRuleFlyout(): AlertRulesManage { + return useContext(AlertRulesManageContext); +} + +export const TransformAlertFlyoutWrapper = () => { + const { editAlertRule$, createAlertRule$, hideAlertFlyout } = useAlertRuleFlyout(); + const editAlertRule = useObservable(editAlertRule$); + const createAlertRule = useObservable(createAlertRule$); + + return editAlertRule || createAlertRule ? ( + + ) : null; +}; diff --git a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx index 8dc0e277c284d..ab38d05ec9f8f 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -19,6 +19,7 @@ import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; import type { AppDependencies } from '../app_dependencies'; import { MlSharedContext } from './shared_context'; import type { GetMlSharedImportsReturnType } from '../../shared_imports'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public'; const coreSetup = coreMock.createSetup(); const coreStart = coreMock.createStart(); @@ -43,6 +44,7 @@ const appDependencies: AppDependencies = { savedObjectsPlugin: savedObjectsPluginMock.createStartContract(), share: { urlGenerators: { getUrlGenerator: jest.fn() } } as unknown as SharePluginStart, ml: {} as GetMlSharedImportsReturnType, + triggersActionsUi: {} as jest.Mocked, }; export const useAppDependencies = () => { diff --git a/x-pack/plugins/transform/public/app/app_dependencies.tsx b/x-pack/plugins/transform/public/app/app_dependencies.tsx index d3f356f3e83b3..da1178e395720 100644 --- a/x-pack/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/app_dependencies.tsx @@ -16,6 +16,7 @@ import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import type { Storage } from '../../../../../src/plugins/kibana_utils/public'; import type { GetMlSharedImportsReturnType } from '../shared_imports'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions_ui/public'; export interface AppDependencies { application: CoreStart['application']; @@ -34,6 +35,7 @@ export interface AppDependencies { share: SharePluginStart; ml: GetMlSharedImportsReturnType; spaces?: SpacesPluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } export const useAppDependencies = () => { diff --git a/x-pack/plugins/transform/public/app/common/transform_list.ts b/x-pack/plugins/transform/public/app/common/transform_list.ts index d73018e284a8c..c8ddd32f6a8ff 100644 --- a/x-pack/plugins/transform/public/app/common/transform_list.ts +++ b/x-pack/plugins/transform/public/app/common/transform_list.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { EuiTableActionsColumnType } from '@elastic/eui'; - -import { TransformConfigUnion, TransformId } from '../../../common/types/transform'; -import { TransformStats } from '../../../common/types/transform_stats'; +import type { EuiTableActionsColumnType } from '@elastic/eui'; +import type { TransformConfigUnion, TransformId } from '../../../common/types/transform'; +import type { TransformStats } from '../../../common/types/transform_stats'; +import type { TransformHealthAlertRule } from '../../../common/types/alerting'; // Used to pass on attribute names to table columns export enum TRANSFORM_LIST_COLUMN { @@ -21,6 +21,7 @@ export interface TransformListRow { config: TransformConfigUnion; mode?: string; // added property on client side to allow filtering by this field stats: TransformStats; + alerting_rules?: TransformHealthAlertRule[]; } // The single Action type is not exported as is 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 2d3425dfeedca..7879e15118a33 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 @@ -87,6 +87,7 @@ export const useGetTransforms = ( mode: typeof config.sync !== 'undefined' ? TRANSFORM_MODE.CONTINUOUS : TRANSFORM_MODE.BATCH, stats, + alerting_rules: config.alerting_rules, }); return reducedtableRows; }, [] as TransformListRow[]); diff --git a/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx b/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx index 875c0f60969ed..cc6313bf058c6 100644 --- a/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx @@ -20,12 +20,14 @@ interface Authorization { capabilities: Capabilities; } -const initialCapabalities: Capabilities = { +const initialCapabilities: Capabilities = { canGetTransform: false, canDeleteTransform: false, canPreviewTransform: false, canCreateTransform: false, canStartStopTransform: false, + canCreateTransformAlerts: false, + canUseTransformAlerts: false, }; const initialValue: Authorization = { @@ -35,7 +37,7 @@ const initialValue: Authorization = { hasAllPrivileges: false, missingPrivileges: {}, }, - capabilities: initialCapabalities, + capabilities: initialCapabilities, }; export const AuthorizationContext = createContext({ ...initialValue }); @@ -58,7 +60,7 @@ export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) = const value = { isLoading, privileges: isLoading ? { ...initialValue.privileges } : privilegesData, - capabilities: { ...initialCapabalities }, + capabilities: { ...initialCapabilities }, apiError: error ? (error as Error) : null, }; @@ -85,6 +87,10 @@ export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) = hasPrivilege(['cluster', 'cluster:admin/transform/start_task']) && hasPrivilege(['cluster', 'cluster:admin/transform/stop']); + value.capabilities.canCreateTransformAlerts = value.capabilities.canCreateTransform; + + value.capabilities.canUseTransformAlerts = value.capabilities.canGetTransform; + return ( {children} ); diff --git a/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts b/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts index d059f73a76137..d430a4d059e5c 100644 --- a/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts @@ -16,6 +16,8 @@ export interface Capabilities { canPreviewTransform: boolean; canCreateTransform: boolean; canStartStopTransform: boolean; + canCreateTransformAlerts: boolean; + canUseTransformAlerts: boolean; } export type Privilege = [string, string]; @@ -67,6 +69,14 @@ export function createCapabilityFailureMessage( defaultMessage: 'You do not have permission to create transforms.', }); break; + case 'canCreateTransformAlerts': + message = i18n.translate( + 'xpack.transform.capability.noPermission.canCreateTransformAlertsTooltip', + { + defaultMessage: 'You do not have permission to create transform alert rules.', + } + ); + break; case 'canStartStopTransform': message = i18n.translate( 'xpack.transform.capability.noPermission.startOrStopTransformTooltip', diff --git a/x-pack/plugins/transform/public/app/mount_management_section.ts b/x-pack/plugins/transform/public/app/mount_management_section.ts index 1747330818547..6e63094064584 100644 --- a/x-pack/plugins/transform/public/app/mount_management_section.ts +++ b/x-pack/plugins/transform/public/app/mount_management_section.ts @@ -29,7 +29,7 @@ export async function mountManagementSection( const startServices = await getStartServices(); const [core, plugins] = startServices; const { application, chrome, docLinks, i18n, overlays, savedObjects, uiSettings } = core; - const { data, share, spaces } = plugins; + const { data, share, spaces, triggersActionsUi } = plugins; const { docTitle } = chrome; // Initialize services @@ -55,6 +55,7 @@ export async function mountManagementSection( share, spaces, ml: await getMlSharedImports(), + triggersActionsUi, }; const unmountAppCallback = renderApp(element, appDependencies); diff --git a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index 8aecf403186c5..218edb95c5f4f 100644 --- a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -21,7 +21,7 @@ import { } from '@elastic/eui'; import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; -import { TransformPivotConfig } from '../../../../common/types/transform'; +import { TransformConfigUnion } from '../../../../common/types/transform'; import { isHttpFetchError } from '../../common/request'; import { useApi } from '../../hooks/use_api'; @@ -50,7 +50,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { const transformId = match.params.transformId; - const [transformConfig, setTransformConfig] = useState(); + const [transformConfig, setTransformConfig] = useState(); const [errorMessage, setErrorMessage] = useState(); const [isInitialized, setIsInitialized] = useState(false); const { error: searchItemsError, searchItems, setSavedObjectId } = useSearchItems(undefined); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 7ccf986d5d497..859ea77ea5a14 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -23,6 +23,7 @@ import { EuiText, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; import { @@ -52,7 +53,8 @@ import { } from '../../../../../../common/api_schemas/transforms'; import type { RuntimeField } from '../../../../../../../../../src/plugins/data/common'; import { isPopulatedObject } from '../../../../../../common/shared_imports'; -import { isLatestTransform } from '../../../../../../common/types/transform'; +import { isContinuousTransform, isLatestTransform } from '../../../../../../common/types/transform'; +import { TransformAlertFlyout } from '../../../../../alerting/transform_alerting_flyout'; export interface StepDetailsExposedState { created: boolean; @@ -86,6 +88,7 @@ export const StepCreateForm: FC = React.memo( const [loading, setLoading] = useState(false); const [created, setCreated] = useState(defaults.created); const [started, setStarted] = useState(defaults.started); + const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false); const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId); const [progressPercentComplete, setProgressPercentComplete] = useState( undefined @@ -398,6 +401,31 @@ export const StepCreateForm: FC = React.memo( )} + {isContinuousTransform(transformConfig) && created ? ( + + + + + + + + + {i18n.translate('xpack.transform.stepCreateForm.createAlertRuleDescription', { + defaultMessage: + 'Opens a wizard to create an alert rule for monitoring transform health.', + })} + + + + ) : null} = React.memo( {i18n.translate('xpack.transform.stepCreateForm.createTransformDescription', { defaultMessage: - 'Create the transform without starting it. You will be able to start the transform later by returning to the transforms list.', + 'Creates the transform without starting it. You will be able to start the transform later by returning to the transforms list.', })} @@ -535,6 +563,12 @@ export const StepCreateForm: FC = React.memo( )} + {alertFlyoutVisible ? ( + + ) : null}
); } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts index fbe32e9bea12f..39b1a2de26f8e 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { TransformId, TransformPivotConfig } from '../../../../../../common/types/transform'; +import type { TransformConfigUnion, TransformId } from '../../../../../../common/types/transform'; export type EsIndexName = string; export type IndexPatternTitle = string; @@ -55,7 +55,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { export function applyTransformConfigToDetailsState( state: StepDetailsExposedState, - transformConfig?: TransformPivotConfig + transformConfig?: TransformConfigUnion ): StepDetailsExposedState { // apply the transform configuration to wizard DETAILS state if (transformConfig !== undefined) { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 0d39ec77d059f..7a47cc539c4aa 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -29,7 +29,7 @@ import { isEsIndices, isPostTransformsPreviewResponseSchema, } from '../../../../../../common/api_schemas/type_guards'; -import { TransformId, TransformPivotConfig } from '../../../../../../common/types/transform'; +import { TransformId } from '../../../../../../common/types/transform'; import { isValidIndexName } from '../../../../../../common/utils/es_utils'; import { getErrorMessage } from '../../../../../../common/utils/errors'; @@ -158,7 +158,7 @@ export const StepDetailsForm: FC = React.memo( ), }); } else { - setTransformIds(resp.transforms.map((transform: TransformPivotConfig) => transform.id)); + setTransformIds(resp.transforms.map((transform) => transform.id)); } const indices = await api.getEsIndices(); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 63e21e5d8aa14..27c43ed01a934 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiSteps, EuiStepStatus } from '@elastic/eui'; -import { TransformPivotConfig } from '../../../../../../common/types/transform'; +import type { TransformConfigUnion } from '../../../../../../common/types/transform'; import { getCreateTransformRequestBody } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; @@ -81,7 +81,7 @@ const StepDefine: FC = ({ }; interface WizardProps { - cloneConfig?: TransformPivotConfig; + cloneConfig?: TransformConfigUnion; searchItems: SearchItems; } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/create_alert_rule_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/create_alert_rule_action_name.tsx new file mode 100644 index 0000000000000..c8d67a86d579a --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/create_alert_rule_action_name.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { EuiToolTip } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { createCapabilityFailureMessage } from '../../../../lib/authorization'; + +interface CreateAlertRuleActionProps { + disabled: boolean; +} + +export const crateAlertRuleActionNameText = i18n.translate( + 'xpack.transform.transformList.createAlertRuleNameText', + { + defaultMessage: 'Create alert rule', + } +); + +export const CreateAlertRuleActionName: FC = ({ disabled }) => { + if (disabled) { + return ( + + <>{crateAlertRuleActionNameText} + + ); + } + + return <>{crateAlertRuleActionNameText}; +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/index.ts new file mode 100644 index 0000000000000..80999d774bdcb --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/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 { useCreateAlertRuleAction } from './use_create_alert_rule_action'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/use_create_alert_rule_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/use_create_alert_rule_action.tsx new file mode 100644 index 0000000000000..070f1eb08ac60 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/use_create_alert_rule_action.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useContext, useMemo } from 'react'; +import { AuthorizationContext } from '../../../../lib/authorization'; +import { TransformListAction, TransformListRow } from '../../../../common'; +import { + crateAlertRuleActionNameText, + CreateAlertRuleActionName, +} from './create_alert_rule_action_name'; +import { useAlertRuleFlyout } from '../../../../../alerting/transform_alerting_flyout'; +import { isContinuousTransform } from '../../../../../../common/types/transform'; + +export type CreateAlertRuleAction = ReturnType; +export const useCreateAlertRuleAction = (forceDisable: boolean) => { + const { canCreateTransformAlerts } = useContext(AuthorizationContext).capabilities; + const { setCreateAlertRule } = useAlertRuleFlyout(); + + const clickHandler = useCallback( + (item: TransformListRow) => { + setCreateAlertRule(item.id); + }, + [setCreateAlertRule] + ); + + const action: TransformListAction = useMemo( + () => ({ + name: (item: TransformListRow) => ( + + ), + available: (item: TransformListRow) => isContinuousTransform(item.config), + enabled: () => canCreateTransformAlerts && !forceDisable, + description: crateAlertRuleActionNameText, + type: 'icon', + icon: 'bell', + onClick: clickHandler, + 'data-test-subj': 'transformActionCreateAlertRule', + }), + [canCreateTransformAlerts, forceDisable, clickHandler] + ); + + return { action }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx index bccd3aff72c58..af85049ce6915 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx @@ -22,6 +22,7 @@ import { getMlSharedImports } from '../../../../../shared_imports'; // FLAKY https://github.com/elastic/kibana/issues/112922 describe.skip('Transform: Transform List ', () => { + const onAlertEdit = jest.fn(); // Set timezone to US/Eastern for consistent test results. beforeEach(() => { moment.tz.setDefault('US/Eastern'); @@ -38,7 +39,7 @@ describe.skip('Transform: Transform List ', () => { render( - + ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx index dff2ba17cb3f0..84110e67d701e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx @@ -7,17 +7,18 @@ import React, { FC } from 'react'; -import { EuiTabbedContent } from '@elastic/eui'; +import { EuiButtonEmpty, EuiTabbedContent } from '@elastic/eui'; import { Optional } from '@kbn/utility-types'; import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; import { TransformListRow } from '../../../../common'; import { useAppDependencies } from '../../../../app_dependencies'; -import { ExpandedRowDetailsPane, SectionConfig } from './expanded_row_details_pane'; +import { ExpandedRowDetailsPane, SectionConfig, SectionItem } from './expanded_row_details_pane'; import { ExpandedRowJsonPane } from './expanded_row_json_pane'; import { ExpandedRowMessagesPane } from './expanded_row_messages_pane'; import { ExpandedRowPreviewPane } from './expanded_row_preview_pane'; +import { TransformHealthAlertRule } from '../../../../../../common/types/alerting'; function getItemDescription(value: any) { if (typeof value === 'object') { @@ -44,18 +45,16 @@ export function stringHash(str: string): number { return hash < 0 ? hash * -2 : hash; } -interface Item { - title: string; - description: any; -} +type Item = SectionItem; interface Props { item: TransformListRow; + onAlertEdit: (alertRule: TransformHealthAlertRule) => void; } type StateValues = Optional; -export const ExpandedRow: FC = ({ item }) => { +export const ExpandedRow: FC = ({ item, onAlertEdit }) => { const { ml: { formatHumanReadableDateTimeSeconds }, } = useAppDependencies(); @@ -166,12 +165,40 @@ export const ExpandedRow: FC = ({ item }) => { } } + const alertRuleItems: Item[] | undefined = item.alerting_rules?.map((rule) => { + return { + title: ( + { + onAlertEdit(rule); + }} + flush="left" + size={'xs'} + iconSize={'s'} + > + {rule.name} + + ), + description: rule.executionStatus.status, + }; + }); + const checkpointing: SectionConfig = { title: 'Checkpointing', items: checkpointingItems, position: 'right', }; + const alertingRules: SectionConfig = { + title: i18n.translate('xpack.transform.transformList.transformDetails.alertRulesTitle', { + defaultMessage: 'Alert rules', + }), + items: alertRuleItems!, + position: 'right', + }; + const stats: SectionConfig = { title: 'Stats', items: Object.entries(item.stats.stats).map((s) => { @@ -192,7 +219,16 @@ export const ExpandedRow: FC = ({ item }) => { defaultMessage: 'Details', } ), - content: , + content: ( + + ), }, { id: `transform-stats-tab-${tabId}`, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx index 03e2fb2115d62..1b2dde0a2e576 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx @@ -17,9 +17,10 @@ import { } from '@elastic/eui'; export interface SectionItem { - title: string; - description: string; + title: string | JSX.Element; + description: string | number | JSX.Element; } + export interface SectionConfig { title: string; position: 'left' | 'right'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index ab30f4793a315..8b7aaf1cf8fd2 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -50,15 +50,18 @@ import { useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; import { transformFilters, filterTransforms } from './transform_search_bar_filters'; import { useTableSettings } from './use_table_settings'; +import { useAlertRuleFlyout } from '../../../../../alerting/transform_alerting_flyout'; +import { TransformHealthAlertRule } from '../../../../../../common/types/alerting'; function getItemIdToExpandedRowMap( itemIds: TransformId[], - transforms: TransformListRow[] + transforms: TransformListRow[], + onAlertEdit: (alertRule: TransformHealthAlertRule) => void ): ItemIdToExpandedRowMap { return itemIds.reduce((m: ItemIdToExpandedRowMap, transformId: TransformId) => { const item = transforms.find((transform) => transform.config.id === transformId); if (item !== undefined) { - m[transformId] = ; + m[transformId] = ; } return m; }, {} as ItemIdToExpandedRowMap); @@ -79,6 +82,7 @@ export const TransformList: FC = ({ }) => { const [isLoading, setIsLoading] = useState(false); const { refresh } = useRefreshTransformList({ isLoading: setIsLoading }); + const { setEditAlertRule } = useAlertRuleFlyout(); const [filterActive, setFilterActive] = useState(false); @@ -171,7 +175,11 @@ export const TransformList: FC = ({ ); } - const itemIdToExpandedRowMap = getItemIdToExpandedRowMap(expandedRowItemIds, transforms); + const itemIdToExpandedRowMap = getItemIdToExpandedRowMap( + expandedRowItemIds, + transforms, + setEditAlertRule + ); const bulkActionMenuItems = [
diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx index b7d5a2b7104ae..20d2f784a4d8b 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx @@ -27,6 +27,7 @@ describe('Transform: Transform List Actions', () => { // in the runtime result here anyway. expect(actions.map((a: any) => a['data-test-subj'])).toStrictEqual([ 'transformActionDiscover', + 'transformActionCreateAlertRule', 'transformActionStart', 'transformActionStop', 'transformActionEdit', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx index 81e51cdafc32e..40b40cfa8c7ba 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx @@ -18,6 +18,7 @@ import { EditTransformFlyout } from '../edit_transform_flyout'; import { useEditAction } from '../action_edit'; import { useStartAction, StartActionModal } from '../action_start'; import { useStopAction } from '../action_stop'; +import { useCreateAlertRuleAction } from '../action_create_alert'; export const useActions = ({ forceDisable, @@ -35,6 +36,7 @@ export const useActions = ({ const editAction = useEditAction(forceDisable, transformNodes); const startAction = useStartAction(forceDisable, transformNodes); const stopAction = useStopAction(forceDisable); + const createAlertRuleAction = useCreateAlertRuleAction(forceDisable); return { modals: ( @@ -52,6 +54,7 @@ export const useActions = ({ ), actions: [ discoverAction.action, + createAlertRuleAction.action, startAction.action, stopAction.action, editAction.action, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx index af2325ede2021..a26ccf0348c9a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx @@ -20,14 +20,15 @@ describe('Transform: Job List Columns', () => { const columns: ReturnType['columns'] = result.current.columns; - expect(columns).toHaveLength(8); + expect(columns).toHaveLength(9); expect(columns[0].isExpander).toBeTruthy(); expect(columns[1].name).toBe('ID'); - expect(columns[2].name).toBe('Description'); - expect(columns[3].name).toBe('Type'); - expect(columns[4].name).toBe('Status'); - expect(columns[5].name).toBe('Mode'); - expect(columns[6].name).toBe('Progress'); - expect(columns[7].name).toBe('Actions'); + expect(columns[2].id).toBe('alertRule'); + expect(columns[3].name).toBe('Description'); + expect(columns[4].name).toBe('Type'); + expect(columns[5].name).toBe('Status'); + expect(columns[6].name).toBe('Mode'); + expect(columns[7].name).toBe('Progress'); + expect(columns[8].name).toBe('Actions'); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx index dbdd3409c7e34..bad42c212293d 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx @@ -21,6 +21,7 @@ import { EuiText, EuiToolTip, RIGHT_ALIGNMENT, + EuiIcon, } from '@elastic/eui'; import { @@ -95,6 +96,7 @@ export const useColumns = ( const columns: [ EuiTableComputedColumnType, EuiTableFieldDataColumnType, + EuiTableComputedColumnType, EuiTableFieldDataColumnType, EuiTableComputedColumnType, EuiTableComputedColumnType, @@ -143,6 +145,38 @@ export const useColumns = ( truncateText: true, scope: 'row', }, + { + id: 'alertRule', + name: ( + +

+ +

+
+ ), + width: '30px', + render: (item) => { + return Array.isArray(item.alerting_rules) ? ( + + } + > + + + ) : ( + + ); + }, + }, { field: TRANSFORM_LIST_COLUMN.DESCRIPTION, 'data-test-subj': 'transformListColumnDescription', 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 2479d34f1579a..055e1e50701f8 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 @@ -35,6 +35,11 @@ import { useRefreshInterval } from './components/transform_list/use_refresh_inte import { SearchSelection } from './components/search_selection'; import { TransformList } from './components/transform_list'; import { TransformStatsBar } from './components/transform_list/transforms_stats_bar'; +import { + AlertRulesManageContext, + getAlertRuleManageContext, + TransformAlertFlyoutWrapper, +} from '../../../alerting/transform_alerting_flyout'; export const TransformManagement: FC = () => { const { esTransform } = useDocumentationLinks(); @@ -149,12 +154,15 @@ export const TransformManagement: FC = () => { )} {typeof errorMessage === 'undefined' && ( - + + + + )} )} diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index da280452c1f0f..a7d0dce256640 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -16,7 +16,7 @@ import type { SharePluginStart } from 'src/plugins/share/public'; import type { SpacesApi } from '../../spaces/public'; import { registerFeature } from './register_feature'; import type { PluginSetupContract as AlertingSetup } from '../../alerting/public'; -import type { TriggersAndActionsUIPublicPluginSetup } from '../../triggers_actions_ui/public'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../triggers_actions_ui/public'; import { getTransformHealthRuleType } from './alerting'; export interface PluginsDependencies { @@ -27,7 +27,7 @@ export interface PluginsDependencies { share: SharePluginStart; spaces?: SpacesApi; alerting?: AlertingSetup; - triggersActionsUi?: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } export class TransformUiPlugin { diff --git a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts index 88b5396c7b110..eb51c04e0bca7 100644 --- a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts +++ b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts @@ -8,16 +8,21 @@ import { ElasticsearchClient } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import type { Transform as EsTransform } from '@elastic/elasticsearch/api/types'; +import { keyBy } from 'lodash'; import { TransformHealthRuleParams } from './schema'; import { ALL_TRANSFORMS_SELECTION, TRANSFORM_HEALTH_CHECK_NAMES, + TRANSFORM_RULE_TYPE, } from '../../../../common/constants'; import { getResultTestConfig } from '../../../../common/utils/alerts'; import { NotStartedTransformResponse, TransformHealthAlertContext, } from './register_transform_health_rule_type'; +import type { RulesClient } from '../../../../../alerting/server'; +import type { TransformHealthAlertRule } from '../../../../common/types/alerting'; +import { isContinuousTransform } from '../../../../common/types/transform'; interface TestResult { name: string; @@ -27,37 +32,48 @@ interface TestResult { // @ts-ignore FIXME update types in the elasticsearch client type Transform = EsTransform & { id: string; description?: string; sync: object }; -export function transformHealthServiceProvider(esClient: ElasticsearchClient) { +type TransformWithAlertingRules = Transform & { alerting_rules: TransformHealthAlertRule[] }; + +export function transformHealthServiceProvider( + esClient: ElasticsearchClient, + rulesClient?: RulesClient +) { const transformsDict = new Map(); /** * Resolves result transform selection. * @param includeTransforms * @param excludeTransforms + * @param skipIDsCheck */ const getResultsTransformIds = async ( includeTransforms: string[], - excludeTransforms: string[] | null + excludeTransforms: string[] | null, + skipIDsCheck = false ): Promise => { const includeAll = includeTransforms.some((id) => id === ALL_TRANSFORMS_SELECTION); - // Fetch transforms to make sure assigned transforms exists. - const transformsResponse = ( - await esClient.transform.getTransform({ - ...(includeAll ? {} : { transform_id: includeTransforms.join(',') }), - allow_no_match: true, - size: 1000, - }) - ).body.transforms as Transform[]; - let resultTransformIds: string[] = []; - transformsResponse.forEach((t) => { - transformsDict.set(t.id, t); - if (t.sync) { - resultTransformIds.push(t.id); - } - }); + if (skipIDsCheck) { + resultTransformIds = includeTransforms; + } else { + // Fetch transforms to make sure assigned transforms exists. + const transformsResponse = ( + await esClient.transform.getTransform({ + ...(includeAll ? {} : { transform_id: includeTransforms.join(',') }), + allow_no_match: true, + size: 1000, + }) + ).body.transforms as Transform[]; + + transformsResponse.forEach((t) => { + transformsDict.set(t.id, t); + if (t.sync) { + resultTransformIds.push(t.id); + } + }); + } if (excludeTransforms && excludeTransforms.length > 0) { const excludeIdsSet = new Set(excludeTransforms); @@ -129,6 +145,53 @@ export function transformHealthServiceProvider(esClient: ElasticsearchClient) { return result; }, + + /** + * Updates transform list with associated alerting rules. + */ + async populateTransformsWithAssignedRules( + transforms: Transform[] + ): Promise { + const newList = transforms.filter(isContinuousTransform) as TransformWithAlertingRules[]; + + if (!rulesClient) { + throw new Error('Rules client is missing'); + } + + const transformMap = keyBy(newList, 'id'); + + const transformAlertingRules = await rulesClient.find({ + options: { + perPage: 1000, + filter: `alert.attributes.alertTypeId:${TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH}`, + }, + }); + + for (const ruleInstance of transformAlertingRules.data) { + // Retrieve result transform IDs + const resultTransformIds: string[] = await getResultsTransformIds( + ruleInstance.params.includeTransforms.includes(ALL_TRANSFORMS_SELECTION) + ? Object.keys(transformMap) + : ruleInstance.params.includeTransforms, + ruleInstance.params.excludeTransforms, + true + ); + + resultTransformIds.forEach((transformId) => { + const transformRef = transformMap[transformId] as TransformWithAlertingRules; + + if (transformRef) { + if (Array.isArray(transformRef.alerting_rules)) { + transformRef.alerting_rules.push(ruleInstance); + } else { + transformRef.alerting_rules = [ruleInstance]; + } + } + }); + } + + return newList; + }, }; } diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 76aac9686c37e..4a657ae615d94 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -63,6 +63,7 @@ import { registerTransformNodesRoutes } from './transforms_nodes'; import { IIndexPattern } from '../../../../../../src/plugins/data/common'; import { isLatestTransform } from '../../../common/types/transform'; import { isKeywordDuplicate } from '../../../common/utils/field_utils'; +import { transformHealthServiceProvider } from '../../lib/alerting/transform_health_rule_type/transform_health_service'; enum TRANSFORM_ACTIONS { STOP = 'stop', @@ -90,6 +91,17 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { size: 1000, ...req.params, }); + + if (ctx.alerting) { + const transformHealthService = transformHealthServiceProvider( + ctx.core.elasticsearch.client.asCurrentUser, + ctx.alerting.getRulesClient() + ); + + // @ts-ignore + await transformHealthService.populateTransformsWithAssignedRules(body.transforms); + } + return res.ok({ body }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); diff --git a/x-pack/plugins/transform/server/services/license.ts b/x-pack/plugins/transform/server/services/license.ts index 978912ce08baf..ce28e0365bb21 100644 --- a/x-pack/plugins/transform/server/services/license.ts +++ b/x-pack/plugins/transform/server/services/license.ts @@ -15,6 +15,7 @@ import { } from 'kibana/server'; import { LicensingPluginSetup, LicenseType } from '../../../licensing/server'; +import type { AlertingApiRequestHandlerContext } from '../../../alerting/server'; export interface LicenseStatus { isValid: boolean; @@ -28,6 +29,10 @@ interface SetupSettings { defaultErrorMessage: string; } +type TransformRequestHandlerContext = RequestHandlerContext & { + alerting?: AlertingApiRequestHandlerContext; +}; + export class License { private licenseStatus: LicenseStatus = { isValid: false, @@ -64,7 +69,9 @@ export class License { }); } - guardApiRoute(handler: RequestHandler) { + guardApiRoute( + handler: RequestHandler + ) { const license = this; return function licenseCheck( diff --git a/x-pack/test/security_solution_endpoint/services/endpoint.ts b/x-pack/test/security_solution_endpoint/services/endpoint.ts index 2e774dcd84782..5bcc5c415a0db 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint.ts @@ -18,7 +18,7 @@ import { IndexedHostsAndAlertsResponse, indexHostsAndAlerts, } from '../../../plugins/security_solution/common/endpoint/index_data'; -import { TransformPivotConfig } from '../../../plugins/transform/common/types/transform'; +import { TransformConfigUnion } from '../../../plugins/transform/common/types/transform'; import { GetTransformsResponseSchema } from '../../../plugins/transform/common/api_schemas/transforms'; import { catchAndWrapError } from '../../../plugins/security_solution/server/endpoint/utils'; import { installOrUpgradeEndpointFleetPackage } from '../../../plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint'; @@ -38,9 +38,9 @@ export class EndpointTestResources extends FtrService { * * @param [endpointPackageVersion] if set, it will be used to get the specific transform this this package version. Else just returns first one found */ - async getTransform(endpointPackageVersion?: string): Promise { + async getTransform(endpointPackageVersion?: string): Promise { const transformId = this.generateTransformId(endpointPackageVersion); - let transform: TransformPivotConfig | undefined; + let transform: TransformConfigUnion | undefined; if (endpointPackageVersion) { await this.transform.api.waitForTransformToExist(transformId); From adbb8088932ea7069381009a408d1d7cea24f5de Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Tue, 19 Oct 2021 11:02:32 -0500 Subject: [PATCH 23/30] [Logs/Metrics UI] Add deprecated field configuration to Deprecations API (#115103) * [Logs/Metrics UI] Add deprecated field configuration to Deprecations API * Add correction steps * Add unit test for source config deprecations * Apply suggestions from code review Co-authored-by: Chris Cowan * Lint fix Co-authored-by: Chris Cowan --- .../plugins/infra/server/deprecations.test.ts | 86 +++++++ x-pack/plugins/infra/server/deprecations.ts | 211 ++++++++++++++++++ x-pack/plugins/infra/server/plugin.ts | 7 + 3 files changed, 304 insertions(+) create mode 100644 x-pack/plugins/infra/server/deprecations.test.ts create mode 100644 x-pack/plugins/infra/server/deprecations.ts diff --git a/x-pack/plugins/infra/server/deprecations.test.ts b/x-pack/plugins/infra/server/deprecations.test.ts new file mode 100644 index 0000000000000..318f1e50d6662 --- /dev/null +++ b/x-pack/plugins/infra/server/deprecations.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getInfraDeprecationsFactory } from './deprecations'; + +describe('Infra plugin deprecations', () => { + describe('Source configuration deprecations', () => { + test('returns no deprecations when all fields are set to the default values', async () => { + const sources = { + getAllSourceConfigurations: () => [ + { + configuration: { + name: 'Default', + fields: { + timestamp: '@timestamp', + tiebreaker: '_doc', + container: 'container.id', + host: 'host.name', + pod: 'kubernetes.pod.uid', + }, + }, + }, + { + configuration: { + name: 'Alternate', + fields: { + timestamp: '@timestamp', + tiebreaker: '_doc', + container: 'container.id', + host: 'host.name', + pod: 'kubernetes.pod.uid', + }, + }, + }, + ], + }; + const getDeprecations = getInfraDeprecationsFactory(sources as any); + const deprecations = await getDeprecations({} as any); + expect(deprecations.length).toBe(0); + }); + }); + test('returns expected deprecations when some fields are not set to default values in one or more source configurations', async () => { + const sources = { + getAllSourceConfigurations: () => [ + { + configuration: { + name: 'Default', + fields: { + timestamp: 'not-@timestamp', + tiebreaker: '_doc', + container: 'not-container.id', + host: 'host.name', + pod: 'not-kubernetes.pod.uid', + }, + }, + }, + { + configuration: { + name: 'Alternate', + fields: { + timestamp: 'not-@timestamp', + tiebreaker: 'not-_doc', + container: 'container.id', + host: 'not-host.name', + pod: 'kubernetes.pod.uid', + }, + }, + }, + ], + }; + const getDeprecations = getInfraDeprecationsFactory(sources as any); + const deprecations = await getDeprecations({} as any); + expect(deprecations.length).toBe(5); + expect( + deprecations.map((d) => + d.title.replace(/Source configuration field "(.*)" is deprecated./, '$1') + ) + ).toEqual( + expect.arrayContaining(['timestamp', 'tiebreaker', 'container ID', 'host name', 'pod ID']) + ); + }); +}); diff --git a/x-pack/plugins/infra/server/deprecations.ts b/x-pack/plugins/infra/server/deprecations.ts new file mode 100644 index 0000000000000..27c2b235f769b --- /dev/null +++ b/x-pack/plugins/infra/server/deprecations.ts @@ -0,0 +1,211 @@ +/* + * 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 { get } from 'lodash'; +import { + ConfigDeprecationProvider, + ConfigDeprecation, + DeprecationsDetails, + GetDeprecationsContext, +} from 'src/core/server'; +import { InfraSources } from './lib/sources'; + +const deprecatedFieldMessage = (fieldName: string, defaultValue: string, configNames: string[]) => + i18n.translate('xpack.infra.deprecations.deprecatedFieldDescription', { + defaultMessage: + 'Configuring the "{fieldName}" field has been deprecated and will be removed in 8.0.0. This plugin is designed to work with ECS, and expects this field to have a value of `{defaultValue}`. It has a different value configured in Source {configCount, plural, one {Configuration} other {Configurations}}: {configNames}', + values: { + fieldName, + defaultValue, + configNames: configNames.join(', '), + configCount: configNames.length, + }, + }); + +const DEFAULT_VALUES = { + timestamp: '@timestamp', + tiebreaker: '_doc', + container: 'container.id', + host: 'host.name', + pod: 'kubernetes.pod.uid', +}; + +const FIELD_DEPRECATION_FACTORIES: Record DeprecationsDetails> = + { + timestamp: (configNames) => ({ + level: 'critical', + title: i18n.translate('xpack.infra.deprecations.timestampFieldTitle', { + defaultMessage: 'Source configuration field "timestamp" is deprecated.', + }), + message: deprecatedFieldMessage( + i18n.translate('xpack.infra.deprecations.timestampFieldName', { + defaultMessage: 'timestamp', + }), + DEFAULT_VALUES.timestamp, + configNames + ), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.infra.deprecations.timestampAdjustIndexing', { + defaultMessage: 'Adjust your indexing to use "{field}" as a timestamp.', + values: { field: '@timestamp' }, + }), + ], + }, + }), + tiebreaker: (configNames) => ({ + level: 'critical', + title: i18n.translate('xpack.infra.deprecations.tiebreakerFieldTitle', { + defaultMessage: 'Source configuration field "tiebreaker" is deprecated.', + }), + message: deprecatedFieldMessage( + i18n.translate('xpack.infra.deprecations.tiebreakerFieldName', { + defaultMessage: 'tiebreaker', + }), + DEFAULT_VALUES.tiebreaker, + configNames + ), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.infra.deprecations.tiebreakerAdjustIndexing', { + defaultMessage: 'Adjust your indexing to use "{field}" as a tiebreaker.', + values: { field: '_doc' }, + }), + ], + }, + }), + host: (configNames) => ({ + level: 'critical', + title: i18n.translate('xpack.infra.deprecations.hostnameFieldTitle', { + defaultMessage: 'Source configuration field "host name" is deprecated.', + }), + message: deprecatedFieldMessage( + i18n.translate('xpack.infra.deprecations.hostnameFieldName', { + defaultMessage: 'host name', + }), + DEFAULT_VALUES.host, + configNames + ), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.infra.deprecations.hostAdjustIndexing', { + defaultMessage: 'Adjust your indexing to identify hosts using "{field}"', + values: { field: 'host.name' }, + }), + ], + }, + }), + pod: (configNames) => ({ + level: 'critical', + title: i18n.translate('xpack.infra.deprecations.podIdFieldTitle', { + defaultMessage: 'Source configuration field "pod ID" is deprecated.', + }), + message: deprecatedFieldMessage( + i18n.translate('xpack.infra.deprecations.podIdFieldName', { defaultMessage: 'pod ID' }), + DEFAULT_VALUES.pod, + configNames + ), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.infra.deprecations.podAdjustIndexing', { + defaultMessage: 'Adjust your indexing to identify Kubernetes pods using "{field}"', + values: { field: 'kubernetes.pod.uid' }, + }), + ], + }, + }), + container: (configNames) => ({ + level: 'critical', + title: i18n.translate('xpack.infra.deprecations.containerIdFieldTitle', { + defaultMessage: 'Source configuration field "container ID" is deprecated.', + }), + message: deprecatedFieldMessage( + i18n.translate('xpack.infra.deprecations.containerIdFieldName', { + defaultMessage: 'container ID', + }), + DEFAULT_VALUES.container, + configNames + ), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.infra.deprecations.containerAdjustIndexing', { + defaultMessage: 'Adjust your indexing to identify Docker containers using "{field}"', + values: { field: 'container.id' }, + }), + ], + }, + }), + }; + +export const configDeprecations: ConfigDeprecationProvider = () => [ + ...Object.keys(FIELD_DEPRECATION_FACTORIES).map( + (key): ConfigDeprecation => + (completeConfig, rootPath, addDeprecation) => { + const configuredValue = get(completeConfig, `xpack.infra.sources.default.fields.${key}`); + if (typeof configuredValue === 'undefined') { + return completeConfig; + } + addDeprecation({ + title: i18n.translate('xpack.infra.deprecations.deprecatedFieldConfigTitle', { + defaultMessage: '"{fieldKey}" is deprecated.', + values: { + fieldKey: key, + }, + }), + message: i18n.translate('xpack.infra.deprecations.deprecatedFieldConfigDescription', { + defaultMessage: + 'Configuring "xpack.infra.sources.default.fields.{fieldKey}" has been deprecated and will be removed in 8.0.0.', + values: { + fieldKey: key, + }, + }), + level: 'warning', + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.infra.deprecations.removeConfigField', { + defaultMessage: + 'Remove "xpack.infra.sources.default.fields.{fieldKey}" from your Kibana configuration.', + values: { fieldKey: key }, + }), + ], + }, + } as Parameters[0]); + + return completeConfig; + } + ), +]; + +export const getInfraDeprecationsFactory = + (sources: InfraSources) => + async ({ savedObjectsClient }: GetDeprecationsContext) => { + const deprecatedFieldsToSourceConfigMap: Map = new Map(); + const sourceConfigurations = await sources.getAllSourceConfigurations(savedObjectsClient); + + for (const { configuration } of sourceConfigurations) { + const { name, fields } = configuration; + for (const [key, defaultValue] of Object.entries(DEFAULT_VALUES)) { + const configuredValue = Reflect.get(fields, key); + if (configuredValue !== defaultValue) { + const affectedConfigNames = deprecatedFieldsToSourceConfigMap.get(key) ?? []; + affectedConfigNames.push(name); + deprecatedFieldsToSourceConfigMap.set(key, affectedConfigNames); + } + } + } + + const deprecations: DeprecationsDetails[] = []; + if (deprecatedFieldsToSourceConfigMap.size > 0) { + for (const [fieldName, affectedConfigNames] of deprecatedFieldsToSourceConfigMap.entries()) { + const deprecationFactory = Reflect.get(FIELD_DEPRECATION_FACTORIES, fieldName); + deprecations.push(deprecationFactory(affectedConfigNames)); + } + } + + return deprecations; + }; diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index d1ea60dd23dfc..bf9c5a152058e 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -40,6 +40,7 @@ import { UsageCollector } from './usage/usage_collector'; import { createGetLogQueryFields } from './services/log_queries/get_log_query_fields'; import { handleEsError } from '../../../../src/plugins/es_ui_shared/server'; import { RulesService } from './services/rules'; +import { configDeprecations, getInfraDeprecationsFactory } from './deprecations'; export const config: PluginConfigDescriptor = { schema: schema.object({ @@ -67,6 +68,7 @@ export const config: PluginConfigDescriptor = { }) ), }), + deprecations: configDeprecations, }; export type InfraConfig = TypeOf; @@ -191,6 +193,11 @@ export class InfraServerPlugin implements Plugin { const logEntriesService = new LogEntriesService(); logEntriesService.setup(core, { ...plugins, sources }); + // register deprecated source configuration fields + core.deprecations.registerDeprecations({ + getDeprecations: getInfraDeprecationsFactory(sources), + }); + return { defineInternalSourceConfiguration(sourceId, sourceProperties) { sources.defineInternalSourceConfiguration(sourceId, sourceProperties); From 9caab5858d62dd662321049e0d6bd0b13d597614 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Tue, 19 Oct 2021 19:05:53 +0300 Subject: [PATCH 24/30] [i18n] remove i18n html extractor (#115004) --- .../test_plugin_1/test_file_1.jsx | 13 +- .../test_plugin_1/test_file_4.html | 8 - .../test_plugin_2/test_file.html | 1 - .../test_plugin_2/test_file.jsx | 8 + .../test_file.jsx | 0 .../test_file.jsx | 8 - .../extract_default_translations.test.js.snap | 22 +- src/dev/i18n/extract_default_translations.js | 27 +- .../i18n/extract_default_translations.test.js | 22 +- .../__snapshots__/html.test.js.snap | 77 ------ src/dev/i18n/extractors/html.js | 252 ------------------ src/dev/i18n/extractors/html.test.js | 103 ------- src/dev/i18n/extractors/index.js | 1 - x-pack/.i18nrc.json | 2 +- 14 files changed, 36 insertions(+), 508 deletions(-) delete mode 100644 src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html delete mode 100644 src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html create mode 100644 src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx rename src/dev/i18n/__fixtures__/extract_default_translations/{test_plugin_3 => test_plugin_2_additional_path}/test_file.jsx (100%) delete mode 100644 src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.jsx delete mode 100644 src/dev/i18n/extractors/__snapshots__/html.test.js.snap delete mode 100644 src/dev/i18n/extractors/html.js delete mode 100644 src/dev/i18n/extractors/html.test.js diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_1.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_1.jsx index a52047f00feb0..8dc47a53da421 100644 --- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_1.jsx +++ b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_1.jsx @@ -1,11 +1,8 @@ /* eslint-disable */ -// Angular service -i18n('plugin_1.id_1', { defaultMessage: 'Message 1' }); - // @kbn/i18n -i18n.translate('plugin_1.id_2', { - defaultMessage: 'Message 2', +i18n.translate('plugin_1.id_1', { + defaultMessage: 'Message 1', description: 'Message description', }); @@ -15,10 +12,10 @@ class Component extends PureComponent { return (
- {intl.formatMessage({ id: 'plugin_1.id_4', defaultMessage: 'Message 4' })} + {intl.formatMessage({ id: 'plugin_1.id_3', defaultMessage: 'Message 3' })}
); } diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html deleted file mode 100644 index f9c8a8383d647..0000000000000 --- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
-
-
diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html deleted file mode 100644 index c12843602b13b..0000000000000 --- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html +++ /dev/null @@ -1 +0,0 @@ -

{{ ::'plugin_2.message-id' | i18n: { defaultMessage: 'Message text' } }}

diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx new file mode 100644 index 0000000000000..8f41a58bf82d1 --- /dev/null +++ b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx @@ -0,0 +1,8 @@ +/* eslint-disable */ + +i18n.translate('plugin_2.duplicate_id', { defaultMessage: 'Message 1' }); + +i18n.translate('plugin_2.duplicate_id', { + defaultMessage: 'Message 2', + description: 'Message description', +}); diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3/test_file.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2_additional_path/test_file.jsx similarity index 100% rename from src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3/test_file.jsx rename to src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2_additional_path/test_file.jsx diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.jsx deleted file mode 100644 index 7fa370dec5ebb..0000000000000 --- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.jsx +++ /dev/null @@ -1,8 +0,0 @@ -/* eslint-disable */ - -i18n('plugin_3.duplicate_id', { defaultMessage: 'Message 1' }); - -i18n.translate('plugin_3.duplicate_id', { - defaultMessage: 'Message 2', - description: 'Message description', -}); diff --git a/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap b/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap index b19b366a8db7b..c9215b9aed98b 100644 --- a/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap +++ b/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap @@ -5,14 +5,14 @@ Array [ Array [ "plugin_1.id_1", Object { - "description": undefined, + "description": "Message description", "message": "Message 1", }, ], Array [ "plugin_1.id_2", Object { - "description": "Message description", + "description": undefined, "message": "Message 2", }, ], @@ -23,27 +23,13 @@ Array [ "message": "Message 3", }, ], - Array [ - "plugin_1.id_4", - Object { - "description": undefined, - "message": "Message 4", - }, - ], - Array [ - "plugin_1.id_7", - Object { - "description": undefined, - "message": "Message 7", - }, - ], ] `; exports[`dev/i18n/extract_default_translations throws on id collision 1`] = ` Array [ - " I18N ERROR  Error in src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3/test_file.jsx -Error: There is more than one default message for the same id \\"plugin_3.duplicate_id\\": + " I18N ERROR  Error in src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx +Error: There is more than one default message for the same id \\"plugin_2.duplicate_id\\": \\"Message 1\\" and \\"Message 2\\"", ] `; diff --git a/src/dev/i18n/extract_default_translations.js b/src/dev/i18n/extract_default_translations.js index 97554704edc7f..a453b0bbae2fb 100644 --- a/src/dev/i18n/extract_default_translations.js +++ b/src/dev/i18n/extract_default_translations.js @@ -8,7 +8,7 @@ import path from 'path'; -import { extractHtmlMessages, extractCodeMessages } from './extractors'; +import { extractCodeMessages } from './extractors'; import { globAsync, readFileAsync, normalizePath } from './utils'; import { createFailError, isFailError } from '@kbn/dev-utils'; @@ -59,7 +59,7 @@ export async function matchEntriesWithExctractors(inputPath, options = {}) { '**/*.d.ts', ].concat(additionalIgnore); - const entries = await globAsync('*.{js,jsx,ts,tsx,html}', { + const entries = await globAsync('*.{js,jsx,ts,tsx}', { cwd: inputPath, matchBase: true, ignore, @@ -67,25 +67,14 @@ export async function matchEntriesWithExctractors(inputPath, options = {}) { absolute, }); - const { htmlEntries, codeEntries } = entries.reduce( - (paths, entry) => { - const resolvedPath = path.resolve(inputPath, entry); + const codeEntries = entries.reduce((paths, entry) => { + const resolvedPath = path.resolve(inputPath, entry); + paths.push(resolvedPath); - if (resolvedPath.endsWith('.html')) { - paths.htmlEntries.push(resolvedPath); - } else { - paths.codeEntries.push(resolvedPath); - } - - return paths; - }, - { htmlEntries: [], codeEntries: [] } - ); + return paths; + }, []); - return [ - [htmlEntries, extractHtmlMessages], - [codeEntries, extractCodeMessages], - ]; + return [[codeEntries, extractCodeMessages]]; } export async function extractMessagesFromPathToMap(inputPath, targetMap, config, reporter) { diff --git a/src/dev/i18n/extract_default_translations.test.js b/src/dev/i18n/extract_default_translations.test.js index 4b0da570ca551..e5b33eba7a4db 100644 --- a/src/dev/i18n/extract_default_translations.test.js +++ b/src/dev/i18n/extract_default_translations.test.js @@ -18,17 +18,15 @@ const fixturesPath = path.resolve(__dirname, '__fixtures__', 'extract_default_tr const pluginsPaths = [ path.join(fixturesPath, 'test_plugin_1'), path.join(fixturesPath, 'test_plugin_2'), - path.join(fixturesPath, 'test_plugin_3'), - path.join(fixturesPath, 'test_plugin_3_additional_path'), + path.join(fixturesPath, 'test_plugin_2_additional_path'), ]; const config = { paths: { plugin_1: ['src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1'], - plugin_2: ['src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2'], - plugin_3: [ - 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3', - 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path', + plugin_2: [ + 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2', + 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2_additional_path', ], }, exclude: [], @@ -44,7 +42,7 @@ describe('dev/i18n/extract_default_translations', () => { }); test('throws on id collision', async () => { - const [, , pluginPath] = pluginsPaths; + const [, pluginPath] = pluginsPaths; const reporter = new ErrorReporter(); await expect( @@ -57,20 +55,20 @@ describe('dev/i18n/extract_default_translations', () => { const id = 'plugin_2.message-id'; const filePath = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_2/test_file.html' + '__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx' ); expect(() => validateMessageNamespace(id, filePath, config.paths)).not.toThrow(); }); test('validates message namespace with multiple paths', () => { - const id = 'plugin_3.message-id'; + const id = 'plugin_2.message-id'; const filePath1 = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_3/test_file.html' + '__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx' ); const filePath2 = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.html' + '__fixtures__/extract_default_translations/test_plugin_2_additional_path/test_file.jsx' ); expect(() => validateMessageNamespace(id, filePath1, config.paths)).not.toThrow(); expect(() => validateMessageNamespace(id, filePath2, config.paths)).not.toThrow(); @@ -81,7 +79,7 @@ describe('dev/i18n/extract_default_translations', () => { const id = 'wrong_plugin_namespace.message-id'; const filePath = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_2/test_file.html' + '__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx' ); expect(() => validateMessageNamespace(id, filePath, config.paths, { report })).not.toThrow(); diff --git a/src/dev/i18n/extractors/__snapshots__/html.test.js.snap b/src/dev/i18n/extractors/__snapshots__/html.test.js.snap deleted file mode 100644 index f911674400d45..0000000000000 --- a/src/dev/i18n/extractors/__snapshots__/html.test.js.snap +++ /dev/null @@ -1,77 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`dev/i18n/extractors/html extracts default messages from HTML 1`] = ` -Array [ - Array [ - "kbn.dashboard.id-1", - Object { - "description": "Message description 1", - "message": "Message text 1 {value}", - }, - ], - Array [ - "kbn.dashboard.id-2", - Object { - "description": undefined, - "message": "Message text 2", - }, - ], - Array [ - "kbn.dashboard.id-3", - Object { - "description": "Message description 3", - "message": "Message text 3", - }, - ], -] -`; - -exports[`dev/i18n/extractors/html extracts default messages from HTML with one-time binding 1`] = ` -Array [ - Array [ - "kbn.id", - Object { - "description": undefined, - "message": "Message text with {value}", - }, - ], -] -`; - -exports[`dev/i18n/extractors/html extracts message from i18n filter in interpolating directive 1`] = ` -Array [ - Array [ - "namespace.messageId", - Object { - "description": undefined, - "message": "Message", - }, - ], -] -`; - -exports[`dev/i18n/extractors/html throws on empty i18n-id 1`] = ` -Array [ - Array [ - [Error: Empty "i18n-id" value in angular directive is not allowed.], - ], -] -`; - -exports[`dev/i18n/extractors/html throws on i18n filter usage in complex angular expression 1`] = ` -Array [ - Array [ - [Error: Couldn't parse angular i18n expression: -Missing semicolon. (1:5): - mode as ('metricVis.colorModes.' + mode], - ], -] -`; - -exports[`dev/i18n/extractors/html throws on missing i18n-default-message attribute 1`] = ` -Array [ - Array [ - [Error: Empty defaultMessage in angular directive is not allowed ("message-id").], - ], -] -`; diff --git a/src/dev/i18n/extractors/html.js b/src/dev/i18n/extractors/html.js deleted file mode 100644 index 922f67ac2fb09..0000000000000 --- a/src/dev/i18n/extractors/html.js +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import cheerio from 'cheerio'; -import { parse } from '@babel/parser'; -import { isObjectExpression, isStringLiteral } from '@babel/types'; - -import { - isPropertyWithKey, - formatHTMLString, - formatJSString, - traverseNodes, - checkValuesProperty, - createParserErrorMessage, - extractMessageValueFromNode, - extractValuesKeysFromNode, - extractDescriptionValueFromNode, -} from '../utils'; -import { DEFAULT_MESSAGE_KEY, DESCRIPTION_KEY, VALUES_KEY } from '../constants'; -import { createFailError, isFailError } from '@kbn/dev-utils'; - -/** - * Find all substrings of "{{ any text }}" pattern allowing '{' and '}' chars in single quote strings - * - * Example: `{{ ::'message.id' | i18n: { defaultMessage: 'Message with {{curlyBraces}}' } }}` - */ -const ANGULAR_EXPRESSION_REGEX = /{{([^{}]|({([^']|('([^']|(\\'))*'))*?}))*}}+/g; - -const LINEBREAK_REGEX = /\n/g; -const I18N_FILTER_MARKER = '| i18n: '; - -function parseExpression(expression) { - let ast; - - try { - ast = parse(`+${expression}`.replace(LINEBREAK_REGEX, ' ')); - } catch (error) { - if (error instanceof SyntaxError) { - const errorWithContext = createParserErrorMessage(` ${expression}`, error); - throw createFailError(`Couldn't parse angular i18n expression:\n${errorWithContext}`); - } - } - - return ast; -} - -/** - * Extract default message from an angular filter expression argument - * @param {string} expression JavaScript code containing a filter object - * @param {string} messageId id of the message - * @returns {{ message?: string, description?: string, valuesKeys: string[]] }} - */ -function parseFilterObjectExpression(expression, messageId) { - const ast = parseExpression(expression); - const objectExpressionNode = [...traverseNodes(ast.program.body)].find((node) => - isObjectExpression(node) - ); - - if (!objectExpressionNode) { - return {}; - } - - const [messageProperty, descriptionProperty, valuesProperty] = [ - DEFAULT_MESSAGE_KEY, - DESCRIPTION_KEY, - VALUES_KEY, - ].map((key) => - objectExpressionNode.properties.find((property) => isPropertyWithKey(property, key)) - ); - - const message = messageProperty - ? formatJSString(extractMessageValueFromNode(messageProperty.value, messageId)) - : undefined; - - const description = descriptionProperty - ? formatJSString(extractDescriptionValueFromNode(descriptionProperty.value, messageId)) - : undefined; - - const valuesKeys = valuesProperty - ? extractValuesKeysFromNode(valuesProperty.value, messageId) - : []; - - return { message, description, valuesKeys }; -} - -function parseIdExpression(expression) { - const ast = parseExpression(expression); - const stringNode = [...traverseNodes(ast.program.body)].find((node) => isStringLiteral(node)); - - if (!stringNode) { - throw createFailError(`Message id should be a string literal, but got: \n${expression}`); - } - - return stringNode ? formatJSString(stringNode.value) : null; -} - -function trimCurlyBraces(string) { - if (string.startsWith('{{') && string.endsWith('}}')) { - return string.slice(2, -2).trim(); - } - - return string; -} - -/** - * Removes one-time binding operator `::` from the start of a string. - * - * Example: `::'id' | i18n: { defaultMessage: 'Message' }` - * @param {string} string string to trim - */ -function trimOneTimeBindingOperator(string) { - if (string.startsWith('::')) { - return string.slice(2); - } - - return string; -} - -function* extractExpressions(htmlContent) { - const elements = cheerio.load(htmlContent)('*').toArray(); - - for (const element of elements) { - for (const node of element.children) { - if (node.type === 'text') { - yield* (node.data.match(ANGULAR_EXPRESSION_REGEX) || []) - .filter((expression) => expression.includes(I18N_FILTER_MARKER)) - .map(trimCurlyBraces); - } - } - - for (const attribute of Object.values(element.attribs)) { - if (attribute.includes(I18N_FILTER_MARKER)) { - yield trimCurlyBraces(attribute); - } - } - } -} - -function* getFilterMessages(htmlContent, reporter) { - for (const expression of extractExpressions(htmlContent)) { - const filterStart = expression.indexOf(I18N_FILTER_MARKER); - - const idExpression = trimOneTimeBindingOperator(expression.slice(0, filterStart).trim()); - const filterObjectExpression = expression.slice(filterStart + I18N_FILTER_MARKER.length).trim(); - - try { - if (!filterObjectExpression || !idExpression) { - throw createFailError(`Cannot parse i18n filter expression: ${expression}`); - } - - const messageId = parseIdExpression(idExpression); - - if (!messageId) { - throw createFailError('Empty "id" value in angular filter expression is not allowed.'); - } - - const { message, description, valuesKeys } = parseFilterObjectExpression( - filterObjectExpression, - messageId - ); - - if (!message) { - throw createFailError( - `Empty defaultMessage in angular filter expression is not allowed ("${messageId}").` - ); - } - - checkValuesProperty(valuesKeys, message, messageId); - - yield [messageId, { message, description }]; - } catch (error) { - if (!isFailError(error)) { - throw error; - } - - reporter.report(error); - } - } -} - -function* getDirectiveMessages(htmlContent, reporter) { - const $ = cheerio.load(htmlContent); - - const elements = $('[i18n-id]') - .map((idx, el) => { - const $el = $(el); - - return { - id: $el.attr('i18n-id'), - defaultMessage: $el.attr('i18n-default-message'), - description: $el.attr('i18n-description'), - values: $el.attr('i18n-values'), - }; - }) - .toArray(); - - for (const element of elements) { - const messageId = formatHTMLString(element.id); - if (!messageId) { - reporter.report( - createFailError('Empty "i18n-id" value in angular directive is not allowed.') - ); - continue; - } - - const message = formatHTMLString(element.defaultMessage); - if (!message) { - reporter.report( - createFailError( - `Empty defaultMessage in angular directive is not allowed ("${messageId}").` - ) - ); - continue; - } - - try { - if (element.values) { - const ast = parseExpression(element.values); - const valuesObjectNode = [...traverseNodes(ast.program.body)].find((node) => - isObjectExpression(node) - ); - const valuesKeys = extractValuesKeysFromNode(valuesObjectNode); - - checkValuesProperty(valuesKeys, message, messageId); - } else { - checkValuesProperty([], message, messageId); - } - - yield [ - messageId, - { message, description: formatHTMLString(element.description) || undefined }, - ]; - } catch (error) { - if (!isFailError(error)) { - throw error; - } - - reporter.report(error); - } - } -} - -export function* extractHtmlMessages(buffer, reporter) { - const content = buffer.toString(); - yield* getDirectiveMessages(content, reporter); - yield* getFilterMessages(content, reporter); -} diff --git a/src/dev/i18n/extractors/html.test.js b/src/dev/i18n/extractors/html.test.js deleted file mode 100644 index 5f0c7ac39e8f6..0000000000000 --- a/src/dev/i18n/extractors/html.test.js +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { extractHtmlMessages } from './html'; - -const htmlSourceBuffer = Buffer.from(` -
-
-

-
-
- {{ 'kbn.dashboard.id-2' | i18n: { defaultMessage: 'Message text 2' } }} -
-
- {{ 'kbn.dashboard.id-3' | i18n: { defaultMessage: 'Message text 3', description: 'Message description 3' } }} -
-
-`); - -const report = jest.fn(); - -describe('dev/i18n/extractors/html', () => { - beforeEach(() => { - report.mockClear(); - }); - - test('extracts default messages from HTML', () => { - const actual = Array.from(extractHtmlMessages(htmlSourceBuffer)); - expect(actual.sort()).toMatchSnapshot(); - }); - - test('extracts default messages from HTML with one-time binding', () => { - const actual = Array.from( - extractHtmlMessages(` -
- {{::'kbn.id' | i18n: { defaultMessage: 'Message text with {value}', values: { value: 'value' } }}} -
-`) - ); - expect(actual.sort()).toMatchSnapshot(); - }); - - test('throws on empty i18n-id', () => { - const source = Buffer.from(`\ -

-`); - - expect(() => extractHtmlMessages(source, { report }).next()).not.toThrow(); - expect(report.mock.calls).toMatchSnapshot(); - }); - - test('throws on missing i18n-default-message attribute', () => { - const source = Buffer.from(`\ -

-`); - - expect(() => extractHtmlMessages(source, { report }).next()).not.toThrow(); - expect(report.mock.calls).toMatchSnapshot(); - }); - - test('throws on i18n filter usage in complex angular expression', () => { - const source = Buffer.from(`\ -
-`); - - expect(() => extractHtmlMessages(source, { report }).next()).not.toThrow(); - expect(report.mock.calls).toMatchSnapshot(); - }); - - test('extracts message from i18n filter in interpolating directive', () => { - const source = Buffer.from(` - -`); - - expect(Array.from(extractHtmlMessages(source))).toMatchSnapshot(); - }); -}); diff --git a/src/dev/i18n/extractors/index.js b/src/dev/i18n/extractors/index.js index dc269586f5abe..601a080c80b1b 100644 --- a/src/dev/i18n/extractors/index.js +++ b/src/dev/i18n/extractors/index.js @@ -7,4 +7,3 @@ */ export { extractCodeMessages } from './code'; -export { extractHtmlMessages } from './html'; diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 372812d4d0dc1..b51363f1b7006 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -64,7 +64,7 @@ "xpack.observability": "plugins/observability", "xpack.banners": "plugins/banners" }, - "exclude": ["examples", "plugins/monitoring/public/angular/angular_i18n"], + "exclude": ["examples"], "translations": [ "plugins/translations/translations/zh-CN.json", "plugins/translations/translations/ja-JP.json" From b7577b5695eebf8e62f151bad7559bc59c346804 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Tue, 19 Oct 2021 12:09:51 -0400 Subject: [PATCH 25/30] [App Search] Wired up organic results on Curation Suggestions view (#114717) --- .../app_search/components/curations/types.ts | 10 +- .../curation_suggestion.test.tsx | 129 +++-- .../curation_suggestion.tsx | 37 +- .../curation_suggestion_logic.test.ts | 259 ++++------ .../curation_suggestion_logic.ts | 147 ++---- .../views/curation_suggestion/temp_data.ts | 470 ------------------ .../search_relevance_suggestions.test.ts | 7 +- .../search_relevance_suggestions.ts | 15 +- 8 files changed, 274 insertions(+), 800 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/temp_data.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts index 7479505ea86da..b67664d8efde2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/types.ts @@ -13,11 +13,19 @@ export interface CurationSuggestion { updated_at: string; promoted: string[]; status: 'pending' | 'applied' | 'automated' | 'rejected' | 'disabled'; - curation_id?: string; + curation_id?: string; // The id of an existing curation that this suggestion would affect operation: 'create' | 'update' | 'delete'; override_manual_curation?: boolean; } +// A curation suggestion with linked ids hydrated with actual values +export interface HydratedCurationSuggestion + extends Omit { + organic: Curation['organic']; + promoted: Curation['promoted']; + curation?: Curation; +} + export interface Curation { id: string; last_updated: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.test.tsx index 1c3f4645d89e9..604d2930a4b5d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.test.tsx @@ -14,6 +14,8 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiEmptyPrompt } from '@elastic/eui'; + import { AppSearchPageTemplate } from '../../../layout'; import { Result } from '../../../result'; @@ -26,44 +28,29 @@ describe('CurationSuggestion', () => { suggestion: { query: 'foo', updated_at: '2021-07-08T14:35:50Z', - promoted: ['1', '2', '3'], - }, - suggestedPromotedDocuments: [ - { - id: { - raw: '1', - }, - _meta: { - id: '1', - engine: 'some-engine', - }, - }, - { - id: { - raw: '2', - }, - _meta: { - id: '2', - engine: 'some-engine', - }, - }, - { - id: { - raw: '3', - }, - _meta: { - id: '3', - engine: 'some-engine', - }, - }, - ], - curation: { - promoted: [ + promoted: [{ id: '4', foo: 'foo' }], + organic: [ { - id: '4', - foo: 'foo', + id: { raw: '3', snippet: null }, + foo: { raw: 'bar', snippet: null }, + _meta: { id: '3' }, }, ], + curation: { + promoted: [{ id: '1', foo: 'foo' }], + organic: [ + { + id: { raw: '5', snippet: null }, + foo: { raw: 'bar', snippet: null }, + _meta: { id: '5' }, + }, + { + id: { raw: '6', snippet: null }, + foo: { raw: 'bar', snippet: null }, + _meta: { id: '6' }, + }, + ], + }, }, isMetaEngine: true, engine: { @@ -99,11 +86,10 @@ describe('CurationSuggestion', () => { it('shows existing promoted documents', () => { const wrapper = shallow(); const suggestedResultsPanel = wrapper.find(CurationResultPanel).at(0); - // gets populated from 'curation' in state, and converted to results format (i.e, has raw properties, etc.) expect(suggestedResultsPanel.prop('results')).toEqual([ { id: { - raw: '4', + raw: '1', snippet: null, }, foo: { @@ -111,7 +97,7 @@ describe('CurationSuggestion', () => { snippet: null, }, _meta: { - id: '4', + id: '1', }, }, ]); @@ -120,7 +106,21 @@ describe('CurationSuggestion', () => { it('shows suggested promoted documents', () => { const wrapper = shallow(); const suggestedResultsPanel = wrapper.find(CurationResultPanel).at(1); - expect(suggestedResultsPanel.prop('results')).toEqual(values.suggestedPromotedDocuments); + expect(suggestedResultsPanel.prop('results')).toEqual([ + { + id: { + raw: '4', + snippet: null, + }, + foo: { + raw: 'foo', + snippet: null, + }, + _meta: { + id: '4', + }, + }, + ]); }); it('displays the query in the title', () => { @@ -142,9 +142,15 @@ describe('CurationSuggestion', () => { it('displays proposed organic results', () => { const wrapper = shallow(); wrapper.find('[data-test-subj="showOrganicResults"]').simulate('click'); - expect(wrapper.find('[data-test-subj="proposedOrganicResults"]').find(Result).length).toBe(4); - expect(wrapper.find(Result).at(0).prop('isMetaEngine')).toEqual(true); - expect(wrapper.find(Result).at(0).prop('schemaForTypeHighlights')).toEqual( + const resultsWrapper = wrapper.find('[data-test-subj="proposedOrganicResults"]').find(Result); + expect(resultsWrapper.length).toBe(1); + expect(resultsWrapper.find(Result).at(0).prop('result')).toEqual({ + id: { raw: '3', snippet: null }, + foo: { raw: 'bar', snippet: null }, + _meta: { id: '3' }, + }); + expect(resultsWrapper.find(Result).at(0).prop('isMetaEngine')).toEqual(true); + expect(resultsWrapper.find(Result).at(0).prop('schemaForTypeHighlights')).toEqual( values.engine.schema ); }); @@ -152,10 +158,43 @@ describe('CurationSuggestion', () => { it('displays current organic results', () => { const wrapper = shallow(); wrapper.find('[data-test-subj="showOrganicResults"]').simulate('click'); - expect(wrapper.find('[data-test-subj="currentOrganicResults"]').find(Result).length).toBe(4); - expect(wrapper.find(Result).at(0).prop('isMetaEngine')).toEqual(true); - expect(wrapper.find(Result).at(0).prop('schemaForTypeHighlights')).toEqual( + const resultWrapper = wrapper.find('[data-test-subj="currentOrganicResults"]').find(Result); + expect(resultWrapper.length).toBe(2); + expect(resultWrapper.find(Result).at(0).prop('result')).toEqual({ + id: { raw: '5', snippet: null }, + foo: { raw: 'bar', snippet: null }, + _meta: { id: '5' }, + }); + expect(resultWrapper.find(Result).at(0).prop('isMetaEngine')).toEqual(true); + expect(resultWrapper.find(Result).at(0).prop('schemaForTypeHighlights')).toEqual( values.engine.schema ); }); + + it('shows an empty prompt when there are no organic results', () => { + setMockValues({ + ...values, + suggestion: { + ...values.suggestion, + organic: [], + curation: { + ...values.suggestion.curation, + organic: [], + }, + }, + }); + const wrapper = shallow(); + wrapper.find('[data-test-subj="showOrganicResults"]').simulate('click'); + expect(wrapper.find('[data-test-subj="currentOrganicResults"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="proposedOrganicResults"]').exists()).toBe(false); + expect(wrapper.find(EuiEmptyPrompt).exists()).toBe(true); + }); + + it('renders even if no data is set yet', () => { + setMockValues({ + suggestion: null, + }); + const wrapper = shallow(); + expect(wrapper.find(AppSearchPageTemplate).exists()).toBe(true); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.tsx index 7539055253732..4e344d8cc2a39 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion.tsx @@ -11,6 +11,7 @@ import { useActions, useValues } from 'kea'; import { EuiButtonEmpty, + EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, @@ -20,6 +21,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { LeafIcon } from '../../../../../shared/icons'; import { useDecodedParams } from '../../../../utils/encode_path_params'; import { EngineLogic } from '../../../engine'; import { AppSearchPageTemplate } from '../../../layout'; @@ -32,19 +34,23 @@ import { CurationActionBar } from './curation_action_bar'; import { CurationResultPanel } from './curation_result_panel'; import { CurationSuggestionLogic } from './curation_suggestion_logic'; -import { DATA } from './temp_data'; export const CurationSuggestion: React.FC = () => { const { query } = useDecodedParams(); const { engine, isMetaEngine } = useValues(EngineLogic); const curationSuggestionLogic = CurationSuggestionLogic({ query }); const { loadSuggestion } = useActions(curationSuggestionLogic); - const { suggestion, suggestedPromotedDocuments, curation, dataLoading } = - useValues(curationSuggestionLogic); + const { suggestion, dataLoading } = useValues(curationSuggestionLogic); const [showOrganicResults, setShowOrganicResults] = useState(false); - const currentOrganicResults = [...DATA].splice(5, 4); - const proposedOrganicResults = [...DATA].splice(2, 4); - const existingCurationResults = curation ? curation.promoted.map(convertToResultFormat) : []; + const currentOrganicResults = suggestion?.curation?.organic || []; + const proposedOrganicResults = suggestion?.organic || []; + const totalNumberOfOrganicResults = currentOrganicResults.length + proposedOrganicResults.length; + const existingCurationResults = suggestion?.curation + ? suggestion.curation.promoted.map(convertToResultFormat) + : []; + const suggestedPromotedDocuments = suggestion?.promoted + ? suggestion?.promoted.map(convertToResultFormat) + : []; const suggestionQuery = suggestion?.query || ''; @@ -114,7 +120,24 @@ export const CurationSuggestion: React.FC = () => { { defaultMessage: 'Expand organic search results' } )} - {showOrganicResults && ( + {showOrganicResults && totalNumberOfOrganicResults === 0 && ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.noOrganicResultsTitle', + { defaultMessage: 'No results' } + )} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.noOrganicResultsDescription', + { defaultMessage: 'No organic search results were returned for this query' } + )} + /> + )} + {showOrganicResults && totalNumberOfOrganicResults > 0 && ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts index 7a91171cc2cc7..e6a847f6e9ec6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts @@ -12,109 +12,141 @@ import { mockKibanaValues, } from '../../../../../__mocks__/kea_logic'; -import { set } from 'lodash/fp'; - import '../../../../__mocks__/engine_logic.mock'; import { nextTick } from '@kbn/test/jest'; -import { CurationSuggestion } from '../../types'; +import { HydratedCurationSuggestion } from '../../types'; import { CurationSuggestionLogic } from './curation_suggestion_logic'; const DEFAULT_VALUES = { dataLoading: true, suggestion: null, - suggestedPromotedDocuments: [], - curation: null, }; -const suggestion: CurationSuggestion = { +const suggestion: HydratedCurationSuggestion = { query: 'foo', updated_at: '2021-07-08T14:35:50Z', - promoted: ['1', '2', '3'], - status: 'pending', - operation: 'create', -}; - -const curation = { - id: 'cur-6155e69c7a2f2e4f756303fd', - queries: ['foo'], promoted: [ { - id: '5', - }, - ], - hidden: [], - last_updated: 'September 30, 2021 at 04:32PM', - organic: [], -}; - -const suggestedPromotedDocuments = [ - { - id: { - raw: '1', - }, - _meta: { id: '1', - engine: 'some-engine', - }, - }, - { - id: { - raw: '2', }, - _meta: { + { id: '2', - engine: 'some-engine', - }, - }, - { - id: { - raw: '3', }, - _meta: { + { id: '3', - engine: 'some-engine', - }, - }, -]; - -const MOCK_RESPONSE = { - meta: { - page: { - current: 1, - size: 10, - total_results: 1, - total_pages: 1, }, + ], + status: 'pending', + operation: 'create', + curation: { + id: 'cur-6155e69c7a2f2e4f756303fd', + queries: ['foo'], + promoted: [ + { + id: '5', + }, + ], + hidden: [], + last_updated: 'September 30, 2021 at 04:32PM', + organic: [ + { + id: { + raw: '1', + }, + _meta: { + id: '1', + engine: 'some-engine', + }, + }, + ], }, - results: [suggestion], -}; - -const MOCK_DOCUMENTS_RESPONSE = { - results: [ + organic: [ { id: { - raw: '2', + raw: '1', }, _meta: { - id: '2', + id: '1', engine: 'some-engine', }, }, { id: { - raw: '1', + raw: '2', }, _meta: { - id: '1', + id: '2', engine: 'some-engine', }, }, ], }; +const MOCK_RESPONSE = { + query: 'foo', + status: 'pending', + updated_at: '2021-07-08T14:35:50Z', + operation: 'create', + suggestion: { + promoted: [ + { + id: '1', + }, + { + id: '2', + }, + { + id: '3', + }, + ], + organic: [ + { + id: { + raw: '1', + }, + _meta: { + id: '1', + engine: 'some-engine', + }, + }, + { + id: { + raw: '2', + }, + _meta: { + id: '2', + engine: 'some-engine', + }, + }, + ], + }, + curation: { + id: 'cur-6155e69c7a2f2e4f756303fd', + queries: ['foo'], + promoted: [ + { + id: '5', + }, + ], + hidden: [], + last_updated: 'September 30, 2021 at 04:32PM', + organic: [ + { + id: { + raw: '1', + }, + _meta: { + id: '1', + engine: 'some-engine', + }, + }, + ], + }, +}; + describe('CurationSuggestionLogic', () => { const { mount } = new LogicMounter(CurationSuggestionLogic); const { flashAPIErrors, setQueuedErrorMessage } = mockFlashMessageHelpers; @@ -177,14 +209,10 @@ describe('CurationSuggestionLogic', () => { mountLogic(); CurationSuggestionLogic.actions.onSuggestionLoaded({ suggestion, - suggestedPromotedDocuments, - curation, }); expect(CurationSuggestionLogic.values).toEqual({ ...DEFAULT_VALUES, suggestion, - suggestedPromotedDocuments, - curation, dataLoading: false, }); }); @@ -205,100 +233,24 @@ describe('CurationSuggestionLogic', () => { }); it('should make API calls to fetch data and trigger onSuggestionLoaded', async () => { - http.post.mockReturnValueOnce(Promise.resolve(MOCK_RESPONSE)); - http.post.mockReturnValueOnce(Promise.resolve(MOCK_DOCUMENTS_RESPONSE)); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_RESPONSE)); mountLogic(); jest.spyOn(CurationSuggestionLogic.actions, 'onSuggestionLoaded'); CurationSuggestionLogic.actions.loadSuggestion(); await nextTick(); - expect(http.post).toHaveBeenCalledWith( + expect(http.get).toHaveBeenCalledWith( '/internal/app_search/engines/some-engine/search_relevance_suggestions/foo-query', { - body: JSON.stringify({ - page: { - current: 1, - size: 1, - }, - filters: { - status: ['pending'], - type: 'curation', - }, - }), - } - ); - - expect(http.post).toHaveBeenCalledWith('/internal/app_search/engines/some-engine/search', { - query: { query: '' }, - body: JSON.stringify({ - page: { - size: 100, - }, - filters: { - // The results of the first API call are used to make the second http call for document details - id: MOCK_RESPONSE.results[0].promoted, - }, - }), - }); - - expect(CurationSuggestionLogic.actions.onSuggestionLoaded).toHaveBeenCalledWith({ - suggestion: { - query: 'foo', - updated_at: '2021-07-08T14:35:50Z', - promoted: ['1', '2', '3'], - status: 'pending', - operation: 'create', - }, - // Note that these were re-ordered to match the 'promoted' list above, and since document - // 3 was not found it is not included in this list - suggestedPromotedDocuments: [ - { - id: { - raw: '1', - }, - _meta: { - id: '1', - engine: 'some-engine', - }, - }, - { - id: { - raw: '2', - }, - _meta: { - id: '2', - engine: 'some-engine', - }, + query: { + type: 'curation', }, - ], - curation: null, - }); - }); - - it('will also fetch curation details if the suggestion has a curation_id', async () => { - http.post.mockReturnValueOnce( - Promise.resolve( - set('results[0].curation_id', 'cur-6155e69c7a2f2e4f756303fd', MOCK_RESPONSE) - ) - ); - http.post.mockReturnValueOnce(Promise.resolve(MOCK_DOCUMENTS_RESPONSE)); - http.get.mockReturnValueOnce(Promise.resolve(curation)); - mountLogic(); - jest.spyOn(CurationSuggestionLogic.actions, 'onSuggestionLoaded'); - - CurationSuggestionLogic.actions.loadSuggestion(); - await nextTick(); - - expect(http.get).toHaveBeenCalledWith( - '/internal/app_search/engines/some-engine/curations/cur-6155e69c7a2f2e4f756303fd', - { query: { skip_record_analytics: 'true' } } + } ); expect(CurationSuggestionLogic.actions.onSuggestionLoaded).toHaveBeenCalledWith({ - suggestion: expect.any(Object), - suggestedPromotedDocuments: expect.any(Object), - curation, + suggestion, }); }); @@ -306,7 +258,12 @@ describe('CurationSuggestionLogic', () => { // the back button, etc. The suggestion still exists, it's just not in a "pending" state // so we can show it.ga it('will redirect if the suggestion is not found', async () => { - http.post.mockReturnValueOnce(Promise.resolve(set('results', [], MOCK_RESPONSE))); + http.get.mockReturnValueOnce( + Promise.reject({ + response: { status: 404 }, + }) + ); + mountLogic(); CurationSuggestionLogic.actions.loadSuggestion(); await nextTick(); @@ -314,7 +271,7 @@ describe('CurationSuggestionLogic', () => { expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations'); }); - itHandlesErrors(http.post, () => { + itHandlesErrors(http.get, () => { CurationSuggestionLogic.actions.loadSuggestion(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts index 4ca1b0adb7814..b206c0c79ed26 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts @@ -19,33 +19,20 @@ import { HttpLogic } from '../../../../../shared/http'; import { KibanaLogic } from '../../../../../shared/kibana'; import { ENGINE_CURATIONS_PATH, ENGINE_CURATION_PATH } from '../../../../routes'; import { EngineLogic, generateEnginePath } from '../../../engine'; -import { Result } from '../../../result/types'; -import { Curation, CurationSuggestion } from '../../types'; +import { CurationSuggestion, HydratedCurationSuggestion } from '../../types'; interface Error { error: string; } interface CurationSuggestionValues { dataLoading: boolean; - suggestion: CurationSuggestion | null; - suggestedPromotedDocuments: Result[]; - curation: Curation | null; + suggestion: HydratedCurationSuggestion | null; } interface CurationSuggestionActions { loadSuggestion(): void; - onSuggestionLoaded({ - suggestion, - suggestedPromotedDocuments, - curation, - }: { - suggestion: CurationSuggestion; - suggestedPromotedDocuments: Result[]; - curation: Curation; - }): { - suggestion: CurationSuggestion; - suggestedPromotedDocuments: Result[]; - curation: Curation; + onSuggestionLoaded({ suggestion }: { suggestion: HydratedCurationSuggestion }): { + suggestion: HydratedCurationSuggestion; }; acceptSuggestion(): void; acceptAndAutomateSuggestion(): void; @@ -63,10 +50,8 @@ export const CurationSuggestionLogic = kea< path: ['enterprise_search', 'app_search', 'curations', 'suggestion_logic'], actions: () => ({ loadSuggestion: true, - onSuggestionLoaded: ({ suggestion, suggestedPromotedDocuments, curation }) => ({ + onSuggestionLoaded: ({ suggestion }) => ({ suggestion, - suggestedPromotedDocuments, - curation, }), acceptSuggestion: true, acceptAndAutomateSuggestion: true, @@ -87,18 +72,6 @@ export const CurationSuggestionLogic = kea< onSuggestionLoaded: (_, { suggestion }) => suggestion, }, ], - suggestedPromotedDocuments: [ - [], - { - onSuggestionLoaded: (_, { suggestedPromotedDocuments }) => suggestedPromotedDocuments, - }, - ], - curation: [ - null, - { - onSuggestionLoaded: (_, { curation }) => curation, - }, - ], }), listeners: ({ actions, values, props }) => ({ loadSuggestion: async () => { @@ -106,34 +79,41 @@ export const CurationSuggestionLogic = kea< const { engineName } = EngineLogic.values; try { - const suggestion = await getSuggestion(http, engineName, props.query); - if (!suggestion) return; - const promotedIds: string[] = suggestion.promoted; - const documentDetailsResopnse = getDocumentDetails(http, engineName, promotedIds); - - let promises = [documentDetailsResopnse]; - if (suggestion.curation_id) { - promises = [...promises, getCuration(http, engineName, suggestion.curation_id)]; - } - - const [documentDetails, curation] = await Promise.all(promises); + const suggestionResponse = await http.get( + `/internal/app_search/engines/${engineName}/search_relevance_suggestions/${props.query}`, + { + query: { + type: 'curation', + }, + } + ); - // Filter out docs that were not found and maintain promoted order - const suggestedPromotedDocuments = promotedIds.reduce((acc: Result[], id: string) => { - const found = documentDetails.results.find( - (documentDetail: Result) => documentDetail.id.raw === id - ); - if (!found) return acc; - return [...acc, found]; - }, []); + // We pull the `organic` and `promoted` fields up to the main body of the suggestion, + // out of the nested `suggestion` field on the response + const { suggestion, ...baseSuggestion } = suggestionResponse; + const suggestionData = { + ...baseSuggestion, + promoted: suggestion.promoted, + organic: suggestion.organic, + }; actions.onSuggestionLoaded({ - suggestion, - suggestedPromotedDocuments, - curation: curation || null, + suggestion: suggestionData, }); } catch (e) { - flashAPIErrors(e); + if (e.response?.status === 404) { + const message = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.notFoundError', + { + defaultMessage: + 'Could not find suggestion, it may have already been applied or rejected.', + } + ); + setQueuedErrorMessage(message); + KibanaLogic.values.navigateToUrl(generateEnginePath(ENGINE_CURATIONS_PATH)); + } else { + flashAPIErrors(e); + } } }, acceptSuggestion: async () => { @@ -289,63 +269,6 @@ const updateSuggestion = async ( return response.results[0] as CurationSuggestion; }; -const getSuggestion = async ( - http: HttpSetup, - engineName: string, - query: string -): Promise => { - const response = await http.post( - `/internal/app_search/engines/${engineName}/search_relevance_suggestions/${query}`, - { - body: JSON.stringify({ - page: { - current: 1, - size: 1, - }, - filters: { - status: ['pending'], - type: 'curation', - }, - }), - } - ); - - if (response.results.length < 1) { - const message = i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.curations.suggestedCuration.notFoundError', - { - defaultMessage: 'Could not find suggestion, it may have already been applied or rejected.', - } - ); - setQueuedErrorMessage(message); - KibanaLogic.values.navigateToUrl(generateEnginePath(ENGINE_CURATIONS_PATH)); - return; - } - - const suggestion = response.results[0] as CurationSuggestion; - return suggestion; -}; - -const getDocumentDetails = async (http: HttpSetup, engineName: string, documentIds: string[]) => { - return http.post(`/internal/app_search/engines/${engineName}/search`, { - query: { query: '' }, - body: JSON.stringify({ - page: { - size: 100, - }, - filters: { - id: documentIds, - }, - }), - }); -}; - -const getCuration = async (http: HttpSetup, engineName: string, curationId: string) => { - return http.get(`/internal/app_search/engines/${engineName}/curations/${curationId}`, { - query: { skip_record_analytics: 'true' }, - }); -}; - const confirmDialog = (msg: string) => { return new Promise(function (resolve) { const confirmed = window.confirm(msg); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/temp_data.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/temp_data.ts deleted file mode 100644 index 83bbc977427a9..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/temp_data.ts +++ /dev/null @@ -1,470 +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 { Result } from '../../../result/types'; - -export const DATA: Result[] = [ - { - visitors: { - raw: 5028868.0, - }, - square_km: { - raw: 3082.7, - }, - world_heritage_site: { - raw: 'true', - snippet: 'true', - }, - date_established: { - raw: '1890-10-01T05:00:00+00:00', - }, - description: { - raw: "Yosemite features sheer granite cliffs, exceptionally tall waterfalls, and old-growth forests at a unique intersection of geology and hydrology. Half Dome and El Capitan rise from the park's centerpiece, the glacier-carved Yosemite Valley, and from its vertical walls drop Yosemite Falls, one of North America's tallest waterfalls at 2,425 feet (739 m) high. Three giant sequoia groves, along with a pristine wilderness in the heart of the Sierra Nevada, are home to a wide variety of rare plant and animal species.", - snippet: - 'Yosemite features sheer granite cliffs, exceptionally tall waterfalls, and old-growth forests', - }, - location: { - raw: '37.83,-119.5', - }, - acres: { - raw: 761747.5, - }, - title: { - raw: 'Yosemite', - snippet: 'Yosemite', - }, - nps_link: { - raw: 'https://www.nps.gov/yose/index.htm', - snippet: 'https://www.nps.gov/yose/index.htm', - }, - states: { - raw: ['California'], - snippet: 'California', - }, - _meta: { - engine: 'national-parks-demo', - score: 7543305.0, - id: 'park_yosemite', - }, - id: { - raw: 'park_yosemite', - }, - }, - { - visitors: { - raw: 4517585.0, - }, - square_km: { - raw: 1075.6, - }, - world_heritage_site: { - raw: 'false', - snippet: 'false', - }, - date_established: { - raw: '1915-01-26T06:00:00+00:00', - }, - description: { - raw: 'Bisected north to south by the Continental Divide, this portion of the Rockies has ecosystems varying from over 150 riparian lakes to montane and subalpine forests to treeless alpine tundra. Wildlife including mule deer, bighorn sheep, black bears, and cougars inhabit its igneous mountains and glacial valleys. Longs Peak, a classic Colorado fourteener, and the scenic Bear Lake are popular destinations, as well as the historic Trail Ridge Road, which reaches an elevation of more than 12,000 feet (3,700 m).', - snippet: - ' varying from over 150 riparian lakes to montane and subalpine forests to treeless alpine tundra. Wildlife', - }, - location: { - raw: '40.4,-105.58', - }, - acres: { - raw: 265795.2, - }, - title: { - raw: 'Rocky Mountain', - snippet: 'Rocky Mountain', - }, - nps_link: { - raw: 'https://www.nps.gov/romo/index.htm', - snippet: 'https://www.nps.gov/romo/index.htm', - }, - states: { - raw: ['Colorado'], - snippet: 'Colorado', - }, - _meta: { - engine: 'national-parks-demo', - score: 6776380.0, - id: 'park_rocky-mountain', - }, - id: { - raw: 'park_rocky-mountain', - }, - }, - { - visitors: { - raw: 4295127.0, - }, - square_km: { - raw: 595.8, - }, - world_heritage_site: { - raw: 'false', - snippet: 'false', - }, - date_established: { - raw: '1919-11-19T06:00:00+00:00', - }, - description: { - raw: 'Located at the junction of the Colorado Plateau, Great Basin, and Mojave Desert, this park contains sandstone features such as mesas, rock towers, and canyons, including the Virgin River Narrows. The various sandstone formations and the forks of the Virgin River create a wilderness divided into four ecosystems: desert, riparian, woodland, and coniferous forest.', - snippet: ' into four ecosystems: desert, riparian, woodland, and coniferous forest.', - }, - location: { - raw: '37.3,-113.05', - }, - acres: { - raw: 147237.02, - }, - title: { - raw: 'Zion', - snippet: 'Zion', - }, - nps_link: { - raw: 'https://www.nps.gov/zion/index.htm', - snippet: 'https://www.nps.gov/zion/index.htm', - }, - states: { - raw: ['Utah'], - snippet: 'Utah', - }, - _meta: { - engine: 'national-parks-demo', - score: 6442695.0, - id: 'park_zion', - }, - id: { - raw: 'park_zion', - }, - }, - { - visitors: { - raw: 3303393.0, - }, - square_km: { - raw: 198.5, - }, - world_heritage_site: { - raw: 'false', - snippet: 'false', - }, - date_established: { - raw: '1919-02-26T06:00:00+00:00', - }, - description: { - raw: 'Covering most of Mount Desert Island and other coastal islands, Acadia features the tallest mountain on the Atlantic coast of the United States, granite peaks, ocean shoreline, woodlands, and lakes. There are freshwater, estuary, forest, and intertidal habitats.', - snippet: - ' mountain on the Atlantic coast of the United States, granite peaks, ocean shoreline, woodlands, and lakes. There are freshwater, estuary, forest, and intertidal habitats.', - }, - location: { - raw: '44.35,-68.21', - }, - acres: { - raw: 49057.36, - }, - title: { - raw: 'Acadia', - snippet: 'Acadia', - }, - nps_link: { - raw: 'https://www.nps.gov/acad/index.htm', - snippet: 'https://www.nps.gov/acad/index.htm', - }, - states: { - raw: ['Maine'], - snippet: 'Maine', - }, - _meta: { - engine: 'national-parks-demo', - score: 4955094.5, - id: 'park_acadia', - }, - id: { - raw: 'park_acadia', - }, - }, - { - visitors: { - raw: 1887580.0, - }, - square_km: { - raw: 1308.9, - }, - world_heritage_site: { - raw: 'true', - snippet: 'true', - }, - date_established: { - raw: '1916-08-01T05:00:00+00:00', - }, - description: { - raw: "This park on the Big Island protects the Kīlauea and Mauna Loa volcanoes, two of the world's most active geological features. Diverse ecosystems range from tropical forests at sea level to barren lava beds at more than 13,000 feet (4,000 m).", - snippet: - ' active geological features. Diverse ecosystems range from tropical forests at sea level to barren lava beds at more than 13,000 feet (4,000 m).', - }, - location: { - raw: '19.38,-155.2', - }, - acres: { - raw: 323431.38, - }, - title: { - raw: 'Hawaii Volcanoes', - snippet: 'Hawaii Volcanoes', - }, - nps_link: { - raw: 'https://www.nps.gov/havo/index.htm', - snippet: 'https://www.nps.gov/havo/index.htm', - }, - states: { - raw: ['Hawaii'], - snippet: 'Hawaii', - }, - _meta: { - engine: 'national-parks-demo', - score: 2831373.2, - id: 'park_hawaii-volcanoes', - }, - id: { - raw: 'park_hawaii-volcanoes', - }, - }, - { - visitors: { - raw: 1437341.0, - }, - square_km: { - raw: 806.1, - }, - world_heritage_site: { - raw: 'false', - snippet: 'false', - }, - date_established: { - raw: '1935-12-26T06:00:00+00:00', - }, - description: { - raw: "Shenandoah's Blue Ridge Mountains are covered by hardwood forests that teem with a wide variety of wildlife. The Skyline Drive and Appalachian Trail run the entire length of this narrow park, along with more than 500 miles (800 km) of hiking trails passing scenic overlooks and cataracts of the Shenandoah River.", - snippet: - 'Shenandoah's Blue Ridge Mountains are covered by hardwood forests that teem with a wide variety', - }, - location: { - raw: '38.53,-78.35', - }, - acres: { - raw: 199195.27, - }, - title: { - raw: 'Shenandoah', - snippet: 'Shenandoah', - }, - nps_link: { - raw: 'https://www.nps.gov/shen/index.htm', - snippet: 'https://www.nps.gov/shen/index.htm', - }, - states: { - raw: ['Virginia'], - snippet: 'Virginia', - }, - _meta: { - engine: 'national-parks-demo', - score: 2156015.5, - id: 'park_shenandoah', - }, - id: { - raw: 'park_shenandoah', - }, - }, - { - visitors: { - raw: 1356913.0, - }, - square_km: { - raw: 956.6, - }, - world_heritage_site: { - raw: 'false', - snippet: 'false', - }, - date_established: { - raw: '1899-03-02T06:00:00+00:00', - }, - description: { - raw: 'Mount Rainier, an active stratovolcano, is the most prominent peak in the Cascades and is covered by 26 named glaciers including Carbon Glacier and Emmons Glacier, the largest in the contiguous United States. The mountain is popular for climbing, and more than half of the park is covered by subalpine and alpine forests and meadows seasonally in bloom with wildflowers. Paradise on the south slope is the snowiest place on Earth where snowfall is measured regularly. The Longmire visitor center is the start of the Wonderland Trail, which encircles the mountain.', - snippet: - ' by subalpine and alpine forests and meadows seasonally in bloom with wildflowers. Paradise on the south slope', - }, - location: { - raw: '46.85,-121.75', - }, - acres: { - raw: 236381.64, - }, - title: { - raw: 'Mount Rainier', - snippet: 'Mount Rainier', - }, - nps_link: { - raw: 'https://www.nps.gov/mora/index.htm', - snippet: 'https://www.nps.gov/mora/index.htm', - }, - states: { - raw: ['Washington'], - snippet: 'Washington', - }, - _meta: { - engine: 'national-parks-demo', - score: 2035372.0, - id: 'park_mount-rainier', - }, - id: { - raw: 'park_mount-rainier', - }, - }, - { - visitors: { - raw: 1254688.0, - }, - square_km: { - raw: 1635.2, - }, - world_heritage_site: { - raw: 'false', - snippet: 'false', - }, - date_established: { - raw: '1890-09-25T05:00:00+00:00', - }, - description: { - raw: "This park protects the Giant Forest, which boasts some of the world's largest trees, the General Sherman being the largest measured tree in the park. Other features include over 240 caves, a long segment of the Sierra Nevada including the tallest mountain in the contiguous United States, and Moro Rock, a large granite dome.", - snippet: - 'This park protects the Giant Forest, which boasts some of the world's largest trees, the General', - }, - location: { - raw: '36.43,-118.68', - }, - acres: { - raw: 404062.63, - }, - title: { - raw: 'Sequoia', - snippet: 'Sequoia', - }, - nps_link: { - raw: 'https://www.nps.gov/seki/index.htm', - snippet: 'https://www.nps.gov/seki/index.htm', - }, - states: { - raw: ['California'], - snippet: 'California', - }, - _meta: { - engine: 'national-parks-demo', - score: 1882038.0, - id: 'park_sequoia', - }, - id: { - raw: 'park_sequoia', - }, - }, - { - visitors: { - raw: 643274.0, - }, - square_km: { - raw: 896.0, - }, - world_heritage_site: { - raw: 'false', - snippet: 'false', - }, - date_established: { - raw: '1962-12-09T06:00:00+00:00', - }, - description: { - raw: 'This portion of the Chinle Formation has a large concentration of 225-million-year-old petrified wood. The surrounding Painted Desert features eroded cliffs of red-hued volcanic rock called bentonite. Dinosaur fossils and over 350 Native American sites are also protected in this park.', - snippet: - 'This portion of the Chinle Formation has a large concentration of 225-million-year-old petrified', - }, - location: { - raw: '35.07,-109.78', - }, - acres: { - raw: 221415.77, - }, - title: { - raw: 'Petrified Forest', - snippet: 'Petrified Forest', - }, - nps_link: { - raw: 'https://www.nps.gov/pefo/index.htm', - snippet: 'https://www.nps.gov/pefo/index.htm', - }, - states: { - raw: ['Arizona'], - snippet: 'Arizona', - }, - _meta: { - engine: 'national-parks-demo', - score: 964919.94, - id: 'park_petrified-forest', - }, - id: { - raw: 'park_petrified-forest', - }, - }, - { - visitors: { - raw: 617377.0, - }, - square_km: { - raw: 137.5, - }, - world_heritage_site: { - raw: 'false', - snippet: 'false', - }, - date_established: { - raw: '1903-01-09T06:00:00+00:00', - }, - description: { - raw: "Wind Cave is distinctive for its calcite fin formations called boxwork, a unique formation rarely found elsewhere, and needle-like growths called frostwork. The cave is one of the longest and most complex caves in the world. Above ground is a mixed-grass prairie with animals such as bison, black-footed ferrets, and prairie dogs, and ponderosa pine forests that are home to cougars and elk. The cave is culturally significant to the Lakota people as the site 'from which Wakan Tanka, the Great Mystery, sent the buffalo out into their hunting grounds.'", - snippet: - '-footed ferrets, and prairie dogs, and ponderosa pine forests that are home to cougars and elk', - }, - location: { - raw: '43.57,-103.48', - }, - acres: { - raw: 33970.84, - }, - title: { - raw: 'Wind Cave', - snippet: 'Wind Cave', - }, - nps_link: { - raw: 'https://www.nps.gov/wica/index.htm', - snippet: 'https://www.nps.gov/wica/index.htm', - }, - states: { - raw: ['South Dakota'], - snippet: 'South Dakota', - }, - _meta: { - engine: 'national-parks-demo', - score: 926068.7, - id: 'park_wind-cave', - }, - id: { - raw: 'park_wind-cave', - }, - }, -]; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts index 2bdcfb9fe9d58..daab7c35596bf 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts @@ -116,9 +116,9 @@ describe('search relevance insights routes', () => { }); }); - describe('POST /internal/app_search/engines/{name}/search_relevance_suggestions/{query}', () => { + describe('GET /internal/app_search/engines/{engineName}/search_relevance_suggestions/{query}', () => { const mockRouter = new MockRouter({ - method: 'post', + method: 'get', path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions/{query}', }); @@ -132,10 +132,11 @@ describe('search relevance insights routes', () => { it('creates a request to enterprise search', () => { mockRouter.callRoute({ params: { engineName: 'some-engine', query: 'foo' }, + query: { type: 'curation' }, }); expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v0/engines/:engineName/search_relevance_suggestions/:query', + path: '/as/engines/:engineName/search_relevance_suggestions/:query', }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts index 8b3b204c24d70..95b50a9c4971e 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts @@ -81,7 +81,7 @@ export function registerSearchRelevanceSuggestionsRoutes({ }) ); - router.post( + router.get( { path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions/{query}', validate: { @@ -89,20 +89,13 @@ export function registerSearchRelevanceSuggestionsRoutes({ engineName: schema.string(), query: schema.string(), }), - body: schema.object({ - page: schema.object({ - current: schema.number(), - size: schema.number(), - }), - filters: schema.object({ - status: schema.arrayOf(schema.string()), - type: schema.string(), - }), + query: schema.object({ + type: schema.string(), }), }, }, enterpriseSearchRequestHandler.createRequest({ - path: '/api/as/v0/engines/:engineName/search_relevance_suggestions/:query', + path: '/as/engines/:engineName/search_relevance_suggestions/:query', }) ); } From 498050e05b3505b8afdb03860cb7d26db780c689 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 19 Oct 2021 09:15:38 -0700 Subject: [PATCH 26/30] Upgrade EUI to v39.1.1 (#114732) * Upversion to EUI 39.1.0 * Update i18n_eui_mapping tokens @see https://github.com/elastic/eui/blob/master/i18ntokens_changelog.json * Merge refractor in yarn.lock * Fix functional table filter selector - Popover ID was removed in recent EUI a11y fix, so we're using child-position selection to target the Tags filter now * Update snaphots * Upgrade to 39.1.1 for extra bugfixes * Update i18n mappings * Fix i18n snapshot * Attempt to harden flaky Security Cypress test * More combobox entry hardening - Got a flake on clicking the combobox dropdown on run 17/20 locally Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- .../__snapshots__/i18n_service.test.tsx.snap | 1 + src/core/public/i18n/i18n_eui_mapping.tsx | 5 +- src/dev/license_checker/config.ts | 2 +- .../__snapshots__/data_view.test.tsx.snap | 1114 +++++++++-------- .../List/__snapshots__/List.test.tsx.snap | 4 +- .../workpad_table.stories.storyshot | 8 +- .../report_listing.test.tsx.snap | 584 ++++----- .../cypress/screens/timeline.ts | 2 + .../cypress/tasks/timeline.ts | 10 +- .../alert_summary_view.test.tsx.snap | 4 - .../__snapshots__/donut_chart.test.tsx.snap | 2 - .../functional/tests/som_integration.ts | 5 +- yarn.lock | 12 +- 14 files changed, 897 insertions(+), 858 deletions(-) diff --git a/package.json b/package.json index 47ed5e110b000..b3b9b96e75566 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.21", "@elastic/ems-client": "7.16.0", - "@elastic/eui": "39.0.0", + "@elastic/eui": "39.1.1", "@elastic/filesaver": "1.1.2", "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index d714f2159d1a2..197714df7f207 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -175,6 +175,7 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiRefreshInterval.legend": "Refresh every", "euiRefreshInterval.start": "Start", "euiRefreshInterval.stop": "Stop", + "euiRelativeTab.dateInputError": "Must be a valid range", "euiRelativeTab.fullDescription": [Function], "euiRelativeTab.numberInputError": "Must be >= 0", "euiRelativeTab.numberInputLabel": "Time span amount", diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index 7585ada886c05..f28add25056ee 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -64,7 +64,7 @@ export const getEuiContextMapping = (): EuiTokensObject => { }), 'euiBasicTable.tablePagination': ({ tableCaption }: EuiValues) => i18n.translate('core.euiBasicTable.tablePagination', { - defaultMessage: 'Pagination for preceding table: {tableCaption}', + defaultMessage: 'Pagination for table: {tableCaption}', values: { tableCaption }, description: 'Screen reader text to describe the pagination controls', }), @@ -861,6 +861,9 @@ export const getEuiContextMapping = (): EuiTokensObject => { 'euiRelativeTab.numberInputLabel': i18n.translate('core.euiRelativeTab.numberInputLabel', { defaultMessage: 'Time span amount', }), + 'euiRelativeTab.dateInputError': i18n.translate('core.euiRelativeTab.dateInputError', { + defaultMessage: 'Must be a valid range', + }), 'euiResizableButton.horizontalResizerAriaLabel': i18n.translate( 'core.euiResizableButton.horizontalResizerAriaLabel', { diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index a4ae39848735e..efa54e74fdf2f 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -75,6 +75,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@7.16.0': ['Elastic License 2.0'], - '@elastic/eui@39.0.0': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@39.1.1': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index eae2032748396..7773f2209bf96 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -1220,93 +1220,75 @@ exports[`Inspector Data View component should render single table without select
- -
- -
- - - +
+
+ + + - -
- - - : - 20 - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" +
-
-
+ - - + + + +
-
-
-
-
- -
+
+
+ - - - -
- -
-
-
-
- + + +
+ +
+ + +
+ + @@ -2791,93 +2806,75 @@ exports[`Inspector Data View component should support multiple datatables 1`] = - -
- -
- - - +
+
+ + + - -
- - - : - 20 - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" +
-
-
+ - - + + + +
-
-
-
-
- -
+
+
+ - - - -
- -
-
-
-
- + + +
+
+ +
+ + + + diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap index c8c7bf82dff04..ee68630daa469 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap @@ -151,7 +151,6 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` className="euiTableHeaderCell" data-test-subj="tableHeaderCell_handled_3" role="columnheader" - scope="col" style={ Object { "width": undefined, @@ -447,7 +446,6 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` className="euiTableHeaderCell" data-test-subj="tableHeaderCell_handled_3" role="columnheader" - scope="col" style={ Object { "width": undefined, @@ -1265,6 +1263,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` className="euiFlexItem euiFlexItem--flexGrowZero" > + + +
+ + + + + + `; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 2e412bbed6fdc..bc3a4282df1c9 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -28,6 +28,8 @@ export const CLOSE_TIMELINE_BTN = '[data-test-subj="close-timeline"]'; export const COMBO_BOX = '.euiComboBoxOption__content'; +export const COMBO_BOX_INPUT = '[data-test-subj="comboBoxInput"]'; + export const CREATE_NEW_TIMELINE = '[data-test-subj="timeline-new"]'; export const CREATE_NEW_TIMELINE_TEMPLATE = '[data-test-subj="template-timeline-new"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 4c6b73de80940..c7cb56c89e9df 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -20,6 +20,7 @@ import { CASE, CLOSE_TIMELINE_BTN, COMBO_BOX, + COMBO_BOX_INPUT, CREATE_NEW_TIMELINE, FIELD_BROWSER, ID_HEADER_FIELD, @@ -164,9 +165,12 @@ export const addDataProvider = (filter: TimelineFilter): Cypress.Chainable { // open the filter dropdown - // the first class selector before the id is of course useless. Only here to help cleaning that once we got - // testSubjects in EUI filters. + // This CSS selector should be cleaned up once we have testSubjects in EUI filters. const filterButton = await find.byCssSelector( - '.euiFilterGroup #field_value_selection_1 .euiFilterButton' + '.euiFilterGroup > *:last-child .euiFilterButton' ); await filterButton.click(); // select the tags diff --git a/yarn.lock b/yarn.lock index e5e2f59359c9f..d715897b57e28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2412,10 +2412,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@39.0.0": - version "39.0.0" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-39.0.0.tgz#abac19edd466eee13612d5668e5456961dc813b8" - integrity sha512-8sf8sbxjpRxV23dFTwbkaWH6LhWrOlMpdUUMVUC9zd0g5iQLj1IxkxQCeyYM/p++SQFl+1hshAuaH//DCz5Xrw== +"@elastic/eui@39.1.1": + version "39.1.1" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-39.1.1.tgz#52e59f1dd6448b2e80047259ca60c6c87e9873f0" + integrity sha512-zYCNitpp6Ds7U6eaa9QkJqc20ZMo2wjpZokNtd1WalFV22vdfiVizFg7DMtDjJrCDLmoXcLOOCMasKlmmJ1cRg== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160" @@ -2441,7 +2441,7 @@ react-is "~16.3.0" react-virtualized-auto-sizer "^1.0.2" react-window "^1.8.5" - refractor "^3.4.0" + refractor "^3.5.0" rehype-raw "^5.0.0" rehype-react "^6.0.0" rehype-stringify "^8.0.0" @@ -24731,7 +24731,7 @@ reflect.ownkeys@^0.2.0: resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA= -refractor@^3.2.0, refractor@^3.4.0: +refractor@^3.2.0, refractor@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/refractor/-/refractor-3.5.0.tgz#334586f352dda4beaf354099b48c2d18e0819aec" integrity sha512-QwPJd3ferTZ4cSPPjdP5bsYHMytwWYnAN5EEnLtGvkqp/FCCnGsBgxrm9EuIDnjUC3Uc/kETtvVi7fSIVC74Dg== From 83f12a9d821f7b7a081dffad59da8797a78c3686 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 19 Oct 2021 18:38:27 +0200 Subject: [PATCH 27/30] Change default session idle timeout to 8 hours. (#115565) --- docs/settings/security-settings.asciidoc | 2 +- docs/user/security/session-management.asciidoc | 2 +- x-pack/plugins/security/server/config.test.ts | 18 +++++++++--------- x-pack/plugins/security/server/config.ts | 2 +- .../security_usage_collector.test.ts | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 11072509da1fc..c291b65c3c35b 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -272,7 +272,7 @@ You can configure the following settings in the `kibana.yml` file. |[[xpack-session-idleTimeout]] `xpack.security.session.idleTimeout` {ess-icon} | Ensures that user sessions will expire after a period of inactivity. This and <> are both -highly recommended. You can also specify this setting for <>. If this is set to `0`, then sessions will never expire due to inactivity. By default, this value is 1 hour. +highly recommended. You can also specify this setting for <>. If this is set to `0`, then sessions will never expire due to inactivity. By default, this value is 8 hours. 2+a| [TIP] diff --git a/docs/user/security/session-management.asciidoc b/docs/user/security/session-management.asciidoc index b0f27d45bb826..e896c8fe77254 100644 --- a/docs/user/security/session-management.asciidoc +++ b/docs/user/security/session-management.asciidoc @@ -12,7 +12,7 @@ To manage user sessions programmatically, {kib} exposes <[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the idle timeout to expire sessions after 30 minutes of inactivity: +By default, sessions expire after 8 hours of inactivity. To define another value for a sliding session expiration, set the property in the `kibana.yml` configuration file. The idle timeout is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the idle timeout to expire sessions after 30 minutes of inactivity: -- [source,yaml] diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 1baf3fd4aac50..4034a7a79e6dd 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -63,7 +63,7 @@ describe('config schema', () => { "secureCookies": false, "session": Object { "cleanupInterval": "PT1H", - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "P30D", }, "showInsecureClusterWarning": true, @@ -117,7 +117,7 @@ describe('config schema', () => { "secureCookies": false, "session": Object { "cleanupInterval": "PT1H", - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "P30D", }, "showInsecureClusterWarning": true, @@ -170,7 +170,7 @@ describe('config schema', () => { "secureCookies": false, "session": Object { "cleanupInterval": "PT1H", - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "P30D", }, "showInsecureClusterWarning": true, @@ -1768,7 +1768,7 @@ describe('createConfig()', () => { expect(createMockConfig().session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "P30D", } `); @@ -1818,7 +1818,7 @@ describe('createConfig()', () => { }) ).toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "PT0.456S", } `); @@ -1852,7 +1852,7 @@ describe('createConfig()', () => { createMockConfig({ session: { lifespan: 456 } }).session.getExpirationTimeouts(provider) ).toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "PT0.456S", } `); @@ -1933,14 +1933,14 @@ describe('createConfig()', () => { expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "PT0.654S", } `); expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "PT11M5.544S", } `); @@ -1957,7 +1957,7 @@ describe('createConfig()', () => { expect(configWithGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "PT0.654S", } `); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 89918e73369d3..23a1fd2efa382 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -211,7 +211,7 @@ export const ConfigSchema = schema.object({ ), session: schema.object({ idleTimeout: schema.oneOf([schema.duration(), schema.literal(null)], { - defaultValue: schema.duration().validate('1h'), + defaultValue: schema.duration().validate('8h'), }), lifespan: schema.oneOf([schema.duration(), schema.literal(null)], { defaultValue: schema.duration().validate('30d'), diff --git a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts index 83f09ef017b01..3a53a2422770c 100644 --- a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts +++ b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts @@ -47,7 +47,7 @@ describe('Security UsageCollector', () => { enabledAuthProviders: ['basic'], loginSelectorEnabled: false, httpAuthSchemes: ['apikey', 'bearer'], - sessionIdleTimeoutInMinutes: 60, + sessionIdleTimeoutInMinutes: 480, sessionLifespanInMinutes: 43200, sessionCleanupInMinutes: 60, }; From 20b11c9f432a7a9b6ee3b5a1f23e027913795366 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 19 Oct 2021 19:39:51 +0300 Subject: [PATCH 28/30] [Cases][Connectors] ServiceNow ITOM: MVP (#114125) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/management/action-types.asciidoc | 4 + .../action-types/servicenow-itom.asciidoc | 90 +++++ .../action-types/servicenow-sir.asciidoc | 2 +- .../action-types/servicenow.asciidoc | 2 +- .../images/servicenow-itom-connector.png | Bin 0 -> 197351 bytes .../images/servicenow-itom-params-test.png | Bin 0 -> 261091 bytes docs/management/connectors/index.asciidoc | 1 + x-pack/plugins/actions/README.md | 51 ++- .../server/builtin_action_types/index.ts | 13 +- .../servicenow/api.test.ts | 3 + .../servicenow/api_itom.test.ts | 53 +++ .../servicenow/api_itom.ts | 70 ++++ .../servicenow/config.test.ts | 11 + .../builtin_action_types/servicenow/config.ts | 10 + .../servicenow/index.test.ts | 6 +- .../builtin_action_types/servicenow/index.ts | 152 ++++++-- .../builtin_action_types/servicenow/mocks.ts | 30 ++ .../builtin_action_types/servicenow/schema.ts | 38 +- .../servicenow/service_itom.test.ts | 90 +++++ .../servicenow/service_itom.ts | 65 ++++ .../servicenow/service_sir.ts | 2 +- .../servicenow/translations.ts | 4 + .../builtin_action_types/servicenow/types.ts | 51 ++- .../servicenow/utils.test.ts | 54 ++- .../builtin_action_types/servicenow/utils.ts | 25 ++ .../actions/server/constants/connectors.ts | 3 + .../security_solution/common/constants.ts | 7 + .../components/builtin_action_types/index.ts | 13 +- .../servicenow/helpers.test.ts | 4 + .../servicenow/helpers.ts | 4 +- .../builtin_action_types/servicenow/index.ts | 6 +- .../servicenow/servicenow.test.tsx | 33 +- .../servicenow/servicenow.tsx | 46 +++ .../servicenow_connectors_no_app.tsx | 33 ++ .../servicenow_itom_params.test.tsx | 179 +++++++++ .../servicenow/servicenow_itom_params.tsx | 160 ++++++++ .../servicenow/servicenow_sir_params.tsx | 2 +- .../servicenow/translations.ts | 80 ++++ .../builtin_action_types/servicenow/types.ts | 6 + .../servicenow/use_choices.test.tsx | 164 +++++++++ .../servicenow/use_choices.tsx | 66 ++++ .../servicenow/use_get_choices.test.tsx | 24 +- .../servicenow/use_get_choices.tsx | 7 +- .../alerting_api_integration/common/config.ts | 1 + .../server/servicenow_simulation.ts | 8 + .../actions/builtin_action_types/jira.ts | 4 - .../actions/builtin_action_types/resilient.ts | 4 - .../builtin_action_types/servicenow_itom.ts | 342 ++++++++++++++++++ .../builtin_action_types/servicenow_itsm.ts | 4 - .../builtin_action_types/servicenow_sir.ts | 4 - .../actions/builtin_action_types/swimlane.ts | 4 - .../tests/actions/index.ts | 1 + 52 files changed, 1956 insertions(+), 80 deletions(-) create mode 100644 docs/management/connectors/action-types/servicenow-itom.asciidoc create mode 100644 docs/management/connectors/images/servicenow-itom-connector.png create mode 100644 docs/management/connectors/images/servicenow-itom-params-test.png create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_no_app.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_choices.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_choices.tsx create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 93d0ee3d2cab6..b2bf5f2bbe308 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -43,6 +43,10 @@ a| <> | Create a security incident in ServiceNow. +a| <> + +| Create an event in ServiceNow. + a| <> | Send a message to a Slack channel or user. diff --git a/docs/management/connectors/action-types/servicenow-itom.asciidoc b/docs/management/connectors/action-types/servicenow-itom.asciidoc new file mode 100644 index 0000000000000..017290dde9b15 --- /dev/null +++ b/docs/management/connectors/action-types/servicenow-itom.asciidoc @@ -0,0 +1,90 @@ +[role="xpack"] +[[servicenow-itom-action-type]] +=== ServiceNow connector and action +++++ +ServiceNow ITOM +++++ + +The ServiceNow ITOM connector uses the https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html[Event API] to create ServiceNow events. + +[float] +[[servicenow-itom-connector-configuration]] +==== Connector configuration + +ServiceNow ITOM connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +URL:: ServiceNow instance URL. +Username:: Username for HTTP Basic authentication. +Password:: Password for HTTP Basic authentication. + +The ServiceNow user requires at minimum read, create, and update access to the Event table and read access to the https://docs.servicenow.com/bundle/paris-platform-administration/page/administer/localization/reference/r_ChoicesTable.html[sys_choice]. If you don't provide access to sys_choice, then the choices will not render. + +[float] +[[servicenow-itom-connector-networking-configuration]] +==== Connector networking configuration + +Use the <> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations. + +[float] +[[Preconfigured-servicenow-itom-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-servicenow-itom: + name: preconfigured-servicenow-connector-type + actionTypeId: .servicenow-itom + config: + apiUrl: https://example.service-now.com/ + secrets: + username: testuser + password: passwordkeystorevalue +-- + +Config defines information for the connector type. + +`apiUrl`:: An address that corresponds to *URL*. + +Secrets defines sensitive information for the connector type. + +`username`:: A string that corresponds to *Username*. +`password`:: A string that corresponds to *Password*. Should be stored in the <>. + +[float] +[[define-servicenow-itom-ui]] +==== Define connector in Stack Management + +Define ServiceNow ITOM connector properties. + +[role="screenshot"] +image::management/connectors/images/servicenow-itom-connector.png[ServiceNow ITOM connector] + +Test ServiceNow ITOM action parameters. + +[role="screenshot"] +image::management/connectors/images/servicenow-itom-params-test.png[ServiceNow ITOM params test] + +[float] +[[servicenow-itom-action-configuration]] +==== Action configuration + +ServiceNow ITOM actions have the following configuration properties. + +Source:: The name of the event source type. +Node:: The Host that the event was triggered for. +Type:: The type of event. +Resource:: The name of the resource. +Metric name:: Name of the metric. +Source instance (event_class):: Specific instance of the source. +Message key:: All actions sharing this key will be associated with the same ServiceNow alert. Default value: `:`. +Severity:: The severity of the event. +Description:: The details about the event. + +Refer to https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html[ServiceNow documentation] for more information about the properties. + +[float] +[[configuring-servicenow-itom]] +==== Configure ServiceNow ITOM + +ServiceNow offers free https://developer.servicenow.com/dev.do#!/guides/madrid/now-platform/pdi-guide/obtaining-a-pdi[Personal Developer Instances], which you can use to test incidents. diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc index 2fa49fe552c2e..40fb07897d206 100644 --- a/docs/management/connectors/action-types/servicenow-sir.asciidoc +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -36,7 +36,7 @@ Use the <> to customize connecto name: preconfigured-servicenow-connector-type actionTypeId: .servicenow-sir config: - apiUrl: https://dev94428.service-now.com/ + apiUrl: https://example.service-now.com/ secrets: username: testuser password: passwordkeystorevalue diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index f7c3187f3f024..eae1fce75731d 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -36,7 +36,7 @@ Use the <> to customize connecto name: preconfigured-servicenow-connector-type actionTypeId: .servicenow config: - apiUrl: https://dev94428.service-now.com/ + apiUrl: https://example.service-now.com/ secrets: username: testuser password: passwordkeystorevalue diff --git a/docs/management/connectors/images/servicenow-itom-connector.png b/docs/management/connectors/images/servicenow-itom-connector.png new file mode 100644 index 0000000000000000000000000000000000000000..5b73336d21b47ccd17fa4c365e7abdf6098bf93c GIT binary patch literal 197351 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

DKX_?cb0t2AuU+R8^!# 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 literal 0 HcmV?d00001 diff --git a/docs/management/connectors/images/servicenow-itom-params-test.png b/docs/management/connectors/images/servicenow-itom-params-test.png new file mode 100644 index 0000000000000000000000000000000000000000..8d7b1e075911a7b49fe5040d16d62b3cc74cbcf1 GIT binary patch literal 261091 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%0DiEGfSp=~e;hbwV090pXn@|b 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 literal 0 HcmV?d00001 diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 536d05705181d..6968475cf3a4e 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -7,6 +7,7 @@ include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/servicenow.asciidoc[] include::action-types/servicenow-sir.asciidoc[] +include::action-types/servicenow-itom.asciidoc[] include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 0d66c9d30f8b9..f838832b6ea66 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -45,9 +45,12 @@ Table of Contents - [`subActionParams (getFields)`](#subactionparams-getfields-1) - [`subActionParams (getIncident)`](#subactionparams-getincident-1) - [`subActionParams (getChoices)`](#subactionparams-getchoices-1) - - [| fields | An array of fields. Example: `[priority, category]`. | string[] |](#-fields----an-array-of-fields-example-priority-category--string-) - - [Jira](#jira) + - [ServiceNow ITOM](#servicenow-itom) - [`params`](#params-2) + - [`subActionParams (addEvent)`](#subactionparams-addevent) + - [`subActionParams (getChoices)`](#subactionparams-getchoices-2) + - [Jira](#jira) + - [`params`](#params-3) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) - [`subActionParams (getIncident)`](#subactionparams-getincident-2) - [`subActionParams (issueTypes)`](#subactionparams-issuetypes) @@ -56,13 +59,13 @@ Table of Contents - [`subActionParams (issue)`](#subactionparams-issue) - [`subActionParams (getFields)`](#subactionparams-getfields-2) - [IBM Resilient](#ibm-resilient) - - [`params`](#params-3) + - [`params`](#params-4) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3) - [`subActionParams (getFields)`](#subactionparams-getfields-3) - [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes) - [`subActionParams (severity)`](#subactionparams-severity) - [Swimlane](#swimlane) - - [`params`](#params-4) + - [`params`](#params-5) - [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) @@ -355,6 +358,43 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`. | Property | Description | Type | | -------- | ---------------------------------------------------- | -------- | | fields | An array of fields. Example: `[priority, category]`. | string[] | + +--- +## ServiceNow ITOM + +The [ServiceNow ITOM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-itom-action-type.html) lists configuration properties for the `addEvent` subaction. In addition, several other subaction types are available. +### `params` + +| Property | Description | Type | +| --------------- | ----------------------------------------------------------------- | ------ | +| subAction | The subaction to perform. It can be `addEvent`, and `getChoices`. | string | +| subActionParams | The parameters of the subaction. | object | + +#### `subActionParams (addEvent)` + + +| Property | Description | Type | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| source | The name of the event source type. | string _(optional)_ | +| event_class | Specific instance of the source. | string _(optional)_ | +| resource | The name of the resource. | string _(optional)_ | +| node | The Host that the event was triggered for. | string _(optional)_ | +| metric_name | Name of the metric. | string _(optional)_ | +| type | The type of event. | string _(optional)_ | +| severity | The category in ServiceNow. | string _(optional)_ | +| description | The subcategory in ServiceNow. | string _(optional)_ | +| additional_info | Any additional information about the event. | string _(optional)_ | +| message_key | This value is used for de-duplication of events. All actions sharing this key will be associated with the same ServiceNow alert. | string _(optional)_ | +| time_of_event | The time of the event. | string _(optional)_ | + +Refer to [ServiceNow documentation](https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html) for more information about the properties. + +#### `subActionParams (getChoices)` + +| Property | Description | Type | +| -------- | ------------------------------------------ | -------- | +| fields | An array of fields. Example: `[severity]`. | string[] | + --- ## Jira @@ -418,6 +458,7 @@ No parameters for the `issueTypes` subaction. Provide an empty object `{}`. No parameters for the `getFields` subaction. Provide an empty object `{}`. --- + ## IBM Resilient The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/kibana/master/resilient-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. @@ -545,4 +586,4 @@ Instead of `schema.maybe()`, use `schema.nullable()`, which is the same as `sche ## user interface -To make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui). +To make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui). \ No newline at end of file diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 07859cba4c371..3351a36b38344 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -16,10 +16,15 @@ import { getActionType as getSwimlaneActionType } from './swimlane'; import { getActionType as getServerLogActionType } from './server_log'; import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; -import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow'; +import { + getServiceNowITSMActionType, + getServiceNowSIRActionType, + getServiceNowITOMActionType, +} from './servicenow'; import { getActionType as getJiraActionType } from './jira'; import { getActionType as getResilientActionType } from './resilient'; import { getActionType as getTeamsActionType } from './teams'; +import { ENABLE_ITOM } from '../constants/connectors'; export { ActionParamsType as EmailActionParams, ActionTypeId as EmailActionTypeId } from './email'; export { ActionParamsType as IndexActionParams, @@ -42,6 +47,7 @@ export { ActionParamsType as ServiceNowActionParams, ServiceNowITSMActionTypeId, ServiceNowSIRActionTypeId, + ServiceNowITOMActionTypeId, } from './servicenow'; export { ActionParamsType as JiraActionParams, ActionTypeId as JiraActionTypeId } from './jira'; export { @@ -75,4 +81,9 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getJiraActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getResilientActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getTeamsActionType({ logger, configurationUtilities })); + + // TODO: Remove when ITOM is ready + if (ENABLE_ITOM) { + actionTypeRegistry.register(getServiceNowITOMActionType({ logger, configurationUtilities })); + } } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index e1f66263729e2..7969f2e53d3d9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -361,6 +361,7 @@ describe('api', () => { const res = await api.getFields({ externalService, params: {}, + logger: mockedLogger, }); expect(res).toEqual(serviceNowCommonFields); }); @@ -371,6 +372,7 @@ describe('api', () => { const res = await api.getChoices({ externalService, params: { fields: ['priority'] }, + logger: mockedLogger, }); expect(res).toEqual(serviceNowChoices); }); @@ -383,6 +385,7 @@ describe('api', () => { params: { externalId: 'incident-1', }, + logger: mockedLogger, }); expect(res).toEqual({ description: 'description from servicenow', diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.test.ts new file mode 100644 index 0000000000000..c918c4a52670a --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '../../../../../../src/core/server'; +import { externalServiceITOMMock, itomEventParams } from './mocks'; +import { ExternalServiceITOM } from './types'; +import { apiITOM, prepareParams } from './api_itom'; +let mockedLogger: jest.Mocked; + +describe('api_itom', () => { + let externalService: jest.Mocked; + const eventParamsWithFormattedDate = { + ...itomEventParams, + time_of_event: '2021-10-13, 10:51:44', + }; + + beforeEach(() => { + externalService = externalServiceITOMMock.create(); + jest.clearAllMocks(); + }); + + describe('prepareParams', () => { + test('it prepares the params correctly', async () => { + expect(prepareParams(itomEventParams)).toEqual(eventParamsWithFormattedDate); + }); + + test('it removes null values', async () => { + const { time_of_event: timeOfEvent, ...rest } = itomEventParams; + expect(prepareParams({ ...rest, time_of_event: null })).toEqual(rest); + }); + + test('it set the time to null if it is not a proper date', async () => { + const { time_of_event: timeOfEvent, ...rest } = itomEventParams; + expect(prepareParams({ ...rest, time_of_event: 'not a proper date' })).toEqual(rest); + }); + }); + + describe('addEvent', () => { + test('it adds an event correctly', async () => { + await apiITOM.addEvent({ + externalService, + params: itomEventParams, + logger: mockedLogger, + }); + + expect(externalService.addEvent).toHaveBeenCalledWith(eventParamsWithFormattedDate); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.ts new file mode 100644 index 0000000000000..668e17a042718 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { api } from './api'; +import { + ExecutorSubActionAddEventParams, + AddEventApiHandlerArgs, + ExternalServiceApiITOM, +} from './types'; + +const isValidDate = (d: Date) => !isNaN(d.valueOf()); + +const formatTimeOfEvent = (timeOfEvent: string | null): string | undefined => { + if (timeOfEvent != null) { + const date = new Date(timeOfEvent); + + return isValidDate(date) + ? // The format is: yyyy-MM-dd HH:mm:ss GMT + date.toLocaleDateString('en-CA', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hour12: false, + minute: '2-digit', + second: '2-digit', + timeZone: 'GMT', + }) + : undefined; + } +}; + +const removeNullValues = ( + params: ExecutorSubActionAddEventParams +): ExecutorSubActionAddEventParams => + (Object.keys(params) as Array).reduce( + (acc, key) => ({ + ...acc, + ...(params[key] != null ? { [key]: params[key] } : {}), + }), + {} as ExecutorSubActionAddEventParams + ); + +export const prepareParams = ( + params: ExecutorSubActionAddEventParams +): ExecutorSubActionAddEventParams => { + const timeOfEvent = formatTimeOfEvent(params.time_of_event); + return removeNullValues({ + ...params, + time_of_event: timeOfEvent ?? null, + }); +}; + +const addEventServiceHandler = async ({ + externalService, + params, +}: AddEventApiHandlerArgs): Promise => { + const itomExternalService = externalService; + const preparedParams = prepareParams(params); + await itomExternalService.addEvent(preparedParams); +}; + +export const apiITOM: ExternalServiceApiITOM = { + getChoices: api.getChoices, + addEvent: addEventServiceHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts index babd360cbcb82..41f723bc9e2aa 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts @@ -37,4 +37,15 @@ describe('config', () => { commentFieldKey: 'work_notes', }); }); + + test('ITOM: the config are correct', async () => { + const snConfig = snExternalServiceConfig['.servicenow-itom']; + expect(snConfig).toEqual({ + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'em_event', + useImportAPI: true, + commentFieldKey: 'work_notes', + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts index 37e4c6994b403..52d2eb7662f53 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts @@ -6,6 +6,7 @@ */ import { + ENABLE_ITOM, ENABLE_NEW_SN_ITSM_CONNECTOR, ENABLE_NEW_SN_SIR_CONNECTOR, } from '../../constants/connectors'; @@ -16,6 +17,7 @@ export const serviceNowSIRTable = 'sn_si_incident'; export const ServiceNowITSMActionTypeId = '.servicenow'; export const ServiceNowSIRActionTypeId = '.servicenow-sir'; +export const ServiceNowITOMActionTypeId = '.servicenow-itom'; export const snExternalServiceConfig: SNProductsConfig = { '.servicenow': { @@ -32,6 +34,14 @@ export const snExternalServiceConfig: SNProductsConfig = { useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR, commentFieldKey: 'work_notes', }, + '.servicenow-itom': { + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'em_event', + useImportAPI: ENABLE_ITOM, + commentFieldKey: 'work_notes', + }, }; export const FIELD_PREFIX = 'u_'; +export const DEFAULT_ALERTS_GROUPING_KEY = '{{rule.id}}:{{alert.id}}'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts index b342844033994..6ba8b80dfc09c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -8,7 +8,7 @@ import { actionsMock } from '../../mocks'; import { createActionTypeRegistry } from '../index.test'; import { - ServiceNowPublicConfigurationType, + ServiceNowPublicConfigurationBaseType, ServiceNowSecretConfigurationType, ExecutorParams, PushToServiceResponse, @@ -56,7 +56,7 @@ describe('ServiceNow', () => { beforeAll(() => { const { actionTypeRegistry } = createActionTypeRegistry(); actionType = actionTypeRegistry.get< - ServiceNowPublicConfigurationType, + ServiceNowPublicConfigurationBaseType, ServiceNowSecretConfigurationType, ExecutorParams, PushToServiceResponse | {} @@ -91,7 +91,7 @@ describe('ServiceNow', () => { beforeAll(() => { const { actionTypeRegistry } = createActionTypeRegistry(); actionType = actionTypeRegistry.get< - ServiceNowPublicConfigurationType, + ServiceNowPublicConfigurationBaseType, ServiceNowSecretConfigurationType, ExecutorParams, PushToServiceResponse | {} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index 29907381d45da..1e07cf858f332 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -11,9 +11,11 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { validate } from './validators'; import { ExternalIncidentServiceConfiguration, + ExternalIncidentServiceConfigurationBase, ExternalIncidentServiceSecretConfiguration, ExecutorParamsSchemaITSM, ExecutorParamsSchemaSIR, + ExecutorParamsSchemaITOM, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; @@ -32,8 +34,14 @@ import { ExecutorSubActionGetChoicesParams, ServiceFactory, ExternalServiceAPI, + ExecutorParamsITOM, + ExecutorSubActionAddEventParams, + ExternalServiceApiITOM, + ExternalServiceITOM, + ServiceNowPublicConfigurationBaseType, } from './types'; import { + ServiceNowITOMActionTypeId, ServiceNowITSMActionTypeId, serviceNowITSMTable, ServiceNowSIRActionTypeId, @@ -42,12 +50,16 @@ import { } from './config'; import { createExternalServiceSIR } from './service_sir'; import { apiSIR } from './api_sir'; +import { throwIfSubActionIsNotSupported } from './utils'; +import { createExternalServiceITOM } from './service_itom'; +import { apiITOM } from './api_itom'; export { ServiceNowITSMActionTypeId, serviceNowITSMTable, ServiceNowSIRActionTypeId, serviceNowSIRTable, + ServiceNowITOMActionTypeId, }; export type ActionParamsType = @@ -59,21 +71,20 @@ interface GetActionTypeParams { configurationUtilities: ActionsConfigurationUtilities; } -export type ServiceNowActionType = ActionType< - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ExecutorParams, - PushToServiceResponse | {} ->; +export type ServiceNowActionType< + C extends Record = ServiceNowPublicConfigurationBaseType, + T extends Record = ExecutorParams +> = ActionType; -export type ServiceNowActionTypeExecutorOptions = ActionTypeExecutorOptions< - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ExecutorParams ->; +export type ServiceNowActionTypeExecutorOptions< + C extends Record = ServiceNowPublicConfigurationBaseType, + T extends Record = ExecutorParams +> = ActionTypeExecutorOptions; // action type definition -export function getServiceNowITSMActionType(params: GetActionTypeParams): ServiceNowActionType { +export function getServiceNowITSMActionType( + params: GetActionTypeParams +): ServiceNowActionType { const { logger, configurationUtilities } = params; return { id: ServiceNowITSMActionTypeId, @@ -98,7 +109,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic }; } -export function getServiceNowSIRActionType(params: GetActionTypeParams): ServiceNowActionType { +export function getServiceNowSIRActionType( + params: GetActionTypeParams +): ServiceNowActionType { const { logger, configurationUtilities } = params; return { id: ServiceNowSIRActionTypeId, @@ -123,6 +136,33 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service }; } +export function getServiceNowITOMActionType( + params: GetActionTypeParams +): ServiceNowActionType { + const { logger, configurationUtilities } = params; + return { + id: ServiceNowITOMActionTypeId, + minimumLicenseRequired: 'platinum', + name: i18n.SERVICENOW_ITOM, + validate: { + config: schema.object(ExternalIncidentServiceConfigurationBase, { + validate: curry(validate.config)(configurationUtilities), + }), + secrets: schema.object(ExternalIncidentServiceSecretConfiguration, { + validate: curry(validate.secrets)(configurationUtilities), + }), + params: ExecutorParamsSchemaITOM, + }, + executor: curry(executorITOM)({ + logger, + configurationUtilities, + actionTypeId: ServiceNowITOMActionTypeId, + createService: createExternalServiceITOM, + api: apiITOM, + }), + }; +} + // action executor const supportedSubActions: string[] = ['getFields', 'pushToService', 'getChoices', 'getIncident']; async function executor( @@ -139,7 +179,10 @@ async function executor( createService: ServiceFactory; api: ExternalServiceAPI; }, - execOptions: ServiceNowActionTypeExecutorOptions + execOptions: ServiceNowActionTypeExecutorOptions< + ServiceNowPublicConfigurationType, + ExecutorParams + > ): Promise> { const { actionId, config, params, secrets } = execOptions; const { subAction, subActionParams } = params; @@ -156,17 +199,8 @@ async function executor( externalServiceConfig ); - if (!api[subAction]) { - const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`; - logger.error(errorMessage); - throw new Error(errorMessage); - } - - if (!supportedSubActions.includes(subAction)) { - const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`; - logger.error(errorMessage); - throw new Error(errorMessage); - } + const apiAsRecord = api as unknown as Record; + throwIfSubActionIsNotSupported({ api: apiAsRecord, subAction, supportedSubActions, logger }); if (subAction === 'pushToService') { const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; @@ -187,6 +221,7 @@ async function executor( data = await api.getFields({ externalService, params: getFieldsParams, + logger, }); } @@ -195,6 +230,73 @@ async function executor( data = await api.getChoices({ externalService, params: getChoicesParams, + logger, + }); + } + + return { status: 'ok', data: data ?? {}, actionId }; +} + +const supportedSubActionsITOM = ['addEvent', 'getChoices']; + +async function executorITOM( + { + logger, + configurationUtilities, + actionTypeId, + createService, + api, + }: { + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + actionTypeId: string; + createService: ServiceFactory; + api: ExternalServiceApiITOM; + }, + execOptions: ServiceNowActionTypeExecutorOptions< + ServiceNowPublicConfigurationBaseType, + ExecutorParamsITOM + > +): Promise> { + const { actionId, config, params, secrets } = execOptions; + const { subAction, subActionParams } = params; + const externalServiceConfig = snExternalServiceConfig[actionTypeId]; + let data: ServiceNowExecutorResultData | null = null; + + const externalService = createService( + { + config, + secrets, + }, + logger, + configurationUtilities, + externalServiceConfig + ) as ExternalServiceITOM; + + const apiAsRecord = api as unknown as Record; + + throwIfSubActionIsNotSupported({ + api: apiAsRecord, + subAction, + supportedSubActions: supportedSubActionsITOM, + logger, + }); + + if (subAction === 'addEvent') { + const eventParams = subActionParams as ExecutorSubActionAddEventParams; + await api.addEvent({ + externalService, + params: eventParams, + logger, + }); + } + + if (subAction === 'getChoices') { + const getChoicesParams = subActionParams as ExecutorSubActionGetChoicesParams; + data = await api.getChoices({ + externalService, + params: getChoicesParams, + logger, }); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 3629fb33915ae..1043fe62af1e1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -12,6 +12,8 @@ import { ExternalServiceSIR, Observable, ObservableTypes, + ExternalServiceITOM, + ExecutorSubActionAddEventParams, } from './types'; export const serviceNowCommonFields = [ @@ -151,6 +153,16 @@ const createSIRMock = (): jest.Mocked => { return service; }; +const createITOMMock = (): jest.Mocked => { + const serviceMock = createMock(); + const service = { + getChoices: serviceMock.getChoices, + addEvent: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + return service; +}; + export const externalServiceMock = { create: createMock, }; @@ -159,6 +171,10 @@ export const externalServiceSIRMock = { create: createSIRMock, }; +export const externalServiceITOMMock = { + create: createITOMMock, +}; + export const executorParams: ExecutorSubActionPushParams = { incident: { externalId: 'incident-3', @@ -227,3 +243,17 @@ export const observables: Observable[] = [ ]; export const apiParams = executorParams; + +export const itomEventParams: ExecutorSubActionAddEventParams = { + source: 'A source', + event_class: 'An event class', + resource: 'C:', + node: 'node.example.com', + metric_name: 'Percentage Logical Disk Free Space', + type: 'Disk space', + severity: '4', + description: 'desc', + additional_info: '{"alert": "test"}', + message_key: 'a key', + time_of_event: '2021-10-13T10:51:44.981Z', +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index dab68bb9d3e9d..5f57555a8f9e1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -6,12 +6,21 @@ */ import { schema } from '@kbn/config-schema'; +import { DEFAULT_ALERTS_GROUPING_KEY } from './config'; -export const ExternalIncidentServiceConfiguration = { +export const ExternalIncidentServiceConfigurationBase = { apiUrl: schema.string(), +}; + +export const ExternalIncidentServiceConfiguration = { + ...ExternalIncidentServiceConfigurationBase, isLegacy: schema.boolean({ defaultValue: false }), }; +export const ExternalIncidentServiceConfigurationBaseSchema = schema.object( + ExternalIncidentServiceConfigurationBase +); + export const ExternalIncidentServiceConfigurationSchema = schema.object( ExternalIncidentServiceConfiguration ); @@ -80,6 +89,21 @@ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({ comments: CommentsSchema, }); +// Schema for ServiceNow ITOM +export const ExecutorSubActionAddEventParamsSchema = schema.object({ + source: schema.nullable(schema.string()), + event_class: schema.nullable(schema.string()), + resource: schema.nullable(schema.string()), + node: schema.nullable(schema.string()), + metric_name: schema.nullable(schema.string()), + type: schema.nullable(schema.string()), + severity: schema.nullable(schema.string()), + description: schema.nullable(schema.string()), + additional_info: schema.nullable(schema.string()), + message_key: schema.nullable(schema.string({ defaultValue: DEFAULT_ALERTS_GROUPING_KEY })), + time_of_event: schema.nullable(schema.string()), +}); + export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ externalId: schema.string(), }); @@ -138,3 +162,15 @@ export const ExecutorParamsSchemaSIR = schema.oneOf([ subActionParams: ExecutorSubActionGetChoicesParamsSchema, }), ]); + +// Executor parameters for ITOM +export const ExecutorParamsSchemaITOM = schema.oneOf([ + schema.object({ + subAction: schema.literal('addEvent'), + subActionParams: ExecutorSubActionAddEventParamsSchema, + }), + schema.object({ + subAction: schema.literal('getChoices'), + subActionParams: ExecutorSubActionGetChoicesParamsSchema, + }), +]); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts new file mode 100644 index 0000000000000..5223add79d301 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts @@ -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 axios from 'axios'; + +import { createExternalServiceITOM } from './service_itom'; +import * as utils from '../lib/axios_utils'; +import { ExternalServiceITOM } from './types'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { snExternalServiceConfig } from './config'; +import { itomEventParams, serviceNowChoices } from './mocks'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +describe('ServiceNow SIR service', () => { + let service: ExternalServiceITOM; + + beforeEach(() => { + service = createExternalServiceITOM( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-itom'] + ) as ExternalServiceITOM; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('addEvent', () => { + test('it adds an event', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + result: { + 'Default Bulk Endpoint': '1 events were inserted', + }, + }, + })); + + await service.addEvent(itomEventParams); + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/global/em/jsonv2', + method: 'post', + data: { records: [itomEventParams] }, + }); + }); + }); + + describe('getChoices', () => { + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { result: serviceNowChoices }, + })); + await service.getChoices(['severity']); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=em_event^element=severity&sysparm_fields=label,value,dependent_value,element', + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts new file mode 100644 index 0000000000000..aa135e07dbc64 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import axios from 'axios'; + +import { + ExternalServiceCredentials, + SNProductsConfigValue, + ServiceFactory, + ExternalServiceITOM, + ExecutorSubActionAddEventParams, +} from './types'; + +import { Logger } from '../../../../../../src/core/server'; +import { ServiceNowSecretConfigurationType } from './types'; +import { request } from '../lib/axios_utils'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { createExternalService } from './service'; +import { createServiceError } from './utils'; + +const getAddEventURL = (url: string) => `${url}/api/global/em/jsonv2`; + +export const createExternalServiceITOM: ServiceFactory = ( + credentials: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities, + serviceConfig: SNProductsConfigValue +): ExternalServiceITOM => { + const snService = createExternalService( + credentials, + logger, + configurationUtilities, + serviceConfig + ); + + const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType; + const axiosInstance = axios.create({ + auth: { username, password }, + }); + + const addEvent = async (params: ExecutorSubActionAddEventParams) => { + try { + const res = await request({ + axios: axiosInstance, + url: getAddEventURL(snService.getUrl()), + logger, + method: 'post', + data: { records: [params] }, + configurationUtilities, + }); + + snService.checkInstance(res); + } catch (error) { + throw createServiceError(error, `Unable to add event`); + } + }; + + return { + addEvent, + getChoices: snService.getChoices, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts index fc8d8cc555bc8..03433f11f9465 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts @@ -29,7 +29,7 @@ const getAddObservableToIncidentURL = (url: string, incidentID: string) => const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) => `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`; -export const createExternalServiceSIR: ServiceFactory = ( +export const createExternalServiceSIR: ServiceFactory = ( credentials: ExternalServiceCredentials, logger: Logger, configurationUtilities: ActionsConfigurationUtilities, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts index b46e118a7235f..8b2bb9423d012 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -19,6 +19,10 @@ export const SERVICENOW_SIR = i18n.translate('xpack.actions.builtin.serviceNowSI defaultMessage: 'ServiceNow SecOps', }); +export const SERVICENOW_ITOM = i18n.translate('xpack.actions.builtin.serviceNowITOMTitle', { + defaultMessage: 'ServiceNow ITOM', +}); + export const ALLOWED_HOSTS_ERROR = (message: string) => i18n.translate('xpack.actions.builtin.configuration.apiAllowedHostsError', { defaultMessage: 'error configuring connector action: {message}', diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index ecca1e55e0fec..31af3781c6b04 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -20,13 +20,21 @@ import { ExecutorParamsSchemaSIR, ExecutorSubActionPushParamsSchemaSIR, ExecutorSubActionGetChoicesParamsSchema, + ExecutorParamsSchemaITOM, + ExecutorSubActionAddEventParamsSchema, + ExternalIncidentServiceConfigurationBaseSchema, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { Logger } from '../../../../../../src/core/server'; +export type ServiceNowPublicConfigurationBaseType = TypeOf< + typeof ExternalIncidentServiceConfigurationBaseSchema +>; + export type ServiceNowPublicConfigurationType = TypeOf< typeof ExternalIncidentServiceConfigurationSchema >; + export type ServiceNowSecretConfigurationType = TypeOf< typeof ExternalIncidentServiceSecretConfigurationSchema >; @@ -108,8 +116,9 @@ export type PushToServiceApiParams = ExecutorSubActionPushParams; export type PushToServiceApiParamsITSM = ExecutorSubActionPushParamsITSM; export type PushToServiceApiParamsSIR = ExecutorSubActionPushParamsSIR; -export interface ExternalServiceApiHandlerArgs { - externalService: ExternalService; +export interface ExternalServiceApiHandlerArgs { + externalService: T; + logger: Logger; } export type ExecutorSubActionGetIncidentParams = TypeOf< @@ -134,7 +143,6 @@ export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerAr params: PushToServiceApiParams; config: Record; secrets: Record; - logger: Logger; commentFieldKey: string; } @@ -162,13 +170,13 @@ export interface ExternalServiceChoices { export type GetCommonFieldsResponse = ExternalServiceFields[]; export type GetChoicesResponse = ExternalServiceChoices[]; -export interface GetCommonFieldsHandlerArgs { - externalService: ExternalService; +export interface GetCommonFieldsHandlerArgs extends ExternalServiceApiHandlerArgs { params: ExecutorSubActionCommonFieldsParams; } export interface GetChoicesHandlerArgs { - externalService: ExternalService; + externalService: Partial & { getChoices: ExternalService['getChoices'] }; + logger: Logger; params: ExecutorSubActionGetChoicesParams; } @@ -276,9 +284,36 @@ export interface ExternalServiceSIR extends ExternalService { ) => Promise; } -export type ServiceFactory = ( +export type ServiceFactory = ( credentials: ExternalServiceCredentials, logger: Logger, configurationUtilities: ActionsConfigurationUtilities, serviceConfig: SNProductsConfigValue -) => ExternalServiceSIR | ExternalService; +) => T; + +/** + * ITOM + */ + +export type ExecutorSubActionAddEventParams = TypeOf; + +export interface ExternalServiceITOM { + getChoices: ExternalService['getChoices']; + addEvent: (params: ExecutorSubActionAddEventParams) => Promise; +} + +export interface AddEventApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: ExecutorSubActionAddEventParams; +} + +export interface GetCommonFieldsHandlerArgsITOM + extends ExternalServiceApiHandlerArgs { + params: ExecutorSubActionGetChoicesParams; +} + +export interface ExternalServiceApiITOM { + getChoices: ExternalServiceAPI['getChoices']; + addEvent: (args: AddEventApiHandlerArgs) => Promise; +} + +export type ExecutorParamsITOM = TypeOf; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts index 87f27da6d213f..3eaf5305d5d26 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -6,7 +6,17 @@ */ import { AxiosError } from 'axios'; -import { prepareIncident, createServiceError, getPushedDate } from './utils'; + +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { + prepareIncident, + createServiceError, + getPushedDate, + throwIfSubActionIsNotSupported, +} from './utils'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; /** * The purpose of this test is to @@ -15,7 +25,6 @@ import { prepareIncident, createServiceError, getPushedDate } from './utils'; * such as the scope or the import set table * of our ServiceNow application */ - describe('utils', () => { describe('prepareIncident', () => { test('it prepares the incident correctly when useOldApi=false', async () => { @@ -81,4 +90,45 @@ describe('utils', () => { expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z'); }); }); + + describe('throwIfSubActionIsNotSupported', () => { + const api = { pushToService: 'whatever' }; + + test('it throws correctly if the subAction is not supported', async () => { + expect.assertions(1); + + expect(() => + throwIfSubActionIsNotSupported({ + api, + subAction: 'addEvent', + supportedSubActions: ['getChoices'], + logger, + }) + ).toThrow('[Action][ExternalService] Unsupported subAction type addEvent'); + }); + + test('it throws correctly if the subAction is not implemented', async () => { + expect.assertions(1); + + expect(() => + throwIfSubActionIsNotSupported({ + api, + subAction: 'pushToService', + supportedSubActions: ['getChoices'], + logger, + }) + ).toThrow('[Action][ExternalService] subAction pushToService not implemented.'); + }); + + test('it does not throw if the sub action is supported and implemented', async () => { + expect(() => + throwIfSubActionIsNotSupported({ + api, + subAction: 'pushToService', + supportedSubActions: ['pushToService'], + logger, + }) + ).not.toThrow(); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts index 5b7ca99ffc709..3bd4864b71e7a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Logger } from '../../../../../../src/core/server'; import { Incident, PartialIncident, ResponseError, ServiceNowError } from './types'; import { FIELD_PREFIX } from './config'; import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils'; @@ -44,3 +45,27 @@ export const getPushedDate = (timestamp?: string) => { return new Date().toISOString(); }; + +export const throwIfSubActionIsNotSupported = ({ + api, + subAction, + supportedSubActions, + logger, +}: { + api: Record; + subAction: string; + supportedSubActions: string[]; + logger: Logger; +}) => { + if (!api[subAction]) { + const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (!supportedSubActions.includes(subAction)) { + const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } +}; diff --git a/x-pack/plugins/actions/server/constants/connectors.ts b/x-pack/plugins/actions/server/constants/connectors.ts index f20d499716cf0..94324e4d82bc2 100644 --- a/x-pack/plugins/actions/server/constants/connectors.ts +++ b/x-pack/plugins/actions/server/constants/connectors.ts @@ -10,3 +10,6 @@ export const ENABLE_NEW_SN_ITSM_CONNECTOR = true; // TODO: Remove when Elastic for Security Operations is published. export const ENABLE_NEW_SN_SIR_CONNECTOR = true; + +// TODO: Remove when ready +export const ENABLE_ITOM = true; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 6e8d574c15860..442718e0975ee 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -5,6 +5,8 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ENABLE_ITOM } from '../../actions/server/constants/connectors'; import type { TransformConfigSchema } from './transforms/types'; import { ENABLE_CASE_CONNECTOR } from '../../cases/common'; import { METADATA_TRANSFORMS_PATTERN } from './endpoint/constants'; @@ -312,6 +314,11 @@ if (ENABLE_CASE_CONNECTOR) { NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.push('.case'); } +// TODO: Remove when ITOM is ready +if (ENABLE_ITOM) { + NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.push('.servicenow-itom'); +} + export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions'; export const NOTIFICATION_THROTTLE_RULE = 'rule'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index 4266822bda1fc..d9bad9677c6b8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -14,10 +14,16 @@ import { getSwimlaneActionType } from './swimlane'; import { getWebhookActionType } from './webhook'; import { TypeRegistry } from '../../type_registry'; import { ActionTypeModel } from '../../../types'; -import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow'; +import { + getServiceNowITSMActionType, + getServiceNowSIRActionType, + getServiceNowITOMActionType, +} from './servicenow'; import { getJiraActionType } from './jira'; import { getResilientActionType } from './resilient'; import { getTeamsActionType } from './teams'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ENABLE_ITOM } from '../../../../../actions/server/constants/connectors'; export function registerBuiltInActionTypes({ actionTypeRegistry, @@ -36,4 +42,9 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getJiraActionType()); actionTypeRegistry.register(getResilientActionType()); actionTypeRegistry.register(getTeamsActionType()); + + // TODO: Remove when ITOM is ready + if (ENABLE_ITOM) { + actionTypeRegistry.register(getServiceNowITOMActionType()); + } } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts index e37d8dd3b4147..e40db85bcb12d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts @@ -35,6 +35,10 @@ describe('helpers', () => { expect(isFieldInvalid(undefined, ['required'])).toBeFalsy(); }); + test('should return if false the field is null', async () => { + expect(isFieldInvalid(null, ['required'])).toBeFalsy(); + }); + test('should return if false the error is not defined', async () => { // @ts-expect-error expect(isFieldInvalid('description', undefined)).toBeFalsy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts index 0134133645bb3..e6acb2e0976a8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -23,9 +23,9 @@ export const isRESTApiError = (res: AppInfo | RESTApiError): res is RESTApiError (res as RESTApiError).error != null || (res as RESTApiError).status === 'failure'; export const isFieldInvalid = ( - field: string | undefined, + field: string | undefined | null, error: string | IErrorObject | string[] -): boolean => error !== undefined && error.length > 0 && field !== undefined; +): boolean => error !== undefined && error.length > 0 && field != null; // TODO: Remove when the applications are certified export const isLegacyConnector = (connector: ServiceNowActionConnector) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts index b5b006b764e41..c313fd5d0edd6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts @@ -5,4 +5,8 @@ * 2.0. */ -export { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow'; +export { + getServiceNowITSMActionType, + getServiceNowSIRActionType, + getServiceNowITOMActionType, +} from './servicenow'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index b40db9c2dabda..eb3e1c01887c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -12,6 +12,7 @@ import { ServiceNowActionConnector } from './types'; const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow'; const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir'; +const SERVICENOW_ITOM_ACTION_TYPE_ID = '.servicenow-itom'; let actionTypeRegistry: TypeRegistry; beforeAll(() => { @@ -20,7 +21,11 @@ beforeAll(() => { }); describe('actionTypeRegistry.get() works', () => { - [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { + [ + SERVICENOW_ITSM_ACTION_TYPE_ID, + SERVICENOW_SIR_ACTION_TYPE_ID, + SERVICENOW_ITOM_ACTION_TYPE_ID, + ].forEach((id) => { test(`${id}: action type static data is as expected`, () => { const actionTypeModel = actionTypeRegistry.get(id); expect(actionTypeModel.id).toEqual(id); @@ -29,7 +34,11 @@ describe('actionTypeRegistry.get() works', () => { }); describe('servicenow connector validation', () => { - [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { + [ + SERVICENOW_ITSM_ACTION_TYPE_ID, + SERVICENOW_SIR_ACTION_TYPE_ID, + SERVICENOW_ITOM_ACTION_TYPE_ID, + ].forEach((id) => { test(`${id}: connector validation succeeds when connector config is valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionConnector = { @@ -106,7 +115,7 @@ describe('servicenow action params validation', () => { }); }); - test(`${id}: params validation fails when body is not valid`, async () => { + test(`${id}: params validation fails when short_description is not valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionParams = { subActionParams: { incident: { short_description: '' }, comments: [] }, @@ -119,4 +128,22 @@ describe('servicenow action params validation', () => { }); }); }); + + test(`${SERVICENOW_ITOM_ACTION_TYPE_ID}: action params validation succeeds when action params is valid`, async () => { + const actionTypeModel = actionTypeRegistry.get(SERVICENOW_ITOM_ACTION_TYPE_ID); + const actionParams = { subActionParams: { severity: 'Critical' } }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { ['severity']: [] }, + }); + }); + + test(`${SERVICENOW_ITOM_ACTION_TYPE_ID}: params validation fails when severity is not valid`, async () => { + const actionTypeModel = actionTypeRegistry.get(SERVICENOW_ITOM_ACTION_TYPE_ID); + const actionParams = { subActionParams: { severity: null } }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { ['severity']: ['Severity is required.'] }, + }); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index bb4a645f10bbc..6b6d536ff303b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -18,6 +18,7 @@ import { ServiceNowSecrets, ServiceNowITSMActionParams, ServiceNowSIRActionParams, + ServiceNowITOMActionParams, } from './types'; import { isValidUrl } from '../../../lib/value_validators'; @@ -90,6 +91,20 @@ export const SERVICENOW_SIR_TITLE = i18n.translate( } ); +export const SERVICENOW_ITOM_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITOM.actionTypeTitle', + { + defaultMessage: 'ServiceNow ITOM', + } +); + +export const SERVICENOW_ITOM_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITOM.selectMessageText', + { + defaultMessage: 'Create an event in ServiceNow ITOM.', + } +); + export function getServiceNowITSMActionType(): ActionTypeModel< ServiceNowConfig, ServiceNowSecrets, @@ -161,3 +176,34 @@ export function getServiceNowSIRActionType(): ActionTypeModel< actionParamsFields: lazy(() => import('./servicenow_sir_params')), }; } + +export function getServiceNowITOMActionType(): ActionTypeModel< + ServiceNowConfig, + ServiceNowSecrets, + ServiceNowITOMActionParams +> { + return { + id: '.servicenow-itom', + iconClass: lazy(() => import('./logo')), + selectMessage: SERVICENOW_ITOM_DESC, + actionTypeTitle: SERVICENOW_ITOM_TITLE, + validateConnector, + actionConnectorFields: lazy(() => import('./servicenow_connectors_no_app')), + validateParams: async ( + actionParams: ServiceNowITOMActionParams + ): Promise> => { + const translations = await import('./translations'); + const errors = { + severity: new Array(), + }; + const validationResult = { errors }; + + if (actionParams?.subActionParams?.severity == null) { + errors.severity.push(translations.SEVERITY_REQUIRED); + } + + return validationResult; + }, + actionParamsFields: lazy(() => import('./servicenow_itom_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_no_app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_no_app.tsx new file mode 100644 index 0000000000000..f49c2d34d3a8d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_no_app.tsx @@ -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 React from 'react'; + +import { ActionConnectorFieldsProps } from '../../../../types'; + +import { ServiceNowActionConnector } from './types'; +import { Credentials } from './credentials'; + +const ServiceNowConnectorFieldsNoApp: React.FC< + ActionConnectorFieldsProps +> = ({ action, editActionSecrets, editActionConfig, errors, readOnly }) => { + return ( + <> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowConnectorFieldsNoApp as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.test.tsx new file mode 100644 index 0000000000000..ef934d4ebacd7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.test.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { ActionConnector } from '../../../../types'; +import { useChoices } from './use_choices'; +import ServiceNowITOMParamsFields from './servicenow_itom_params'; + +jest.mock('./use_choices'); +jest.mock('../../../../common/lib/kibana'); + +const useChoicesMock = useChoices as jest.Mock; + +const actionParams = { + subAction: 'addEvent', + subActionParams: { + source: 'A source', + event_class: 'An event class', + resource: 'C:', + node: 'node.example.com', + metric_name: 'Percentage Logical Disk Free Space', + type: 'Disk space', + severity: '4', + description: 'desc', + additional_info: '{"alert": "test"}', + message_key: 'a key', + time_of_event: '2021-10-13T10:51:44.981Z', + }, +}; + +const connector: ActionConnector = { + secrets: {}, + config: {}, + id: 'test', + actionTypeId: '.test', + name: 'Test', + isPreconfigured: false, +}; + +const editAction = jest.fn(); +const defaultProps = { + actionConnector: connector, + actionParams, + errors: { ['subActionParams.incident.short_description']: [] }, + editAction, + index: 0, + messageVariables: [], +}; + +const choicesResponse = { + isLoading: false, + choices: { + severity: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element: 'severity', + }, + { + dependent_value: '', + label: '2 - Major', + value: '2', + element: 'severity', + }, + ], + }, +}; + +describe('ServiceNowITOMParamsFields renders', () => { + beforeEach(() => { + jest.clearAllMocks(); + useChoicesMock.mockImplementation((args) => { + return choicesResponse; + }); + }); + + test('all params fields is rendered', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="sourceInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="nodeInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="typeInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="resourceInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="metric_nameInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="event_classInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="message_keyInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy(); + }); + + test('If severity has errors, form row is invalid', () => { + const newProps = { + ...defaultProps, + errors: { severity: ['error'] }, + }; + const wrapper = mount(); + const severity = wrapper.find('[data-test-subj="severitySelect"]').first(); + expect(severity.prop('isInvalid')).toBeTruthy(); + }); + + test('When subActionParams is undefined, set to default', () => { + const { subActionParams, ...newParams } = actionParams; + + const newProps = { + ...defaultProps, + actionParams: newParams, + }; + + mount(); + expect(editAction.mock.calls[0][1]).toEqual({ + message_key: '{{rule.id}}:{{alert.id}}', + additional_info: + '{"alert":{"id":"{{alert.id}}","actionGroup":"{{alert.actionGroup}}","actionSubgroup":"{{alert.actionSubgroup}}","actionGroupName":"{{alert.actionGroupName}}"},"rule":{"id":"{{rule.id}}","name":"{{rule.name}}","type":"{{rule.type}}"},"date":"{{date}}"}', + }); + }); + + test('When subAction is undefined, set to default', () => { + const { subAction, ...newParams } = actionParams; + + const newProps = { + ...defaultProps, + actionParams: newParams, + }; + mount(); + expect(editAction.mock.calls[0][1]).toEqual('addEvent'); + }); + + test('Resets fields when connector changes', () => { + const wrapper = mount(); + expect(editAction.mock.calls.length).toEqual(0); + wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); + expect(editAction.mock.calls.length).toEqual(1); + expect(editAction.mock.calls[0][1]).toEqual({ + message_key: '{{rule.id}}:{{alert.id}}', + additional_info: + '{"alert":{"id":"{{alert.id}}","actionGroup":"{{alert.actionGroup}}","actionSubgroup":"{{alert.actionSubgroup}}","actionGroupName":"{{alert.actionGroupName}}"},"rule":{"id":"{{rule.id}}","name":"{{rule.name}}","type":"{{rule.type}}"},"date":"{{date}}"}', + }); + }); + + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('options')).toEqual([ + { value: '1', text: '1 - Critical' }, + { value: '2', text: '2 - Major' }, + ]); + }); + + describe('UI updates', () => { + const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent; + const simpleFields = [ + { dataTestSubj: 'input[data-test-subj="sourceInput"]', key: 'source' }, + { dataTestSubj: 'textarea[data-test-subj="descriptionTextArea"]', key: 'description' }, + { dataTestSubj: '[data-test-subj="nodeInput"]', key: 'node' }, + { dataTestSubj: '[data-test-subj="typeInput"]', key: 'type' }, + { dataTestSubj: '[data-test-subj="resourceInput"]', key: 'resource' }, + { dataTestSubj: '[data-test-subj="metric_nameInput"]', key: 'metric_name' }, + { dataTestSubj: '[data-test-subj="event_classInput"]', key: 'event_class' }, + { dataTestSubj: '[data-test-subj="message_keyInput"]', key: 'message_key' }, + { dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' }, + ]; + + simpleFields.forEach((field) => + test(`${field.key} update triggers editAction :D`, () => { + const wrapper = mount(); + const theField = wrapper.find(field.dataTestSubj).first(); + theField.prop('onChange')!(changeEvent); + expect(editAction.mock.calls[0][1][field.key]).toEqual(changeEvent.target.value); + }) + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.tsx new file mode 100644 index 0000000000000..8ad32fc0bc86b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.tsx @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useRef, useMemo } from 'react'; +import { EuiFormRow, EuiSpacer, EuiTitle, EuiSelect } from '@elastic/eui'; +import { useKibana } from '../../../../common/lib/kibana'; +import { ActionParamsProps } from '../../../../types'; +import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; +import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; + +import * as i18n from './translations'; +import { useChoices } from './use_choices'; +import { ServiceNowITOMActionParams } from './types'; +import { choicesToEuiOptions, isFieldInvalid } from './helpers'; + +const choicesFields = ['severity']; + +const fields: Array<{ + label: string; + fieldKey: keyof ServiceNowITOMActionParams['subActionParams']; +}> = [ + { label: i18n.SOURCE, fieldKey: 'source' }, + { label: i18n.NODE, fieldKey: 'node' }, + { label: i18n.TYPE, fieldKey: 'type' }, + { label: i18n.RESOURCE, fieldKey: 'resource' }, + { label: i18n.METRIC_NAME, fieldKey: 'metric_name' }, + { label: i18n.EVENT_CLASS, fieldKey: 'event_class' }, + { label: i18n.MESSAGE_KEY, fieldKey: 'message_key' }, +]; + +const additionalInformation = JSON.stringify({ + alert: { + id: '{{alert.id}}', + actionGroup: '{{alert.actionGroup}}', + actionSubgroup: '{{alert.actionSubgroup}}', + actionGroupName: '{{alert.actionGroupName}}', + }, + rule: { + id: '{{rule.id}}', + name: '{{rule.name}}', + type: '{{rule.type}}', + }, + date: '{{date}}', +}); + +const ServiceNowITOMParamsFields: React.FunctionComponent< + ActionParamsProps +> = ({ actionConnector, actionParams, editAction, index, messageVariables, errors }) => { + const params = useMemo( + () => (actionParams.subActionParams ?? {}) as ServiceNowITOMActionParams['subActionParams'], + [actionParams.subActionParams] + ); + + const { description, severity } = params; + + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const actionConnectorRef = useRef(actionConnector?.id ?? ''); + const { choices, isLoading: isLoadingChoices } = useChoices({ + http, + toastNotifications: toasts, + actionConnector, + fields: choicesFields, + }); + + const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); + + const editSubActionProperty = useCallback( + (key: string, value: any) => { + editAction('subActionParams', { ...params, [key]: value }, index); + }, + [editAction, index, params] + ); + + useEffect(() => { + if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) { + actionConnectorRef.current = actionConnector.id; + editAction( + 'subActionParams', + { additional_info: additionalInformation, message_key: '{{rule.id}}:{{alert.id}}' }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionConnector]); + + useEffect(() => { + if (!actionParams.subAction) { + editAction('subAction', 'addEvent', index); + } + + if (!actionParams.subActionParams) { + editAction( + 'subActionParams', + { additional_info: additionalInformation, message_key: '{{rule.id}}:{{alert.id}}' }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionParams]); + + return ( + <> + +

{i18n.EVENT}

+ + + {fields.map((field) => ( + + + + + + + ))} + + editSubActionProperty('severity', e.target.value)} + isInvalid={isFieldInvalid(severity, errors.severity)} + /> + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowITOMParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index 72f6d7635268f..42758250408d9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -152,7 +152,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< return ( <> -

{i18n.INCIDENT}

+

{i18n.SECURITY_INCIDENT}

; +const getChoicesMock = getChoices as jest.Mock; + +const actionConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + name: 'ServiceNow ITSM', + isPreconfigured: false, + config: { + apiUrl: 'https://dev94428.service-now.com/', + }, +} as ActionConnector; + +const getChoicesResponse = [ + { + dependent_value: '', + label: 'Priviledge Escalation', + value: 'Priviledge Escalation', + element: 'category', + }, + { + dependent_value: '', + label: 'Criminal activity/investigation', + value: 'Criminal activity/investigation', + element: 'category', + }, + { + dependent_value: '', + label: 'Denial of Service', + value: 'Denial of Service', + element: 'category', + }, +]; + +const useChoicesResponse = { + isLoading: false, + choices: { category: getChoicesResponse }, +}; + +describe('UseChoices', () => { + const { services } = useKibanaMock(); + getChoicesMock.mockResolvedValue({ + data: getChoicesResponse, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const fields = ['category']; + + it('init', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields, + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual(useChoicesResponse); + }); + + it('returns an empty array if the field is not in response', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields: ['priority'], + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + isLoading: false, + choices: { priority: [], category: getChoicesResponse }, + }); + }); + + it('returns an empty array when connector is not presented', async () => { + const { result } = renderHook(() => + useChoices({ + http: services.http, + actionConnector: undefined, + toastNotifications: services.notifications.toasts, + fields, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + choices: { category: [] }, + }); + }); + + it('it displays an error when service fails', async () => { + getChoicesMock.mockResolvedValue({ + status: 'error', + service_message: 'An error occurred', + }); + + const { waitForNextUpdate } = renderHook(() => + useChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields, + }) + ); + + await waitForNextUpdate(); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); + + it('it displays an error when http throws an error', async () => { + getChoicesMock.mockImplementation(() => { + throw new Error('An error occurred'); + }); + + renderHook(() => + useChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields, + }) + ); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_choices.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_choices.tsx new file mode 100644 index 0000000000000..5493fdaee8bfa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_choices.tsx @@ -0,0 +1,66 @@ +/* + * 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 { useCallback, useMemo, useState } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; + +import { ActionConnector } from '../../../../types'; +import { Choice, Fields } from './types'; +import { useGetChoices } from './use_get_choices'; + +export interface UseChoicesProps { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionConnector?: ActionConnector; + fields: string[]; +} + +export interface UseChoices { + choices: Fields; + isLoading: boolean; +} + +export const useChoices = ({ + http, + actionConnector, + toastNotifications, + fields, +}: UseChoicesProps): UseChoices => { + const defaultFields: Record = useMemo( + () => fields.reduce((acc, field) => ({ ...acc, [field]: [] }), {}), + [fields] + ); + const [choices, setChoices] = useState(defaultFields); + + const onChoicesSuccess = useCallback( + (values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ + ...acc, + [value.element]: [...(acc[value.element] ?? []), value], + }), + defaultFields + ) + ); + }, + [defaultFields] + ); + + const { isLoading } = useGetChoices({ + http, + toastNotifications, + actionConnector, + fields, + onSuccess: onChoicesSuccess, + }); + + return { choices, isLoading }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx index 01347a32e6da9..532789385e8bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx @@ -121,7 +121,7 @@ describe('useGetChoices', () => { it('it displays an error when service fails', async () => { getChoicesMock.mockResolvedValue({ status: 'error', - serviceMessage: 'An error occurred', + service_message: 'An error occurred', }); const { waitForNextUpdate } = renderHook(() => @@ -162,4 +162,26 @@ describe('useGetChoices', () => { title: 'Unable to get choices', }); }); + + it('returns an empty array if the response is not an array', async () => { + getChoicesMock.mockResolvedValue({ + status: 'ok', + data: {}, + }); + + const { result } = renderHook(() => + useGetChoices({ + http: services.http, + actionConnector: undefined, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + choices: [], + }); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx index ef80c99955542..f4c881c633cdc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx @@ -60,15 +60,16 @@ export const useGetChoices = ({ }); if (!didCancel.current) { + const data = Array.isArray(res.data) ? res.data : []; setIsLoading(false); - setChoices(res.data ?? []); + setChoices(data); if (res.status && res.status === 'error') { toastNotifications.addDanger({ title: i18n.CHOICES_API_ERROR, - text: `${res.serviceMessage ?? res.message}`, + text: `${res.service_message ?? res.message}`, }); } else if (onSuccess) { - onSuccess(res.data ?? []); + onSuccess(data); } } } catch (error) { diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 0618d379dc77d..3fe5ecb6076e2 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -35,6 +35,7 @@ const enabledActionTypes = [ '.server-log', '.servicenow', '.servicenow-sir', + '.servicenow-itom', '.jira', '.resilient', '.slack', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts index 19a789659fd7f..f5f08bd0de246 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts @@ -175,6 +175,14 @@ const handler = async (request: http.IncomingMessage, response: http.ServerRespo }); } + if (pathName === '/api/global/em/jsonv2') { + return sendResponse(response, { + result: { + 'Default Bulk Endpoint': '1 events were inserted', + }, + }); + } + // Return an 400 error if endpoint is not supported response.statusCode = 400; response.setHeader('Content-Type', 'application/json'); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts index 7e8272b0a8afa..41a0d65b624b7 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts @@ -231,10 +231,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - expect(resp.body.message).to.be( - `error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.` - ); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts index 4421c984b4aed..8e2036ce688ea 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts @@ -233,10 +233,6 @@ export default function resilientTest({ getService }: FtrProviderContext) { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - expect(resp.body.message).to.be( - `error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.` - ); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts new file mode 100644 index 0000000000000..6f1ddc6ee2748 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts @@ -0,0 +1,342 @@ +/* + * 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 httpProxy from 'http-proxy'; +import expect from '@kbn/expect'; +import getPort from 'get-port'; +import http from 'http'; + +import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function serviceNowITOMTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const configService = getService('config'); + + const mockServiceNow = { + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + params: { + subAction: 'addEvent', + subActionParams: { + source: 'A source', + event_class: 'An event class', + resource: 'C:', + node: 'node.example.com', + metric_name: 'Percentage Logical Disk Free Space', + type: 'Disk space', + severity: '4', + description: 'desc', + additional_info: '{"alert": "test"}', + message_key: 'a key', + time_of_event: '2021-10-13T10:51:44.981Z', + }, + }, + }; + + describe('ServiceNow ITOM', () => { + let simulatedActionId = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; + + before(async () => { + serviceNowServer = await getServiceNowServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!serviceNowServer.listening) { + serviceNowServer.listen(availablePort); + } + serviceNowSimulatorURL = `http://localhost:${availablePort}`; + proxyServer = await getHttpProxyServer( + serviceNowSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } + ); + }); + + after(() => { + serviceNowServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + + describe('ServiceNow ITOM - Action Creation', () => { + it('should return 200 when creating a servicenow action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + }, + }); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + }, + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: { + apiUrl: 'http://servicenow.mynonexistent.com', + }, + secrets: mockServiceNow.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: { + apiUrl: serviceNowSimulatorURL, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', + }); + }); + }); + }); + + describe('ServiceNow ITOM - Executor', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow-itom', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + describe('Validation', () => { + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); + expect(resp.body.connector_id).to.eql(simulatedActionId); + expect(resp.body.status).to.eql('error'); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + describe('getChoices', () => { + it('should fail when field is not provided', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: {}, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subActionParams.fields]: expected value of type [array] but got [undefined]', + }); + }); + }); + }); + }); + + describe('Execution', () => { + // New connectors + describe('Add event', () => { + it('should add an event ', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: mockServiceNow.params, + }) + .expect(200); + expect(result.status).to.eql('ok'); + }); + }); + + describe('getChoices', () => { + it('should get choices', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: { fields: ['priority'] }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + }, + ], + }); + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts index 5ff1663975145..82f43ed4a3040 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts @@ -241,10 +241,6 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - expect(resp.body.message).to.be( - `error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.` - ); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts index bc4ec43fb4c7b..0cdb279ac0746 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts @@ -245,10 +245,6 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - expect(resp.body.message).to.be( - `error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.` - ); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts index 93d3a6c9e003f..9647d083460fd 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts @@ -322,10 +322,6 @@ export default function swimlaneTest({ getService }: FtrProviderContext) { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - expect(resp.body.message).to.be( - `error validating action params: undefined is not iterable (cannot read property Symbol(Symbol.iterator))` - ); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index 61bd1bcad34ad..d247e066226e9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -27,6 +27,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow_itsm')); loadTestFile(require.resolve('./builtin_action_types/servicenow_sir')); + loadTestFile(require.resolve('./builtin_action_types/servicenow_itom')); loadTestFile(require.resolve('./builtin_action_types/jira')); loadTestFile(require.resolve('./builtin_action_types/resilient')); loadTestFile(require.resolve('./builtin_action_types/slack')); From d78fa6c7d8e3bf226c8d85e3ce8dc28d4ff3235b Mon Sep 17 00:00:00 2001 From: Orhan Toy Date: Tue, 19 Oct 2021 18:48:52 +0200 Subject: [PATCH 29/30] [App Search, Crawler] Fix validation step panel padding/whitespace (#115542) * Remove padding override * Move spacer to before action --- .../components/add_domain/validation_step_panel.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/validation_step_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/validation_step_panel.tsx index dc19b526714a5..6e93fc1f74bb1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/validation_step_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/validation_step_panel.tsx @@ -33,14 +33,9 @@ export const ValidationStepPanel: React.FC = ({ action, }) => { const showErrorMessage = step.state === 'invalid' || step.state === 'warning'; - const styleOverride = showErrorMessage ? { paddingBottom: 0 } : {}; return ( - + @@ -59,8 +54,8 @@ export const ValidationStepPanel: React.FC = ({ {action && ( <> + {action} - )} From 9d0bf40c25dcdb3e58a489f936d7413c15c6949a Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Tue, 19 Oct 2021 12:53:24 -0400 Subject: [PATCH 30/30] Fix potential error from undefined (#115562) --- .../dashboard/public/saved_dashboards/saved_dashboard.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index 8772f14a6ec4c..4afb42aa841bb 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -114,14 +114,14 @@ export function createSavedDashboardClass( }, // if this is null/undefined then the SavedObject will be assigned the defaults - id: typeof arg === 'string' ? arg : arg.id, + id: typeof arg === 'object' ? arg.id : arg, // default values that will get assigned if the doc is new defaults, }); - const id: string = typeof arg === 'string' ? arg : arg.id; - const useResolve = typeof arg === 'string' ? false : arg.useResolve; + const id: string = typeof arg === 'object' ? arg.id : arg; + const useResolve = typeof arg === 'object' ? arg.useResolve : false; this.getFullPath = () => `/app/dashboards#${createDashboardEditUrl(this.aliasId || this.id)}`;