From f9afe67f1e554cf3b295c4c43bf2b3f68c103120 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Tue, 19 Oct 2021 04:10:14 -0600 Subject: [PATCH 01/21] [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/21] 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/21] [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/21] [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/21] [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/21] 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/21] [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/21] 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/21] [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/21] [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/21] :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/21] [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/21] [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/21] [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/21] [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/21] [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/21] [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/21] [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/21] 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/21] [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/21] 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: ( - +