From e57d3f896e301933f7061803c0ca740b6f166771 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Fri, 4 Mar 2022 01:47:48 -0500 Subject: [PATCH 01/33] [Security Soluttion] Add footer and take action dropdown to timeline detail panel (#126195) * Add failing test and footer to component * Add cases context to timeline flyout * Use normal string format * Fix failing test * Fix z-index for event filter flyout * Wrap endpoint event filter page to utilize the important important * Pass spread props down from flyout footer instead of using global style override * Update new location of case context mock --- .../view/components/flyout/index.tsx | 12 +- .../__snapshots__/index.test.tsx.snap | 381 +++++++++++------- .../side_panel/event_details/footer.tsx | 8 +- .../side_panel/event_details/index.test.tsx | 168 ++++++++ .../side_panel/event_details/index.tsx | 23 +- .../components/side_panel/index.test.tsx | 27 ++ 6 files changed, 464 insertions(+), 155 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx index 6b3cc7478079a4..4107c971cc3b2f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx @@ -47,10 +47,13 @@ export interface EventFiltersFlyoutProps { id?: string; data?: Ecs; onCancel(): void; + maskProps?: { + style?: string; + }; } export const EventFiltersFlyout: React.FC = memo( - ({ onCancel, id, type = 'create', data }) => { + ({ onCancel, id, type = 'create', data, ...flyoutProps }) => { useEventFiltersNotification(); const [enrichedData, setEnrichedData] = useState(); const toasts = useToasts(); @@ -210,7 +213,12 @@ export const EventFiltersFlyout: React.FC = memo( ); return ( - +

diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 7324213975a742..5108fa86b0f56e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -58,187 +58,264 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should tabType="query" timelineId="test" > - - - -
- -
- - -
+
+ + - - - -
- -
- - - - -
- - +
+
+
+ + + + +
+ + - - - - + className="euiLoadingContent__singleLine" + key="0" + > + + - - + className="euiLoadingContent__singleLine" + key="1" + > + + - - + className="euiLoadingContent__singleLine" + key="2" + > + + - - + className="euiLoadingContent__singleLine" + key="3" + > + + - - + className="euiLoadingContent__singleLine" + key="4" + > + + - - + className="euiLoadingContent__singleLine" + key="5" + > + + - - + className="euiLoadingContent__singleLine" + key="6" + > + + - - + className="euiLoadingContent__singleLine" + key="7" + > + + - - + className="euiLoadingContent__singleLine" + key="8" + > + + + className="euiLoadingContent__singleLine" + key="9" + > + + - - - + + + + + +
+ +
+ +
+ +
+ +
+ + + + `; @@ -508,9 +585,12 @@ Array [ } } > - +
- +
- + {detailsEcsData && ( @@ -145,7 +145,11 @@ export const EventDetailsFooterComponent = React.memo( /> )} {isAddEventFilterModalOpen && detailsEcsData != null && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx new file mode 100644 index 00000000000000..dc8cf627b4656b --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.test.tsx @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { EventDetailsPanel } from './'; +import '../../../../common/mock/match_media'; +import { TestProviders } from '../../../../common/mock'; +import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; +import { Ecs } from '../../../../../common/ecs'; +import { mockAlertDetailsData } from '../../../../common/components/event_details/__mocks__'; +import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy'; +import { + KibanaServices, + useKibana, + useGetUserCasesPermissions, +} from '../../../../common/lib/kibana'; +import { + mockBrowserFields, + mockDocValueFields, + mockRuntimeMappings, +} from '../../../../common/containers/source/mock'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { mockCasesContext } from '../../../../../../cases/public/mocks/mock_cases_context'; + +const ecsData: Ecs = { + _id: '1', + agent: { type: ['blah'] }, + kibana: { + alert: { + workflow_status: ['open'], + rule: { + parameters: {}, + uuid: ['testId'], + }, + }, + }, +}; + +const mockAlertDetailsDataWithIsObject = mockAlertDetailsData.map((detail) => { + return { + ...detail, + isObjectArray: false, + }; +}) as TimelineEventsDetailsItem[]; + +jest.mock('../../../../../common/endpoint/service/host_isolation/utils', () => { + return { + isIsolationSupported: jest.fn().mockReturnValue(true), + }; +}); + +jest.mock( + '../../../../detections/containers/detection_engine/alerts/use_host_isolation_status', + () => { + return { + useHostIsolationStatus: jest.fn().mockReturnValue({ + loading: false, + isIsolated: false, + agentStatus: 'healthy', + }), + }; + } +); + +jest.mock('../../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + +jest.mock('../../../../detections/components/user_info', () => ({ + useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]), +})); +jest.mock('../../../../common/lib/kibana'); +jest.mock( + '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', + () => ({ + useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }), + }) +); +jest.mock('../../../../cases/components/use_insert_timeline'); + +jest.mock('../../../../common/utils/endpoint_alert_check', () => { + return { + isAlertFromEndpointAlert: jest.fn().mockReturnValue(true), + isAlertFromEndpointEvent: jest.fn().mockReturnValue(true), + }; +}); +jest.mock( + '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline', + () => { + return { + useInvestigateInTimeline: jest.fn().mockReturnValue({ + investigateInTimelineActionItems: [
], + investigateInTimelineAlertClick: () => {}, + }), + }; + } +); +jest.mock('../../../../detections/components/alerts_table/actions'); +const mockSearchStrategy = jest.fn(); + +const defaultProps = { + timelineId: TimelineId.test, + loadingEventDetails: false, + detailsEcsData: ecsData, + isHostIsolationPanelOpen: false, + handleOnEventClosed: jest.fn(), + onAddIsolationStatusClick: jest.fn(), + expandedEvent: { eventId: ecsData._id, indexName: '' }, + detailsData: mockAlertDetailsDataWithIsObject, + tabType: TimelineTabs.query, + browserFields: mockBrowserFields, + docValueFields: mockDocValueFields, + runtimeMappings: mockRuntimeMappings, +}; + +describe('event details footer component', () => { + beforeEach(() => { + const coreStartMock = coreMock.createStart(); + (KibanaServices.get as jest.Mock).mockReturnValue(coreStartMock); + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + search: { + searchStrategyClient: jest.fn(), + search: mockSearchStrategy.mockReturnValue({ + unsubscribe: jest.fn(), + subscribe: jest.fn(), + }), + }, + query: jest.fn(), + }, + uiSettings: { + get: jest.fn().mockReturnValue([]), + }, + cases: { + getCasesContext: () => mockCasesContext, + }, + }, + }); + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: true, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + test('it renders the take action dropdown in the timeline version', () => { + const wrapper = render( + + + + ); + expect(wrapper.getByTestId('side-panel-flyout-footer')).toBeTruthy(); + }); + test('it renders the take action dropdown in the flyout version', () => { + const wrapper = render( + + + + ); + expect(wrapper.getByTestId('side-panel-flyout-footer')).toBeTruthy(); + }); +}); 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 057f18899e8fd1..112b3aaab8687f 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 @@ -19,6 +19,8 @@ import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; +import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; +import { APP_ID } from '../../../../../common/constants'; import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; import { useTimelineEventsDetails } from '../../../containers/details'; import { TimelineTabs } from '../../../../../common/types/timeline'; @@ -94,6 +96,13 @@ const EventDetailsPanelComponent: React.FC = ({ 'isolateHost' ); + const { + services: { cases }, + } = useKibana(); + + const CasesContext = cases.getCasesContext(); + const casesPermissions = useGetUserCasesPermissions(); + const [isIsolateActionSuccessBannerVisible, setIsIsolateActionSuccessBannerVisible] = useState(false); @@ -239,7 +248,7 @@ const EventDetailsPanelComponent: React.FC = ({ /> ) : ( - <> + = ({ hostRisk={hostRisk} handleOnEventClosed={handleOnEventClosed} /> - + + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx index 48aa0853be49ea..2b75f57b43d20f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx @@ -25,6 +25,8 @@ import { } from '../../../../common/types/timeline'; import { FlowTarget } from '../../../../common/search_strategy/security_solution/network'; import { EventDetailsPanel } from './event_details'; +import { useKibana } from '../../../common/lib/kibana'; +import { mockCasesContext } from '../../../../../cases/public/mocks/mock_cases_context'; jest.mock('../../../common/lib/kibana'); @@ -99,9 +101,34 @@ describe('Details Panel Component', () => { timelineId: 'test', }; + const mockSearchStrategy = jest.fn(); + describe('DetailsPanel: rendering', () => { beforeEach(() => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + search: { + searchStrategyClient: jest.fn(), + search: mockSearchStrategy.mockReturnValue({ + unsubscribe: jest.fn(), + subscribe: jest.fn(), + }), + }, + query: jest.fn(), + }, + uiSettings: { + get: jest.fn().mockReturnValue([]), + }, + application: { + navigateToApp: jest.fn(), + }, + cases: { + getCasesContext: () => mockCasesContext, + }, + }, + }); }); test('it should not render the DetailsPanel if no expanded detail has been set in the reducer', () => { From 77ac1d822fcb19b000b86a54d7dd4b36a572f2ef Mon Sep 17 00:00:00 2001 From: Muhammad Ibragimov <53621505+mibragimov@users.noreply.github.com> Date: Fri, 4 Mar 2022 13:01:46 +0500 Subject: [PATCH 02/33] Unskip flaky tests (#126743) Co-authored-by: Muhammad Ibragimov Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- test/functional/apps/console/_autocomplete.ts | 9 +++++---- test/functional/page_objects/console_page.ts | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/test/functional/apps/console/_autocomplete.ts b/test/functional/apps/console/_autocomplete.ts index 1ba4bcaa76b365..cd17244b1f4983 100644 --- a/test/functional/apps/console/_autocomplete.ts +++ b/test/functional/apps/console/_autocomplete.ts @@ -13,24 +13,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const PageObjects = getPageObjects(['common', 'console']); - // Failing: See https://github.com/elastic/kibana/issues/126421 - describe.skip('console autocomplete feature', function describeIndexTests() { + describe('console autocomplete feature', function describeIndexTests() { this.tags('includeFirefox'); before(async () => { log.debug('navigateTo console'); await PageObjects.common.navigateToApp('console'); // Ensure that the text area can be interacted with await PageObjects.console.dismissTutorial(); + await PageObjects.console.clearTextArea(); }); it('should provide basic auto-complete functionality', async () => { await PageObjects.console.enterRequest(); + await PageObjects.console.enterText(`{\n\t"query": {`); + await PageObjects.console.pressEnter(); await PageObjects.console.promptAutocomplete(); expect(PageObjects.console.isAutocompleteVisible()).to.be.eql(true); }); - // FLAKY: https://github.com/elastic/kibana/issues/126414 - describe.skip('with a missing comma in query', () => { + describe('with a missing comma in query', () => { const LINE_NUMBER = 4; beforeEach(async () => { await PageObjects.console.clearTextArea(); diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts index e6450480bbb023..32c859cc1aed9e 100644 --- a/test/functional/page_objects/console_page.ts +++ b/test/functional/page_objects/console_page.ts @@ -85,8 +85,8 @@ export class ConsolePageObject extends FtrService { public async promptAutocomplete() { const textArea = await this.testSubjects.find('console-textarea'); - // There should be autocomplete for this on all license levels - await textArea.pressKeys([Key.CONTROL, Key.SPACE]); + await textArea.clickMouseButton(); + await textArea.type('b'); await this.retry.waitFor('autocomplete to be visible', () => this.isAutocompleteVisible()); } From 6c0526b67cd0b19b1224a9fdc42f442499d7ea82 Mon Sep 17 00:00:00 2001 From: Mat Schaffer Date: Fri, 4 Mar 2022 17:21:46 +0900 Subject: [PATCH 03/33] Skip apm overview tests on cloud (#126877) --- x-pack/test/api_integration/apis/monitoring/apm/overview.js | 5 ++++- .../test/api_integration/apis/monitoring/apm/overview_mb.js | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/x-pack/test/api_integration/apis/monitoring/apm/overview.js b/x-pack/test/api_integration/apis/monitoring/apm/overview.js index b58e8f151b7954..afa8d55f28adc7 100644 --- a/x-pack/test/api_integration/apis/monitoring/apm/overview.js +++ b/x-pack/test/api_integration/apis/monitoring/apm/overview.js @@ -12,7 +12,10 @@ export default function ({ getService }) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - describe('overview', () => { + describe('overview', function () { + // Archive contains non-cgroup data which collides with the in-cgroup APM server present by default on cloud deployments + this.tags(['skipCloud']); + const archive = 'x-pack/test/functional/es_archives/monitoring/apm'; const timeRange = { min: '2018-08-31T12:59:49.104Z', diff --git a/x-pack/test/api_integration/apis/monitoring/apm/overview_mb.js b/x-pack/test/api_integration/apis/monitoring/apm/overview_mb.js index 18cf7e17b760e2..734b493caeec5e 100644 --- a/x-pack/test/api_integration/apis/monitoring/apm/overview_mb.js +++ b/x-pack/test/api_integration/apis/monitoring/apm/overview_mb.js @@ -12,7 +12,10 @@ export default function ({ getService }) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - describe('overview mb', () => { + describe('overview mb', function () { + // Archive contains non-cgroup data which collides with the in-cgroup APM server present by default on cloud deployments + this.tags(['skipCloud']); + const archive = 'x-pack/test/functional/es_archives/monitoring/apm_mb'; const timeRange = { min: '2018-08-31T12:59:49.104Z', From 2b6885a74cd86b36553a21d3c49c0309e7e6bf9e Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Fri, 4 Mar 2022 11:06:06 +0200 Subject: [PATCH 04/33] [Gauge] Vis Type (#126048) * Added transparent background * Added gauge/goal visType. * Fixed palette, scale, and types. * Set legacy chart as default. * Removed deprecation message. * Added percent format params, coming from visdimensions. * Added support of labels/sublabels. * Updated i18n label. * Added support of showElasticChartsOptions * Added autoextend ranges elastic charts tooltip. * The outline elastic-charts message added. * outline renaming and metric/buckets limitations * reverted mistaken change of sample_vis.test.mocks. * Warning message added to gauge split chart. * Added warning message to the splitChart button at goal/gauge. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + .../expression_gauge/common/index.ts | 1 + .../public/components/gauge_component.tsx | 22 +-- src/plugins/vis_types/gauge/common/index.ts | 9 ++ .../config.ts} | 13 +- src/plugins/vis_types/gauge/jest.config.js | 18 +++ src/plugins/vis_types/gauge/kibana.json | 16 +++ .../public/__snapshots__/to_ast.test.ts.snap | 63 +++++++++ .../public/editor/collections.ts | 19 +-- .../public/editor/components/gauge/index.tsx | 12 +- .../editor/components/gauge/labels_panel.tsx | 6 +- .../editor/components/gauge/ranges_panel.tsx | 49 +++++-- .../editor/components/gauge/style_panel.tsx | 57 +++++--- .../public/editor/components/index.tsx | 9 +- .../{vislib => gauge}/public/editor/index.ts | 0 src/plugins/vis_types/gauge/public/index.ts | 17 +++ src/plugins/vis_types/gauge/public/plugin.ts | 41 ++++++ .../vis_types/gauge/public/to_ast.test.ts | 53 +++++++ src/plugins/vis_types/gauge/public/to_ast.ts | 93 +++++++++++++ .../vis_types/gauge/public/to_ast_esaggs.ts | 33 +++++ src/plugins/vis_types/gauge/public/types.ts | 68 +++++++++ .../vis_types/gauge/public/utils/index.ts | 9 ++ .../vis_types/gauge/public/utils/palette.ts | 49 +++++++ .../vis_types/gauge/public/vis_type/gauge.tsx | 130 +++++++++++++++++ .../vis_types/gauge/public/vis_type/goal.tsx | 122 ++++++++++++++++ .../vis_types/gauge/public/vis_type/index.ts | 19 +++ .../gauge/public/vis_type/split_tooltip.tsx | 19 +++ src/plugins/vis_types/gauge/server/index.ts | 17 +++ src/plugins/vis_types/gauge/server/plugin.ts | 47 +++++++ src/plugins/vis_types/gauge/tsconfig.json | 27 ++++ src/plugins/vis_types/vislib/kibana.json | 2 +- src/plugins/vis_types/vislib/public/gauge.ts | 114 +-------------- src/plugins/vis_types/vislib/public/goal.ts | 103 +------------- src/plugins/vis_types/vislib/public/plugin.ts | 15 +- src/plugins/vis_types/vislib/tsconfig.json | 2 +- .../visualizations/common/utils/accessors.ts | 12 +- .../visualizations/common/utils/index.ts | 2 +- .../components/split_chart_warning.tsx | 131 +++++++++++++----- .../components/visualize_editor_common.tsx | 29 +++- .../public/visualize_app/constants.ts | 10 ++ .../utils/split_chart_warning_helpers.ts | 34 +++++ .../translations/translations/ja-JP.json | 54 ++++---- .../translations/translations/zh-CN.json | 54 ++++---- 45 files changed, 1228 insertions(+), 378 deletions(-) create mode 100755 src/plugins/vis_types/gauge/common/index.ts rename src/plugins/vis_types/{vislib/public/vis_type_vislib_vis_types.ts => gauge/config.ts} (51%) create mode 100644 src/plugins/vis_types/gauge/jest.config.js create mode 100755 src/plugins/vis_types/gauge/kibana.json create mode 100644 src/plugins/vis_types/gauge/public/__snapshots__/to_ast.test.ts.snap rename src/plugins/vis_types/{vislib => gauge}/public/editor/collections.ts (67%) rename src/plugins/vis_types/{vislib => gauge}/public/editor/components/gauge/index.tsx (84%) rename src/plugins/vis_types/{vislib => gauge}/public/editor/components/gauge/labels_panel.tsx (87%) rename src/plugins/vis_types/{vislib => gauge}/public/editor/components/gauge/ranges_panel.tsx (63%) rename src/plugins/vis_types/{vislib => gauge}/public/editor/components/gauge/style_panel.tsx (50%) rename src/plugins/vis_types/{vislib => gauge}/public/editor/components/index.tsx (64%) rename src/plugins/vis_types/{vislib => gauge}/public/editor/index.ts (100%) create mode 100755 src/plugins/vis_types/gauge/public/index.ts create mode 100755 src/plugins/vis_types/gauge/public/plugin.ts create mode 100644 src/plugins/vis_types/gauge/public/to_ast.test.ts create mode 100644 src/plugins/vis_types/gauge/public/to_ast.ts create mode 100644 src/plugins/vis_types/gauge/public/to_ast_esaggs.ts create mode 100755 src/plugins/vis_types/gauge/public/types.ts create mode 100644 src/plugins/vis_types/gauge/public/utils/index.ts create mode 100644 src/plugins/vis_types/gauge/public/utils/palette.ts create mode 100644 src/plugins/vis_types/gauge/public/vis_type/gauge.tsx create mode 100644 src/plugins/vis_types/gauge/public/vis_type/goal.tsx create mode 100644 src/plugins/vis_types/gauge/public/vis_type/index.ts create mode 100644 src/plugins/vis_types/gauge/public/vis_type/split_tooltip.tsx create mode 100755 src/plugins/vis_types/gauge/server/index.ts create mode 100755 src/plugins/vis_types/gauge/server/plugin.ts create mode 100644 src/plugins/vis_types/gauge/tsconfig.json create mode 100644 src/plugins/visualizations/public/visualize_app/constants.ts create mode 100644 src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.ts diff --git a/.i18nrc.json b/.i18nrc.json index 7ec704aab3a7ae..eeb2578ef3472f 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -68,6 +68,7 @@ "usageCollection": "src/plugins/usage_collection", "utils": "packages/kbn-securitysolution-utils/src", "visDefaultEditor": "src/plugins/vis_default_editor", + "visTypeGauge": "src/plugins/vis_types/gauge", "visTypeHeatmap": "src/plugins/vis_types/heatmap", "visTypeMarkdown": "src/plugins/vis_type_markdown", "visTypeMetric": "src/plugins/vis_types/metric", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 2dd78be3c1012f..2de3fc3000ac56 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -297,6 +297,10 @@ It acts as a container for a particular visualization and options tabs. Contains The plugin exposes the static DefaultEditorController class to consume. +|{kib-repo}blob/{branch}/src/plugins/vis_types/gauge[visTypeGauge] +|WARNING: Missing README. + + |{kib-repo}blob/{branch}/src/plugins/vis_types/heatmap[visTypeHeatmap] |WARNING: Missing README. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 3a999272c3a4dd..f9f0bfc4fd29ec 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -121,4 +121,5 @@ pageLoadAssetSize: expressionPartitionVis: 26338 sharedUX: 16225 ux: 20784 + visTypeGauge: 24113 cloudSecurityPosture: 19109 diff --git a/src/plugins/chart_expressions/expression_gauge/common/index.ts b/src/plugins/chart_expressions/expression_gauge/common/index.ts index afd8f6105d8f6a..395aa3ed608612 100755 --- a/src/plugins/chart_expressions/expression_gauge/common/index.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/index.ts @@ -10,6 +10,7 @@ export const PLUGIN_ID = 'expressionGauge'; export const PLUGIN_NAME = 'expressionGauge'; export type { + GaugeExpressionFunctionDefinition, GaugeExpressionProps, FormatFactory, GaugeRenderProps, diff --git a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx index 99342edbdbc648..22601ae409f62e 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx +++ b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx @@ -10,6 +10,7 @@ import { Chart, Goal, Settings } from '@elastic/charts'; import { FormattedMessage } from '@kbn/i18n-react'; import type { CustomPaletteState } from '../../../../charts/public'; import { EmptyPlaceholder } from '../../../../charts/public'; +import { isVisDimension } from '../../../../visualizations/common/utils'; import { GaugeRenderProps, GaugeLabelMajorMode, @@ -234,17 +235,22 @@ export const GaugeComponent: FC = memo( /> ); } + const customMetricFormatParams = isVisDimension(args.metric) ? args.metric.format : undefined; + const tableMetricFormatParams = metricColumn?.meta?.params?.params + ? metricColumn?.meta?.params + : undefined; + + const defaultMetricFormatParams = { + id: 'number', + params: { + pattern: max - min > 5 ? `0,0` : `0,0.0`, + }, + }; const tickFormatter = formatFactory( - metricColumn?.meta?.params?.params - ? metricColumn?.meta?.params - : { - id: 'number', - params: { - pattern: max - min > 5 ? `0,0` : `0,0.0`, - }, - } + customMetricFormatParams ?? tableMetricFormatParams ?? defaultMetricFormatParams ); + const colors = palette?.params?.colors ? normalizeColors(palette.params, min, max) : undefined; const bands: number[] = (palette?.params as CustomPaletteState) ? normalizeBands(args.palette?.params as CustomPaletteState, { min, max }) diff --git a/src/plugins/vis_types/gauge/common/index.ts b/src/plugins/vis_types/gauge/common/index.ts new file mode 100755 index 00000000000000..e27c302c53c169 --- /dev/null +++ b/src/plugins/vis_types/gauge/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const LEGACY_GAUGE_CHARTS_LIBRARY = 'visualization:visualize:legacyGaugeChartsLibrary'; diff --git a/src/plugins/vis_types/vislib/public/vis_type_vislib_vis_types.ts b/src/plugins/vis_types/gauge/config.ts similarity index 51% rename from src/plugins/vis_types/vislib/public/vis_type_vislib_vis_types.ts rename to src/plugins/vis_types/gauge/config.ts index 220c69afb21d2a..b831d26854c304 100644 --- a/src/plugins/vis_types/vislib/public/vis_type_vislib_vis_types.ts +++ b/src/plugins/vis_types/gauge/config.ts @@ -6,13 +6,10 @@ * Side Public License, v 1. */ -import { VisTypeDefinition } from 'src/plugins/visualizations/public'; -import { gaugeVisTypeDefinition } from './gauge'; -import { goalVisTypeDefinition } from './goal'; +import { schema, TypeOf } from '@kbn/config-schema'; -export { pieVisTypeDefinition } from './pie'; +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), +}); -export const visLibVisTypeDefinitions: Array> = [ - gaugeVisTypeDefinition, - goalVisTypeDefinition, -]; +export type ConfigSchema = TypeOf; diff --git a/src/plugins/vis_types/gauge/jest.config.js b/src/plugins/vis_types/gauge/jest.config.js new file mode 100644 index 00000000000000..87fd58fd42dbcc --- /dev/null +++ b/src/plugins/vis_types/gauge/jest.config.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/src/plugins/vis_types/gauge'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/vis_types/gauge', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/vis_types/gauge/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/vis_types/gauge/kibana.json b/src/plugins/vis_types/gauge/kibana.json new file mode 100755 index 00000000000000..5eb2794452de90 --- /dev/null +++ b/src/plugins/vis_types/gauge/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "visTypeGauge", + "version": "1.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["charts", "data", "expressions", "visualizations"], + "requiredBundles": ["visDefaultEditor"], + "optionalPlugins": ["expressionGauge"], + "extraPublicDirs": ["common/index"], + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + }, + "description": "Contains the gauge chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting." +} diff --git a/src/plugins/vis_types/gauge/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/gauge/public/__snapshots__/to_ast.test.ts.snap new file mode 100644 index 00000000000000..dbc909f9ede22b --- /dev/null +++ b/src/plugins/vis_types/gauge/public/__snapshots__/to_ast.test.ts.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gauge vis toExpressionAst function with minimal params 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "aggs": Array [], + "index": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "id": Array [ + "123", + ], + }, + "function": "indexPatternLoad", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "metricsAtAllLevels": Array [ + false, + ], + "partialRows": Array [ + false, + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "centralMajorMode": Array [ + "custom", + ], + "colorMode": Array [ + "palette", + ], + "labelMajorMode": Array [ + "auto", + ], + "labelMinor": Array [ + "some custom sublabel", + ], + "metric": Array [], + "shape": Array [ + "circle", + ], + "ticksPosition": Array [ + "hidden", + ], + }, + "function": "gauge", + "type": "function", + }, + ], + "type": "expression", +} +`; diff --git a/src/plugins/vis_types/vislib/public/editor/collections.ts b/src/plugins/vis_types/gauge/public/editor/collections.ts similarity index 67% rename from src/plugins/vis_types/vislib/public/editor/collections.ts rename to src/plugins/vis_types/gauge/public/editor/collections.ts index e7905ccaf1c295..3f52ffbead01c1 100644 --- a/src/plugins/vis_types/vislib/public/editor/collections.ts +++ b/src/plugins/vis_types/gauge/public/editor/collections.ts @@ -7,21 +7,18 @@ */ import { i18n } from '@kbn/i18n'; - import { colorSchemas } from '../../../../charts/public'; -import { getPositions, getScaleTypes } from '../../../xy/public'; - import { Alignment, GaugeType } from '../types'; export const getGaugeTypes = () => [ { - text: i18n.translate('visTypeVislib.gauge.gaugeTypes.arcText', { + text: i18n.translate('visTypeGauge.gauge.gaugeTypes.arcText', { defaultMessage: 'Arc', }), value: GaugeType.Arc, }, { - text: i18n.translate('visTypeVislib.gauge.gaugeTypes.circleText', { + text: i18n.translate('visTypeGauge.gauge.gaugeTypes.circleText', { defaultMessage: 'Circle', }), value: GaugeType.Circle, @@ -30,19 +27,19 @@ export const getGaugeTypes = () => [ export const getAlignments = () => [ { - text: i18n.translate('visTypeVislib.gauge.alignmentAutomaticTitle', { + text: i18n.translate('visTypeGauge.gauge.alignmentAutomaticTitle', { defaultMessage: 'Automatic', }), value: Alignment.Automatic, }, { - text: i18n.translate('visTypeVislib.gauge.alignmentHorizontalTitle', { + text: i18n.translate('visTypeGauge.gauge.alignmentHorizontalTitle', { defaultMessage: 'Horizontal', }), value: Alignment.Horizontal, }, { - text: i18n.translate('visTypeVislib.gauge.alignmentVerticalTitle', { + text: i18n.translate('visTypeGauge.gauge.alignmentVerticalTitle', { defaultMessage: 'Vertical', }), value: Alignment.Vertical, @@ -54,9 +51,3 @@ export const getGaugeCollections = () => ({ alignments: getAlignments(), colorSchemas, }); - -export const getHeatmapCollections = () => ({ - legendPositions: getPositions(), - scales: getScaleTypes(), - colorSchemas, -}); diff --git a/src/plugins/vis_types/vislib/public/editor/components/gauge/index.tsx b/src/plugins/vis_types/gauge/public/editor/components/gauge/index.tsx similarity index 84% rename from src/plugins/vis_types/vislib/public/editor/components/gauge/index.tsx rename to src/plugins/vis_types/gauge/public/editor/components/gauge/index.tsx index 5a741ffbadd83f..8fbe8b1567ae3a 100644 --- a/src/plugins/vis_types/vislib/public/editor/components/gauge/index.tsx +++ b/src/plugins/vis_types/gauge/public/editor/components/gauge/index.tsx @@ -10,19 +10,21 @@ import React, { useCallback } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { GaugeVisParams } from '../../../gauge'; +import { GaugeTypeProps, GaugeVisParams } from '../../../types'; import { RangesPanel } from './ranges_panel'; import { StylePanel } from './style_panel'; import { LabelsPanel } from './labels_panel'; -export type GaugeOptionsInternalProps = VisEditorOptionsProps & { +export interface GaugeOptionsProps extends VisEditorOptionsProps, GaugeTypeProps {} + +export type GaugeOptionsInternalProps = GaugeOptionsProps & { setGaugeValue: ( paramName: T, value: GaugeVisParams['gauge'][T] ) => void; }; -function GaugeOptions(props: VisEditorOptionsProps) { +function GaugeOptions(props: GaugeOptionsProps) { const { stateParams, setValue } = props; const setGaugeValue: GaugeOptionsInternalProps['setGaugeValue'] = useCallback( @@ -37,13 +39,9 @@ function GaugeOptions(props: VisEditorOptionsProps) { return ( <> - - - - ); diff --git a/src/plugins/vis_types/vislib/public/editor/components/gauge/labels_panel.tsx b/src/plugins/vis_types/gauge/public/editor/components/gauge/labels_panel.tsx similarity index 87% rename from src/plugins/vis_types/vislib/public/editor/components/gauge/labels_panel.tsx rename to src/plugins/vis_types/gauge/public/editor/components/gauge/labels_panel.tsx index fb5c1594e601a1..087a43c5dd0059 100644 --- a/src/plugins/vis_types/vislib/public/editor/components/gauge/labels_panel.tsx +++ b/src/plugins/vis_types/gauge/public/editor/components/gauge/labels_panel.tsx @@ -19,7 +19,7 @@ function LabelsPanel({ stateParams, setValue, setGaugeValue }: GaugeOptionsInter

@@ -27,7 +27,7 @@ function LabelsPanel({ stateParams, setValue, setGaugeValue }: GaugeOptionsInter

@@ -66,13 +67,20 @@ function RangesPanel({ /> + ); + return (

@@ -35,7 +53,7 @@ function StylePanel({ aggs, setGaugeValue, stateParams }: GaugeOptionsInternalPr - - + {showElasticChartsOptions ? ( + <> + + + {alignmentSelect} + + + + ) : ( + alignmentSelect + )}
); } diff --git a/src/plugins/vis_types/vislib/public/editor/components/index.tsx b/src/plugins/vis_types/gauge/public/editor/components/index.tsx similarity index 64% rename from src/plugins/vis_types/vislib/public/editor/components/index.tsx rename to src/plugins/vis_types/gauge/public/editor/components/index.tsx index ab7e34b576e874..7cb1ca9a26c697 100644 --- a/src/plugins/vis_types/vislib/public/editor/components/index.tsx +++ b/src/plugins/vis_types/gauge/public/editor/components/index.tsx @@ -9,10 +9,11 @@ import React, { lazy } from 'react'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { GaugeVisParams } from '../../gauge'; +import { GaugeTypeProps, GaugeVisParams } from '../../types'; const GaugeOptionsLazy = lazy(() => import('./gauge')); -export const GaugeOptions = (props: VisEditorOptionsProps) => ( - -); +export const getGaugeOptions = + ({ showElasticChartsOptions }: GaugeTypeProps) => + (props: VisEditorOptionsProps) => + ; diff --git a/src/plugins/vis_types/vislib/public/editor/index.ts b/src/plugins/vis_types/gauge/public/editor/index.ts similarity index 100% rename from src/plugins/vis_types/vislib/public/editor/index.ts rename to src/plugins/vis_types/gauge/public/editor/index.ts diff --git a/src/plugins/vis_types/gauge/public/index.ts b/src/plugins/vis_types/gauge/public/index.ts new file mode 100755 index 00000000000000..78aa55f59486f7 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { VisTypeGaugePlugin } from './plugin'; + +export function plugin() { + return new VisTypeGaugePlugin(); +} + +export type { VisTypeGaugePluginSetup, VisTypeGaugePluginStart } from './types'; + +export { gaugeVisType, goalVisType } from './vis_type'; diff --git a/src/plugins/vis_types/gauge/public/plugin.ts b/src/plugins/vis_types/gauge/public/plugin.ts new file mode 100755 index 00000000000000..8c11892192766c --- /dev/null +++ b/src/plugins/vis_types/gauge/public/plugin.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { VisualizationsSetup } from '../../../../plugins/visualizations/public'; +import { DataPublicPluginStart } from '../../../../plugins/data/public'; +import { CoreSetup } from '../../../../core/public'; +import { LEGACY_GAUGE_CHARTS_LIBRARY } from '../common'; +import { VisTypeGaugePluginSetup } from './types'; +import { gaugeVisType, goalVisType } from './vis_type'; + +/** @internal */ +export interface VisTypeGaugeSetupDependencies { + visualizations: VisualizationsSetup; +} + +/** @internal */ +export interface VisTypePiePluginStartDependencies { + data: DataPublicPluginStart; +} + +export class VisTypeGaugePlugin { + public setup( + core: CoreSetup, + { visualizations }: VisTypeGaugeSetupDependencies + ): VisTypeGaugePluginSetup { + if (!core.uiSettings.get(LEGACY_GAUGE_CHARTS_LIBRARY)) { + const visTypeProps = { showElasticChartsOptions: true }; + visualizations.createBaseVisualization(gaugeVisType(visTypeProps)); + visualizations.createBaseVisualization(goalVisType(visTypeProps)); + } + + return {}; + } + + public start() {} +} diff --git a/src/plugins/vis_types/gauge/public/to_ast.test.ts b/src/plugins/vis_types/gauge/public/to_ast.test.ts new file mode 100644 index 00000000000000..4f76e8e5f727e4 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/to_ast.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { TimefilterContract } from 'src/plugins/data/public'; +import { Vis } from 'src/plugins/visualizations/public'; +import { toExpressionAst } from './to_ast'; +import { GaugeVisParams } from './types'; + +describe('gauge vis toExpressionAst function', () => { + let vis: Vis; + + beforeEach(() => { + vis = { + isHierarchical: () => false, + type: {}, + params: { + gauge: { + gaugeType: 'Circle', + scale: { + show: false, + labels: false, + color: 'rgba(105,112,125,0.2)', + }, + labels: { + show: true, + }, + style: { + subText: 'some custom sublabel', + }, + }, + }, + data: { + indexPattern: { id: '123' } as any, + aggs: { + getResponseAggs: () => [], + aggs: [], + } as any, + }, + } as unknown as Vis; + }); + + it('with minimal params', () => { + const actual = toExpressionAst(vis, { + timefilter: {} as TimefilterContract, + }); + expect(actual).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_types/gauge/public/to_ast.ts b/src/plugins/vis_types/gauge/public/to_ast.ts new file mode 100644 index 00000000000000..041ae765b76969 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/to_ast.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getVisSchemas, SchemaConfig, VisToExpressionAst } from '../../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../../expressions/public'; +import type { + GaugeExpressionFunctionDefinition, + GaugeShape, +} from '../../../chart_expressions/expression_gauge/common'; +import { GaugeType, GaugeVisParams } from './types'; +import { getStopsWithColorsFromRanges } from './utils'; +import { getEsaggsFn } from './to_ast_esaggs'; + +const prepareDimension = (params: SchemaConfig) => { + const visdimension = buildExpressionFunction('visdimension', { accessor: params.accessor }); + + if (params.format) { + visdimension.addArgument('format', params.format.id); + visdimension.addArgument('formatParams', JSON.stringify(params.format.params)); + } + + return buildExpression([visdimension]); +}; + +const gaugeTypeToShape = (type: GaugeType): GaugeShape => { + const arc: GaugeShape = 'arc'; + const circle: GaugeShape = 'circle'; + + return { + [GaugeType.Arc]: arc, + [GaugeType.Circle]: circle, + }[type]; +}; + +export const toExpressionAst: VisToExpressionAst = (vis, params) => { + const schemas = getVisSchemas(vis, params); + + const { + gaugeType, + percentageMode, + percentageFormatPattern, + colorSchema, + colorsRange, + invertColors, + scale, + style, + labels, + } = vis.params.gauge; + + // fix formatter for percentage mode + if (percentageMode === true) { + schemas.metric.forEach((metric: SchemaConfig) => { + metric.format = { + id: 'percent', + params: { pattern: percentageFormatPattern }, + }; + }); + } + + const centralMajorMode = labels.show ? (style.subText ? 'custom' : 'auto') : 'none'; + const gauge = buildExpressionFunction('gauge', { + shape: gaugeTypeToShape(gaugeType), + metric: schemas.metric.map(prepareDimension), + ticksPosition: scale.show ? 'auto' : 'hidden', + labelMajorMode: 'auto', + colorMode: 'palette', + centralMajorMode, + ...(centralMajorMode === 'custom' ? { labelMinor: style.subText } : {}), + }); + + if (colorsRange && colorsRange.length) { + const stopsWithColors = getStopsWithColorsFromRanges(colorsRange, colorSchema, invertColors); + const palette = buildExpressionFunction('palette', { + ...stopsWithColors, + range: percentageMode ? 'percent' : 'number', + continuity: 'none', + gradient: true, + rangeMax: percentageMode ? 100 : Infinity, + rangeMin: 0, + }); + + gauge.addArgument('palette', buildExpression([palette])); + } + + const ast = buildExpression([getEsaggsFn(vis), gauge]); + + return ast.toAst(); +}; diff --git a/src/plugins/vis_types/gauge/public/to_ast_esaggs.ts b/src/plugins/vis_types/gauge/public/to_ast_esaggs.ts new file mode 100644 index 00000000000000..ecf3f3e6371777 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/to_ast_esaggs.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Vis } from '../../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../../expressions/public'; +import { + EsaggsExpressionFunctionDefinition, + IndexPatternLoadExpressionFunctionDefinition, +} from '../../../data/public'; + +import { GaugeVisParams } from './types'; + +/** + * Get esaggs expressions function + * @param vis + */ +export function getEsaggsFn(vis: Vis) { + return buildExpressionFunction('esaggs', { + index: buildExpression([ + buildExpressionFunction('indexPatternLoad', { + id: vis.data.indexPattern!.id!, + }), + ]), + metricsAtAllLevels: vis.isHierarchical(), + partialRows: false, + aggs: vis.data.aggs!.aggs.map((agg) => buildExpression(agg.toExpressionAst())), + }); +} diff --git a/src/plugins/vis_types/gauge/public/types.ts b/src/plugins/vis_types/gauge/public/types.ts new file mode 100755 index 00000000000000..c160b2ccf2f3f0 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/types.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { $Values } from '@kbn/utility-types'; +import { Range } from '../../../expressions/public'; +import { ColorSchemaParams, Labels, Style } from '../../../charts/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface VisTypeGaugePluginSetup {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface VisTypeGaugePluginStart {} + +/** + * Gauge title alignment + */ +export const Alignment = { + Automatic: 'automatic', + Horizontal: 'horizontal', + Vertical: 'vertical', +} as const; + +export type Alignment = $Values; + +export const GaugeType = { + Arc: 'Arc', + Circle: 'Circle', +} as const; + +export type GaugeType = $Values; + +export interface Gauge extends ColorSchemaParams { + backStyle: 'Full'; + gaugeStyle: 'Full'; + orientation: 'vertical'; + type: 'meter'; + alignment: Alignment; + colorsRange: Range[]; + extendRange: boolean; + gaugeType: GaugeType; + labels: Labels; + percentageMode: boolean; + percentageFormatPattern?: string; + outline?: boolean; + scale: { + show: boolean; + labels: false; + color: 'rgba(105,112,125,0.2)'; + }; + style: Style; +} + +export interface GaugeVisParams { + type: 'gauge'; + addTooltip: boolean; + addLegend: boolean; + isDisplayWarning: boolean; + gauge: Gauge; +} + +export interface GaugeTypeProps { + showElasticChartsOptions?: boolean; +} diff --git a/src/plugins/vis_types/gauge/public/utils/index.ts b/src/plugins/vis_types/gauge/public/utils/index.ts new file mode 100644 index 00000000000000..fb23c97d835fe0 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/utils/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { getStopsWithColorsFromRanges } from './palette'; diff --git a/src/plugins/vis_types/gauge/public/utils/palette.ts b/src/plugins/vis_types/gauge/public/utils/palette.ts new file mode 100644 index 00000000000000..a236a0daa6d533 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/utils/palette.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ColorSchemas, getHeatmapColors } from '../../../../charts/common'; +import { Range } from '../../../../expressions'; + +export interface PaletteConfig { + color: Array; + stop: number[]; +} + +const TRANSPARENT = 'rgb(0, 0, 0, 0)'; + +const getColor = ( + index: number, + elementsCount: number, + colorSchema: ColorSchemas, + invertColors: boolean = false +) => { + const divider = Math.max(elementsCount - 1, 1); + const value = invertColors ? 1 - index / divider : index / divider; + return getHeatmapColors(value, colorSchema); +}; + +export const getStopsWithColorsFromRanges = ( + ranges: Range[], + colorSchema: ColorSchemas, + invertColors: boolean = false +) => { + return ranges.reduce( + (acc, range, index, rangesArr) => { + if (index && range.from !== rangesArr[index - 1].to) { + acc.color.push(TRANSPARENT); + acc.stop.push(range.from); + } + + acc.color.push(getColor(index, rangesArr.length, colorSchema, invertColors)); + acc.stop.push(range.to); + + return acc; + }, + { color: [], stop: [] } + ); +}; diff --git a/src/plugins/vis_types/gauge/public/vis_type/gauge.tsx b/src/plugins/vis_types/gauge/public/vis_type/gauge.tsx new file mode 100644 index 00000000000000..648d34cdee7bdb --- /dev/null +++ b/src/plugins/vis_types/gauge/public/vis_type/gauge.tsx @@ -0,0 +1,130 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +import { ColorMode, ColorSchemas } from '../../../../charts/public'; +import { AggGroupNames } from '../../../../data/public'; +import { VisTypeDefinition, VIS_EVENT_TO_TRIGGER } from '../../../../visualizations/public'; + +import { Alignment, GaugeType, GaugeTypeProps } from '../types'; +import { toExpressionAst } from '../to_ast'; +import { getGaugeOptions } from '../editor/components'; +import { GaugeVisParams } from '../types'; +import { SplitTooltip } from './split_tooltip'; + +export const getGaugeVisTypeDefinition = ( + props: GaugeTypeProps +): VisTypeDefinition => ({ + name: 'gauge', + title: i18n.translate('visTypeGauge.gauge.gaugeTitle', { defaultMessage: 'Gauge' }), + icon: 'visGauge', + description: i18n.translate('visTypeGauge.gauge.gaugeDescription', { + defaultMessage: 'Show the status of a metric.', + }), + getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], + toExpressionAst, + visConfig: { + defaults: { + type: 'gauge', + addTooltip: true, + addLegend: true, + isDisplayWarning: false, + gauge: { + alignment: Alignment.Automatic, + extendRange: true, + percentageMode: false, + gaugeType: GaugeType.Arc, + gaugeStyle: 'Full', + backStyle: 'Full', + orientation: 'vertical', + colorSchema: ColorSchemas.GreenToRed, + gaugeColorMode: ColorMode.Labels, + colorsRange: [ + { from: 0, to: 50 }, + { from: 50, to: 75 }, + { from: 75, to: 100 }, + ], + invertColors: false, + labels: { + show: true, + color: 'black', + }, + scale: { + show: true, + labels: false, + color: 'rgba(105,112,125,0.2)', + }, + type: 'meter', + style: { + bgWidth: 0.9, + width: 0.9, + mask: false, + bgMask: false, + maskBars: 50, + bgFill: 'rgba(105,112,125,0.2)', + bgColor: true, + subText: '', + fontSize: 60, + }, + }, + }, + }, + editorConfig: { + optionsTemplate: getGaugeOptions(props), + schemas: [ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeGauge.gauge.metricTitle', { defaultMessage: 'Metric' }), + min: 1, + ...(props.showElasticChartsOptions ? { max: 1 } : {}), + aggFilter: [ + '!std_dev', + '!geo_centroid', + '!percentiles', + '!percentile_ranks', + '!derivative', + '!serial_diff', + '!moving_avg', + '!cumulative_sum', + '!geo_bounds', + '!filtered_metric', + '!single_percentile', + ], + defaults: [{ schema: 'metric', type: 'count' }], + }, + { + group: AggGroupNames.Buckets, + name: 'group', + // TODO: Remove when split chart aggs are supported + ...(props.showElasticChartsOptions && { + disabled: true, + tooltip: , + }), + title: i18n.translate('visTypeGauge.gauge.groupTitle', { + defaultMessage: 'Split group', + }), + min: 0, + max: 1, + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!rare_terms', + '!multi_terms', + '!significant_text', + ], + }, + ], + }, + requiresSearch: true, +}); diff --git a/src/plugins/vis_types/gauge/public/vis_type/goal.tsx b/src/plugins/vis_types/gauge/public/vis_type/goal.tsx new file mode 100644 index 00000000000000..e56e87ee70dff8 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/vis_type/goal.tsx @@ -0,0 +1,122 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +import { AggGroupNames } from '../../../../data/public'; +import { ColorMode, ColorSchemas } from '../../../../charts/public'; +import { VisTypeDefinition } from '../../../../visualizations/public'; + +import { getGaugeOptions } from '../editor/components'; +import { toExpressionAst } from '../to_ast'; +import { GaugeVisParams, GaugeType, GaugeTypeProps } from '../types'; +import { SplitTooltip } from './split_tooltip'; + +export const getGoalVisTypeDefinition = ( + props: GaugeTypeProps +): VisTypeDefinition => ({ + name: 'goal', + title: i18n.translate('visTypeGauge.goal.goalTitle', { defaultMessage: 'Goal' }), + icon: 'visGoal', + description: i18n.translate('visTypeGauge.goal.goalDescription', { + defaultMessage: 'Track how a metric progresses to a goal.', + }), + toExpressionAst, + visConfig: { + defaults: { + addTooltip: true, + addLegend: false, + isDisplayWarning: false, + type: 'gauge', + gauge: { + verticalSplit: false, + autoExtend: false, + percentageMode: true, + gaugeType: GaugeType.Arc, + gaugeStyle: 'Full', + backStyle: 'Full', + orientation: 'vertical', + useRanges: false, + colorSchema: ColorSchemas.GreenToRed, + gaugeColorMode: ColorMode.None, + colorsRange: [{ from: 0, to: 10000 }], + invertColors: false, + labels: { + show: true, + color: 'black', + }, + scale: { + show: false, + labels: false, + color: 'rgba(105,112,125,0.2)', + width: 2, + }, + type: 'meter', + style: { + bgFill: 'rgba(105,112,125,0.2)', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 60, + }, + }, + }, + }, + editorConfig: { + optionsTemplate: getGaugeOptions(props), + schemas: [ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeGauge.goal.metricTitle', { defaultMessage: 'Metric' }), + min: 1, + ...(props.showElasticChartsOptions ? { max: 1 } : {}), + aggFilter: [ + '!std_dev', + '!geo_centroid', + '!percentiles', + '!percentile_ranks', + '!derivative', + '!serial_diff', + '!moving_avg', + '!cumulative_sum', + '!geo_bounds', + '!filtered_metric', + '!single_percentile', + ], + defaults: [{ schema: 'metric', type: 'count' }], + }, + { + group: AggGroupNames.Buckets, + name: 'group', + // TODO: Remove when split chart aggs are supported + ...(props.showElasticChartsOptions && { + disabled: true, + tooltip: , + }), + title: i18n.translate('visTypeGauge.goal.groupTitle', { + defaultMessage: 'Split group', + }), + min: 0, + max: 1, + aggFilter: [ + '!geohash_grid', + '!geotile_grid', + '!filter', + '!sampler', + '!diversified_sampler', + '!rare_terms', + '!multi_terms', + '!significant_text', + ], + }, + ], + }, + requiresSearch: true, +}); diff --git a/src/plugins/vis_types/gauge/public/vis_type/index.ts b/src/plugins/vis_types/gauge/public/vis_type/index.ts new file mode 100644 index 00000000000000..cc78afedc02bd0 --- /dev/null +++ b/src/plugins/vis_types/gauge/public/vis_type/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { GaugeTypeProps } from '../types'; +import { getGaugeVisTypeDefinition } from './gauge'; +import { getGoalVisTypeDefinition } from './goal'; + +export const gaugeVisType = (props: GaugeTypeProps) => { + return getGaugeVisTypeDefinition(props); +}; + +export const goalVisType = (props: GaugeTypeProps) => { + return getGoalVisTypeDefinition(props); +}; diff --git a/src/plugins/vis_types/gauge/public/vis_type/split_tooltip.tsx b/src/plugins/vis_types/gauge/public/vis_type/split_tooltip.tsx new file mode 100644 index 00000000000000..8c92b6d65ff77e --- /dev/null +++ b/src/plugins/vis_types/gauge/public/vis_type/split_tooltip.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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'; + +export function SplitTooltip() { + return ( + + ); +} diff --git a/src/plugins/vis_types/gauge/server/index.ts b/src/plugins/vis_types/gauge/server/index.ts new file mode 100755 index 00000000000000..8d958e63356e2a --- /dev/null +++ b/src/plugins/vis_types/gauge/server/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { PluginConfigDescriptor } from 'src/core/server'; +import { configSchema, ConfigSchema } from '../config'; +import { VisTypeGaugeServerPlugin } from './plugin'; + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export const plugin = () => new VisTypeGaugeServerPlugin(); diff --git a/src/plugins/vis_types/gauge/server/plugin.ts b/src/plugins/vis_types/gauge/server/plugin.ts new file mode 100755 index 00000000000000..0334f963c720c1 --- /dev/null +++ b/src/plugins/vis_types/gauge/server/plugin.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; + +import { CoreSetup, Plugin, UiSettingsParams } from 'kibana/server'; + +import { LEGACY_GAUGE_CHARTS_LIBRARY } from '../common'; + +export const getUiSettingsConfig: () => Record> = () => ({ + [LEGACY_GAUGE_CHARTS_LIBRARY]: { + name: i18n.translate( + 'visTypeGauge.advancedSettings.visualization.legacyGaugeChartsLibrary.name', + { + defaultMessage: 'Gauge legacy charts library', + } + ), + requiresPageReload: true, + value: true, + description: i18n.translate( + 'visTypeGauge.advancedSettings.visualization.legacyGaugeChartsLibrary.description', + { + defaultMessage: 'Enables legacy charts library for gauge charts in visualize.', + } + ), + category: ['visualization'], + schema: schema.boolean(), + }, +}); + +export class VisTypeGaugeServerPlugin implements Plugin { + public setup(core: CoreSetup) { + core.uiSettings.register(getUiSettingsConfig()); + + return {}; + } + + public start() { + return {}; + } +} diff --git a/src/plugins/vis_types/gauge/tsconfig.json b/src/plugins/vis_types/gauge/tsconfig.json new file mode 100644 index 00000000000000..b1717173757e7d --- /dev/null +++ b/src/plugins/vis_types/gauge/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "*.ts" + ], + "references": [ + { "path": "../../../core/tsconfig.json" }, + { "path": "../../charts/tsconfig.json" }, + { "path": "../../data/tsconfig.json" }, + { "path": "../../expressions/tsconfig.json" }, + { "path": "../../chart_expressions/expression_gauge/tsconfig.json" }, + { "path": "../../visualizations/tsconfig.json" }, + { "path": "../../usage_collection/tsconfig.json" }, + { "path": "../../vis_default_editor/tsconfig.json" }, + { "path": "../../field_formats/tsconfig.json" }, + { "path": "../../chart_expressions/expression_partition_vis/tsconfig.json" } + ] + } \ No newline at end of file diff --git a/src/plugins/vis_types/vislib/kibana.json b/src/plugins/vis_types/vislib/kibana.json index feb252f1bb0f56..7c55aba21e7e47 100644 --- a/src/plugins/vis_types/vislib/kibana.json +++ b/src/plugins/vis_types/vislib/kibana.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations"], - "requiredBundles": ["kibanaUtils", "visDefaultEditor", "visTypeXy", "visTypePie", "visTypeHeatmap", "fieldFormats", "kibanaReact"], + "requiredBundles": ["kibanaUtils", "visTypeXy", "visTypePie", "visTypeHeatmap", "visTypeGauge", "fieldFormats", "kibanaReact"], "owner": { "name": "Vis Editors", "githubTeam": "kibana-vis-editors" diff --git a/src/plugins/vis_types/vislib/public/gauge.ts b/src/plugins/vis_types/vislib/public/gauge.ts index 128c0758bfd034..5edc33edb84fae 100644 --- a/src/plugins/vis_types/vislib/public/gauge.ts +++ b/src/plugins/vis_types/vislib/public/gauge.ts @@ -6,16 +6,13 @@ * Side Public License, v 1. */ -import { i18n } from '@kbn/i18n'; - -import { ColorMode, ColorSchemas, ColorSchemaParams, Labels, Style } from '../../../charts/public'; +import { ColorSchemaParams, Labels, Style } from '../../../charts/public'; import { RangeValues } from '../../../vis_default_editor/public'; -import { AggGroupNames } from '../../../data/public'; -import { VisTypeDefinition, VIS_EVENT_TO_TRIGGER } from '../../../visualizations/public'; +import { gaugeVisType } from '../../gauge/public'; +import { VisTypeDefinition } from '../../../visualizations/public'; -import { Alignment, GaugeType, VislibChartType } from './types'; +import { Alignment, GaugeType } from './types'; import { toExpressionAst } from './to_ast'; -import { GaugeOptions } from './editor/components'; export interface Gauge extends ColorSchemaParams { backStyle: 'Full'; @@ -46,104 +43,7 @@ export interface GaugeVisParams { gauge: Gauge; } -export const gaugeVisTypeDefinition: VisTypeDefinition = { - name: 'gauge', - title: i18n.translate('visTypeVislib.gauge.gaugeTitle', { defaultMessage: 'Gauge' }), - icon: 'visGauge', - description: i18n.translate('visTypeVislib.gauge.gaugeDescription', { - defaultMessage: 'Show the status of a metric.', - }), - getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], +export const gaugeVisTypeDefinition = { + ...gaugeVisType({}), toExpressionAst, - visConfig: { - defaults: { - type: VislibChartType.Gauge, - addTooltip: true, - addLegend: true, - isDisplayWarning: false, - gauge: { - alignment: Alignment.Automatic, - extendRange: true, - percentageMode: false, - gaugeType: GaugeType.Arc, - gaugeStyle: 'Full', - backStyle: 'Full', - orientation: 'vertical', - colorSchema: ColorSchemas.GreenToRed, - gaugeColorMode: ColorMode.Labels, - colorsRange: [ - { from: 0, to: 50 }, - { from: 50, to: 75 }, - { from: 75, to: 100 }, - ], - invertColors: false, - labels: { - show: true, - color: 'black', - }, - scale: { - show: true, - labels: false, - color: 'rgba(105,112,125,0.2)', - }, - type: 'meter', - style: { - bgWidth: 0.9, - width: 0.9, - mask: false, - bgMask: false, - maskBars: 50, - bgFill: 'rgba(105,112,125,0.2)', - bgColor: true, - subText: '', - fontSize: 60, - }, - }, - }, - }, - editorConfig: { - optionsTemplate: GaugeOptions, - schemas: [ - { - group: AggGroupNames.Metrics, - name: 'metric', - title: i18n.translate('visTypeVislib.gauge.metricTitle', { defaultMessage: 'Metric' }), - min: 1, - aggFilter: [ - '!std_dev', - '!geo_centroid', - '!percentiles', - '!percentile_ranks', - '!derivative', - '!serial_diff', - '!moving_avg', - '!cumulative_sum', - '!geo_bounds', - '!filtered_metric', - '!single_percentile', - ], - defaults: [{ schema: 'metric', type: 'count' }], - }, - { - group: AggGroupNames.Buckets, - name: 'group', - title: i18n.translate('visTypeVislib.gauge.groupTitle', { - defaultMessage: 'Split group', - }), - min: 0, - max: 1, - aggFilter: [ - '!geohash_grid', - '!geotile_grid', - '!filter', - '!sampler', - '!diversified_sampler', - '!rare_terms', - '!multi_terms', - '!significant_text', - ], - }, - ], - }, - requiresSearch: true, -}; +} as VisTypeDefinition; diff --git a/src/plugins/vis_types/vislib/public/goal.ts b/src/plugins/vis_types/vislib/public/goal.ts index 9dd5fdbc92b5f0..205b3a7a4280a0 100644 --- a/src/plugins/vis_types/vislib/public/goal.ts +++ b/src/plugins/vis_types/vislib/public/goal.ts @@ -6,108 +6,13 @@ * Side Public License, v 1. */ -import { i18n } from '@kbn/i18n'; - -import { AggGroupNames } from '../../../data/public'; -import { ColorMode, ColorSchemas } from '../../../charts/public'; import { VisTypeDefinition } from '../../../visualizations/public'; +import { goalVisType } from '../../gauge/public'; -import { GaugeOptions } from './editor'; import { toExpressionAst } from './to_ast'; -import { GaugeType } from './types'; import { GaugeVisParams } from './gauge'; -export const goalVisTypeDefinition: VisTypeDefinition = { - name: 'goal', - title: i18n.translate('visTypeVislib.goal.goalTitle', { defaultMessage: 'Goal' }), - icon: 'visGoal', - description: i18n.translate('visTypeVislib.goal.goalDescription', { - defaultMessage: 'Track how a metric progresses to a goal.', - }), +export const goalVisTypeDefinition = { + ...goalVisType({}), toExpressionAst, - visConfig: { - defaults: { - addTooltip: true, - addLegend: false, - isDisplayWarning: false, - type: 'gauge', - gauge: { - verticalSplit: false, - autoExtend: false, - percentageMode: true, - gaugeType: GaugeType.Arc, - gaugeStyle: 'Full', - backStyle: 'Full', - orientation: 'vertical', - useRanges: false, - colorSchema: ColorSchemas.GreenToRed, - gaugeColorMode: ColorMode.None, - colorsRange: [{ from: 0, to: 10000 }], - invertColors: false, - labels: { - show: true, - color: 'black', - }, - scale: { - show: false, - labels: false, - color: 'rgba(105,112,125,0.2)', - width: 2, - }, - type: 'meter', - style: { - bgFill: 'rgba(105,112,125,0.2)', - bgColor: false, - labelColor: false, - subText: '', - fontSize: 60, - }, - }, - }, - }, - editorConfig: { - optionsTemplate: GaugeOptions, - schemas: [ - { - group: AggGroupNames.Metrics, - name: 'metric', - title: i18n.translate('visTypeVislib.goal.metricTitle', { defaultMessage: 'Metric' }), - min: 1, - aggFilter: [ - '!std_dev', - '!geo_centroid', - '!percentiles', - '!percentile_ranks', - '!derivative', - '!serial_diff', - '!moving_avg', - '!cumulative_sum', - '!geo_bounds', - '!filtered_metric', - '!single_percentile', - ], - defaults: [{ schema: 'metric', type: 'count' }], - }, - { - group: AggGroupNames.Buckets, - name: 'group', - title: i18n.translate('visTypeVislib.goal.groupTitle', { - defaultMessage: 'Split group', - }), - min: 0, - max: 1, - aggFilter: [ - '!geohash_grid', - '!geotile_grid', - '!filter', - '!sampler', - '!diversified_sampler', - '!rare_terms', - '!multi_terms', - '!significant_text', - ], - }, - ], - }, - requiresSearch: true, -}; +} as VisTypeDefinition; diff --git a/src/plugins/vis_types/vislib/public/plugin.ts b/src/plugins/vis_types/vislib/public/plugin.ts index 8c54df99bb9888..23013bc582387e 100644 --- a/src/plugins/vis_types/vislib/public/plugin.ts +++ b/src/plugins/vis_types/vislib/public/plugin.ts @@ -14,13 +14,16 @@ import { ChartsPluginSetup } from '../../../charts/public'; import { DataPublicPluginStart } from '../../../data/public'; import { LEGACY_PIE_CHARTS_LIBRARY } from '../../pie/common/index'; import { LEGACY_HEATMAP_CHARTS_LIBRARY } from '../../heatmap/common/index'; +import { LEGACY_GAUGE_CHARTS_LIBRARY } from '../../gauge/common/index'; import { heatmapVisTypeDefinition } from './heatmap'; import { createVisTypeVislibVisFn } from './vis_type_vislib_vis_fn'; import { createPieVisFn } from './pie_fn'; -import { visLibVisTypeDefinitions, pieVisTypeDefinition } from './vis_type_vislib_vis_types'; +import { pieVisTypeDefinition } from './pie'; import { setFormatService, setDataActions, setTheme } from './services'; import { getVislibVisRenderer } from './vis_renderer'; +import { gaugeVisTypeDefinition } from './gauge'; +import { goalVisTypeDefinition } from './goal'; /** @internal */ export interface VisTypeVislibPluginSetupDependencies { @@ -48,7 +51,7 @@ export class VisTypeVislibPlugin { expressions, visualizations, charts }: VisTypeVislibPluginSetupDependencies ) { // register vislib XY axis charts - visLibVisTypeDefinitions.forEach(visualizations.createBaseVisualization); + expressions.registerRenderer(getVislibVisRenderer(core, charts)); expressions.registerFunction(createVisTypeVislibVisFn()); @@ -57,10 +60,16 @@ export class VisTypeVislibPlugin visualizations.createBaseVisualization(pieVisTypeDefinition); expressions.registerFunction(createPieVisFn()); } + if (core.uiSettings.get(LEGACY_HEATMAP_CHARTS_LIBRARY)) { // register vislib heatmap chart visualizations.createBaseVisualization(heatmapVisTypeDefinition); - expressions.registerFunction(createVisTypeVislibVisFn()); + } + + if (core.uiSettings.get(LEGACY_GAUGE_CHARTS_LIBRARY)) { + // register vislib gauge and goal charts + visualizations.createBaseVisualization(gaugeVisTypeDefinition); + visualizations.createBaseVisualization(goalVisTypeDefinition); } } diff --git a/src/plugins/vis_types/vislib/tsconfig.json b/src/plugins/vis_types/vislib/tsconfig.json index 6c0b13e36a6199..ef4d0a97fd2a4a 100644 --- a/src/plugins/vis_types/vislib/tsconfig.json +++ b/src/plugins/vis_types/vislib/tsconfig.json @@ -18,7 +18,7 @@ { "path": "../../expressions/tsconfig.json" }, { "path": "../../visualizations/tsconfig.json" }, { "path": "../../kibana_utils/tsconfig.json" }, - { "path": "../../vis_default_editor/tsconfig.json" }, + { "path": "../../vis_types/gauge/tsconfig.json" }, { "path": "../../vis_types/xy/tsconfig.json" }, { "path": "../../vis_types/pie/tsconfig.json" }, { "path": "../../vis_types/heatmap/tsconfig.json" }, diff --git a/src/plugins/visualizations/common/utils/accessors.ts b/src/plugins/visualizations/common/utils/accessors.ts index 57a2d434dfd762..27940e1fb6890b 100644 --- a/src/plugins/visualizations/common/utils/accessors.ts +++ b/src/plugins/visualizations/common/utils/accessors.ts @@ -37,7 +37,7 @@ export const getAccessorByDimension = ( dimension: string | ExpressionValueVisDimension, columns: DatatableColumn[] ) => { - if (typeof dimension === 'string') { + if (!isVisDimension(dimension)) { return dimension; } @@ -48,3 +48,13 @@ export const getAccessorByDimension = ( return accessor.id; }; + +export function isVisDimension( + accessor: string | ExpressionValueVisDimension | undefined +): accessor is ExpressionValueVisDimension { + if (typeof accessor === 'string' || accessor === undefined) { + return false; + } + + return true; +} diff --git a/src/plugins/visualizations/common/utils/index.ts b/src/plugins/visualizations/common/utils/index.ts index 59833b3e54e461..3e92e878bb5cf7 100644 --- a/src/plugins/visualizations/common/utils/index.ts +++ b/src/plugins/visualizations/common/utils/index.ts @@ -8,4 +8,4 @@ export { prepareLogTable } from './prepare_log_table'; export type { Dimension } from './prepare_log_table'; -export { findAccessorOrFail, getAccessorByDimension } from './accessors'; +export { findAccessorOrFail, getAccessorByDimension, isVisDimension } from './accessors'; diff --git a/src/plugins/visualizations/public/visualize_app/components/split_chart_warning.tsx b/src/plugins/visualizations/public/visualize_app/components/split_chart_warning.tsx index 942c6269f15f8e..679aa6aa2fbe1c 100644 --- a/src/plugins/visualizations/public/visualize_app/components/split_chart_warning.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/split_chart_warning.tsx @@ -6,56 +6,117 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiLink } from '@elastic/eui'; import { useKibana } from '../../../../kibana_react/public'; import { VisualizeServices } from '../types'; +import { CHARTS_WITHOUT_SMALL_MULTIPLES } from '../utils/split_chart_warning_helpers'; +import type { CHARTS_WITHOUT_SMALL_MULTIPLES as CHART_WITHOUT_SMALL_MULTIPLES } from '../utils/split_chart_warning_helpers'; -export const NEW_HEATMAP_CHARTS_LIBRARY = 'visualization:visualize:legacyHeatmapChartsLibrary'; +interface Props { + chartType: CHART_WITHOUT_SMALL_MULTIPLES; + chartConfigToken: string; +} -export const SplitChartWarning = () => { +interface WarningMessageProps { + canEditAdvancedSettings: boolean | Readonly<{ [x: string]: boolean }>; + advancedSettingsLink: string; +} + +const SwitchToOldLibraryMessage: FC = ({ + canEditAdvancedSettings, + advancedSettingsLink, +}) => { + return ( + <> + {canEditAdvancedSettings && ( + + + + ), + }} + /> + )} + + ); +}; + +const ContactAdminMessage: FC = ({ canEditAdvancedSettings }) => { + return ( + <> + {!canEditAdvancedSettings && ( + + )} + + ); +}; + +const GaugeWarningFormatMessage: FC = (props) => { + return ( + + + + + ), + }} + /> + ); +}; + +const HeatmapWarningFormatMessage: FC = (props) => { + return ( + + + + + ), + }} + /> + ); +}; + +const warningMessages = { + [CHARTS_WITHOUT_SMALL_MULTIPLES.heatmap]: HeatmapWarningFormatMessage, + [CHARTS_WITHOUT_SMALL_MULTIPLES.gauge]: GaugeWarningFormatMessage, +}; + +export const SplitChartWarning: FC = ({ chartType, chartConfigToken }) => { const { services } = useKibana(); const canEditAdvancedSettings = services.application.capabilities.advancedSettings.save; const advancedSettingsLink = services.application.getUrlForApp('management', { - path: `/kibana/settings?query=${NEW_HEATMAP_CHARTS_LIBRARY}`, + path: `/kibana/settings?query=${chartConfigToken}`, }); + const WarningMessage = warningMessages[chartType]; return ( - {canEditAdvancedSettings && ( - - - - ), - }} - /> - )} - {!canEditAdvancedSettings && ( - - )} - - ), - }} + } iconType="alert" diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx index 7d6594e05ae180..c76515072a1e2e 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.tsx @@ -17,7 +17,7 @@ import { ExperimentalVisInfo } from './experimental_vis_info'; import { useKibana } from '../../../../kibana_react/public'; import { urlFor } from '../../../../visualizations/public'; import { getUISettings } from '../../services'; -import { SplitChartWarning, NEW_HEATMAP_CHARTS_LIBRARY } from './split_chart_warning'; +import { SplitChartWarning } from './split_chart_warning'; import { SavedVisInstance, VisualizeAppState, @@ -25,6 +25,11 @@ import { VisualizeAppStateContainer, VisualizeEditorVisInstance, } from '../types'; +import { + CHARTS_CONFIG_TOKENS, + CHARTS_WITHOUT_SMALL_MULTIPLES, + isSplitChart as isSplitChartFn, +} from '../utils/split_chart_warning_helpers'; interface VisualizeEditorCommonProps { visInstance?: VisualizeEditorVisInstance; @@ -110,8 +115,17 @@ export const VisualizeEditorCommon = ({ return null; }, [visInstance?.savedVis, services, visInstance?.vis?.type.title]); // Adds a notification for split chart on the new implementation as it is not supported yet - const isSplitChart = visInstance?.vis?.data?.aggs?.aggs.some((agg) => agg.schema === 'split'); - const hasHeatmapLegacyhartsEnabled = getUISettings().get(NEW_HEATMAP_CHARTS_LIBRARY); + const chartName = visInstance?.vis.type.name; + const isSplitChart = isSplitChartFn(chartName, visInstance?.vis?.data?.aggs); + + const chartsWithoutSmallMultiples: string[] = Object.values(CHARTS_WITHOUT_SMALL_MULTIPLES); + const chartNeedsWarning = chartName ? chartsWithoutSmallMultiples.includes(chartName) : false; + const chartToken = + chartName && chartNeedsWarning + ? CHARTS_CONFIG_TOKENS[chartName as CHARTS_WITHOUT_SMALL_MULTIPLES] + : undefined; + + const hasLegacyChartsEnabled = chartToken ? getUISettings().get(chartToken) : true; return (
@@ -134,9 +148,12 @@ export const VisualizeEditorCommon = ({ /> )} {visInstance?.vis?.type?.stage === 'experimental' && } - {!hasHeatmapLegacyhartsEnabled && - isSplitChart && - visInstance?.vis.type.name === 'heatmap' && } + {!hasLegacyChartsEnabled && isSplitChart && chartNeedsWarning && chartToken && chartName && ( + + )} {visInstance?.vis?.type?.getInfoMessage?.(visInstance.vis)} {getLegacyUrlConflictCallout()} {visInstance && ( diff --git a/src/plugins/visualizations/public/visualize_app/constants.ts b/src/plugins/visualizations/public/visualize_app/constants.ts new file mode 100644 index 00000000000000..fd256cb5bbb864 --- /dev/null +++ b/src/plugins/visualizations/public/visualize_app/constants.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const NEW_HEATMAP_CHARTS_LIBRARY = 'visualization:visualize:legacyHeatmapChartsLibrary'; +export const NEW_GAUGE_CHARTS_LIBRARY = 'visualization:visualize:legacyGaugeChartsLibrary'; diff --git a/src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.ts b/src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.ts new file mode 100644 index 00000000000000..d40f15aa08657a --- /dev/null +++ b/src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { $Values } from '@kbn/utility-types'; +import { AggConfigs } from '../../../../data/common'; +import { NEW_HEATMAP_CHARTS_LIBRARY, NEW_GAUGE_CHARTS_LIBRARY } from '../constants'; + +export const CHARTS_WITHOUT_SMALL_MULTIPLES = { + heatmap: 'heatmap', + gauge: 'gauge', +} as const; + +export type CHARTS_WITHOUT_SMALL_MULTIPLES = $Values; + +export const CHARTS_CONFIG_TOKENS = { + [CHARTS_WITHOUT_SMALL_MULTIPLES.heatmap]: NEW_HEATMAP_CHARTS_LIBRARY, + [CHARTS_WITHOUT_SMALL_MULTIPLES.gauge]: NEW_GAUGE_CHARTS_LIBRARY, +} as const; + +export const isSplitChart = (chartType: string | undefined, aggs?: AggConfigs) => { + const defaultIsSplitChart = () => aggs?.aggs.some((agg) => agg.schema === 'split'); + + const knownCheckers = { + [CHARTS_WITHOUT_SMALL_MULTIPLES.heatmap]: defaultIsSplitChart, + [CHARTS_WITHOUT_SMALL_MULTIPLES.gauge]: () => aggs?.aggs.some((agg) => agg.schema === 'group'), + }; + + return (knownCheckers[chartType as CHARTS_WITHOUT_SMALL_MULTIPLES] ?? defaultIsSplitChart)(); +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 565dba89ffd321..d47ff1ed31496a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6285,33 +6285,33 @@ "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsText": "1つのデータソースが返せるバケットの最大数です。値が大きいとブラウザのレンダリング速度が下がる可能性があります。", "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsTitle": "ヒートマップの最大バケット数", "visTypeVislib.aggResponse.allDocsTitle": "すべてのドキュメント", - "visTypeVislib.controls.gaugeOptions.alignmentLabel": "アラインメント", - "visTypeVislib.controls.gaugeOptions.autoExtendRangeLabel": "範囲を自動拡張", - "visTypeVislib.controls.gaugeOptions.extendRangeTooltip": "範囲をデータの最高値に広げます。", - "visTypeVislib.controls.gaugeOptions.gaugeTypeLabel": "ゲージタイプ", - "visTypeVislib.controls.gaugeOptions.labelsTitle": "ラベル", - "visTypeVislib.controls.gaugeOptions.rangesTitle": "範囲", - "visTypeVislib.controls.gaugeOptions.showLabelsLabel": "ラベルを表示", - "visTypeVislib.controls.gaugeOptions.showLegendLabel": "凡例を表示", - "visTypeVislib.controls.gaugeOptions.showOutline": "アウトラインを表示", - "visTypeVislib.controls.gaugeOptions.showScaleLabel": "縮尺を表示", - "visTypeVislib.controls.gaugeOptions.styleTitle": "スタイル", - "visTypeVislib.controls.gaugeOptions.subTextLabel": "サブラベル", + "visTypeGauge.controls.gaugeOptions.alignmentLabel": "アラインメント", + "visTypeGauge.controls.gaugeOptions.autoExtendRangeLabel": "範囲を自動拡張", + "visTypeGauge.controls.gaugeOptions.extendRangeTooltip": "範囲をデータの最高値に広げます。", + "visTypeGauge.controls.gaugeOptions.gaugeTypeLabel": "ゲージタイプ", + "visTypeGauge.controls.gaugeOptions.labelsTitle": "ラベル", + "visTypeGauge.controls.gaugeOptions.rangesTitle": "範囲", + "visTypeGauge.controls.gaugeOptions.showLabelsLabel": "ラベルを表示", + "visTypeGauge.controls.gaugeOptions.showLegendLabel": "凡例を表示", + "visTypeGauge.controls.gaugeOptions.showOutline": "アウトラインを表示", + "visTypeGauge.controls.gaugeOptions.showScaleLabel": "縮尺を表示", + "visTypeGauge.controls.gaugeOptions.styleTitle": "スタイル", + "visTypeGauge.controls.gaugeOptions.subTextLabel": "サブラベル", "visTypeVislib.functions.pie.help": "パイビジュアライゼーション", "visTypeVislib.functions.vislib.help": "Vislib ビジュアライゼーション", - "visTypeVislib.gauge.alignmentAutomaticTitle": "自動", - "visTypeVislib.gauge.alignmentHorizontalTitle": "横", - "visTypeVislib.gauge.alignmentVerticalTitle": "縦", - "visTypeVislib.gauge.gaugeDescription": "メトリックのステータスを示します。", - "visTypeVislib.gauge.gaugeTitle": "ゲージ", - "visTypeVislib.gauge.gaugeTypes.arcText": "弧形", - "visTypeVislib.gauge.gaugeTypes.circleText": "円", - "visTypeVislib.gauge.groupTitle": "グループを分割", - "visTypeVislib.gauge.metricTitle": "メトリック", - "visTypeVislib.goal.goalDescription": "メトリックがどのように目標まで進むのかを追跡します。", - "visTypeVislib.goal.goalTitle": "ゴール", - "visTypeVislib.goal.groupTitle": "グループを分割", - "visTypeVislib.goal.metricTitle": "メトリック", + "visTypeGauge.gauge.alignmentAutomaticTitle": "自動", + "visTypeGauge.gauge.alignmentHorizontalTitle": "横", + "visTypeGauge.gauge.alignmentVerticalTitle": "縦", + "visTypeGauge.gauge.gaugeDescription": "メトリックのステータスを示します。", + "visTypeGauge.gauge.gaugeTitle": "ゲージ", + "visTypeGauge.gauge.gaugeTypes.arcText": "弧形", + "visTypeGauge.gauge.gaugeTypes.circleText": "円", + "visTypeGauge.gauge.groupTitle": "グループを分割", + "visTypeGauge.gauge.metricTitle": "メトリック", + "visTypeGauge.goal.goalDescription": "メトリックがどのように目標まで進むのかを追跡します。", + "visTypeGauge.goal.goalTitle": "ゴール", + "visTypeGauge.goal.groupTitle": "グループを分割", + "visTypeGauge.goal.metricTitle": "メトリック", "visTypeVislib.vislib.errors.noResultsFoundTitle": "結果が見つかりませんでした", "visTypeVislib.vislib.heatmap.maxBucketsText": "定義された数列が多すぎます({nr})。構成されている最大値は {max} です。", "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "値 {legendDataLabel} でフィルタリング", @@ -6587,8 +6587,8 @@ "visualizations.listing.table.titleColumnName": "タイトル", "visualizations.listing.table.typeColumnName": "型", "visualizations.listingPageTitle": "Visualizeライブラリ", - "visualizations.newHeatmapChart.conditionalMessage.advancedSettingsLink": "高度な設定", - "visualizations.newHeatmapChart.conditionalMessage.newLibrary": "{link}で古いライブラリに切り替える", + "visualizations.newChart.conditionalMessage.advancedSettingsLink": "高度な設定", + "visualizations.newChart.conditionalMessage.newLibrary": "{link}で古いライブラリに切り替える", "visualizations.newHeatmapChart.notificationMessage": "新しいヒートマップグラフライブラリはまだ分割グラフアグリゲーションをサポートしていません。{conditionalMessage}", "visualizations.newVisWizard.aggBasedGroupDescription": "クラシック Visualize ライブラリを使用して、アグリゲーションに基づいてグラフを作成します。", "visualizations.newVisWizard.aggBasedGroupTitle": "アグリゲーションに基づく", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a230de12371745..96f9e892ba0b93 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6296,33 +6296,33 @@ "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsText": "单个数据源可以返回的最大存储桶数目。较高的数目可能对浏览器呈现性能有负面影响", "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsTitle": "热图最大存储桶数", "visTypeVislib.aggResponse.allDocsTitle": "所有文档", - "visTypeVislib.controls.gaugeOptions.alignmentLabel": "对齐方式", - "visTypeVislib.controls.gaugeOptions.autoExtendRangeLabel": "自动扩展范围", - "visTypeVislib.controls.gaugeOptions.extendRangeTooltip": "将数据范围扩展到数据中的最大值。", - "visTypeVislib.controls.gaugeOptions.gaugeTypeLabel": "仪表类型", - "visTypeVislib.controls.gaugeOptions.labelsTitle": "标签", - "visTypeVislib.controls.gaugeOptions.rangesTitle": "范围", - "visTypeVislib.controls.gaugeOptions.showLabelsLabel": "显示标签", - "visTypeVislib.controls.gaugeOptions.showLegendLabel": "显示图例", - "visTypeVislib.controls.gaugeOptions.showOutline": "显示轮廓", - "visTypeVislib.controls.gaugeOptions.showScaleLabel": "显示比例", - "visTypeVislib.controls.gaugeOptions.styleTitle": "样式", - "visTypeVislib.controls.gaugeOptions.subTextLabel": "子标签", + "visTypeGauge.controls.gaugeOptions.alignmentLabel": "对齐方式", + "visTypeGauge.controls.gaugeOptions.autoExtendRangeLabel": "自动扩展范围", + "visTypeGauge.controls.gaugeOptions.extendRangeTooltip": "将数据范围扩展到数据中的最大值。", + "visTypeGauge.controls.gaugeOptions.gaugeTypeLabel": "仪表类型", + "visTypeGauge.controls.gaugeOptions.labelsTitle": "标签", + "visTypeGauge.controls.gaugeOptions.rangesTitle": "范围", + "visTypeGauge.controls.gaugeOptions.showLabelsLabel": "显示标签", + "visTypeGauge.controls.gaugeOptions.showLegendLabel": "显示图例", + "visTypeGauge.controls.gaugeOptions.showOutline": "显示轮廓", + "visTypeGauge.controls.gaugeOptions.showScaleLabel": "显示比例", + "visTypeGauge.controls.gaugeOptions.styleTitle": "样式", + "visTypeGauge.controls.gaugeOptions.subTextLabel": "子标签", "visTypeVislib.functions.pie.help": "饼图可视化", "visTypeVislib.functions.vislib.help": "Vislib 可视化", - "visTypeVislib.gauge.alignmentAutomaticTitle": "自动", - "visTypeVislib.gauge.alignmentHorizontalTitle": "水平", - "visTypeVislib.gauge.alignmentVerticalTitle": "垂直", - "visTypeVislib.gauge.gaugeDescription": "显示指标的状态。", - "visTypeVislib.gauge.gaugeTitle": "仪表盘", - "visTypeVislib.gauge.gaugeTypes.arcText": "弧形", - "visTypeVislib.gauge.gaugeTypes.circleText": "圆形", - "visTypeVislib.gauge.groupTitle": "拆分组", - "visTypeVislib.gauge.metricTitle": "指标", - "visTypeVislib.goal.goalDescription": "跟踪指标如何达到目标。", - "visTypeVislib.goal.goalTitle": "目标图", - "visTypeVislib.goal.groupTitle": "拆分组", - "visTypeVislib.goal.metricTitle": "指标", + "visTypeGauge.gauge.alignmentAutomaticTitle": "自动", + "visTypeGauge.gauge.alignmentHorizontalTitle": "水平", + "visTypeGauge.gauge.alignmentVerticalTitle": "垂直", + "visTypeGauge.gauge.gaugeDescription": "显示指标的状态。", + "visTypeGauge.gauge.gaugeTitle": "仪表盘", + "visTypeGauge.gauge.gaugeTypes.arcText": "弧形", + "visTypeGauge.gauge.gaugeTypes.circleText": "圆形", + "visTypeGauge.gauge.groupTitle": "拆分组", + "visTypeGauge.gauge.metricTitle": "指标", + "visTypeGauge.goal.goalDescription": "跟踪指标如何达到目标。", + "visTypeGauge.goal.goalTitle": "目标图", + "visTypeGauge.goal.groupTitle": "拆分组", + "visTypeGauge.goal.metricTitle": "指标", "visTypeVislib.vislib.errors.noResultsFoundTitle": "找不到结果", "visTypeVislib.vislib.heatmap.maxBucketsText": "定义了过多的序列 ({nr})。配置的最大值为 {max}。", "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "筛留值 {legendDataLabel}", @@ -6598,8 +6598,8 @@ "visualizations.listing.table.titleColumnName": "标题", "visualizations.listing.table.typeColumnName": "类型", "visualizations.listingPageTitle": "Visualize 库", - "visualizations.newHeatmapChart.conditionalMessage.advancedSettingsLink": "免费的 API 密钥。", - "visualizations.newHeatmapChart.conditionalMessage.newLibrary": "切换到{link}中的旧库", + "visualizations.newChart.conditionalMessage.advancedSettingsLink": "免费的 API 密钥。", + "visualizations.newChart.conditionalMessage.newLibrary": "切换到{link}中的旧库", "visualizations.newHeatmapChart.notificationMessage": "新的热图图表库尚不支持拆分图表聚合。{conditionalMessage}", "visualizations.newVisWizard.aggBasedGroupDescription": "使用我们的经典可视化库,基于聚合创建图表。", "visualizations.newVisWizard.aggBasedGroupTitle": "基于聚合", From 157f0a8bf8715cb74ab0120de9ce43196883ac16 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Fri, 4 Mar 2022 12:15:02 +0100 Subject: [PATCH 05/33] [Kibana React] Refactor Page Template (#125304) * [Kibana React] Refactor Page Template * More refactoring * Fix failing snapshot * More unit tests * More refactoring * Further refactoring * Fix missing import * Revert "Fix missing import" This reverts commit 9a238a06e7df2c5d9085cda9bbbc7a0060ed332c. * Revert "Further refactoring" This reverts commit 59fea87ac2026ce1505f437f8df02d3f3e5a62fd. * Revert "More refactoring" This reverts commit 19659f0c6999c4ad1f56288f68bcd6487ca9e2c2. * Remove ActionCard * Fix failing test * Minor fixes * Fix failing test * Introducing HOC withSolutionNavBar * Fix typescript errors * Fix typescript errors/invalid import * Code restructuring * Fix failing tests * Some polishing * Minor fixes * Fix missing export * update failing test * Fix template name * Fix failing test * Fixed spacing & some names; moved sass imports * Applying Clint's comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: cchaos --- .../__snapshots__/page_template.test.tsx.snap | 415 +++++++++--------- .../public/page_template/index.ts | 4 +- .../__snapshots__/no_data_page.test.tsx.snap | 96 +--- .../action_cards.scss} | 0 .../action_cards/action_cards.test.tsx | 31 ++ .../action_cards/action_cards.tsx | 29 ++ .../no_data_page/action_cards/index.tsx | 9 + .../page_template/no_data_page/index.ts | 1 + .../no_data_config_page/index.tsx | 9 + .../no_data_config_page.tsx | 38 ++ .../no_data_page/no_data_page.tsx | 107 ++--- .../no_data_page_body.test.tsx.snap | 59 +++ .../no_data_page/no_data_page_body/index.tsx | 9 + .../no_data_page_body.test.tsx | 34 ++ .../no_data_page_body/no_data_page_body.tsx | 58 +++ .../page_template/page_template.test.tsx | 11 +- .../public/page_template/page_template.tsx | 139 ++---- .../page_template/page_template_inner.tsx | 61 +++ .../public/page_template/util/constants.ts | 20 + .../public/page_template/util/index.ts | 10 + .../public/page_template/util/presentation.ts | 13 + .../page_template/with_solution_nav.tsx | 75 ++++ 22 files changed, 750 insertions(+), 478 deletions(-) rename src/plugins/kibana_react/public/page_template/no_data_page/{no_data_page.scss => action_cards/action_cards.scss} (100%) create mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.test.tsx create mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.tsx create mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/action_cards/index.tsx create mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/index.tsx create mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/no_data_config_page.tsx create mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/__snapshots__/no_data_page_body.test.tsx.snap create mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/index.tsx create mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.test.tsx create mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.tsx create mode 100644 src/plugins/kibana_react/public/page_template/page_template_inner.tsx create mode 100644 src/plugins/kibana_react/public/page_template/util/constants.ts create mode 100644 src/plugins/kibana_react/public/page_template/util/index.ts create mode 100644 src/plugins/kibana_react/public/page_template/util/presentation.ts create mode 100644 src/plugins/kibana_react/public/page_template/with_solution_nav.tsx diff --git a/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap b/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap index d90daa33d16863..ae036f6a002ecb 100644 --- a/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap @@ -1,39 +1,65 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`KibanaPageTemplate render basic template 1`] = ` - +
+
+
+
+
+
+
+

+ test +

+
+
+
+
+
+ test +
+
+
+
+
+
+
+
+
+
+
`; exports[`KibanaPageTemplate render custom empty prompt only 1`] = ` - } /> - + `; exports[`KibanaPageTemplate render custom empty prompt with page header 1`] = ` - } /> - + `; exports[`KibanaPageTemplate render default empty prompt 1`] = ` - - - test -

+ ], + "title": "test", } - iconColor="" - iconType="test" - /> -
+ } +/> `; exports[`KibanaPageTemplate render noDataContent 1`] = ` - + { + const { + className, + noDataConfig, + ...rest + } = props; + + if (!noDataConfig) { + return null; } - pageSideBarProps={ + + const template = _util.NO_DATA_PAGE_TEMPLATE_PROPS.template; + const classes = (0, _util.getClasses)(template, className); + return /*#__PURE__*/_react.default.createElement(_eui.EuiPageTemplate, (0, _extends2.default)({ + "data-test-subj": props['data-test-subj'], + template: template, + className: classes + }, rest, _util.NO_DATA_PAGE_TEMPLATE_PROPS), /*#__PURE__*/_react.default.createElement(_no_data_page.NoDataPage, noDataConfig)); +} + noDataConfig={ Object { - "className": "kbnPageTemplate__pageSideBar", - "paddingSize": "none", - } - } - restrictWidth={950} - template="centeredBody" -> - - -`; - -exports[`KibanaPageTemplate render solutionNav 1`] = ` - - } - pageSideBarProps={ + solutionNav={ Object { - "className": "kbnPageTemplate__pageSideBar", - "paddingSize": "none", + "icon": "solution", + "items": Array [ + Object { + "id": "1", + "items": Array [ + Object { + "id": "1.1", + "items": undefined, + "name": "Ingest Node Pipelines", + "tabIndex": undefined, + }, + Object { + "id": "1.2", + "items": undefined, + "name": "Logstash Pipelines", + "tabIndex": undefined, + }, + Object { + "id": "1.3", + "items": undefined, + "name": "Beats Central Management", + "tabIndex": undefined, + }, + ], + "name": "Ingest", + "tabIndex": undefined, + }, + Object { + "id": "2", + "items": Array [ + Object { + "id": "2.1", + "items": undefined, + "name": "Index Management", + "tabIndex": undefined, + }, + Object { + "id": "2.2", + "items": undefined, + "name": "Index Lifecycle Policies", + "tabIndex": undefined, + }, + Object { + "id": "2.3", + "items": undefined, + "name": "Snapshot and Restore", + "tabIndex": undefined, + }, + ], + "name": "Data", + "tabIndex": undefined, + }, + ], + "name": "Solution", } } - restrictWidth={true} /> `; + +exports[`KibanaPageTemplate render solutionNav 1`] = ` +
+
+
+
+
+
+
+
+
+

+ test +

+
+
+
+
+
+ test +
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/plugins/kibana_react/public/page_template/index.ts b/src/plugins/kibana_react/public/page_template/index.ts index 41eeaab01ef39c..fda644a2847974 100644 --- a/src/plugins/kibana_react/public/page_template/index.ts +++ b/src/plugins/kibana_react/public/page_template/index.ts @@ -8,5 +8,7 @@ export type { KibanaPageTemplateProps } from './page_template'; export { KibanaPageTemplate } from './page_template'; -export { KibanaPageTemplateSolutionNavAvatar } from './solution_nav'; +export { KibanaPageTemplateSolutionNavAvatar, KibanaPageTemplateSolutionNav } from './solution_nav'; export * from './no_data_page'; +export { withSolutionNav } from './with_solution_nav'; +export { NO_DATA_PAGE_MAX_WIDTH, NO_DATA_PAGE_TEMPLATE_PROPS } from './util'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap index 0554e64c5ecb6f..18df4fa2444966 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap @@ -106,88 +106,22 @@ exports[`NoDataPage render 1`] = ` } } > - - - -

- -

- -

- - - , - "solution": "Elastic", - } - } - /> -

-
-
- - - , + , + , + ] } - > - - - - - - - - - - + docsLink="test" + pageTitle="Welcome to Elastic Elastic!" + solution="Elastic" + /> diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.scss b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.scss similarity index 100% rename from src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.scss rename to src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.scss diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.test.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.test.tsx new file mode 100644 index 00000000000000..6223613815f58c --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { shallowWithIntl } from '@kbn/test-jest-helpers'; +import { NoDataCard } from '../no_data_card'; +import { ActionCards } from './action_cards'; + +describe('ActionCards', () => { + const onClick = jest.fn(); + const action = { + recommended: false, + button: 'Button text', + onClick, + }; + const card = ; + const actionCard1 =
{card}
; + const actionCard2 =
{card}
; + + test('renders correctly', () => { + const component = shallowWithIntl(); + const actionCards = component.find('div'); + expect(actionCards.length).toBe(2); + expect(actionCards.at(0).key()).toBe('first'); + expect(actionCards.at(1).key()).toBe('second'); + }); +}); diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.tsx new file mode 100644 index 00000000000000..3af0a61876729a --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/action_cards.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 './action_cards.scss'; + +import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; +import React, { ReactElement } from 'react'; +import { ElasticAgentCard, NoDataCard } from '../no_data_card'; + +interface ActionCardsProps { + actionCards: Array | ReactElement>; +} +export const ActionCards = ({ actionCards }: ActionCardsProps) => { + const cards = actionCards.map((card) => ( + + {card} + + )); + return ( + + {cards} + + ); +}; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/index.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/index.tsx new file mode 100644 index 00000000000000..0ba8ef86ba5cbd --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/action_cards/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { ActionCards } from './action_cards'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/index.ts b/src/plugins/kibana_react/public/page_template/no_data_page/index.ts index 55661ad6f14f75..b5a11722dd3975 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/index.ts +++ b/src/plugins/kibana_react/public/page_template/no_data_page/index.ts @@ -8,3 +8,4 @@ export * from './no_data_page'; export * from './no_data_card'; +export * from './no_data_config_page'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/index.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/index.tsx new file mode 100644 index 00000000000000..0bdde400213984 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { NoDataConfigPage, NoDataConfigPageWithSolutionNavBar } from './no_data_config_page'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/no_data_config_page.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/no_data_config_page.tsx new file mode 100644 index 00000000000000..07ffc96181476d --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_config_page/no_data_config_page.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { EuiPageTemplate } from '@elastic/eui'; +import React from 'react'; +import { NoDataPage } from '../no_data_page'; +import { withSolutionNav } from '../../with_solution_nav'; +import { KibanaPageTemplateProps } from '../../page_template'; +import { getClasses, NO_DATA_PAGE_TEMPLATE_PROPS } from '../../util'; + +export const NoDataConfigPage = (props: KibanaPageTemplateProps) => { + const { className, noDataConfig, ...rest } = props; + + if (!noDataConfig) { + return null; + } + + const template = NO_DATA_PAGE_TEMPLATE_PROPS.template; + const classes = getClasses(template, className); + + return ( + + + + ); +}; + +export const NoDataConfigPageWithSolutionNavBar = withSolutionNav(NoDataConfigPage); diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx index 0c8754f852b042..077f991477e8d7 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx @@ -6,36 +6,14 @@ * Side Public License, v 1. */ -import './no_data_page.scss'; - import React, { ReactNode, useMemo, FunctionComponent, MouseEventHandler } from 'react'; -import { - EuiFlexItem, - EuiCardProps, - EuiFlexGrid, - EuiSpacer, - EuiText, - EuiTextColor, - EuiLink, - CommonProps, -} from '@elastic/eui'; +import { EuiCardProps, EuiSpacer, EuiText, EuiLink, CommonProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import classNames from 'classnames'; -import { KibanaPageTemplateProps } from '../page_template'; import { ElasticAgentCard, NoDataCard } from './no_data_card'; -import { KibanaPageTemplateSolutionNavAvatar } from '../solution_nav'; - -export const NO_DATA_PAGE_MAX_WIDTH = 950; -export const NO_DATA_PAGE_TEMPLATE_PROPS: KibanaPageTemplateProps = { - restrictWidth: NO_DATA_PAGE_MAX_WIDTH, - template: 'centeredBody', - pageContentProps: { - hasShadow: false, - color: 'transparent', - }, -}; +import { NoDataPageBody } from './no_data_page_body/no_data_page_body'; export const NO_DATA_RECOMMENDED = i18n.translate( 'kibana-react.noDataPage.noDataPage.recommended', @@ -112,70 +90,35 @@ export const NoDataPage: FunctionComponent = ({ // Convert the iterated [[key, value]] array format back into an object const sortedData = Object.fromEntries(sortedEntries); const actionsKeys = Object.keys(sortedData); - const renderActions = useMemo(() => { + + const actionCards = useMemo(() => { return Object.values(sortedData).map((action, i) => { - if (actionsKeys[i] === 'elasticAgent' || actionsKeys[i] === 'beats') { - return ( - - - - ); - } else { - return ( - - - - ); - } + const isAgent = actionsKeys[i] === 'elasticAgent' || actionsKeys[i] === 'beats'; + const key = isAgent ? 'empty-page-agent-action' : `empty-page-${actionsKeys[i]}-action`; + return isAgent ? ( + + ) : ( + + ); }); }, [actions, sortedData, actionsKeys]); + const title = + pageTitle || + i18n.translate('kibana-react.noDataPage.welcomeTitle', { + defaultMessage: 'Welcome to Elastic {solution}!', + values: { solution }, + }); + return (
- - - -

- {pageTitle || ( - - )} -

- -

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

-
-
- - - - {renderActions} - + {actionsKeys.length > 1 ? ( <> diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/__snapshots__/no_data_page_body.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/__snapshots__/no_data_page_body.test.tsx.snap new file mode 100644 index 00000000000000..034e716cb6dce0 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/__snapshots__/no_data_page_body.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NoDataPageBody render 1`] = ` + + + + +

+ +

+ + + , + "solution": "Elastic", + } + } + /> +

+
+ + + + +

, + ] + } + /> + +`; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/index.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/index.tsx new file mode 100644 index 00000000000000..a5312d696139dc --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { NoDataPageBody } from './no_data_page_body'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.test.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.test.tsx new file mode 100644 index 00000000000000..f3419a47f63b8d --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { NoDataPageBody } from './no_data_page_body'; +import React, { ReactElement } from 'react'; +import { shallowWithIntl } from '@kbn/test-jest-helpers'; +import { NoDataCard } from '../no_data_card'; + +describe('NoDataPageBody', () => { + const action = { + recommended: false, + button: 'Button text', + onClick: jest.fn(), + }; + const el = ; + const actionCards: ReactElement[] = []; + actionCards.push(
{el}
); + test('render', () => { + const component = shallowWithIntl( + + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.tsx new file mode 100644 index 00000000000000..67e123de68885b --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page_body/no_data_page_body.tsx @@ -0,0 +1,58 @@ +/* + * 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 { EuiLink, EuiSpacer, EuiText, EuiTextColor } from '@elastic/eui'; +import React, { ReactElement } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { NoDataPageProps } from '../no_data_page'; +import { KibanaPageTemplateSolutionNavAvatar } from '../../solution_nav'; +import { ActionCards } from '../action_cards'; +import { ElasticAgentCard, NoDataCard } from '../no_data_card'; + +type NoDataPageBodyProps = { + actionCards: Array | ReactElement>; +} & Omit; + +export const NoDataPageBody = (props: NoDataPageBodyProps) => { + const { pageTitle, docsLink, solution, actionCards, logo } = props; + + return ( + <> + + + +

{pageTitle}

+ +

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

+
+
+ + + + ); +}; diff --git a/src/plugins/kibana_react/public/page_template/page_template.test.tsx b/src/plugins/kibana_react/public/page_template/page_template.test.tsx index 6c6c4bb33e6bb7..aff6082902a34e 100644 --- a/src/plugins/kibana_react/public/page_template/page_template.test.tsx +++ b/src/plugins/kibana_react/public/page_template/page_template.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, render } from 'enzyme'; import { KibanaPageTemplate, KibanaPageTemplateProps } from './page_template'; import { EuiEmptyPrompt } from '@elastic/eui'; import { KibanaPageTemplateSolutionNavProps } from './solution_nav'; @@ -104,7 +104,7 @@ describe('KibanaPageTemplate', () => { }); test('render basic template', () => { - const component = shallow( + const component = render( { }); test('render solutionNav', () => { - const component = shallow( + const component = render( { /> ); expect(component).toMatchSnapshot(); + expect(component.find('div.kbnPageTemplate__pageSideBar').length).toBe(1); }); test('render noDataContent', () => { @@ -167,8 +168,6 @@ describe('KibanaPageTemplate', () => { pageSideBarProps={{ className: 'customClass' }} /> ); - expect(component.prop('pageSideBarProps').className).toEqual( - 'kbnPageTemplate__pageSideBar customClass' - ); + expect(component.html().includes('kbnPageTemplate__pageSideBar customClass')).toBe(true); }); }); diff --git a/src/plugins/kibana_react/public/page_template/page_template.tsx b/src/plugins/kibana_react/public/page_template/page_template.tsx index cf2b27c3b00dab..77469b240a19da 100644 --- a/src/plugins/kibana_react/public/page_template/page_template.tsx +++ b/src/plugins/kibana_react/public/page_template/page_template.tsx @@ -6,25 +6,18 @@ * Side Public License, v 1. */ -/* eslint-disable @typescript-eslint/naming-convention */ import './page_template.scss'; -import React, { FunctionComponent, useState } from 'react'; -import classNames from 'classnames'; +import React, { FunctionComponent } from 'react'; +import { EuiPageTemplateProps } from '@elastic/eui'; +import { KibanaPageTemplateSolutionNavProps } from './solution_nav'; import { - EuiEmptyPrompt, - EuiPageTemplate, - EuiPageTemplateProps, - useIsWithinBreakpoints, -} from '@elastic/eui'; - -import { - KibanaPageTemplateSolutionNav, - KibanaPageTemplateSolutionNavProps, -} from './solution_nav/solution_nav'; - -import { NoDataPage, NoDataPageProps, NO_DATA_PAGE_TEMPLATE_PROPS } from './no_data_page'; + NoDataPageProps, + NoDataConfigPage, + NoDataConfigPageWithSolutionNavBar, +} from './no_data_page'; +import { KibanaPageTemplateInner, KibanaPageTemplateWithSolutionNav } from './page_template_inner'; /** * A thin wrapper around EuiPageTemplate with a few Kibana specific additions @@ -51,119 +44,53 @@ export type KibanaPageTemplateProps = EuiPageTemplateProps & { export const KibanaPageTemplate: FunctionComponent = ({ template, className, - pageHeader, children, - isEmptyState, - restrictWidth = true, - pageSideBar, - pageSideBarProps, solutionNav, noDataConfig, ...rest }) => { /** - * Only default to open in large+ breakpoints - */ - const isMediumBreakpoint = useIsWithinBreakpoints(['m']); - const isLargerBreakpoint = useIsWithinBreakpoints(['l', 'xl']); - - /** - * Create the solution nav component + * If passing the custom template of `noDataConfig` */ - const [isSideNavOpenOnDesktop, setisSideNavOpenOnDesktop] = useState( - JSON.parse(String(localStorage.getItem('solutionNavIsCollapsed'))) ? false : true - ); - const toggleOpenOnDesktop = () => { - setisSideNavOpenOnDesktop(!isSideNavOpenOnDesktop); - // Have to store it as the opposite of the default we want - localStorage.setItem('solutionNavIsCollapsed', JSON.stringify(isSideNavOpenOnDesktop)); - }; - let sideBarClasses = 'kbnPageTemplate__pageSideBar'; - if (solutionNav) { - // Only apply shrinking classes if collapsibility is available through `solutionNav` - sideBarClasses = classNames(sideBarClasses, { - 'kbnPageTemplate__pageSideBar--shrink': - isMediumBreakpoint || (isLargerBreakpoint && !isSideNavOpenOnDesktop), - }); - - pageSideBar = ( - ); } - /** - * An easy way to create the right content for empty pages - */ - const emptyStateDefaultTemplate = pageSideBar ? 'centeredContent' : 'centeredBody'; - if (isEmptyState && pageHeader && !children) { - template = template ?? emptyStateDefaultTemplate; - const { iconType, pageTitle, description, rightSideItems } = pageHeader; - pageHeader = undefined; - children = ( - {pageTitle}

: undefined} - body={description ?

{description}

: undefined} - actions={rightSideItems} + if (noDataConfig) { + return ( + ); - } else if (isEmptyState && pageHeader && children) { - template = template ?? 'centeredContent'; - } else if (isEmptyState && !pageHeader) { - template = template ?? emptyStateDefaultTemplate; } - // Set the template before the classes - template = noDataConfig ? NO_DATA_PAGE_TEMPLATE_PROPS.template : template; - - const classes = classNames( - 'kbnPageTemplate', - { [`kbnPageTemplate--${template}`]: template }, - className - ); - - /** - * If passing the custom template of `noDataConfig` - */ - if (noDataConfig) { + if (solutionNav) { return ( - - - + className={className} + solutionNav={solutionNav} + children={children} + {...rest} + /> ); } return ( - - {children} - + /> ); }; diff --git a/src/plugins/kibana_react/public/page_template/page_template_inner.tsx b/src/plugins/kibana_react/public/page_template/page_template_inner.tsx new file mode 100644 index 00000000000000..3060a77c781c4d --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/page_template_inner.tsx @@ -0,0 +1,61 @@ +/* + * 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, { FunctionComponent } from 'react'; + +import { EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui'; +import { withSolutionNav } from './with_solution_nav'; +import { KibanaPageTemplateProps } from './page_template'; +import { getClasses } from './util'; + +type Props = KibanaPageTemplateProps; + +/** + * A thin wrapper around EuiPageTemplate with a few Kibana specific additions + */ +export const KibanaPageTemplateInner: FunctionComponent = ({ + template, + className, + pageHeader, + children, + isEmptyState, + ...rest +}) => { + /** + * An easy way to create the right content for empty pages + */ + const emptyStateDefaultTemplate = 'centeredBody'; + if (isEmptyState && pageHeader && !children) { + template = template ?? emptyStateDefaultTemplate; + const { iconType, pageTitle, description, rightSideItems } = pageHeader; + pageHeader = undefined; + children = ( + {pageTitle} : undefined} + body={description ?

{description}

: undefined} + actions={rightSideItems} + /> + ); + } else if (isEmptyState && pageHeader && children) { + template = template ?? 'centeredContent'; + } else if (isEmptyState && !pageHeader) { + template = template ?? emptyStateDefaultTemplate; + } + + const classes = getClasses(template, className); + + return ( + + {children} + + ); +}; + +export const KibanaPageTemplateWithSolutionNav = withSolutionNav(KibanaPageTemplateInner); diff --git a/src/plugins/kibana_react/public/page_template/util/constants.ts b/src/plugins/kibana_react/public/page_template/util/constants.ts new file mode 100644 index 00000000000000..159a6d0d8d4c15 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/util/constants.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPageTemplateProps } from '../page_template'; + +export const NO_DATA_PAGE_MAX_WIDTH = 950; + +export const NO_DATA_PAGE_TEMPLATE_PROPS: KibanaPageTemplateProps = { + restrictWidth: NO_DATA_PAGE_MAX_WIDTH, + template: 'centeredBody', + pageContentProps: { + hasShadow: false, + color: 'transparent', + }, +}; diff --git a/src/plugins/kibana_react/public/page_template/util/index.ts b/src/plugins/kibana_react/public/page_template/util/index.ts new file mode 100644 index 00000000000000..adfefdf8345664 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/util/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { getClasses } from './presentation'; +export * from './constants'; diff --git a/src/plugins/kibana_react/public/page_template/util/presentation.ts b/src/plugins/kibana_react/public/page_template/util/presentation.ts new file mode 100644 index 00000000000000..ab7144ee37b579 --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/util/presentation.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. + */ + +import classNames from 'classnames'; + +export const getClasses = (template: string | undefined, className: string | undefined) => { + return classNames('kbnPageTemplate', { [`kbnPageTemplate--${template}`]: template }, className); +}; diff --git a/src/plugins/kibana_react/public/page_template/with_solution_nav.tsx b/src/plugins/kibana_react/public/page_template/with_solution_nav.tsx new file mode 100644 index 00000000000000..5ec49b7c7cf29c --- /dev/null +++ b/src/plugins/kibana_react/public/page_template/with_solution_nav.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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, { ComponentType, useState } from 'react'; +import classNames from 'classnames'; +import { useIsWithinBreakpoints } from '@elastic/eui'; +import { EuiPageSideBarProps } from '@elastic/eui/src/components/page/page_side_bar'; +import { + KibanaPageTemplateSolutionNav, + KibanaPageTemplateSolutionNavProps, +} from '../page_template/solution_nav'; +import { KibanaPageTemplateProps } from '../page_template'; + +type SolutionNavProps = KibanaPageTemplateProps & { + solutionNav: KibanaPageTemplateSolutionNavProps; +}; + +const SOLUTION_NAV_COLLAPSED_KEY = 'solutionNavIsCollapsed'; + +export const withSolutionNav = (WrappedComponent: ComponentType) => { + const WithSolutionNav = (props: SolutionNavProps) => { + const isMediumBreakpoint = useIsWithinBreakpoints(['m']); + const isLargerBreakpoint = useIsWithinBreakpoints(['l', 'xl']); + const [isSideNavOpenOnDesktop, setisSideNavOpenOnDesktop] = useState( + !JSON.parse(String(localStorage.getItem(SOLUTION_NAV_COLLAPSED_KEY))) + ); + const { solutionNav, children, isEmptyState, template } = props; + const toggleOpenOnDesktop = () => { + setisSideNavOpenOnDesktop(!isSideNavOpenOnDesktop); + // Have to store it as the opposite of the default we want + localStorage.setItem(SOLUTION_NAV_COLLAPSED_KEY, JSON.stringify(isSideNavOpenOnDesktop)); + }; + const sideBarClasses = classNames( + 'kbnPageTemplate__pageSideBar', + { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'kbnPageTemplate__pageSideBar--shrink': + isMediumBreakpoint || (isLargerBreakpoint && !isSideNavOpenOnDesktop), + }, + props.pageSideBarProps?.className + ); + + const templateToUse = isEmptyState && !template ? 'centeredContent' : template; + + const pageSideBar = ( + + ); + const pageSideBarProps = { + paddingSize: 'none', + ...props.pageSideBarProps, + className: sideBarClasses, + } as EuiPageSideBarProps; // needed because for some reason 'none' is not recognized as a valid value for paddingSize + return ( + + {children} + + ); + }; + WithSolutionNav.displayName = `WithSolutionNavBar${WrappedComponent}`; + return WithSolutionNav; +}; From de4f3e204ce39e7fe79dfd07f2f8d046b66ac51e Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Fri, 4 Mar 2022 12:29:53 +0100 Subject: [PATCH 06/33] test: skip flaky suricata test (#126893) --- .../cypress/integration/timelines/row_renderers.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts index 2219339d0577d4..ffb9e8b61c0b9a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts @@ -87,7 +87,9 @@ describe('Row renderers', () => { }); describe('Suricata', () => { - it('Signature tooltips do not overlap', () => { + // This test has become very flaky over time and was blocking a lot of PRs. + // A follw-up ticket to tackle this issue has been created. + it.skip('Signature tooltips do not overlap', () => { // Hover the signature to show the tooltips cy.get(TIMELINE_ROW_RENDERERS_SURICATA_SIGNATURE) .parents('.euiPopover__anchor') From 494047a2c0bf628448cc059479c1137d3ec11a0a Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 4 Mar 2022 13:52:01 +0200 Subject: [PATCH 07/33] [Cases] Enable Cases on the stack management page (#125224) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/actions/server/feature.ts | 7 ++ x-pack/plugins/cases/common/constants.ts | 83 +++++++++++-------- x-pack/plugins/cases/kibana.json | 8 +- x-pack/plugins/cases/public/application.tsx | 72 ++++++++++++++++ .../cases/public/common/hooks.test.tsx | 43 ++++++++++ x-pack/plugins/cases/public/common/hooks.ts | 15 ++++ .../cases/public/common/lib/kibana/hooks.ts | 20 ++++- .../public/common/navigation/hooks.test.tsx | 47 +++++++---- .../cases/public/common/navigation/hooks.ts | 71 ++++++++++++---- .../public/common/navigation/paths.test.ts | 16 ++++ .../cases/public/common/navigation/paths.ts | 16 ++-- .../cases/public/common/translations.ts | 8 ++ .../cases/public/components/app/index.tsx | 30 ++++++- .../cases/public/components/app/routes.tsx | 2 + .../public/components/app/translations.ts | 15 ---- .../markdown_editor/plugins/lens/plugin.tsx | 9 +- .../plugins/cases/public/components/utils.ts | 4 +- .../public/components/wrappers/index.tsx | 5 ++ .../cases/public/methods/get_cases.tsx | 5 +- x-pack/plugins/cases/public/plugin.ts | 62 ++++++++++++-- x-pack/plugins/cases/public/types.ts | 24 +++++- x-pack/plugins/cases/server/features.ts | 63 ++++++++++++++ x-pack/plugins/cases/server/plugin.ts | 9 +- .../apis/features/features/features.ts | 1 + .../apis/security/privileges.ts | 1 + .../apis/security/privileges_basic.ts | 1 + .../feature_controls/management_security.ts | 2 +- 27 files changed, 531 insertions(+), 108 deletions(-) create mode 100644 x-pack/plugins/cases/public/application.tsx create mode 100644 x-pack/plugins/cases/public/common/hooks.test.tsx create mode 100644 x-pack/plugins/cases/public/common/hooks.ts create mode 100644 x-pack/plugins/cases/server/features.ts diff --git a/x-pack/plugins/actions/server/feature.ts b/x-pack/plugins/actions/server/feature.ts index c6265a17b122e0..ecb2e0e0b9ea3c 100644 --- a/x-pack/plugins/actions/server/feature.ts +++ b/x-pack/plugins/actions/server/feature.ts @@ -13,6 +13,12 @@ import { } from './constants/saved_objects'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; +/** + * The order of appearance in the feature privilege page + * under the management section. + */ +const FEATURE_ORDER = 3000; + export const ACTIONS_FEATURE = { id: 'actions', name: i18n.translate('xpack.actions.featureRegistry.actionsFeatureName', { @@ -20,6 +26,7 @@ export const ACTIONS_FEATURE = { }), category: DEFAULT_APP_CATEGORIES.management, app: [], + order: FEATURE_ORDER, management: { insightsAndAlerting: ['triggersActions'], }, diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index c57b40dbcf0024..0f3fd6345a672f 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -7,16 +7,33 @@ import { ConnectorTypes } from './api'; import { CasesContextFeatures } from './ui/types'; -export const DEFAULT_DATE_FORMAT = 'dateFormat'; -export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz'; +export const DEFAULT_DATE_FORMAT = 'dateFormat' as const; +export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz' as const; -export const APP_ID = 'cases'; +/** + * Application + */ + +export const APP_ID = 'cases' as const; +export const FEATURE_ID = 'generalCases' as const; +export const APP_OWNER = 'cases' as const; +export const APP_PATH = '/app/management/insightsAndAlerting/cases' as const; +/** + * The main Cases application is in the stack management under the + * Alerts and Insights section. To do that, Cases registers to the management + * application. This constant holds the application ID of the management plugin + */ +export const STACK_APP_ID = 'management' as const; + +/** + * Saved objects + */ -export const CASE_SAVED_OBJECT = 'cases'; -export const CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT = 'cases-connector-mappings'; -export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions'; -export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments'; -export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure'; +export const CASE_SAVED_OBJECT = 'cases' as const; +export const CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT = 'cases-connector-mappings' as const; +export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions' as const; +export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments' as const; +export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure' as const; /** * If more values are added here please also add them here: x-pack/test/cases_api_integration/common/fixtures/plugins @@ -33,32 +50,32 @@ export const SAVED_OBJECT_TYPES = [ * Case routes */ -export const CASES_URL = '/api/cases'; -export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; -export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; -export const CASE_CONFIGURE_DETAILS_URL = `${CASES_URL}/configure/{configuration_id}`; -export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; +export const CASES_URL = '/api/cases' as const; +export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}` as const; +export const CASE_CONFIGURE_URL = `${CASES_URL}/configure` as const; +export const CASE_CONFIGURE_DETAILS_URL = `${CASES_URL}/configure/{configuration_id}` as const; +export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors` as const; -export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; -export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; -export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push`; -export const CASE_REPORTERS_URL = `${CASES_URL}/reporters`; -export const CASE_STATUS_URL = `${CASES_URL}/status`; -export const CASE_TAGS_URL = `${CASES_URL}/tags`; -export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`; +export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments` as const; +export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}` as const; +export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push` as const; +export const CASE_REPORTERS_URL = `${CASES_URL}/reporters` as const; +export const CASE_STATUS_URL = `${CASES_URL}/status` as const; +export const CASE_TAGS_URL = `${CASES_URL}/tags` as const; +export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions` as const; -export const CASE_ALERTS_URL = `${CASES_URL}/alerts/{alert_id}`; -export const CASE_DETAILS_ALERTS_URL = `${CASE_DETAILS_URL}/alerts`; +export const CASE_ALERTS_URL = `${CASES_URL}/alerts/{alert_id}` as const; +export const CASE_DETAILS_ALERTS_URL = `${CASE_DETAILS_URL}/alerts` as const; -export const CASE_METRICS_DETAILS_URL = `${CASES_URL}/metrics/{case_id}`; +export const CASE_METRICS_DETAILS_URL = `${CASES_URL}/metrics/{case_id}` as const; /** * Action routes */ -export const ACTION_URL = '/api/actions'; -export const ACTION_TYPES_URL = `${ACTION_URL}/connector_types`; -export const CONNECTORS_URL = `${ACTION_URL}/connectors`; +export const ACTION_URL = '/api/actions' as const; +export const ACTION_TYPES_URL = `${ACTION_URL}/connector_types` as const; +export const CONNECTORS_URL = `${ACTION_URL}/connectors` as const; export const SUPPORTED_CONNECTORS = [ `${ConnectorTypes.serviceNowITSM}`, @@ -71,10 +88,10 @@ export const SUPPORTED_CONNECTORS = [ /** * Alerts */ -export const MAX_ALERTS_PER_CASE = 5000; +export const MAX_ALERTS_PER_CASE = 5000 as const; -export const SECURITY_SOLUTION_OWNER = 'securitySolution'; -export const OBSERVABILITY_OWNER = 'observability'; +export const SECURITY_SOLUTION_OWNER = 'securitySolution' as const; +export const OBSERVABILITY_OWNER = 'observability' as const; export const OWNER_INFO = { [SECURITY_SOLUTION_OWNER]: { @@ -85,16 +102,16 @@ export const OWNER_INFO = { label: 'Observability', iconType: 'logoObservability', }, -}; +} as const; -export const MAX_DOCS_PER_PAGE = 10000; -export const MAX_CONCURRENT_SEARCHES = 10; +export const MAX_DOCS_PER_PAGE = 10000 as const; +export const MAX_CONCURRENT_SEARCHES = 10 as const; /** * Validation */ -export const MAX_TITLE_LENGTH = 64; +export const MAX_TITLE_LENGTH = 64 as const; /** * Cases features diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index 170ac2a96aaa8d..c96372b57593db 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -10,8 +10,10 @@ "id":"cases", "kibanaVersion":"kibana", "optionalPlugins":[ + "home", "security", "spaces", + "features", "usageCollection" ], "owner":{ @@ -20,14 +22,18 @@ }, "requiredPlugins":[ "actions", + "data", + "embeddable", "esUiShared", "lens", "features", "kibanaReact", "kibanaUtils", - "triggersActionsUi" + "triggersActionsUi", + "management" ], "requiredBundles": [ + "home", "savedObjects" ], "server":true, diff --git a/x-pack/plugins/cases/public/application.tsx b/x-pack/plugins/cases/public/application.tsx new file mode 100644 index 00000000000000..a528fa2376dbb5 --- /dev/null +++ b/x-pack/plugins/cases/public/application.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router } from 'react-router-dom'; +import { I18nProvider } from '@kbn/i18n-react'; +import { EuiErrorBoundary } from '@elastic/eui'; + +import { + KibanaContextProvider, + KibanaThemeProvider, + useUiSetting$, +} from '../../../../src/plugins/kibana_react/public'; +import { EuiThemeProvider as StyledComponentsThemeProvider } from '../../../../src/plugins/kibana_react/common'; +import { RenderAppProps } from './types'; +import { CasesApp } from './components/app'; + +export const renderApp = (deps: RenderAppProps) => { + const { mountParams } = deps; + const { element } = mountParams; + + ReactDOM.render(, element); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + +const CasesAppWithContext = () => { + const [darkMode] = useUiSetting$('theme:darkMode'); + + return ( + + + + ); +}; + +CasesAppWithContext.displayName = 'CasesAppWithContext'; + +export const App: React.FC<{ deps: RenderAppProps }> = ({ deps }) => { + const { mountParams, coreStart, pluginsStart, storage, kibanaVersion } = deps; + const { history, theme$ } = mountParams; + + return ( + + + + + + + + + + + + ); +}; + +App.displayName = 'App'; diff --git a/x-pack/plugins/cases/public/common/hooks.test.tsx b/x-pack/plugins/cases/public/common/hooks.test.tsx new file mode 100644 index 00000000000000..f122d3312a6435 --- /dev/null +++ b/x-pack/plugins/cases/public/common/hooks.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { TestProviders } from '../common/mock'; +import { useIsMainApplication } from './hooks'; +import { useApplication } from '../components/cases_context/use_application'; + +jest.mock('../components/cases_context/use_application'); + +const useApplicationMock = useApplication as jest.Mock; + +describe('hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + useApplicationMock.mockReturnValue({ appId: 'management', appTitle: 'Management' }); + }); + + describe('useIsMainApplication', () => { + it('returns true if it is the main application', () => { + const { result } = renderHook(() => useIsMainApplication(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toBe(true); + }); + + it('returns false if it is not the main application', () => { + useApplicationMock.mockReturnValue({ appId: 'testAppId', appTitle: 'Test app' }); + const { result } = renderHook(() => useIsMainApplication(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/common/hooks.ts b/x-pack/plugins/cases/public/common/hooks.ts new file mode 100644 index 00000000000000..f65b56fecfd848 --- /dev/null +++ b/x-pack/plugins/cases/public/common/hooks.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { STACK_APP_ID } from '../../common/constants'; +import { useCasesContext } from '../components/cases_context/use_cases_context'; + +export const useIsMainApplication = () => { + const { appId } = useCasesContext(); + + return appId === STACK_APP_ID; +}; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts index 08eb2ebf3df7a6..bf81e92af92bd7 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -10,7 +10,11 @@ import moment from 'moment-timezone'; import { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants'; +import { + FEATURE_ID, + DEFAULT_DATE_FORMAT, + DEFAULT_DATE_FORMAT_TZ, +} from '../../../../common/constants'; import { AuthenticatedUser } from '../../../../../security/common/model'; import { convertToCamelCase } from '../../../containers/utils'; import { StartServices } from '../../../types'; @@ -155,3 +159,17 @@ export const useNavigation = (appId: string) => { const { getAppUrl } = useAppUrl(appId); return { navigateTo, getAppUrl }; }; + +/** + * Returns the capabilities of the main cases application + * + */ +export const useApplicationCapabilities = (): { crud: boolean; read: boolean } => { + const capabilities = useKibana().services.application.capabilities; + const casesCapabilities = capabilities[FEATURE_ID]; + + return { + crud: !!casesCapabilities?.crud_cases, + read: !!casesCapabilities?.read_cases, + }; +}; diff --git a/x-pack/plugins/cases/public/common/navigation/hooks.test.tsx b/x-pack/plugins/cases/public/common/navigation/hooks.test.tsx index 96e34d6c69cc99..cd6cf13e7256da 100644 --- a/x-pack/plugins/cases/public/common/navigation/hooks.test.tsx +++ b/x-pack/plugins/cases/public/common/navigation/hooks.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { act, renderHook } from '@testing-library/react-hooks'; +import { APP_ID } from '../../../common/constants'; import { useNavigation } from '../../common/lib/kibana'; import { TestProviders } from '../../common/mock'; import { @@ -33,9 +34,12 @@ describe('hooks', () => { describe('useCasesNavigation', () => { it('it calls getAppUrl with correct arguments', () => { - const { result } = renderHook(() => useCasesNavigation(CasesDeepLinkId.cases), { - wrapper: ({ children }) => {children}, - }); + const { result } = renderHook( + () => useCasesNavigation({ deepLinkId: CasesDeepLinkId.cases }), + { + wrapper: ({ children }) => {children}, + } + ); const [getCasesUrl] = result.current; @@ -43,20 +47,23 @@ describe('hooks', () => { getCasesUrl(false); }); - expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases' }); + expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: APP_ID }); }); it('it calls navigateToAllCases with correct arguments', () => { - const { result } = renderHook(() => useCasesNavigation(CasesDeepLinkId.cases), { - wrapper: ({ children }) => {children}, - }); + const { result } = renderHook( + () => useCasesNavigation({ deepLinkId: CasesDeepLinkId.cases }), + { + wrapper: ({ children }) => {children}, + } + ); const [, navigateToCases] = result.current; act(() => { navigateToCases(); }); - expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases' }); + expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: APP_ID }); }); }); @@ -70,7 +77,7 @@ describe('hooks', () => { result.current.getAllCasesUrl(false); }); - expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases' }); + expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, path: '/', deepLinkId: APP_ID }); }); it('it calls navigateToAllCases with correct arguments', () => { @@ -82,7 +89,7 @@ describe('hooks', () => { result.current.navigateToAllCases(); }); - expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases' }); + expect(navigateTo).toHaveBeenCalledWith({ path: '/', deepLinkId: APP_ID }); }); }); @@ -96,7 +103,11 @@ describe('hooks', () => { result.current.getCreateCaseUrl(false); }); - expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases_create' }); + expect(getAppUrl).toHaveBeenCalledWith({ + absolute: false, + path: '/create', + deepLinkId: APP_ID, + }); }); it('it calls navigateToAllCases with correct arguments', () => { @@ -108,7 +119,7 @@ describe('hooks', () => { result.current.navigateToCreateCase(); }); - expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases_create' }); + expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: APP_ID, path: '/create' }); }); }); @@ -122,7 +133,11 @@ describe('hooks', () => { result.current.getConfigureCasesUrl(false); }); - expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, deepLinkId: 'cases_configure' }); + expect(getAppUrl).toHaveBeenCalledWith({ + absolute: false, + path: '/configure', + deepLinkId: APP_ID, + }); }); it('it calls navigateToAllCases with correct arguments', () => { @@ -134,7 +149,7 @@ describe('hooks', () => { result.current.navigateToConfigureCases(); }); - expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases_configure' }); + expect(navigateTo).toHaveBeenCalledWith({ path: '/configure', deepLinkId: APP_ID }); }); }); @@ -150,7 +165,7 @@ describe('hooks', () => { expect(getAppUrl).toHaveBeenCalledWith({ absolute: false, - deepLinkId: 'cases', + deepLinkId: APP_ID, path: '/test', }); }); @@ -164,7 +179,7 @@ describe('hooks', () => { result.current.navigateToCaseView({ detailName: 'test' }); }); - expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: 'cases', path: '/test' }); + expect(navigateTo).toHaveBeenCalledWith({ deepLinkId: APP_ID, path: '/test' }); }); }); }); diff --git a/x-pack/plugins/cases/public/common/navigation/hooks.ts b/x-pack/plugins/cases/public/common/navigation/hooks.ts index b6dcae1c0c1ce0..c5488b40607959 100644 --- a/x-pack/plugins/cases/public/common/navigation/hooks.ts +++ b/x-pack/plugins/cases/public/common/navigation/hooks.ts @@ -7,10 +7,17 @@ import { useCallback } from 'react'; import { useParams } from 'react-router-dom'; + +import { APP_ID } from '../../../common/constants'; import { useNavigation } from '../lib/kibana'; import { useCasesContext } from '../../components/cases_context/use_cases_context'; -import { CasesDeepLinkId, ICasesDeepLinkId } from './deep_links'; -import { CaseViewPathParams, generateCaseViewPath } from './paths'; +import { ICasesDeepLinkId } from './deep_links'; +import { + CASES_CONFIGURE_PATH, + CASES_CREATE_PATH, + CaseViewPathParams, + generateCaseViewPath, +} from './paths'; export const useCaseViewParams = () => useParams(); @@ -18,34 +25,60 @@ type GetCasesUrl = (absolute?: boolean) => string; type NavigateToCases = () => void; type UseCasesNavigation = [GetCasesUrl, NavigateToCases]; -export const useCasesNavigation = (deepLinkId: ICasesDeepLinkId): UseCasesNavigation => { +export const useCasesNavigation = ({ + path, + deepLinkId, +}: { + path?: string; + deepLinkId?: ICasesDeepLinkId; +}): UseCasesNavigation => { const { appId } = useCasesContext(); const { navigateTo, getAppUrl } = useNavigation(appId); const getCasesUrl = useCallback( - (absolute) => getAppUrl({ deepLinkId, absolute }), - [getAppUrl, deepLinkId] + (absolute) => getAppUrl({ path, deepLinkId, absolute }), + [getAppUrl, deepLinkId, path] ); const navigateToCases = useCallback( - () => navigateTo({ deepLinkId }), - [navigateTo, deepLinkId] + () => navigateTo({ path, deepLinkId }), + [navigateTo, deepLinkId, path] ); return [getCasesUrl, navigateToCases]; }; +/** + * Cases can be either be part of a solution or a standalone application + * The standalone application is registered from the cases plugin and is called + * the main application. The main application uses paths and the solutions + * deep links. + */ +const navigationMapping = { + all: { path: '/' }, + create: { path: CASES_CREATE_PATH }, + configure: { path: CASES_CONFIGURE_PATH }, +}; + export const useAllCasesNavigation = () => { - const [getAllCasesUrl, navigateToAllCases] = useCasesNavigation(CasesDeepLinkId.cases); + const [getAllCasesUrl, navigateToAllCases] = useCasesNavigation({ + path: navigationMapping.all.path, + deepLinkId: APP_ID, + }); + return { getAllCasesUrl, navigateToAllCases }; }; export const useCreateCaseNavigation = () => { - const [getCreateCaseUrl, navigateToCreateCase] = useCasesNavigation(CasesDeepLinkId.casesCreate); + const [getCreateCaseUrl, navigateToCreateCase] = useCasesNavigation({ + path: navigationMapping.create.path, + deepLinkId: APP_ID, + }); return { getCreateCaseUrl, navigateToCreateCase }; }; export const useConfigureCasesNavigation = () => { - const [getConfigureCasesUrl, navigateToConfigureCases] = useCasesNavigation( - CasesDeepLinkId.casesConfigure - ); + const [getConfigureCasesUrl, navigateToConfigureCases] = useCasesNavigation({ + path: navigationMapping.configure.path, + deepLinkId: APP_ID, + }); return { getConfigureCasesUrl, navigateToConfigureCases }; }; @@ -55,19 +88,25 @@ type NavigateToCaseView = (pathParams: CaseViewPathParams) => void; export const useCaseViewNavigation = () => { const { appId } = useCasesContext(); const { navigateTo, getAppUrl } = useNavigation(appId); + const deepLinkId = APP_ID; + const getCaseViewUrl = useCallback( (pathParams, absolute) => getAppUrl({ - deepLinkId: CasesDeepLinkId.cases, + deepLinkId, absolute, path: generateCaseViewPath(pathParams), }), - [getAppUrl] + [deepLinkId, getAppUrl] ); + const navigateToCaseView = useCallback( (pathParams) => - navigateTo({ deepLinkId: CasesDeepLinkId.cases, path: generateCaseViewPath(pathParams) }), - [navigateTo] + navigateTo({ + deepLinkId, + path: generateCaseViewPath(pathParams), + }), + [navigateTo, deepLinkId] ); return { getCaseViewUrl, navigateToCaseView }; }; diff --git a/x-pack/plugins/cases/public/common/navigation/paths.test.ts b/x-pack/plugins/cases/public/common/navigation/paths.test.ts index a3fa042042a2d5..3750dc4d12eb9b 100644 --- a/x-pack/plugins/cases/public/common/navigation/paths.test.ts +++ b/x-pack/plugins/cases/public/common/navigation/paths.test.ts @@ -18,24 +18,40 @@ describe('Paths', () => { it('returns the correct path', () => { expect(getCreateCasePath('test')).toBe('test/create'); }); + + it('normalize the path correctly', () => { + expect(getCreateCasePath('//test//page')).toBe('/test/page/create'); + }); }); describe('getCasesConfigurePath', () => { it('returns the correct path', () => { expect(getCasesConfigurePath('test')).toBe('test/configure'); }); + + it('normalize the path correctly', () => { + expect(getCasesConfigurePath('//test//page')).toBe('/test/page/configure'); + }); }); describe('getCaseViewPath', () => { it('returns the correct path', () => { expect(getCaseViewPath('test')).toBe('test/:detailName'); }); + + it('normalize the path correctly', () => { + expect(getCaseViewPath('//test//page')).toBe('/test/page/:detailName'); + }); }); describe('getCaseViewWithCommentPath', () => { it('returns the correct path', () => { expect(getCaseViewWithCommentPath('test')).toBe('test/:detailName/:commentId'); }); + + it('normalize the path correctly', () => { + expect(getCaseViewWithCommentPath('//test//page')).toBe('/test/page/:detailName/:commentId'); + }); }); describe('generateCaseViewPath', () => { diff --git a/x-pack/plugins/cases/public/common/navigation/paths.ts b/x-pack/plugins/cases/public/common/navigation/paths.ts index 1cd7a99630b857..a8660b5cf63ab2 100644 --- a/x-pack/plugins/cases/public/common/navigation/paths.ts +++ b/x-pack/plugins/cases/public/common/navigation/paths.ts @@ -18,12 +18,16 @@ export const CASES_CONFIGURE_PATH = '/configure' as const; export const CASE_VIEW_PATH = '/:detailName' as const; export const CASE_VIEW_COMMENT_PATH = `${CASE_VIEW_PATH}/:commentId` as const; -export const getCreateCasePath = (casesBasePath: string) => `${casesBasePath}${CASES_CREATE_PATH}`; +const normalizePath = (path: string): string => path.replaceAll('//', '/'); + +export const getCreateCasePath = (casesBasePath: string) => + normalizePath(`${casesBasePath}${CASES_CREATE_PATH}`); export const getCasesConfigurePath = (casesBasePath: string) => - `${casesBasePath}${CASES_CONFIGURE_PATH}`; -export const getCaseViewPath = (casesBasePath: string) => `${casesBasePath}${CASE_VIEW_PATH}`; + normalizePath(`${casesBasePath}${CASES_CONFIGURE_PATH}`); +export const getCaseViewPath = (casesBasePath: string) => + normalizePath(`${casesBasePath}${CASE_VIEW_PATH}`); export const getCaseViewWithCommentPath = (casesBasePath: string) => - `${casesBasePath}${CASE_VIEW_COMMENT_PATH}`; + normalizePath(`${casesBasePath}${CASE_VIEW_COMMENT_PATH}`); export const generateCaseViewPath = (params: CaseViewPathParams): string => { const { commentId } = params; @@ -31,7 +35,7 @@ export const generateCaseViewPath = (params: CaseViewPathParams): string => { const pathParams = params as unknown as { [paramName: string]: string }; if (commentId) { - return generatePath(CASE_VIEW_COMMENT_PATH, pathParams); + return normalizePath(generatePath(CASE_VIEW_COMMENT_PATH, pathParams)); } - return generatePath(CASE_VIEW_PATH, pathParams); + return normalizePath(generatePath(CASE_VIEW_PATH, pathParams)); }; diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 046eb67d38b248..61554f5191dc83 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -268,3 +268,11 @@ export const CASE_SUCCESS_SYNC_TEXT = i18n.translate('xpack.cases.actions.caseSu export const VIEW_CASE = i18n.translate('xpack.cases.actions.viewCase', { defaultMessage: 'View Case', }); + +export const APP_TITLE = i18n.translate('xpack.cases.common.appTitle', { + defaultMessage: 'Cases', +}); + +export const APP_DESC = i18n.translate('xpack.cases.common.appDescription', { + defaultMessage: 'Open and track issues, push information to third party systems.', +}); diff --git a/x-pack/plugins/cases/public/components/app/index.tsx b/x-pack/plugins/cases/public/components/app/index.tsx index 0ac336adb94a91..bf3dbcf1dc95b3 100644 --- a/x-pack/plugins/cases/public/components/app/index.tsx +++ b/x-pack/plugins/cases/public/components/app/index.tsx @@ -5,9 +5,33 @@ * 2.0. */ -import { CasesRoutes } from './routes'; +import React from 'react'; +import { APP_OWNER } from '../../../common/constants'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; + +import { getCasesLazy } from '../../methods'; +import { Wrapper } from '../wrappers'; import { CasesRoutesProps } from './types'; export type CasesProps = CasesRoutesProps; -// eslint-disable-next-line import/no-default-export -export { CasesRoutes as default }; + +const CasesAppComponent: React.FC = () => { + const userCapabilities = useApplicationCapabilities(); + + return ( + + {getCasesLazy({ + owner: [APP_OWNER], + useFetchAlertData: () => [false, {}], + userCanCrud: userCapabilities.crud, + basePath: '/', + features: { alerts: { sync: false } }, + releasePhase: 'experimental', + })} + + ); +}; + +CasesAppComponent.displayName = 'CasesApp'; + +export const CasesApp = React.memo(CasesAppComponent); diff --git a/x-pack/plugins/cases/public/components/app/routes.tsx b/x-pack/plugins/cases/public/components/app/routes.tsx index 6222c413a11674..6fc87f691b2a22 100644 --- a/x-pack/plugins/cases/public/components/app/routes.tsx +++ b/x-pack/plugins/cases/public/components/app/routes.tsx @@ -91,3 +91,5 @@ const CasesRoutesComponent: React.FC = ({ CasesRoutesComponent.displayName = 'CasesRoutes'; export const CasesRoutes = React.memo(CasesRoutesComponent); +// eslint-disable-next-line import/no-default-export +export { CasesRoutes as default }; diff --git a/x-pack/plugins/cases/public/components/app/translations.ts b/x-pack/plugins/cases/public/components/app/translations.ts index 6796f0e03aa77b..4958ce4358c1c5 100644 --- a/x-pack/plugins/cases/public/components/app/translations.ts +++ b/x-pack/plugins/cases/public/components/app/translations.ts @@ -7,21 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const NO_PRIVILEGES_MSG = (pageName: string) => - i18n.translate('xpack.cases.noPrivileges.message', { - values: { pageName }, - defaultMessage: - 'To view {pageName} page, you must update privileges. For more information, contact your Kibana administrator.', - }); - -export const NO_PRIVILEGES_TITLE = i18n.translate('xpack.cases.noPrivileges.title', { - defaultMessage: 'Privileges required', -}); - -export const NO_PRIVILEGES_BUTTON = i18n.translate('xpack.cases.noPrivileges.button', { - defaultMessage: 'Back to Cases', -}); - export const CREATE_CASE_PAGE_NAME = i18n.translate('xpack.cases.createCase', { defaultMessage: 'Create Case', }); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx index 5179aed6518b5e..2105618ae03151 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/plugin.tsx @@ -36,6 +36,7 @@ import type { EmbeddablePackageState } from '../../../../../../../../src/plugins import { SavedObjectFinderUi } from './saved_objects_finder'; import { useLensDraftComment } from './use_lens_draft_comment'; import { VISUALIZATION } from './translations'; +import { useIsMainApplication } from '../../../../common/hooks'; const BetaBadgeWrapper = styled.span` display: inline-flex; @@ -84,6 +85,7 @@ const LensEditorComponent: LensEuiMarkdownEditorUiPlugin['editor'] = ({ const { draftComment, clearDraftComment } = useLensDraftComment(); const commentEditorContext = useContext(CommentEditorContext); const markdownContext = useContext(EuiMarkdownContext); + const isMainApplication = useIsMainApplication(); const handleClose = useCallback(() => { if (currentAppId) { @@ -126,8 +128,11 @@ const LensEditorComponent: LensEuiMarkdownEditorUiPlugin['editor'] = ({ ); const originatingPath = useMemo( - () => `${location.pathname}${location.search}`, - [location.pathname, location.search] + () => + isMainApplication + ? `/insightsAndAlerting/cases${location.pathname}${location.search}` + : `${location.pathname}${location.search}`, + [isMainApplication, location.pathname, location.search] ); const handleCreateInLensClick = useCallback(() => { diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 1fafe5afe6990e..5ff675a31ce613 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -8,7 +8,7 @@ import { IconType } from '@elastic/eui'; import { ConnectorTypes } from '../../common/api'; import { FieldConfig, ValidationConfig } from '../common/shared_imports'; -import { StartPlugins } from '../types'; +import { CasesPluginStart } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator'; import { CaseActionConnector } from './types'; @@ -48,7 +48,7 @@ export const getConnectorsFormValidators = ({ }); export const getConnectorIcon = ( - triggersActionsUi: StartPlugins['triggersActionsUi'], + triggersActionsUi: CasesPluginStart['triggersActionsUi'], type?: string ): IconType => { /** diff --git a/x-pack/plugins/cases/public/components/wrappers/index.tsx b/x-pack/plugins/cases/public/components/wrappers/index.tsx index 7a3d611413be65..d412ef34451b28 100644 --- a/x-pack/plugins/cases/public/components/wrappers/index.tsx +++ b/x-pack/plugins/cases/public/components/wrappers/index.tsx @@ -27,3 +27,8 @@ export const ContentWrapper = styled.div` padding: ${theme.eui.paddingSizes.l} 0 ${gutterTimeline} 0; `}; `; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; +`; diff --git a/x-pack/plugins/cases/public/methods/get_cases.tsx b/x-pack/plugins/cases/public/methods/get_cases.tsx index 3c1d3294d38ce0..8acb61326902b0 100644 --- a/x-pack/plugins/cases/public/methods/get_cases.tsx +++ b/x-pack/plugins/cases/public/methods/get_cases.tsx @@ -12,7 +12,8 @@ import { CasesProvider, CasesContextProps } from '../components/cases_context'; export type GetCasesProps = CasesProps & CasesContextProps; -const CasesLazy: React.FC = lazy(() => import('../components/app')); +const CasesRoutesLazy: React.FC = lazy(() => import('../components/app/routes')); + export const getCasesLazy = ({ owner, userCanCrud, @@ -29,7 +30,7 @@ export const getCasesLazy = ({ }: GetCasesProps) => ( }> - { - private kibanaVersion: string; +export class CasesUiPlugin + implements Plugin +{ + private readonly kibanaVersion: string; + private readonly storage = new Storage(localStorage); constructor(private readonly initializerContext: PluginInitializerContext) { this.kibanaVersion = initializerContext.env.packageInfo.version; } - public setup(core: CoreSetup, plugins: SetupPlugins) {} - public start(core: CoreStart, plugins: StartPlugins): CasesUiStart { + public setup(core: CoreSetup, plugins: CasesPluginSetup) { + const kibanaVersion = this.kibanaVersion; + const storage = this.storage; + + if (plugins.home) { + plugins.home.featureCatalogue.register({ + id: APP_ID, + title: APP_TITLE, + description: APP_DESC, + icon: 'watchesApp', + path: APP_PATH, + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + } + + plugins.management.sections.section.insightsAndAlerting.registerApp({ + id: APP_ID, + title: APP_TITLE, + order: 0, + async mount(params: ManagementAppMountParams) { + const [coreStart, pluginsStart] = (await core.getStartServices()) as [ + CoreStart, + CasesPluginStart, + unknown + ]; + + const { renderApp } = await import('./application'); + + return renderApp({ + mountParams: params, + coreStart, + pluginsStart, + storage, + kibanaVersion, + }); + }, + }); + + // Return methods that should be available to other plugins + return {}; + } + + public start(core: CoreStart, plugins: CasesPluginStart): CasesUiStart { const config = this.initializerContext.config.get(); KibanaServices.init({ ...core, ...plugins, kibanaVersion: this.kibanaVersion, config }); return { diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index b2198aad50911b..c756a5f73a0a73 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -10,6 +10,12 @@ import { ReactElement, ReactNode } from 'react'; import type { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { Storage } from '../../../../src/plugins/kibana_utils/public'; +import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { + ManagementSetup, + ManagementAppMountParams, +} from '../../../../src/plugins/management/public'; +import { FeaturesPluginStart } from '../..//features/public'; import type { LensPublicStart } from '../../lens/public'; import type { SecurityPluginSetup } from '../../security/public'; import type { SpacesPluginStart } from '../../spaces/public'; @@ -18,6 +24,7 @@ import { CommentRequestAlertType, CommentRequestUserType } from '../common/api'; import { UseCasesAddToExistingCaseModal } from './components/all_cases/selector_modal/use_cases_add_to_existing_case_modal'; import { CreateCaseFlyoutProps } from './components/create/flyout'; import { UseCasesAddToNewCaseFlyout } from './components/create/flyout/use_cases_add_to_new_case_flyout'; + import type { CasesOwners, GetAllCasesSelectorModalProps, @@ -28,16 +35,19 @@ import type { import { GetCasesContextProps } from './methods/get_cases_context'; import { getRuleIdFromEvent } from './methods/get_rule_id_from_event'; -export interface SetupPlugins { +export interface CasesPluginSetup { security: SecurityPluginSetup; + management: ManagementSetup; + home?: HomePublicPluginSetup; } -export interface StartPlugins { +export interface CasesPluginStart { data: DataPublicPluginStart; embeddable: EmbeddableStart; lens: LensPublicStart; storage: Storage; triggersActionsUi: TriggersActionsStart; + features: FeaturesPluginStart; spaces?: SpacesPluginStart; } @@ -48,10 +58,18 @@ export interface StartPlugins { */ export type StartServices = CoreStart & - StartPlugins & { + CasesPluginStart & { security: SecurityPluginSetup; }; +export interface RenderAppProps { + mountParams: ManagementAppMountParams; + coreStart: CoreStart; + pluginsStart: CasesPluginStart; + storage: Storage; + kibanaVersion: string; +} + export interface CasesUiStart { /** * Returns an object denoting the current user's ability to read and crud cases. diff --git a/x-pack/plugins/cases/server/features.ts b/x-pack/plugins/cases/server/features.ts new file mode 100644 index 00000000000000..33a3315b7d9d3f --- /dev/null +++ b/x-pack/plugins/cases/server/features.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import { KibanaFeatureConfig } from '../../features/common'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; + +import { APP_ID, FEATURE_ID } from '../common/constants'; + +/** + * The order of appearance in the feature privilege page + * under the management section. Cases should be under + * the Actions and Connectors feature + */ + +const FEATURE_ORDER = 3100; + +export const getCasesKibanaFeature = (): KibanaFeatureConfig => ({ + id: FEATURE_ID, + name: i18n.translate('xpack.cases.features.casesFeatureName', { + defaultMessage: 'Cases', + }), + category: DEFAULT_APP_CATEGORIES.management, + app: [], + order: FEATURE_ORDER, + management: { + insightsAndAlerting: [APP_ID], + }, + cases: [APP_ID], + privileges: { + all: { + cases: { + all: [APP_ID], + }, + management: { + insightsAndAlerting: [APP_ID], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['crud_cases', 'read_cases'], + }, + read: { + cases: { + read: [APP_ID], + }, + management: { + insightsAndAlerting: [APP_ID], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['read_cases'], + }, + }, +}); diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index e6c4faac939389..9d2915491c4469 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -28,14 +28,19 @@ import { CasesClient } from './client'; import type { CasesRequestHandlerContext } from './types'; import { CasesClientFactory } from './client/factory'; import { SpacesPluginStart } from '../../spaces/server'; -import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; +import { + PluginStartContract as FeaturesPluginStart, + PluginSetupContract as FeaturesPluginSetup, +} from '../../features/server'; import { LensServerPluginSetup } from '../../lens/server'; +import { getCasesKibanaFeature } from './features'; import { registerRoutes } from './routes/api/register_routes'; import { getExternalRoutes } from './routes/api/get_external_routes'; export interface PluginsSetup { actions: ActionsPluginSetup; lens: LensServerPluginSetup; + features: FeaturesPluginSetup; usageCollection?: UsageCollectionSetup; security?: SecurityPluginSetup; } @@ -77,6 +82,8 @@ export class CasePlugin { this.securityPluginSetup = plugins.security; this.lensEmbeddableFactory = plugins.lens.lensEmbeddableFactory; + plugins.features.registerKibanaFeature(getCasesKibanaFeature()); + core.savedObjects.registerType( createCaseCommentSavedObjectType({ migrationDeps: { diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 378db8eecb5b93..3339484c2b9a27 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -111,6 +111,7 @@ export default function ({ getService }: FtrProviderContext) { 'apm', 'stackAlerts', 'canvas', + 'generalCases', 'infrastructure', 'logs', 'maps', diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 667b13b854b71f..ecf37477831a9f 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -28,6 +28,7 @@ export default function ({ getService }: FtrProviderContext) { savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'], canvas: ['all', 'read', 'minimal_all', 'minimal_read'], maps: ['all', 'read', 'minimal_all', 'minimal_read'], + generalCases: ['all', 'read', 'minimal_all', 'minimal_read'], observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read'], fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'], fleet: ['all', 'read', 'minimal_all', 'minimal_read'], 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 0fcc15e1de6e4b..dfc60aaebca345 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -30,6 +30,7 @@ export default function ({ getService }: FtrProviderContext) { savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'], graph: ['all', 'read', 'minimal_all', 'minimal_read'], maps: ['all', 'read', 'minimal_all', 'minimal_read'], + generalCases: ['all', 'read', 'minimal_all', 'minimal_read'], observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read'], canvas: ['all', 'read', 'minimal_all', 'minimal_read'], infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], diff --git a/x-pack/test/functional/apps/management/feature_controls/management_security.ts b/x-pack/test/functional/apps/management/feature_controls/management_security.ts index d286f69f1f6097..5af7aeda6440cc 100644 --- a/x-pack/test/functional/apps/management/feature_controls/management_security.ts +++ b/x-pack/test/functional/apps/management/feature_controls/management_security.ts @@ -64,7 +64,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(sections).to.have.length(2); expect(sections[0]).to.eql({ sectionId: 'insightsAndAlerting', - sectionLinks: ['triggersActions', 'jobsListLink'], + sectionLinks: ['triggersActions', 'cases', 'jobsListLink'], }); expect(sections[1]).to.eql({ sectionId: 'kibana', From 91f30ed366b350f69f9e483492ff3ad98b9c23c5 Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Fri, 4 Mar 2022 14:11:37 +0100 Subject: [PATCH 08/33] [Fleet] Fix json marshalling in QA labeling job (#126905) --- .github/workflows/label-qa-fixed-in.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/label-qa-fixed-in.yml b/.github/workflows/label-qa-fixed-in.yml index 836aa308e92c7a..55e65bd7665545 100644 --- a/.github/workflows/label-qa-fixed-in.yml +++ b/.github/workflows/label-qa-fixed-in.yml @@ -19,7 +19,7 @@ jobs: github.event.pull_request.merged_at && contains(github.event.pull_request.labels.*.name, 'Team:Fleet') outputs: - matrix: ${{ steps.issues_to_label.outputs.value }} + issue_ids: ${{ steps.issues_to_label.outputs.value }} label_ids: ${{ steps.label_ids.outputs.value }} steps: - uses: octokit/graphql-action@v2.x @@ -66,22 +66,23 @@ jobs: label_issues: needs: fetch_issues_to_label runs-on: ubuntu-latest - # For each issue closed by the PR run this job + # For each issue closed by the PR x each label to apply, run this job strategy: matrix: - issueNodeId: ${{ fromJSON(needs.fetch_issues_to_label.outputs.matrix) }} - name: Label issue ${{ matrix.issueNodeId }} + issueId: ${{ fromJSON(needs.fetch_issues_to_label.outputs.issue_ids) || [] }} + labelId: ${{ fromJSON(needs.fetch_issues_to_label.outputs.label_ids) || [] }} + name: Label issue ${{ matrix.issueId }} with ${{ matrix.labelId }} steps: - uses: octokit/graphql-action@v2.x id: add_labels_to_closed_issue with: query: | - mutation add_label($issueid: ID!, $labelids:[ID!]!) { - addLabelsToLabelable(input: {labelableId: $issueid, labelIds: $labelids}) { + mutation add_label($issueid: ID!, $labelid:ID!) { + addLabelsToLabelable(input: {labelableId: $issueid, labelIds: [$labelid]}) { clientMutationId } } - issueid: ${{ matrix.issueNodeId }} - labelids: ${{ fromJSON(needs.fetch_issues_to_label.outputs.label_ids) }} + issueid: ${{ matrix.issueId }} + labelid: ${{ matrix.labelId }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 10249bceebab5b68c9f8fe61388159627af7020d Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Fri, 4 Mar 2022 16:16:06 +0300 Subject: [PATCH 09/33] [Chart expressions] Add validation for args which have `options` (#126258) * Add validation for args with `options` * Fix error text Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../expression_functions/gauge_function.ts | 11 +-------- .../expression_functions/heatmap_legend.ts | 11 +++++++++ .../metric_vis_function.ts | 12 +--------- .../common/expression_functions/i18n.ts | 12 ++++++++++ .../mosaic_vis_function.ts | 7 ++++++ .../expression_functions/pie_vis_function.ts | 5 ++++ .../treemap_vis_function.ts | 7 ++++++ .../waffle_vis_function.ts | 7 ++++++ .../expression_tagcloud/common/constants.ts | 12 ++++++++++ .../tagcloud_function.test.ts | 5 ++-- .../expression_functions/tagcloud_function.ts | 24 +++++++++++++++---- .../common/types/expression_functions.ts | 8 ++++--- .../__stories__/tagcloud_renderer.stories.tsx | 19 ++++++++++----- .../components/tagcloud_component.test.tsx | 10 +++++--- .../public/components/tagcloud_component.tsx | 9 +++---- src/plugins/charts/common/index.ts | 2 ++ src/plugins/charts/common/utils.ts | 20 ++++++++++++++++ 17 files changed, 137 insertions(+), 44 deletions(-) create mode 100644 src/plugins/charts/common/utils.ts diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts index ae9da90ffb5727..80cfaff57c35c1 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts @@ -11,6 +11,7 @@ import { findAccessorOrFail } from '../../../../visualizations/common/utils'; import type { ExpressionValueVisDimension } from '../../../../visualizations/common'; import { prepareLogTable } from '../../../../visualizations/common/utils'; import type { DatatableColumn } from '../../../../expressions'; +import { validateOptions } from '../../../../charts/common'; import { GaugeExpressionFunctionDefinition } from '../types'; import { EXPRESSION_GAUGE_NAME, @@ -79,16 +80,6 @@ const validateAccessor = ( } }; -const validateOptions = ( - value: string, - availableOptions: Record, - getErrorMessage: () => string -) => { - if (!Object.values(availableOptions).includes(value)) { - throw new Error(getErrorMessage()); - } -}; - export const gaugeFunction = (): GaugeExpressionFunctionDefinition => ({ name: EXPRESSION_GAUGE_NAME, type: 'render', diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts index efbc251f6360b3..28a37c522ac2db 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts @@ -8,9 +8,18 @@ import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import type { ExpressionFunctionDefinition } from '../../../../expressions/common'; +import { validateOptions } from '../../../../charts/common'; import { EXPRESSION_HEATMAP_LEGEND_NAME } from '../constants'; import { HeatmapLegendConfig, HeatmapLegendConfigResult } from '../types'; +export const errors = { + invalidPositionError: () => + i18n.translate('expressionHeatmap.functions.heatmap.errors.invalidPositionError', { + defaultMessage: `Invalid position is specified. Supported positions: {positions}`, + values: { positions: Object.values(Position).join(', ') }, + }), +}; + export const heatmapLegendConfig: ExpressionFunctionDefinition< typeof EXPRESSION_HEATMAP_LEGEND_NAME, null, @@ -31,6 +40,7 @@ export const heatmapLegendConfig: ExpressionFunctionDefinition< }, position: { types: ['string'], + default: Position.Right, options: [Position.Top, Position.Right, Position.Bottom, Position.Left], help: i18n.translate('expressionHeatmap.function.args.legend.position.help', { defaultMessage: 'Specifies the legend position.', @@ -51,6 +61,7 @@ export const heatmapLegendConfig: ExpressionFunctionDefinition< }, }, fn(input, args) { + validateOptions(args.position, Position, errors.invalidPositionError); return { type: EXPRESSION_HEATMAP_LEGEND_NAME, ...args, diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts index 596b59b418b422..690ea75e9cb2fc 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts @@ -10,20 +10,10 @@ import { i18n } from '@kbn/i18n'; import { visType } from '../types'; import { prepareLogTable, Dimension } from '../../../../visualizations/common/utils'; -import { ColorMode } from '../../../../charts/common'; +import { ColorMode, validateOptions } from '../../../../charts/common'; import { MetricVisExpressionFunctionDefinition } from '../types'; import { EXPRESSION_METRIC_NAME, LabelPosition } from '../constants'; -const validateOptions = ( - value: string, - availableOptions: Record, - getErrorMessage: () => string -) => { - if (!Object.values(availableOptions).includes(value)) { - throw new Error(getErrorMessage()); - } -}; - const errors = { invalidColorModeError: () => i18n.translate('expressionMetricVis.function.errors.invalidColorModeError', { diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts index aa433b8eaee2d8..deedb10940253a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts @@ -7,6 +7,8 @@ */ import { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; +import { LegendDisplay } from '../types/expression_renderers'; export const strings = { getPieVisFunctionName: () => @@ -127,4 +129,14 @@ export const errors = { defaultMessage: 'A split row and column are specified. Expression is supporting only one of them at once.', }), + invalidLegendDisplayError: () => + i18n.translate('expressionPartitionVis.reusable.function.errors.invalidLegendDisplayError', { + defaultMessage: `Invalid legend display mode is specified. Supported ticks legend display modes: {legendDisplayModes}`, + values: { legendDisplayModes: Object.values(LegendDisplay).join(', ') }, + }), + invalidLegendPositionError: () => + i18n.translate('expressionPartitionVis.reusable.function.errors.invalidLegendPositionError', { + defaultMessage: `Invalid legend position is specified. Supported ticks legend positions: {positions}`, + values: { positions: Object.values(Position).join(', ') }, + }), }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts index 2f4c681ef336ca..7db9445ea3ee85 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts @@ -6,8 +6,10 @@ * Side Public License, v 1. */ +import { Position } from '@elastic/charts'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { prepareLogTable } from '../../../../visualizations/common/utils'; +import { validateOptions } from '../../../../charts/common'; import { ChartTypes, MosaicVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -56,7 +58,9 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ }, legendPosition: { types: ['string'], + default: Position.Right, help: strings.getLegendPositionArgHelp(), + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], }, nestedLegend: { types: ['boolean'], @@ -98,6 +102,9 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } + validateOptions(args.legendDisplay, LegendDisplay, errors.invalidLegendDisplayError); + validateOptions(args.legendPosition, Position, errors.invalidLegendPositionError); + const visConfig: PartitionVisParams = { ...args, ariaLabel: diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts index d743637f44b866..5982f9a762d8c9 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts @@ -9,6 +9,7 @@ import { Position } from '@elastic/charts'; import { EmptySizeRatios, LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { prepareLogTable } from '../../../../visualizations/common/utils'; +import { validateOptions } from '../../../../charts/common'; import { ChartTypes, PieVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -57,6 +58,7 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ }, legendPosition: { types: ['string'], + default: Position.Right, help: strings.getLegendPositionArgHelp(), options: [Position.Top, Position.Right, Position.Bottom, Position.Left], }, @@ -120,6 +122,9 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } + validateOptions(args.legendDisplay, LegendDisplay, errors.invalidLegendDisplayError); + validateOptions(args.legendPosition, Position, errors.invalidLegendPositionError); + const visConfig: PartitionVisParams = { ...args, ariaLabel: diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts index d2016b3ae0c813..634fa079671cb2 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts @@ -6,8 +6,10 @@ * Side Public License, v 1. */ +import { Position } from '@elastic/charts'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { prepareLogTable } from '../../../../visualizations/common/utils'; +import { validateOptions } from '../../../../charts/common'; import { ChartTypes, TreemapVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -56,7 +58,9 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => }, legendPosition: { types: ['string'], + default: Position.Right, help: strings.getLegendPositionArgHelp(), + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], }, nestedLegend: { types: ['boolean'], @@ -98,6 +102,9 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } + validateOptions(args.legendDisplay, LegendDisplay, errors.invalidLegendDisplayError); + validateOptions(args.legendPosition, Position, errors.invalidLegendPositionError); + const visConfig: PartitionVisParams = { ...args, ariaLabel: diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts index 242d8a2c9bace0..f88c2821cdb658 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts @@ -6,8 +6,10 @@ * Side Public License, v 1. */ +import { Position } from '@elastic/charts'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { prepareLogTable } from '../../../../visualizations/common/utils'; +import { validateOptions } from '../../../../charts/common'; import { ChartTypes, WaffleVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -55,7 +57,9 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ }, legendPosition: { types: ['string'], + default: Position.Right, help: strings.getLegendPositionArgHelp(), + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], }, truncateLegend: { types: ['boolean'], @@ -92,6 +96,9 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } + validateOptions(args.legendDisplay, LegendDisplay, errors.invalidLegendDisplayError); + validateOptions(args.legendPosition, Position, errors.invalidLegendPositionError); + const buckets = args.bucket ? [args.bucket] : []; const visConfig: PartitionVisParams = { ...args, diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/constants.ts b/src/plugins/chart_expressions/expression_tagcloud/common/constants.ts index 3d834448a94efc..8c4041c6a3a7eb 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/constants.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/constants.ts @@ -10,3 +10,15 @@ export const PLUGIN_ID = 'expressionTagcloud'; export const PLUGIN_NAME = 'expressionTagcloud'; export const EXPRESSION_NAME = 'tagcloud'; + +export const ScaleOptions = { + LINEAR: 'linear', + LOG: 'log', + SQUARE_ROOT: 'square root', +} as const; + +export const Orientation = { + SINGLE: 'single', + RIGHT_ANGLED: 'right angled', + MULTIPLE: 'multiple', +} as const; diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts index 86a371afd6912d..dc77848d37ceba 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts @@ -11,6 +11,7 @@ import { tagcloudFunction } from './tagcloud_function'; import { functionWrapper } from '../../../../expressions/common/expression_functions/specs/tests/utils'; import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { Datatable } from '../../../../expressions/common/expression_types/specs'; +import { ScaleOptions, Orientation } from '../constants'; type Arguments = Parameters['fn']>[1]; @@ -30,8 +31,8 @@ describe('interpreter/functions#tagcloud', () => { ], } as unknown as Datatable; const visConfig = { - scale: 'linear', - orientation: 'single', + scale: ScaleOptions.LINEAR, + orientation: Orientation.SINGLE, minFontSize: 18, maxFontSize: 72, showLabel: true, diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts index 85f98b35cede93..a11c0b6d867ec1 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts @@ -8,9 +8,10 @@ import { i18n } from '@kbn/i18n'; import { prepareLogTable, Dimension } from '../../../../visualizations/common/utils'; +import { validateOptions } from '../../../../charts/common'; import { TagCloudRendererParams } from '../types'; import { ExpressionTagcloudFunction } from '../types'; -import { EXPRESSION_NAME } from '../constants'; +import { EXPRESSION_NAME, ScaleOptions, Orientation } from '../constants'; const strings = { help: i18n.translate('expressionTagcloud.functions.tagcloudHelpText', { @@ -74,6 +75,16 @@ export const errors = { }, }) ), + invalidScaleOptionError: () => + i18n.translate('expressionTagcloud.functions.tagcloud.invalidScaleOptionError', { + defaultMessage: `Invalid scale option is specified. Supported scale options: {scaleOptions}`, + values: { scaleOptions: Object.values(ScaleOptions).join(', ') }, + }), + invalidOrientationError: () => + i18n.translate('expressionTagcloud.functions.tagcloud.invalidOrientationError', { + defaultMessage: `Invalid orientation of words is specified. Supported scale orientation: {orientation}`, + values: { orientation: Object.values(Orientation).join(', ') }, + }), }; export const tagcloudFunction: ExpressionTagcloudFunction = () => { @@ -87,14 +98,14 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => { args: { scale: { types: ['string'], - default: 'linear', - options: ['linear', 'log', 'square root'], + default: ScaleOptions.LINEAR, + options: [ScaleOptions.LINEAR, ScaleOptions.LOG, ScaleOptions.SQUARE_ROOT], help: argHelp.scale, }, orientation: { types: ['string'], - default: 'single', - options: ['single', 'right angled', 'multiple'], + default: Orientation.SINGLE, + options: [Orientation.SINGLE, Orientation.RIGHT_ANGLED, Orientation.MULTIPLE], help: argHelp.orientation, }, minFontSize: { @@ -133,6 +144,9 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => { }, }, fn(input, args, handlers) { + validateOptions(args.scale, ScaleOptions, errors.invalidScaleOptionError); + validateOptions(args.orientation, Orientation, errors.invalidOrientationError); + const visParams: TagCloudRendererParams = { scale: args.scale, orientation: args.orientation, diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts index 44fc6f3048790f..62f04551680b71 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts @@ -5,6 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + +import { $Values } from '@kbn/utility-types'; import { PaletteOutput } from '../../../../charts/common'; import { Datatable, @@ -12,11 +14,11 @@ import { ExpressionValueRender, } from '../../../../expressions'; import { ExpressionValueVisDimension } from '../../../../visualizations/common'; -import { EXPRESSION_NAME } from '../constants'; +import { EXPRESSION_NAME, ScaleOptions, Orientation } from '../constants'; interface TagCloudCommonParams { - scale: 'linear' | 'log' | 'square root'; - orientation: 'single' | 'right angled' | 'multiple'; + scale: $Values; + orientation: $Values; minFontSize: number; maxFontSize: number; showLabel: boolean; diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx index eca35918d72898..9866ec644ae263 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx @@ -11,6 +11,7 @@ import { storiesOf } from '@storybook/react'; import { tagcloudRenderer } from '../expression_renderers'; import { Render } from '../../../../presentation_util/public/__stories__'; import { TagcloudRendererConfig } from '../../common/types'; +import { ScaleOptions, Orientation } from '../../common/constants'; import { palettes } from '../__mocks__/palettes'; import { theme } from '../__mocks__/theme'; @@ -39,8 +40,8 @@ const config: TagcloudRendererConfig = { ], }, visParams: { - scale: 'linear', - orientation: 'single', + scale: ScaleOptions.LINEAR, + orientation: Orientation.SINGLE, minFontSize: 18, maxFontSize: 72, showLabel: true, @@ -78,7 +79,7 @@ storiesOf('renderers/tag_cloud_vis', module) return ( tagcloudRenderer({ palettes, theme })} - config={{ ...config, visParams: { ...config.visParams, scale: 'log' } }} + config={{ ...config, visParams: { ...config.visParams, scale: ScaleOptions.LOG } }} {...containerSize} /> ); @@ -87,7 +88,7 @@ storiesOf('renderers/tag_cloud_vis', module) return ( tagcloudRenderer({ palettes, theme })} - config={{ ...config, visParams: { ...config.visParams, scale: 'square root' } }} + config={{ ...config, visParams: { ...config.visParams, scale: ScaleOptions.SQUARE_ROOT } }} {...containerSize} /> ); @@ -96,7 +97,10 @@ storiesOf('renderers/tag_cloud_vis', module) return ( tagcloudRenderer({ palettes, theme })} - config={{ ...config, visParams: { ...config.visParams, orientation: 'right angled' } }} + config={{ + ...config, + visParams: { ...config.visParams, orientation: Orientation.RIGHT_ANGLED }, + }} {...containerSize} /> ); @@ -105,7 +109,10 @@ storiesOf('renderers/tag_cloud_vis', module) return ( tagcloudRenderer({ palettes, theme })} - config={{ ...config, visParams: { ...config.visParams, orientation: 'multiple' } }} + config={{ + ...config, + visParams: { ...config.visParams, orientation: Orientation.MULTIPLE }, + }} {...containerSize} /> ); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx index f65630e422cceb..a85455b9240108 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx @@ -13,6 +13,7 @@ import { mount } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import TagCloudChart, { TagCloudChartProps } from './tagcloud_component'; import { TagCloudRendererParams } from '../../common/types'; +import { ScaleOptions, Orientation } from '../../common/constants'; jest.mock('../format_service', () => ({ getFormatService: jest.fn(() => { @@ -51,8 +52,8 @@ const visData: Datatable = { const visParams: TagCloudRendererParams = { bucket: { type: 'vis_dimension', accessor: 0, format: { params: {} } }, metric: { type: 'vis_dimension', accessor: 1, format: { params: {} } }, - scale: 'linear', - orientation: 'single', + scale: ScaleOptions.LINEAR, + orientation: Orientation.SINGLE, palette: { type: 'palette', name: 'default', @@ -166,7 +167,10 @@ describe('TagCloudChart', function () { }); it('sets the angles correctly', async () => { - const newVisParams: TagCloudRendererParams = { ...visParams, orientation: 'right angled' }; + const newVisParams: TagCloudRendererParams = { + ...visParams, + orientation: Orientation.RIGHT_ANGLED, + }; const newProps = { ...wrapperPropsWithIndexes, visParams: newVisParams }; const component = mount(); expect(component.find(Wordcloud).prop('endAngle')).toBe(90); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx index 560507f84831a0..150da4c952c331 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx @@ -20,6 +20,7 @@ import { import { getFormatService } from '../format_service'; import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { TagcloudRendererConfig } from '../../common/types'; +import { ScaleOptions, Orientation } from '../../common/constants'; import './tag_cloud.scss'; @@ -60,15 +61,15 @@ const getColor = ( }; const ORIENTATIONS = { - single: { + [Orientation.SINGLE]: { endAngle: 0, angleCount: 360, }, - 'right angled': { + [Orientation.RIGHT_ANGLED]: { endAngle: 90, angleCount: 2, }, - multiple: { + [Orientation.MULTIPLE]: { endAngle: -90, angleCount: 12, }, @@ -210,7 +211,7 @@ export const TagCloudChart = ({ maxFontSize={visParams.maxFontSize} spiral="archimedean" data={tagCloudData} - weightFn={scale === 'square root' ? 'squareRoot' : scale} + weightFn={scale === ScaleOptions.SQUARE_ROOT ? 'squareRoot' : scale} outOfRoomCallback={() => { setWarning(true); }} diff --git a/src/plugins/charts/common/index.ts b/src/plugins/charts/common/index.ts index 2b8f252f892a59..35f12884d29cd4 100644 --- a/src/plugins/charts/common/index.ts +++ b/src/plugins/charts/common/index.ts @@ -35,3 +35,5 @@ export { } from './static'; export type { ColorSchemaParams, Labels, Style, PaletteContinuity } from './types'; + +export { validateOptions } from './utils'; diff --git a/src/plugins/charts/common/utils.ts b/src/plugins/charts/common/utils.ts new file mode 100644 index 00000000000000..393110e26994b2 --- /dev/null +++ b/src/plugins/charts/common/utils.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const validateOptions = ( + value: string, + availableOptions: Record | Array, + getErrorMessage: () => string +) => { + const options = Array.isArray(availableOptions) + ? availableOptions + : Object.values(availableOptions); + if (!options.includes(value)) { + throw new Error(getErrorMessage()); + } +}; From 1174ac0cfc8d8678e1a3a5c473bbd6c9fc6cee1b Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Fri, 4 Mar 2022 14:25:34 +0100 Subject: [PATCH 10/33] [Fleet] Fix matrix configuration in QA labeling job (#126906) --- .github/workflows/label-qa-fixed-in.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/label-qa-fixed-in.yml b/.github/workflows/label-qa-fixed-in.yml index 55e65bd7665545..99803c2c4e8800 100644 --- a/.github/workflows/label-qa-fixed-in.yml +++ b/.github/workflows/label-qa-fixed-in.yml @@ -66,11 +66,16 @@ jobs: label_issues: needs: fetch_issues_to_label runs-on: ubuntu-latest + # For each issue closed by the PR x each label to apply, run this job + if: | + fromJSON(needs.fetch_issues_to_label.outputs.issue_ids).length > 0 && + fromJSON(needs.fetch_issues_to_label.outputs.label_ids).length > 0 strategy: matrix: - issueId: ${{ fromJSON(needs.fetch_issues_to_label.outputs.issue_ids) || [] }} - labelId: ${{ fromJSON(needs.fetch_issues_to_label.outputs.label_ids) || [] }} + issueId: ${{ fromJSON(needs.fetch_issues_to_label.outputs.issue_ids) }} + labelId: ${{ fromJSON(needs.fetch_issues_to_label.outputs.label_ids) }} + name: Label issue ${{ matrix.issueId }} with ${{ matrix.labelId }} steps: - uses: octokit/graphql-action@v2.x From eb68e95acd4d8afb38779c333d9f9a0219085f9e Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 4 Mar 2022 08:02:02 -0600 Subject: [PATCH 11/33] [ts] enable sourcemaps in summarized types of @kbn/crypto (#126410) * [ts] enable sourcemaps in summarized types of @kbn/crypto * update snapshots * remove unnecessary exports of @kbn/type-summarizer package * remove tsc from the build process * use `@kbn/type-summarizer` to summarize its own types * add tests for interface and function * switch to export type where necessary * ignore __tmp__ in global jest preset * ignore __tmp__ globally * remove `@kbn/crypto` types path --- .eslintignore | 1 + .eslintrc.js | 52 +-- .gitignore | 1 + package.json | 2 + packages/BUILD.bazel | 2 + packages/kbn-crypto/BUILD.bazel | 1 + packages/kbn-crypto/tsconfig.json | 1 + packages/kbn-test/jest-preset.js | 4 + packages/kbn-type-summarizer/BUILD.bazel | 136 +++++++ packages/kbn-type-summarizer/README.md | 17 + packages/kbn-type-summarizer/jest.config.js | 15 + .../jest.integration.config.js | 15 + packages/kbn-type-summarizer/package.json | 7 + packages/kbn-type-summarizer/src/bazel_cli.ts | 73 ++++ packages/kbn-type-summarizer/src/index.ts | 11 + .../src/lib/bazel_cli_config.ts | 151 ++++++++ .../kbn-type-summarizer/src/lib/cli_error.ts | 24 ++ .../kbn-type-summarizer/src/lib/cli_flags.ts | 45 +++ .../lib/export_collector/collector_results.ts | 93 +++++ .../lib/export_collector/exports_collector.ts | 209 ++++++++++ .../lib/export_collector/imported_symbols.ts | 21 + .../src/lib/export_collector/index.ts | 10 + .../src/lib/export_collector/reference.ts | 14 + .../src/lib/export_collector/result_value.ts | 15 + .../src/lib/export_info.ts | 11 + .../src/lib/helpers/error.ts | 19 + .../kbn-type-summarizer/src/lib/helpers/fs.ts | 26 ++ .../src/lib/helpers/json.test.ts | 23 ++ .../src/lib/helpers/json.ts | 18 + .../src/lib/is_node_module.ts | 17 + .../src/lib/log/cli_log.ts | 99 +++++ .../kbn-type-summarizer/src/lib/log/index.ts | 11 + .../kbn-type-summarizer/src/lib/log/logger.ts | 49 +++ .../src/lib/log/test_log.ts | 20 + .../kbn-type-summarizer/src/lib/printer.ts | 362 ++++++++++++++++++ packages/kbn-type-summarizer/src/lib/run.ts | 49 +++ .../src/lib/source_mapper.ts | 141 +++++++ .../kbn-type-summarizer/src/lib/ts_nodes.ts | 73 ++++ .../kbn-type-summarizer/src/lib/ts_project.ts | 20 + .../src/lib/tsconfig_file.ts | 26 ++ .../src/run_api_extractor.ts | 86 +++++ .../src/summarize_package.ts | 123 ++++++ .../tests/integration_helpers.ts | 176 +++++++++ .../tests/integration_tests/class.test.ts | 77 ++++ .../tests/integration_tests/function.test.ts | 81 ++++ .../integration_tests/import_boundary.test.ts | 90 +++++ .../tests/integration_tests/interface.test.ts | 62 +++ .../integration_tests/references.test.ts | 71 ++++ .../integration_tests/type_alias.test.ts | 42 ++ .../tests/integration_tests/variables.test.ts | 68 ++++ packages/kbn-type-summarizer/tsconfig.json | 17 + scripts/build_type_summarizer_output.js | 11 + src/dev/bazel/index.bzl | 2 +- .../{pkg_npm_types => }/pkg_npm_types.bzl | 34 +- src/dev/bazel/pkg_npm_types/BUILD.bazel | 28 -- src/dev/bazel/pkg_npm_types/index.bzl | 15 - .../bazel/pkg_npm_types/package_json.mustache | 8 - .../packager/create_api_extraction.js | 90 ----- .../packager/generate_package_json.js | 43 --- src/dev/bazel/pkg_npm_types/packager/index.js | 46 --- src/dev/typescript/projects.ts | 1 + yarn.lock | 4 + 62 files changed, 2783 insertions(+), 276 deletions(-) create mode 100644 packages/kbn-type-summarizer/BUILD.bazel create mode 100644 packages/kbn-type-summarizer/README.md create mode 100644 packages/kbn-type-summarizer/jest.config.js create mode 100644 packages/kbn-type-summarizer/jest.integration.config.js create mode 100644 packages/kbn-type-summarizer/package.json create mode 100644 packages/kbn-type-summarizer/src/bazel_cli.ts create mode 100644 packages/kbn-type-summarizer/src/index.ts create mode 100644 packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts create mode 100644 packages/kbn-type-summarizer/src/lib/cli_error.ts create mode 100644 packages/kbn-type-summarizer/src/lib/cli_flags.ts create mode 100644 packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts create mode 100644 packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts create mode 100644 packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts create mode 100644 packages/kbn-type-summarizer/src/lib/export_collector/index.ts create mode 100644 packages/kbn-type-summarizer/src/lib/export_collector/reference.ts create mode 100644 packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts create mode 100644 packages/kbn-type-summarizer/src/lib/export_info.ts create mode 100644 packages/kbn-type-summarizer/src/lib/helpers/error.ts create mode 100644 packages/kbn-type-summarizer/src/lib/helpers/fs.ts create mode 100644 packages/kbn-type-summarizer/src/lib/helpers/json.test.ts create mode 100644 packages/kbn-type-summarizer/src/lib/helpers/json.ts create mode 100644 packages/kbn-type-summarizer/src/lib/is_node_module.ts create mode 100644 packages/kbn-type-summarizer/src/lib/log/cli_log.ts create mode 100644 packages/kbn-type-summarizer/src/lib/log/index.ts create mode 100644 packages/kbn-type-summarizer/src/lib/log/logger.ts create mode 100644 packages/kbn-type-summarizer/src/lib/log/test_log.ts create mode 100644 packages/kbn-type-summarizer/src/lib/printer.ts create mode 100644 packages/kbn-type-summarizer/src/lib/run.ts create mode 100644 packages/kbn-type-summarizer/src/lib/source_mapper.ts create mode 100644 packages/kbn-type-summarizer/src/lib/ts_nodes.ts create mode 100644 packages/kbn-type-summarizer/src/lib/ts_project.ts create mode 100644 packages/kbn-type-summarizer/src/lib/tsconfig_file.ts create mode 100644 packages/kbn-type-summarizer/src/run_api_extractor.ts create mode 100644 packages/kbn-type-summarizer/src/summarize_package.ts create mode 100644 packages/kbn-type-summarizer/tests/integration_helpers.ts create mode 100644 packages/kbn-type-summarizer/tests/integration_tests/class.test.ts create mode 100644 packages/kbn-type-summarizer/tests/integration_tests/function.test.ts create mode 100644 packages/kbn-type-summarizer/tests/integration_tests/import_boundary.test.ts create mode 100644 packages/kbn-type-summarizer/tests/integration_tests/interface.test.ts create mode 100644 packages/kbn-type-summarizer/tests/integration_tests/references.test.ts create mode 100644 packages/kbn-type-summarizer/tests/integration_tests/type_alias.test.ts create mode 100644 packages/kbn-type-summarizer/tests/integration_tests/variables.test.ts create mode 100644 packages/kbn-type-summarizer/tsconfig.json create mode 100644 scripts/build_type_summarizer_output.js rename src/dev/bazel/{pkg_npm_types => }/pkg_npm_types.bzl (83%) delete mode 100644 src/dev/bazel/pkg_npm_types/BUILD.bazel delete mode 100644 src/dev/bazel/pkg_npm_types/index.bzl delete mode 100644 src/dev/bazel/pkg_npm_types/package_json.mustache delete mode 100644 src/dev/bazel/pkg_npm_types/packager/create_api_extraction.js delete mode 100644 src/dev/bazel/pkg_npm_types/packager/generate_package_json.js delete mode 100644 src/dev/bazel/pkg_npm_types/packager/index.js diff --git a/.eslintignore b/.eslintignore index 7b9b7f77e83792..8f1fcff422d0e0 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,5 @@ **/*.js.snap +__tmp__ /.es /.chromium /build diff --git a/.eslintrc.js b/.eslintrc.js index 6c98a016469f7b..fc6d6201d1fc0f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -104,6 +104,7 @@ const DEV_PACKAGES = [ 'kbn-storybook', 'kbn-telemetry-tools', 'kbn-test', + 'kbn-type-summarizer', ]; /** Directories (at any depth) which include dev-only code. */ @@ -1632,28 +1633,6 @@ module.exports = { }, }, - /** - * Prettier disables all conflicting rules, listing as last override so it takes precedence - */ - { - files: ['**/*'], - rules: { - ...require('eslint-config-prettier').rules, - ...require('eslint-config-prettier/react').rules, - ...require('eslint-config-prettier/@typescript-eslint').rules, - }, - }, - /** - * Enterprise Search Prettier override - * Lints unnecessary backticks - @see https://github.com/prettier/eslint-config-prettier/blob/main/README.md#forbid-unnecessary-backticks - */ - { - files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'], - rules: { - quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }], - }, - }, - /** * Platform Security Team overrides */ @@ -1768,5 +1747,34 @@ module.exports = { '@kbn/eslint/no_export_all': 'error', }, }, + + { + files: ['packages/kbn-type-summarizer/**/*.ts'], + rules: { + 'no-bitwise': 'off', + }, + }, + + /** + * Prettier disables all conflicting rules, listing as last override so it takes precedence + */ + { + files: ['**/*'], + rules: { + ...require('eslint-config-prettier').rules, + ...require('eslint-config-prettier/react').rules, + ...require('eslint-config-prettier/@typescript-eslint').rules, + }, + }, + /** + * Enterprise Search Prettier override + * Lints unnecessary backticks - @see https://github.com/prettier/eslint-config-prettier/blob/main/README.md#forbid-unnecessary-backticks + */ + { + files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'], + rules: { + quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }], + }, + }, ], }; diff --git a/.gitignore b/.gitignore index 7e451584582380..4704247e6f548d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ target *.iml *.log types.eslint.config.js +__tmp__ # Ignore example plugin builds /examples/*/build diff --git a/package.json b/package.json index 0ced0e81250b49..6c313ac834af7b 100644 --- a/package.json +++ b/package.json @@ -479,6 +479,7 @@ "@kbn/test": "link:bazel-bin/packages/kbn-test", "@kbn/test-jest-helpers": "link:bazel-bin/packages/kbn-test-jest-helpers", "@kbn/test-subj-selector": "link:bazel-bin/packages/kbn-test-subj-selector", + "@kbn/type-summarizer": "link:bazel-bin/packages/kbn-type-summarizer", "@loaders.gl/polyfills": "^2.3.5", "@mapbox/vector-tile": "1.3.1", "@microsoft/api-documenter": "7.13.68", @@ -869,6 +870,7 @@ "simple-git": "1.116.0", "sinon": "^7.4.2", "sort-package-json": "^1.53.1", + "source-map": "^0.7.3", "spawn-sync": "^1.0.15", "string-replace-loader": "^2.2.0", "strong-log-transformer": "^2.1.0", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 02e82476cd88dd..77ec3b0c17295b 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -66,6 +66,7 @@ filegroup( "//packages/kbn-test-subj-selector:build", "//packages/kbn-timelion-grammar:build", "//packages/kbn-tinymath:build", + "//packages/kbn-type-summarizer:build", "//packages/kbn-typed-react-router-config:build", "//packages/kbn-ui-framework:build", "//packages/kbn-ui-shared-deps-npm:build", @@ -132,6 +133,7 @@ filegroup( "//packages/kbn-telemetry-tools:build_types", "//packages/kbn-test:build_types", "//packages/kbn-test-jest-helpers:build_types", + "//packages/kbn-type-summarizer:build_types", "//packages/kbn-typed-react-router-config:build_types", "//packages/kbn-ui-shared-deps-npm:build_types", "//packages/kbn-ui-shared-deps-src:build_types", diff --git a/packages/kbn-crypto/BUILD.bazel b/packages/kbn-crypto/BUILD.bazel index de8c97ed3b713a..09c5fbb47e3aaf 100644 --- a/packages/kbn-crypto/BUILD.bazel +++ b/packages/kbn-crypto/BUILD.bazel @@ -61,6 +61,7 @@ ts_project( srcs = SRCS, deps = TYPES_DEPS, declaration = True, + declaration_map = True, emit_declaration_only = True, out_dir = "target_types", root_dir = "src", diff --git a/packages/kbn-crypto/tsconfig.json b/packages/kbn-crypto/tsconfig.json index 272363e976ba1e..fc929cba6868ec 100644 --- a/packages/kbn-crypto/tsconfig.json +++ b/packages/kbn-crypto/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.bazel.json", "compilerOptions": { "declaration": true, + "declarationMap": true, "emitDeclarationOnly": true, "outDir": "./target_types", "rootDir": "src", diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index 9dad901f5eb272..ba515865e53230 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -9,6 +9,8 @@ // For a detailed explanation regarding each configuration property, visit: // https://jestjs.io/docs/en/configuration.html +/** @typedef {import("@jest/types").Config.InitialOptions} JestConfig */ +/** @type {JestConfig} */ module.exports = { // The directory where Jest should output its coverage files coverageDirectory: '/target/kibana-coverage/jest', @@ -128,4 +130,6 @@ module.exports = { // A custom resolver to preserve symlinks by default resolver: '/node_modules/@kbn/test/target_node/jest/setup/preserve_symlinks_resolver.js', + + watchPathIgnorePatterns: ['.*/__tmp__/.*'], }; diff --git a/packages/kbn-type-summarizer/BUILD.bazel b/packages/kbn-type-summarizer/BUILD.bazel new file mode 100644 index 00000000000000..13a89e0669b80f --- /dev/null +++ b/packages/kbn-type-summarizer/BUILD.bazel @@ -0,0 +1,136 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") +load("@build_bazel_rules_nodejs//internal/node:node.bzl", "nodejs_binary") +load("@build_bazel_rules_nodejs//:index.bzl", "directory_file_path") + +PKG_BASE_NAME = "kbn-type-summarizer" +PKG_REQUIRE_NAME = "@kbn/type-summarizer" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +RUNTIME_DEPS = [ + "@npm//@babel/runtime", + "@npm//@microsoft/api-extractor", + "@npm//source-map-support", + "@npm//chalk", + "@npm//getopts", + "@npm//is-path-inside", + "@npm//normalize-path", + "@npm//source-map", + "@npm//tslib", +] + +TYPES_DEPS = [ + "@npm//@microsoft/api-extractor", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/normalize-path", + "@npm//getopts", + "@npm//is-path-inside", + "@npm//normalize-path", + "@npm//source-map", + "@npm//tslib", +] + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + declaration_map = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +directory_file_path( + name = "bazel-cli-path", + directory = ":target_node", + path = "bazel_cli.js", +) + +nodejs_binary( + name = "bazel-cli", + data = [ + ":%s" % PKG_BASE_NAME + ], + entry_point = ":bazel-cli-path", + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ], +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [ + ":npm_module_types", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-type-summarizer/README.md b/packages/kbn-type-summarizer/README.md new file mode 100644 index 00000000000000..fdd58886a0a691 --- /dev/null +++ b/packages/kbn-type-summarizer/README.md @@ -0,0 +1,17 @@ +# @kbn/type-summarizer + +Consume the .d.ts files for a package, produced by `tsc`, and generate a single `.d.ts` file of the public types along with a source map that points back to the original source. + +## You mean like API Extractor? + +Yeah, except with source map support and without all the legacy features and other features we disable to generate our current type summaries. + +I first attempted to implement this in api-extractor but I (@spalger) hit a wall when dealing with the `Span` class. This class handles all the text output which ends up becoming source code, and I wasn't able to find a way to associate specific spans with source locations without getting 12 headaches. Instead I decided to try implementing this from scratch, reducing our reliance on the api-extractor project and putting us in control of how we generate type summaries. + +This package is missing some critical features for wider adoption, but rather than build the entire product in a branch I decided to implement support for a small number of TS features and put this to use in the `@kbn/crypto` module ASAP. + +The plan is to expand to other packages in the Kibana repo, adding support for language features as we go. + +## Something isn't working and I'm blocked! + +If there's a problem with the implmentation blocking another team at any point we can move the package back to using api-extractor by removing the package from the `TYPE_SUMMARIZER_PACKAGES` list at the top of [packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts](./src/lib/bazel_cli_config.ts). \ No newline at end of file diff --git a/packages/kbn-type-summarizer/jest.config.js b/packages/kbn-type-summarizer/jest.config.js new file mode 100644 index 00000000000000..84b10626e82c87 --- /dev/null +++ b/packages/kbn-type-summarizer/jest.config.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +/** @typedef {import("@jest/types").Config.InitialOptions} JestConfig */ +/** @type {JestConfig} */ +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-type-summarizer'], +}; diff --git a/packages/kbn-type-summarizer/jest.integration.config.js b/packages/kbn-type-summarizer/jest.integration.config.js new file mode 100644 index 00000000000000..ae7b80073b9359 --- /dev/null +++ b/packages/kbn-type-summarizer/jest.integration.config.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +/** @typedef {import("@jest/types").Config.InitialOptions} JestConfig */ +/** @type {JestConfig} */ +module.exports = { + preset: '@kbn/test/jest_integration_node', + rootDir: '../..', + roots: ['/packages/kbn-type-summarizer'], +}; diff --git a/packages/kbn-type-summarizer/package.json b/packages/kbn-type-summarizer/package.json new file mode 100644 index 00000000000000..531928ce788429 --- /dev/null +++ b/packages/kbn-type-summarizer/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/type-summarizer", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "main": "./target_node/index.js", + "private": true +} diff --git a/packages/kbn-type-summarizer/src/bazel_cli.ts b/packages/kbn-type-summarizer/src/bazel_cli.ts new file mode 100644 index 00000000000000..af6b13ebfc09ca --- /dev/null +++ b/packages/kbn-type-summarizer/src/bazel_cli.ts @@ -0,0 +1,73 @@ +/* + * 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 Fsp from 'fs/promises'; +import Path from 'path'; + +import { run } from './lib/run'; +import { parseBazelCliConfig } from './lib/bazel_cli_config'; + +import { summarizePackage } from './summarize_package'; +import { runApiExtractor } from './run_api_extractor'; + +const HELP = ` +Script called from bazel to create the summarized version of a package. When called by Bazel +config is passed as a JSON encoded object. + +When called via "node scripts/build_type_summarizer_output" pass a path to a package and that +package's types will be read from node_modules and written to data/type-summarizer-output. + +`; + +run( + async ({ argv, log }) => { + log.debug('cwd:', process.cwd()); + log.debug('argv', process.argv); + + const config = parseBazelCliConfig(argv); + await Fsp.mkdir(config.outputDir, { recursive: true }); + + // generate pkg json output + await Fsp.writeFile( + Path.resolve(config.outputDir, 'package.json'), + JSON.stringify( + { + name: `@types/${config.packageName.replaceAll('@', '').replaceAll('/', '__')}`, + description: 'Generated by @kbn/type-summarizer', + types: './index.d.ts', + private: true, + license: 'MIT', + version: '1.1.0', + }, + null, + 2 + ) + ); + + if (config.use === 'type-summarizer') { + await summarizePackage(log, { + dtsDir: Path.dirname(config.inputPath), + inputPaths: [config.inputPath], + outputDir: config.outputDir, + tsconfigPath: config.tsconfigPath, + repoRelativePackageDir: config.repoRelativePackageDir, + }); + log.success('type summary created for', config.repoRelativePackageDir); + } else { + await runApiExtractor( + config.tsconfigPath, + config.inputPath, + Path.resolve(config.outputDir, 'index.d.ts') + ); + } + }, + { + helpText: HELP, + defaultLogLevel: 'quiet', + } +); diff --git a/packages/kbn-type-summarizer/src/index.ts b/packages/kbn-type-summarizer/src/index.ts new file mode 100644 index 00000000000000..1667ab5cd8d2fb --- /dev/null +++ b/packages/kbn-type-summarizer/src/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { Logger } from './lib/log'; +export type { SummarizePacakgeOptions } from './summarize_package'; +export { summarizePackage } from './summarize_package'; diff --git a/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts b/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts new file mode 100644 index 00000000000000..a0fdb3e4685b1f --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; + +import { CliError } from './cli_error'; +import { parseCliFlags } from './cli_flags'; + +const TYPE_SUMMARIZER_PACKAGES = ['@kbn/type-summarizer', '@kbn/crypto']; + +const isString = (i: any): i is string => typeof i === 'string' && i.length > 0; + +interface BazelCliConfig { + packageName: string; + outputDir: string; + tsconfigPath: string; + inputPath: string; + repoRelativePackageDir: string; + use: 'api-extractor' | 'type-summarizer'; +} + +export function parseBazelCliFlags(argv: string[]): BazelCliConfig { + const { rawFlags, unknownFlags } = parseCliFlags(argv, { + string: ['use'], + default: { + use: 'api-extractor', + }, + }); + + if (unknownFlags.length) { + throw new CliError(`Unknown flags: ${unknownFlags.join(', ')}`, { + showHelp: true, + }); + } + + let REPO_ROOT; + try { + const name = 'utils'; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const utils = require('@kbn/' + name); + REPO_ROOT = utils.REPO_ROOT as string; + } catch (error) { + if (error && error.code === 'MODULE_NOT_FOUND') { + throw new CliError('type-summarizer bazel cli only works after bootstrap'); + } + + throw error; + } + + const [relativePackagePath, ...extraPositional] = rawFlags._; + if (typeof relativePackagePath !== 'string') { + throw new CliError(`missing path to package as first positional argument`, { showHelp: true }); + } + if (extraPositional.length) { + throw new CliError(`extra positional arguments`, { showHelp: true }); + } + + const use = rawFlags.use; + if (use !== 'api-extractor' && use !== 'type-summarizer') { + throw new CliError(`invalid --use flag, expected "api-extractor" or "type-summarizer"`); + } + + const packageDir = Path.resolve(relativePackagePath); + const packageName: string = JSON.parse( + Fs.readFileSync(Path.join(packageDir, 'package.json'), 'utf8') + ).name; + const repoRelativePackageDir = Path.relative(REPO_ROOT, packageDir); + + return { + use, + packageName, + tsconfigPath: Path.join(REPO_ROOT, repoRelativePackageDir, 'tsconfig.json'), + inputPath: Path.resolve(REPO_ROOT, 'node_modules', packageName, 'target_types/index.d.ts'), + repoRelativePackageDir, + outputDir: Path.resolve(REPO_ROOT, 'data/type-summarizer-output', use), + }; +} + +export function parseBazelCliJson(json: string): BazelCliConfig { + let config; + try { + config = JSON.parse(json); + } catch (error) { + throw new CliError('unable to parse first positional argument as JSON'); + } + + if (typeof config !== 'object' || config === null) { + throw new CliError('config JSON must be an object'); + } + + const packageName = config.packageName; + if (!isString(packageName)) { + throw new CliError('packageName config must be a non-empty string'); + } + + const outputDir = config.outputDir; + if (!isString(outputDir)) { + throw new CliError('outputDir config must be a non-empty string'); + } + if (Path.isAbsolute(outputDir)) { + throw new CliError(`outputDir [${outputDir}] must be a relative path`); + } + + const tsconfigPath = config.tsconfigPath; + if (!isString(tsconfigPath)) { + throw new CliError('tsconfigPath config must be a non-empty string'); + } + if (Path.isAbsolute(tsconfigPath)) { + throw new CliError(`tsconfigPath [${tsconfigPath}] must be a relative path`); + } + + const inputPath = config.inputPath; + if (!isString(inputPath)) { + throw new CliError('inputPath config must be a non-empty string'); + } + if (Path.isAbsolute(inputPath)) { + throw new CliError(`inputPath [${inputPath}] must be a relative path`); + } + + const buildFilePath = config.buildFilePath; + if (!isString(buildFilePath)) { + throw new CliError('buildFilePath config must be a non-empty string'); + } + if (Path.isAbsolute(buildFilePath)) { + throw new CliError(`buildFilePath [${buildFilePath}] must be a relative path`); + } + + const repoRelativePackageDir = Path.dirname(buildFilePath); + + return { + packageName, + outputDir: Path.resolve(outputDir), + tsconfigPath: Path.resolve(tsconfigPath), + inputPath: Path.resolve(inputPath), + repoRelativePackageDir, + use: TYPE_SUMMARIZER_PACKAGES.includes(packageName) ? 'type-summarizer' : 'api-extractor', + }; +} + +export function parseBazelCliConfig(argv: string[]) { + if (typeof argv[0] === 'string' && argv[0].startsWith('{')) { + return parseBazelCliJson(argv[0]); + } + return parseBazelCliFlags(argv); +} diff --git a/packages/kbn-type-summarizer/src/lib/cli_error.ts b/packages/kbn-type-summarizer/src/lib/cli_error.ts new file mode 100644 index 00000000000000..143d790612f619 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/cli_error.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 CliErrorOptions { + exitCode?: number; + showHelp?: boolean; +} + +export class CliError extends Error { + public readonly exitCode: number; + public readonly showHelp: boolean; + + constructor(message: string, options: CliErrorOptions = {}) { + super(message); + + this.exitCode = options.exitCode ?? 1; + this.showHelp = options.showHelp ?? false; + } +} diff --git a/packages/kbn-type-summarizer/src/lib/cli_flags.ts b/packages/kbn-type-summarizer/src/lib/cli_flags.ts new file mode 100644 index 00000000000000..0f616dca873beb --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/cli_flags.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import getopts from 'getopts'; + +interface ParseCliFlagsOptions { + alias?: Record; + boolean?: string[]; + string?: string[]; + default?: Record; +} + +export function parseCliFlags(argv = process.argv.slice(2), options: ParseCliFlagsOptions = {}) { + const unknownFlags: string[] = []; + + const string = options.string ?? []; + const boolean = ['help', 'verbose', 'debug', 'quiet', 'silent', ...(options.boolean ?? [])]; + const alias = { + v: 'verbose', + d: 'debug', + h: 'help', + ...options.alias, + }; + + const rawFlags = getopts(argv, { + alias, + boolean, + string, + default: options.default, + unknown(name) { + unknownFlags.push(name); + return false; + }, + }); + + return { + rawFlags, + unknownFlags, + }; +} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts b/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts new file mode 100644 index 00000000000000..f8f4e131f83868 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as ts from 'typescript'; +import { ValueNode, ExportFromDeclaration } from '../ts_nodes'; +import { ResultValue } from './result_value'; +import { ImportedSymbols } from './imported_symbols'; +import { Reference, ReferenceKey } from './reference'; +import { SourceMapper } from '../source_mapper'; + +export type CollectorResult = Reference | ImportedSymbols | ResultValue; + +export class CollectorResults { + imports: ImportedSymbols[] = []; + importsByPath = new Map(); + + nodes: ResultValue[] = []; + nodesByAst = new Map(); + + constructor(private readonly sourceMapper: SourceMapper) {} + + addNode(exported: boolean, node: ValueNode) { + const existing = this.nodesByAst.get(node); + if (existing) { + existing.exported = existing.exported || exported; + return; + } + + const result = new ResultValue(exported, node); + this.nodesByAst.set(node, result); + this.nodes.push(result); + } + + ensureExported(node: ValueNode) { + this.addNode(true, node); + } + + addImport( + exported: boolean, + node: ts.ImportDeclaration | ExportFromDeclaration, + symbol: ts.Symbol + ) { + const literal = node.moduleSpecifier; + if (!ts.isStringLiteral(literal)) { + throw new Error('import statement with non string literal module identifier'); + } + + const existing = this.importsByPath.get(literal.text); + if (existing) { + existing.symbols.push(symbol); + return; + } + + const result = new ImportedSymbols(exported, node, [symbol]); + this.importsByPath.set(literal.text, result); + this.imports.push(result); + } + + private getReferencesFromNodes() { + // collect the references from all the sourcefiles of all the resulting nodes + const sourceFiles = new Set(); + for (const { node } of this.nodes) { + sourceFiles.add(this.sourceMapper.getSourceFile(node)); + } + + const references: Record> = { + lib: new Set(), + types: new Set(), + }; + for (const sourceFile of sourceFiles) { + for (const ref of sourceFile.libReferenceDirectives) { + references.lib.add(ref.fileName); + } + for (const ref of sourceFile.typeReferenceDirectives) { + references.types.add(ref.fileName); + } + } + + return [ + ...Array.from(references.lib).map((name) => new Reference('lib', name)), + ...Array.from(references.types).map((name) => new Reference('types', name)), + ]; + } + + getAll(): CollectorResult[] { + return [...this.getReferencesFromNodes(), ...this.imports, ...this.nodes]; + } +} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts b/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts new file mode 100644 index 00000000000000..3f46ceda70e1fd --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as ts from 'typescript'; + +import { Logger } from '../log'; +import { + assertExportedValueNode, + isExportedValueNode, + DecSymbol, + assertDecSymbol, + toDecSymbol, + ExportFromDeclaration, + isExportFromDeclaration, + isAliasSymbol, +} from '../ts_nodes'; + +import { ExportInfo } from '../export_info'; +import { CollectorResults } from './collector_results'; +import { SourceMapper } from '../source_mapper'; +import { isNodeModule } from '../is_node_module'; + +interface ResolvedNmImport { + type: 'import'; + node: ts.ImportDeclaration | ExportFromDeclaration; + targetPath: string; +} +interface ResolvedSymbol { + type: 'symbol'; + symbol: DecSymbol; +} + +export class ExportCollector { + constructor( + private readonly log: Logger, + private readonly typeChecker: ts.TypeChecker, + private readonly sourceFile: ts.SourceFile, + private readonly dtsDir: string, + private readonly sourceMapper: SourceMapper + ) {} + + private getParentImport( + symbol: DecSymbol + ): ts.ImportDeclaration | ExportFromDeclaration | undefined { + for (const node of symbol.declarations) { + let cursor: ts.Node = node; + while (true) { + if (ts.isImportDeclaration(cursor) || isExportFromDeclaration(cursor)) { + return cursor; + } + + if (ts.isSourceFile(cursor)) { + break; + } + + cursor = cursor.parent; + } + } + } + + private getAllChildSymbols( + node: ts.Node, + results = new Set(), + seen = new Set() + ) { + node.forEachChild((child) => { + const childSymbol = this.typeChecker.getSymbolAtLocation(child); + if (childSymbol) { + results.add(toDecSymbol(childSymbol)); + } + if (!seen.has(child)) { + seen.add(child); + this.getAllChildSymbols(child, results, seen); + } + }); + return results; + } + + private resolveAliasSymbolStep(alias: ts.Symbol): DecSymbol { + // get the symbol this symbol references + const aliased = this.typeChecker.getImmediateAliasedSymbol(alias); + if (!aliased) { + throw new Error(`symbol [${alias.escapedName}] is an alias without aliased symbol`); + } + assertDecSymbol(aliased); + return aliased; + } + + private getImportFromNodeModules(symbol: DecSymbol): undefined | ResolvedNmImport { + const parentImport = this.getParentImport(symbol); + if (parentImport) { + // this symbol is within an import statement, is it an import from a node_module? + const aliased = this.resolveAliasSymbolStep(symbol); + + // symbol is in an import or export-from statement, make sure we want to traverse to that file + const targetPaths = [ + ...new Set(aliased.declarations.map((d) => this.sourceMapper.getSourceFile(d).fileName)), + ]; + + if (targetPaths.length > 1) { + throw new Error('importing a symbol from multiple locations is unsupported at this time'); + } + + const targetPath = targetPaths[0]; + if (isNodeModule(this.dtsDir, targetPath)) { + return { + type: 'import', + node: parentImport, + targetPath, + }; + } + } + } + + private resolveAliasSymbol(alias: DecSymbol): ResolvedNmImport | ResolvedSymbol { + let symbol = alias; + + while (isAliasSymbol(symbol)) { + const nmImport = this.getImportFromNodeModules(symbol); + if (nmImport) { + return nmImport; + } + + symbol = this.resolveAliasSymbolStep(symbol); + } + + return { + type: 'symbol', + symbol, + }; + } + + private traversedSymbols = new Set(); + private collectResults( + results: CollectorResults, + exportInfo: ExportInfo | undefined, + symbol: DecSymbol + ): void { + const seen = this.traversedSymbols.has(symbol); + if (seen && !exportInfo) { + return; + } + this.traversedSymbols.add(symbol); + + const source = this.resolveAliasSymbol(symbol); + if (source.type === 'import') { + results.addImport(!!exportInfo, source.node, symbol); + return; + } + + symbol = source.symbol; + if (seen) { + for (const node of symbol.declarations) { + assertExportedValueNode(node); + results.ensureExported(node); + } + return; + } + + const globalDecs: ts.Declaration[] = []; + const localDecs: ts.Declaration[] = []; + for (const node of symbol.declarations) { + const sourceFile = this.sourceMapper.getSourceFile(node); + (isNodeModule(this.dtsDir, sourceFile.fileName) ? globalDecs : localDecs).push(node); + } + + if (globalDecs.length) { + this.log.debug( + `Ignoring ${globalDecs.length} global declarations for "${source.symbol.escapedName}"` + ); + } + + for (const node of localDecs) { + // iterate through the child nodes to find nodes we need to export to make this useful + const childSymbols = this.getAllChildSymbols(node); + childSymbols.delete(symbol); + + for (const childSymbol of childSymbols) { + this.collectResults(results, undefined, childSymbol); + } + + if (isExportedValueNode(node)) { + results.addNode(!!exportInfo, node); + } + } + } + + run(): CollectorResults { + const results = new CollectorResults(this.sourceMapper); + + const moduleSymbol = this.typeChecker.getSymbolAtLocation(this.sourceFile); + if (!moduleSymbol) { + this.log.warn('Source file has no symbol in the type checker, is it empty?'); + return results; + } + + for (const symbol of this.typeChecker.getExportsOfModule(moduleSymbol)) { + assertDecSymbol(symbol); + this.collectResults(results, new ExportInfo(`${symbol.escapedName}`), symbol); + } + + return results; + } +} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts b/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts new file mode 100644 index 00000000000000..1c9fa800baaab2 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as ts from 'typescript'; +import { ExportFromDeclaration } from '../ts_nodes'; + +export class ImportedSymbols { + type = 'import' as const; + + constructor( + public readonly exported: boolean, + public readonly importNode: ts.ImportDeclaration | ExportFromDeclaration, + // TODO: I'm going to need to keep track of local names for these... unless that's embedded in the symbols + public readonly symbols: ts.Symbol[] + ) {} +} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/index.ts b/packages/kbn-type-summarizer/src/lib/export_collector/index.ts new file mode 100644 index 00000000000000..87f6630d2fcfac --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './exports_collector'; +export * from './collector_results'; diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/reference.ts b/packages/kbn-type-summarizer/src/lib/export_collector/reference.ts new file mode 100644 index 00000000000000..b664a457a24ada --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/reference.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type ReferenceKey = 'types' | 'lib'; + +export class Reference { + type = 'reference' as const; + constructor(public readonly key: ReferenceKey, public readonly name: string) {} +} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts b/packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts new file mode 100644 index 00000000000000..91249eea68e140 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { ValueNode } from '../ts_nodes'; + +export class ResultValue { + type = 'value' as const; + + constructor(public exported: boolean, public readonly node: ValueNode) {} +} diff --git a/packages/kbn-type-summarizer/src/lib/export_info.ts b/packages/kbn-type-summarizer/src/lib/export_info.ts new file mode 100644 index 00000000000000..3dee04121d3225 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_info.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export class ExportInfo { + constructor(public readonly name: string) {} +} diff --git a/packages/kbn-type-summarizer/src/lib/helpers/error.ts b/packages/kbn-type-summarizer/src/lib/helpers/error.ts new file mode 100644 index 00000000000000..f78eb29083b04e --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/helpers/error.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 function toError(thrown: unknown) { + if (thrown instanceof Error) { + return thrown; + } + + return new Error(`${thrown} thrown`); +} + +export function isSystemError(error: Error): error is NodeJS.ErrnoException { + return typeof (error as any).code === 'string'; +} diff --git a/packages/kbn-type-summarizer/src/lib/helpers/fs.ts b/packages/kbn-type-summarizer/src/lib/helpers/fs.ts new file mode 100644 index 00000000000000..092310c1e5db08 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/helpers/fs.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Fsp from 'fs/promises'; +import { toError, isSystemError } from './error'; + +export async function tryReadFile( + path: string, + encoding: 'utf-8' | 'utf8' +): Promise; +export async function tryReadFile(path: string, encoding?: BufferEncoding) { + try { + return await Fsp.readFile(path, encoding); + } catch (_) { + const error = toError(_); + if (isSystemError(error) && error.code === 'ENOENT') { + return undefined; + } + throw error; + } +} diff --git a/packages/kbn-type-summarizer/src/lib/helpers/json.test.ts b/packages/kbn-type-summarizer/src/lib/helpers/json.test.ts new file mode 100644 index 00000000000000..4bb86652221d9e --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/helpers/json.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { parseJson } from './json'; + +it('parses JSON', () => { + expect(parseJson('{"foo": "bar"}')).toMatchInlineSnapshot(` + Object { + "foo": "bar", + } + `); +}); + +it('throws more helpful errors', () => { + expect(() => parseJson('{"foo": bar}')).toThrowErrorMatchingInlineSnapshot( + `"Failed to parse JSON: Unexpected token b in JSON at position 8"` + ); +}); diff --git a/packages/kbn-type-summarizer/src/lib/helpers/json.ts b/packages/kbn-type-summarizer/src/lib/helpers/json.ts new file mode 100644 index 00000000000000..ee2403bd9422cb --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/helpers/json.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { toError } from './error'; + +export function parseJson(json: string, from?: string) { + try { + return JSON.parse(json); + } catch (_) { + const error = toError(_); + throw new Error(`Failed to parse JSON${from ? ` from ${from}` : ''}: ${error.message}`); + } +} diff --git a/packages/kbn-type-summarizer/src/lib/is_node_module.ts b/packages/kbn-type-summarizer/src/lib/is_node_module.ts new file mode 100644 index 00000000000000..67efde569a1b4e --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/is_node_module.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; + +import isPathInside from 'is-path-inside'; + +export function isNodeModule(dtsDir: string, path: string) { + return (isPathInside(path, dtsDir) ? Path.relative(dtsDir, path) : path) + .split(Path.sep) + .includes('node_modules'); +} diff --git a/packages/kbn-type-summarizer/src/lib/log/cli_log.ts b/packages/kbn-type-summarizer/src/lib/log/cli_log.ts new file mode 100644 index 00000000000000..1121dfae3606a1 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/log/cli_log.ts @@ -0,0 +1,99 @@ +/* + * 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 { format } from 'util'; +import { dim, blueBright, yellowBright, redBright, gray } from 'chalk'; +import getopts from 'getopts'; + +import { Logger } from './logger'; + +const LOG_LEVEL_RANKS = { + silent: 0, + quiet: 1, + info: 2, + debug: 3, + verbose: 4, +}; +export type LogLevel = keyof typeof LOG_LEVEL_RANKS; +const LOG_LEVELS = (Object.keys(LOG_LEVEL_RANKS) as LogLevel[]).sort( + (a, b) => LOG_LEVEL_RANKS[a] - LOG_LEVEL_RANKS[b] +); +const LOG_LEVELS_DESC = LOG_LEVELS.slice().reverse(); + +type LogLevelMap = { [k in LogLevel]: boolean }; + +export interface LogWriter { + write(chunk: string): void; +} + +export class CliLog implements Logger { + static parseLogLevel(level: LogLevel) { + if (!LOG_LEVELS.includes(level)) { + throw new Error('invalid log level'); + } + + const rank = LOG_LEVEL_RANKS[level]; + return Object.fromEntries( + LOG_LEVELS.map((l) => [l, LOG_LEVEL_RANKS[l] <= rank]) + ) as LogLevelMap; + } + + static pickLogLevelFromFlags( + flags: getopts.ParsedOptions, + defaultLogLevl: LogLevel = 'info' + ): LogLevel { + for (const level of LOG_LEVELS_DESC) { + if (Object.prototype.hasOwnProperty.call(flags, level) && flags[level] === true) { + return level; + } + } + + return defaultLogLevl; + } + + private readonly map: LogLevelMap; + constructor(public readonly level: LogLevel, private readonly writeTo: LogWriter) { + this.map = CliLog.parseLogLevel(level); + } + + info(msg: string, ...args: any[]) { + if (this.map.info) { + this.writeTo.write(`${blueBright('info')} ${format(msg, ...args)}\n`); + } + } + + warn(msg: string, ...args: any[]) { + if (this.map.quiet) { + this.writeTo.write(`${yellowBright('warning')} ${format(msg, ...args)}\n`); + } + } + + error(msg: string, ...args: any[]) { + if (this.map.quiet) { + this.writeTo.write(`${redBright('error')} ${format(msg, ...args)}\n`); + } + } + + debug(msg: string, ...args: any[]) { + if (this.map.debug) { + this.writeTo.write(`${gray('debug')} ${format(msg, ...args)}\n`); + } + } + + verbose(msg: string, ...args: any[]) { + if (this.map.verbose) { + this.writeTo.write(`${dim('verbose')}: ${format(msg, ...args)}\n`); + } + } + + success(msg: string, ...args: any[]): void { + if (this.map.quiet) { + this.writeTo.write(`✅ ${format(msg, ...args)}\n`); + } + } +} diff --git a/packages/kbn-type-summarizer/src/lib/log/index.ts b/packages/kbn-type-summarizer/src/lib/log/index.ts new file mode 100644 index 00000000000000..68a37528d49767 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/log/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './logger'; +export * from './cli_log'; +export * from './test_log'; diff --git a/packages/kbn-type-summarizer/src/lib/log/logger.ts b/packages/kbn-type-summarizer/src/lib/log/logger.ts new file mode 100644 index 00000000000000..76cb7fe525f6d8 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/log/logger.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Logger interface used by @kbn/type-summarizer + */ +export interface Logger { + /** + * Write a message to the log with the level "info" + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + info(msg: string, ...args: any[]): void; + /** + * Write a message to the log with the level "warn" + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + warn(msg: string, ...args: any[]): void; + /** + * Write a message to the log with the level "error" + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + error(msg: string, ...args: any[]): void; + /** + * Write a message to the log with the level "debug" + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + debug(msg: string, ...args: any[]): void; + /** + * Write a message to the log with the level "verbose" + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + verbose(msg: string, ...args: any[]): void; + /** + * Write a message to the log, only excluded in silent mode + * @param msg any message + * @param args any serializeable values you would like to be appended to the log message + */ + success(msg: string, ...args: any[]): void; +} diff --git a/packages/kbn-type-summarizer/src/lib/log/test_log.ts b/packages/kbn-type-summarizer/src/lib/log/test_log.ts new file mode 100644 index 00000000000000..5062a8cbae841d --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/log/test_log.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CliLog, LogLevel } from './cli_log'; + +export class TestLog extends CliLog { + messages: string[] = []; + constructor(level: LogLevel = 'verbose') { + super(level, { + write: (chunk) => { + this.messages.push(chunk); + }, + }); + } +} diff --git a/packages/kbn-type-summarizer/src/lib/printer.ts b/packages/kbn-type-summarizer/src/lib/printer.ts new file mode 100644 index 00000000000000..3ce675f7279275 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/printer.ts @@ -0,0 +1,362 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; + +import * as ts from 'typescript'; +import { SourceNode, CodeWithSourceMap } from 'source-map'; + +import { findKind } from './ts_nodes'; +import { SourceMapper } from './source_mapper'; +import { CollectorResult } from './export_collector'; + +type SourceNodes = Array; +const COMMENT_TRIM = /^(\s+)(\/\*|\*|\/\/)/; + +export class Printer { + private readonly tsPrint = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed, + noEmitHelpers: true, + omitTrailingSemicolon: false, + removeComments: true, + }); + + constructor( + private readonly sourceMapper: SourceMapper, + private readonly results: CollectorResult[], + private readonly outputPath: string, + private readonly mapOutputPath: string, + private readonly sourceRoot: string, + private readonly strict: boolean + ) {} + + async print(): Promise { + const file = new SourceNode( + null, + null, + null, + this.results.flatMap((r) => { + if (r.type === 'reference') { + return `/// \n`; + } + + if (r.type === 'import') { + // TODO: handle default imports, imports with alternate names, etc + return `import { ${r.symbols + .map((s) => s.escapedName) + .join(', ')} } from ${r.importNode.moduleSpecifier.getText()};\n`; + } + + return this.toSourceNodes(r.node, r.exported); + }) + ); + + const outputDir = Path.dirname(this.outputPath); + const mapOutputDir = Path.dirname(this.mapOutputPath); + + const output = file.toStringWithSourceMap({ + file: Path.relative(mapOutputDir, this.outputPath), + sourceRoot: this.sourceRoot, + }); + + const nl = output.code.endsWith('\n') ? '' : '\n'; + const sourceMapPathRel = Path.relative(outputDir, this.mapOutputPath); + output.code += `${nl}//# sourceMappingURL=${sourceMapPathRel}`; + + return output; + } + + private getDeclarationKeyword(node: ts.Declaration) { + if (node.kind === ts.SyntaxKind.FunctionDeclaration) { + return 'function'; + } + + if (node.kind === ts.SyntaxKind.TypeAliasDeclaration) { + return 'type'; + } + + if (node.kind === ts.SyntaxKind.ClassDeclaration) { + return 'class'; + } + + if (node.kind === ts.SyntaxKind.InterfaceDeclaration) { + return 'interface'; + } + + if (ts.isVariableDeclaration(node)) { + return this.getVariableDeclarationType(node); + } + } + + private printModifiers(exported: boolean, node: ts.Declaration) { + const flags = ts.getCombinedModifierFlags(node); + const modifiers: string[] = []; + if (exported) { + modifiers.push('export'); + } + if (flags & ts.ModifierFlags.Default) { + modifiers.push('default'); + } + if (flags & ts.ModifierFlags.Abstract) { + modifiers.push('abstract'); + } + if (flags & ts.ModifierFlags.Private) { + modifiers.push('private'); + } + if (flags & ts.ModifierFlags.Public) { + modifiers.push('public'); + } + if (flags & ts.ModifierFlags.Static) { + modifiers.push('static'); + } + if (flags & ts.ModifierFlags.Readonly) { + modifiers.push('readonly'); + } + if (flags & ts.ModifierFlags.Const) { + modifiers.push('const'); + } + if (flags & ts.ModifierFlags.Async) { + modifiers.push('async'); + } + + const keyword = this.getDeclarationKeyword(node); + if (keyword) { + modifiers.push(keyword); + } + + return `${modifiers.join(' ')} `; + } + + private printNode(node: ts.Node) { + return this.tsPrint.printNode( + ts.EmitHint.Unspecified, + node, + this.sourceMapper.getSourceFile(node) + ); + } + + private ensureNewline(string: string): string; + private ensureNewline(string: SourceNodes): SourceNodes; + private ensureNewline(string: string | SourceNodes): string | SourceNodes { + if (typeof string === 'string') { + return string.endsWith('\n') ? string : `${string}\n`; + } + + const end = string.at(-1); + if (end === undefined) { + return []; + } + + const valid = (typeof end === 'string' ? end : end.toString()).endsWith('\n'); + return valid ? string : [...string, '\n']; + } + + private getMappedSourceNode(node: ts.Node, code?: string) { + return this.sourceMapper.getSourceNode(node, code ?? node.getText()); + } + + private getVariableDeclarationList(node: ts.VariableDeclaration) { + const list = node.parent; + if (!ts.isVariableDeclarationList(list)) { + const kind = findKind(list); + throw new Error( + `expected parent of variable declaration to be a VariableDeclarationList, got [${kind}]` + ); + } + return list; + } + + private getVariableDeclarationType(node: ts.VariableDeclaration) { + const flags = ts.getCombinedNodeFlags(this.getVariableDeclarationList(node)); + if (flags & ts.NodeFlags.Const) { + return 'const'; + } + if (flags & ts.NodeFlags.Let) { + return 'let'; + } + return 'var'; + } + + private getSourceWithLeadingComments(node: ts.Node) { + // variable declarations regularly have leading comments but they're two-parents up, so we have to handle them separately + if (!ts.isVariableDeclaration(node)) { + return node.getFullText(); + } + + const list = this.getVariableDeclarationList(node); + if (list.declarations.length > 1) { + return node.getFullText(); + } + + const statement = list.parent; + if (!ts.isVariableStatement(statement)) { + throw new Error('expected parent of VariableDeclarationList to be a VariableStatement'); + } + + return statement.getFullText(); + } + + private getLeadingComments(node: ts.Node, indentWidth = 0): string[] { + const fullText = this.getSourceWithLeadingComments(node); + const ranges = ts.getLeadingCommentRanges(fullText, 0); + if (!ranges) { + return []; + } + const indent = ' '.repeat(indentWidth); + + return ranges.flatMap((range) => { + const comment = fullText + .slice(range.pos, range.end) + .split('\n') + .map((line) => { + const match = line.match(COMMENT_TRIM); + if (!match) { + return line; + } + + const [, spaces, type] = match; + return line.slice(type === '*' ? spaces.length - 1 : spaces.length); + }) + .map((line) => `${indent}${line}`) + .join('\n'); + + if (comment.startsWith('/// this.printNode(p)).join(', ')}>`; + } + + private toSourceNodes(node: ts.Node, exported = false): SourceNodes { + switch (node.kind) { + case ts.SyntaxKind.LiteralType: + case ts.SyntaxKind.StringLiteral: + case ts.SyntaxKind.BigIntLiteral: + case ts.SyntaxKind.NumericLiteral: + case ts.SyntaxKind.StringKeyword: + return [this.printNode(node)]; + } + + if (ts.isFunctionDeclaration(node)) { + // we are just trying to replace the name with a sourceMapped node, so if there + // is no name just return the source + if (!node.name) { + return [node.getFullText()]; + } + + return [ + this.getLeadingComments(node), + this.printModifiers(exported, node), + this.getMappedSourceNode(node.name), + this.printTypeParameters(node), + `(${node.parameters.map((p) => p.getFullText()).join(', ')})`, + node.type ? [': ', this.printNode(node.type), ';'] : ';', + ].flat(); + } + + if (ts.isInterfaceDeclaration(node)) { + const text = node.getText(); + const name = node.name.getText(); + const nameI = text.indexOf(name); + if (nameI === -1) { + throw new Error(`printed version of interface does not include name [${name}]: ${text}`); + } + return [ + ...this.getLeadingComments(node), + text.slice(0, nameI), + this.getMappedSourceNode(node.name, name), + text.slice(nameI + name.length), + '\n', + ]; + } + + if (ts.isVariableDeclaration(node)) { + return [ + ...this.getLeadingComments(node), + this.printModifiers(exported, node), + this.getMappedSourceNode(node.name), + ...(node.type ? [': ', this.printNode(node.type)] : []), + ';\n', + ]; + } + + if (ts.isUnionTypeNode(node)) { + return node.types.flatMap((type, i) => + i > 0 ? [' | ', ...this.toSourceNodes(type)] : this.toSourceNodes(type) + ); + } + + if (ts.isTypeAliasDeclaration(node)) { + return [ + ...this.getLeadingComments(node), + this.printModifiers(exported, node), + this.getMappedSourceNode(node.name), + this.printTypeParameters(node), + ' = ', + this.ensureNewline(this.toSourceNodes(node.type)), + ].flat(); + } + + if (ts.isClassDeclaration(node)) { + return [ + ...this.getLeadingComments(node), + this.printModifiers(exported, node), + node.name ? this.getMappedSourceNode(node.name) : [], + this.printTypeParameters(node), + ' {\n', + node.members.flatMap((m) => { + const memberText = m.getText(); + + if (ts.isConstructorDeclaration(m)) { + return ` ${memberText}\n`; + } + + if (!m.name) { + return ` ${memberText}\n`; + } + + const nameText = m.name.getText(); + const pos = memberText.indexOf(nameText); + if (pos === -1) { + return ` ${memberText}\n`; + } + + const left = memberText.slice(0, pos); + const right = memberText.slice(pos + nameText.length); + const nameNode = this.getMappedSourceNode(m.name, nameText); + + return [...this.getLeadingComments(m, 2), ` `, left, nameNode, right, `\n`]; + }), + '}\n', + ].flat(); + } + + if (!this.strict) { + return [this.ensureNewline(this.printNode(node))]; + } else { + throw new Error(`unable to print export type of kind [${findKind(node)}]`); + } + } +} diff --git a/packages/kbn-type-summarizer/src/lib/run.ts b/packages/kbn-type-summarizer/src/lib/run.ts new file mode 100644 index 00000000000000..4834c4d8aae9bf --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/run.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import getopts from 'getopts'; + +import { CliLog, LogLevel } from './log'; +import { toError } from './helpers/error'; +import { CliError } from './cli_error'; + +export interface RunContext { + argv: string[]; + log: CliLog; +} + +export interface RunOptions { + helpText: string; + defaultLogLevel?: LogLevel; +} + +export async function run(main: (ctx: RunContext) => Promise, options: RunOptions) { + const argv = process.argv.slice(2); + const rawFlags = getopts(argv); + + const log = new CliLog( + CliLog.pickLogLevelFromFlags(rawFlags, options.defaultLogLevel), + process.stdout + ); + + try { + await main({ argv, log }); + } catch (_) { + const error = toError(_); + if (error instanceof CliError) { + process.exitCode = error.exitCode; + log.error(error.message); + if (error.showHelp) { + process.stdout.write(options.helpText); + } + } else { + log.error('UNHANDLED ERROR', error.stack); + process.exitCode = 1; + } + } +} diff --git a/packages/kbn-type-summarizer/src/lib/source_mapper.ts b/packages/kbn-type-summarizer/src/lib/source_mapper.ts new file mode 100644 index 00000000000000..f6075684e04a6d --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/source_mapper.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; + +import * as ts from 'typescript'; +import { SourceNode, SourceMapConsumer, BasicSourceMapConsumer } from 'source-map'; +import normalizePath from 'normalize-path'; + +import { Logger } from './log'; +import { tryReadFile } from './helpers/fs'; +import { parseJson } from './helpers/json'; +import { isNodeModule } from './is_node_module'; + +export class SourceMapper { + static async forSourceFiles( + log: Logger, + dtsDir: string, + repoRelativePackageDir: string, + sourceFiles: readonly ts.SourceFile[] + ) { + const consumers = new Map(); + + await Promise.all( + sourceFiles.map(async (sourceFile) => { + if (isNodeModule(dtsDir, sourceFile.fileName)) { + return; + } + + const text = sourceFile.getText(); + const match = text.match(/^\/\/#\s*sourceMappingURL=(.*)/im); + if (!match) { + consumers.set(sourceFile, undefined); + return; + } + + const relSourceFile = Path.relative(process.cwd(), sourceFile.fileName); + const sourceMapPath = Path.resolve(Path.dirname(sourceFile.fileName), match[1]); + const relSourceMapPath = Path.relative(process.cwd(), sourceMapPath); + const sourceJson = await tryReadFile(sourceMapPath, 'utf8'); + if (!sourceJson) { + throw new Error( + `unable to find source map for [${relSourceFile}] expected at [${match[1]}]` + ); + } + + const json = parseJson(sourceJson, `source map at [${relSourceMapPath}]`); + consumers.set(sourceFile, await new SourceMapConsumer(json)); + log.debug('loaded sourcemap for', relSourceFile); + }) + ); + + return new SourceMapper(consumers, repoRelativePackageDir); + } + + private readonly sourceFixDir: string; + constructor( + private readonly consumers: Map, + repoRelativePackageDir: string + ) { + this.sourceFixDir = Path.join('/', repoRelativePackageDir); + } + + /** + * We ensure that `sourceRoot` is not defined in the tsconfig files, and we assume that the `source` value + * for each file in the source map will be a relative path out of the bazel-out dir and to the `repoRelativePackageDir` + * or some path outside of the package in rare situations. Our goal is to convert each of these source paths + * to new path that is relative to the `repoRelativePackageDir` path. To do this we resolve the `repoRelativePackageDir` + * as if it was at the root of the filesystem, then do the same for the `source`, so both paths should be + * absolute, but only include the path segments from the root of the repo. We then get the relative path from + * the absolute version of the `repoRelativePackageDir` to the absolute version of the `source`, which should give + * us the path to the source, relative to the `repoRelativePackageDir`. + */ + fixSourcePath(source: string) { + return normalizePath(Path.relative(this.sourceFixDir, Path.join('/', source))); + } + + getSourceNode(generatedNode: ts.Node, code: string) { + const pos = this.findOriginalPosition(generatedNode); + + if (pos) { + return new SourceNode(pos.line, pos.column, pos.source, code, pos.name ?? undefined); + } + + return new SourceNode(null, null, null, code); + } + + sourceFileCache = new WeakMap(); + // abstracted so we can cache this + getSourceFile(node: ts.Node): ts.SourceFile { + if (ts.isSourceFile(node)) { + return node; + } + + const cached = this.sourceFileCache.get(node); + if (cached) { + return cached; + } + + const sourceFile = this.getSourceFile(node.parent); + this.sourceFileCache.set(node, sourceFile); + return sourceFile; + } + + findOriginalPosition(node: ts.Node) { + const dtsSource = this.getSourceFile(node); + + if (!this.consumers.has(dtsSource)) { + throw new Error(`sourceFile for [${dtsSource.fileName}] didn't have sourcemaps loaded`); + } + + const consumer = this.consumers.get(dtsSource); + if (!consumer) { + return; + } + + const posInDts = dtsSource.getLineAndCharacterOfPosition(node.getStart()); + const pos = consumer.originalPositionFor({ + /* ts line column numbers are 0 based, source map column numbers are also 0 based */ + column: posInDts.character, + /* ts line numbers are 0 based, source map line numbers are 1 based */ + line: posInDts.line + 1, + }); + + return { + ...pos, + source: pos.source ? this.fixSourcePath(pos.source) : null, + }; + } + + close() { + for (const consumer of this.consumers.values()) { + consumer?.destroy(); + } + } +} diff --git a/packages/kbn-type-summarizer/src/lib/ts_nodes.ts b/packages/kbn-type-summarizer/src/lib/ts_nodes.ts new file mode 100644 index 00000000000000..b5c03ee8c4c17b --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/ts_nodes.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as ts from 'typescript'; + +export type ValueNode = + | ts.ClassDeclaration + | ts.FunctionDeclaration + | ts.TypeAliasDeclaration + | ts.VariableDeclaration + | ts.InterfaceDeclaration; + +export function isExportedValueNode(node: ts.Node): node is ValueNode { + return ( + node.kind === ts.SyntaxKind.ClassDeclaration || + node.kind === ts.SyntaxKind.FunctionDeclaration || + node.kind === ts.SyntaxKind.TypeAliasDeclaration || + node.kind === ts.SyntaxKind.VariableDeclaration || + node.kind === ts.SyntaxKind.InterfaceDeclaration + ); +} +export function assertExportedValueNode(node: ts.Node): asserts node is ValueNode { + if (!isExportedValueNode(node)) { + const kind = findKind(node); + throw new Error(`not a valid ExportedValueNode [kind=${kind}]`); + } +} +export function toExportedNodeValue(node: ts.Node): ValueNode { + assertExportedValueNode(node); + return node; +} + +export function findKind(node: ts.Node) { + for (const [name, value] of Object.entries(ts.SyntaxKind)) { + if (node.kind === value) { + return name; + } + } + + throw new Error('node.kind is not in the SyntaxKind map'); +} + +export type DecSymbol = ts.Symbol & { + declarations: NonNullable; +}; +export function isDecSymbol(symbol: ts.Symbol): symbol is DecSymbol { + return !!symbol.declarations; +} +export function assertDecSymbol(symbol: ts.Symbol): asserts symbol is DecSymbol { + if (!isDecSymbol(symbol)) { + throw new Error('symbol has no declarations'); + } +} +export function toDecSymbol(symbol: ts.Symbol): DecSymbol { + assertDecSymbol(symbol); + return symbol; +} + +export type ExportFromDeclaration = ts.ExportDeclaration & { + moduleSpecifier: NonNullable; +}; +export function isExportFromDeclaration(node: ts.Node): node is ExportFromDeclaration { + return ts.isExportDeclaration(node) && !!node.moduleSpecifier; +} + +export function isAliasSymbol(symbol: ts.Symbol) { + return symbol.flags & ts.SymbolFlags.Alias; +} diff --git a/packages/kbn-type-summarizer/src/lib/ts_project.ts b/packages/kbn-type-summarizer/src/lib/ts_project.ts new file mode 100644 index 00000000000000..92946e3290449c --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/ts_project.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as ts from 'typescript'; + +export function createTsProject(tsConfig: ts.ParsedCommandLine, inputPaths: string[]) { + return ts.createProgram({ + rootNames: inputPaths, + options: { + ...tsConfig.options, + skipLibCheck: false, + }, + projectReferences: tsConfig.projectReferences, + }); +} diff --git a/packages/kbn-type-summarizer/src/lib/tsconfig_file.ts b/packages/kbn-type-summarizer/src/lib/tsconfig_file.ts new file mode 100644 index 00000000000000..7d327b1f03e0a3 --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/tsconfig_file.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as ts from 'typescript'; +import Path from 'path'; + +import { CliError } from './cli_error'; + +export function readTsConfigFile(path: string) { + const json = ts.readConfigFile(path, ts.sys.readFile); + + if (json.error) { + throw new CliError(`Unable to load tsconfig file: ${json.error.messageText}`); + } + + return json.config; +} + +export function loadTsConfigFile(path: string) { + return ts.parseJsonConfigFileContent(readTsConfigFile(path) ?? {}, ts.sys, Path.dirname(path)); +} diff --git a/packages/kbn-type-summarizer/src/run_api_extractor.ts b/packages/kbn-type-summarizer/src/run_api_extractor.ts new file mode 100644 index 00000000000000..0e7bae5165a4d8 --- /dev/null +++ b/packages/kbn-type-summarizer/src/run_api_extractor.ts @@ -0,0 +1,86 @@ +/* eslint-disable @kbn/eslint/require-license-header */ + +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import Fsp from 'fs/promises'; +import Path from 'path'; + +import { Extractor, ExtractorConfig } from '@microsoft/api-extractor'; + +import { readTsConfigFile } from './lib/tsconfig_file'; +import { CliError } from './lib/cli_error'; + +export async function runApiExtractor( + tsconfigPath: string, + entryPath: string, + dtsBundleOutDir: string +) { + const pkgJson = Path.resolve(Path.dirname(entryPath), 'package.json'); + try { + await Fsp.writeFile( + pkgJson, + JSON.stringify({ + name: 'GENERATED-BY-BAZEL', + description: 'This is a dummy package.json as API Extractor always requires one.', + types: './index.d.ts', + private: true, + license: 'SSPL-1.0 OR Elastic License 2.0', + version: '1.0.0', + }), + { + flag: 'wx', + } + ); + } catch (error) { + if (!error.code || error.code !== 'EEXIST') { + throw error; + } + } + + // API extractor doesn't always support the version of TypeScript used in the repo + // example: at the moment it is not compatable with 3.2 + // to use the internal TypeScript we shall not create a program but rather pass a parsed tsConfig. + const extractorOptions = { + localBuild: false, + }; + + const extractorConfig = ExtractorConfig.prepare({ + configObject: { + compiler: { + overrideTsconfig: readTsConfigFile(tsconfigPath), + }, + projectFolder: Path.dirname(tsconfigPath), + mainEntryPointFilePath: entryPath, + apiReport: { + enabled: false, + // TODO(alan-agius4): remove this folder name when the below issue is solved upstream + // See: https://github.com/microsoft/web-build-tools/issues/1470 + reportFileName: 'invalid', + }, + docModel: { + enabled: false, + }, + dtsRollup: { + enabled: !!dtsBundleOutDir, + untrimmedFilePath: dtsBundleOutDir, + }, + tsdocMetadata: { + enabled: false, + }, + }, + packageJson: undefined, + packageJsonFullPath: pkgJson, + configObjectFullPath: undefined, + }); + const { succeeded } = Extractor.invoke(extractorConfig, extractorOptions); + + if (!succeeded) { + throw new CliError('api-extractor failed'); + } +} diff --git a/packages/kbn-type-summarizer/src/summarize_package.ts b/packages/kbn-type-summarizer/src/summarize_package.ts new file mode 100644 index 00000000000000..d3aac96af17725 --- /dev/null +++ b/packages/kbn-type-summarizer/src/summarize_package.ts @@ -0,0 +1,123 @@ +/* + * 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 Fsp from 'fs/promises'; +import Path from 'path'; + +import normalizePath from 'normalize-path'; + +import { SourceMapper } from './lib/source_mapper'; +import { createTsProject } from './lib/ts_project'; +import { loadTsConfigFile } from './lib/tsconfig_file'; +import { ExportCollector } from './lib/export_collector'; +import { isNodeModule } from './lib/is_node_module'; +import { Printer } from './lib/printer'; +import { Logger } from './lib/log'; + +/** + * Options used to customize the summarizePackage function + */ +export interface SummarizePacakgeOptions { + /** + * Absolute path to the directory containing the .d.ts files produced by `tsc`. Maps to the + * `declarationDir` compiler option. + */ + dtsDir: string; + /** + * Absolute path to the tsconfig.json file for the project we are summarizing + */ + tsconfigPath: string; + /** + * Array of absolute paths to the .d.ts files which will be summarized. Each file in this + * array will cause an output .d.ts summary file to be created containing all the AST nodes + * which are exported or referenced by those exports. + */ + inputPaths: string[]; + /** + * Absolute path to the output directory where the summary .d.ts files should be written + */ + outputDir: string; + /** + * Repo-relative path to the package source, for example `packages/kbn-type-summarizer` for + * this package. This is used to provide the correct `sourceRoot` path in the resulting source + * map files. + */ + repoRelativePackageDir: string; + /** + * Should the printer throw an error if it doesn't know how to print an AST node? Primarily + * used for testing + */ + strictPrinting?: boolean; +} + +/** + * Produce summary .d.ts files for a package + */ +export async function summarizePackage(log: Logger, options: SummarizePacakgeOptions) { + const tsConfig = loadTsConfigFile(options.tsconfigPath); + log.verbose('Created tsconfig', tsConfig); + + if (tsConfig.options.sourceRoot) { + throw new Error(`${options.tsconfigPath} must not define "compilerOptions.sourceRoot"`); + } + + const program = createTsProject(tsConfig, options.inputPaths); + log.verbose('Loaded typescript program'); + + const typeChecker = program.getTypeChecker(); + log.verbose('Typechecker loaded'); + + const sourceFiles = program + .getSourceFiles() + .filter((f) => !isNodeModule(options.dtsDir, f.fileName)) + .sort((a, b) => a.fileName.localeCompare(b.fileName)); + + const sourceMapper = await SourceMapper.forSourceFiles( + log, + options.dtsDir, + options.repoRelativePackageDir, + sourceFiles + ); + + // value that will end up as the `sourceRoot` in the final sourceMaps + const sourceRoot = `../../../${normalizePath(options.repoRelativePackageDir)}`; + + for (const input of options.inputPaths) { + const outputPath = Path.resolve(options.outputDir, Path.basename(input)); + const mapOutputPath = `${outputPath}.map`; + const sourceFile = program.getSourceFile(input); + if (!sourceFile) { + throw new Error(`input file wasn't included in the program`); + } + + const results = new ExportCollector( + log, + typeChecker, + sourceFile, + options.dtsDir, + sourceMapper + ).run(); + + const printer = new Printer( + sourceMapper, + results.getAll(), + outputPath, + mapOutputPath, + sourceRoot, + !!options.strictPrinting + ); + + const summary = await printer.print(); + + await Fsp.mkdir(options.outputDir, { recursive: true }); + await Fsp.writeFile(outputPath, summary.code); + await Fsp.writeFile(mapOutputPath, JSON.stringify(summary.map)); + + sourceMapper.close(); + } +} diff --git a/packages/kbn-type-summarizer/tests/integration_helpers.ts b/packages/kbn-type-summarizer/tests/integration_helpers.ts new file mode 100644 index 00000000000000..68e1f3cc3a3b0f --- /dev/null +++ b/packages/kbn-type-summarizer/tests/integration_helpers.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable no-console */ + +import Path from 'path'; +import Fsp from 'fs/promises'; + +import * as ts from 'typescript'; +import stripAnsi from 'strip-ansi'; + +import { loadTsConfigFile } from '../src/lib/tsconfig_file'; +import { createTsProject } from '../src/lib/ts_project'; +import { TestLog } from '../src/lib/log'; +import { summarizePackage } from '../src/summarize_package'; + +const TMP_DIR = Path.resolve(__dirname, '__tmp__'); + +const DIAGNOSTIC_HOST = { + getCanonicalFileName: (p: string) => p, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => '\n', +}; + +function dedent(string: string) { + const lines = string.split('\n'); + while (lines.length && lines[0].trim() === '') { + lines.shift(); + } + if (lines.length === 0) { + return ''; + } + const indent = lines[0].split('').findIndex((c) => c !== ' '); + return lines.map((l) => l.slice(indent)).join('\n'); +} + +function ensureDts(path: string) { + if (path.endsWith('.d.ts')) { + throw new Error('path should end with .ts, not .d.ts'); + } + return `${path.slice(0, -3)}.d.ts`; +} + +interface Options { + /* Other files which should be available to the test execution */ + otherFiles?: Record; +} + +class MockCli { + /* file contents which will be fed into TypeScript for this test */ + public readonly mockFiles: Record; + + /* directory where mockFiles pretend to be from */ + public readonly sourceDir = Path.resolve(TMP_DIR, 'src'); + /* directory where we will write .d.ts versions of mockFiles */ + public readonly dtsOutputDir = Path.resolve(TMP_DIR, 'dist_dts'); + /* directory where output will be written */ + public readonly outputDir = Path.resolve(TMP_DIR, 'dts'); + /* path where the tsconfig.json file will be written */ + public readonly tsconfigPath = Path.resolve(this.sourceDir, 'tsconfig.json'); + + /* .d.ts file which we will read to discover the types we need to summarize */ + public readonly inputPath = ensureDts(Path.resolve(this.dtsOutputDir, 'index.ts')); + /* the location we will write the summarized .d.ts file */ + public readonly outputPath = Path.resolve(this.outputDir, Path.basename(this.inputPath)); + /* the location we will write the sourcemaps for the summaried .d.ts file */ + public readonly mapOutputPath = `${this.outputPath}.map`; + + constructor(tsContent: string, options?: Options) { + this.mockFiles = { + ...options?.otherFiles, + 'index.ts': tsContent, + }; + } + + private buildDts() { + const program = createTsProject( + loadTsConfigFile(this.tsconfigPath), + Object.keys(this.mockFiles).map((n) => Path.resolve(this.sourceDir, n)) + ); + + this.printDiagnostics(`dts/config`, program.getConfigFileParsingDiagnostics()); + this.printDiagnostics(`dts/global`, program.getGlobalDiagnostics()); + this.printDiagnostics(`dts/options`, program.getOptionsDiagnostics()); + this.printDiagnostics(`dts/semantic`, program.getSemanticDiagnostics()); + this.printDiagnostics(`dts/syntactic`, program.getSyntacticDiagnostics()); + this.printDiagnostics(`dts/declaration`, program.getDeclarationDiagnostics()); + + const result = program.emit(undefined, undefined, undefined, true); + this.printDiagnostics('dts/results', result.diagnostics); + } + + private printDiagnostics(type: string, diagnostics: readonly ts.Diagnostic[]) { + const errors = diagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error); + if (!errors.length) { + return; + } + + const message = ts.formatDiagnosticsWithColorAndContext(errors, DIAGNOSTIC_HOST); + + console.error( + `TS Errors (${type}):\n${message + .split('\n') + .map((l) => ` ${l}`) + .join('\n')}` + ); + } + + async run() { + const log = new TestLog('debug'); + + // wipe out the tmp dir + await Fsp.rm(TMP_DIR, { recursive: true, force: true }); + + // write mock files to the filesystem + await Promise.all( + Object.entries(this.mockFiles).map(async ([rel, content]) => { + const path = Path.resolve(this.sourceDir, rel); + await Fsp.mkdir(Path.dirname(path), { recursive: true }); + await Fsp.writeFile(path, dedent(content)); + }) + ); + + // write tsconfig.json to the filesystem + await Fsp.writeFile( + this.tsconfigPath, + JSON.stringify({ + include: [`**/*.ts`, `**/*.tsx`], + compilerOptions: { + moduleResolution: 'node', + target: 'es2021', + module: 'CommonJS', + strict: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + declaration: true, + emitDeclarationOnly: true, + declarationDir: '../dist_dts', + declarationMap: true, + // prevent loading all @types packages + typeRoots: [], + }, + }) + ); + + // convert the source files to .d.ts files + this.buildDts(); + + // summarize the .d.ts files into the output dir + await summarizePackage(log, { + dtsDir: this.dtsOutputDir, + inputPaths: [this.inputPath], + outputDir: this.outputDir, + repoRelativePackageDir: 'src', + tsconfigPath: this.tsconfigPath, + strictPrinting: false, + }); + + // return the results + return { + code: await Fsp.readFile(this.outputPath, 'utf8'), + map: JSON.parse(await Fsp.readFile(this.mapOutputPath, 'utf8')), + logs: stripAnsi(log.messages.join('')), + }; + } +} + +export async function run(tsContent: string, options?: Options) { + const project = new MockCli(tsContent, options); + return await project.run(); +} diff --git a/packages/kbn-type-summarizer/tests/integration_tests/class.test.ts b/packages/kbn-type-summarizer/tests/integration_tests/class.test.ts new file mode 100644 index 00000000000000..84c1ee80c5f166 --- /dev/null +++ b/packages/kbn-type-summarizer/tests/integration_tests/class.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { run } from '../integration_helpers'; + +it('prints basic class correctly', async () => { + const output = await run(` + /** + * Interface for writin records to a database + */ + interface Db { + write(record: Record): Promise + } + + export class Foo { + /** + * The name of the Foo + */ + public readonly name: string + constructor(name: string) { + this.name = name.toLowerCase() + } + + speak() { + alert('hi, my name is ' + this.name) + } + + async save(db: Db) { + await db.write({ + name: this.name + }) + } + } + `); + + expect(output.code).toMatchInlineSnapshot(` + "/** + * Interface for writin records to a database + */ + interface Db { + write(record: Record): Promise; + } + export class Foo { + /** + * The name of the Foo + */ + readonly name: string; + constructor(name: string); + speak(): void; + save(db: Db): Promise; + } + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";;;UAGU,E;;;aAIG,G;;;;WAIK,I;;EAKhB,K;EAIM,I", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/index.d.ts + debug Ignoring 1 global declarations for \\"Record\\" + debug Ignoring 5 global declarations for \\"Promise\\" + " + `); +}); diff --git a/packages/kbn-type-summarizer/tests/integration_tests/function.test.ts b/packages/kbn-type-summarizer/tests/integration_tests/function.test.ts new file mode 100644 index 00000000000000..6afc04afe8faad --- /dev/null +++ b/packages/kbn-type-summarizer/tests/integration_tests/function.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { run } from '../integration_helpers'; + +it('prints the function declaration, including comments', async () => { + const result = await run( + ` + import { Bar } from './bar'; + + /** + * Convert a Bar to a string + */ + export function foo( + /** + * Important comment + */ + name: Bar + ) { + return name.toString(); + } + `, + { + otherFiles: { + 'bar.ts': ` + export class Bar { + constructor( + private value: T + ){} + + toString() { + return this.value.toString() + } + } + `, + }, + } + ); + + expect(result.code).toMatchInlineSnapshot(` + "class Bar { + private value; + constructor(value: T); + toString(): string; + } + /** + * Convert a Bar to a string + */ + export function foo( + /** + * Important comment + */ + name: Bar): string; + //# sourceMappingURL=index.d.ts.map" + `); + expect(result.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "MAAa,G;;;UAED,K;;EAGV,Q;;;;;gBCAc,G", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "bar.ts", + "index.ts", + ], + "version": 3, + } + `); + expect(result.logs).toMatchInlineSnapshot(` + "debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/bar.d.ts + debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/index.d.ts + " + `); +}); diff --git a/packages/kbn-type-summarizer/tests/integration_tests/import_boundary.test.ts b/packages/kbn-type-summarizer/tests/integration_tests/import_boundary.test.ts new file mode 100644 index 00000000000000..f23b6c3656d508 --- /dev/null +++ b/packages/kbn-type-summarizer/tests/integration_tests/import_boundary.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { run } from '../integration_helpers'; + +const nodeModules = { + 'node_modules/foo/index.ts': ` + export class Foo { + render() { + return 'hello' + } + } + `, + 'node_modules/bar/index.ts': ` + export default class Bar { + render() { + return 'hello' + } + } + `, +}; + +it('output type links to named import from node modules', async () => { + const output = await run( + ` + import { Foo } from 'foo' + export type ValidName = string | Foo + `, + { otherFiles: nodeModules } + ); + + expect(output.code).toMatchInlineSnapshot(` + "import { Foo } from 'foo'; + export type ValidName = string | Foo + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";YACY,S", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/index.d.ts + " + `); +}); + +it('output type links to default import from node modules', async () => { + const output = await run( + ` + import Bar from 'bar' + export type ValidName = string | Bar + `, + { otherFiles: nodeModules } + ); + + expect(output.code).toMatchInlineSnapshot(` + "import { Bar } from 'bar'; + export type ValidName = string | Bar + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";YACY,S", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/index.d.ts + " + `); +}); diff --git a/packages/kbn-type-summarizer/tests/integration_tests/interface.test.ts b/packages/kbn-type-summarizer/tests/integration_tests/interface.test.ts new file mode 100644 index 00000000000000..da53e91302eef0 --- /dev/null +++ b/packages/kbn-type-summarizer/tests/integration_tests/interface.test.ts @@ -0,0 +1,62 @@ +/* + * 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 { run } from '../integration_helpers'; + +it('prints the whole interface, including comments', async () => { + const result = await run(` + /** + * This is an interface + */ + export interface Foo { + /** + * method + */ + name(): string + + /** + * hello + */ + close(): Promise + } + `); + + expect(result.code).toMatchInlineSnapshot(` + "/** + * This is an interface + */ + export interface Foo { + /** + * method + */ + name(): string; + /** + * hello + */ + close(): Promise; + } + //# sourceMappingURL=index.d.ts.map" + `); + expect(result.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";;;iBAGiB,G", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(result.logs).toMatchInlineSnapshot(` + "debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/index.d.ts + debug Ignoring 5 global declarations for \\"Promise\\" + " + `); +}); diff --git a/packages/kbn-type-summarizer/tests/integration_tests/references.test.ts b/packages/kbn-type-summarizer/tests/integration_tests/references.test.ts new file mode 100644 index 00000000000000..1733b43694000d --- /dev/null +++ b/packages/kbn-type-summarizer/tests/integration_tests/references.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { run } from '../integration_helpers'; + +it('collects references from source files which contribute to result', async () => { + const result = await run( + ` + /// + export type PromiseOfString = Promise<'string'> + export * from './files' + `, + { + otherFiles: { + 'files/index.ts': ` + /// + export type MySymbol = Symbol & { __tag: 'MySymbol' } + export * from './foo' + `, + 'files/foo.ts': ` + /// + interface Props {} + export type MyComponent = React.Component + `, + }, + } + ); + + expect(result.code).toMatchInlineSnapshot(` + "/// + /// + /// + export type PromiseOfString = Promise<'string'> + export type MySymbol = Symbol & { + __tag: 'MySymbol'; + } + interface Props { + } + export type MyComponent = React.Component + //# sourceMappingURL=index.d.ts.map" + `); + expect(result.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";;;YACY,e;YCAA,Q;;;UCAF,K;;YACE,W", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + "files/index.ts", + "files/foo.ts", + ], + "version": 3, + } + `); + expect(result.logs).toMatchInlineSnapshot(` + "debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/files/foo.d.ts + debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/files/index.d.ts + debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/index.d.ts + debug Ignoring 5 global declarations for \\"Promise\\" + debug Ignoring 4 global declarations for \\"Symbol\\" + debug Ignoring 2 global declarations for \\"Component\\" + debug Ignoring 1 global declarations for \\"React\\" + " + `); +}); diff --git a/packages/kbn-type-summarizer/tests/integration_tests/type_alias.test.ts b/packages/kbn-type-summarizer/tests/integration_tests/type_alias.test.ts new file mode 100644 index 00000000000000..79c2ea69b94777 --- /dev/null +++ b/packages/kbn-type-summarizer/tests/integration_tests/type_alias.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { run } from '../integration_helpers'; + +it('prints basic type alias', async () => { + const output = await run(` + export type Name = 'foo' | string + + function hello(name: Name) { + console.log('hello', name) + } + + hello('john') + `); + + expect(output.code).toMatchInlineSnapshot(` + "export type Name = 'foo' | string + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "YAAY,I", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/index.d.ts + " + `); +}); diff --git a/packages/kbn-type-summarizer/tests/integration_tests/variables.test.ts b/packages/kbn-type-summarizer/tests/integration_tests/variables.test.ts new file mode 100644 index 00000000000000..daa6abcc34c594 --- /dev/null +++ b/packages/kbn-type-summarizer/tests/integration_tests/variables.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { run } from '../integration_helpers'; + +it('prints basic variable exports with sourcemaps', async () => { + const output = await run(` + /** + * What is a type + */ + type Type = 'bar' | 'baz' + + /** some comment */ + export const bar: Type = 'bar' + + export var + /** + * checkout bar + */ + baz: Type = 'baz', + /** + * this is foo + */ + foo: Type = 'bar' + + export let types = [bar, baz, foo] + `); + + expect(output.code).toMatchInlineSnapshot(` + "/** + * What is a type + */ + type Type = 'bar' | 'baz' + /** some comment */ + export const bar: Type; + /** + * checkout bar + */ + export var baz: Type; + /** + * this is foo + */ + export var foo: Type; + export let types: (\\"bar\\" | \\"baz\\")[]; + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";;;KAGK,I;;aAGQ,G;;;;WAMX,G;;;;WAIA,G;WAES,K", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemap for packages/kbn-type-summarizer/tests/__tmp__/dist_dts/index.d.ts + " + `); +}); diff --git a/packages/kbn-type-summarizer/tsconfig.json b/packages/kbn-type-summarizer/tsconfig.json new file mode 100644 index 00000000000000..f3c3802071ac46 --- /dev/null +++ b/packages/kbn-type-summarizer/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "outDir": "target_types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "src/**/*", + "tests/**/*" + ] +} diff --git a/scripts/build_type_summarizer_output.js b/scripts/build_type_summarizer_output.js new file mode 100644 index 00000000000000..619c8db5d2d059 --- /dev/null +++ b/scripts/build_type_summarizer_output.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +require('../src/setup_node_env/ensure_node_preserve_symlinks'); +require('source-map-support/register'); +require('@kbn/type-summarizer/target_node/bazel_cli'); diff --git a/src/dev/bazel/index.bzl b/src/dev/bazel/index.bzl index fcd4212bd5329b..cca81dfcbcd5ae 100644 --- a/src/dev/bazel/index.bzl +++ b/src/dev/bazel/index.bzl @@ -12,7 +12,7 @@ Please do not import from any other files when looking to use a custom rule load("//src/dev/bazel:jsts_transpiler.bzl", _jsts_transpiler = "jsts_transpiler") load("//src/dev/bazel:pkg_npm.bzl", _pkg_npm = "pkg_npm") -load("//src/dev/bazel/pkg_npm_types:index.bzl", _pkg_npm_types = "pkg_npm_types") +load("//src/dev/bazel:pkg_npm_types.bzl", _pkg_npm_types = "pkg_npm_types") load("//src/dev/bazel:ts_project.bzl", _ts_project = "ts_project") jsts_transpiler = _jsts_transpiler diff --git a/src/dev/bazel/pkg_npm_types/pkg_npm_types.bzl b/src/dev/bazel/pkg_npm_types.bzl similarity index 83% rename from src/dev/bazel/pkg_npm_types/pkg_npm_types.bzl rename to src/dev/bazel/pkg_npm_types.bzl index ed48228bc95871..e5caba51490538 100644 --- a/src/dev/bazel/pkg_npm_types/pkg_npm_types.bzl +++ b/src/dev/bazel/pkg_npm_types.bzl @@ -72,32 +72,22 @@ def _pkg_npm_types_impl(ctx): inputs = ctx.files.srcs[:] inputs.extend(tsconfig_inputs) inputs.extend(deps_inputs) - inputs.append(ctx.file._generated_package_json_template) # output dir declaration package_path = ctx.label.package package_dir = ctx.actions.declare_directory(ctx.label.name) outputs = [package_dir] - # gathering template args - template_args = [ - "NAME", _get_type_package_name(ctx.attr.package_name) - ] - # layout api extractor arguments extractor_args = ctx.actions.args() - ## general args layout - ### [0] = base output dir - ### [1] = generated package json template input file path - ### [2] = stringified template args - ### [3] = tsconfig input file path - ### [4] = entry point from provided types to summarise - extractor_args.add(package_dir.path) - extractor_args.add(ctx.file._generated_package_json_template.path) - extractor_args.add_joined(template_args, join_with = ",", omit_if_empty = False) - extractor_args.add(tsconfig_inputs[0]) - extractor_args.add(_calculate_entrypoint_path(ctx)) + extractor_args.add(struct( + packageName = ctx.attr.package_name, + outputDir = package_dir.path, + buildFilePath = ctx.build_file_path, + tsconfigPath = tsconfig_inputs[0].path, + inputPath = _calculate_entrypoint_path(ctx), + ).to_json()) run_node( ctx, @@ -141,7 +131,9 @@ pkg_npm_types = rule( doc = """Entrypoint name of the types files group to summarise""", default = "index.d.ts", ), - "package_name": attr.string(), + "package_name": attr.string( + mandatory = True + ), "srcs": attr.label_list( doc = """Files inside this directory which are inputs for the types to summarise.""", allow_files = True, @@ -151,11 +143,7 @@ pkg_npm_types = rule( doc = "Target that executes the npm types package assembler binary", executable = True, cfg = "host", - default = Label("//src/dev/bazel/pkg_npm_types:_packager"), - ), - "_generated_package_json_template": attr.label( - allow_single_file = True, - default = "package_json.mustache", + default = Label("//packages/kbn-type-summarizer:bazel-cli"), ), }, ) diff --git a/src/dev/bazel/pkg_npm_types/BUILD.bazel b/src/dev/bazel/pkg_npm_types/BUILD.bazel deleted file mode 100644 index f30d0f8cb8324a..00000000000000 --- a/src/dev/bazel/pkg_npm_types/BUILD.bazel +++ /dev/null @@ -1,28 +0,0 @@ -package(default_visibility = ["//visibility:public"]) - -load("@build_bazel_rules_nodejs//internal/node:node.bzl", "nodejs_binary") - -filegroup( - name = "packager_all_files", - srcs = glob([ - "packager/*", - ]), -) - -exports_files( - [ - "package_json.mustache", - ], - visibility = ["//visibility:public"] -) - -nodejs_binary( - name = "_packager", - data = [ - "@npm//@bazel/typescript", - "@npm//@microsoft/api-extractor", - "@npm//mustache", - ":packager_all_files" - ], - entry_point = ":packager/index.js", -) diff --git a/src/dev/bazel/pkg_npm_types/index.bzl b/src/dev/bazel/pkg_npm_types/index.bzl deleted file mode 100644 index 578ecdd885d158..00000000000000 --- a/src/dev/bazel/pkg_npm_types/index.bzl +++ /dev/null @@ -1,15 +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. -# - -"""Public API interface for pkg_npm_types rule. -Please do not import from any other files when looking to this rule -""" - -load(":pkg_npm_types.bzl", _pkg_npm_types = "pkg_npm_types") - -pkg_npm_types = _pkg_npm_types diff --git a/src/dev/bazel/pkg_npm_types/package_json.mustache b/src/dev/bazel/pkg_npm_types/package_json.mustache deleted file mode 100644 index 2229345252e3f2..00000000000000 --- a/src/dev/bazel/pkg_npm_types/package_json.mustache +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "{{{NAME}}}", - "description": "Generated by Bazel", - "types": "./index.d.ts", - "private": true, - "license": "MIT", - "version": "1.1.0" -} diff --git a/src/dev/bazel/pkg_npm_types/packager/create_api_extraction.js b/src/dev/bazel/pkg_npm_types/packager/create_api_extraction.js deleted file mode 100644 index d5f7e0c33ff1cf..00000000000000 --- a/src/dev/bazel/pkg_npm_types/packager/create_api_extraction.js +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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. - */ - -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -const { format, parseTsconfig } = require('@bazel/typescript'); -const { Extractor, ExtractorConfig } = require('@microsoft/api-extractor'); -const fs = require('fs'); -const path = require('path'); - -function createApiExtraction( - tsConfig, - entryPoint, - dtsBundleOut, - apiReviewFolder, - acceptApiUpdates = false -) { - const [parsedConfig, errors] = parseTsconfig(tsConfig); - if (errors && errors.length) { - console.error(format('', errors)); - return 1; - } - const pkgJson = path.resolve(path.dirname(entryPoint), 'package.json'); - if (!fs.existsSync(pkgJson)) { - fs.writeFileSync( - pkgJson, - JSON.stringify({ - name: 'GENERATED-BY-BAZEL', - description: 'This is a dummy package.json as API Extractor always requires one.', - types: './index.d.ts', - private: true, - license: 'SSPL-1.0 OR Elastic License 2.0', - version: '1.0.0', - }) - ); - } - // API extractor doesn't always support the version of TypeScript used in the repo - // example: at the moment it is not compatable with 3.2 - // to use the internal TypeScript we shall not create a program but rather pass a parsed tsConfig. - const parsedTsConfig = parsedConfig.config; - const extractorOptions = { - localBuild: acceptApiUpdates, - }; - const configObject = { - compiler: { - overrideTsconfig: parsedTsConfig, - }, - projectFolder: path.resolve(path.dirname(tsConfig)), - mainEntryPointFilePath: path.resolve(entryPoint), - apiReport: { - enabled: !!apiReviewFolder, - // TODO(alan-agius4): remove this folder name when the below issue is solved upstream - // See: https://github.com/microsoft/web-build-tools/issues/1470 - reportFileName: (apiReviewFolder && path.resolve(apiReviewFolder)) || 'invalid', - }, - docModel: { - enabled: false, - }, - dtsRollup: { - enabled: !!dtsBundleOut, - untrimmedFilePath: dtsBundleOut && path.resolve(dtsBundleOut), - }, - tsdocMetadata: { - enabled: false, - }, - }; - const options = { - configObject, - packageJson: undefined, - packageJsonFullPath: pkgJson, - configObjectFullPath: undefined, - }; - const extractorConfig = ExtractorConfig.prepare(options); - const { succeeded } = Extractor.invoke(extractorConfig, extractorOptions); - // API extractor errors are emitted by it's logger. - return succeeded ? 0 : 1; -} - -module.exports.createApiExtraction = createApiExtraction; diff --git a/src/dev/bazel/pkg_npm_types/packager/generate_package_json.js b/src/dev/bazel/pkg_npm_types/packager/generate_package_json.js deleted file mode 100644 index d4a478a262e5bc..00000000000000 --- a/src/dev/bazel/pkg_npm_types/packager/generate_package_json.js +++ /dev/null @@ -1,43 +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. - */ - -const fs = require('fs'); -const Mustache = require('mustache'); -const path = require('path'); - -function generatePackageJson(outputBasePath, packageJsonTemplatePath, rawPackageJsonTemplateArgs) { - const packageJsonTemplateArgsInTuples = rawPackageJsonTemplateArgs.reduce( - (a, v) => { - const lastTupleIdx = a.length - 1; - const lastTupleSize = a[lastTupleIdx].length; - - if (lastTupleSize < 2) { - a[lastTupleIdx].push(v); - - return a; - } - - return a.push([v]); - }, - [[]] - ); - const packageJsonTemplateArgs = Object.fromEntries(new Map(packageJsonTemplateArgsInTuples)); - - try { - const template = fs.readFileSync(packageJsonTemplatePath); - const renderedTemplate = Mustache.render(template.toString(), packageJsonTemplateArgs); - fs.writeFileSync(path.resolve(outputBasePath, 'package.json'), renderedTemplate); - } catch (e) { - console.error(e); - return 1; - } - - return 0; -} - -module.exports.generatePackageJson = generatePackageJson; diff --git a/src/dev/bazel/pkg_npm_types/packager/index.js b/src/dev/bazel/pkg_npm_types/packager/index.js deleted file mode 100644 index cda299a99d76fc..00000000000000 --- a/src/dev/bazel/pkg_npm_types/packager/index.js +++ /dev/null @@ -1,46 +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. - */ - -const { createApiExtraction } = require('./create_api_extraction'); -const { generatePackageJson } = require('./generate_package_json'); -const path = require('path'); - -const DEBUG = false; - -if (require.main === module) { - if (DEBUG) { - console.error(` -pkg_npm_types packager: running with - cwd: ${process.cwd()} - argv: - ${process.argv.join('\n ')} - `); - } - - // layout args - const [ - outputBasePath, - packageJsonTemplatePath, - stringifiedPackageJsonTemplateArgs, - tsConfig, - entryPoint, - ] = process.argv.slice(2); - const dtsBundleOutput = path.resolve(outputBasePath, 'index.d.ts'); - - // generate pkg json output - const generatePackageJsonRValue = generatePackageJson( - outputBasePath, - packageJsonTemplatePath, - stringifiedPackageJsonTemplateArgs.split(',') - ); - // create api extraction output - const createApiExtractionRValue = createApiExtraction(tsConfig, entryPoint, dtsBundleOutput); - - // setup correct exit code - process.exitCode = generatePackageJsonRValue || createApiExtractionRValue; -} diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index b4d350c44174cb..6b47d9b805af79 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -82,4 +82,5 @@ export const PROJECTS = [ ...findProjects('test/plugin_functional/plugins/*/tsconfig.json'), ...findProjects('test/interpreter_functional/plugins/*/tsconfig.json'), ...findProjects('test/server_integration/__fixtures__/plugins/*/tsconfig.json'), + ...findProjects('packages/kbn-type-summarizer/tests/tsconfig.json'), ]; diff --git a/yarn.lock b/yarn.lock index 2edd9eb503dfab..753a9e5d9805c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4047,6 +4047,10 @@ version "0.0.0" uid "" +"@kbn/type-summarizer@link:bazel-bin/packages/kbn-type-summarizer": + version "0.0.0" + uid "" + "@kbn/typed-react-router-config@link:bazel-bin/packages/kbn-typed-react-router-config": version "0.0.0" uid "" From e694507960d61def6ebbde146a6cb296b1a74c96 Mon Sep 17 00:00:00 2001 From: Andrew Tate Date: Fri, 4 Mar 2022 08:13:16 -0600 Subject: [PATCH 12/33] [Lens] allow user to disable auto apply (#125158) --- .../lens/public/app_plugin/app.test.tsx | 12 + x-pack/plugins/lens/public/app_plugin/app.tsx | 3 + .../config_panel/dimension_container.tsx | 24 +- .../editor_frame/data_panel_wrapper.test.tsx | 87 ++++ .../editor_frame/data_panel_wrapper.tsx | 8 +- .../editor_frame/editor_frame.tsx | 2 +- .../editor_frame/frame_layout.scss | 1 - .../editor_frame/suggestion_helpers.ts | 11 +- .../editor_frame/suggestion_panel.scss | 4 + .../editor_frame/suggestion_panel.test.tsx | 73 ++- .../editor_frame/suggestion_panel.tsx | 135 ++++-- .../workspace_panel/chart_switch.test.tsx | 2 + .../workspace_panel/chart_switch.tsx | 2 +- .../workspace_panel/workspace_panel.test.tsx | 436 ++++++++++++++---- .../workspace_panel/workspace_panel.tsx | 57 ++- .../workspace_panel_wrapper.scss | 9 + .../workspace_panel_wrapper.test.tsx | 148 ++++++ .../workspace_panel_wrapper.tsx | 136 ++++-- .../indexpattern_datasource/datapanel.tsx | 2 +- .../indexpattern_datasource/indexpattern.tsx | 2 +- .../indexpattern_datasource/loader.test.ts | 13 +- .../public/indexpattern_datasource/loader.ts | 89 ++-- .../plugins/lens/public/settings_storage.tsx | 2 +- .../shared_components/axis_title_settings.tsx | 11 +- .../context_middleware/index.test.ts | 50 +- .../context_middleware/index.ts | 17 +- .../lens/public/state_management/index.ts | 3 + .../state_management/init_middleware/index.ts | 9 +- .../init_middleware/load_initial.ts | 12 +- .../state_management/lens_slice.test.ts | 60 ++- .../public/state_management/lens_slice.ts | 24 + .../public/state_management/selectors.test.ts | 49 ++ .../lens/public/state_management/selectors.ts | 10 + .../lens/public/state_management/types.ts | 3 + x-pack/plugins/lens/public/types.ts | 26 +- .../xy_config_panel/axis_settings_popover.tsx | 3 +- x-pack/plugins/lens/server/usage/schema.ts | 6 + .../schema/xpack_plugins.json | 12 + .../apps/lens/disable_auto_apply.ts | 99 ++++ x-pack/test/functional/apps/lens/index.ts | 1 + .../test/functional/page_objects/lens_page.ts | 28 ++ 41 files changed, 1430 insertions(+), 251 deletions(-) create mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.test.tsx create mode 100644 x-pack/plugins/lens/public/state_management/selectors.test.ts create mode 100644 x-pack/test/functional/apps/lens/disable_auto_apply.ts diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index b16afbfc56a4ab..c4e82aca9ad454 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -635,6 +635,18 @@ describe('Lens App', () => { ); }); + it('applies all changes on-save', async () => { + const { lensStore } = await save({ + initialSavedObjectId: undefined, + newCopyOnSave: false, + newTitle: 'hello there', + preloadedState: { + applyChangesCounter: 0, + }, + }); + expect(lensStore.getState().lens.applyChangesCounter).toBe(1); + }); + it('adds to the recently accessed list on save', async () => { const { services } = await save({ initialSavedObjectId: undefined, diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 5ef9e05cf590b0..6312225af579b6 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -24,6 +24,7 @@ import { Document } from '../persistence/saved_object_store'; import { setState, + applyChanges, useLensSelector, useLensDispatch, LensAppState, @@ -276,6 +277,7 @@ export function App({ const runSave = useCallback( (saveProps: SaveProps, options: { saveToLibrary: boolean }) => { + dispatch(applyChanges()); return runSaveLensVisualization( { lastKnownDoc, @@ -316,6 +318,7 @@ export function App({ redirectTo, lensAppServices, dispatchSetState, + dispatch, setIsSaveModalVisible, ] ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index f7402e78ebd96f..5bc6a69b2efaf1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -24,6 +24,26 @@ import { import { i18n } from '@kbn/i18n'; +/** + * The dimension container is set up to close when it detects a click outside it. + * Use this CSS class to exclude particular elements from this behavior. + */ +export const DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS = + 'lensDontCloseDimensionContainerOnClick'; + +function fromExcludedClickTarget(event: Event) { + for ( + let node: HTMLElement | null = event.target as HTMLElement; + node !== null; + node = node!.parentElement + ) { + if (node.classList!.contains(DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS)) { + return true; + } + } + return false; +} + export function DimensionContainer({ isOpen, groupLabel, @@ -77,8 +97,8 @@ export function DimensionContainer({ { - if (isFullscreen) { + onOutsideClick={(event) => { + if (isFullscreen || fromExcludedClickTarget(event)) { return; } closeFlyout(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.test.tsx new file mode 100644 index 00000000000000..64656a2eedf63c --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { DataPanelWrapper } from './data_panel_wrapper'; +import { Datasource, DatasourceDataPanelProps } from '../../types'; +import { DragDropIdentifier } from '../../drag_drop'; +import { UiActionsStart } from 'src/plugins/ui_actions/public'; +import { mockStoreDeps, mountWithProvider } from '../../mocks'; +import { disableAutoApply } from '../../state_management/lens_slice'; +import { selectTriggerApplyChanges } from '../../state_management'; + +describe('Data Panel Wrapper', () => { + describe('Datasource data panel properties', () => { + let datasourceDataPanelProps: DatasourceDataPanelProps; + let lensStore: Awaited>['lensStore']; + beforeEach(async () => { + const renderDataPanel = jest.fn(); + + const datasourceMap = { + activeDatasource: { + renderDataPanel, + } as unknown as Datasource, + }; + + const mountResult = await mountWithProvider( + {}} + core={{} as DatasourceDataPanelProps['core']} + dropOntoWorkspace={(field: DragDropIdentifier) => {}} + hasSuggestionForField={(field: DragDropIdentifier) => true} + plugins={{ uiActions: {} as UiActionsStart }} + />, + { + preloadedState: { + activeDatasourceId: 'activeDatasource', + datasourceStates: { + activeDatasource: { + isLoading: false, + state: { + age: 'old', + }, + }, + }, + }, + storeDeps: mockStoreDeps({ datasourceMap }), + } + ); + + lensStore = mountResult.lensStore; + + datasourceDataPanelProps = renderDataPanel.mock.calls[0][1] as DatasourceDataPanelProps; + }); + + describe('setState', () => { + it('applies state immediately when option true', async () => { + lensStore.dispatch(disableAutoApply()); + selectTriggerApplyChanges(lensStore.getState()); + + const newDatasourceState = { age: 'new' }; + datasourceDataPanelProps.setState(newDatasourceState, { applyImmediately: true }); + + expect(lensStore.getState().lens.datasourceStates.activeDatasource.state).toEqual( + newDatasourceState + ); + expect(selectTriggerApplyChanges(lensStore.getState())).toBeTruthy(); + }); + + it('does not apply state immediately when option false', async () => { + lensStore.dispatch(disableAutoApply()); + selectTriggerApplyChanges(lensStore.getState()); + + const newDatasourceState = { age: 'new' }; + datasourceDataPanelProps.setState(newDatasourceState, { applyImmediately: false }); + + const lensState = lensStore.getState().lens; + expect(lensState.datasourceStates.activeDatasource.state).toEqual(newDatasourceState); + expect(selectTriggerApplyChanges(lensStore.getState())).toBeFalsy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index b77d313973432c..17f3d385123c20 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -20,6 +20,7 @@ import { updateDatasourceState, useLensSelector, setState, + applyChanges, selectExecutionContext, selectActiveDatasourceId, selectDatasourceStates, @@ -45,8 +46,8 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { : true; const dispatchLens = useLensDispatch(); - const setDatasourceState: StateSetter = useMemo(() => { - return (updater) => { + const setDatasourceState: StateSetter = useMemo(() => { + return (updater: unknown | ((prevState: unknown) => unknown), options) => { dispatchLens( updateDatasourceState({ updater, @@ -54,6 +55,9 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { clearStagedPreview: true, }) ); + if (options?.applyImmediately) { + dispatchLens(applyChanges()); + } }; }, [activeDatasourceId, dispatchLens]); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index f2e4af61ddbdb6..7f1c673d0d1ddc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -79,7 +79,7 @@ export function EditorFrame(props: EditorFrameProps) { const suggestion = getSuggestionForField.current!(field); if (suggestion) { trackUiEvent('drop_onto_workspace'); - switchToSuggestion(dispatchLens, suggestion, true); + switchToSuggestion(dispatchLens, suggestion, { clearStagedPreview: true }); } }, [getSuggestionForField, dispatchLens] diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss index c8c0a6e2ebbd22..b49c77bb8b4198 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss @@ -73,7 +73,6 @@ a tilemap in an iframe: https://github.com/elastic/kibana/issues/16457 */ } &.lnsFrameLayout__pageBody-isFullscreen { - background: $euiColorEmptyShade; flex: 1; padding: 0; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index b8ce851f25349c..c9d237961b4752 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -27,6 +27,7 @@ import { switchVisualization, DatasourceStates, VisualizationState, + applyChanges, } from '../../state_management'; /** @@ -232,7 +233,10 @@ export function switchToSuggestion( Suggestion, 'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId' >, - clearStagedPreview?: boolean + options?: { + clearStagedPreview?: boolean; + applyImmediately?: boolean; + } ) { dispatchLens( switchVisualization({ @@ -242,9 +246,12 @@ export function switchToSuggestion( datasourceState: suggestion.datasourceState, datasourceId: suggestion.datasourceId!, }, - clearStagedPreview, + clearStagedPreview: options?.clearStagedPreview, }) ); + if (options?.applyImmediately) { + dispatchLens(applyChanges()); + } } export function getTopSuggestionForField( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss index 37a4a88c32f22e..804bfbf11d7406 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss @@ -88,3 +88,7 @@ text-align: center; flex-grow: 0; } + +.lnsSuggestionPanel__applyChangesPrompt { + height: $lnsSuggestionHeight; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index c9ddc0ea6551c7..8d9ea9b3c70b71 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -21,7 +21,21 @@ import { getSuggestions } from './suggestion_helpers'; import { EuiIcon, EuiPanel, EuiToolTip, EuiAccordion } from '@elastic/eui'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; import { mountWithProvider } from '../../mocks'; -import { LensAppState, PreviewState, setState, setToggleFullscreen } from '../../state_management'; +import { + applyChanges, + LensAppState, + PreviewState, + setState, + setToggleFullscreen, + VisualizationState, +} from '../../state_management'; +import { setChangesApplied } from '../../state_management/lens_slice'; + +const SELECTORS = { + APPLY_CHANGES_BUTTON: 'button[data-test-subj="lnsSuggestionApplyChanges"]', + SUGGESTIONS_PANEL: '[data-test-subj="lnsSuggestionsPanel"]', + SUGGESTION_TILE_BUTTON: 'button[data-test-subj="lnsSuggestion"]', +}; jest.mock('./suggestion_helpers'); @@ -108,6 +122,38 @@ describe('suggestion_panel', () => { expect(instance.find(SuggestionPanel).exists()).toBe(true); }); + it('should display apply-changes prompt when changes not applied', async () => { + const { instance, lensStore } = await mountWithProvider(, { + preloadedState: { + ...preloadedState, + visualization: { + ...preloadedState.visualization, + state: { + something: 'changed', + }, + } as VisualizationState, + changesApplied: false, + autoApplyDisabled: true, + }, + }); + + expect(instance.exists(SELECTORS.APPLY_CHANGES_BUTTON)).toBeTruthy(); + expect(instance.exists(SELECTORS.SUGGESTION_TILE_BUTTON)).toBeFalsy(); + + instance.find(SELECTORS.APPLY_CHANGES_BUTTON).simulate('click'); + + // check changes applied + expect(lensStore.dispatch).toHaveBeenCalledWith(applyChanges()); + + // simulate workspace panel behavior + lensStore.dispatch(setChangesApplied(true)); + instance.update(); + + // check UI updated + expect(instance.exists(SELECTORS.APPLY_CHANGES_BUTTON)).toBeFalsy(); + expect(instance.exists(SELECTORS.SUGGESTION_TILE_BUTTON)).toBeTruthy(); + }); + it('should list passed in suggestions', async () => { const { instance } = await mountWithProvider(, { preloadedState, @@ -173,12 +219,12 @@ describe('suggestion_panel', () => { preloadedState, }); act(() => { - instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); + instance.find(SELECTORS.SUGGESTION_TILE_BUTTON).at(2).simulate('click'); }); instance.update(); - expect(instance.find('[data-test-subj="lnsSuggestion"]').at(2).prop('className')).toContain( + expect(instance.find(SELECTORS.SUGGESTION_TILE_BUTTON).at(2).prop('className')).toContain( 'lnsSuggestionPanel__button-isSelected' ); }); @@ -189,13 +235,13 @@ describe('suggestion_panel', () => { ); act(() => { - instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); + instance.find(SELECTORS.SUGGESTION_TILE_BUTTON).at(2).simulate('click'); }); instance.update(); act(() => { - instance.find('[data-test-subj="lnsSuggestion"]').at(0).simulate('click'); + instance.find(SELECTORS.SUGGESTION_TILE_BUTTON).at(0).simulate('click'); }); instance.update(); @@ -203,6 +249,10 @@ describe('suggestion_panel', () => { expect(lensStore.dispatch).toHaveBeenCalledWith({ type: 'lens/rollbackSuggestion', }); + // check that it immediately applied any state changes in case auto-apply disabled + expect(lensStore.dispatch).toHaveBeenLastCalledWith({ + type: applyChanges.type, + }); }); }); @@ -212,7 +262,7 @@ describe('suggestion_panel', () => { }); act(() => { - instance.find('button[data-test-subj="lnsSuggestion"]').at(1).simulate('click'); + instance.find(SELECTORS.SUGGESTION_TILE_BUTTON).at(1).simulate('click'); }); expect(lensStore.dispatch).toHaveBeenCalledWith( @@ -228,6 +278,7 @@ describe('suggestion_panel', () => { }, }) ); + expect(lensStore.dispatch).toHaveBeenLastCalledWith({ type: applyChanges.type }); }); it('should render render icon if there is no preview expression', async () => { @@ -264,10 +315,10 @@ describe('suggestion_panel', () => { preloadedState, }); - expect(instance.find('[data-test-subj="lnsSuggestionsPanel"]').find(EuiIcon)).toHaveLength(1); - expect( - instance.find('[data-test-subj="lnsSuggestionsPanel"]').find(EuiIcon).prop('type') - ).toEqual(LensIconChartDatatable); + expect(instance.find(SELECTORS.SUGGESTIONS_PANEL).find(EuiIcon)).toHaveLength(1); + expect(instance.find(SELECTORS.SUGGESTIONS_PANEL).find(EuiIcon).prop('type')).toEqual( + LensIconChartDatatable + ); }); it('should return no suggestion if visualization has missing index-patterns', async () => { @@ -301,7 +352,7 @@ describe('suggestion_panel', () => { instance.find(EuiAccordion).at(0).simulate('change'); }); - expect(instance.find('[data-test-subj="lnsSuggestionsPanel"]')).toEqual({}); + expect(instance.find(SELECTORS.SUGGESTIONS_PANEL)).toEqual({}); }); it('should render preview expression if there is one', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index f6fccbb831eae2..e6a9831e0aae59 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -19,6 +19,10 @@ import { EuiToolTip, EuiButtonEmpty, EuiAccordion, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiSpacer, } from '@elastic/eui'; import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Ast, toExpression } from '@kbn/interpreter'; @@ -55,7 +59,10 @@ import { selectActiveDatasourceId, selectActiveData, selectDatasourceStates, + selectChangesApplied, + applyChanges, } from '../../state_management'; +import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from './config_panel/dimension_container'; const MAX_SUGGESTIONS_DISPLAYED = 5; const LOCAL_STORAGE_SUGGESTIONS_PANEL = 'LENS_SUGGESTIONS_PANEL_HIDDEN'; @@ -190,6 +197,7 @@ export function SuggestionPanel({ const existsStagedPreview = useLensSelector((state) => Boolean(state.lens.stagedPreview)); const currentVisualization = useLensSelector(selectCurrentVisualization); const currentDatasourceStates = useLensSelector(selectCurrentDatasourceStates); + const changesApplied = useLensSelector(selectChangesApplied); // get user's selection from localStorage, this key defines if the suggestions panel will be hidden or not const [hideSuggestions, setHideSuggestions] = useLocalStorage( LOCAL_STORAGE_SUGGESTIONS_PANEL, @@ -327,9 +335,92 @@ export function SuggestionPanel({ trackSuggestionEvent('back_to_current'); setLastSelectedSuggestion(-1); dispatchLens(rollbackSuggestion()); + dispatchLens(applyChanges()); } } + const applyChangesPrompt = ( + + + +

+ +

+ + dispatchLens(applyChanges())} + data-test-subj="lnsSuggestionApplyChanges" + > + + +
+
+
+ ); + + const suggestionsUI = ( + <> + {currentVisualization.activeId && !hideSuggestions && ( + + )} + {!hideSuggestions && + suggestions.map((suggestion, index) => { + return ( + { + trackUiEvent('suggestion_clicked'); + if (lastSelectedSuggestion === index) { + rollbackToCurrentVisualization(); + } else { + setLastSelectedSuggestion(index); + switchToSuggestion(dispatchLens, suggestion, { applyImmediately: true }); + } + }} + selected={index === lastSelectedSuggestion} + /> + ); + })} + + ); + return (
- {currentVisualization.activeId && !hideSuggestions && ( - - )} - {!hideSuggestions && - suggestions.map((suggestion, index) => { - return ( - { - trackUiEvent('suggestion_clicked'); - if (lastSelectedSuggestion === index) { - rollbackToCurrentVisualization(); - } else { - setLastSelectedSuggestion(index); - switchToSuggestion(dispatchLens, suggestion); - } - }} - selected={index === lastSelectedSuggestion} - /> - ); - })} + {changesApplied ? suggestionsUI : applyChangesPrompt}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx index c325e6d516c8b7..a486b6315c3f46 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx @@ -31,6 +31,7 @@ jest.mock('react-virtualized-auto-sizer', () => { import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../../types'; import { ChartSwitch } from './chart_switch'; import { PaletteOutput } from 'src/plugins/charts/public'; +import { applyChanges } from '../../../state_management'; describe('chart_switch', () => { function generateVisualization(id: string): jest.Mocked { @@ -189,6 +190,7 @@ describe('chart_switch', () => { clearStagedPreview: true, }, }); + expect(lensStore.dispatch).not.toHaveBeenCalledWith({ type: applyChanges.type }); // should not apply changes automatically }); it('should use initial state if there is no suggestion from the target visualization', async () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index d24ed0a736ae24..5c528832ac5b28 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -132,7 +132,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { ...selection, visualizationState: selection.getVisualizationState(), }, - true + { clearStagedPreview: true } ); if ( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index ccd9e8aace2ab2..7359f7cdc185b2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -35,9 +35,15 @@ import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/publ import { uiActionsPluginMock } from '../../../../../../../src/plugins/ui_actions/public/mocks'; import { TriggerContract } from '../../../../../../../src/plugins/ui_actions/public/triggers'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public/embeddable'; -import { LensRootStore, setState } from '../../../state_management'; +import { + applyChanges, + setState, + updateDatasourceState, + updateVisualizationState, +} from '../../../state_management'; import { getLensInspectorService } from '../../../lens_inspector_service'; import { inspectorPluginMock } from '../../../../../../../src/plugins/inspector/public/mocks'; +import { disableAutoApply, enableAutoApply } from '../../../state_management/lens_slice'; const defaultPermissions: Record>> = { navLinks: { management: true }, @@ -102,12 +108,13 @@ describe('workspace_panel', () => { }} ExpressionRenderer={expressionRendererMock} />, - { preloadedState: { visualization: { activeId: null, state: {} }, datasourceStates: {} }, } ); instance = mounted.instance; + instance.update(); + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2); expect(instance.find(expressionRendererMock)).toHaveLength(0); }); @@ -124,6 +131,7 @@ describe('workspace_panel', () => { { preloadedState: { datasourceStates: {} } } ); instance = mounted.instance; + instance.update(); expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2); expect(instance.find(expressionRendererMock)).toHaveLength(0); @@ -141,6 +149,7 @@ describe('workspace_panel', () => { { preloadedState: { datasourceStates: {} } } ); instance = mounted.instance; + instance.update(); expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2); expect(instance.find(expressionRendererMock)).toHaveLength(0); @@ -170,6 +179,8 @@ describe('workspace_panel', () => { instance = mounted.instance; + instance.update(); + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` "kibana | lens_merge_tables layerIds=\\"first\\" tables={datasource} @@ -177,6 +188,188 @@ describe('workspace_panel', () => { `); }); + it('should give user control when auto-apply disabled', async () => { + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + mockDatasource.toExpression.mockReturnValue('datasource'); + mockDatasource.getLayers.mockReturnValue(['first']); + + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + />, + { + preloadedState: { + autoApplyDisabled: true, + }, + } + ); + + instance = mounted.instance; + instance.update(); + + // allows initial render + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={datasource} + | testVis" + `); + + mockDatasource.toExpression.mockReturnValue('new-datasource'); + act(() => { + instance.setProps({ + visualizationMap: { + testVis: { ...mockVisualization, toExpression: () => 'new-vis' }, + }, + }); + }); + + // nothing should change + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={datasource} + | testVis" + `); + + act(() => { + mounted.lensStore.dispatch(applyChanges()); + }); + instance.update(); + + // should update + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={new-datasource} + | new-vis" + `); + + mockDatasource.toExpression.mockReturnValue('other-new-datasource'); + act(() => { + instance.setProps({ + visualizationMap: { + testVis: { ...mockVisualization, toExpression: () => 'other-new-vis' }, + }, + }); + }); + + // should not update + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={new-datasource} + | new-vis" + `); + + act(() => { + mounted.lensStore.dispatch(enableAutoApply()); + }); + instance.update(); + + // reenabling auto-apply triggers an update as well + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={other-new-datasource} + | other-new-vis" + `); + }); + + it('should base saveability on working changes when auto-apply disabled', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockVisualization.getErrorMessages.mockImplementation((currentVisualizationState: any) => { + if (currentVisualizationState.hasProblem) { + return [{ shortMessage: 'An error occurred', longMessage: 'An long description here' }]; + } else { + return []; + } + }); + + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + mockDatasource.toExpression.mockReturnValue('datasource'); + mockDatasource.getLayers.mockReturnValue(['first']); + + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + /> + ); + + instance = mounted.instance; + const isSaveable = () => mounted.lensStore.getState().lens.isSaveable; + + instance.update(); + + // allows initial render + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + "kibana + | lens_merge_tables layerIds=\\"first\\" tables={datasource} + | testVis" + `); + expect(isSaveable()).toBe(true); + + act(() => { + mounted.lensStore.dispatch( + updateVisualizationState({ + visualizationId: 'testVis', + newState: { activeId: 'testVis', hasProblem: true }, + }) + ); + }); + instance.update(); + + expect(isSaveable()).toBe(false); + }); + + it('should allow empty workspace as initial render when auto-apply disabled', async () => { + mockVisualization.toExpression.mockReturnValue('testVis'); + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + const mounted = await mountWithProvider( + , + { + preloadedState: { + autoApplyDisabled: true, + }, + } + ); + + instance = mounted.instance; + instance.update(); + + expect(instance.exists('[data-test-subj="empty-workspace"]')).toBeTruthy(); + }); + it('should execute a trigger on expression event', async () => { const framePublicAPI = createMockFramePublicAPI(); framePublicAPI.datasourceLayers = { @@ -289,6 +482,7 @@ describe('workspace_panel', () => { } ); instance = mounted.instance; + instance.update(); const ast = fromExpression(instance.find(expressionRendererMock).prop('expression') as string); @@ -342,27 +536,25 @@ describe('workspace_panel', () => { expressionRendererMock = jest.fn((_arg) => ); - await act(async () => { - const mounted = await mountWithProvider( - 'testVis' }, - }} - ExpressionRenderer={expressionRendererMock} - /> - ); - instance = mounted.instance; - }); + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + /> + ); + instance = mounted.instance; instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); + expect(expressionRendererMock).toHaveBeenCalledTimes(2); - await act(async () => { + act(() => { instance.setProps({ framePublicAPI: { ...framePublicAPI, @@ -373,7 +565,7 @@ describe('workspace_panel', () => { instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(2); + expect(expressionRendererMock).toHaveBeenCalledTimes(3); }); it('should run the expression again if the filters change', async () => { @@ -388,31 +580,29 @@ describe('workspace_panel', () => { .mockReturnValueOnce('datasource second'); expressionRendererMock = jest.fn((_arg) => ); - await act(async () => { - const mounted = await mountWithProvider( - 'testVis' }, - }} - ExpressionRenderer={expressionRendererMock} - /> - ); - instance = mounted.instance; - }); + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + /> + ); + instance = mounted.instance; instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); + expect(expressionRendererMock).toHaveBeenCalledTimes(2); const indexPattern = { id: 'index1' } as unknown as IndexPattern; const field = { name: 'myfield' } as unknown as FieldSpec; - await act(async () => { + act(() => { instance.setProps({ framePublicAPI: { ...framePublicAPI, @@ -423,7 +613,7 @@ describe('workspace_panel', () => { instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(2); + expect(expressionRendererMock).toHaveBeenCalledTimes(3); }); it('should show an error message if there are missing indexpatterns in the visualization', async () => { @@ -572,6 +762,9 @@ describe('workspace_panel', () => { /> ); instance = mounted.instance; + act(() => { + instance.update(); + }); expect(instance.find('[data-test-subj="configuration-failure"]').exists()).toBeTruthy(); expect(instance.find(expressionRendererMock)).toHaveLength(0); @@ -642,6 +835,97 @@ describe('workspace_panel', () => { expect(instance.find(expressionRendererMock)).toHaveLength(0); }); + it('should NOT display errors for unapplied changes', async () => { + // this test is important since we don't want the workspace panel to + // display errors if the user has disabled auto-apply, messed something up, + // but not yet applied their changes + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockDatasource.getErrorMessages.mockImplementation((currentDatasourceState: any) => { + if (currentDatasourceState.hasProblem) { + return [{ shortMessage: 'An error occurred', longMessage: 'An long description here' }]; + } else { + return []; + } + }); + mockDatasource.getLayers.mockReturnValue(['first']); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockVisualization.getErrorMessages.mockImplementation((currentVisualizationState: any) => { + if (currentVisualizationState.hasProblem) { + return [{ shortMessage: 'An error occurred', longMessage: 'An long description here' }]; + } else { + return []; + } + }); + mockVisualization.toExpression.mockReturnValue('testVis'); + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + const mounted = await mountWithProvider( + + ); + + instance = mounted.instance; + const lensStore = mounted.lensStore; + + const showingErrors = () => + instance.exists('[data-test-subj="configuration-failure-error"]') || + instance.exists('[data-test-subj="configuration-failure-more-errors"]'); + + expect(showingErrors()).toBeFalsy(); + + act(() => { + lensStore.dispatch(disableAutoApply()); + }); + instance.update(); + + expect(showingErrors()).toBeFalsy(); + + // introduce some issues + act(() => { + lensStore.dispatch( + updateDatasourceState({ + datasourceId: 'testDatasource', + updater: { hasProblem: true }, + }) + ); + }); + instance.update(); + + expect(showingErrors()).toBeFalsy(); + + act(() => { + lensStore.dispatch( + updateVisualizationState({ + visualizationId: 'testVis', + newState: { activeId: 'testVis', hasProblem: true }, + }) + ); + }); + instance.update(); + + expect(showingErrors()).toBeFalsy(); + + // errors should appear when problem changes are applied + act(() => { + lensStore.dispatch(applyChanges()); + }); + instance.update(); + + expect(showingErrors()).toBeTruthy(); + }); + it('should show an error message if the expression fails to parse', async () => { mockDatasource.toExpression.mockReturnValue('|||'); mockDatasource.getLayers.mockReturnValue(['first']); @@ -676,30 +960,27 @@ describe('workspace_panel', () => { first: mockDatasource.publicAPIMock, }; - await act(async () => { - const mounted = await mountWithProvider( - 'testVis' }, - }} - ExpressionRenderer={expressionRendererMock} - /> - ); - instance = mounted.instance; - }); - + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + /> + ); + instance = mounted.instance; instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); + expect(expressionRendererMock).toHaveBeenCalledTimes(2); instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); + expect(expressionRendererMock).toHaveBeenCalledTimes(2); }); it('should attempt to run the expression again if it changes', async () => { @@ -709,28 +990,25 @@ describe('workspace_panel', () => { framePublicAPI.datasourceLayers = { first: mockDatasource.publicAPIMock, }; - let lensStore: LensRootStore; - await act(async () => { - const mounted = await mountWithProvider( - 'testVis' }, - }} - ExpressionRenderer={expressionRendererMock} - /> - ); - instance = mounted.instance; - lensStore = mounted.lensStore; - }); + const mounted = await mountWithProvider( + 'testVis' }, + }} + ExpressionRenderer={expressionRendererMock} + /> + ); + instance = mounted.instance; + const lensStore = mounted.lensStore; instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(1); + expect(expressionRendererMock).toHaveBeenCalledTimes(2); expressionRendererMock.mockImplementation((_) => { return ; @@ -746,7 +1024,7 @@ describe('workspace_panel', () => { ); instance.update(); - expect(expressionRendererMock).toHaveBeenCalledTimes(2); + expect(expressionRendererMock).toHaveBeenCalledTimes(3); expect(instance.find(expressionRendererMock)).toHaveLength(1); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index a26d72f1b4fc2d..acebc640e3a09b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect, useMemo, useContext, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useContext, useCallback, useRef } from 'react'; import classNames from 'classnames'; import { FormattedMessage } from '@kbn/i18n-react'; import { toExpression } from '@kbn/interpreter'; @@ -66,9 +66,12 @@ import { selectDatasourceStates, selectActiveDatasourceId, selectSearchSessionId, + selectAutoApplyEnabled, + selectTriggerApplyChanges, } from '../../../state_management'; import type { LensInspector } from '../../../lens_inspector_service'; import { inferTimeField } from '../../../utils'; +import { setChangesApplied } from '../../../state_management/lens_slice'; export interface WorkspacePanelProps { visualizationMap: VisualizationMap; @@ -88,6 +91,7 @@ interface WorkspaceState { fixAction?: DatasourceFixAction; }>; expandError: boolean; + expressionToRender: string | null | undefined; } const dropProps = { @@ -136,13 +140,22 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ const visualization = useLensSelector(selectVisualization); const activeDatasourceId = useLensSelector(selectActiveDatasourceId); const datasourceStates = useLensSelector(selectDatasourceStates); + const autoApplyEnabled = useLensSelector(selectAutoApplyEnabled); + const triggerApply = useLensSelector(selectTriggerApplyChanges); - const { datasourceLayers } = framePublicAPI; const [localState, setLocalState] = useState({ expressionBuildError: undefined, expandError: false, + expressionToRender: undefined, }); + // const expressionToRender = useRef(); + const initialRenderComplete = useRef(); + + const shouldApplyExpression = autoApplyEnabled || !initialRenderComplete.current || triggerApply; + + const { datasourceLayers } = framePublicAPI; + const activeVisualization = visualization.activeId ? visualizationMap[visualization.activeId] : null; @@ -186,7 +199,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ [activeVisualization, visualization.state, activeDatasourceId, datasourceMap, datasourceStates] ); - const expression = useMemo(() => { + const _expression = useMemo(() => { if (!configurationValidationError?.length && !missingRefsErrors.length && !unknownVisError) { try { const ast = buildExpression({ @@ -238,10 +251,32 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ visualization.activeId, ]); - const expressionExists = Boolean(expression); useEffect(() => { - dispatchLens(setSaveable(expressionExists)); - }, [expressionExists, dispatchLens]); + dispatchLens(setSaveable(Boolean(_expression))); + }, [_expression, dispatchLens]); + + useEffect(() => { + if (!autoApplyEnabled) { + dispatchLens(setChangesApplied(_expression === localState.expressionToRender)); + } + }); + + useEffect(() => { + if (shouldApplyExpression) { + setLocalState((s) => ({ ...s, expressionToRender: _expression })); + } + }, [_expression, shouldApplyExpression]); + + const expressionExists = Boolean(localState.expressionToRender); + useEffect(() => { + // null signals an empty workspace which should count as an initial render + if ( + (expressionExists || localState.expressionToRender === null) && + !initialRenderComplete.current + ) { + initialRenderComplete.current = true; + } + }, [expressionExists, localState.expressionToRender]); const onEvent = useCallback( (event: ExpressionRendererEvent) => { @@ -291,7 +326,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ if (suggestionForDraggedField) { trackUiEvent('drop_onto_workspace'); trackUiEvent(expressionExists ? 'drop_non_empty' : 'drop_empty'); - switchToSuggestion(dispatchLens, suggestionForDraggedField, true); + switchToSuggestion(dispatchLens, suggestionForDraggedField, { clearStagedPreview: true }); } }, [suggestionForDraggedField, expressionExists, dispatchLens]); @@ -343,12 +378,12 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ }; const renderVisualization = () => { - if (expression === null) { + if (localState.expressionToRender === null) { return renderEmptyWorkspace(); } return ( { @@ -392,7 +425,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ order={dropProps.order} > - {element} + {renderVisualization()} ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss index 9a87f1ba46e94a..9b4502ea819445 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss @@ -32,6 +32,7 @@ &.lnsWorkspacePanelWrapper--fullscreen { margin-bottom: 0; } + } .lnsWorkspacePanel__dragDrop { @@ -80,6 +81,14 @@ .lnsWorkspacePanelWrapper__toolbar { margin-bottom: 0; + + &.lnsWorkspacePanelWrapper__toolbar--fullscreen { + padding: $euiSizeS $euiSizeS 0 $euiSizeS; + } + + & > .euiFlexItem { + min-height: $euiButtonHeightSmall; + } } .lnsDropIllustration__adjustFill { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx index fb77ff75324f09..3aab4d6e7d85c4 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx @@ -10,6 +10,14 @@ import { Visualization } from '../../../types'; import { createMockVisualization, createMockFramePublicAPI, FrameMock } from '../../../mocks'; import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; import { mountWithProvider } from '../../../mocks'; +import { ReactWrapper } from 'enzyme'; +import { + selectAutoApplyEnabled, + updateVisualizationState, + disableAutoApply, + selectTriggerApplyChanges, +} from '../../../state_management'; +import { setChangesApplied } from '../../../state_management/lens_slice'; describe('workspace_panel_wrapper', () => { let mockVisualization: jest.Mocked; @@ -61,4 +69,144 @@ describe('workspace_panel_wrapper', () => { setState: expect.anything(), }); }); + + describe('auto-apply controls', () => { + class Harness { + private _instance: ReactWrapper; + + constructor(instance: ReactWrapper) { + this._instance = instance; + } + + update() { + this._instance.update(); + } + + private get applyChangesButton() { + return this._instance.find('button[data-test-subj="lensApplyChanges"]'); + } + + private get autoApplyToggleSwitch() { + return this._instance.find('button[data-test-subj="lensToggleAutoApply"]'); + } + + toggleAutoApply() { + this.autoApplyToggleSwitch.simulate('click'); + } + + public get autoApplySwitchOn() { + return this.autoApplyToggleSwitch.prop('aria-checked'); + } + + applyChanges() { + this.applyChangesButton.simulate('click'); + } + + public get applyChangesExists() { + return this.applyChangesButton.exists(); + } + + public get applyChangesDisabled() { + if (!this.applyChangesExists) { + throw Error('apply changes button doesnt exist'); + } + return this.applyChangesButton.prop('disabled'); + } + } + + let store: Awaited>['lensStore']; + let harness: Harness; + beforeEach(async () => { + const { instance, lensStore } = await mountWithProvider( + +
+ + ); + + store = lensStore; + harness = new Harness(instance); + }); + + it('toggles auto-apply', async () => { + store.dispatch(disableAutoApply()); + harness.update(); + + expect(selectAutoApplyEnabled(store.getState())).toBeFalsy(); + expect(harness.autoApplySwitchOn).toBeFalsy(); + expect(harness.applyChangesExists).toBeTruthy(); + + harness.toggleAutoApply(); + + expect(selectAutoApplyEnabled(store.getState())).toBeTruthy(); + expect(harness.autoApplySwitchOn).toBeTruthy(); + expect(harness.applyChangesExists).toBeFalsy(); + + harness.toggleAutoApply(); + + expect(selectAutoApplyEnabled(store.getState())).toBeFalsy(); + expect(harness.autoApplySwitchOn).toBeFalsy(); + expect(harness.applyChangesExists).toBeTruthy(); + }); + + it('apply-changes button works', () => { + store.dispatch(disableAutoApply()); + harness.update(); + + expect(selectAutoApplyEnabled(store.getState())).toBeFalsy(); + expect(harness.applyChangesDisabled).toBeTruthy(); + + // make a change + store.dispatch( + updateVisualizationState({ + visualizationId: store.getState().lens.visualization.activeId as string, + newState: { something: 'changed' }, + }) + ); + // simulate workspace panel behavior + store.dispatch(setChangesApplied(false)); + harness.update(); + + expect(harness.applyChangesDisabled).toBeFalsy(); + + harness.applyChanges(); + + expect(selectTriggerApplyChanges(store.getState())).toBeTruthy(); + // simulate workspace panel behavior + store.dispatch(setChangesApplied(true)); + harness.update(); + + expect(harness.applyChangesDisabled).toBeTruthy(); + }); + + it('enabling auto apply while having unapplied changes works', () => { + // setup + store.dispatch(disableAutoApply()); + store.dispatch( + updateVisualizationState({ + visualizationId: store.getState().lens.visualization.activeId as string, + newState: { something: 'changed' }, + }) + ); + store.dispatch(setChangesApplied(false)); // simulate workspace panel behavior + harness.update(); + + expect(harness.applyChangesDisabled).toBeFalsy(); + expect(harness.autoApplySwitchOn).toBeFalsy(); + expect(harness.applyChangesExists).toBeTruthy(); + + // enable auto apply + harness.toggleAutoApply(); + + expect(harness.autoApplySwitchOn).toBeTruthy(); + expect(harness.applyChangesExists).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index be230634886105..274992c5f5e6d9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -8,8 +8,12 @@ import './workspace_panel_wrapper.scss'; import React, { useCallback } from 'react'; -import { EuiPageContent, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiPageContent, EuiFlexGroup, EuiFlexItem, EuiSwitch, EuiButton } from '@elastic/eui'; import classNames from 'classnames'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { trackUiEvent } from '../../../lens_ui_telemetry'; +import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { DatasourceMap, FramePublicAPI, VisualizationMap } from '../../../types'; import { NativeRenderer } from '../../../native_renderer'; import { ChartSwitch } from './chart_switch'; @@ -20,8 +24,18 @@ import { DatasourceStates, VisualizationState, updateDatasourceState, + useLensSelector, + selectChangesApplied, + applyChanges, + enableAutoApply, + disableAutoApply, + selectAutoApplyEnabled, } from '../../../state_management'; import { WorkspaceTitle } from './title'; +import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from '../config_panel/dimension_container'; +import { writeToStorage } from '../../../settings_storage'; + +export const AUTO_APPLY_DISABLED_STORAGE_KEY = 'autoApplyDisabled'; export interface WorkspacePanelWrapperProps { children: React.ReactNode | React.ReactNode[]; @@ -46,6 +60,9 @@ export function WorkspacePanelWrapper({ }: WorkspacePanelWrapperProps) { const dispatchLens = useLensDispatch(); + const changesApplied = useLensSelector(selectChangesApplied); + const autoApplyEnabled = useLensSelector(selectAutoApplyEnabled); + const activeVisualization = visualizationId ? visualizationMap[visualizationId] : null; const setVisualizationState = useCallback( (newState: unknown) => { @@ -72,6 +89,18 @@ export function WorkspacePanelWrapper({ }, [dispatchLens] ); + + const toggleAutoApply = useCallback(() => { + trackUiEvent('toggle_autoapply'); + + writeToStorage( + new Storage(localStorage), + AUTO_APPLY_DISABLED_STORAGE_KEY, + String(autoApplyEnabled) + ); + dispatchLens(autoApplyEnabled ? disableAutoApply() : enableAutoApply()); + }, [dispatchLens, autoApplyEnabled]); + const warningMessages: React.ReactNode[] = []; if (activeVisualization?.getWarningMessages) { warningMessages.push( @@ -93,44 +122,93 @@ export function WorkspacePanelWrapper({
- {!isFullscreen ? ( - - + + + {!isFullscreen && ( - + + + + + {activeVisualization && activeVisualization.renderToolbar && ( + + + + )} + - {activeVisualization && activeVisualization.renderToolbar && ( + )} + + - - )} - - - ) : null} + {!autoApplyEnabled && ( + +
+ dispatchLens(applyChanges())} + size="s" + data-test-subj="lensApplyChanges" + > + + +
+
+ )} +
+
+
+
{warningMessages && warningMessages.length ? ( {warningMessages} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 199131564f7c43..d8b5874050b2a8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -52,7 +52,7 @@ export type Props = Omit, 'co changeIndexPattern: ( id: string, state: IndexPatternPrivateState, - setState: StateSetter + setState: StateSetter ) => void; charts: ChartsPluginSetup; core: CoreStart; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 0ac77696d5987c..f40f3b9623ca85 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -138,7 +138,7 @@ export function getIndexPatternDatasource({ const handleChangeIndexPattern = ( id: string, state: IndexPatternPrivateState, - setState: StateSetter + setState: StateSetter ) => { changeIndexPattern({ id, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index 9099b68cdaf0e8..d992e36a0c6d80 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -854,7 +854,9 @@ describe('loader', () => { }); expect(setState).toHaveBeenCalledTimes(1); - expect(setState.mock.calls[0][0](state)).toMatchObject({ + const [fn, options] = setState.mock.calls[0]; + expect(options).toEqual({ applyImmediately: true }); + expect(fn(state)).toMatchObject({ currentIndexPatternId: '1', indexPatterns: { '1': { @@ -1071,7 +1073,8 @@ describe('loader', () => { expect(fetchJson).toHaveBeenCalledTimes(3); expect(setState).toHaveBeenCalledTimes(1); - const [fn] = setState.mock.calls[0]; + const [fn, options] = setState.mock.calls[0]; + expect(options).toEqual({ applyImmediately: true }); const newState = fn({ foo: 'bar', existingFields: {}, @@ -1155,7 +1158,8 @@ describe('loader', () => { await syncExistingFields(args); - const [fn] = setState.mock.calls[0]; + const [fn, options] = setState.mock.calls[0]; + expect(options).toEqual({ applyImmediately: true }); const newState = fn({ foo: 'bar', existingFields: {}, @@ -1204,7 +1208,8 @@ describe('loader', () => { await syncExistingFields(args); - const [fn] = setState.mock.calls[0]; + const [fn, options] = setState.mock.calls[0]; + expect(options).toEqual({ applyImmediately: true }); const newState = fn({ foo: 'bar', existingFields: {}, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 8b3a0556b03202..9495276f15960e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -9,7 +9,11 @@ import { uniq, mapValues, difference } from 'lodash'; import type { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import type { DataView } from 'src/plugins/data_views/public'; import type { HttpSetup, SavedObjectReference } from 'kibana/public'; -import type { InitializationOptions, StateSetter, VisualizeEditorContext } from '../types'; +import type { + DatasourceDataPanelProps, + InitializationOptions, + VisualizeEditorContext, +} from '../types'; import { IndexPattern, IndexPatternRef, @@ -33,7 +37,7 @@ import { readFromStorage, writeToStorage } from '../settings_storage'; import { getFieldByNameFactory } from './pure_helpers'; import { memoizedGetAvailableOperationsByMetadata } from './operations'; -type SetState = StateSetter; +type SetState = DatasourceDataPanelProps['setState']; type IndexPatternsService = Pick; type ErrorHandler = (err: Error) => void; @@ -326,17 +330,20 @@ export async function changeIndexPattern({ } try { - setState((s) => ({ - ...s, - layers: isSingleEmptyLayer(state.layers) - ? mapValues(state.layers, (layer) => updateLayerIndexPattern(layer, indexPatterns[id])) - : state.layers, - indexPatterns: { - ...s.indexPatterns, - [id]: indexPatterns[id], - }, - currentIndexPatternId: id, - })); + setState( + (s) => ({ + ...s, + layers: isSingleEmptyLayer(state.layers) + ? mapValues(state.layers, (layer) => updateLayerIndexPattern(layer, indexPatterns[id])) + : state.layers, + indexPatterns: { + ...s.indexPatterns, + [id]: indexPatterns[id], + }, + currentIndexPatternId: id, + }), + { applyImmediately: true } + ); setLastUsedIndexPatternId(storage, id); } catch (err) { onError(err); @@ -458,33 +465,39 @@ export async function syncExistingFields({ } } - setState((state) => ({ - ...state, - isFirstExistenceFetch: false, - existenceFetchFailed: false, - existenceFetchTimeout: false, - existingFields: emptinessInfo.reduce( - (acc, info) => { - acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); - return acc; - }, - { ...state.existingFields } - ), - })); + setState( + (state) => ({ + ...state, + isFirstExistenceFetch: false, + existenceFetchFailed: false, + existenceFetchTimeout: false, + existingFields: emptinessInfo.reduce( + (acc, info) => { + acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); + return acc; + }, + { ...state.existingFields } + ), + }), + { applyImmediately: true } + ); } catch (e) { // show all fields as available if fetch failed or timed out - setState((state) => ({ - ...state, - existenceFetchFailed: e.res?.status !== 408, - existenceFetchTimeout: e.res?.status === 408, - existingFields: indexPatterns.reduce( - (acc, pattern) => { - acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name)); - return acc; - }, - { ...state.existingFields } - ), - })); + setState( + (state) => ({ + ...state, + existenceFetchFailed: e.res?.status !== 408, + existenceFetchTimeout: e.res?.status === 408, + existingFields: indexPatterns.reduce( + (acc, pattern) => { + acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name)); + return acc; + }, + { ...state.existingFields } + ), + }), + { applyImmediately: true } + ); } } diff --git a/x-pack/plugins/lens/public/settings_storage.tsx b/x-pack/plugins/lens/public/settings_storage.tsx index fa59bff166c309..ebe812915242ed 100644 --- a/x-pack/plugins/lens/public/settings_storage.tsx +++ b/x-pack/plugins/lens/public/settings_storage.tsx @@ -14,5 +14,5 @@ export const readFromStorage = (storage: IStorageWrapper, key: string) => { return data && data[key]; }; export const writeToStorage = (storage: IStorageWrapper, key: string, value: string) => { - storage.set(STORAGE_KEY, { [key]: value }); + storage.set(STORAGE_KEY, { ...storage.get(STORAGE_KEY), [key]: value }); }; diff --git a/x-pack/plugins/lens/public/shared_components/axis_title_settings.tsx b/x-pack/plugins/lens/public/shared_components/axis_title_settings.tsx index edecee61d7709c..f54b07905b94cf 100644 --- a/x-pack/plugins/lens/public/shared_components/axis_title_settings.tsx +++ b/x-pack/plugins/lens/public/shared_components/axis_title_settings.tsx @@ -49,10 +49,13 @@ export const AxisTitleSettings: React.FunctionComponent isAxisTitleVisible, toggleAxisTitleVisibility, }) => { - const { inputValue: title, handleInputChange: onTitleChange } = useDebouncedValue({ - value: axisTitle || '', - onChange: updateTitleState, - }); + const { inputValue: title, handleInputChange: onTitleChange } = useDebouncedValue( + { + value: axisTitle || '', + onChange: updateTitleState, + }, + { allowFalsyValue: true } + ); return ( <> diff --git a/x-pack/plugins/lens/public/state_management/context_middleware/index.test.ts b/x-pack/plugins/lens/public/state_management/context_middleware/index.test.ts index f115cb59e6121d..d256fcf9b11e53 100644 --- a/x-pack/plugins/lens/public/state_management/context_middleware/index.test.ts +++ b/x-pack/plugins/lens/public/state_management/context_middleware/index.test.ts @@ -10,12 +10,12 @@ import moment from 'moment'; import { contextMiddleware } from '.'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; -import { initialState } from '../lens_slice'; +import { applyChanges, initialState } from '../lens_slice'; import { LensAppState } from '../types'; import { mockDataPlugin, mockStoreDeps } from '../../mocks'; const storeDeps = mockStoreDeps(); -const createMiddleware = (data: DataPublicPluginStart) => { +const createMiddleware = (data: DataPublicPluginStart, state?: Partial) => { const middleware = contextMiddleware({ ...storeDeps, lensServices: { @@ -24,12 +24,13 @@ const createMiddleware = (data: DataPublicPluginStart) => { }, }); const store = { - getState: jest.fn(() => ({ lens: initialState })), + getState: jest.fn(() => ({ lens: state || initialState })), dispatch: jest.fn(), }; const next = jest.fn(); - const invoke = (action: PayloadAction>) => middleware(store)(next)(action); + const invoke = (action: PayloadAction | void>) => + middleware(store)(next)(action); return { store, next, invoke }; }; @@ -70,6 +71,47 @@ describe('contextMiddleware', () => { }); expect(next).toHaveBeenCalledWith(action); }); + describe('when auto-apply is disabled', () => { + it('only updates searchSessionId when user applies changes', () => { + // setup + const data = mockDataPlugin(); + (data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 30000)); + (data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({ + from: 'now-2m', + to: 'now', + }); + (data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({ + min: moment(Date.now() - 100000), + max: moment(Date.now() - 30000), + }); + const { invoke, store } = createMiddleware(data, { + ...initialState, + autoApplyDisabled: true, + }); + + // setState shouldn't trigger + const setStateAction = { + type: 'lens/setState', + payload: { + visualization: { + state: {}, + activeId: 'id2', + }, + }, + }; + invoke(setStateAction); + expect(store.dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'lens/setState' }) + ); + + // applyChanges should trigger + const applyChangesAction = applyChanges(); + invoke(applyChangesAction); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'lens/setState' }) + ); + }); + }); it('does not update the searchSessionId when the state changes and too little time has passed', () => { const data = mockDataPlugin(); // time range is 100,000ms ago to 300ms ago (that's a lag of .3 percent, not enough to trigger a session update) diff --git a/x-pack/plugins/lens/public/state_management/context_middleware/index.ts b/x-pack/plugins/lens/public/state_management/context_middleware/index.ts index 25dea5527d0612..3ca806d17dcb78 100644 --- a/x-pack/plugins/lens/public/state_management/context_middleware/index.ts +++ b/x-pack/plugins/lens/public/state_management/context_middleware/index.ts @@ -8,7 +8,14 @@ import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit'; import moment from 'moment'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; -import { setState, LensDispatch, LensStoreDeps, navigateAway } from '..'; +import { + setState, + LensDispatch, + LensStoreDeps, + navigateAway, + applyChanges, + selectAutoApplyEnabled, +} from '..'; import { LensAppState } from '../types'; import { getResolvedDateRange, containsDynamicMath } from '../../utils'; import { subscribeToExternalContext } from './subscribe_to_external_context'; @@ -20,8 +27,12 @@ export const contextMiddleware = (storeDeps: LensStoreDeps) => (store: Middlewar store.getState, store.dispatch ); - return (next: Dispatch) => (action: PayloadAction>) => { - if (!action.payload?.searchSessionId && !onActiveDataChange.match(action)) { + return (next: Dispatch) => (action: PayloadAction) => { + if ( + !(action.payload as Partial)?.searchSessionId && + !onActiveDataChange.match(action) && + (selectAutoApplyEnabled(store.getState()) || applyChanges.match(action)) + ) { updateTimeRange(storeDeps.lensServices.data, store.dispatch); } if (navigateAway.match(action)) { diff --git a/x-pack/plugins/lens/public/state_management/index.ts b/x-pack/plugins/lens/public/state_management/index.ts index bdd1bd8f39cc03..7b9c345ff89f63 100644 --- a/x-pack/plugins/lens/public/state_management/index.ts +++ b/x-pack/plugins/lens/public/state_management/index.ts @@ -20,6 +20,9 @@ export const { loadInitial, navigateAway, setState, + enableAutoApply, + disableAutoApply, + applyChanges, setSaveable, onActiveDataChange, updateState, diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/index.ts b/x-pack/plugins/lens/public/state_management/init_middleware/index.ts index 88a045ed0b506d..164941d5d5f89f 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/index.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/index.ts @@ -9,11 +9,18 @@ import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit'; import { LensStoreDeps } from '..'; import { loadInitial as loadInitialAction } from '..'; import { loadInitial } from './load_initial'; +import { readFromStorage } from '../../settings_storage'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { AUTO_APPLY_DISABLED_STORAGE_KEY } from '../../editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper'; + +const autoApplyDisabled = () => { + return readFromStorage(new Storage(localStorage), AUTO_APPLY_DISABLED_STORAGE_KEY) === 'true'; +}; export const initMiddleware = (storeDeps: LensStoreDeps) => (store: MiddlewareAPI) => { return (next: Dispatch) => (action: PayloadAction) => { if (loadInitialAction.match(action)) { - return loadInitial(store, storeDeps, action.payload); + return loadInitial(store, storeDeps, action.payload, autoApplyDisabled()); } next(action); }; diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts index 372d08017ee2a0..709577594ceae9 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts @@ -9,7 +9,7 @@ import { MiddlewareAPI } from '@reduxjs/toolkit'; import { i18n } from '@kbn/i18n'; import { History } from 'history'; import { setState, initEmpty, LensStoreDeps } from '..'; -import { getPreloadedState } from '../lens_slice'; +import { disableAutoApply, getPreloadedState } from '../lens_slice'; import { SharingSavedObjectProps } from '../../types'; import { LensEmbeddableInput, LensByReferenceInput } from '../../embeddable/embeddable'; import { getInitialDatasourceId } from '../../utils'; @@ -93,7 +93,8 @@ export function loadInitial( redirectCallback: (savedObjectId?: string) => void; initialInput?: LensEmbeddableInput; history?: History; - } + }, + autoApplyDisabled: boolean ) { const { lensServices, datasourceMap, embeddableEditorIncomingState, initialContext } = storeDeps; const { resolvedDateRange, searchSessionId, isLinkedToOriginatingApp, ...emptyState } = @@ -129,6 +130,9 @@ export function loadInitial( initialContext, }) ); + if (autoApplyDisabled) { + store.dispatch(disableAutoApply()); + } }) .catch((e: { message: string }) => { notifications.toasts.addDanger({ @@ -209,6 +213,10 @@ export function loadInitial( isLoading: false, }) ); + + if (autoApplyDisabled) { + store.dispatch(disableAutoApply()); + } }) .catch((e: { message: string }) => notifications.toasts.addDanger({ diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.test.ts b/x-pack/plugins/lens/public/state_management/lens_slice.test.ts index 85061f36ce35e7..4a183c11d896bb 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.test.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { EnhancedStore } from '@reduxjs/toolkit'; import { Query } from 'src/plugins/data/public'; import { switchDatasource, @@ -16,13 +17,20 @@ import { removeOrClearLayer, addLayer, LensRootStore, + selectTriggerApplyChanges, + selectChangesApplied, } from '.'; import { layerTypes } from '../../common'; import { makeLensStore, defaultState, mockStoreDeps } from '../mocks'; import { DatasourceMap, VisualizationMap } from '../types'; +import { applyChanges, disableAutoApply, enableAutoApply, setChangesApplied } from './lens_slice'; +import { LensAppState } from './types'; describe('lensSlice', () => { - const { store } = makeLensStore({}); + let store: EnhancedStore<{ lens: LensAppState }>; + beforeEach(() => { + store = makeLensStore({}).store; + }); const customQuery = { query: 'custom' } as Query; describe('state update', () => { @@ -34,6 +42,56 @@ describe('lensSlice', () => { expect(changedState).toEqual({ ...defaultState, query: customQuery }); }); + describe('auto-apply-related actions', () => { + it('should disable auto apply', () => { + expect(store.getState().lens.autoApplyDisabled).toBeUndefined(); + expect(store.getState().lens.changesApplied).toBeUndefined(); + + store.dispatch(disableAutoApply()); + + expect(store.getState().lens.autoApplyDisabled).toBe(true); + expect(store.getState().lens.changesApplied).toBe(true); + }); + + it('should enable auto-apply', () => { + store.dispatch(disableAutoApply()); + + expect(store.getState().lens.autoApplyDisabled).toBe(true); + + store.dispatch(enableAutoApply()); + + expect(store.getState().lens.autoApplyDisabled).toBe(false); + }); + + it('applies changes when auto-apply disabled', () => { + store.dispatch(disableAutoApply()); + + store.dispatch(applyChanges()); + + expect(selectTriggerApplyChanges(store.getState())).toBe(true); + }); + + it('does not apply changes if auto-apply enabled', () => { + expect(store.getState().lens.autoApplyDisabled).toBeUndefined(); + + store.dispatch(applyChanges()); + + expect(selectTriggerApplyChanges(store.getState())).toBe(false); + }); + + it('sets changes-applied flag', () => { + expect(store.getState().lens.changesApplied).toBeUndefined(); + + store.dispatch(setChangesApplied(true)); + + expect(selectChangesApplied(store.getState())).toBe(true); + + store.dispatch(setChangesApplied(false)); + + expect(selectChangesApplied(store.getState())).toBe(true); + }); + }); + it('updateState: updates state with updater', () => { const customUpdater = jest.fn((state) => ({ ...state, query: customQuery })); store.dispatch(updateState({ updater: customUpdater })); diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index 099929cdf47962..56ff89f506c858 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -83,6 +83,10 @@ export const getPreloadedState = ({ export const setState = createAction>('lens/setState'); export const onActiveDataChange = createAction('lens/onActiveDataChange'); export const setSaveable = createAction('lens/setSaveable'); +export const enableAutoApply = createAction('lens/enableAutoApply'); +export const disableAutoApply = createAction('lens/disableAutoApply'); +export const applyChanges = createAction('lens/applyChanges'); +export const setChangesApplied = createAction('lens/setChangesApplied'); export const updateState = createAction<{ updater: (prevState: LensAppState) => LensAppState; }>('lens/updateState'); @@ -162,6 +166,10 @@ export const lensActions = { setState, onActiveDataChange, setSaveable, + enableAutoApply, + disableAutoApply, + applyChanges, + setChangesApplied, updateState, updateDatasourceState, updateVisualizationState, @@ -202,6 +210,22 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { isSaveable: payload, }; }, + [enableAutoApply.type]: (state) => { + state.autoApplyDisabled = false; + }, + [disableAutoApply.type]: (state) => { + state.autoApplyDisabled = true; + state.changesApplied = true; + }, + [applyChanges.type]: (state) => { + if (typeof state.applyChangesCounter === 'undefined') { + state.applyChangesCounter = 0; + } + state.applyChangesCounter!++; + }, + [setChangesApplied.type]: (state, { payload: applied }) => { + state.changesApplied = applied; + }, [updateState.type]: ( state, { diff --git a/x-pack/plugins/lens/public/state_management/selectors.test.ts b/x-pack/plugins/lens/public/state_management/selectors.test.ts new file mode 100644 index 00000000000000..2313d341b7e03e --- /dev/null +++ b/x-pack/plugins/lens/public/state_management/selectors.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LensAppState, selectTriggerApplyChanges, selectChangesApplied } from '.'; + +describe('lens selectors', () => { + describe('selecting changes applied', () => { + it('should be true when auto-apply disabled and flag is set', () => { + const lensState = { + changesApplied: true, + autoApplyDisabled: true, + } as Partial; + + expect(selectChangesApplied({ lens: lensState as LensAppState })).toBeTruthy(); + }); + + it('should be false when auto-apply disabled and flag is false', () => { + const lensState = { + changesApplied: false, + autoApplyDisabled: true, + } as Partial; + + expect(selectChangesApplied({ lens: lensState as LensAppState })).toBeFalsy(); + }); + + it('should be true when auto-apply enabled no matter what', () => { + const lensState = { + changesApplied: false, + autoApplyDisabled: false, + } as Partial; + + expect(selectChangesApplied({ lens: lensState as LensAppState })).toBeTruthy(); + }); + }); + it('should select apply changes trigger', () => { + selectTriggerApplyChanges({ lens: { applyChangesCounter: 1 } as LensAppState }); // get the counters in sync + + expect( + selectTriggerApplyChanges({ lens: { applyChangesCounter: 2 } as LensAppState }) + ).toBeTruthy(); + expect( + selectTriggerApplyChanges({ lens: { applyChangesCounter: 2 } as LensAppState }) + ).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/lens/public/state_management/selectors.ts b/x-pack/plugins/lens/public/state_management/selectors.ts index 250e9dde31373d..26a0d70d068f5c 100644 --- a/x-pack/plugins/lens/public/state_management/selectors.ts +++ b/x-pack/plugins/lens/public/state_management/selectors.ts @@ -19,12 +19,22 @@ export const selectFilters = (state: LensState) => state.lens.filters; export const selectResolvedDateRange = (state: LensState) => state.lens.resolvedDateRange; export const selectVisualization = (state: LensState) => state.lens.visualization; export const selectStagedPreview = (state: LensState) => state.lens.stagedPreview; +export const selectAutoApplyEnabled = (state: LensState) => !state.lens.autoApplyDisabled; +export const selectChangesApplied = (state: LensState) => + !state.lens.autoApplyDisabled || Boolean(state.lens.changesApplied); export const selectDatasourceStates = (state: LensState) => state.lens.datasourceStates; export const selectActiveDatasourceId = (state: LensState) => state.lens.activeDatasourceId; export const selectActiveData = (state: LensState) => state.lens.activeData; export const selectIsFullscreenDatasource = (state: LensState) => Boolean(state.lens.isFullscreenDatasource); +let applyChangesCounter: number | undefined; +export const selectTriggerApplyChanges = (state: LensState) => { + const shouldApply = state.lens.applyChangesCounter !== applyChangesCounter; + applyChangesCounter = state.lens.applyChangesCounter; + return shouldApply; +}; + export const selectExecutionContext = createSelector( [selectQuery, selectFilters, selectResolvedDateRange], (query, filters, dateRange) => ({ diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts index b0ff49862d9b83..0c902f944072d0 100644 --- a/x-pack/plugins/lens/public/state_management/types.ts +++ b/x-pack/plugins/lens/public/state_management/types.ts @@ -33,6 +33,9 @@ export interface PreviewState { export interface EditorFrameState extends PreviewState { activeDatasourceId: string | null; stagedPreview?: PreviewState; + autoApplyDisabled?: boolean; + applyChangesCounter?: number; + changesApplied?: boolean; isFullscreenDatasource?: boolean; } export interface LensAppState extends EditorFrameState { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 276c31328bb050..7047201c5dba38 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -160,7 +160,12 @@ export interface DatasourceSuggestion { keptLayerIds: string[]; } -export type StateSetter = (newState: T | ((prevState: T) => T)) => void; +type StateSetterArg = T | ((prevState: T) => T); + +export type StateSetter = ( + newState: StateSetterArg, + options?: OptionsShape +) => void; export interface InitializationOptions { isFullEditor?: boolean; @@ -361,7 +366,7 @@ export interface DatasourcePublicAPI { export interface DatasourceDataPanelProps { state: T; dragDropContext: DragContextState; - setState: StateSetter; + setState: StateSetter; showNoDataPopover: () => void; core: Pick; query: Query; @@ -400,13 +405,13 @@ export type ParamEditorCustomProps = Record & { label?: string // The only way a visualization has to restrict the query building export type DatasourceDimensionEditorProps = DatasourceDimensionProps & { // Not a StateSetter because we have this unique use case of determining valid columns - setState: ( - newState: Parameters>[0], - publishToVisualization?: { + setState: StateSetter< + T, + { isDimensionComplete?: boolean; forceRender?: boolean; } - ) => void; + >; core: Pick; dateRange: DateRange; dimensionGroups: VisualizationDimensionGroupConfig[]; @@ -449,7 +454,13 @@ export type DatasourceDimensionDropProps = SharedDimensionProps & { groupId: string; columnId: string; state: T; - setState: StateSetter; + setState: StateSetter< + T, + { + isDimensionComplete?: boolean; + forceRender?: boolean; + } + >; dimensionGroups: VisualizationDimensionGroupConfig[]; }; @@ -651,6 +662,7 @@ export interface VisualizationSuggestion { export interface FramePublicAPI { datasourceLayers: Record; + appliedDatasourceLayers?: Record; // this is only set when auto-apply is turned off /** * Data of the chart currently rendered in the preview. * This data might be not available (e.g. if the chart can't be rendered) or outdated and belonging to another chart. diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx index 20d2bd31c7c648..3766e1f022c882 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx @@ -18,6 +18,7 @@ import { EuiFieldNumber, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { isEqual } from 'lodash'; import { XYLayerConfig, AxesSettingsConfig, AxisExtentConfig } from '../../../common/expressions'; import { ToolbarPopover, @@ -241,7 +242,7 @@ export const AxisSettingsPopover: React.FunctionComponent = { type: 'long', _meta: { description: 'Number of times the user opened the in-product formula help popover.' }, }, + toggle_autoapply: { + type: 'long', + _meta: { + description: 'Number of times the user toggled auto-apply.', + }, + }, toggle_fullscreen_formula: { type: 'long', _meta: { diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index e0dd709a54e574..f7a82f69ae817c 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -3720,6 +3720,12 @@ "description": "Number of times the user toggled fullscreen mode on formula." } }, + "toggle_autoapply": { + "type": "long", + "_meta": { + "description": "Number of times the user toggled auto-apply." + } + }, "indexpattern_field_info_click": { "type": "long" }, @@ -3967,6 +3973,12 @@ "description": "Number of times the user toggled fullscreen mode on formula." } }, + "toggle_autoapply": { + "type": "long", + "_meta": { + "description": "Number of times the user toggled auto-apply." + } + }, "indexpattern_field_info_click": { "type": "long" }, diff --git a/x-pack/test/functional/apps/lens/disable_auto_apply.ts b/x-pack/test/functional/apps/lens/disable_auto_apply.ts new file mode 100644 index 00000000000000..3660de10ecd477 --- /dev/null +++ b/x-pack/test/functional/apps/lens/disable_auto_apply.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['lens', 'visualize']); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + + describe('lens disable auto-apply tests', () => { + it('should persist auto-apply setting across page refresh', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + + expect(await PageObjects.lens.getAutoApplyEnabled()).to.be.ok(); + + await PageObjects.lens.disableAutoApply(); + + expect(await PageObjects.lens.getAutoApplyEnabled()).not.to.be.ok(); + + await browser.refresh(); + PageObjects.lens.waitForEmptyWorkspace(); + + expect(await PageObjects.lens.getAutoApplyEnabled()).not.to.be.ok(); + + await PageObjects.lens.enableAutoApply(); + + expect(await PageObjects.lens.getAutoApplyEnabled()).to.be.ok(); + + await browser.refresh(); + PageObjects.lens.waitForEmptyWorkspace(); + + expect(await PageObjects.lens.getAutoApplyEnabled()).to.be.ok(); + + await PageObjects.lens.disableAutoApply(); + + expect(await PageObjects.lens.getAutoApplyEnabled()).not.to.be.ok(); + }); + + it('should preserve auto-apply controls with full-screen datasource', async () => { + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'formula', + formula: `count()`, + keepOpen: true, + }); + + PageObjects.lens.toggleFullscreen(); + + expect(await PageObjects.lens.getAutoApplyToggleExists()).to.be.ok(); + + PageObjects.lens.toggleFullscreen(); + + PageObjects.lens.closeDimensionEditor(); + }); + + it('should apply changes when "Apply" is clicked', async () => { + await retry.waitForWithTimeout('x dimension to be available', 1000, () => + testSubjects + .existOrFail('lnsXY_xDimensionPanel > lns-empty-dimension') + .then(() => true) + .catch(() => false) + ); + + // configureDimension + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + // assert that changes haven't been applied + await PageObjects.lens.waitForEmptyWorkspace(); + + await PageObjects.lens.applyChanges(); + + await PageObjects.lens.waitForVisualization(); + }); + + it('should hide suggestions when a change is made', async () => { + await PageObjects.lens.switchToVisualization('lnsDatatable'); + + expect(await PageObjects.lens.getAreSuggestionsPromptingToApply()).to.be.ok(); + + await PageObjects.lens.applyChanges(true); + + expect(await PageObjects.lens.getAreSuggestionsPromptingToApply()).not.to.be.ok(); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 45c53ea18a6016..3687aab7bfb69b 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -66,6 +66,7 @@ export default function ({ getService, loadTestFile, getPageObjects }: FtrProvid loadTestFile(require.resolve('./chart_data')); loadTestFile(require.resolve('./time_shift')); loadTestFile(require.resolve('./drag_and_drop')); + loadTestFile(require.resolve('./disable_auto_apply')); loadTestFile(require.resolve('./geo_field')); loadTestFile(require.resolve('./formula')); loadTestFile(require.resolve('./heatmap')); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 61b0cd10750b23..1898ef10345e6a 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -1330,5 +1330,33 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont hasEmptySizeRatioButtonGroup() { return testSubjects.exists('lnsEmptySizeRatioButtonGroup'); }, + + getAutoApplyToggleExists() { + return testSubjects.exists('lensToggleAutoApply'); + }, + + enableAutoApply() { + return testSubjects.setEuiSwitch('lensToggleAutoApply', 'check'); + }, + + disableAutoApply() { + return testSubjects.setEuiSwitch('lensToggleAutoApply', 'uncheck'); + }, + + getAutoApplyEnabled() { + return testSubjects.isEuiSwitchChecked('lensToggleAutoApply'); + }, + + async applyChanges(throughSuggestions = false) { + const applyButtonSelector = throughSuggestions + ? 'lnsSuggestionApplyChanges' + : 'lensApplyChanges'; + await testSubjects.waitForEnabled(applyButtonSelector); + await testSubjects.click(applyButtonSelector); + }, + + async getAreSuggestionsPromptingToApply() { + return testSubjects.exists('lnsSuggestionApplyChanges'); + }, }); } From 9e14054a747385850a912e7411319179181cb18c Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 4 Mar 2022 10:00:03 -0500 Subject: [PATCH 13/33] Tweak Canvas plot renderer (#126862) --- packages/kbn-flot-charts/lib/jquery_flot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-flot-charts/lib/jquery_flot.js b/packages/kbn-flot-charts/lib/jquery_flot.js index 43db1cc3d93db1..5252356279e51c 100644 --- a/packages/kbn-flot-charts/lib/jquery_flot.js +++ b/packages/kbn-flot-charts/lib/jquery_flot.js @@ -351,7 +351,7 @@ Licensed under the MIT license. if (info == null) { - var element = $("
").html(text) + var element = $("
").text(text) .css({ position: "absolute", 'max-width': width, From dd93f0c2d3a25dd7f93fca564379421f6452acc9 Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Fri, 4 Mar 2022 16:04:10 +0100 Subject: [PATCH 14/33] [Security Solution][Endpoint] Generate blocklist artifacts (#126517) * Generate blocklist artifacts fixes elastic/security-team/issues/2783 * update tests to include event filters, host isolation exceptions and blocklists fixes elastic/security-team/issues/2783 * todo comment * fix typo * Unify artifact kuery method into one Since the os specific filter and policy filter strings are same for trusted apps, event filters, host isolation exceptions and blocklists, it makes sense to unify these into a single method that accepts a listId param to distinguish each artifact. Morever, since endpoint list does not need specific policy id for a policy filter, this can also be unified into the same method. fixes elastic/security-team/issues/2783 * update blocklist generator to add random os entry refs elastic/kibana/pull/126390 * Move entries back in `ExceptionsListItemGenerator` review changes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../exceptions_list_item_generator.ts | 92 +++-- .../scripts/endpoint/blocklists/index.ts | 2 +- .../server/endpoint/lib/artifacts/common.ts | 3 + .../endpoint/lib/artifacts/lists.test.ts | 320 ++++++++++++++---- .../server/endpoint/lib/artifacts/lists.ts | 117 +++---- .../manifest_manager/manifest_manager.test.ts | 49 ++- .../manifest_manager/manifest_manager.ts | 92 ++++- 7 files changed, 477 insertions(+), 198 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts index ed4d81a03739f7..e6f2669c95c34c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts @@ -253,49 +253,69 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator = {}): ExceptionListItemSchema { - return this.generate({ - name: `Blocklist ${this.randomString(5)}`, - list_id: ENDPOINT_BLOCKLISTS_LIST_ID, - item_id: `generator_endpoint_blocklist_${this.seededUUIDv4()}`, - os_types: ['windows'], - entries: [ - this.randomChoice([ + const os = this.randomOSFamily() as ExceptionListItemSchema['os_types'][number]; + const entriesList: CreateExceptionListItemSchema['entries'] = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: os === 'windows' ? 'C:\\Fol*\\file.*' : '/usr/*/*.dmg', + }, + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: os === 'windows' ? 'C:\\Fol*\\file.exe' : '/usr/*/app.dmg', + }, + { + field: 'process.executable.caseless', + value: + os === 'windows' + ? ['C:\\some\\path', 'C:\\some\\other\\path', 'C:\\yet\\another\\path'] + : ['/some/path', 'some/other/path', 'yet/another/path'], + type: 'match_any', + operator: 'included', + }, + { + field: 'process.hash.sha256', + value: [ + 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', + '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE', + 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9', + ], + type: 'match_any', + operator: 'included', + }, + { + field: 'process.Ext.code_signature', + entries: [ { - field: 'process.executable.caseless', - value: ['/some/path', 'some/other/path', 'yet/another/path'], - type: 'match_any', + field: 'trusted', + value: 'true', + type: 'match', operator: 'included', }, { - field: 'process.hash.sha256', - value: [ - 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', - '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE', - 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9', - ], + field: 'subject_name', + value: + os === 'windows' + ? ['notsus.app', 'verynotsus.app', 'superlegit.app'] + : ['notsus.exe', 'verynotsus.exe', 'superlegit.exe'], type: 'match_any', operator: 'included', }, - { - field: 'process.Ext.code_signature', - entries: [ - { - field: 'trusted', - value: 'true', - type: 'match', - operator: 'included', - }, - { - field: 'subject_name', - value: ['notsus.exe', 'verynotsus.exe', 'superlegit.exe'], - type: 'match_any', - operator: 'included', - }, - ], - type: 'nested', - }, - ]), - ], + ], + type: 'nested', + }, + ]; + + return this.generate({ + name: `Blocklist ${this.randomString(5)}`, + list_id: ENDPOINT_BLOCKLISTS_LIST_ID, + item_id: `generator_endpoint_blocklist_${this.seededUUIDv4()}`, + tags: [this.randomChoice([BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG])], + os_types: [os], + entries: [entriesList[this.randomN(5)]], ...overrides, }); } diff --git a/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts index 81a18bb89c3547..3ed2ea6e7f1b3d 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/blocklists/index.ts @@ -80,7 +80,7 @@ const createBlocklists: RunFn = async ({ flags, log }) => { const body = eventGenerator.generateBlocklistForCreate(); if (isArtifactByPolicy(body)) { - const nmExceptions = Math.floor(Math.random() * 3) || 1; + const nmExceptions = eventGenerator.randomN(3) || 1; body.tags = Array.from({ length: nmExceptions }, () => { return `policy:${randomPolicyId()}`; }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts index 60f91330d4558c..20da1e212f40f0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts @@ -28,6 +28,9 @@ export const ArtifactConstants = { SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS: ['macos', 'windows', 'linux'], GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME: 'endpoint-hostisolationexceptionlist', + + SUPPORTED_BLOCKLISTS_OPERATING_SYSTEMS: ['macos', 'windows', 'linux'], + GLOBAL_BLOCKLISTS_NAME: 'endpoint-blocklist', }; export const ManifestConstants = { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index 16cbe618c5076a..83dbcf1ca6f6de 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -10,15 +10,13 @@ import { listMock } from '../../../../../lists/server/mocks'; import { getFoundExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import type { EntriesArray, EntryList } from '@kbn/securitysolution-io-ts-list-types'; -import { - buildArtifact, - getEndpointExceptionList, - getEndpointTrustedAppsList, - getFilteredEndpointExceptionList, -} from './lists'; +import { buildArtifact, getEndpointExceptionList, getFilteredEndpointExceptionList } from './lists'; import { TranslatedEntry, TranslatedExceptionListItem } from '../../schemas/artifacts'; import { ArtifactConstants } from './common'; import { + ENDPOINT_BLOCKLISTS_LIST_ID, + ENDPOINT_EVENT_FILTERS_LIST_ID, + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID, } from '@kbn/securitysolution-list-constants'; @@ -61,12 +59,12 @@ describe('artifacts lists', () => { const first = getFoundExceptionListItemSchemaMock(); mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -107,12 +105,12 @@ describe('artifacts lists', () => { first.data[0].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -158,12 +156,12 @@ describe('artifacts lists', () => { first.data[0].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -211,12 +209,12 @@ describe('artifacts lists', () => { first.data[0].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -263,12 +261,12 @@ describe('artifacts lists', () => { first.data[0].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -306,12 +304,12 @@ describe('artifacts lists', () => { first.data[1].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -349,12 +347,12 @@ describe('artifacts lists', () => { first.data[0].entries = testEntries; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp).toEqual({ entries: [expectedEndpointExceptions], }); @@ -378,12 +376,12 @@ describe('artifacts lists', () => { .mockReturnValueOnce(first) .mockReturnValueOnce(second); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); // Expect 2 exceptions, the first two calls returned the same exception list items expect(resp.entries.length).toEqual(2); @@ -394,12 +392,12 @@ describe('artifacts lists', () => { exceptionsResponse.data = []; exceptionsResponse.total = 0; mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(exceptionsResponse); - const resp = await getFilteredEndpointExceptionList( - mockExceptionClient, - 'v1', - TEST_FILTER, - ENDPOINT_LIST_ID - ); + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: TEST_FILTER, + listId: ENDPOINT_LIST_ID, + }); expect(resp.entries.length).toEqual(0); }); @@ -543,13 +541,17 @@ describe('artifacts lists', () => { ], }; - describe('getEndpointExceptionList', () => { - test('it should build proper kuery', async () => { + describe('Builds proper kuery without policy', () => { + test('for Endpoint List', async () => { mockExceptionClient.findExceptionListItem = jest .fn() .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); - const resp = await getEndpointExceptionList(mockExceptionClient, 'v1', 'windows'); + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'windows', + }); expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); @@ -563,15 +565,18 @@ describe('artifacts lists', () => { sortOrder: 'desc', }); }); - }); - describe('getEndpointTrustedAppsList', () => { - test('it should build proper kuery without policy', async () => { + test('for Trusted Apps', async () => { mockExceptionClient.findExceptionListItem = jest .fn() .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); - const resp = await getEndpointTrustedAppsList(mockExceptionClient, 'v1', 'macos'); + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); @@ -587,17 +592,98 @@ describe('artifacts lists', () => { }); }); - test('it should build proper kuery with policy', async () => { + test('for Event Filters', async () => { mockExceptionClient.findExceptionListItem = jest .fn() .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); - const resp = await getEndpointTrustedAppsList( - mockExceptionClient, - 'v1', - 'macos', - 'c6d16e42-c32d-4dce-8a88-113cfe276ad1' - ); + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and (exception-list-agnostic.attributes.tags:"policy:all")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + + test('for Host Isolation Exceptions', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and (exception-list-agnostic.attributes.tags:"policy:all")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + + test('for Blocklists', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and (exception-list-agnostic.attributes.tags:"policy:all")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + }); + + describe('Build proper kuery with policy', () => { + test('for Trusted Apps', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + policyId: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); @@ -614,5 +700,91 @@ describe('artifacts lists', () => { sortOrder: 'desc', }); }); + + test('for Event Filters', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + policyId: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and ' + + '(exception-list-agnostic.attributes.tags:"policy:all" or ' + + 'exception-list-agnostic.attributes.tags:"policy:c6d16e42-c32d-4dce-8a88-113cfe276ad1")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + + test('for Host Isolation Exceptions', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + policyId: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and ' + + '(exception-list-agnostic.attributes.tags:"policy:all" or ' + + 'exception-list-agnostic.attributes.tags:"policy:c6d16e42-c32d-4dce-8a88-113cfe276ad1")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); + test('for Blocklists', async () => { + mockExceptionClient.findExceptionListItem = jest + .fn() + .mockReturnValueOnce(getFoundExceptionListItemSchemaMock()); + + const resp = await getEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + os: 'macos', + policyId: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + }); + + expect(resp).toEqual(TEST_EXCEPTION_LIST_ITEM); + + expect(mockExceptionClient.findExceptionListItem).toHaveBeenCalledWith({ + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + namespaceType: 'agnostic', + filter: + 'exception-list-agnostic.attributes.os_types:"macos" and ' + + '(exception-list-agnostic.attributes.tags:"policy:all" or ' + + 'exception-list-agnostic.attributes.tags:"policy:c6d16e42-c32d-4dce-8a88-113cfe276ad1")', + perPage: 100, + page: 1, + sortField: 'created_at', + sortOrder: 'desc', + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index b23c2fe08bf103..7a36e2ef940e5f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -16,6 +16,7 @@ import { validate } from '@kbn/securitysolution-io-ts-utils'; import { hasSimpleExecutableName, OperatingSystem } from '@kbn/securitysolution-utils'; import { + ENDPOINT_BLOCKLISTS_LIST_ID, ENDPOINT_EVENT_FILTERS_LIST_ID, ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, ENDPOINT_LIST_ID, @@ -63,22 +64,30 @@ export async function buildArtifact( }; } -export async function getFilteredEndpointExceptionList( - eClient: ExceptionListClient, - schemaVersion: string, - filter: string, - listId: - | typeof ENDPOINT_LIST_ID - | typeof ENDPOINT_TRUSTED_APPS_LIST_ID - | typeof ENDPOINT_EVENT_FILTERS_LIST_ID - | typeof ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID -): Promise { +export type ArtifactListId = + | typeof ENDPOINT_LIST_ID + | typeof ENDPOINT_TRUSTED_APPS_LIST_ID + | typeof ENDPOINT_EVENT_FILTERS_LIST_ID + | typeof ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID + | typeof ENDPOINT_BLOCKLISTS_LIST_ID; + +export async function getFilteredEndpointExceptionList({ + elClient, + filter, + listId, + schemaVersion, +}: { + elClient: ExceptionListClient; + filter: string; + listId: ArtifactListId; + schemaVersion: string; +}): Promise { const exceptions: WrappedTranslatedExceptionList = { entries: [] }; let page = 1; let paging = true; while (paging) { - const response = await eClient.findExceptionListItem({ + const response = await elClient.findExceptionListItem({ listId, namespaceType: 'agnostic', filter, @@ -107,72 +116,42 @@ export async function getFilteredEndpointExceptionList( return validated as WrappedTranslatedExceptionList; } -export async function getEndpointExceptionList( - eClient: ExceptionListClient, - schemaVersion: string, - os: string -): Promise { - const filter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; - - return getFilteredEndpointExceptionList(eClient, schemaVersion, filter, ENDPOINT_LIST_ID); -} - -export async function getEndpointTrustedAppsList( - eClient: ExceptionListClient, - schemaVersion: string, - os: string, - policyId?: string -): Promise { - const osFilter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; - const policyFilter = `(exception-list-agnostic.attributes.tags:\"policy:all\"${ - policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' - })`; - - return getFilteredEndpointExceptionList( - eClient, - schemaVersion, - `${osFilter} and ${policyFilter}`, - ENDPOINT_TRUSTED_APPS_LIST_ID - ); -} - -export async function getEndpointEventFiltersList( - eClient: ExceptionListClient, - schemaVersion: string, - os: string, - policyId?: string -): Promise { +export async function getEndpointExceptionList({ + elClient, + listId, + os, + policyId, + schemaVersion, +}: { + elClient: ExceptionListClient; + listId?: ArtifactListId; + os: string; + policyId?: string; + schemaVersion: string; +}): Promise { const osFilter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; const policyFilter = `(exception-list-agnostic.attributes.tags:\"policy:all\"${ policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' })`; - return getFilteredEndpointExceptionList( - eClient, + // for endpoint list + if (!listId || listId === ENDPOINT_LIST_ID) { + return getFilteredEndpointExceptionList({ + elClient, + schemaVersion, + filter: `${osFilter}`, + listId: ENDPOINT_LIST_ID, + }); + } + // for TAs, EFs, Host IEs and Blocklists + return getFilteredEndpointExceptionList({ + elClient, schemaVersion, - `${osFilter} and ${policyFilter}`, - ENDPOINT_EVENT_FILTERS_LIST_ID - ); + filter: `${osFilter} and ${policyFilter}`, + listId, + }); } -export async function getHostIsolationExceptionsList( - eClient: ExceptionListClient, - schemaVersion: string, - os: string, - policyId?: string -): Promise { - const osFilter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; - const policyFilter = `(exception-list-agnostic.attributes.tags:\"policy:all\"${ - policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' - })`; - - return getFilteredEndpointExceptionList( - eClient, - schemaVersion, - `${osFilter} and ${policyFilter}`, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID - ); -} /** * Translates Exception list items to Exceptions the endpoint can understand * @param exceptions diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index c878c02df2a081..717eadc1363315 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -10,6 +10,8 @@ import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID, + ENDPOINT_EVENT_FILTERS_LIST_ID, + ENDPOINT_BLOCKLISTS_LIST_ID, } from '@kbn/securitysolution-list-constants'; import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { PackagePolicy } from '../../../../../../fleet/common/types/models'; @@ -73,6 +75,9 @@ describe('ManifestManager', () => { 'endpoint-hostisolationexceptionlist-windows-v1'; const ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_LINUX = 'endpoint-hostisolationexceptionlist-linux-v1'; + const ARTIFACT_NAME_BLOCKLISTS_MACOS = 'endpoint-blocklist-macos-v1'; + const ARTIFACT_NAME_BLOCKLISTS_WINDOWS = 'endpoint-blocklist-windows-v1'; + const ARTIFACT_NAME_BLOCKLISTS_LINUX = 'endpoint-blocklist-linux-v1'; let ARTIFACTS: InternalArtifactCompleteSchema[] = []; let ARTIFACTS_BY_ID: { [K: string]: InternalArtifactCompleteSchema } = {}; @@ -284,6 +289,9 @@ describe('ManifestManager', () => { ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_MACOS, ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_WINDOWS, ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_LINUX, + ARTIFACT_NAME_BLOCKLISTS_MACOS, + ARTIFACT_NAME_BLOCKLISTS_WINDOWS, + ARTIFACT_NAME_BLOCKLISTS_LINUX, ]; const getArtifactIds = (artifacts: InternalArtifactSchema[]) => [ @@ -327,7 +335,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(12); + expect(artifacts.length).toBe(15); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); for (const artifact of artifacts) { @@ -342,14 +350,18 @@ describe('ManifestManager', () => { test('Builds fully new manifest if no baseline parameter passed and present exception list items', async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const eventFiltersListItem = getExceptionListItemSchemaMock({ os_types: ['windows'] }); const hostIsolationExceptionsItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const blocklistsListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, [ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] }, + [ENDPOINT_EVENT_FILTERS_LIST_ID]: { linux: [eventFiltersListItem] }, [ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: { linux: [hostIsolationExceptionsItem] }, + [ENDPOINT_BLOCKLISTS_LIST_ID]: { linux: [blocklistsListItem] }, }); context.savedObjectsClient.create = jest .fn() @@ -366,7 +378,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(12); + expect(artifacts.length).toBe(15); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ @@ -381,12 +393,19 @@ describe('ManifestManager', () => { }); expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); - expect(getArtifactObject(artifacts[8])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[8])).toStrictEqual({ + entries: translateToEndpointExceptions([eventFiltersListItem], 'v1'), + }); expect(getArtifactObject(artifacts[9])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[10])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[11])).toStrictEqual({ entries: translateToEndpointExceptions([hostIsolationExceptionsItem], 'v1'), }); + expect(getArtifactObject(artifacts[12])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[13])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[14])).toStrictEqual({ + entries: translateToEndpointExceptions([blocklistsListItem], 'v1'), + }); for (const artifact of artifacts) { expect(manifest.isDefaultArtifact(artifact)).toBe(true); @@ -399,6 +418,9 @@ describe('ManifestManager', () => { test('Reuses artifacts when baseline parameter passed and present exception list items', async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const eventFiltersListItem = getExceptionListItemSchemaMock({ os_types: ['windows'] }); + const hostIsolationExceptionsItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const blocklistsListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); @@ -416,6 +438,9 @@ describe('ManifestManager', () => { context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, [ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] }, + [ENDPOINT_EVENT_FILTERS_LIST_ID]: { linux: [eventFiltersListItem] }, + [ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: { linux: [hostIsolationExceptionsItem] }, + [ENDPOINT_BLOCKLISTS_LIST_ID]: { linux: [blocklistsListItem] }, }); const manifest = await manifestManager.buildNewManifest(oldManifest); @@ -426,7 +451,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(12); + expect(artifacts.length).toBe(15); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(artifacts[0]).toStrictEqual(oldManifest.getAllArtifacts()[0]); @@ -439,7 +464,19 @@ describe('ManifestManager', () => { }); expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); - expect(getArtifactObject(artifacts[8])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[8])).toStrictEqual({ + entries: translateToEndpointExceptions([eventFiltersListItem], 'v1'), + }); + expect(getArtifactObject(artifacts[9])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[10])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[11])).toStrictEqual({ + entries: translateToEndpointExceptions([hostIsolationExceptionsItem], 'v1'), + }); + expect(getArtifactObject(artifacts[12])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[13])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[14])).toStrictEqual({ + entries: translateToEndpointExceptions([blocklistsListItem], 'v1'), + }); for (const artifact of artifacts) { expect(manifest.isDefaultArtifact(artifact)).toBe(true); @@ -488,7 +525,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(13); + expect(artifacts.length).toBe(16); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index af985bf2301730..7be2a36396a715 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -10,6 +10,12 @@ import semver from 'semver'; import LRU from 'lru-cache'; import { isEqual, isEmpty } from 'lodash'; import { Logger, SavedObjectsClientContract } from 'src/core/server'; +import { + ENDPOINT_EVENT_FILTERS_LIST_ID, + ENDPOINT_TRUSTED_APPS_LIST_ID, + ENDPOINT_BLOCKLISTS_LIST_ID, + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, +} from '@kbn/securitysolution-list-constants'; import { ListResult } from '../../../../../../fleet/common'; import { PackagePolicyServiceInterface } from '../../../../../../fleet/server'; import { ExceptionListClient } from '../../../../../../lists/server'; @@ -23,10 +29,7 @@ import { ArtifactConstants, buildArtifact, getArtifactId, - getEndpointEventFiltersList, getEndpointExceptionList, - getEndpointTrustedAppsList, - getHostIsolationExceptionsList, Manifest, } from '../../../lib/artifacts'; import { @@ -133,7 +136,11 @@ export class ManifestManager { */ protected async buildExceptionListArtifact(os: string): Promise { return buildArtifact( - await getEndpointExceptionList(this.exceptionListClient, this.schemaVersion, os), + await getEndpointExceptionList({ + elClient: this.exceptionListClient, + schemaVersion: this.schemaVersion, + os, + }), this.schemaVersion, os, ArtifactConstants.GLOBAL_ALLOWLIST_NAME @@ -171,7 +178,13 @@ export class ManifestManager { */ protected async buildTrustedAppsArtifact(os: string, policyId?: string) { return buildArtifact( - await getEndpointTrustedAppsList(this.exceptionListClient, this.schemaVersion, os, policyId), + await getEndpointExceptionList({ + elClient: this.exceptionListClient, + schemaVersion: this.schemaVersion, + os, + policyId, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }), this.schemaVersion, os, ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME @@ -231,13 +244,66 @@ export class ManifestManager { protected async buildEventFiltersForOs(os: string, policyId?: string) { return buildArtifact( - await getEndpointEventFiltersList(this.exceptionListClient, this.schemaVersion, os, policyId), + await getEndpointExceptionList({ + elClient: this.exceptionListClient, + schemaVersion: this.schemaVersion, + os, + policyId, + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + }), this.schemaVersion, os, ArtifactConstants.GLOBAL_EVENT_FILTERS_NAME ); } + /** + * Builds an array of Blocklist entries (one per supported OS) based on the current state of the + * Blocklist list + * @protected + */ + protected async buildBlocklistArtifacts(): Promise { + const defaultArtifacts: InternalArtifactCompleteSchema[] = []; + const policySpecificArtifacts: Record = {}; + + for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { + defaultArtifacts.push(await this.buildBlocklistForOs(os)); + } + + await iterateAllListItems( + (page) => this.listEndpointPolicyIds(page), + async (policyId) => { + for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { + policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; + policySpecificArtifacts[policyId].push(await this.buildBlocklistForOs(os, policyId)); + } + } + ); + + return { defaultArtifacts, policySpecificArtifacts }; + } + + protected async buildBlocklistForOs(os: string, policyId?: string) { + return buildArtifact( + await getEndpointExceptionList({ + elClient: this.exceptionListClient, + schemaVersion: this.schemaVersion, + os, + policyId, + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + }), + this.schemaVersion, + os, + ArtifactConstants.GLOBAL_BLOCKLISTS_NAME + ); + } + + /** + * Builds an array of endpoint host isolation exception (one per supported OS) based on the current state of the + * Host Isolation Exception List + * @returns + */ + protected async buildHostIsolationExceptionsArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; @@ -266,12 +332,13 @@ export class ManifestManager { policyId?: string ): Promise { return buildArtifact( - await getHostIsolationExceptionsList( - this.exceptionListClient, - this.schemaVersion, + await getEndpointExceptionList({ + elClient: this.exceptionListClient, + schemaVersion: this.schemaVersion, os, - policyId - ), + policyId, + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + }), this.schemaVersion, os, ArtifactConstants.GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME @@ -413,7 +480,7 @@ export class ManifestManager { * Builds a new manifest based on the current user exception list. * * @param baselineManifest A baseline manifest to use for initializing pre-existing artifacts. - * @returns {Promise} A new Manifest object reprenting the current exception list. + * @returns {Promise} A new Manifest object representing the current exception list. */ public async buildNewManifest( baselineManifest: Manifest = ManifestManager.createDefaultManifest(this.schemaVersion) @@ -423,6 +490,7 @@ export class ManifestManager { this.buildTrustedAppsArtifacts(), this.buildEventFiltersArtifacts(), this.buildHostIsolationExceptionsArtifacts(), + this.buildBlocklistArtifacts(), ]); const manifest = new Manifest({ From 688602188380bde224467711e2aaaecfb5163963 Mon Sep 17 00:00:00 2001 From: Diana Derevyankina <54894989+DziyanaDzeraviankina@users.noreply.github.com> Date: Fri, 4 Mar 2022 18:04:37 +0300 Subject: [PATCH 15/33] (Accessibility) Nested buttons in visualization editor (#125088) * (Accessibility) Nested buttons in visualization editor * Update tests and snapshots Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/agg.test.tsx.snap | 4 +- .../__snapshots__/agg_group.test.tsx.snap | 2 + .../public/components/agg.test.tsx | 4 +- .../public/components/agg.tsx | 52 +++++++------------ .../public/components/agg_group.tsx | 1 + 5 files changed, 25 insertions(+), 38 deletions(-) diff --git a/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap b/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap index b25444d16c46a2..dd9a9232692941 100644 --- a/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap +++ b/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap @@ -17,7 +17,7 @@ exports[`DefaultEditorAgg component should init with the default set of props 1` data-test-subj="visEditorAggAccordion1" element="div" extraAction={ -
+ -
+ } id="visEditorAggAccordion1" initialIsOpen={true} diff --git a/src/plugins/vis_default_editor/public/components/__snapshots__/agg_group.test.tsx.snap b/src/plugins/vis_default_editor/public/components/__snapshots__/agg_group.test.tsx.snap index 373ff6b4c3ee41..c9c7b91e8fc137 100644 --- a/src/plugins/vis_default_editor/public/components/__snapshots__/agg_group.test.tsx.snap +++ b/src/plugins/vis_default_editor/public/components/__snapshots__/agg_group.test.tsx.snap @@ -23,6 +23,7 @@ exports[`DefaultEditorAgg component should init with the default set of props 1` > { it('should not have actions', () => { const comp = shallow(); - const actions = shallow(comp.prop('extraAction')); + const actions = comp.prop('extraAction'); - expect(actions.children().exists()).toBeFalsy(); + expect(actions).toBeNull(); }); it('should have disable and remove actions', () => { diff --git a/src/plugins/vis_default_editor/public/components/agg.tsx b/src/plugins/vis_default_editor/public/components/agg.tsx index 0c1ddefa59e420..b813519d8caf99 100644 --- a/src/plugins/vis_default_editor/public/components/agg.tsx +++ b/src/plugins/vis_default_editor/public/components/agg.tsx @@ -13,7 +13,6 @@ import { EuiButtonIcon, EuiButtonIconProps, EuiSpacer, - EuiIconTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -198,6 +197,7 @@ function DefaultEditorAgg({ if (isDraggable) { actionIcons.push({ id: 'dragHandle', + color: 'text', type: 'grab', tooltip: i18n.translate('visDefaultEditor.agg.modifyPriorityButtonTooltip', { defaultMessage: 'Modify priority of {schemaTitle} {aggTitle} by dragging', @@ -219,39 +219,23 @@ function DefaultEditorAgg({ dataTestSubj: 'removeDimensionBtn', }); } - return ( -
- {actionIcons.map((icon) => { - if (icon.id === 'dragHandle') { - return ( - - ); - } - - return ( - - - - ); - })} -
- ); + return actionIcons.length ? ( + <> + {actionIcons.map((icon) => ( + + + + ))} + + ) : null; }; const buttonContent = ( diff --git a/src/plugins/vis_default_editor/public/components/agg_group.tsx b/src/plugins/vis_default_editor/public/components/agg_group.tsx index 03b06056c649c9..4d2ae02777663f 100644 --- a/src/plugins/vis_default_editor/public/components/agg_group.tsx +++ b/src/plugins/vis_default_editor/public/components/agg_group.tsx @@ -153,6 +153,7 @@ function DefaultEditorAggGroup({ index={index} draggableId={`agg_group_dnd_${groupName}_${agg.id}`} customDragHandle={true} + disableInteractiveElementBlocking // Allows button to be drag handle > {(provided) => ( Date: Fri, 4 Mar 2022 07:32:13 -0800 Subject: [PATCH 16/33] [DOCS] Add license expiration for searchable snapshot (#126865) Co-authored-by: Leaf-Lin <39002973+Leaf-Lin@users.noreply.github.com> --- docs/management/managing-licenses.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index 8944414f6bfbc8..cf501518ea5342 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -79,6 +79,7 @@ cluster. * The deprecation API is disabled. * SQL support is disabled. * Aggregations provided by the analytics plugin are no longer usable. +* All searchable snapshots indices are unassigned and cannot be searched. [discrete] [[expiration-watcher]] From 79935f3341f4d234b79ea0c5f7f3de817134556b Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Fri, 4 Mar 2022 11:19:22 -0500 Subject: [PATCH 17/33] [CI] Expand spot instance trial a bit (#126928) --- .buildkite/pipelines/hourly.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index e5bc841774fde5..8c1162954cfacb 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -19,7 +19,7 @@ steps: label: 'Default CI Group' parallelism: 27 agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 250 key: default-cigroup @@ -31,7 +31,7 @@ steps: - command: CI_GROUP=Docker .buildkite/scripts/steps/functional/xpack_cigroup.sh label: 'Docker CI Group' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 key: default-cigroup-docker @@ -44,7 +44,7 @@ steps: label: 'OSS CI Group' parallelism: 11 agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 key: oss-cigroup @@ -98,7 +98,7 @@ steps: - command: .buildkite/scripts/steps/functional/oss_firefox.sh label: 'OSS Firefox Tests' agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: @@ -130,7 +130,7 @@ steps: - command: .buildkite/scripts/steps/functional/oss_misc.sh label: 'OSS Misc Functional Tests' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: From 0b876fe350ff6f875bb8b3eb770d4f06c32ac0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emilio=20Alvarez=20Pi=C3=B1eiro?= <95703246+emilioalvap@users.noreply.github.com> Date: Fri, 4 Mar 2022 17:51:07 +0100 Subject: [PATCH 18/33] Add Synthetics flaky test runner pipeline config (#126602) * Add flaky test runner pipeline config * Add default value to grep expression if not set * Add kibana build id override * Add concurreny option (limited) --- .buildkite/scripts/steps/functional/uptime.sh | 2 +- .../uptime/.buildkite/pipelines/flaky.js | 117 ++++++++++++++++++ .../uptime/.buildkite/pipelines/flaky.sh | 8 ++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/uptime/.buildkite/pipelines/flaky.js create mode 100755 x-pack/plugins/uptime/.buildkite/pipelines/flaky.sh diff --git a/.buildkite/scripts/steps/functional/uptime.sh b/.buildkite/scripts/steps/functional/uptime.sh index 5a59f4dfa48bd7..a1c8c2bf6c85b1 100755 --- a/.buildkite/scripts/steps/functional/uptime.sh +++ b/.buildkite/scripts/steps/functional/uptime.sh @@ -14,4 +14,4 @@ echo "--- Uptime @elastic/synthetics Tests" cd "$XPACK_DIR" checks-reporter-with-killswitch "Uptime @elastic/synthetics Tests" \ - node plugins/uptime/scripts/e2e.js --kibana-install-dir "$KIBANA_BUILD_LOCATION" + node plugins/uptime/scripts/e2e.js --kibana-install-dir "$KIBANA_BUILD_LOCATION" ${GREP:+--grep \"${GREP}\"} diff --git a/x-pack/plugins/uptime/.buildkite/pipelines/flaky.js b/x-pack/plugins/uptime/.buildkite/pipelines/flaky.js new file mode 100644 index 00000000000000..6e12f8ca3c921a --- /dev/null +++ b/x-pack/plugins/uptime/.buildkite/pipelines/flaky.js @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const { execSync } = require('child_process'); + +// List of steps generated dynamically from this jobs +const steps = []; +const pipeline = { + env: { + IGNORE_SHIP_CI_STATS_ERROR: 'true', + }, + steps: steps, +}; + +// Default config +const defaultCount = 25; +const maxCount = 500; +const defaultConcurrency = 25; +const maxConcurrency = 50; +const initialJobs = 2; + +const UUID = process.env.UUID; +const KIBANA_BUILD_ID = 'KIBANA_BUILD_ID'; +const BUILD_UUID = 'build'; + +// Metada keys, should match the ones specified in pipeline step configuration +const E2E_COUNT = 'e2e/count'; +const E2E_CONCURRENCY = 'e2e/concurrent'; +const E2E_GREP = 'e2e/grep'; +const E2E_ARTIFACTS_ID = 'e2e/build-id'; + +const env = getEnvFromMetadata(); + +const totalJobs = env[E2E_COUNT] + initialJobs; + +if (totalJobs > maxCount) { + console.error('+++ Too many steps'); + console.error( + `Buildkite builds can only contain 500 steps in total. Found ${totalJobs} in total. Make sure your test runs are less than ${ + maxCount - initialJobs + }` + ); + process.exit(1); +} + +// If build id is provided, export it so build step is skipped +pipeline.env[KIBANA_BUILD_ID] = env[E2E_ARTIFACTS_ID]; + +// Build job first +steps.push(getBuildJob()); +steps.push(getGroupRunnerJob(env)); + +console.log(JSON.stringify(pipeline, null, 2)); + +/*** + * Utils + */ + +function getBuildJob() { + return { + command: '.buildkite/scripts/steps/build_kibana.sh', + label: 'Build Kibana Distribution and Plugins', + agents: { queue: 'c2-8' }, + key: BUILD_UUID, + if: `build.env('${KIBANA_BUILD_ID}') == null || build.env('${KIBANA_BUILD_ID}') == ''`, + }; +} + +function getGroupRunnerJob(env) { + return { + command: `${ + env[E2E_GREP] ? `GREP="${env[E2E_GREP]}" ` : '' + }.buildkite/scripts/steps/functional/uptime.sh`, + label: `Uptime E2E - Synthetics runner`, + agents: { queue: 'n2-4' }, + depends_on: BUILD_UUID, + parallelism: env[E2E_COUNT], + concurrency: env[E2E_CONCURRENCY], + concurrency_group: UUID, + concurrency_method: 'eager', + }; +} + +function getEnvFromMetadata() { + const env = {}; + + env[E2E_COUNT] = getIntValue(E2E_COUNT, defaultCount); + env[E2E_CONCURRENCY] = getIntValue(E2E_CONCURRENCY, defaultConcurrency); + env[E2E_GREP] = getStringValue(E2E_GREP); + env[E2E_ARTIFACTS_ID] = getStringValue(E2E_ARTIFACTS_ID); + + env[E2E_CONCURRENCY] = + env[E2E_CONCURRENCY] > maxConcurrency ? maxConcurrency : env[E2E_CONCURRENCY]; + + return env; +} + +function getIntValue(key, defaultValue) { + let value = defaultValue; + const cli = execSync(`buildkite-agent meta-data get '${key}' --default ${defaultValue} `) + .toString() + .trim(); + + try { + value = parseInt(cli, 10); + } finally { + return value; + } +} + +function getStringValue(key) { + return execSync(`buildkite-agent meta-data get '${key}' --default ''`).toString().trim(); +} diff --git a/x-pack/plugins/uptime/.buildkite/pipelines/flaky.sh b/x-pack/plugins/uptime/.buildkite/pipelines/flaky.sh new file mode 100755 index 00000000000000..742435f6bec283 --- /dev/null +++ b/x-pack/plugins/uptime/.buildkite/pipelines/flaky.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +UUID="$(cat /proc/sys/kernel/random/uuid)" +export UUID + +node x-pack/plugins/uptime/.buildkite/pipelines/flaky.js | buildkite-agent pipeline upload From e397dabe62914cff2feb0d7722a6a645b1d36cab Mon Sep 17 00:00:00 2001 From: Paulo Henrique Date: Fri, 4 Mar 2022 13:54:37 -0300 Subject: [PATCH 19/33] [Security Solution] Session View Plugin (#124575) Co-authored-by: mitodrummer Co-authored-by: Jan Monschke Co-authored-by: Paulo Henrique Co-authored-by: Jack Co-authored-by: Karl Godard Co-authored-by: Jiawei Wu Co-authored-by: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Co-authored-by: Ricky Ang Co-authored-by: Rickyanto Ang Co-authored-by: Jack Co-authored-by: Maxwell Borden Co-authored-by: Maxwell Borden --- .github/CODEOWNERS | 3 + docs/developer/plugin-list.asciidoc | 4 + package.json | 2 +- packages/kbn-optimizer/limits.yml | 3 +- x-pack/.i18nrc.json | 1 + x-pack/plugins/session_view/.eslintrc.json | 5 + x-pack/plugins/session_view/README.md | 36 + .../plugins/session_view/common/constants.ts | 27 + .../constants/session_view_process.mock.ts | 951 +++++++++++++ .../session_view_process_events.mock.ts | 1236 +++++++++++++++++ .../common/types/process_tree/index.ts | 163 +++ .../common/utils/expand_dotted_object.test.ts | 41 + .../common/utils/expand_dotted_object.ts | 52 + .../common/utils/sort_processes.test.ts | 30 + .../common/utils/sort_processes.ts | 23 + x-pack/plugins/session_view/jest.config.js | 18 + x-pack/plugins/session_view/kibana.json | 19 + x-pack/plugins/session_view/package.json | 11 + .../detail_panel_accordion/index.test.tsx | 77 + .../detail_panel_accordion/index.tsx | 76 + .../detail_panel_accordion/styles.ts | 40 + .../detail_panel_copy/index.test.tsx | 33 + .../components/detail_panel_copy/index.tsx | 59 + .../components/detail_panel_copy/styles.ts | 30 + .../index.test.tsx | 51 + .../detail_panel_description_list/index.tsx | 33 + .../detail_panel_description_list/styles.ts | 40 + .../detail_panel_host_tab/index.test.tsx | 88 ++ .../detail_panel_host_tab/index.tsx | 161 +++ .../detail_panel_list_item/index.test.tsx | 61 + .../detail_panel_list_item/index.tsx | 51 + .../detail_panel_list_item/styles.ts | 46 + .../detail_panel_process_tab/helpers.test.ts | 36 + .../detail_panel_process_tab/helpers.ts | 28 + .../detail_panel_process_tab/index.test.tsx | 79 ++ .../detail_panel_process_tab/index.tsx | 255 ++++ .../detail_panel_process_tab/styles.ts | 41 + .../components/process_tree/helpers.test.ts | 76 + .../public/components/process_tree/helpers.ts | 170 +++ .../components/process_tree/hooks.test.tsx | 29 + .../public/components/process_tree/hooks.ts | 255 ++++ .../components/process_tree/index.test.tsx | 91 ++ .../public/components/process_tree/index.tsx | 179 +++ .../public/components/process_tree/styles.ts | 49 + .../process_tree_alerts/index.test.tsx | 54 + .../components/process_tree_alerts/index.tsx | 95 ++ .../components/process_tree_alerts/styles.ts | 45 + .../components/process_tree_node/buttons.tsx | 105 ++ .../process_tree_node/index.test.tsx | 200 +++ .../components/process_tree_node/index.tsx | 213 +++ .../components/process_tree_node/styles.ts | 118 ++ .../process_tree_node/use_button_styles.ts | 62 + .../public/components/session_view/hooks.ts | 91 ++ .../components/session_view/index.test.tsx | 104 ++ .../public/components/session_view/index.tsx | 205 +++ .../public/components/session_view/styles.ts | 36 + .../session_view_detail_panel/helpers.ts | 63 + .../session_view_detail_panel/index.test.tsx | 40 + .../session_view_detail_panel/index.tsx | 82 ++ .../session_view_search_bar/index.test.tsx | 95 ++ .../session_view_search_bar/index.tsx | 70 + .../session_view_search_bar/styles.ts | 28 + .../session_view/public/hooks/use_scroll.ts | 51 + x-pack/plugins/session_view/public/index.ts | 12 + .../session_view/public/methods/index.tsx | 25 + x-pack/plugins/session_view/public/plugin.ts | 22 + .../session_view/public/shared_imports.ts | 8 + .../session_view/public/test/index.tsx | 137 ++ x-pack/plugins/session_view/public/types.ts | 49 + .../public/utils/data_or_dash.test.ts | 30 + .../session_view/public/utils/data_or_dash.ts | 22 + x-pack/plugins/session_view/server/index.ts | 13 + x-pack/plugins/session_view/server/plugin.ts | 44 + .../session_view/server/routes/index.ts | 14 + .../routes/process_events_route.test.ts | 57 + .../server/routes/process_events_route.ts | 85 ++ .../routes/session_entry_leaders_route.ts | 37 + x-pack/plugins/session_view/server/types.ts | 11 + x-pack/plugins/session_view/tsconfig.json | 42 + yarn.lock | 8 +- 80 files changed, 7126 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/session_view/.eslintrc.json create mode 100644 x-pack/plugins/session_view/README.md create mode 100644 x-pack/plugins/session_view/common/constants.ts create mode 100644 x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts create mode 100644 x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts create mode 100644 x-pack/plugins/session_view/common/types/process_tree/index.ts create mode 100644 x-pack/plugins/session_view/common/utils/expand_dotted_object.test.ts create mode 100644 x-pack/plugins/session_view/common/utils/expand_dotted_object.ts create mode 100644 x-pack/plugins/session_view/common/utils/sort_processes.test.ts create mode 100644 x-pack/plugins/session_view/common/utils/sort_processes.ts create mode 100644 x-pack/plugins/session_view/jest.config.js create mode 100644 x-pack/plugins/session_view/kibana.json create mode 100644 x-pack/plugins/session_view/package.json create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_copy/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_copy/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_copy/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_list_item/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree/helpers.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree/hooks.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree/hooks.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_node/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_node/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts create mode 100644 x-pack/plugins/session_view/public/components/session_view/hooks.ts create mode 100644 x-pack/plugins/session_view/public/components/session_view/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.ts create mode 100644 x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/session_view_search_bar/styles.ts create mode 100644 x-pack/plugins/session_view/public/hooks/use_scroll.ts create mode 100644 x-pack/plugins/session_view/public/index.ts create mode 100644 x-pack/plugins/session_view/public/methods/index.tsx create mode 100644 x-pack/plugins/session_view/public/plugin.ts create mode 100644 x-pack/plugins/session_view/public/shared_imports.ts create mode 100644 x-pack/plugins/session_view/public/test/index.tsx create mode 100644 x-pack/plugins/session_view/public/types.ts create mode 100644 x-pack/plugins/session_view/public/utils/data_or_dash.test.ts create mode 100644 x-pack/plugins/session_view/public/utils/data_or_dash.ts create mode 100644 x-pack/plugins/session_view/server/index.ts create mode 100644 x-pack/plugins/session_view/server/plugin.ts create mode 100644 x-pack/plugins/session_view/server/routes/index.ts create mode 100644 x-pack/plugins/session_view/server/routes/process_events_route.test.ts create mode 100644 x-pack/plugins/session_view/server/routes/process_events_route.ts create mode 100644 x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts create mode 100644 x-pack/plugins/session_view/server/types.ts create mode 100644 x-pack/plugins/session_view/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 63e335067199d0..691daa042bba95 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -416,6 +416,9 @@ x-pack/plugins/security_solution/cypress/upgrade_integration @elastic/security-e x-pack/plugins/security_solution/cypress/README.md @elastic/security-engineering-productivity x-pack/test/security_solution_cypress @elastic/security-engineering-productivity +## Security Solution sub teams - adaptive-workload-protection +x-pack/plugins/session_view @elastic/awp-platform + # Security Intelligence And Analytics /x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules @elastic/security-intelligence-analytics diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 2de3fc3000ac56..c26a748839daf1 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -584,6 +584,10 @@ Kibana. |Welcome to the Kibana Security Solution plugin! This README will go over getting started with development and testing. +|{kib-repo}blob/{branch}/x-pack/plugins/session_view/README.md[sessionView] +|Session View is meant to provide a visualization into what is going on in a particular Linux environment where the agent is running. It looks likes a terminal emulator; however, it is a tool for introspecting process activity and understanding user and service behaviour in your Linux servers and infrastructure. It is a time-ordered series of process executions displayed in a tree over time. + + |{kib-repo}blob/{branch}/x-pack/plugins/snapshot_restore/README.md[snapshotRestore] |or diff --git a/package.json b/package.json index 6c313ac834af7b..baf1103a8ef5cc 100644 --- a/package.json +++ b/package.json @@ -347,7 +347,7 @@ "react-moment-proptypes": "^1.7.0", "react-monaco-editor": "^0.41.2", "react-popper-tooltip": "^2.10.1", - "react-query": "^3.34.0", + "react-query": "^3.34.7", "react-redux": "^7.2.0", "react-resizable": "^1.7.5", "react-resize-detector": "^4.2.0", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index f9f0bfc4fd29ec..afe7fcd9ddc867 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -121,5 +121,6 @@ pageLoadAssetSize: expressionPartitionVis: 26338 sharedUX: 16225 ux: 20784 - visTypeGauge: 24113 + sessionView: 77750 cloudSecurityPosture: 19109 + visTypeGauge: 24113 \ No newline at end of file diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index c48041c1e18839..dfe34988c4d270 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -52,6 +52,7 @@ "xpack.security": "plugins/security", "xpack.server": "legacy/server", "xpack.securitySolution": "plugins/security_solution", + "xpack.sessionView": "plugins/session_view", "xpack.snapshotRestore": "plugins/snapshot_restore", "xpack.spaces": "plugins/spaces", "xpack.savedObjectsTagging": ["plugins/saved_objects_tagging"], diff --git a/x-pack/plugins/session_view/.eslintrc.json b/x-pack/plugins/session_view/.eslintrc.json new file mode 100644 index 00000000000000..2aab6c2d9093b6 --- /dev/null +++ b/x-pack/plugins/session_view/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "@typescript-eslint/consistent-type-definitions": 0 + } +} diff --git a/x-pack/plugins/session_view/README.md b/x-pack/plugins/session_view/README.md new file mode 100644 index 00000000000000..384be8bcc292b5 --- /dev/null +++ b/x-pack/plugins/session_view/README.md @@ -0,0 +1,36 @@ +# Session View + +Session View is meant to provide a visualization into what is going on in a particular Linux environment where the agent is running. It looks likes a terminal emulator; however, it is a tool for introspecting process activity and understanding user and service behaviour in your Linux servers and infrastructure. It is a time-ordered series of process executions displayed in a tree over time. + +It provides an audit trail of: + +- Interactive processes being entered by a user into the terminal - User Input +- Processes and services which do not have a controlling tty (ie are not interactive) +- Output which is generated as a result of process activity - Output +- Nested sessions inside the entry session - Nested session (Note: For now nested sessions will display as they did at Cmd with no special handling for TMUX) +- Full telemetry about the process initiated event. This will include the information specified in the Linux logical event model +- Who executed the session or process, even if the user changes. + +## Development + +## Tests + +### Unit tests + +From kibana path in your terminal go to this plugin root: + +```bash +cd x-pack/plugins/session_view +``` + +Then run jest with: + +```bash +yarn test:jest +``` + +Or if running from kibana root, you can specify the `-i` to specify the path: + +```bash +yarn test:jest -i x-pack/plugins/session_view/ +``` diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts new file mode 100644 index 00000000000000..5baf690dc44a53 --- /dev/null +++ b/x-pack/plugins/session_view/common/constants.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PROCESS_EVENTS_ROUTE = '/internal/session_view/process_events_route'; +export const SESSION_ENTRY_LEADERS_ROUTE = '/internal/session_view/session_entry_leaders_route'; +export const PROCESS_EVENTS_INDEX = 'logs-endpoint.events.process-default'; +export const ALERTS_INDEX = '.siem-signals-default'; +export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id'; + +// We fetch a large number of events per page to mitigate a few design caveats in session viewer +// 1. Due to the hierarchical nature of the data (e.g we are rendering a time ordered pid tree) there are common scenarios where there +// are few top level processes, but many nested children. For example, a build script is run on a remote host via ssh. If for example our page +// size is 10 and the build script has 500 nested children, the user would see a load more button that they could continously click without seeing +// anychange since the next 10 events would be for processes nested under a top level process that might not be expanded. That being said, it's quite +// possible there are build scripts with many thousands of events, in which case this initial large page will have the same issue. A technique used +// in previous incarnations of session view included auto expanding the node which is receiving the new page of events so as to not confuse the user. +// We may need to include this trick as part of this implementation as well. +// 2. The plain text search that comes with Session view is currently limited in that it only searches through data that has been loaded into the browser. +// The large page size allows the user to get a broader set of results per page. That being said, this feature is kind of flawed since sessions could be many thousands +// if not 100s of thousands of events, and to be required to page through these sessions to find more search matches is not a great experience. Future iterations of the +// search functionality will instead use a separate ES backend search to avoid this. +// 3. Fewer round trips to the backend! +export const PROCESS_EVENTS_PER_PAGE = 1000; diff --git a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts new file mode 100644 index 00000000000000..b7b0bbb91b5ec2 --- /dev/null +++ b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts @@ -0,0 +1,951 @@ +/* + * 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 { + Process, + ProcessEvent, + ProcessEventsPage, + ProcessFields, + EventAction, + EventKind, + ProcessMap, +} from '../../types/process_tree'; + +export const mockEvents: ProcessEvent[] = [ + { + '@timestamp': '2021-11-23T15:25:04.210Z', + user: { + name: '', + id: '1000', + }, + process: { + pid: 3535, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: false, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 0, + args: [], + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + }, + event: { + action: EventAction.fork, + category: 'process', + kind: EventKind.event, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, + { + '@timestamp': '2021-11-23T15:25:04.218Z', + user: { + name: '', + id: '1000', + }, + process: { + pid: 3535, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 3535, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.218Z', + }, + event: { + action: EventAction.exec, + category: 'process', + kind: EventKind.event, + }, + }, + { + '@timestamp': '2021-11-23T15:25:05.202Z', + user: { + name: '', + id: '1000', + }, + process: { + pid: 3535, + exit_code: 137, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3728', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.202Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.202Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.202Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 3535, + exit_code: 137, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3728', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + start: '2021-11-23T15:25:05.202Z', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + }, + event: { + action: EventAction.end, + category: 'process', + kind: EventKind.event, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, +] as ProcessEvent[]; + +export const mockAlerts: ProcessEvent[] = [ + { + kibana: { + alert: { + rule: { + category: 'Custom Query Rule', + consumer: 'siem', + name: 'cmd test alert', + uuid: '709d3890-4c71-11ec-8c67-01ccde9db9bf', + enabled: true, + description: 'cmd test alert', + risk_score: 21, + severity: 'low', + query: "process.executable: '/usr/bin/vi'", + }, + status: 'active', + workflow_status: 'open', + reason: 'process event created low alert cmd test alert.', + original_time: new Date('2021-11-23T15:25:04.218Z'), + original_event: { + action: 'exec', + }, + uuid: '6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38', + }, + }, + '@timestamp': '2021-11-23T15:26:34.859Z', + user: { + name: 'vagrant', + id: '1000', + }, + process: { + pid: 3535, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + event: { + action: EventAction.exec, + category: 'process', + kind: EventKind.signal, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, + { + kibana: { + alert: { + rule: { + category: 'Custom Query Rule', + consumer: 'siem', + name: 'cmd test alert', + uuid: '709d3890-4c71-11ec-8c67-01ccde9db9bf', + enabled: true, + description: 'cmd test alert', + risk_score: 21, + severity: 'low', + query: "process.executable: '/usr/bin/vi'", + }, + status: 'active', + workflow_status: 'open', + reason: 'process event created low alert cmd test alert.', + original_time: new Date('2021-11-23T15:25:05.202Z'), + original_event: { + action: 'exit', + }, + uuid: '2873463965b70d37ab9b2b3a90ac5a03b88e76e94ad33568285cadcefc38ed75', + }, + }, + '@timestamp': '2021-11-23T15:26:34.860Z', + user: { + name: 'vagrant', + id: '1000', + }, + process: { + pid: 3535, + exit_code: 137, + executable: '/usr/bin/vi', + command_line: 'bash', + interactive: true, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + parent: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + pid: 2442, + user: { + name: '', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + name: '', + args_count: 2, + args: ['vi', 'cmd/config.ini'], + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.860Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + event: { + action: EventAction.end, + category: 'process', + kind: EventKind.signal, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + }, +]; + +export const mockData: ProcessEventsPage[] = [ + { + events: mockEvents, + cursor: '2021-11-23T15:25:04.210Z', + }, +]; + +export const childProcessMock: Process = { + id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bd', + events: [], + children: [], + autoExpand: false, + searchMatched: null, + parent: undefined, + orphans: [], + addEvent: (_) => undefined, + clearSearch: () => undefined, + getChildren: () => [], + hasOutput: () => false, + hasAlerts: () => false, + getAlerts: () => [], + hasExec: () => false, + getOutput: () => '', + getDetails: () => + ({ + '@timestamp': '2021-11-23T15:25:05.210Z', + event: { + kind: EventKind.event, + category: 'process', + action: EventAction.exec, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '1', + name: 'vagrant', + }, + process: { + args: ['ls', '-l'], + args_count: 2, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bd', + executable: '/bin/ls', + interactive: true, + name: 'ls', + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:05.210Z', + pid: 2, + parent: { + args: ['bash'], + args_count: 1, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + executable: '/bin/bash', + interactive: false, + name: '', + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + pid: 1, + user: { + id: '1', + name: 'vagrant', + }, + }, + session_leader: {} as ProcessFields, + entry_leader: {} as ProcessFields, + group_leader: {} as ProcessFields, + }, + } as ProcessEvent), + isUserEntered: () => false, + getMaxAlertLevel: () => null, +}; + +export const processMock: Process = { + id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + events: [], + children: [], + autoExpand: false, + searchMatched: null, + parent: undefined, + orphans: [], + addEvent: (_) => undefined, + clearSearch: () => undefined, + getChildren: () => [], + hasOutput: () => false, + hasAlerts: () => false, + getAlerts: () => [], + hasExec: () => false, + getOutput: () => '', + getDetails: () => + ({ + '@timestamp': '2021-11-23T15:25:04.210Z', + event: { + kind: EventKind.event, + category: 'process', + action: EventAction.exec, + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '1', + name: 'vagrant', + }, + process: { + args: ['bash'], + args_count: 1, + entity_id: '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726', + executable: '/bin/bash', + exit_code: 137, + interactive: false, + name: '', + working_directory: '/home/vagrant', + start: '2021-11-23T15:25:04.210Z', + pid: 1, + parent: {} as ProcessFields, + session_leader: {} as ProcessFields, + entry_leader: {} as ProcessFields, + group_leader: {} as ProcessFields, + }, + } as ProcessEvent), + isUserEntered: () => false, + getMaxAlertLevel: () => null, +}; + +export const sessionViewBasicProcessMock: Process = { + ...processMock, + events: mockEvents, + hasExec: () => true, + isUserEntered: () => true, +}; + +export const sessionViewAlertProcessMock: Process = { + ...processMock, + events: [...mockEvents, ...mockAlerts], + hasAlerts: () => true, + getAlerts: () => mockEvents, + hasExec: () => true, + isUserEntered: () => true, +}; + +export const mockProcessMap = mockEvents.reduce( + (processMap, event) => { + processMap[event.process.entity_id] = { + id: event.process.entity_id, + events: [event], + children: [], + parent: undefined, + autoExpand: false, + searchMatched: null, + orphans: [], + addEvent: (_) => undefined, + clearSearch: () => undefined, + getChildren: () => [], + hasOutput: () => false, + hasAlerts: () => false, + getAlerts: () => [], + hasExec: () => false, + getOutput: () => '', + getDetails: () => event, + isUserEntered: () => false, + getMaxAlertLevel: () => null, + }; + return processMap; + }, + { + [sessionViewBasicProcessMock.id]: sessionViewBasicProcessMock, + } as ProcessMap +); diff --git a/x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts b/x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts new file mode 100644 index 00000000000000..47849f859ba9c1 --- /dev/null +++ b/x-pack/plugins/session_view/common/mocks/responses/session_view_process_events.mock.ts @@ -0,0 +1,1236 @@ +/* + * 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 { ProcessEventResults } from '../../types/process_tree'; + +export const sessionViewProcessEventsMock: ProcessEventResults = { + events: [ + { + _index: 'cmd', + _id: 'FMUGTX0BGGlsPv9flMF7', + _score: null, + _source: { + '@timestamp': '2021-11-23T13:40:16.528Z', + event: { + kind: 'event', + category: 'process', + action: 'fork', + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + Ext: { + variant: 'CentOS', + }, + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + // To keep backwards compat and avoid data duplication. We keep user/group info for top level process at the top level + id: '0', // the effective user aka euid + name: 'root', + real: { + // ruid + id: '2', + name: 'kg', + }, + saved: { + // suid + id: '2', + name: 'kg', + }, + }, + group: { + id: '1', // the effective group aka egid + name: 'groupA', + real: { + // rgid + id: '1', + name: 'groupA', + }, + saved: { + // sgid + id: '1', + name: 'groupA', + }, + }, + process: { + entity_id: '4321', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: false, + working_directory: '/', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + parent: { + entity_id: '4322', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: true, + working_directory: '/', + pid: 2, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '2', + name: 'kg', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + group_leader: { + entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + pid: 1234, // this directly replaces parent.pgid + start: '2021-10-14T08:05:34.853Z', + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the session_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the entry_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + entry_meta: { + type: 'sshd', + source: { + ip: '10.132.0.50', + geo: { + city_name: 'Vancouver', + continent_code: 'NA', + continent_name: 'North America', + country_iso_code: 'CA', + country_name: 'Canada', + location: { + lon: -73.61483, + lat: 45.505918, + }, + postal_code: 'V9J1E3', + region_iso_code: 'BC', + region_name: 'British Columbia', + timezone: 'America/Los_Angeles', + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + { + descriptor: 1, + type: 'pipe', + pipe: { + inode: '6183207', + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + }, + sort: [1637674816528], + }, + { + _index: 'cmd', + _id: 'FsUGTX0BGGlsPv9flMGF', + _score: null, + _source: { + '@timestamp': '2021-11-23T13:40:16.541Z', + event: { + kind: 'event', + category: 'process', + action: 'exec', + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + Ext: { + variant: 'CentOS', + }, + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '2', + name: 'kg', + real: { + id: '2', + name: 'kg', + }, + saved: { + id: '2', + name: 'kg', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + process: { + entity_id: '4321', + args: ['/bin/bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + previous: [{ args: ['/bin/sshd'], args_count: 1, executable: '/bin/sshd' }], + parent: { + entity_id: '4322', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: true, + working_directory: '/', + pid: 2, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + group_leader: { + entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + pid: 1234, // this directly replaces parent.pgid + start: '2021-10-14T08:05:34.853Z', + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the session_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the entry_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + entry_meta: { + type: 'sshd', + source: { + ip: '10.132.0.50', + geo: { + city_name: 'Vancouver', + continent_code: 'NA', + continent_name: 'North America', + country_iso_code: 'CA', + country_name: 'Canada', + location: { + lon: -73.61483, + lat: 45.505918, + }, + postal_code: 'V9J1E3', + region_iso_code: 'BC', + region_name: 'British Columbia', + timezone: 'America/Los_Angeles', + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + { + descriptor: 1, + type: 'pipe', + pipe: { + inode: '6183207', + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + }, + sort: [1637674816541], + }, + { + _index: 'cmd', + _id: 'H8UGTX0BGGlsPv9fp8F_', + _score: null, + _source: { + '@timestamp': '2021-11-23T13:40:21.392Z', + event: { + kind: 'event', + category: 'process', + action: 'end', + }, + host: { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809', + mac: '42:01:0a:84:00:32', + name: 'james-fleet-714-2', + os: { + Ext: { + variant: 'CentOS', + }, + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, + }, + user: { + id: '2', + name: 'kg', + real: { + id: '2', + name: 'kg', + }, + saved: { + id: '2', + name: 'kg', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + process: { + entity_id: '4321', + args: ['/bin/bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + end: '2021-10-14T10:05:34.853Z', + exit_code: 137, + previous: [{ args: ['/bin/sshd'], args_count: 1, executable: '/bin/sshd' }], + parent: { + entity_id: '4322', + args: ['/bin/sshd'], + args_count: 1, + command_line: 'sshd', + executable: '/bin/sshd', + name: 'sshd', + interactive: true, + working_directory: '/', + pid: 2, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + group_leader: { + entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + pid: 1234, // this directly replaces parent.pgid + start: '2021-10-14T08:05:34.853Z', + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + group_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + session_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the session_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + entry_leader: { + entity_id: '4321', + args: ['bash'], + args_count: 1, + command_line: 'bash', + executable: '/bin/bash', + name: 'bash', + interactive: true, + working_directory: '/home/kg', + pid: 3, + start: '2021-10-14T08:05:34.853Z', + user: { + id: '0', + name: 'root', + real: { + id: '0', + name: 'root', + }, + saved: { + id: '0', + name: 'root', + }, + }, + group: { + id: '1', + name: 'groupA', + real: { + id: '1', + name: 'groupA', + }, + saved: { + id: '1', + name: 'groupA', + }, + supplemental: [ + { + id: '2', + name: 'groupB', + }, + { + id: '3', + name: 'groupC', + }, + ], + }, + // parent: { + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 2, + // start: '2021-10-14T08:05:34.853Z', + // session_leader: { + // // used as a foreign key to the parent session of the entry_leader + // entity_id: '0fe5f6a0-6f04-49a5-8faf-768445b38d16', + // pid: 4321, + // start: '2021-10-14T08:05:34.853Z', + // }, + // }, + entry_meta: { + type: 'sshd', + source: { + ip: '10.132.0.50', + geo: { + city_name: 'Vancouver', + continent_code: 'NA', + continent_name: 'North America', + country_iso_code: 'CA', + country_name: 'Canada', + location: { + lon: -73.61483, + lat: 45.505918, + }, + postal_code: 'V9J1E3', + region_iso_code: 'BC', + region_name: 'British Columbia', + timezone: 'America/Los_Angeles', + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + file_descriptions: [ + { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + { + descriptor: 1, + type: 'pipe', + pipe: { + inode: '6183207', + }, + }, + ], + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + }, + }, + sort: [1637674821392], + }, + ], +}; diff --git a/x-pack/plugins/session_view/common/types/process_tree/index.ts b/x-pack/plugins/session_view/common/types/process_tree/index.ts new file mode 100644 index 00000000000000..746c1b2093661b --- /dev/null +++ b/x-pack/plugins/session_view/common/types/process_tree/index.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const enum EventKind { + event = 'event', + signal = 'signal', +} + +export const enum EventAction { + fork = 'fork', + exec = 'exec', + end = 'end', + output = 'output', +} + +export interface User { + id: string; + name: string; +} + +export interface ProcessEventResults { + events: any[]; +} + +export type EntryMetaType = + | 'init' + | 'sshd' + | 'ssm' + | 'kubelet' + | 'teleport' + | 'terminal' + | 'console'; + +export interface EntryMeta { + type: EntryMetaType; + source: { + ip: string; + }; +} + +export interface Teletype { + descriptor: number; + type: string; + char_device: { + major: number; + minor: number; + }; +} + +export interface ProcessFields { + entity_id: string; + args: string[]; + args_count: number; + command_line: string; + executable: string; + name: string; + interactive: boolean; + working_directory: string; + pid: number; + start: string; + end?: string; + user: User; + exit_code?: number; + entry_meta?: EntryMeta; + tty: Teletype; +} + +export interface ProcessSelf extends Omit { + parent: ProcessFields; + session_leader: ProcessFields; + entry_leader: ProcessFields; + group_leader: ProcessFields; +} + +export interface ProcessEventHost { + architecture: string; + hostname: string; + id: string; + ip: string; + mac: string; + name: string; + os: { + family: string; + full: string; + kernel: string; + name: string; + platform: string; + version: string; + }; +} + +export interface ProcessEventAlertRule { + category: string; + consumer: string; + description: string; + enabled: boolean; + name: string; + query: string; + risk_score: number; + severity: string; + uuid: string; +} + +export interface ProcessEventAlert { + uuid: string; + reason: string; + workflow_status: string; + status: string; + original_time: Date; + original_event: { + action: string; + }; + rule: ProcessEventAlertRule; +} + +export interface ProcessEvent { + '@timestamp': string; + event: { + kind: EventKind; + category: string; + action: EventAction; + }; + user: User; + host: ProcessEventHost; + process: ProcessSelf; + kibana?: { + alert: ProcessEventAlert; + }; +} + +export interface ProcessEventsPage { + events: ProcessEvent[]; + cursor: string; +} + +export interface Process { + id: string; // the process entity_id + events: ProcessEvent[]; + children: Process[]; + orphans: Process[]; // currently, orphans are rendered inline with the entry session leaders children + parent: Process | undefined; + autoExpand: boolean; + searchMatched: string | null; // either false, or set to searchQuery + addEvent(event: ProcessEvent): void; + clearSearch(): void; + hasOutput(): boolean; + hasAlerts(): boolean; + getAlerts(): ProcessEvent[]; + hasExec(): boolean; + getOutput(): string; + getDetails(): ProcessEvent; + isUserEntered(): boolean; + getMaxAlertLevel(): number | null; + getChildren(verboseMode: boolean): Process[]; +} + +export type ProcessMap = { + [key: string]: Process; +}; diff --git a/x-pack/plugins/session_view/common/utils/expand_dotted_object.test.ts b/x-pack/plugins/session_view/common/utils/expand_dotted_object.test.ts new file mode 100644 index 00000000000000..a4a4845e759e7a --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/expand_dotted_object.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expandDottedObject } from './expand_dotted_object'; + +const testFlattenedObj = { + 'flattened.property.a': 'valueA', + 'flattened.property.b': 'valueB', + regularProp: { + nestedProp: 'nestedValue', + }, + 'nested.array': [ + { + arrayProp: 'arrayValue', + }, + ], + emptyArray: [], +}; +describe('expandDottedObject(obj)', () => { + it('retrieves values from flattened keys', () => { + const expanded: any = expandDottedObject(testFlattenedObj); + + expect(expanded.flattened.property.a).toEqual('valueA'); + expect(expanded.flattened.property.b).toEqual('valueB'); + }); + it('retrieves values from nested keys', () => { + const expanded: any = expandDottedObject(testFlattenedObj); + + expect(Array.isArray(expanded.nested.array)).toBeTruthy(); + expect(expanded.nested.array[0].arrayProp).toEqual('arrayValue'); + }); + it("doesn't break regular value access", () => { + const expanded: any = expandDottedObject(testFlattenedObj); + + expect(expanded.regularProp.nestedProp).toEqual('nestedValue'); + }); +}); diff --git a/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts b/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts new file mode 100644 index 00000000000000..69a9cb8236cbce --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { merge } from '@kbn/std'; + +const expandDottedField = (dottedFieldName: string, val: unknown): object => { + const parts = dottedFieldName.split('.'); + if (parts.length === 1) { + return { [parts[0]]: val }; + } else { + return { [parts[0]]: expandDottedField(parts.slice(1).join('.'), val) }; + } +}; + +/* + * Expands an object with "dotted" fields to a nested object with unflattened fields. + * + * Example: + * expandDottedObject({ + * "kibana.alert.depth": 1, + * "kibana.alert.ancestors": [{ + * id: "d5e8eb51-a6a0-456d-8a15-4b79bfec3d71", + * type: "event", + * index: "signal_index", + * depth: 0, + * }], + * }) + * + * => { + * kibana: { + * alert: { + * ancestors: [ + * id: "d5e8eb51-a6a0-456d-8a15-4b79bfec3d71", + * type: "event", + * index: "signal_index", + * depth: 0, + * ], + * depth: 1, + * }, + * }, + * } + */ +export const expandDottedObject = (dottedObj: object) => { + return Object.entries(dottedObj).reduce( + (acc, [key, val]) => merge(acc, expandDottedField(key, val)), + {} + ); +}; diff --git a/x-pack/plugins/session_view/common/utils/sort_processes.test.ts b/x-pack/plugins/session_view/common/utils/sort_processes.test.ts new file mode 100644 index 00000000000000..b1db5381954dcb --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/sort_processes.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { sortProcesses } from './sort_processes'; +import { mockProcessMap } from '../mocks/constants/session_view_process.mock'; + +describe('sortProcesses(a, b)', () => { + it('sorts processes in ascending order by start time', () => { + const processes = Object.values(mockProcessMap); + + // shuffle some things to ensure all sort lines are hit + const c = processes[0]; + processes[0] = processes[processes.length - 1]; + processes[processes.length - 1] = c; + + processes.sort(sortProcesses); + + for (let i = 0; i < processes.length - 1; i++) { + const current = processes[i]; + const next = processes[i + 1]; + expect( + new Date(next.getDetails().process.start) >= new Date(current.getDetails().process.start) + ).toBeTruthy(); + } + }); +}); diff --git a/x-pack/plugins/session_view/common/utils/sort_processes.ts b/x-pack/plugins/session_view/common/utils/sort_processes.ts new file mode 100644 index 00000000000000..a0a42590e457e6 --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/sort_processes.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Process } from '../types/process_tree'; + +export const sortProcesses = (a: Process, b: Process) => { + const eventAStartTime = new Date(a.getDetails().process.start); + const eventBStartTime = new Date(b.getDetails().process.start); + + if (eventAStartTime < eventBStartTime) { + return -1; + } + + if (eventAStartTime > eventBStartTime) { + return 1; + } + + return 0; +}; diff --git a/x-pack/plugins/session_view/jest.config.js b/x-pack/plugins/session_view/jest.config.js new file mode 100644 index 00000000000000..d35db0d369468d --- /dev/null +++ b/x-pack/plugins/session_view/jest.config.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/session_view'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/session_view', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/session_view/{common,public,server}/**/*.{ts,tsx}', + ], + setupFiles: ['jest-canvas-mock'], +}; diff --git a/x-pack/plugins/session_view/kibana.json b/x-pack/plugins/session_view/kibana.json new file mode 100644 index 00000000000000..ff9d849016c555 --- /dev/null +++ b/x-pack/plugins/session_view/kibana.json @@ -0,0 +1,19 @@ +{ + "id": "sessionView", + "version": "8.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Security Team", + "githubTeam": "security-team" + }, + "requiredPlugins": [ + "data", + "timelines" + ], + "requiredBundles": [ + "kibanaReact", + "esUiShared" + ], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/session_view/package.json b/x-pack/plugins/session_view/package.json new file mode 100644 index 00000000000000..2cb3dc882ed711 --- /dev/null +++ b/x-pack/plugins/session_view/package.json @@ -0,0 +1,11 @@ +{ + "author": "Elastic", + "name": "session_view", + "version": "8.0.0", + "private": true, + "license": "Elastic-License", + "scripts": { + "test:jest": "node ../../scripts/jest", + "test:coverage": "node ../../scripts/jest --coverage" + } +} diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx new file mode 100644 index 00000000000000..80ad3ce0c46302 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelAccordion } from './index'; + +const TEST_ID = 'test'; +const TEST_LIST_ITEM = [ + { + title: 'item title', + description: 'item description', + }, +]; +const TEST_TITLE = 'accordion title'; +const ACTION_TEXT = 'extra action'; + +describe('DetailPanelAccordion component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelAccordion is mounted', () => { + it('should render basic acoordion', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-accordion')).toBeVisible(); + }); + + it('should render acoordion with tooltip', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-accordion')).toBeVisible(); + expect( + renderResult.queryByTestId('sessionView:detail-panel-accordion-tooltip') + ).toBeVisible(); + }); + + it('should render acoordion with extra action', async () => { + const mockFn = jest.fn(); + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-accordion')).toBeVisible(); + const extraActionButton = renderResult.getByTestId( + 'sessionView:detail-panel-accordion-action' + ); + expect(extraActionButton).toHaveTextContent(ACTION_TEXT); + extraActionButton.click(); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx new file mode 100644 index 00000000000000..4e03931e4fcd97 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx @@ -0,0 +1,76 @@ +/* + * 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, { ReactNode } from 'react'; +import { EuiAccordion, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; +import { useStyles } from './styles'; +import { DetailPanelDescriptionList } from '../detail_panel_description_list'; + +interface DetailPanelAccordionDeps { + id: string; + listItems: Array<{ + title: NonNullable; + description: NonNullable; + }>; + title: string; + tooltipContent?: string; + extraActionTitle?: string; + onExtraActionClick?: () => void; +} + +/** + * An accordion section in session view detail panel. + */ +export const DetailPanelAccordion = ({ + id, + listItems, + title, + tooltipContent, + extraActionTitle, + onExtraActionClick, +}: DetailPanelAccordionDeps) => { + const styles = useStyles(); + + return ( + + + {title} + + {tooltipContent && ( + + + + )} +
+ } + extraAction={ + extraActionTitle ? ( + + {extraActionTitle} + + ) : null + } + css={styles.accordion} + data-test-subj="sessionView:detail-panel-accordion" + > + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts new file mode 100644 index 00000000000000..c44e069c05c004 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const tabSection: CSSObject = { + padding: euiTheme.size.base, + }; + + const accordion: CSSObject = { + borderTop: euiTheme.border.thin, + '&:last-child': { + borderBottom: euiTheme.border.thin, + }, + }; + + const accordionButton: CSSObject = { + padding: euiTheme.size.base, + fontWeight: euiTheme.font.weight.bold, + }; + + return { + accordion, + accordionButton, + tabSection, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_copy/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_copy/index.test.tsx new file mode 100644 index 00000000000000..bb1dd243621bd5 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_copy/index.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelCopy } from './index'; + +const TEST_TEXT_COPY = 'copy component test'; +const TEST_CHILD = {TEST_TEXT_COPY}; + +describe('DetailPanelCopy component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelCopy is mounted', () => { + it('renders DetailPanelCopy correctly', async () => { + renderResult = mockedContext.render( + {TEST_CHILD} + ); + + expect(renderResult.queryByText(TEST_TEXT_COPY)).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_copy/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_copy/index.tsx new file mode 100644 index 00000000000000..a5ce77894949b1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_copy/index.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { ReactNode } from 'react'; +import { EuiButtonIcon, EuiCopy } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DetailPanelListItem } from '../detail_panel_list_item'; +import { dataOrDash } from '../../utils/data_or_dash'; +import { useStyles } from './styles'; + +interface DetailPanelCopyDeps { + children: ReactNode; + textToCopy: string | number; + display?: 'inlineBlock' | 'block' | undefined; +} + +interface DetailPanelListItemProps { + copy: ReactNode; + display?: string; +} + +/** + * Copy to clipboard component in Session view detail panel. + */ +export const DetailPanelCopy = ({ + children, + textToCopy, + display = 'inlineBlock', +}: DetailPanelCopyDeps) => { + const styles = useStyles(); + + const props: DetailPanelListItemProps = { + copy: ( + + {(copy) => ( + + )} + + ), + }; + + if (display === 'block') { + props.display = display; + } + + return {children}; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_copy/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_copy/styles.ts new file mode 100644 index 00000000000000..0bfc67dddb8859 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_copy/styles.ts @@ -0,0 +1,30 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const copyButton: CSSObject = { + position: 'absolute', + right: euiTheme.size.s, + top: 0, + bottom: 0, + margin: 'auto', + }; + + return { + copyButton, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.tsx new file mode 100644 index 00000000000000..aaf3086aabf5ee --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelDescriptionList } from './index'; + +const TEST_FIRST_TITLE = 'item title'; +const TEST_FIRST_DESCRIPTION = 'item description'; +const TEST_SECOND_TITLE = 'second title'; +const TEST_SECOND_DESCRIPTION = 'second description'; +const TEST_LIST_ITEM = [ + { + title: TEST_FIRST_TITLE, + description: TEST_FIRST_DESCRIPTION, + }, + { + title: TEST_SECOND_TITLE, + description: TEST_SECOND_DESCRIPTION, + }, +]; + +describe('DetailPanelDescriptionList component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelDescriptionList is mounted', () => { + it('renders DetailPanelDescriptionList correctly', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:detail-panel-description-list')).toBeVisible(); + + // check list items are rendered + expect(renderResult.queryByText(TEST_FIRST_TITLE)).toBeVisible(); + expect(renderResult.queryByText(TEST_FIRST_DESCRIPTION)).toBeVisible(); + expect(renderResult.queryByText(TEST_SECOND_TITLE)).toBeVisible(); + expect(renderResult.queryByText(TEST_SECOND_DESCRIPTION)).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx new file mode 100644 index 00000000000000..3d942fc42326e5 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_description_list/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { ReactNode } from 'react'; +import { EuiDescriptionList } from '@elastic/eui'; +import { useStyles } from './styles'; + +interface DetailPanelDescriptionListDeps { + listItems: Array<{ + title: NonNullable; + description: NonNullable; + }>; +} + +/** + * Description list in session view detail panel. + */ +export const DetailPanelDescriptionList = ({ listItems }: DetailPanelDescriptionListDeps) => { + const styles = useStyles(); + return ( + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.ts new file mode 100644 index 00000000000000..d815cb2a48283b --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_description_list/styles.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { CSSObject } from '@emotion/react'; +import { useEuiTheme } from '@elastic/eui'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const descriptionList: CSSObject = { + padding: euiTheme.size.s, + }; + + const tabListTitle = { + width: '40%', + display: 'flex', + alignItems: 'center', + }; + + const tabListDescription = { + width: '60%', + display: 'flex', + alignItems: 'center', + }; + + return { + descriptionList, + tabListTitle, + tabListDescription, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx new file mode 100644 index 00000000000000..2df9f47e5a4163 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessEventHost } from '../../../common/types/process_tree'; +import { DetailPanelHostTab } from './index'; + +const TEST_ARCHITECTURE = 'x86_64'; +const TEST_HOSTNAME = 'host-james-fleet-714-2'; +const TEST_ID = '48c1b3f1ac5da4e0057fc9f60f4d1d5d'; +const TEST_IP = '127.0.0.1,::1,10.132.0.50,fe80::7d39:3147:4d9a:f809'; +const TEST_MAC = '42:01:0a:84:00:32'; +const TEST_NAME = 'name-james-fleet-714-2'; +const TEST_OS_FAMILY = 'family-centos'; +const TEST_OS_FULL = 'full-CentOS 7.9.2009'; +const TEST_OS_KERNEL = '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021'; +const TEST_OS_NAME = 'os-Linux'; +const TEST_OS_PLATFORM = 'platform-centos'; +const TEST_OS_VERSION = 'version-7.9.2009'; + +const TEST_HOST: ProcessEventHost = { + architecture: TEST_ARCHITECTURE, + hostname: TEST_HOSTNAME, + id: TEST_ID, + ip: TEST_IP, + mac: TEST_MAC, + name: TEST_NAME, + os: { + family: TEST_OS_FAMILY, + full: TEST_OS_FULL, + kernel: TEST_OS_KERNEL, + name: TEST_OS_NAME, + platform: TEST_OS_PLATFORM, + version: TEST_OS_VERSION, + }, +}; + +describe('DetailPanelHostTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelHostTab is mounted', () => { + it('renders DetailPanelHostTab correctly', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByText('architecture')).toBeVisible(); + expect(renderResult.queryByText('hostname')).toBeVisible(); + expect(renderResult.queryByText('id')).toBeVisible(); + expect(renderResult.queryByText('ip')).toBeVisible(); + expect(renderResult.queryByText('mac')).toBeVisible(); + expect(renderResult.queryByText('name')).toBeVisible(); + expect(renderResult.queryByText(TEST_ARCHITECTURE)).toBeVisible(); + expect(renderResult.queryByText(TEST_HOSTNAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_ID)).toBeVisible(); + expect(renderResult.queryByText(TEST_IP)).toBeVisible(); + expect(renderResult.queryByText(TEST_MAC)).toBeVisible(); + expect(renderResult.queryByText(TEST_NAME)).toBeVisible(); + + // expand host os accordion + renderResult + .queryByTestId('sessionView:detail-panel-accordion') + ?.querySelector('button') + ?.click(); + expect(renderResult.queryByText('os.family')).toBeVisible(); + expect(renderResult.queryByText('os.full')).toBeVisible(); + expect(renderResult.queryByText('os.kernel')).toBeVisible(); + expect(renderResult.queryByText('os.name')).toBeVisible(); + expect(renderResult.queryByText('os.platform')).toBeVisible(); + expect(renderResult.queryByText('os.version')).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_FAMILY)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_FULL)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_KERNEL)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_NAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_PLATFORM)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_VERSION)).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx new file mode 100644 index 00000000000000..e46e0e2751872d --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiTextColor } from '@elastic/eui'; +import { ProcessEventHost } from '../../../common/types/process_tree'; +import { DetailPanelAccordion } from '../detail_panel_accordion'; +import { DetailPanelCopy } from '../detail_panel_copy'; +import { DetailPanelDescriptionList } from '../detail_panel_description_list'; +import { DetailPanelListItem } from '../detail_panel_list_item'; +import { dataOrDash } from '../../utils/data_or_dash'; +import { useStyles } from '../detail_panel_process_tab/styles'; + +interface DetailPanelHostTabDeps { + processHost: ProcessEventHost; +} + +/** + * Host Panel of session view detail panel. + */ +export const DetailPanelHostTab = ({ processHost }: DetailPanelHostTabDeps) => { + const styles = useStyles(); + + return ( + <> + hostname, + description: ( + + + {dataOrDash(processHost.hostname)} + + + ), + }, + { + title: id, + description: ( + + + {dataOrDash(processHost.id)} + + + ), + }, + { + title: ip, + description: ( + + + {dataOrDash(processHost.ip)} + + + ), + }, + { + title: mac, + description: ( + + + {dataOrDash(processHost.mac)} + + + ), + }, + { + title: name, + description: ( + + + {dataOrDash(processHost.name)} + + + ), + }, + ]} + /> + architecture, + description: ( + + + {dataOrDash(processHost.architecture)} + + + ), + }, + { + title: os.family, + description: ( + + + {dataOrDash(processHost.os.family)} + + + ), + }, + { + title: os.full, + description: ( + + + {dataOrDash(processHost.os.full)} + + + ), + }, + { + title: os.kernel, + description: ( + + + {dataOrDash(processHost.os.kernel)} + + + ), + }, + { + title: os.name, + description: ( + + + {dataOrDash(processHost.os.name)} + + + ), + }, + { + title: os.platform, + description: ( + + + {dataOrDash(processHost.os.platform)} + + + ), + }, + { + title: os.version, + description: ( + + + {dataOrDash(processHost.os.version)} + + + ), + }, + ]} + /> + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.tsx new file mode 100644 index 00000000000000..e6572a097d85a3 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelListItem } from './index'; + +const TEST_STRING = 'item title'; +const TEST_CHILD = {TEST_STRING}; +const TEST_COPY_STRING = 'test copy button'; +const BUTTON_TEST_ID = 'sessionView:test-copy-button'; +const TEST_COPY = ; +const LIST_ITEM_TEST_ID = 'sessionView:detail-panel-list-item'; +const WAIT_TIMEOUT = 500; + +describe('DetailPanelListItem component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelListItem is mounted', () => { + it('renders DetailPanelListItem correctly', async () => { + renderResult = mockedContext.render({TEST_CHILD}); + + expect(renderResult.queryByTestId(LIST_ITEM_TEST_ID)).toBeVisible(); + expect(renderResult.queryByText(TEST_STRING)).toBeVisible(); + }); + + it('renders copy element correctly', async () => { + renderResult = mockedContext.render( + {TEST_CHILD} + ); + + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + fireEvent.mouseEnter(renderResult.getByTestId(LIST_ITEM_TEST_ID)); + await waitFor(() => screen.queryByTestId(BUTTON_TEST_ID)); + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeVisible(); + + fireEvent.mouseLeave(renderResult.getByTestId(LIST_ITEM_TEST_ID)); + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + }); + + it('does not have mouse events when copy prop is not present', async () => { + renderResult = mockedContext.render({TEST_CHILD}); + + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + fireEvent.mouseEnter(renderResult.getByTestId(LIST_ITEM_TEST_ID)); + await waitFor(() => screen.queryByTestId(BUTTON_TEST_ID), { timeout: WAIT_TIMEOUT }); + expect(renderResult.queryByTestId(BUTTON_TEST_ID)).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.tsx new file mode 100644 index 00000000000000..93a6554bbe54aa --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_list_item/index.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, ReactNode } from 'react'; +import { EuiText, EuiTextProps } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; +import { useStyles } from './styles'; + +interface DetailPanelListItemDeps { + children: ReactNode; + copy?: ReactNode; + display?: string; +} + +interface EuiTextPropsCss extends EuiTextProps { + css: CSSObject; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelListItem = ({ + children, + copy, + display = 'flex', +}: DetailPanelListItemDeps) => { + const [isHovered, setIsHovered] = useState(false); + const styles = useStyles({ display }); + + const props: EuiTextPropsCss = { + size: 's', + css: !!copy ? styles.copiableItem : styles.item, + }; + + if (!!copy) { + props.onMouseEnter = () => setIsHovered(true); + props.onMouseLeave = () => setIsHovered(false); + } + + return ( + + {children} + {isHovered && copy} + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.ts new file mode 100644 index 00000000000000..c370bd8adb6e2d --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_list_item/styles.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +interface StylesDeps { + display: string | undefined; +} + +export const useStyles = ({ display }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const item: CSSObject = { + display, + alignItems: 'center', + padding: euiTheme.size.s, + width: '100%', + fontSize: 'inherit', + fontWeight: 'inherit', + minHeight: '36px', + }; + + const copiableItem: CSSObject = { + ...item, + position: 'relative', + borderRadius: euiTheme.border.radius.medium, + '&:hover': { + background: transparentize(euiTheme.colors.primary, 0.1), + }, + }; + + return { + item, + copiableItem, + }; + }, [display, euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.ts b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.ts new file mode 100644 index 00000000000000..d458ee3a1d6669 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getProcessExecutableCopyText } from './helpers'; + +describe('detail panel process tab helpers tests', () => { + it('getProcessExecutableCopyText works with empty array', () => { + const result = getProcessExecutableCopyText([]); + expect(result).toEqual(''); + }); + + it('getProcessExecutableCopyText works with array of tuples', () => { + const result = getProcessExecutableCopyText([ + ['echo', 'exec'], + ['echo', 'exit'], + ]); + expect(result).toEqual('echo exec, echo exit'); + }); + + it('getProcessExecutableCopyText returns empty string with an invalid array of tuples', () => { + // when some sub arrays only have 1 item + let result = getProcessExecutableCopyText([['echo', 'exec'], ['echo']]); + expect(result).toEqual(''); + + // when some sub arrays have more than two item + result = getProcessExecutableCopyText([ + ['echo', 'exec'], + ['echo', 'exec', 'random'], + ['echo', 'exit'], + ]); + expect(result).toEqual(''); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts new file mode 100644 index 00000000000000..632e0bc5fd2e3e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/helpers.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Serialize an array of executable tuples to a copyable text. + * + * @param {String[][]} executable + * @return {String} serialized string with data of each executable + */ +export const getProcessExecutableCopyText = (executable: string[][]) => { + try { + return executable + .map((execTuple) => { + const [execCommand, eventAction] = execTuple; + if (!execCommand || !eventAction || execTuple.length !== 2) { + throw new Error(); + } + return `${execCommand} ${eventAction}`; + }) + .join(', '); + } catch (_) { + return ''; + } +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.test.tsx new file mode 100644 index 00000000000000..074c69de7e8992 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelProcess, DetailPanelProcessLeader } from '../../types'; +import { DetailPanelProcessTab } from './index'; + +const getLeaderDetail = (leader: string): DetailPanelProcessLeader => ({ + id: `${leader}-id`, + name: `${leader}-name`, + start: new Date('2022-02-24').toISOString(), + entryMetaType: 'sshd', + userName: `${leader}-jack`, + interactive: true, + pid: 1234, + entryMetaSourceIp: '10.132.0.50', + executable: '/usr/bin/bash', +}); + +const TEST_PROCESS_DETAIL: DetailPanelProcess = { + id: 'process-id', + start: new Date('2022-02-22').toISOString(), + end: new Date('2022-02-23').toISOString(), + exit_code: 137, + user: 'process-jack', + args: ['vi', 'test.txt'], + executable: [ + ['test-executable-cmd', '(fork)'], + ['test-executable-cmd', '(exec)'], + ['test-executable-cmd', '(end)'], + ], + pid: 1233, + entryLeader: getLeaderDetail('entryLeader'), + sessionLeader: getLeaderDetail('sessionLeader'), + groupLeader: getLeaderDetail('groupLeader'), + parent: getLeaderDetail('parent'), +}; + +describe('DetailPanelProcessTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelProcessTab is mounted', () => { + it('renders DetailPanelProcessTab correctly', async () => { + renderResult = mockedContext.render( + + ); + + // Process detail rendered correctly + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.id)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.start)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.end)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.exit_code)).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.user)).toBeVisible(); + expect(renderResult.queryByText(`['vi','test.txt']`)).toBeVisible(); + expect(renderResult.queryAllByText('test-executable-cmd')).toHaveLength(3); + expect(renderResult.queryByText('(fork)')).toBeVisible(); + expect(renderResult.queryByText('(exec)')).toBeVisible(); + expect(renderResult.queryByText('(end)')).toBeVisible(); + expect(renderResult.queryByText(TEST_PROCESS_DETAIL.pid)).toBeVisible(); + + // Process tab accordions rendered correctly + expect(renderResult.queryByText('entryLeader-name')).toBeVisible(); + expect(renderResult.queryByText('sessionLeader-name')).toBeVisible(); + expect(renderResult.queryByText('groupLeader-name')).toBeVisible(); + expect(renderResult.queryByText('parent-name')).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx new file mode 100644 index 00000000000000..97e2cdc806c0f0 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/index.tsx @@ -0,0 +1,255 @@ +/* + * 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, { ReactNode } from 'react'; +import { EuiTextColor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DetailPanelProcess } from '../../types'; +import { DetailPanelAccordion } from '../detail_panel_accordion'; +import { DetailPanelCopy } from '../detail_panel_copy'; +import { DetailPanelDescriptionList } from '../detail_panel_description_list'; +import { DetailPanelListItem } from '../detail_panel_list_item'; +import { dataOrDash } from '../../utils/data_or_dash'; +import { getProcessExecutableCopyText } from './helpers'; +import { useStyles } from './styles'; + +interface DetailPanelProcessTabDeps { + processDetail: DetailPanelProcess; +} + +type ListItems = Array<{ + title: NonNullable; + description: NonNullable; +}>; + +// TODO: Update placeholder descriptions for these tootips once UX Writer Team Defines them +const leaderDescriptionListInfo = [ + { + id: 'processEntryLeader', + title: 'Entry Leader', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.entryLeaderTooltip', { + defaultMessage: 'A entry leader placeholder description', + }), + }, + { + id: 'processSessionLeader', + title: 'Session Leader', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.sessionLeaderTooltip', { + defaultMessage: 'A session leader placeholder description', + }), + }, + { + id: 'processGroupLeader', + title: 'Group Leader', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.processGroupLeaderTooltip', { + defaultMessage: 'a group leader placeholder description', + }), + }, + { + id: 'processParent', + title: 'Parent', + tooltipContent: i18n.translate('xpack.sessionView.detailPanel.processParentTooltip', { + defaultMessage: 'a parent placeholder description', + }), + }, +]; + +/** + * Detail panel in the session view. + */ +export const DetailPanelProcessTab = ({ processDetail }: DetailPanelProcessTabDeps) => { + const styles = useStyles(); + const leaderListItems = [ + processDetail.entryLeader, + processDetail.sessionLeader, + processDetail.groupLeader, + processDetail.parent, + ].map((leader, idx) => { + const listItems: ListItems = [ + { + title: id, + description: ( + + + {dataOrDash(leader.id)} + + + ), + }, + { + title: start, + description: ( + + {leader.start} + + ), + }, + ]; + // Only include entry_meta.type for entry leader + if (idx === 0) { + listItems.push({ + title: entry_meta.type, + description: ( + + + {dataOrDash(leader.entryMetaType)} + + + ), + }); + } + listItems.push( + { + title: user.name, + description: ( + + {dataOrDash(leader.userName)} + + ), + }, + { + title: interactive, + description: ( + + {leader.interactive ? 'True' : 'False'} + + ), + }, + { + title: pid, + description: ( + + {dataOrDash(leader.pid)} + + ), + } + ); + // Only include entry_meta.source.ip for entry leader + if (idx === 0) { + listItems.push({ + title: entry_meta.source.ip, + description: ( + + {dataOrDash(leader.entryMetaSourceIp)} + + ), + }); + } + return { + ...leaderDescriptionListInfo[idx], + name: leader.name, + listItems, + }; + }); + + const processArgs = processDetail.args.length + ? `[${processDetail.args.map((arg) => `'${arg}'`)}]` + : '-'; + + return ( + <> + id, + description: ( + + + {dataOrDash(processDetail.id)} + + + ), + }, + { + title: start, + description: ( + + {processDetail.start} + + ), + }, + { + title: end, + description: ( + + {processDetail.end} + + ), + }, + { + title: exit_code, + description: ( + + + {dataOrDash(processDetail.exit_code)} + + + ), + }, + { + title: user, + description: ( + + {dataOrDash(processDetail.user)} + + ), + }, + { + title: args, + description: ( + + {processArgs} + + ), + }, + { + title: executable, + description: ( + + {processDetail.executable.map((execTuple, idx) => { + const [executable, eventAction] = execTuple; + return ( +
+ + {executable} + + + {eventAction} + +
+ ); + })} +
+ ), + }, + { + title: process.pid, + description: ( + + + {dataOrDash(processDetail.pid)} + + + ), + }, + ]} + /> + {leaderListItems.map((leader) => ( + + ))} + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.ts new file mode 100644 index 00000000000000..8c1154f0c0076f --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_process_tab/styles.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const description: CSSObject = { + width: `calc(100% - ${euiTheme.size.xl})`, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }; + + const descriptionSemibold: CSSObject = { + ...description, + fontWeight: euiTheme.font.weight.medium, + }; + + const executableAction: CSSObject = { + fontWeight: euiTheme.font.weight.semiBold, + paddingLeft: euiTheme.size.xs, + }; + + return { + description, + descriptionSemibold, + executableAction, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts new file mode 100644 index 00000000000000..9092009a7d291c --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { + mockData, + mockProcessMap, +} from '../../../common/mocks/constants/session_view_process.mock'; +import { Process, ProcessMap } from '../../../common/types/process_tree'; +import { + updateProcessMap, + buildProcessTree, + searchProcessTree, + autoExpandProcessTree, +} from './helpers'; + +const SESSION_ENTITY_ID = '3d0192c6-7c54-5ee6-a110-3539a7cf42bc'; +const SEARCH_QUERY = 'vi'; +const SEARCH_RESULT_PROCESS_ID = '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727'; + +const mockEvents = mockData[0].events; + +describe('process tree hook helpers tests', () => { + let processMap: ProcessMap; + + beforeEach(() => { + processMap = {}; + }); + + it('updateProcessMap works', () => { + processMap = updateProcessMap(processMap, mockEvents); + + // processes are added to processMap + mockEvents.forEach((event) => { + expect(processMap[event.process.entity_id]).toBeTruthy(); + }); + }); + + it('buildProcessTree works', () => { + const newOrphans = buildProcessTree(mockProcessMap, mockEvents, [], SESSION_ENTITY_ID); + + const sessionLeaderChildrenIds = new Set( + mockProcessMap[SESSION_ENTITY_ID].children.map((child: Process) => child.id) + ); + + // processes are added under their parent's childrean array in processMap + mockEvents.forEach((event) => { + expect(sessionLeaderChildrenIds.has(event.process.entity_id)); + }); + + expect(newOrphans.length).toBe(0); + }); + + it('searchProcessTree works', () => { + const searchResults = searchProcessTree(mockProcessMap, SEARCH_QUERY); + + // search returns the process with search query in its event args + expect(searchResults[0].id).toBe(SEARCH_RESULT_PROCESS_ID); + }); + + it('autoExpandProcessTree works', () => { + processMap = mockProcessMap; + // mock what buildProcessTree does + const childProcesses = Object.values(processMap).filter( + (process) => process.id !== SESSION_ENTITY_ID + ); + processMap[SESSION_ENTITY_ID].children = childProcesses; + + expect(processMap[SESSION_ENTITY_ID].autoExpand).toBeFalsy(); + processMap = autoExpandProcessTree(processMap); + // session leader should have autoExpand to be true + expect(processMap[SESSION_ENTITY_ID].autoExpand).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts new file mode 100644 index 00000000000000..d3d7af1c62eda9 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts @@ -0,0 +1,170 @@ +/* + * 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 { Process, ProcessEvent, ProcessMap } from '../../../common/types/process_tree'; +import { ProcessImpl } from './hooks'; + +// given a page of new events, add these events to the appropriate process class model +// create a new process if none are created and return the mutated processMap +export const updateProcessMap = (processMap: ProcessMap, events: ProcessEvent[]) => { + events.forEach((event) => { + const { entity_id: id } = event.process; + let process = processMap[id]; + + if (!process) { + process = new ProcessImpl(id); + processMap[id] = process; + } + + process.addEvent(event); + }); + + return processMap; +}; + +// given a page of events, update process model parent child relationships +// if we cannot find a parent for a process include said process +// in the array of orphans. We track orphans in their own array, so +// we can attempt to re-parent the orphans when new pages of events are +// processed. This is especially important when paginating backwards +// (e.g in the case where the SessionView jumpToEvent prop is used, potentially skipping over ancestor processes) +export const buildProcessTree = ( + processMap: ProcessMap, + events: ProcessEvent[], + orphans: Process[], + sessionEntityId: string, + backwardDirection: boolean = false +) => { + // we process events in reverse order when paginating backwards. + if (backwardDirection) { + events = events.slice().reverse(); + } + + events.forEach((event) => { + const process = processMap[event.process.entity_id]; + const parentProcess = processMap[event.process.parent?.entity_id]; + + // if session leader, or process already has a parent, return + if (process.id === sessionEntityId || process.parent) { + return; + } + + if (parentProcess) { + process.parent = parentProcess; // handy for recursive operations (like auto expand) + + if (backwardDirection) { + parentProcess.children.unshift(process); + } else { + parentProcess.children.push(process); + } + } else if (!orphans?.includes(process)) { + // if no parent process, process is probably orphaned + if (backwardDirection) { + orphans?.unshift(process); + } else { + orphans?.push(process); + } + } + }); + + const newOrphans: Process[] = []; + + // with this new page of events processed, lets try re-parent any orphans + orphans?.forEach((process) => { + const parentProcess = processMap[process.getDetails().process.parent.entity_id]; + + if (parentProcess) { + process.parent = parentProcess; // handy for recursive operations (like auto expand) + + parentProcess.children.push(process); + } else { + newOrphans.push(process); + } + }); + + return newOrphans; +}; + +// given a plain text searchQuery, iterates over all processes in processMap +// and marks ones which match the below text (currently what is rendered in the process line item) +// process.searchMatched is used by process_tree_node to highlight the text which matched the search +// this funtion also returns a list of process results which is used by session_view_search_bar to drive +// result navigation UX +// FYI: this function mutates properties of models contained in processMap +export const searchProcessTree = (processMap: ProcessMap, searchQuery: string | undefined) => { + const results = []; + + for (const processId of Object.keys(processMap)) { + const process = processMap[processId]; + + if (searchQuery) { + const event = process.getDetails(); + const { working_directory: workingDirectory, args } = event.process; + + // TODO: the text we search is the same as what we render. + // in future we may support KQL searches to match against any property + // for now plain text search is limited to searching process.working_directory + process.args + const text = `${workingDirectory} ${args?.join(' ')}`; + + process.searchMatched = text.includes(searchQuery) ? searchQuery : null; + + if (process.searchMatched) { + results.push(process); + } + } else { + process.clearSearch(); + } + } + + return results; +}; + +// Iterate over all processes in processMap, and mark each process (and it's ancestors) for auto expansion if: +// a) the process was "user entered" (aka an interactive group leader) +// b) matches the plain text search above +// Returns the processMap with it's processes autoExpand bool set to true or false +// process.autoExpand is read by process_tree_node to determine whether to auto expand it's child processes. +export const autoExpandProcessTree = (processMap: ProcessMap) => { + for (const processId of Object.keys(processMap)) { + const process = processMap[processId]; + + if (process.searchMatched || process.isUserEntered()) { + let { parent } = process; + const parentIdSet = new Set(); + + while (parent && !parentIdSet.has(parent.id)) { + parentIdSet.add(parent.id); + parent.autoExpand = true; + parent = parent.parent; + } + } + } + + return processMap; +}; + +export const processNewEvents = ( + eventsProcessMap: ProcessMap, + events: ProcessEvent[] | undefined, + orphans: Process[], + sessionEntityId: string, + backwardDirection: boolean = false +): [ProcessMap, Process[]] => { + if (!events || events.length === 0) { + return [eventsProcessMap, orphans]; + } + + const updatedProcessMap = updateProcessMap(eventsProcessMap, events); + const newOrphans = buildProcessTree( + updatedProcessMap, + events, + orphans, + sessionEntityId, + backwardDirection + ); + + return [autoExpandProcessTree(updatedProcessMap), newOrphans]; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/hooks.test.tsx new file mode 100644 index 00000000000000..9cece96fe84670 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EventAction } from '../../../common/types/process_tree'; +import { mockEvents } from '../../../common/mocks/constants/session_view_process.mock'; +import { ProcessImpl } from './hooks'; + +describe('ProcessTree hooks', () => { + describe('ProcessImpl.getDetails memoize will cache bust on new events', () => { + it('should return the exec event details when this.events changes', () => { + const process = new ProcessImpl(mockEvents[0].process.entity_id); + + process.addEvent(mockEvents[0]); + + let result = process.getDetails(); + + // push exec event + process.addEvent(mockEvents[1]); + + result = process.getDetails(); + + expect(result.event.action).toEqual(EventAction.exec); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts new file mode 100644 index 00000000000000..a8c6ffe8e75d3a --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -0,0 +1,255 @@ +/* + * 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 _ from 'lodash'; +import memoizeOne from 'memoize-one'; +import { useState, useEffect } from 'react'; +import { + EventAction, + EventKind, + Process, + ProcessEvent, + ProcessMap, + ProcessEventsPage, +} from '../../../common/types/process_tree'; +import { processNewEvents, searchProcessTree, autoExpandProcessTree } from './helpers'; +import { sortProcesses } from '../../../common/utils/sort_processes'; + +interface UseProcessTreeDeps { + sessionEntityId: string; + data: ProcessEventsPage[]; + searchQuery?: string; +} + +export class ProcessImpl implements Process { + id: string; + events: ProcessEvent[]; + children: Process[]; + parent: Process | undefined; + autoExpand: boolean; + searchMatched: string | null; + orphans: Process[]; + + constructor(id: string) { + this.id = id; + this.events = []; + this.children = []; + this.orphans = []; + this.autoExpand = false; + this.searchMatched = null; + } + + addEvent(event: ProcessEvent) { + // rather than push new events on the array, we return a new one + // this helps the below memoizeOne functions to behave correctly. + this.events = this.events.concat(event); + } + + clearSearch() { + this.searchMatched = null; + this.autoExpand = false; + } + + getChildren(verboseMode: boolean) { + let children = this.children; + + // if there are orphans, we just render them inline with the other child processes (currently only session leader does this) + if (this.orphans.length) { + children = [...children, ...this.orphans].sort(sortProcesses); + } + + // When verboseMode is false, we filter out noise via a few techniques. + // This option is driven by the "verbose mode" toggle in SessionView/index.tsx + if (!verboseMode) { + return children.filter((child) => { + const { group_leader: groupLeader, session_leader: sessionLeader } = + child.getDetails().process; + + // search matches will never be filtered out + if (child.searchMatched) { + return true; + } + + // Hide processes that have their session leader as their process group leader. + // This accounts for a lot of noise from bash and other shells forking, running auto completion processes and + // other shell startup activities (e.g bashrc .profile etc) + if (groupLeader.pid === sessionLeader.pid) { + return false; + } + + // If the process has no children and has not exec'd (fork only), we hide it. + if (child.children.length === 0 && !child.hasExec()) { + return false; + } + + return true; + }); + } + + return children; + } + + hasOutput() { + return !!this.findEventByAction(this.events, EventAction.output); + } + + hasAlerts() { + return !!this.findEventByKind(this.events, EventKind.signal); + } + + getAlerts() { + return this.filterEventsByKind(this.events, EventKind.signal); + } + + hasExec() { + return !!this.findEventByAction(this.events, EventAction.exec); + } + + hasExited() { + return !!this.findEventByAction(this.events, EventAction.end); + } + + getDetails() { + return this.getDetailsMemo(this.events); + } + + getOutput() { + // not implemented, output ECS schema not defined (for a future release) + return ''; + } + + // isUserEntered is a best guess at which processes were initiated by a real person + // In most situations a user entered command in a shell such as bash, will cause bash + // to fork, create a new process group, and exec the command (e.g ls). If the session + // has a controlling tty (aka an interactive session), we assume process group leaders + // with a session leader for a parent are "user entered". + // Because of the presence of false positives in this calculation, it is currently + // only used to auto expand parts of the tree that could be of interest. + isUserEntered() { + const event = this.getDetails(); + const { + pid, + tty, + parent, + session_leader: sessionLeader, + group_leader: groupLeader, + } = event.process; + + const parentIsASessionLeader = parent.pid === sessionLeader.pid; // possibly bash, zsh or some other shell + const processIsAGroupLeader = pid === groupLeader.pid; + const sessionIsInteractive = !!tty; + + return sessionIsInteractive && parentIsASessionLeader && processIsAGroupLeader; + } + + getMaxAlertLevel() { + // TODO: as part of alerts details work + tie in with the new alert flyout + return null; + } + + findEventByAction = memoizeOne((events: ProcessEvent[], action: EventAction) => { + return events.find(({ event }) => event.action === action); + }); + + findEventByKind = memoizeOne((events: ProcessEvent[], kind: EventKind) => { + return events.find(({ event }) => event.kind === kind); + }); + + filterEventsByAction = memoizeOne((events: ProcessEvent[], action: EventAction) => { + return events.filter(({ event }) => event.action === action); + }); + + filterEventsByKind = memoizeOne((events: ProcessEvent[], kind: EventKind) => { + return events.filter(({ event }) => event.kind === kind); + }); + + // returns the most recent fork, exec, or end event + // to be used as a source for the most up to date details + // on the processes lifecycle. + getDetailsMemo = memoizeOne((events: ProcessEvent[]) => { + const actionsToFind = [EventAction.fork, EventAction.exec, EventAction.end]; + const filtered = events.filter((processEvent) => { + return actionsToFind.includes(processEvent.event.action); + }); + + // because events is already ordered by @timestamp we take the last event + // which could be a fork (w no exec or exit), most recent exec event (there can be multiple), or end event. + // If a process has an 'end' event will always be returned (since it is last and includes details like exit_code and end time) + return filtered[filtered.length - 1] || ({} as ProcessEvent); + }); +} + +export const useProcessTree = ({ sessionEntityId, data, searchQuery }: UseProcessTreeDeps) => { + // initialize map, as well as a placeholder for session leader process + // we add a fake session leader event, sourced from wide event data. + // this is because we might not always have a session leader event + // especially if we are paging in reverse from deep within a large session + const fakeLeaderEvent = data[0].events.find((event) => event.event.kind === EventKind.event); + const sessionLeaderProcess = new ProcessImpl(sessionEntityId); + + if (fakeLeaderEvent) { + fakeLeaderEvent.process = { + ...fakeLeaderEvent.process, + ...fakeLeaderEvent.process.entry_leader, + parent: fakeLeaderEvent.process.parent, + }; + sessionLeaderProcess.events.push(fakeLeaderEvent); + } + + const initializedProcessMap: ProcessMap = { + [sessionEntityId]: sessionLeaderProcess, + }; + + const [processMap, setProcessMap] = useState(initializedProcessMap); + const [processedPages, setProcessedPages] = useState([]); + const [searchResults, setSearchResults] = useState([]); + const [orphans, setOrphans] = useState([]); + + useEffect(() => { + let updatedProcessMap: ProcessMap = processMap; + let newOrphans: Process[] = orphans; + const newProcessedPages: ProcessEventsPage[] = []; + + data.forEach((page, i) => { + const processed = processedPages.find((p) => p.cursor === page.cursor); + + if (!processed) { + const backwards = i < processedPages.length; + + const result = processNewEvents( + updatedProcessMap, + page.events, + orphans, + sessionEntityId, + backwards + ); + + updatedProcessMap = result[0]; + newOrphans = result[1]; + + newProcessedPages.push(page); + } + }); + + if (newProcessedPages.length > 0) { + setProcessMap({ ...updatedProcessMap }); + setProcessedPages([...processedPages, ...newProcessedPages]); + setOrphans(newOrphans); + } + }, [data, processMap, orphans, processedPages, sessionEntityId]); + + useEffect(() => { + setSearchResults(searchProcessTree(processMap, searchQuery)); + autoExpandProcessTree(processMap); + }, [searchQuery, processMap]); + + // set new orphans array on the session leader + const sessionLeader = processMap[sessionEntityId]; + + sessionLeader.orphans = orphans; + + return { sessionLeader: processMap[sessionEntityId], processMap, searchResults }; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx new file mode 100644 index 00000000000000..ac6807984ba831 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mockData } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessImpl } from './hooks'; +import { ProcessTree } from './index'; + +describe('ProcessTree component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When ProcessTree is mounted', () => { + it('should render given a valid sessionEntityId and data', () => { + renderResult = mockedContext.render( + true} + hasNextPage={false} + fetchPreviousPage={() => true} + hasPreviousPage={false} + onProcessSelected={jest.fn()} + /> + ); + expect(renderResult.queryByTestId('sessionView:sessionViewProcessTree')).toBeTruthy(); + expect(renderResult.queryAllByTestId('sessionView:processTreeNode')).toBeTruthy(); + }); + + it('should insert a DOM element used to highlight a process when selectedProcess is set', () => { + const mockSelectedProcess = new ProcessImpl(mockData[0].events[0].process.entity_id); + + renderResult = mockedContext.render( + true} + hasNextPage={false} + fetchPreviousPage={() => true} + hasPreviousPage={false} + selectedProcess={mockSelectedProcess} + onProcessSelected={jest.fn()} + /> + ); + + // click on view more button + renderResult.getByTestId('sessionView:processTreeNodeChildProcessesButton').click(); + + expect( + renderResult + .queryByTestId('sessionView:processTreeSelectionArea') + ?.parentElement?.getAttribute('data-id') + ).toEqual(mockSelectedProcess.id); + + // change the selected process + const mockSelectedProcess2 = new ProcessImpl(mockData[0].events[1].process.entity_id); + + renderResult.rerender( + true} + hasNextPage={false} + fetchPreviousPage={() => true} + hasPreviousPage={false} + selectedProcess={mockSelectedProcess2} + onProcessSelected={jest.fn()} + /> + ); + + expect( + renderResult + .queryByTestId('sessionView:processTreeSelectionArea') + ?.parentElement?.getAttribute('data-id') + ).toEqual(mockSelectedProcess2.id); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.tsx new file mode 100644 index 00000000000000..6b3061a0d77bb3 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/index.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useRef, useEffect, useLayoutEffect, useCallback } from 'react'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ProcessTreeNode } from '../process_tree_node'; +import { useProcessTree } from './hooks'; +import { Process, ProcessEventsPage, ProcessEvent } from '../../../common/types/process_tree'; +import { useScroll } from '../../hooks/use_scroll'; +import { useStyles } from './styles'; + +type FetchFunction = () => void; + +interface ProcessTreeDeps { + // process.entity_id to act as root node (typically a session (or entry session) leader). + sessionEntityId: string; + + data: ProcessEventsPage[]; + + jumpToEvent?: ProcessEvent; + isFetching: boolean; + hasNextPage: boolean | undefined; + hasPreviousPage: boolean | undefined; + fetchNextPage: FetchFunction; + fetchPreviousPage: FetchFunction; + + // plain text search query (only searches "process.working_directory process.args.join(' ')" + searchQuery?: string; + + // currently selected process + selectedProcess?: Process | null; + onProcessSelected: (process: Process) => void; + setSearchResults?: (results: Process[]) => void; +} + +export const ProcessTree = ({ + sessionEntityId, + data, + jumpToEvent, + isFetching, + hasNextPage, + hasPreviousPage, + fetchNextPage, + fetchPreviousPage, + searchQuery, + selectedProcess, + onProcessSelected, + setSearchResults, +}: ProcessTreeDeps) => { + const styles = useStyles(); + + const { sessionLeader, processMap, searchResults } = useProcessTree({ + sessionEntityId, + data, + searchQuery, + }); + + const scrollerRef = useRef(null); + const selectionAreaRef = useRef(null); + + useEffect(() => { + if (setSearchResults) { + setSearchResults(searchResults); + } + }, [searchResults, setSearchResults]); + + useScroll({ + div: scrollerRef.current, + handler: (pos: number, endReached: boolean) => { + if (!isFetching && endReached) { + fetchNextPage(); + } + }, + }); + + /** + * highlights a process in the tree + * we do it this way to avoid state changes on potentially thousands of components + */ + const selectProcess = useCallback( + (process: Process) => { + if (!selectionAreaRef?.current || !scrollerRef?.current) { + return; + } + + const selectionAreaEl = selectionAreaRef.current; + selectionAreaEl.style.display = 'block'; + + // TODO: concept of alert level unknown wrt to elastic security + const alertLevel = process.getMaxAlertLevel(); + + if (alertLevel && alertLevel >= 0) { + selectionAreaEl.style.backgroundColor = + alertLevel > 0 ? styles.alertSelected : styles.defaultSelected; + } else { + selectionAreaEl.style.backgroundColor = ''; + } + + // find the DOM element for the command which is selected by id + const processEl = scrollerRef.current.querySelector(`[data-id="${process.id}"]`); + + if (processEl) { + processEl.prepend(selectionAreaEl); + + const cTop = scrollerRef.current.scrollTop; + const cBottom = cTop + scrollerRef.current.clientHeight; + + const eTop = processEl.offsetTop; + const eBottom = eTop + processEl.clientHeight; + const isVisible = eTop >= cTop && eBottom <= cBottom; + + if (!isVisible) { + processEl.scrollIntoView({ block: 'center' }); + } + } + }, + [styles.alertSelected, styles.defaultSelected] + ); + + useLayoutEffect(() => { + if (selectedProcess) { + selectProcess(selectedProcess); + } + }, [selectedProcess, selectProcess]); + + useEffect(() => { + // after 2 pages are loaded (due to bi-directional jump to), auto select the process + // for the jumpToEvent + if (jumpToEvent && data.length === 2) { + const process = processMap[jumpToEvent.process.entity_id]; + + if (process) { + onProcessSelected(process); + } + } + }, [jumpToEvent, processMap, onProcessSelected, data]); + + // auto selects the session leader process if no selection is made yet + useEffect(() => { + if (!selectedProcess) { + onProcessSelected(sessionLeader); + } + }, [sessionLeader, onProcessSelected, selectedProcess]); + + return ( +
+ {hasPreviousPage && ( + + + + )} + {sessionLeader && ( + + )} +
+ {hasNextPage && ( + + + + )} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/styles.ts b/x-pack/plugins/session_view/public/components/process_tree/styles.ts new file mode 100644 index 00000000000000..65fb66ad90aa7c --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree/styles.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { transparentize, useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const defaultSelectionColor = euiTheme.colors.accent; + + const scroller: CSSObject = { + position: 'relative', + fontFamily: euiTheme.font.familyCode, + overflow: 'auto', + height: '100%', + backgroundColor: euiTheme.colors.lightestShade, + }; + + const selectionArea: CSSObject = { + position: 'absolute', + display: 'none', + marginLeft: '-50%', + width: '150%', + height: '100%', + backgroundColor: defaultSelectionColor, + pointerEvents: 'none', + opacity: 0.1, + }; + + const defaultSelected = transparentize(euiTheme.colors.primary, 0.008); + const alertSelected = transparentize(euiTheme.colors.danger, 0.008); + + return { + scroller, + selectionArea, + defaultSelected, + alertSelected, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx new file mode 100644 index 00000000000000..618b36578d7dae --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessTreeAlerts } from './index'; + +describe('ProcessTreeAlerts component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When ProcessTreeAlerts is mounted', () => { + it('should return null if no alerts', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeNull(); + }); + + it('should return an array of alert details', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); + mockAlerts.forEach((alert) => { + if (!alert.kibana) { + return; + } + const { uuid, rule, original_event: event, workflow_status: status } = alert.kibana.alert; + const { name, query, severity } = rule; + + expect( + renderResult.queryByTestId(`sessionView:sessionViewAlertDetail-${uuid}`) + ).toBeTruthy(); + expect( + renderResult.queryByTestId(`sessionView:sessionViewAlertDetailViewRule-${uuid}`) + ).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(event.action, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(status, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(name, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(query, 'i')).length).toBeTruthy(); + expect(renderResult.queryAllByText(new RegExp(severity, 'i')).length).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx new file mode 100644 index 00000000000000..5312c09867b96e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButton, EuiText, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useStyles } from './styles'; +import { ProcessEvent } from '../../../common/types/process_tree'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { CoreStart } from '../../../../../../src/core/public'; + +interface ProcessTreeAlertsDeps { + alerts: ProcessEvent[]; +} + +const getRuleUrl = (alert: ProcessEvent, http: CoreStart['http']) => { + return http.basePath.prepend(`/app/security/rules/id/${alert.kibana?.alert.rule.uuid}`); +}; + +const ProcessTreeAlert = ({ alert }: { alert: ProcessEvent }) => { + const { http } = useKibana().services; + + if (!alert.kibana) { + return null; + } + + const { uuid, rule, original_event: event, workflow_status: status } = alert.kibana.alert; + const { name, query, severity } = rule; + + return ( + + + +
+ +
+ {name} +
+ +
+ {query} +
+ +
+ +
+ {severity} +
+ +
+ {status} +
+ +
+ +
+ {event.action} + +
+ + + +
+
+
+
+ ); +}; + +export function ProcessTreeAlerts({ alerts }: ProcessTreeAlertsDeps) { + const styles = useStyles(); + + if (alerts.length === 0) { + return null; + } + + return ( +
+ {alerts.map((alert: ProcessEvent) => ( + + ))} +
+ ); +} diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts new file mode 100644 index 00000000000000..d601891591305b --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { size, colors, border } = euiTheme; + + const container: CSSObject = { + marginTop: size.s, + marginRight: size.s, + color: colors.text, + padding: size.m, + borderStyle: 'solid', + borderColor: colors.lightShade, + borderWidth: border.width.thin, + borderRadius: border.radius.medium, + maxWidth: 800, + backgroundColor: 'white', + '&>div': { + borderTop: border.thin, + marginTop: size.m, + paddingTop: size.m, + '&:first-child': { + borderTop: 'none', + }, + }, + }; + + return { + container, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx new file mode 100644 index 00000000000000..16cb9461746916 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiButton, EuiIcon, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Process } from '../../../common/types/process_tree'; +import { useButtonStyles } from './use_button_styles'; + +export const ChildrenProcessesButton = ({ + onToggle, + isExpanded, +}: { + onToggle: () => void; + isExpanded: boolean; +}) => { + const { button, buttonArrow, getExpandedIcon } = useButtonStyles(); + + return ( + + + + + ); +}; + +export const SessionLeaderButton = ({ + process, + onClick, + showGroupLeadersOnly, + childCount, +}: { + process: Process; + onClick: () => void; + showGroupLeadersOnly: boolean; + childCount: number; +}) => { + const groupLeaderCount = process.getChildren(false).length; + const sameGroupCount = childCount - groupLeaderCount; + const { button, buttonArrow, getExpandedIcon } = useButtonStyles(); + + if (sameGroupCount > 0) { + return ( + + +

+ } + > + + + + +
+ ); + } + return null; +}; + +export const AlertButton = ({ + isExpanded, + onToggle, +}: { + isExpanded: boolean; + onToggle: () => void; +}) => { + const { alertButton, buttonArrow, getExpandedIcon } = useButtonStyles(); + + return ( + + + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx new file mode 100644 index 00000000000000..2a3bf94086021b --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { + processMock, + childProcessMock, + sessionViewAlertProcessMock, +} from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessTreeNode } from './index'; + +describe('ProcessTreeNode component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When ProcessTreeNode is mounted', () => { + it('should render given a valid process', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:processTreeNode')).toBeTruthy(); + }); + + it('should have an alternate rendering for a session leader', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.container.textContent).toEqual(' bash started by vagrant'); + }); + + // commented out until we get new UX for orphans treatment aka disjointed tree + // it('renders orphaned node', async () => { + // renderResult = mockedContext.render(); + // expect(renderResult.queryByText(/orphaned/i)).toBeTruthy(); + // }); + + it('renders Exec icon and exit code for executed process', async () => { + const executedProcessMock: typeof processMock = { + ...processMock, + hasExec: () => true, + }; + + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('sessionView:processTreeNodeExecIcon')).toBeTruthy(); + expect(renderResult.queryByTestId('sessionView:processTreeNodeExitCode')).toBeTruthy(); + }); + + it('does not render exit code if it does not exist', async () => { + const processWithoutExitCode: typeof processMock = { + ...processMock, + hasExec: () => true, + getDetails: () => ({ + ...processMock.getDetails(), + process: { + ...processMock.getDetails().process, + exit_code: undefined, + }, + }), + }; + + renderResult = mockedContext.render(); + expect(renderResult.queryByTestId('sessionView:processTreeNodeExitCode')).toBeFalsy(); + }); + + it('renders Root Escalation flag properly', async () => { + const rootEscalationProcessMock: typeof processMock = { + ...processMock, + getDetails: () => ({ + ...processMock.getDetails(), + user: { + id: '-1', + name: 'root', + }, + process: { + ...processMock.getDetails().process, + parent: { + ...processMock.getDetails().process.parent, + user: { + name: 'test', + id: '1000', + }, + }, + }, + }), + }; + + renderResult = mockedContext.render(); + + expect( + renderResult.queryByTestId('sessionView:processTreeNodeRootEscalationFlag') + ).toBeTruthy(); + }); + + it('executes callback function when user Clicks', async () => { + const onProcessSelected = jest.fn(); + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId('sessionView:processTreeNodeRow')); + expect(onProcessSelected).toHaveBeenCalled(); + }); + + it('does not executes callback function when user is Clicking to copy text', async () => { + const windowGetSelectionSpy = jest.spyOn(window, 'getSelection'); + + const onProcessSelected = jest.fn(); + + renderResult = mockedContext.render( + + ); + + // @ts-ignore + windowGetSelectionSpy.mockImplementation(() => ({ type: 'Range' })); + + userEvent.click(renderResult.getByTestId('sessionView:processTreeNodeRow')); + expect(onProcessSelected).not.toHaveBeenCalled(); + + // cleanup + windowGetSelectionSpy.mockRestore(); + }); + describe('Alerts', () => { + it('renders Alert button when process has alerts', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('processTreeNodeAlertButton')).toBeTruthy(); + }); + it('toggle Alert Details button when Alert button is clicked', async () => { + renderResult = mockedContext.render( + + ); + userEvent.click(renderResult.getByTestId('processTreeNodeAlertButton')); + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); + userEvent.click(renderResult.getByTestId('processTreeNodeAlertButton')); + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeFalsy(); + }); + }); + describe('Child processes', () => { + it('renders Child processes button when process has Child processes', async () => { + const processMockWithChildren: typeof processMock = { + ...processMock, + getChildren: () => [childProcessMock], + }; + + renderResult = mockedContext.render(); + + expect( + renderResult.queryByTestId('sessionView:processTreeNodeChildProcessesButton') + ).toBeTruthy(); + }); + it('toggle Child processes nodes when Child processes button is clicked', async () => { + const processMockWithChildren: typeof processMock = { + ...processMock, + getChildren: () => [childProcessMock], + }; + + renderResult = mockedContext.render(); + + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toHaveLength(1); + + userEvent.click( + renderResult.getByTestId('sessionView:processTreeNodeChildProcessesButton') + ); + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toHaveLength(2); + + userEvent.click( + renderResult.getByTestId('sessionView:processTreeNodeChildProcessesButton') + ); + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toHaveLength(1); + }); + }); + describe('Search', () => { + it('highlights text within the process node line item if it matches the searchQuery', () => { + // set a mock search matched indicator for the process (typically done by ProcessTree/helpers.ts) + processMock.searchMatched = '/vagrant'; + + renderResult = mockedContext.render(); + + expect( + renderResult.getByTestId('sessionView:processNodeSearchHighlight').textContent + ).toEqual('/vagrant'); + }); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx new file mode 100644 index 00000000000000..9db83f58f77382 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -0,0 +1,213 @@ +/* + * 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 React, { + useRef, + useLayoutEffect, + useState, + useEffect, + MouseEvent, + useCallback, +} from 'react'; +import { EuiButton, EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Process } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { ProcessTreeAlerts } from '../process_tree_alerts'; +import { SessionLeaderButton, AlertButton, ChildrenProcessesButton } from './buttons'; +import { useButtonStyles } from './use_button_styles'; +interface ProcessDeps { + process: Process; + isSessionLeader?: boolean; + depth?: number; + onProcessSelected?: (process: Process) => void; +} + +/** + * Renders a node on the process tree + */ +export function ProcessTreeNode({ + process, + isSessionLeader = false, + depth = 0, + onProcessSelected, +}: ProcessDeps) { + const textRef = useRef(null); + + const [childrenExpanded, setChildrenExpanded] = useState(isSessionLeader || process.autoExpand); + const [alertsExpanded, setAlertsExpanded] = useState(false); + const [showGroupLeadersOnly, setShowGroupLeadersOnly] = useState(isSessionLeader); + const { searchMatched } = process; + + useEffect(() => { + setChildrenExpanded(isSessionLeader || process.autoExpand); + }, [isSessionLeader, process.autoExpand]); + + const alerts = process.getAlerts(); + const styles = useStyles({ depth, hasAlerts: !!alerts.length }); + const buttonStyles = useButtonStyles(); + + useLayoutEffect(() => { + if (searchMatched !== null && textRef.current) { + const regex = new RegExp(searchMatched); + const text = textRef.current.textContent; + + if (text) { + const html = text.replace(regex, (match) => { + return `${match}`; + }); + + // eslint-disable-next-line no-unsanitized/property + textRef.current.innerHTML = html; + } + } + }, [searchMatched, styles.searchHighlight]); + + const onShowGroupLeaderOnlyClick = useCallback(() => { + setShowGroupLeadersOnly(!showGroupLeadersOnly); + }, [showGroupLeadersOnly]); + + const onChildrenToggle = useCallback(() => { + setChildrenExpanded(!childrenExpanded); + }, [childrenExpanded]); + + const onAlertsToggle = useCallback(() => { + setAlertsExpanded(!alertsExpanded); + }, [alertsExpanded]); + + const onProcessClicked = (e: MouseEvent) => { + e.stopPropagation(); + + const selection = window.getSelection(); + + // do not select the command if the user was just selecting text for copy. + if (selection && selection.type === 'Range') { + return; + } + + onProcessSelected?.(process); + }; + + const processDetails = process.getDetails(); + + if (!processDetails) { + return null; + } + + const id = process.id; + const { user } = processDetails; + const { + args, + name, + tty, + parent, + working_directory: workingDirectory, + exit_code: exitCode, + } = processDetails.process; + + const children = process.getChildren(!showGroupLeadersOnly); + const childCount = process.getChildren(true).length; + const shouldRenderChildren = childrenExpanded && children && children.length > 0; + const childrenTreeDepth = depth + 1; + + const showRootEscalation = user.name === 'root' && user.id !== parent.user.id; + const interactiveSession = !!tty; + const sessionIcon = interactiveSession ? 'consoleApp' : 'compute'; + const hasExec = process.hasExec(); + const iconTestSubj = hasExec + ? 'sessionView:processTreeNodeExecIcon' + : 'sessionView:processTreeNodeForkIcon'; + const processIcon = hasExec ? 'console' : 'branch'; + + return ( +
+
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} +
+ {isSessionLeader ? ( + <> + {name || args[0]}{' '} + {' '} + {user.name} + + + ) : ( + + + + {workingDirectory}  + {args[0]}  + {args.slice(1).join(' ')} + {exitCode !== undefined && ( + + {' '} + [exit_code: {exitCode}] + + )} + + + )} + + {showRootEscalation && ( + + + + )} + {!isSessionLeader && childCount > 0 && ( + + )} + {alerts.length > 0 && ( + + )} +
+
+ + {alertsExpanded && } + + {shouldRenderChildren && ( +
+ {children.map((child) => { + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts new file mode 100644 index 00000000000000..07092d6de28ead --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts @@ -0,0 +1,118 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +interface StylesDeps { + depth: number; + hasAlerts: boolean; +} + +export const useStyles = ({ depth, hasAlerts }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, border, size } = euiTheme; + + const TREE_INDENT = euiTheme.base * 2; + + const darkText: CSSObject = { + color: colors.text, + }; + + const searchHighlight = ` + background-color: ${colors.highlight}; + color: ${colors.fullShade}; + border-radius: ${border.radius.medium}; + `; + + const children: CSSObject = { + position: 'relative', + color: colors.ghost, + marginLeft: size.base, + paddingLeft: size.s, + borderLeft: border.editable, + marginTop: size.s, + }; + + /** + * gets border, bg and hover colors for a process + */ + const getHighlightColors = () => { + let bgColor = 'none'; + const hoverColor = transparentize(colors.primary, 0.04); + let borderColor = 'transparent'; + + // TODO: alerts highlight colors + if (hasAlerts) { + bgColor = transparentize(colors.danger, 0.04); + borderColor = transparentize(colors.danger, 0.48); + } + + return { bgColor, borderColor, hoverColor }; + }; + + const { bgColor, borderColor, hoverColor } = getHighlightColors(); + + const processNode: CSSObject = { + display: 'block', + cursor: 'pointer', + position: 'relative', + margin: `${size.s} 0px`, + '&:not(:first-child)': { + marginTop: size.s, + }, + '&:hover:before': { + backgroundColor: hoverColor, + }, + '&:before': { + position: 'absolute', + height: '100%', + pointerEvents: 'none', + content: `''`, + marginLeft: `-${depth * TREE_INDENT}px`, + borderLeft: `${size.xs} solid ${borderColor}`, + backgroundColor: bgColor, + width: `calc(100% + ${depth * TREE_INDENT}px)`, + }, + }; + + const wrapper: CSSObject = { + paddingLeft: size.s, + position: 'relative', + verticalAlign: 'middle', + color: colors.mediumShade, + wordBreak: 'break-all', + minHeight: size.l, + lineHeight: size.l, + }; + + const workingDir: CSSObject = { + color: colors.successText, + }; + + const alertDetails: CSSObject = { + padding: size.s, + border: border.editable, + borderRadius: border.radius.medium, + }; + + return { + darkText, + searchHighlight, + children, + processNode, + wrapper, + workingDir, + alertDetails, + }; + }, [depth, euiTheme, hasAlerts]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts new file mode 100644 index 00000000000000..d208fa8f079af3 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts @@ -0,0 +1,62 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { euiLightVars as theme } from '@kbn/ui-theme'; +import { CSSObject } from '@emotion/react'; + +export const useButtonStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, border, font, size } = euiTheme; + + const button: CSSObject = { + background: transparentize(theme.euiColorVis6, 0.04), + border: `${border.width.thin} solid ${transparentize(theme.euiColorVis6, 0.48)}`, + lineHeight: '18px', + height: '20px', + fontSize: '11px', + fontFamily: font.familyCode, + borderRadius: border.radius.medium, + color: colors.text, + marginLeft: size.s, + minWidth: 0, + }; + + const buttonArrow: CSSObject = { + marginLeft: size.s, + }; + + const alertButton: CSSObject = { + ...button, + background: transparentize(colors.dangerText, 0.04), + border: `${border.width.thin} solid ${transparentize(colors.dangerText, 0.48)}`, + }; + + const userChangedButton: CSSObject = { + ...button, + background: transparentize(theme.euiColorVis1, 0.04), + border: `${border.width.thin} solid ${transparentize(theme.euiColorVis1, 0.48)}`, + }; + + const getExpandedIcon = (expanded: boolean) => { + return expanded ? 'arrowUp' : 'arrowDown'; + }; + + return { + buttonArrow, + button, + alertButton, + userChangedButton, + getExpandedIcon, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts new file mode 100644 index 00000000000000..b93e5b43ddf884 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useEffect, useState } from 'react'; +import { useInfiniteQuery } from 'react-query'; +import { EuiSearchBarOnChangeArgs } from '@elastic/eui'; +import { CoreStart } from 'kibana/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { ProcessEvent, ProcessEventResults } from '../../../common/types/process_tree'; +import { PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE } from '../../../common/constants'; + +export const useFetchSessionViewProcessEvents = ( + sessionEntityId: string, + jumpToEvent: ProcessEvent | undefined +) => { + const { http } = useKibana().services; + + const jumpToCursor = jumpToEvent && jumpToEvent['@timestamp']; + + const query = useInfiniteQuery( + 'sessionViewProcessEvents', + async ({ pageParam = {} }) => { + let { cursor } = pageParam; + const { forward } = pageParam; + + if (!cursor && jumpToCursor) { + cursor = jumpToCursor; + } + + const res = await http.get(PROCESS_EVENTS_ROUTE, { + query: { + sessionEntityId, + cursor, + forward, + }, + }); + + const events = res.events.map((event: any) => event._source as ProcessEvent); + + return { events, cursor }; + }, + { + getNextPageParam: (lastPage, pages) => { + if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) { + return { + cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], + forward: true, + }; + } + }, + getPreviousPageParam: (firstPage, pages) => { + if (jumpToEvent && firstPage.events.length === PROCESS_EVENTS_PER_PAGE) { + return { + cursor: firstPage.events[0]['@timestamp'], + forward: false, + }; + } + }, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + } + ); + + useEffect(() => { + if (jumpToEvent && query.data?.pages.length === 1) { + query.fetchPreviousPage(); + } + }, [jumpToEvent, query]); + + return query; +}; + +export const useSearchQuery = () => { + const [searchQuery, setSearchQuery] = useState(''); + const onSearch = ({ query }: EuiSearchBarOnChangeArgs) => { + if (query) { + setSearchQuery(query.text); + } else { + setSearchQuery(''); + } + }; + + return { + searchQuery, + onSearch, + }; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view/index.test.tsx new file mode 100644 index 00000000000000..41336977cf78a3 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/index.test.tsx @@ -0,0 +1,104 @@ +/* + * 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 { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import React from 'react'; +import { sessionViewProcessEventsMock } from '../../../common/mocks/responses/session_view_process_events.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { SessionView } from './index'; +import userEvent from '@testing-library/user-event'; + +describe('SessionView component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockedApi: AppContextTestRender['coreStart']['http']['get']; + + const waitForApiCall = () => waitFor(() => expect(mockedApi).toHaveBeenCalled()); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockedApi = mockedContext.coreStart.http.get; + render = () => + (renderResult = mockedContext.render()); + }); + + describe('When SessionView is mounted', () => { + describe('And no data exists', () => { + beforeEach(async () => { + mockedApi.mockResolvedValue({ + events: [], + }); + }); + + it('should show the Empty message', async () => { + render(); + await waitForApiCall(); + expect(renderResult.getByTestId('sessionView:sessionViewProcessEventsEmpty')).toBeTruthy(); + }); + + it('should not display the search bar', async () => { + render(); + await waitForApiCall(); + expect( + renderResult.queryByTestId('sessionView:sessionViewProcessEventsSearch') + ).toBeFalsy(); + }); + }); + + describe('And data exists', () => { + beforeEach(async () => { + mockedApi.mockResolvedValue(sessionViewProcessEventsMock); + }); + + it('should show loading indicator while retrieving data and hide it when it gets it', async () => { + let releaseApiResponse: (value?: unknown) => void; + + // make the request wait + mockedApi.mockReturnValue(new Promise((resolve) => (releaseApiResponse = resolve))); + render(); + await waitForApiCall(); + + // see if loader is present + expect(renderResult.getByText('Loading session…')).toBeTruthy(); + + // release the request + releaseApiResponse!(mockedApi); + + // check the loader is gone + await waitForElementToBeRemoved(renderResult.getByText('Loading session…')); + }); + + it('should display the search bar', async () => { + render(); + await waitForApiCall(); + expect(renderResult.getByTestId('sessionView:sessionViewProcessEventsSearch')).toBeTruthy(); + }); + + it('should show items on the list, and auto selects session leader', async () => { + render(); + await waitForApiCall(); + + expect(renderResult.getAllByTestId('sessionView:processTreeNode')).toBeTruthy(); + + const selectionArea = renderResult.queryByTestId('sessionView:processTreeSelectionArea'); + + expect(selectionArea?.parentElement?.getAttribute('data-id')).toEqual('test-entity-id'); + }); + + it('should toggle detail panel visibilty when detail button clicked', async () => { + render(); + await waitForApiCall(); + + userEvent.click(renderResult.getByTestId('sessionViewDetailPanelToggle')); + expect(renderResult.getByText('Process')).toBeTruthy(); + expect(renderResult.getByText('Host')).toBeTruthy(); + expect(renderResult.getByText('Alerts')).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx new file mode 100644 index 00000000000000..7a82edc94ff1b1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useCallback } from 'react'; +import { + EuiEmptyPrompt, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiResizableContainer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { SectionLoading } from '../../shared_imports'; +import { ProcessTree } from '../process_tree'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { SessionViewDetailPanel } from '../session_view_detail_panel'; +import { SessionViewSearchBar } from '../session_view_search_bar'; +import { useStyles } from './styles'; +import { useFetchSessionViewProcessEvents } from './hooks'; + +interface SessionViewDeps { + // the root node of the process tree to render. e.g process.entry.entity_id or process.session_leader.entity_id + sessionEntityId: string; + height?: number; + jumpToEvent?: ProcessEvent; +} + +/** + * The main wrapper component for the session view. + */ +export const SessionView = ({ sessionEntityId, height, jumpToEvent }: SessionViewDeps) => { + const [isDetailOpen, setIsDetailOpen] = useState(false); + const [selectedProcess, setSelectedProcess] = useState(null); + + const styles = useStyles({ height }); + + const onProcessSelected = useCallback((process: Process) => { + setSelectedProcess(process); + }, []); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState(null); + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + fetchPreviousPage, + hasPreviousPage, + } = useFetchSessionViewProcessEvents(sessionEntityId, jumpToEvent); + + const hasData = data && data.pages.length > 0 && data.pages[0].events.length > 0; + const renderIsLoading = isFetching && !data; + const renderDetails = isDetailOpen && selectedProcess; + const toggleDetailPanel = () => { + setIsDetailOpen(!isDetailOpen); + }; + + if (!isFetching && !hasData) { + return ( + + + + } + body={ +

+ +

+ } + /> + ); + } + + return ( + <> + + + + + + + + + + + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + {renderIsLoading && ( + + + + )} + + {error && ( + + + + } + body={ +

+ +

+ } + /> + )} + + {hasData && ( +
+ +
+ )} +
+ + {renderDetails ? ( + <> + + + + + + ) : ( + <> + {/* Returning an empty element here (instead of false) to avoid a bug in EuiResizableContainer */} + + )} + + )} +
+ + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SessionView as default }; diff --git a/x-pack/plugins/session_view/public/components/session_view/styles.ts b/x-pack/plugins/session_view/public/components/session_view/styles.ts new file mode 100644 index 00000000000000..d7159ec5b1b399 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view/styles.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +interface StylesDeps { + height: number | undefined; +} + +export const useStyles = ({ height = 500 }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const processTree: CSSObject = { + height: `${height}px`, + paddingTop: euiTheme.size.s, + }; + + const detailPanel: CSSObject = { + height: `${height}px`, + }; + + return { + processTree, + detailPanel, + }; + }, [height, euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.ts b/x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.ts new file mode 100644 index 00000000000000..295371fbff96cf --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/helpers.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Process, ProcessFields } from '../../../common/types/process_tree'; +import { DetailPanelProcess, EuiTabProps } from '../../types'; + +const getDetailPanelProcessLeader = (leader: ProcessFields) => ({ + ...leader, + id: leader.entity_id, + entryMetaType: leader.entry_meta?.type || '', + userName: leader.user.name, + entryMetaSourceIp: leader.entry_meta?.source.ip || '', +}); + +export const getDetailPanelProcess = (process: Process) => { + const processData = {} as DetailPanelProcess; + + processData.id = process.id; + processData.start = process.events[0]['@timestamp']; + processData.end = process.events[process.events.length - 1]['@timestamp']; + const args = new Set(); + processData.executable = []; + + process.events.forEach((event) => { + if (!processData.user) { + processData.user = event.user.name; + } + if (!processData.pid) { + processData.pid = event.process.pid; + } + + if (event.process.args.length > 0) { + args.add(event.process.args.join(' ')); + } + if (event.process.executable) { + processData.executable.push([event.process.executable, `(${event.event.action})`]); + } + if (event.process.exit_code) { + processData.exit_code = event.process.exit_code; + } + }); + + processData.args = [...args]; + processData.entryLeader = getDetailPanelProcessLeader(process.events[0].process.entry_leader); + processData.sessionLeader = getDetailPanelProcessLeader(process.events[0].process.session_leader); + processData.groupLeader = getDetailPanelProcessLeader(process.events[0].process.group_leader); + processData.parent = getDetailPanelProcessLeader(process.events[0].process.parent); + + return processData; +}; + +export const getSelectedTabContent = (tabs: EuiTabProps[], selectedTabId: string) => { + const selectedTab = tabs.find((tab) => tab.id === selectedTabId); + + if (selectedTab) { + return selectedTab.content; + } + + return null; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx new file mode 100644 index 00000000000000..f754086fe5fab7 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { sessionViewBasicProcessMock } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { SessionViewDetailPanel } from './index'; + +describe('SessionView component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When SessionViewDetailPanel is mounted', () => { + it('shows process detail by default', async () => { + renderResult = mockedContext.render( + + ); + expect(renderResult.queryByText('8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726')).toBeVisible(); + }); + + it('can switch tabs to show host details', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Host')?.click(); + expect(renderResult.queryByText('hostname')).toBeVisible(); + expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx new file mode 100644 index 00000000000000..a47ce1d91ac973 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useMemo, useCallback } from 'react'; +import { EuiTabs, EuiTab, EuiNotificationBadge } from '@elastic/eui'; +import { EuiTabProps } from '../../types'; +import { Process } from '../../../common/types/process_tree'; +import { getDetailPanelProcess, getSelectedTabContent } from './helpers'; +import { DetailPanelProcessTab } from '../detail_panel_process_tab'; +import { DetailPanelHostTab } from '../detail_panel_host_tab'; + +interface SessionViewDetailPanelDeps { + selectedProcess: Process; + onProcessSelected?: (process: Process) => void; +} + +/** + * Detail panel in the session view. + */ +export const SessionViewDetailPanel = ({ selectedProcess }: SessionViewDetailPanelDeps) => { + const [selectedTabId, setSelectedTabId] = useState('process'); + const processDetail = useMemo(() => getDetailPanelProcess(selectedProcess), [selectedProcess]); + + const tabs: EuiTabProps[] = useMemo( + () => [ + { + id: 'process', + name: 'Process', + content: , + }, + { + id: 'host', + name: 'Host', + content: , + }, + { + id: 'alerts', + disabled: true, + name: 'Alerts', + append: ( + + 10 + + ), + content: null, + }, + ], + [processDetail, selectedProcess.events] + ); + + const onSelectedTabChanged = useCallback((id: string) => { + setSelectedTabId(id); + }, []); + + const tabContent = useMemo( + () => getSelectedTabContent(tabs, selectedTabId), + [tabs, selectedTabId] + ); + + return ( + <> + + {tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + prepend={tab.prepend} + append={tab.append} + > + {tab.name} + + ))} + + {tabContent} + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx new file mode 100644 index 00000000000000..b27260668af076 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { processMock } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { SessionViewSearchBar } from './index'; +import userEvent from '@testing-library/user-event'; +import { fireEvent } from '@testing-library/dom'; + +describe('SessionViewSearchBar component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + it('handles a typed search query', async () => { + const mockSetSearchQuery = jest.fn((query) => query); + const mockOnProcessSelected = jest.fn((process) => process); + + renderResult = mockedContext.render( + + ); + + const searchInput = renderResult.getByTestId('sessionView:searchInput').querySelector('input'); + + expect(searchInput?.value).toEqual('ls'); + + if (searchInput) { + userEvent.type(searchInput, ' -la'); + fireEvent.keyUp(searchInput, { key: 'Enter', code: 'Enter' }); + } + + expect(searchInput?.value).toEqual('ls -la'); + expect(mockSetSearchQuery.mock.calls.length).toBe(1); + expect(mockSetSearchQuery.mock.results[0].value).toBe('ls -la'); + }); + + it('shows a results navigator when searchResults provided', async () => { + const processMock2 = { ...processMock }; + const processMock3 = { ...processMock }; + const mockResults = [processMock, processMock2, processMock3]; + const mockSetSearchQuery = jest.fn((query) => query); + const mockOnProcessSelected = jest.fn((process) => process); + + renderResult = mockedContext.render( + + ); + + const searchPagination = renderResult.getByTestId('sessionView:searchPagination'); + expect(searchPagination).toBeTruthy(); + + const paginationTextClass = '.euiPagination__compressedText'; + expect(searchPagination.querySelector(paginationTextClass)?.textContent).toEqual('1 of 3'); + + userEvent.click(renderResult.getByTestId('pagination-button-next')); + expect(searchPagination.querySelector(paginationTextClass)?.textContent).toEqual('2 of 3'); + + const searchInput = renderResult.getByTestId('sessionView:searchInput').querySelector('input'); + + if (searchInput) { + userEvent.type(searchInput, ' -la'); + fireEvent.keyUp(searchInput, { key: 'Enter', code: 'Enter' }); + } + + // after search is changed, results index should reset to 1 + expect(searchPagination.querySelector(paginationTextClass)?.textContent).toEqual('1 of 3'); + + // setSelectedProcess should be called 3 times: + // 1. searchResults is set so auto select first item + // 2. next button hit, so call with 2nd item + // 3. search changed, so call with first result. + expect(mockOnProcessSelected.mock.calls.length).toBe(3); + expect(mockOnProcessSelected.mock.results[0].value).toEqual(processMock); + expect(mockOnProcessSelected.mock.results[1].value).toEqual(processMock2); + expect(mockOnProcessSelected.mock.results[1].value).toEqual(processMock); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx new file mode 100644 index 00000000000000..f4e4dac7a94c7e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_search_bar/index.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useEffect } from 'react'; +import { EuiSearchBar, EuiPagination } from '@elastic/eui'; +import { EuiSearchBarOnChangeArgs } from '@elastic/eui'; +import { Process } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; + +interface SessionViewSearchBarDeps { + searchQuery: string; + setSearchQuery(val: string): void; + searchResults: Process[] | null; + onProcessSelected(process: Process): void; +} + +/** + * The main wrapper component for the session view. + */ +export const SessionViewSearchBar = ({ + searchQuery, + setSearchQuery, + onProcessSelected, + searchResults, +}: SessionViewSearchBarDeps) => { + const styles = useStyles(); + + const [selectedResult, setSelectedResult] = useState(0); + + const onSearch = ({ query }: EuiSearchBarOnChangeArgs) => { + setSelectedResult(0); + + if (query) { + setSearchQuery(query.text); + } else { + setSearchQuery(''); + } + }; + + useEffect(() => { + if (searchResults) { + const process = searchResults[selectedResult]; + + if (process) { + onProcessSelected(process); + } + } + }, [searchResults, onProcessSelected, selectedResult]); + + const showPagination = !!searchResults?.length; + + return ( +
+ + {showPagination && ( + + )} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/session_view_search_bar/styles.ts b/x-pack/plugins/session_view/public/components/session_view_search_bar/styles.ts new file mode 100644 index 00000000000000..97a49ca2aa8c1d --- /dev/null +++ b/x-pack/plugins/session_view/public/components/session_view_search_bar/styles.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const pagination: CSSObject = { + position: 'absolute', + top: euiTheme.size.s, + right: euiTheme.size.xxl, + }; + + return { + pagination, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/hooks/use_scroll.ts b/x-pack/plugins/session_view/public/hooks/use_scroll.ts new file mode 100644 index 00000000000000..716e35dbb09870 --- /dev/null +++ b/x-pack/plugins/session_view/public/hooks/use_scroll.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect } from 'react'; +import _ from 'lodash'; + +const SCROLL_END_BUFFER_HEIGHT = 20; +const DEBOUNCE_TIMEOUT = 500; + +function getScrollPosition(div: HTMLElement) { + if (div) { + return div.scrollTop; + } else { + return document.documentElement.scrollTop || document.body.scrollTop; + } +} + +interface IUseScrollDeps { + div: HTMLElement | null; + handler(pos: number, endReached: boolean): void; +} + +/** + * listens to scroll events on given div, if scroll reaches bottom, calls a callback + * @param {ref} ref to listen to scroll events on + * @param {function} handler function receives params (scrollTop, endReached) + */ +export function useScroll({ div, handler }: IUseScrollDeps) { + useEffect(() => { + if (div) { + const debounced = _.debounce(() => { + const pos = getScrollPosition(div); + const endReached = pos + div.offsetHeight > div.scrollHeight - SCROLL_END_BUFFER_HEIGHT; + + handler(pos, endReached); + }, DEBOUNCE_TIMEOUT); + + div.onscroll = debounced; + + return () => { + debounced.cancel(); + + div.onscroll = null; + }; + } + }, [div, handler]); +} diff --git a/x-pack/plugins/session_view/public/index.ts b/x-pack/plugins/session_view/public/index.ts new file mode 100644 index 00000000000000..90043e9a691dce --- /dev/null +++ b/x-pack/plugins/session_view/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SessionViewPlugin } from './plugin'; + +export function plugin() { + return new SessionViewPlugin(); +} diff --git a/x-pack/plugins/session_view/public/methods/index.tsx b/x-pack/plugins/session_view/public/methods/index.tsx new file mode 100644 index 00000000000000..560bb302ebabfb --- /dev/null +++ b/x-pack/plugins/session_view/public/methods/index.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +// Initializing react-query +const queryClient = new QueryClient(); + +const SessionViewLazy = lazy(() => import('../components/session_view')); + +export const getSessionViewLazy = (sessionEntityId: string) => { + return ( + + }> + + + + ); +}; diff --git a/x-pack/plugins/session_view/public/plugin.ts b/x-pack/plugins/session_view/public/plugin.ts new file mode 100644 index 00000000000000..d25c95b00b2c63 --- /dev/null +++ b/x-pack/plugins/session_view/public/plugin.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup, CoreStart, Plugin } from '../../../../src/core/public'; +import { SessionViewServices } from './types'; +import { getSessionViewLazy } from './methods'; + +export class SessionViewPlugin implements Plugin { + public setup(core: CoreSetup) {} + + public start(core: CoreStart) { + return { + getSessionView: (sessionEntityId: string) => getSessionViewLazy(sessionEntityId), + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/session_view/public/shared_imports.ts b/x-pack/plugins/session_view/public/shared_imports.ts new file mode 100644 index 00000000000000..0a087e1ac36ae3 --- /dev/null +++ b/x-pack/plugins/session_view/public/shared_imports.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 { SectionLoading } from '../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/session_view/public/test/index.tsx b/x-pack/plugins/session_view/public/test/index.tsx new file mode 100644 index 00000000000000..8570e142538de8 --- /dev/null +++ b/x-pack/plugins/session_view/public/test/index.tsx @@ -0,0 +1,137 @@ +/* + * 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, ReactNode, useMemo } from 'react'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react'; +import { QueryClient, QueryClientProvider, setLogger } from 'react-query'; +import { Router } from 'react-router-dom'; +import { History } from 'history'; +import useObservable from 'react-use/lib/useObservable'; +import { I18nProvider } from '@kbn/i18n-react'; +import { CoreStart } from 'src/core/public'; +import { coreMock } from 'src/core/public/mocks'; +import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; +import { EuiThemeProvider } from 'src/plugins/kibana_react/common'; + +type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; + +// hide react-query output in console +setLogger({ + error: () => {}, + // eslint-disable-next-line no-console + log: console.log, + // eslint-disable-next-line no-console + warn: console.warn, +}); + +/** + * Mocked app root context renderer + */ +export interface AppContextTestRender { + history: ReturnType; + coreStart: ReturnType; + /** + * A wrapper around `AppRootContext` component. Uses the mocked modules as input to the + * `AppRootContext` + */ + AppWrapper: React.FC; + /** + * Renders the given UI within the created `AppWrapper` providing the given UI a mocked + * endpoint runtime context environment + */ + render: UiRender; +} + +const createCoreStartMock = ( + history: MemoryHistory +): ReturnType => { + const coreStart = coreMock.createStart({ basePath: '/mock' }); + + // Mock the certain APP Ids returned by `application.getUrlForApp()` + coreStart.application.getUrlForApp.mockImplementation((appId) => { + switch (appId) { + case 'sessionView': + return '/app/sessionView'; + default: + return `${appId} not mocked!`; + } + }); + + coreStart.application.navigateToUrl.mockImplementation((url) => { + history.push(url.replace('/app/sessionView', '')); + return Promise.resolve(); + }); + + return coreStart; +}; + +const AppRootProvider = memo<{ + history: History; + coreStart: CoreStart; + children: ReactNode | ReactNode[]; +}>(({ history, coreStart: { http, notifications, uiSettings, application }, children }) => { + const isDarkMode = useObservable(uiSettings.get$('theme:darkMode')); + const services = useMemo( + () => ({ http, notifications, application }), + [application, http, notifications] + ); + return ( + + + + {children} + + + + ); +}); + +AppRootProvider.displayName = 'AppRootProvider'; + +/** + * Creates a mocked app context custom renderer that can be used to render + * component that depend upon the application's surrounding context providers. + * Factory also returns the content that was used to create the custom renderer, allowing + * for further customization. + */ + +export const createAppRootMockRenderer = (): AppContextTestRender => { + const history = createMemoryHistory(); + const coreStart = createCoreStartMock(history); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // turns retries off + retry: false, + // prevent jest did not exit errors + cacheTime: Infinity, + }, + }, + }); + + const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( + + {children} + + ); + + const render: UiRender = (ui, options = {}) => { + return reactRender(ui, { + wrapper: AppWrapper as React.ComponentType, + ...options, + }); + }; + + return { + history, + coreStart, + AppWrapper, + render, + }; +}; diff --git a/x-pack/plugins/session_view/public/types.ts b/x-pack/plugins/session_view/public/types.ts new file mode 100644 index 00000000000000..2349b8423eb363 --- /dev/null +++ b/x-pack/plugins/session_view/public/types.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ReactNode } from 'react'; +import { CoreStart } from '../../../../src/core/public'; +import { TimelinesUIStart } from '../../timelines/public'; + +export type SessionViewServices = CoreStart & { + timelines: TimelinesUIStart; +}; + +export interface EuiTabProps { + id: string; + name: string; + content: ReactNode; + disabled?: boolean; + append?: ReactNode; + prepend?: ReactNode; +} + +export interface DetailPanelProcess { + id: string; + start: string; + end: string; + exit_code: number; + user: string; + args: string[]; + executable: string[][]; + pid: number; + entryLeader: DetailPanelProcessLeader; + sessionLeader: DetailPanelProcessLeader; + groupLeader: DetailPanelProcessLeader; + parent: DetailPanelProcessLeader; +} + +export interface DetailPanelProcessLeader { + id: string; + name: string; + start: string; + entryMetaType: string; + userName: string; + interactive: boolean; + pid: number; + entryMetaSourceIp: string; + executable: string; +} diff --git a/x-pack/plugins/session_view/public/utils/data_or_dash.test.ts b/x-pack/plugins/session_view/public/utils/data_or_dash.test.ts new file mode 100644 index 00000000000000..12ef44cf1d7083 --- /dev/null +++ b/x-pack/plugins/session_view/public/utils/data_or_dash.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { dataOrDash } from './data_or_dash'; + +const TEST_STRING = '123'; +const TEST_NUMBER = 123; +const DASH = '-'; + +describe('dataOrDash(data)', () => { + it('works for a valid string', () => { + expect(dataOrDash(TEST_STRING)).toEqual(TEST_STRING); + }); + it('works for a valid number', () => { + expect(dataOrDash(TEST_NUMBER)).toEqual(TEST_NUMBER); + }); + it('returns dash for undefined', () => { + expect(dataOrDash(undefined)).toEqual(DASH); + }); + it('returns dash for empty string', () => { + expect(dataOrDash('')).toEqual(DASH); + }); + it('returns dash for NaN', () => { + expect(dataOrDash(NaN)).toEqual(DASH); + }); +}); diff --git a/x-pack/plugins/session_view/public/utils/data_or_dash.ts b/x-pack/plugins/session_view/public/utils/data_or_dash.ts new file mode 100644 index 00000000000000..ff6c2fb9bc1ffb --- /dev/null +++ b/x-pack/plugins/session_view/public/utils/data_or_dash.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Returns a dash ('-') if data is undefined, and empty string, or a NaN. + * + * Used by frontend components + * + * @param {String | Number | undefined} data + * @return {String | Number} either data itself or if invalid, a dash ('-') + */ +export const dataOrDash = (data: string | number | undefined): string | number => { + if (data === undefined || data === '' || (typeof data === 'number' && isNaN(data))) { + return '-'; + } + + return data; +}; diff --git a/x-pack/plugins/session_view/server/index.ts b/x-pack/plugins/session_view/server/index.ts new file mode 100644 index 00000000000000..a86684094dfd70 --- /dev/null +++ b/x-pack/plugins/session_view/server/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { SessionViewPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new SessionViewPlugin(initializerContext); +} diff --git a/x-pack/plugins/session_view/server/plugin.ts b/x-pack/plugins/session_view/server/plugin.ts new file mode 100644 index 00000000000000..c7fd511b3de050 --- /dev/null +++ b/x-pack/plugins/session_view/server/plugin.ts @@ -0,0 +1,44 @@ +/* + * 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 { + CoreSetup, + CoreStart, + Plugin, + Logger, + PluginInitializerContext, +} from '../../../../src/core/server'; +import { SessionViewSetupPlugins, SessionViewStartPlugins } from './types'; +import { registerRoutes } from './routes'; + +export class SessionViewPlugin implements Plugin { + private logger: Logger; + + /** + * Initialize SessionViewPlugin class properties (logger, etc) that is accessible + * through the initializerContext. + */ + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup, plugins: SessionViewSetupPlugins) { + this.logger.debug('session view: Setup'); + const router = core.http.createRouter(); + + // Register server routes + registerRoutes(router); + } + + public start(core: CoreStart, plugins: SessionViewStartPlugins) { + this.logger.debug('session view: Start'); + } + + public stop() { + this.logger.debug('session view: Stop'); + } +} diff --git a/x-pack/plugins/session_view/server/routes/index.ts b/x-pack/plugins/session_view/server/routes/index.ts new file mode 100644 index 00000000000000..7b9cfb45f580b7 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/index.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 { IRouter } from '../../../../../src/core/server'; +import { registerProcessEventsRoute } from './process_events_route'; +import { sessionEntryLeadersRoute } from './session_entry_leaders_route'; + +export const registerRoutes = (router: IRouter) => { + registerProcessEventsRoute(router); + sessionEntryLeadersRoute(router); +}; diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.test.ts b/x-pack/plugins/session_view/server/routes/process_events_route.test.ts new file mode 100644 index 00000000000000..76f54eb4b8ab65 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/process_events_route.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { doSearch } from './process_events_route'; +import { mockEvents } from '../../common/mocks/constants/session_view_process.mock'; + +const getEmptyResponse = async () => { + return { + hits: { + total: { value: 0, relation: 'eq' }, + hits: [], + }, + }; +}; + +const getResponse = async () => { + return { + hits: { + total: { value: mockEvents.length, relation: 'eq' }, + hits: mockEvents.map((event) => { + return { _source: event }; + }), + }, + }; +}; + +describe('process_events_route.ts', () => { + describe('doSearch(client, entityId, cursor, forward)', () => { + it('should return an empty events array for a non existant entity_id', async () => { + const client = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse()); + + const body = await doSearch(client, 'asdf', undefined); + + expect(body.events.length).toBe(0); + }); + + it('returns results for a particular session entity_id', async () => { + const client = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + + const body = await doSearch(client, 'mockId', undefined); + + expect(body.events.length).toBe(mockEvents.length); + }); + + it('returns hits in reverse order when paginating backwards', async () => { + const client = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + + const body = await doSearch(client, 'mockId', undefined, false); + + expect(body.events[0]._source).toEqual(mockEvents[mockEvents.length - 1]); + }); + }); +}); diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts new file mode 100644 index 00000000000000..47e2d917733d5b --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import type { ElasticsearchClient } from 'kibana/server'; +import { IRouter } from '../../../../../src/core/server'; +import { + PROCESS_EVENTS_ROUTE, + PROCESS_EVENTS_PER_PAGE, + PROCESS_EVENTS_INDEX, + ALERTS_INDEX, + ENTRY_SESSION_ENTITY_ID_PROPERTY, +} from '../../common/constants'; +import { expandDottedObject } from '../../common/utils/expand_dotted_object'; + +export const registerProcessEventsRoute = (router: IRouter) => { + router.get( + { + path: PROCESS_EVENTS_ROUTE, + validate: { + query: schema.object({ + sessionEntityId: schema.string(), + cursor: schema.maybe(schema.string()), + forward: schema.maybe(schema.boolean()), + }), + }, + }, + async (context, request, response) => { + const client = context.core.elasticsearch.client.asCurrentUser; + const { sessionEntityId, cursor, forward = true } = request.query; + const body = await doSearch(client, sessionEntityId, cursor, forward); + + return response.ok({ body }); + } + ); +}; + +export const doSearch = async ( + client: ElasticsearchClient, + sessionEntityId: string, + cursor: string | undefined, + forward = true +) => { + const search = await client.search({ + // TODO: move alerts into it's own route with it's own pagination. + index: [PROCESS_EVENTS_INDEX, ALERTS_INDEX], + ignore_unavailable: true, + body: { + query: { + match: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, + }, + }, + // This runtime_mappings is a temporary fix, so we are able to Query these ECS fields while they are not available + // TODO: Remove the runtime_mappings once process.entry_leader.entity_id is implemented to ECS + runtime_mappings: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: { + type: 'keyword', + }, + }, + size: PROCESS_EVENTS_PER_PAGE, + sort: [{ '@timestamp': forward ? 'asc' : 'desc' }], + search_after: cursor ? [cursor] : undefined, + }, + }); + + const events = search.hits.hits.map((hit: any) => { + // TODO: re-eval if this is needed after moving alerts to it's own route. + // the .siem-signals-default index flattens many properties. this util unflattens them. + hit._source = expandDottedObject(hit._source); + + return hit; + }); + + if (!forward) { + events.reverse(); + } + + return { + events, + }; +}; diff --git a/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts b/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts new file mode 100644 index 00000000000000..98aee357fb91e0 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/session_entry_leaders_route.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../../../../src/core/server'; +import { SESSION_ENTRY_LEADERS_ROUTE, PROCESS_EVENTS_INDEX } from '../../common/constants'; + +export const sessionEntryLeadersRoute = (router: IRouter) => { + router.get( + { + path: SESSION_ENTRY_LEADERS_ROUTE, + validate: { + query: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = context.core.elasticsearch.client.asCurrentUser; + const { id } = request.query; + + const result = await client.get({ + index: PROCESS_EVENTS_INDEX, + id, + }); + + return response.ok({ + body: { + session_entry_leader: result?._source, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/session_view/server/types.ts b/x-pack/plugins/session_view/server/types.ts new file mode 100644 index 00000000000000..0d1375081ca870 --- /dev/null +++ b/x-pack/plugins/session_view/server/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SessionViewSetupPlugins {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SessionViewStartPlugins {} diff --git a/x-pack/plugins/session_view/tsconfig.json b/x-pack/plugins/session_view/tsconfig.json new file mode 100644 index 00000000000000..a99e83976a31d4 --- /dev/null +++ b/x-pack/plugins/session_view/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + // add all the folders containg files to be compiled + "common/**/*", + "public/**/*", + "server/**/*", + "server/**/*.json", + "scripts/**/*", + "package.json", + "storybook/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + // add references to other TypeScript projects the plugin depends on + + // requiredPlugins from ./kibana.json + { "path": "../licensing/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../encrypted_saved_objects/tsconfig.json" }, + + // optionalPlugins from ./kibana.json + { "path": "../security/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + + // requiredBundles from ./kibana.json + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../infra/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" } + ] +} diff --git a/yarn.lock b/yarn.lock index 753a9e5d9805c2..6867f4edc81910 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24737,10 +24737,10 @@ react-popper@^2.2.4: react-fast-compare "^3.0.1" warning "^4.0.2" -react-query@^3.34.0: - version "3.34.8" - resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.34.8.tgz#a3be8523fd95f766b04c32847a1b58d8231db03c" - integrity sha512-pl9e2VmVbgKf29Qn/WpmFVtB2g17JPqLLyOQg3GfSs/S2WABvip5xlT464vfXtilLPcJVg9bEHHlqmC38/nvDw== +react-query@^3.34.7: + version "3.34.7" + resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.34.7.tgz#e3d71318f510ea354794cd188b351bb57f577cb9" + integrity sha512-Q8+H2DgpoZdGUpwW2Z9WAbSrIE+yOdZiCUokHjlniOOmlcsfqNLgvHF5i7rtuCmlw3hv5OAhtpS7e97/DvgpWw== dependencies: "@babel/runtime" "^7.5.5" broadcast-channel "^3.4.1" From b51ae8bbddb8f8cc2bc83ca87918036c690b7659 Mon Sep 17 00:00:00 2001 From: Kyle Pollich Date: Fri, 4 Mar 2022 12:01:59 -0500 Subject: [PATCH 20/33] Empty fleet_package.json on main (#126920) --- fleet_packages.json | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/fleet_packages.json b/fleet_packages.json index 69fd83f12037ca..3657057ad3431f 100644 --- a/fleet_packages.json +++ b/fleet_packages.json @@ -12,25 +12,4 @@ in order to verify package integrity. */ -[ - { - "name": "apm", - "version": "8.1.0" - }, - { - "name": "elastic_agent", - "version": "1.3.0" - }, - { - "name": "endpoint", - "version": "1.5.0" - }, - { - "name": "fleet_server", - "version": "1.1.0" - }, - { - "name": "synthetics", - "version": "0.9.2" - } -] +[] From f7f8d6da9a7188a767f92c19931da5c2f950305b Mon Sep 17 00:00:00 2001 From: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Date: Fri, 4 Mar 2022 09:04:36 -0800 Subject: [PATCH 21/33] [ResponseOps] Mapped/searchable params (#126531) * Mapped params implementation with unit/integration/migration tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/alerting/common/alert.ts | 8 + .../lib/mapped_params_utils.test.ts | 131 +++++++++++++ .../rules_client/lib/mapped_params_utils.ts | 172 ++++++++++++++++++ .../lib/validate_attributes.test.ts | 26 ++- .../rules_client/lib/validate_attributes.ts | 77 ++++++-- .../server/rules_client/rules_client.ts | 46 ++++- .../server/rules_client/tests/create.test.ts | 161 ++++++++++++++++ .../server/rules_client/tests/find.test.ts | 31 ++++ .../server/rules_client/tests/update.test.ts | 8 + .../server/saved_objects/mappings.json | 10 + .../server/saved_objects/migrations.test.ts | 24 +++ .../server/saved_objects/migrations.ts | 30 +++ x-pack/plugins/alerting/server/types.ts | 2 + .../rules/all/use_columns.tsx | 4 +- .../spaces_only/tests/alerting/create.ts | 48 +++++ .../spaces_only/tests/alerting/find.ts | 74 ++++++++ .../spaces_only/tests/alerting/migrations.ts | 16 ++ .../spaces_only/tests/alerting/update.ts | 16 +- .../functional/es_archives/alerts/data.json | 42 +++++ 19 files changed, 907 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts create mode 100644 x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts index 35058aa343b1a1..da916ee7ed98aa 100644 --- a/x-pack/plugins/alerting/common/alert.ts +++ b/x-pack/plugins/alerting/common/alert.ts @@ -63,6 +63,13 @@ export interface AlertAggregations { ruleMutedStatus: { muted: number; unmuted: number }; } +export interface MappedParamsProperties { + risk_score?: number; + severity?: string; +} + +export type MappedParams = SavedObjectAttributes & MappedParamsProperties; + export interface Alert { id: string; enabled: boolean; @@ -73,6 +80,7 @@ export interface Alert { schedule: IntervalSchedule; actions: AlertAction[]; params: Params; + mapped_params?: MappedParams; scheduledTaskId?: string; createdBy: string | null; updatedBy: string | null; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts new file mode 100644 index 00000000000000..d8618d0ed6c210 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fromKueryExpression } from '@kbn/es-query'; +import { + getMappedParams, + getModifiedFilter, + getModifiedField, + getModifiedSearchFields, + getModifiedSearch, + getModifiedValue, + modifyFilterKueryNode, +} from './mapped_params_utils'; + +describe('getModifiedParams', () => { + it('converts params to mapped params', () => { + const params = { + riskScore: 42, + severity: 'medium', + a: 'test', + b: 'test', + c: 'test,', + }; + + expect(getMappedParams(params)).toEqual({ + risk_score: 42, + severity: '40-medium', + }); + }); + + it('returns empty mapped params if nothing exists in the input params', () => { + const params = { + a: 'test', + b: 'test', + c: 'test', + }; + + expect(getMappedParams(params)).toEqual({}); + }); +}); + +describe('getModifiedFilter', () => { + it('converts params filters to mapped params filters', () => { + // Make sure it works for both camel and snake case params + const filter = 'alert.attributes.params.risk_score: 45'; + + expect(getModifiedFilter(filter)).toEqual('alert.attributes.mapped_params.risk_score: 45'); + }); +}); + +describe('getModifiedField', () => { + it('converts sort field to mapped params sort field', () => { + expect(getModifiedField('params.risk_score')).toEqual('mapped_params.risk_score'); + expect(getModifiedField('params.riskScore')).toEqual('mapped_params.risk_score'); + expect(getModifiedField('params.invalid')).toEqual('params.invalid'); + }); +}); + +describe('getModifiedSearchFields', () => { + it('converts a list of params search fields to mapped param search fields', () => { + const searchFields = [ + 'params.risk_score', + 'params.riskScore', + 'params.severity', + 'params.invalid', + 'invalid', + ]; + + expect(getModifiedSearchFields(searchFields)).toEqual([ + 'mapped_params.risk_score', + 'mapped_params.risk_score', + 'mapped_params.severity', + 'params.invalid', + 'invalid', + ]); + }); +}); + +describe('getModifiedSearch', () => { + it('converts the search value depending on the search field', () => { + const searchFields = ['params.severity', 'another']; + + expect(getModifiedSearch(searchFields, 'medium')).toEqual('40-medium'); + expect(getModifiedSearch(searchFields, 'something else')).toEqual('something else'); + expect(getModifiedSearch('params.risk_score', 'something else')).toEqual('something else'); + expect(getModifiedSearch('mapped_params.severity', 'medium')).toEqual('40-medium'); + }); +}); + +describe('getModifiedValue', () => { + it('converts severity strings to sortable strings', () => { + expect(getModifiedValue('severity', 'low')).toEqual('20-low'); + expect(getModifiedValue('severity', 'medium')).toEqual('40-medium'); + expect(getModifiedValue('severity', 'high')).toEqual('60-high'); + expect(getModifiedValue('severity', 'critical')).toEqual('80-critical'); + }); +}); + +describe('modifyFilterKueryNode', () => { + it('modifies the resulting kuery node AST filter for alert params', () => { + const astFilter = fromKueryExpression( + 'alert.attributes.name: "Rule I" and alert.attributes.tags: "fast" and alert.attributes.params.severity > medium' + ); + + expect(astFilter.arguments[2].arguments[0]).toEqual({ + type: 'literal', + value: 'alert.attributes.params.severity', + }); + + expect(astFilter.arguments[2].arguments[2]).toEqual({ + type: 'literal', + value: 'medium', + }); + + modifyFilterKueryNode({ astFilter }); + + expect(astFilter.arguments[2].arguments[0]).toEqual({ + type: 'literal', + value: 'alert.attributes.mapped_params.severity', + }); + + expect(astFilter.arguments[2].arguments[2]).toEqual({ + type: 'literal', + value: '40-medium', + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts new file mode 100644 index 00000000000000..b4d82990654c25 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/mapped_params_utils.ts @@ -0,0 +1,172 @@ +/* + * 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 { snakeCase } from 'lodash'; +import { AlertTypeParams, MappedParams, MappedParamsProperties } from '../../types'; +import { SavedObjectAttribute } from '../../../../../../src/core/server'; +import { + iterateFilterKureyNode, + IterateFilterKureyNodeParams, + IterateActionProps, + getFieldNameAttribute, +} from './validate_attributes'; + +export const MAPPED_PARAMS_PROPERTIES: Array = [ + 'risk_score', + 'severity', +]; + +const SEVERITY_MAP: Record = { + low: '20-low', + medium: '40-medium', + high: '60-high', + critical: '80-critical', +}; + +/** + * Returns the mapped_params object when given a params object. + * The function will match params present in MAPPED_PARAMS_PROPERTIES and + * return an empty object if nothing is matched. + */ +export const getMappedParams = (params: AlertTypeParams) => { + return Object.entries(params).reduce((result, [key, value]) => { + const snakeCaseKey = snakeCase(key); + + if (MAPPED_PARAMS_PROPERTIES.includes(snakeCaseKey as keyof MappedParamsProperties)) { + result[snakeCaseKey] = getModifiedValue( + snakeCaseKey, + value as string + ) as SavedObjectAttribute; + } + + return result; + }, {}); +}; + +/** + * Returns a string of the filter, but with params replaced with mapped_params. + * This function will check both camel and snake case to make sure we're consistent + * with the naming + * + * i.e.: 'alerts.attributes.params.riskScore' -> 'alerts.attributes.mapped_params.risk_score' + */ +export const getModifiedFilter = (filter: string) => { + return filter.replace('.params.', '.mapped_params.'); +}; + +/** + * Returns modified field with mapped_params instead of params. + * + * i.e.: 'params.riskScore' -> 'mapped_params.risk_score' + */ +export const getModifiedField = (field: string | undefined) => { + if (!field) { + return field; + } + + const sortFieldToReplace = `${snakeCase(field.replace('params.', ''))}`; + + if (MAPPED_PARAMS_PROPERTIES.includes(sortFieldToReplace as keyof MappedParamsProperties)) { + return `mapped_params.${sortFieldToReplace}`; + } + + return field; +}; + +/** + * Returns modified search fields with mapped_params instead of params. + * + * i.e.: + * [ + * 'params.riskScore', + * 'params.severity', + * ] + * -> + * [ + * 'mapped_params.riskScore', + * 'mapped_params.severity', + * ] + */ +export const getModifiedSearchFields = (searchFields: string[] | undefined) => { + if (!searchFields) { + return searchFields; + } + + return searchFields.reduce((result, field) => { + const modifiedField = getModifiedField(field); + if (modifiedField) { + return [...result, modifiedField]; + } + return result; + }, []); +}; + +export const getModifiedValue = (key: string, value: string) => { + if (key === 'severity') { + return SEVERITY_MAP[value] || ''; + } + return value; +}; + +export const getModifiedSearch = (searchFields: string | string[] | undefined, value: string) => { + if (!searchFields) { + return value; + } + + const fieldNames = Array.isArray(searchFields) ? searchFields : [searchFields]; + + const modifiedSearchValues = fieldNames.map((fieldName) => { + const firstAttribute = getFieldNameAttribute(fieldName, [ + 'alert', + 'attributes', + 'params', + 'mapped_params', + ]); + return getModifiedValue(firstAttribute, value); + }); + + return modifiedSearchValues.find((search) => search !== value) || value; +}; + +export const modifyFilterKueryNode = ({ + astFilter, + hasNestedKey = false, + nestedKeys, + storeValue, + path = 'arguments', +}: IterateFilterKureyNodeParams) => { + const action = ({ index, ast, fieldName, localFieldName }: IterateActionProps) => { + // First index, assuming ast value is the attribute name + if (index === 0) { + const firstAttribute = getFieldNameAttribute(fieldName, ['alert', 'attributes']); + // Replace the ast.value for params to mapped_params + if (firstAttribute === 'params') { + ast.value = getModifiedFilter(ast.value); + } + } + + // Subsequent indices, assuming ast value is the filtering value + else { + const firstAttribute = getFieldNameAttribute(localFieldName, ['alert', 'attributes']); + + // Replace the ast.value for params value to the modified mapped_params value + if (firstAttribute === 'params' && ast.value) { + const attribute = getFieldNameAttribute(localFieldName, ['alert', 'attributes', 'params']); + ast.value = getModifiedValue(attribute, ast.value); + } + } + }; + + iterateFilterKureyNode({ + astFilter, + hasNestedKey, + nestedKeys, + storeValue, + path, + action, + }); +}; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts index 652c30ff380c55..1777a36d80a2f1 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.test.ts @@ -13,7 +13,7 @@ import { } from './validate_attributes'; describe('Validate attributes', () => { - const excludedFieldNames = ['monitoring']; + const excludedFieldNames = ['monitoring', 'mapped_params']; describe('validateSortField', () => { test('should NOT throw an error, when sort field is not part of the field to exclude', () => { expect(() => validateSortField('name.keyword', excludedFieldNames)).not.toThrow(); @@ -86,6 +86,17 @@ describe('Validate attributes', () => { ).not.toThrow(); }); + test('should NOT throw an error, when filter contains params with validate properties', () => { + expect(() => + validateFilterKueryNode({ + astFilter: esKuery.fromKueryExpression( + 'alert.attributes.name: "Rule I" and alert.attributes.tags: "fast" and alert.attributes.params.risk_score > 50' + ), + excludedFieldNames, + }) + ).not.toThrow(); + }); + test('should throw an error, when filter contains the field to exclude', () => { expect(() => validateFilterKueryNode({ @@ -111,5 +122,18 @@ describe('Validate attributes', () => { `"Filter is not supported on this field alert.attributes.actions"` ); }); + + test('should throw an error, when filtering contains a property that is not valid', () => { + expect(() => + validateFilterKueryNode({ + astFilter: esKuery.fromKueryExpression( + 'alert.attributes.name: "Rule I" and alert.attributes.tags: "fast" and alert.attributes.mapped_params.risk_score > 50' + ), + excludedFieldNames, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Filter is not supported on this field alert.attributes.mapped_params.risk_score"` + ); + }); }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts index fa65f4c2f0999b..ad17ede1b99adc 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_attributes.ts @@ -7,11 +7,18 @@ import { KueryNode } from '@kbn/es-query'; import { get, isEmpty } from 'lodash'; - import mappings from '../../saved_objects/mappings.json'; const astFunctionType = ['is', 'range', 'nested']; +export const getFieldNameAttribute = (fieldName: string, attributesToIgnore: string[]) => { + const fieldNameSplit = (fieldName || '') + .split('.') + .filter((fn: string) => !attributesToIgnore.includes(fn)); + + return fieldNameSplit.length > 0 ? fieldNameSplit[0] : ''; +}; + export const validateOperationOnAttributes = ( astFilter: KueryNode | null, sortField: string | undefined, @@ -44,28 +51,41 @@ export const validateSearchFields = (searchFields: string[], excludedFieldNames: } }; -interface ValidateFilterKueryNodeParams { +export interface IterateActionProps { + ast: KueryNode; + index: number; + fieldName: string; + localFieldName: string; +} + +export interface IterateFilterKureyNodeParams { astFilter: KueryNode; - excludedFieldNames: string[]; hasNestedKey?: boolean; nestedKeys?: string; storeValue?: boolean; path?: string; + action?: (props: IterateActionProps) => void; } -export const validateFilterKueryNode = ({ +export interface ValidateFilterKueryNodeParams extends IterateFilterKureyNodeParams { + excludedFieldNames: string[]; +} + +export const iterateFilterKureyNode = ({ astFilter, - excludedFieldNames, hasNestedKey = false, nestedKeys, storeValue, path = 'arguments', -}: ValidateFilterKueryNodeParams) => { + action = () => {}, +}: IterateFilterKureyNodeParams) => { let localStoreValue = storeValue; let localNestedKeys: string | undefined; + let localFieldName: string = ''; if (localStoreValue === undefined) { localStoreValue = astFilter.type === 'function' && astFunctionType.includes(astFilter.function); } + astFilter.arguments.forEach((ast: KueryNode, index: number) => { if (hasNestedKey && ast.type === 'literal' && ast.value != null) { localNestedKeys = ast.value; @@ -80,25 +100,56 @@ export const validateFilterKueryNode = ({ if (ast.arguments) { const myPath = `${path}.${index}`; - validateFilterKueryNode({ + iterateFilterKureyNode({ astFilter: ast, - excludedFieldNames, storeValue: ast.type === 'function' && astFunctionType.includes(ast.function), path: `${myPath}.arguments`, hasNestedKey: ast.type === 'function' && ast.function === 'nested', nestedKeys: localNestedKeys || nestedKeys, + action, }); } - if (localStoreValue && index === 0) { + if (localStoreValue) { const fieldName = nestedKeys != null ? `${nestedKeys}.${ast.value}` : ast.value; - const fieldNameSplit = fieldName - .split('.') - .filter((fn: string) => !['alert', 'attributes'].includes(fn)); - const firstAttribute = fieldNameSplit.length > 0 ? fieldNameSplit[0] : ''; + + if (index === 0) { + localFieldName = fieldName; + } + + action({ + ast, + index, + fieldName, + localFieldName, + }); + } + }); +}; + +export const validateFilterKueryNode = ({ + astFilter, + excludedFieldNames, + hasNestedKey = false, + nestedKeys, + storeValue, + path = 'arguments', +}: ValidateFilterKueryNodeParams) => { + const action = ({ index, fieldName }: IterateActionProps) => { + if (index === 0) { + const firstAttribute = getFieldNameAttribute(fieldName, ['alert', 'attributes']); if (excludedFieldNames.includes(firstAttribute)) { throw new Error(`Filter is not supported on this field ${fieldName}`); } } + }; + + iterateFilterKureyNode({ + astFilter, + hasNestedKey, + nestedKeys, + storeValue, + path, + action, }); }; diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 6d3ffc822a626d..86f0d3becdce77 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -78,6 +78,13 @@ import { Alert } from '../alert'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; import { getDefaultRuleMonitoring } from '../task_runner/task_runner'; +import { + getMappedParams, + getModifiedField, + getModifiedSearchFields, + getModifiedSearch, + modifyFilterKueryNode, +} from './lib/mapped_params_utils'; export interface RegistryAlertTypeWithAuth extends RegistryRuleType { authorizedConsumers: string[]; @@ -251,7 +258,10 @@ export class RulesClient { private readonly kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version']; private readonly auditLogger?: AuditLogger; private readonly eventLogger?: IEventLogger; - private readonly fieldsToExcludeFromPublicApi: Array = ['monitoring']; + private readonly fieldsToExcludeFromPublicApi: Array = [ + 'monitoring', + 'mapped_params', + ]; constructor({ ruleTypeRegistry, @@ -371,6 +381,12 @@ export class RulesClient { monitoring: getDefaultRuleMonitoring(), }; + const mappedParams = getMappedParams(updatedParams); + + if (Object.keys(mappedParams).length) { + rawRule.mapped_params = mappedParams; + } + this.auditLogger?.log( ruleAuditEvent({ action: RuleAuditAction.CREATE, @@ -634,9 +650,10 @@ export class RulesClient { ); throw error; } + const { filter: authorizationFilter, ensureRuleTypeIsAuthorized } = authorizationTuple; const filterKueryNode = options.filter ? esKuery.fromKueryExpression(options.filter) : null; - const sortField = mapSortField(options.sortField); + let sortField = mapSortField(options.sortField); if (excludeFromPublicApi) { try { validateOperationOnAttributes( @@ -650,6 +667,24 @@ export class RulesClient { } } + sortField = mapSortField(getModifiedField(options.sortField)); + + // Generate new modified search and search fields, translating certain params properties + // to mapped_params. Thus, allowing for sort/search/filtering on params. + // We do the modifcation after the validate check to make sure the public API does not + // use the mapped_params in their queries. + options = { + ...options, + ...(options.searchFields && { searchFields: getModifiedSearchFields(options.searchFields) }), + ...(options.search && { search: getModifiedSearch(options.searchFields, options.search) }), + }; + + // Modifies kuery node AST to translate params filter and the filter value to mapped_params. + // This translation is done in place, and therefore is not a pure function. + if (filterKueryNode) { + modifyFilterKueryNode({ astFilter: filterKueryNode }); + } + const { page, per_page: perPage, @@ -1027,6 +1062,13 @@ export class RulesClient { updatedBy: username, updatedAt: new Date().toISOString(), }); + + const mappedParams = getMappedParams(updatedParams); + + if (Object.keys(mappedParams).length) { + createAttributes.mapped_params = mappedParams; + } + try { updatedObject = await this.unsecuredSavedObjectsClient.create( 'alert', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index 6ccc640dcc1351..8cecb47f23a886 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -1878,6 +1878,167 @@ describe('create()', () => { `); }); + test('should create alerts with mapped_params', async () => { + const data = getMockData({ + params: { + bar: true, + risk_score: 42, + severity: 'low', + }, + }); + + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + risk_score: 42, + severity: 'low', + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '123', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + + const result = await rulesClient.create({ data }); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + enabled: true, + name: 'abc', + tags: ['foo'], + alertTypeId: '123', + consumer: 'bar', + schedule: { + interval: '1m', + }, + throttle: null, + notifyWhen: 'onActiveAlert', + params: { + bar: true, + risk_score: 42, + severity: 'low', + }, + actions: [ + { + group: 'default', + params: { + foo: true, + }, + actionRef: 'action_0', + actionTypeId: 'test', + }, + ], + apiKeyOwner: null, + apiKey: null, + legacyId: null, + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastExecutionDate: '2019-02-12T21:01:22.479Z', + error: null, + }, + monitoring: { + execution: { + history: [], + calculated_metrics: { + success_ratio: 0, + }, + }, + }, + mapped_params: { + risk_score: 42, + severity: '20-low', + }, + meta: { + versionApiKeyLastmodified: 'v8.0.0', + }, + }, + { + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + id: 'mock-saved-object-id', + } + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "123", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": null, + "params": Object { + "bar": true, + "risk_score": 42, + "severity": "low", + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": null, + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + test('should validate params', async () => { const data = getMockData(); ruleTypeRegistry.get.mockReturnValue({ diff --git a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts index 60aac3f266e785..bd382faa6d6cb0 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts @@ -290,6 +290,37 @@ describe('find()', () => { expect(jest.requireMock('../lib/map_sort_field').mapSortField).toHaveBeenCalledWith('name'); }); + test('should translate filter/sort/search on params to mapped_params', async () => { + const filter = esKuery.fromKueryExpression( + '((alert.attributes.alertTypeId:myType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myOtherApp))' + ); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + filter, + ensureRuleTypeIsAuthorized() {}, + }); + + const rulesClient = new RulesClient(rulesClientParams); + await rulesClient.find({ + options: { + sortField: 'params.risk_score', + searchFields: ['params.risk_score', 'params.severity'], + filter: 'alert.attributes.params.risk_score > 50', + }, + excludeFromPublicApi: true, + }); + + const findCallParams = unsecuredSavedObjectsClient.find.mock.calls[0][0]; + + expect(findCallParams.searchFields).toEqual([ + 'mapped_params.risk_score', + 'mapped_params.severity', + ]); + + expect(findCallParams.filter.arguments[0].arguments[0].value).toEqual( + 'alert.attributes.mapped_params.risk_score' + ); + }); + test('should call useSavedObjectReferences.injectReferences if defined for rule type', async () => { jest.resetAllMocks(); authorization.getFindAuthorizationFilter.mockResolvedValue({ diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index 1def4b7d60f4e1..be2f859ac96b3f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -252,6 +252,8 @@ describe('update()', () => { tags: ['foo'], params: { bar: true, + risk_score: 40, + severity: 'low', }, throttle: null, notifyWhen: 'onActiveAlert', @@ -362,6 +364,10 @@ describe('update()', () => { "apiKeyOwner": null, "consumer": "myApp", "enabled": true, + "mapped_params": Object { + "risk_score": 40, + "severity": "20-low", + }, "meta": Object { "versionApiKeyLastmodified": "v7.10.0", }, @@ -369,6 +375,8 @@ describe('update()', () => { "notifyWhen": "onActiveAlert", "params": Object { "bar": true, + "risk_score": 40, + "severity": "low", }, "schedule": Object { "interval": "1m", diff --git a/x-pack/plugins/alerting/server/saved_objects/mappings.json b/x-pack/plugins/alerting/server/saved_objects/mappings.json index 806e72fa33d5d0..e6eedced78914a 100644 --- a/x-pack/plugins/alerting/server/saved_objects/mappings.json +++ b/x-pack/plugins/alerting/server/saved_objects/mappings.json @@ -53,6 +53,16 @@ "type": "flattened", "ignore_above": 4096 }, + "mapped_params": { + "properties": { + "risk_score": { + "type": "float" + }, + "severity": { + "type": "keyword" + } + } + }, "scheduledTaskId": { "type": "keyword" }, diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index 1d7d3d2a362a99..28b1f599f9575a 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -2229,6 +2229,30 @@ describe('successful migrations', () => { ); }); + describe('8.2.0', () => { + test('migrates params to mapped_params', () => { + const migration820 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.2.0']; + const alert = getMockData( + { + params: { + risk_score: 60, + severity: 'high', + foo: 'bar', + }, + alertTypeId: 'siem.signals', + }, + true + ); + + const migratedAlert820 = migration820(alert, migrationContext); + + expect(migratedAlert820.attributes.mapped_params).toEqual({ + risk_score: 60, + severity: '60-high', + }); + }); + }); + describe('Metrics Inventory Threshold rule', () => { test('Migrates incorrect action group spelling', () => { const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 6e6c886d91b531..09d505aec0f0c4 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -21,6 +21,7 @@ import { RawRule, RawAlertAction } from '../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; import type { IsMigrationNeededPredicate } from '../../../encrypted_saved_objects/server'; import { extractRefsFromGeoContainmentAlert } from './geo_containment/migrations'; +import { getMappedParams } from '../../server/rules_client/lib/mapped_params_utils'; const SIEM_APP_ID = 'securitySolution'; const SIEM_SERVER_APP_ID = 'siem'; @@ -145,6 +146,12 @@ export function getMigrations( pipeMigrations(addSecuritySolutionAADRuleTypeTags) ); + const migrationRules820 = createEsoMigration( + encryptedSavedObjects, + (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, + pipeMigrations(addMappedParams) + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), @@ -155,6 +162,7 @@ export function getMigrations( '7.16.0': executeMigrationWithErrorHandling(migrateRules716, '7.16.0'), '8.0.0': executeMigrationWithErrorHandling(migrationRules800, '8.0.0'), '8.0.1': executeMigrationWithErrorHandling(migrationRules801, '8.0.1'), + '8.2.0': executeMigrationWithErrorHandling(migrationRules820, '8.2.0'), }; } @@ -822,6 +830,28 @@ function fixInventoryThresholdGroupId( } } +function addMappedParams( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const { + attributes: { params }, + } = doc; + + const mappedParams = getMappedParams(params); + + if (Object.keys(mappedParams).length) { + return { + ...doc, + attributes: { + ...doc.attributes, + mapped_params: mappedParams, + }, + }; + } + + return doc; +} + function getCorrespondingAction( actions: SavedObjectAttribute, connectorRef: string diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 8a0b61fed787a4..6b06f7efe30660 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -40,6 +40,7 @@ import { ActionVariable, SanitizedRuleConfig, RuleMonitoring, + MappedParams, } from '../common'; import { LicenseType } from '../../licensing/server'; import { IAbortableClusterClient } from './lib/create_abortable_es_client_factory'; @@ -236,6 +237,7 @@ export interface RawRule extends SavedObjectAttributes { schedule: SavedObjectAttributes; actions: RawAlertAction[]; params: SavedObjectAttributes; + mapped_params?: MappedParams; scheduledTaskId?: string | null; createdBy: string | null; updatedBy: string | null; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx index f241a3df873274..37882030082384 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx @@ -196,7 +196,7 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] {value} ), - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '85px', }, @@ -204,7 +204,7 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] field: 'severity', name: i18n.COLUMN_SEVERITY, render: (value: Rule['severity']) => , - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '12%', }, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts index 7eb7cf5efc7d35..b002e0668dc527 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts @@ -272,6 +272,54 @@ export default function createAlertTests({ getService }: FtrProviderContext) { }); }); + it('should create rules with mapped parameters', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const createResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + params: { + risk_score: 40, + severity: 'medium', + another_param: 'another', + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + + const response = await supertest.get( + `${getUrlPrefix( + Spaces.space1.id + )}/internal/alerting/rules/_find?filter=alert.attributes.params.risk_score:40` + ); + + expect(response.status).to.eql(200); + objectRemover.add(Spaces.space1.id, createResponse.body.id, 'rule', 'alerting'); + expect(response.body.total).to.equal(1); + expect(response.body.data[0].mapped_params).to.eql({ + risk_score: 40, + severity: '40-medium', + }); + }); + it('should allow providing custom saved object ids (uuid v1)', async () => { const customId = '09570bb0-6299-11eb-8fde-9fe5ce6ea450'; const response = await supertest diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts index 94198579d612de..7a4a91bd575bb1 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts @@ -106,6 +106,24 @@ const findTestUtils = ( createAlert(objectRemover, supertest, { params: { strValue: 'my a' } }), createAlert(objectRemover, supertest, { params: { strValue: 'my b' } }), createAlert(objectRemover, supertest, { params: { strValue: 'my c' } }), + createAlert(objectRemover, supertest, { + params: { + risk_score: 60, + severity: 'high', + }, + }), + createAlert(objectRemover, supertest, { + params: { + risk_score: 40, + severity: 'medium', + }, + }), + createAlert(objectRemover, supertest, { + params: { + risk_score: 20, + severity: 'low', + }, + }), ]); }); @@ -171,6 +189,62 @@ const findTestUtils = ( expect(response.body.total).to.equal(1); expect(response.body.data[0].params.strValue).to.eql('my b'); }); + + it('should sort by parameters', async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/${ + describeType === 'public' ? 'api' : 'internal' + }/alerting/rules/_find?sort_field=params.severity&sort_order=asc` + ); + expect(response.body.data[0].params.severity).to.equal('low'); + expect(response.body.data[1].params.severity).to.equal('medium'); + expect(response.body.data[2].params.severity).to.equal('high'); + }); + + it('should search by parameters', async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/${ + describeType === 'public' ? 'api' : 'internal' + }/alerting/rules/_find?search_fields=params.severity&search=medium` + ); + + expect(response.status).to.eql(200); + expect(response.body.total).to.equal(1); + expect(response.body.data[0].params.severity).to.eql('medium'); + }); + + it('should filter on parameters', async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/${ + describeType === 'public' ? 'api' : 'internal' + }/alerting/rules/_find?filter=alert.attributes.params.risk_score:40` + ); + + expect(response.status).to.eql(200); + expect(response.body.total).to.equal(1); + expect(response.body.data[0].params.risk_score).to.eql(40); + + if (describeType === 'public') { + expect(response.body.data[0].mapped_params).to.eql(undefined); + } + }); + + it('should error if filtering on mapped parameters directly using the public API', async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/${ + describeType === 'public' ? 'api' : 'internal' + }/alerting/rules/_find?filter=alert.attributes.mapped_params.risk_score:40` + ); + + if (describeType === 'public') { + expect(response.status).to.eql(400); + expect(response.body.message).to.eql( + 'Error find rules: Filter is not supported on this field alert.attributes.mapped_params.risk_score' + ); + } else { + expect(response.status).to.eql(200); + } + }); }); }); }; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts index 5077c8d720c246..9bcce86b57fe60 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts @@ -407,5 +407,21 @@ export default function createGetTests({ getService }: FtrProviderContext) { '__internal_immutable:false', ]); }); + + it('8.2.0 migrates params to mapped_params for specific params properties', async () => { + const response = await es.get<{ alert: RawRule }>( + { + index: '.kibana', + id: 'alert:66560b6f-5ca4-41e2-a1a1-dcfd7117e124', + }, + { meta: true } + ); + + expect(response.statusCode).to.equal(200); + expect(response.body._source?.alert?.mapped_params).to.eql({ + risk_score: 90, + severity: '80-critical', + }); + }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index 326fb0bfac4656..d97ca18c52d4a8 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -32,13 +32,15 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { tags: ['bar'], params: { foo: true, + risk_score: 40, + severity: 'medium', }, schedule: { interval: '12s' }, actions: [], throttle: '1m', notify_when: 'onThrottleInterval', }; - const response = await supertest + let response = await supertest .put(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${createdAlert.id}`) .set('kbn-xsrf', 'foo') .send(updatedData) @@ -68,6 +70,17 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { Date.parse(response.body.created_at) ); + response = await supertest.get( + `${getUrlPrefix( + Spaces.space1.id + )}/internal/alerting/rules/_find?filter=alert.attributes.params.risk_score:40` + ); + + expect(response.body.data[0].mapped_params).to.eql({ + risk_score: 40, + severity: '40-medium', + }); + // Ensure AAD isn't broken await checkAAD({ supertest, @@ -126,6 +139,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { throttle: '1m', notifyWhen: 'onThrottleInterval', }; + const response = await supertest .put(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}`) .set('kbn-xsrf', 'foo') diff --git a/x-pack/test/functional/es_archives/alerts/data.json b/x-pack/test/functional/es_archives/alerts/data.json index 96dad21732d0df..39ce6248c7ebbf 100644 --- a/x-pack/test/functional/es_archives/alerts/data.json +++ b/x-pack/test/functional/es_archives/alerts/data.json @@ -854,3 +854,45 @@ } } } + +{ + "type": "doc", + "value": { + "id": "alert:66560b6f-5ca4-41e2-a1a1-dcfd7117e124", + "index": ".kibana_1", + "source": { + "alert" : { + "name" : "Test mapped params migration", + "alertTypeId" : "siem.signals", + "consumer" : "alertsFixture", + "params" : { + "type": "eql", + "risk_score": 90, + "severity": "critical" + }, + "schedule" : { + "interval" : "1m" + }, + "enabled" : true, + "actions" : [ ], + "throttle" : null, + "apiKeyOwner" : null, + "apiKey" : null, + "createdBy" : "elastic", + "updatedBy" : "elastic", + "createdAt" : "2021-07-27T20:42:55.896Z", + "muteAll" : false, + "mutedInstanceIds" : [ ], + "scheduledTaskId" : null, + "tags": [] + }, + "type" : "alert", + "migrationVersion" : { + "alert" : "7.8.0" + }, + "updated_at" : "2021-08-13T23:00:11.985Z", + "references": [ + ] + } + } +} \ No newline at end of file From 646c15c1de96931108ce3d36cb64ccec61c1c40a Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Fri, 4 Mar 2022 10:01:56 -0800 Subject: [PATCH 22/33] [Github] Remove Security & Ops project assigner (#126939) --- .github/workflows/project-assigner.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 65808dffd801f5..8c381dd1ecdefa 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -18,8 +18,6 @@ jobs: {"label": "Feature:Canvas", "projectNumber": 38, "columnName": "Inbox"}, {"label": "Feature:Dashboard", "projectNumber": 68, "columnName": "Inbox"}, {"label": "Feature:Drilldowns", "projectNumber": 68, "columnName": "Inbox"}, - {"label": "Feature:Input Controls", "projectNumber": 72, "columnName": "Inbox"}, - {"label": "Team:Security", "projectNumber": 320, "columnName": "Awaiting triage", "projectScope": "org"}, - {"label": "Team:Operations", "projectNumber": 314, "columnName": "Triage", "projectScope": "org"} + {"label": "Feature:Input Controls", "projectNumber": 72, "columnName": "Inbox"} ] ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} From 377e2b4c3db1d90d7067ce2c6ab161c9554c90b7 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Fri, 4 Mar 2022 13:17:25 -0500 Subject: [PATCH 23/33] [Security Solution] Improve fields browser performance (#126114) * Probably better * Make backspace not slow * Type and prop cleanup * PR comments, fix failing cypress test * Update cypress tests to wait for debounced text filtering * Update cypress test * Update failing cypress tests by waiting when needed * Reload entire page for field browser tests * Skip failing local storage test * Remove unused import, cleanKibana back to before * Skip failing tests * Clear applied filter onHide, undo some cypress changes * Remove unnecessary wait Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../integration/cases/creation.spec.ts | 3 +- .../detection_rules/export_rule.spec.ts | 3 +- .../integration/hosts/events_viewer.spec.ts | 1 - .../timelines/fields_browser.spec.ts | 23 +++- .../timelines/local_storage.spec.ts | 2 +- .../cypress/tasks/alerts_detection_rules.ts | 2 +- .../cypress/tasks/fields_browser.ts | 13 ++- .../components/fields_browser/index.tsx | 5 +- .../fields_browser/field_browser.test.tsx | 2 + .../toolbar/fields_browser/field_browser.tsx | 5 +- .../toolbar/fields_browser/fields_pane.tsx | 25 +++-- .../t_grid/toolbar/fields_browser/index.tsx | 106 ++++++++++-------- .../es_archives/auditbeat/mappings.json | 5 + 13 files changed, 126 insertions(+), 69 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts index 90ab1d098aef50..d08f11a95b1945 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts @@ -50,7 +50,8 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { CASES_URL } from '../../urls/navigation'; -describe('Cases', () => { +// Flaky: https://github.com/elastic/kibana/issues/69847 +describe.skip('Cases', () => { beforeEach(() => { cleanKibana(); createTimeline(getCase1().timeline).then((response) => diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts index 0314c0c3a66b62..1e1abaa326bd47 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts @@ -13,7 +13,8 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; -describe('Export rules', () => { +// Flaky https://github.com/elastic/kibana/issues/69849 +describe.skip('Export rules', () => { beforeEach(() => { cleanKibana(); cy.intercept( diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts index 048efd00d276b3..c28c55e0eb3f7f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts @@ -108,7 +108,6 @@ describe('Events Viewer', () => { it('resets all fields in the events viewer when `Reset Fields` is clicked', () => { const filterInput = 'host.geo.c'; - filterFieldsBrowser(filterInput); cy.get(HOST_GEO_COUNTRY_NAME_HEADER).should('not.exist'); addsHostGeoCountryNameToHeader(); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts index be726f0323d48c..07ea4078ce7c4b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts @@ -32,6 +32,7 @@ import { import { loginAndWaitForPage } from '../../tasks/login'; import { openTimelineUsingToggle } from '../../tasks/security_main'; import { openTimelineFieldsBrowser, populateTimeline } from '../../tasks/timeline'; +import { ecsFieldMap } from '../../../../rule_registry/common/assets/field_maps/ecs_field_map'; import { HOSTS_URL } from '../../urls/navigation'; @@ -109,7 +110,27 @@ describe('Fields Browser', () => { filterFieldsBrowser(filterInput); - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should('have.text', '5'); + const fieldsThatMatchFilterInput = Object.keys(ecsFieldMap).filter((fieldName) => { + const dotDelimitedFieldParts = fieldName.split('.'); + const fieldPartMatch = dotDelimitedFieldParts.filter((fieldPart) => { + const camelCasedStringsMatching = fieldPart + .split('_') + .some((part) => part.startsWith(filterInput)); + if (fieldPart.startsWith(filterInput)) { + return true; + } else if (camelCasedStringsMatching) { + return true; + } else { + return false; + } + }); + return fieldName.startsWith(filterInput) || fieldPartMatch.length > 0; + }).length; + + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should( + 'have.text', + fieldsThatMatchFilterInput + ); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts index 617f04697c9513..b3139d94aa6258 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/local_storage.spec.ts @@ -14,7 +14,7 @@ import { waitsForEventsToBeLoaded } from '../../tasks/hosts/events'; import { removeColumn } from '../../tasks/timeline'; // TODO: Fix bug in persisting the columns of timeline -describe('persistent timeline', () => { +describe.skip('persistent timeline', () => { beforeEach(() => { cleanKibana(); loginAndWaitForPage(HOSTS_URL); diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 8475ef7247c2c6..ab09aca83f575a 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -233,7 +233,7 @@ export const changeRowsPerPageTo = (rowsCount: number) => { cy.get(PAGINATION_POPOVER_BTN).click({ force: true }); cy.get(rowsPerPageSelector(rowsCount)) .pipe(($el) => $el.trigger('click')) - .should('not.be.visible'); + .should('not.exist'); }; export const changeRowsPerPageTo100 = () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts index ee8bdb3b023dde..941a19669f2efb 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts @@ -34,17 +34,24 @@ export const addsHostGeoContinentNameToTimeline = () => { }; export const clearFieldsBrowser = () => { + cy.clock(); cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{selectall}{backspace}'); + cy.wait(0); + cy.tick(1000); }; export const closeFieldsBrowser = () => { cy.get(CLOSE_BTN).click({ force: true }); + cy.get(FIELDS_BROWSER_FILTER_INPUT).should('not.exist'); }; export const filterFieldsBrowser = (fieldName: string) => { - cy.get(FIELDS_BROWSER_FILTER_INPUT) - .type(fieldName) - .should('not.have.class', 'euiFieldSearch-isLoading'); + cy.clock(); + cy.get(FIELDS_BROWSER_FILTER_INPUT).type(fieldName, { delay: 50 }); + cy.wait(0); + cy.tick(1000); + // the text filter is debounced by 250 ms, wait 1s for changes to be applied + cy.get(FIELDS_BROWSER_FILTER_INPUT).should('not.have.class', 'euiFieldSearch-isLoading'); }; export const removesMessageField = () => { diff --git a/x-pack/plugins/timelines/public/components/fields_browser/index.tsx b/x-pack/plugins/timelines/public/components/fields_browser/index.tsx index 02fd0553f4016c..31b8e9f62803ec 100644 --- a/x-pack/plugins/timelines/public/components/fields_browser/index.tsx +++ b/x-pack/plugins/timelines/public/components/fields_browser/index.tsx @@ -28,10 +28,7 @@ export const FieldBrowserWrappedComponent = (props: FieldBrowserWrappedComponent return ( - + ); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx index dc9837007e1538..d435d7a280840b 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx @@ -32,6 +32,7 @@ const testProps = { browserFields: mockBrowserFields, filteredBrowserFields: mockBrowserFields, searchInput: '', + appliedFilterInput: '', isSearching: false, onCategorySelected: jest.fn(), onHide, @@ -84,6 +85,7 @@ describe('FieldsBrowser', () => { browserFields={mockBrowserFields} filteredBrowserFields={mockBrowserFields} searchInput={''} + appliedFilterInput={''} isSearching={false} onCategorySelected={jest.fn()} onHide={jest.fn()} diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx index fea22e4efe77c1..e55f54e946ad13 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx @@ -75,6 +75,8 @@ type Props = Pick & isSearching: boolean; /** The text displayed in the search input */ searchInput: string; + /** The text actually being applied to the result set, a debounced version of searchInput */ + appliedFilterInput: string; /** * The category selected on the left-hand side of the field browser */ @@ -115,6 +117,7 @@ const FieldsBrowserComponent: React.FC = ({ onHide, restoreFocusTo, searchInput, + appliedFilterInput, selectedCategoryId, timelineId, width = FIELD_BROWSER_WIDTH, @@ -237,7 +240,7 @@ const FieldsBrowserComponent: React.FC = ({ filteredBrowserFields={filteredBrowserFields} onCategorySelected={onCategorySelected} onUpdateColumns={onUpdateColumns} - searchInput={searchInput} + searchInput={appliedFilterInput} selectedCategoryId={selectedCategoryId} timelineId={timelineId} width={FIELDS_PANE_WIDTH} diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx index 5345475a025018..d1d0254d0c917d 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/fields_pane.tsx @@ -98,19 +98,30 @@ export const FieldsPane = React.memo( [filteredBrowserFields] ); + const fieldItems = useMemo(() => { + return getFieldItems({ + category: filteredBrowserFields[selectedCategoryId], + columnHeaders, + highlight: searchInput, + timelineId, + toggleColumn, + }); + }, [ + columnHeaders, + filteredBrowserFields, + searchInput, + selectedCategoryId, + timelineId, + toggleColumn, + ]); + if (filteredBrowserFieldsExists) { return ( = ({ /** all field names shown in the field browser must contain this string (when specified) */ const [filterInput, setFilterInput] = useState(''); + + const [appliedFilterInput, setAppliedFilterInput] = useState(''); /** all fields in this collection have field names that match the filterInput */ const [filteredBrowserFields, setFilteredBrowserFields] = useState(null); /** when true, show a spinner in the input to indicate the field browser is searching for matching field names */ @@ -51,15 +53,6 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ const [selectedCategoryId, setSelectedCategoryId] = useState(DEFAULT_CATEGORY_NAME); /** show the field browser */ const [show, setShow] = useState(false); - useEffect(() => { - return () => { - if (inputTimeoutId.current !== 0) { - // ⚠️ mutation: cancel any remaining timers and zero-out the timer id: - clearTimeout(inputTimeoutId.current); - inputTimeoutId.current = 0; - } - }; - }, []); /** Shows / hides the field browser */ const onShow = useCallback(() => { @@ -69,52 +62,68 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ /** Invoked when the field browser should be hidden */ const onHide = useCallback(() => { setFilterInput(''); + setAppliedFilterInput(''); setFilteredBrowserFields(null); setIsSearching(false); setSelectedCategoryId(DEFAULT_CATEGORY_NAME); setShow(false); }, []); + const newFilteredBrowserFields = useMemo(() => { + return filterBrowserFieldsByFieldName({ + browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), + substring: appliedFilterInput, + }); + }, [appliedFilterInput, browserFields]); + + const newSelectedCategoryId = useMemo(() => { + if (appliedFilterInput === '' || Object.keys(newFilteredBrowserFields).length === 0) { + return DEFAULT_CATEGORY_NAME; + } else { + return Object.keys(newFilteredBrowserFields) + .sort() + .reduce((selected, category) => { + const filteredBrowserFieldsByCategory = + (newFilteredBrowserFields[category] && newFilteredBrowserFields[category].fields) || []; + const filteredBrowserFieldsBySelected = + (newFilteredBrowserFields[selected] && newFilteredBrowserFields[selected].fields) || []; + return newFilteredBrowserFields[category].fields != null && + newFilteredBrowserFields[selected].fields != null && + Object.keys(filteredBrowserFieldsByCategory).length > + Object.keys(filteredBrowserFieldsBySelected).length + ? category + : selected; + }, Object.keys(newFilteredBrowserFields)[0]); + } + }, [appliedFilterInput, newFilteredBrowserFields]); + /** Invoked when the user types in the filter input */ - const updateFilter = useCallback( - (newFilterInput: string) => { - setFilterInput(newFilterInput); - setIsSearching(true); - if (inputTimeoutId.current !== 0) { - clearTimeout(inputTimeoutId.current); // ⚠️ mutation: cancel any previous timers - } - // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: - inputTimeoutId.current = window.setTimeout(() => { - const newFilteredBrowserFields = filterBrowserFieldsByFieldName({ - browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), - substring: newFilterInput, - }); - setFilteredBrowserFields(newFilteredBrowserFields); - setIsSearching(false); - - const newSelectedCategoryId = - newFilterInput === '' || Object.keys(newFilteredBrowserFields).length === 0 - ? DEFAULT_CATEGORY_NAME - : Object.keys(newFilteredBrowserFields) - .sort() - .reduce( - (selected, category) => - newFilteredBrowserFields[category].fields != null && - newFilteredBrowserFields[selected].fields != null && - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - Object.keys(newFilteredBrowserFields[category].fields!).length > - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - Object.keys(newFilteredBrowserFields[selected].fields!).length - ? category - : selected, - Object.keys(newFilteredBrowserFields)[0] - ); - setSelectedCategoryId(newSelectedCategoryId); - }, INPUT_TIMEOUT); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [browserFields, filterInput, inputTimeoutId.current] - ); + const updateFilter = useCallback((newFilterInput: string) => { + setFilterInput(newFilterInput); + setIsSearching(true); + }, []); + + useEffect(() => { + if (inputTimeoutId.current !== 0) { + clearTimeout(inputTimeoutId.current); // ⚠️ mutation: cancel any previous timers + } + // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: + inputTimeoutId.current = window.setTimeout(() => { + setIsSearching(false); + setAppliedFilterInput(filterInput); + }, INPUT_TIMEOUT); + return () => { + clearTimeout(inputTimeoutId.current); + }; + }, [filterInput]); + + useEffect(() => { + setFilteredBrowserFields(newFilteredBrowserFields); + }, [newFilteredBrowserFields]); + + useEffect(() => { + setSelectedCategoryId(newSelectedCategoryId); + }, [newSelectedCategoryId]); // only merge in the default category if the field browser is visible const browserFieldsWithDefaultCategory = useMemo(() => { @@ -152,6 +161,7 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ onSearchInputChange={updateFilter} restoreFocusTo={customizeColumnsButtonRef} searchInput={filterInput} + appliedFilterInput={appliedFilterInput} selectedCategoryId={selectedCategoryId} timelineId={timelineId} width={width} diff --git a/x-pack/test/security_solution_cypress/es_archives/auditbeat/mappings.json b/x-pack/test/security_solution_cypress/es_archives/auditbeat/mappings.json index 3196232e59643f..061748d72b77b0 100644 --- a/x-pack/test/security_solution_cypress/es_archives/auditbeat/mappings.json +++ b/x-pack/test/security_solution_cypress/es_archives/auditbeat/mappings.json @@ -1735,6 +1735,10 @@ "ignore_above": 1024, "type": "keyword" }, + "continent_code": { + "ignore_above": 1024, + "type": "keyword" + }, "continent_name": { "ignore_above": 1024, "type": "keyword" @@ -3110,6 +3114,7 @@ "group.name", "host.architecture", "host.geo.city_name", + "host.geo.continent_code", "host.geo.continent_name", "host.geo.country_iso_code", "host.geo.country_name", From eed64fda745de983c7a21f7b291952fcb396ddd8 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Fri, 4 Mar 2022 14:17:01 -0500 Subject: [PATCH 24/33] Testing project_assigner action for beta projects (#126950) We suspect this action will not work with GH beta projects, so let's confirm. --- .github/workflows/assign-infra-monitoring.yml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/assign-infra-monitoring.yml diff --git a/.github/workflows/assign-infra-monitoring.yml b/.github/workflows/assign-infra-monitoring.yml new file mode 100644 index 00000000000000..96376ecebf8544 --- /dev/null +++ b/.github/workflows/assign-infra-monitoring.yml @@ -0,0 +1,22 @@ +name: Assign to Infra Monitoring UI (beta) project +on: + issues: + types: [labeled] + +jobs: + assign_to_project: + runs-on: ubuntu-latest + name: Assign issue or PR to project based on label + steps: + - name: Assign to project + uses: elastic/github-actions/project-assigner@v2.1.1 + id: project_assigner_infra_monitoring + with: + issue-mappings: | + [ + {"label": "Team:Infra Monitoring UI", "projectNumber": 664, "projectScope": "org"}, + {"label": "Feature:Stack Monitoring", "projectNumber": 664, "projectScope": "org"}, + {"label": "Feature:Logs UI", "projectNumber": 664, "projectScope": "org"}, + {"label": "Feature:Metrics UI", "projectNumber": 664, "projectScope": "org"}, + ] + ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} From 70a4f7930f007af741f0328c88c9a6a714542135 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Fri, 4 Mar 2022 11:19:58 -0800 Subject: [PATCH 25/33] Revert "Testing project_assigner action for beta projects" (#126952) This reverts commit eed64fda745de983c7a21f7b291952fcb396ddd8. --- .github/workflows/assign-infra-monitoring.yml | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/assign-infra-monitoring.yml diff --git a/.github/workflows/assign-infra-monitoring.yml b/.github/workflows/assign-infra-monitoring.yml deleted file mode 100644 index 96376ecebf8544..00000000000000 --- a/.github/workflows/assign-infra-monitoring.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Assign to Infra Monitoring UI (beta) project -on: - issues: - types: [labeled] - -jobs: - assign_to_project: - runs-on: ubuntu-latest - name: Assign issue or PR to project based on label - steps: - - name: Assign to project - uses: elastic/github-actions/project-assigner@v2.1.1 - id: project_assigner_infra_monitoring - with: - issue-mappings: | - [ - {"label": "Team:Infra Monitoring UI", "projectNumber": 664, "projectScope": "org"}, - {"label": "Feature:Stack Monitoring", "projectNumber": 664, "projectScope": "org"}, - {"label": "Feature:Logs UI", "projectNumber": 664, "projectScope": "org"}, - {"label": "Feature:Metrics UI", "projectNumber": 664, "projectScope": "org"}, - ] - ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} From 1ed4aea9e4be3a50d1635552af005b4ec76b8ebc Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Fri, 4 Mar 2022 14:23:35 -0500 Subject: [PATCH 26/33] Adds workflow for infra monitoring ui team (#126921) * Adds workflow for infra monitoring ui team * Adds other labels for our team * Updates token to general use one @tylersmalley mentioned this one exists, so it seems like a safer choice for now. Ultimately we may want a single one from the Elastic org that is enabled for every repo that needs it. --- .../workflows/project-infra-monitoring-ui.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/project-infra-monitoring-ui.yml diff --git a/.github/workflows/project-infra-monitoring-ui.yml b/.github/workflows/project-infra-monitoring-ui.yml new file mode 100644 index 00000000000000..b9fd04b164a8d5 --- /dev/null +++ b/.github/workflows/project-infra-monitoring-ui.yml @@ -0,0 +1,25 @@ +name: Add issues to Infra Monitoring UI project +on: + issues: + types: [labeled] + +jobs: + sync_issues_with_table: + runs-on: ubuntu-latest + name: Add issues to project + steps: + - name: Add + uses: richkuz/projectnext-label-assigner@1.0.2 + id: add_to_projects + with: + config: | + [ + {"label": "Team:Infra Monitoring UI", "projectNumber": 664}, + {"label": "Feature:Stack Monitoring", "projectNumber": 664}, + {"label": "Feature:Logs UI", "projectNumber": 664}, + {"label": "Feature:Metrics UI", "projectNumber": 664}, + ] + env: + GRAPHQL_API_BASE: 'https://api.github.com' + PAT_TOKEN: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From f974150c7be332ed9e016f1134400c268ffe1cde Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 4 Mar 2022 13:46:45 -0700 Subject: [PATCH 27/33] [Dashboard] Close toolbar popover for log stream visualizations (#126840) * Fix close popover on click * Fix close popover on click - second attempt * Add functional test to ensure menu closes --- .../application/top_nav/editor_menu.tsx | 85 ++++++++++--------- .../dashboard/create_and_add_embeddables.ts | 16 +++- .../services/dashboard/add_panel.ts | 5 ++ 3 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx index 44b1aec226fd66..5fece7ff959ced 100644 --- a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx +++ b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx @@ -153,7 +153,8 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { }; const getEmbeddableFactoryMenuItem = ( - factory: EmbeddableFactoryDefinition + factory: EmbeddableFactoryDefinition, + closePopover: () => void ): EuiContextMenuPanelItemDescriptor => { const icon = factory?.getIconType ? factory.getIconType() : 'empty'; @@ -164,6 +165,7 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { icon, toolTipContent, onClick: async () => { + closePopover(); if (trackUiMetric) { trackUiMetric(METRIC_TYPE.CLICK, factory.type); } @@ -192,42 +194,47 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { defaultMessage: 'Aggregation based', }); - const editorMenuPanels = [ - { - id: 0, - items: [ - ...visTypeAliases.map(getVisTypeAliasMenuItem), - ...Object.values(factoryGroupMap).map(({ id, appName, icon, panelId }) => ({ - name: appName, - icon, - panel: panelId, - 'data-test-subj': `dashboardEditorMenu-${id}Group`, - })), - ...ungroupedFactories.map(getEmbeddableFactoryMenuItem), - ...promotedVisTypes.map(getVisTypeMenuItem), - { - name: aggsPanelTitle, - icon: 'visualizeApp', - panel: aggBasedPanelID, - 'data-test-subj': `dashboardEditorAggBasedMenuItem`, - }, - ...toolVisTypes.map(getVisTypeMenuItem), - ], - }, - { - id: aggBasedPanelID, - title: aggsPanelTitle, - items: aggsBasedVisTypes.map(getVisTypeMenuItem), - }, - ...Object.values(factoryGroupMap).map( - ({ appName, panelId, factories: groupFactories }: FactoryGroup) => ({ - id: panelId, - title: appName, - items: groupFactories.map(getEmbeddableFactoryMenuItem), - }) - ), - ]; - + const getEditorMenuPanels = (closePopover: () => void) => { + return [ + { + id: 0, + items: [ + ...visTypeAliases.map(getVisTypeAliasMenuItem), + ...Object.values(factoryGroupMap).map(({ id, appName, icon, panelId }) => ({ + name: appName, + icon, + panel: panelId, + 'data-test-subj': `dashboardEditorMenu-${id}Group`, + })), + ...ungroupedFactories.map((factory) => { + return getEmbeddableFactoryMenuItem(factory, closePopover); + }), + ...promotedVisTypes.map(getVisTypeMenuItem), + { + name: aggsPanelTitle, + icon: 'visualizeApp', + panel: aggBasedPanelID, + 'data-test-subj': `dashboardEditorAggBasedMenuItem`, + }, + ...toolVisTypes.map(getVisTypeMenuItem), + ], + }, + { + id: aggBasedPanelID, + title: aggsPanelTitle, + items: aggsBasedVisTypes.map(getVisTypeMenuItem), + }, + ...Object.values(factoryGroupMap).map( + ({ appName, panelId, factories: groupFactories }: FactoryGroup) => ({ + id: panelId, + title: appName, + items: groupFactories.map((factory) => { + return getEmbeddableFactoryMenuItem(factory, closePopover); + }), + }) + ), + ]; + }; return ( { panelPaddingSize="none" data-test-subj="dashboardEditorMenuButton" > - {() => ( + {({ closePopover }: { closePopover: () => void }) => ( { await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.preserveCrossAppState(); - await PageObjects.dashboard.loadSavedDashboard('few panels'); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('LOG_STREAM_EMBEDDABLE'); + await dashboardAddPanel.expectEditorMenuClosed(); }); describe('add new visualization link', () => { + before(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + }); + it('adds new visualization via the top nav link', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await PageObjects.dashboard.switchToEditMode(); diff --git a/test/functional/services/dashboard/add_panel.ts b/test/functional/services/dashboard/add_panel.ts index 43ab1f966bc9a0..e42c221a494759 100644 --- a/test/functional/services/dashboard/add_panel.ts +++ b/test/functional/services/dashboard/add_panel.ts @@ -46,6 +46,11 @@ export class DashboardAddPanelService extends FtrService { async clickEditorMenuButton() { this.log.debug('DashboardAddPanel.clickEditorMenuButton'); await this.testSubjects.click('dashboardEditorMenuButton'); + await this.testSubjects.existOrFail('dashboardEditorContextMenu'); + } + + async expectEditorMenuClosed() { + await this.testSubjects.missingOrFail('dashboardEditorContextMenu'); } async clickAggBasedVisualizations() { From 5f8f4d7c4f9151d4baaa5cb579e1c732b57b078a Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 4 Mar 2022 12:56:30 -0800 Subject: [PATCH 28/33] Edits dateFormat settings (#126858) --- .../server/ui_settings/settings/date_formats.ts | 14 +++++--------- .../plugins/translations/translations/fr-FR.json | 5 ----- .../plugins/translations/translations/ja-JP.json | 5 ----- .../plugins/translations/translations/zh-CN.json | 5 ----- 4 files changed, 5 insertions(+), 24 deletions(-) diff --git a/src/core/server/ui_settings/settings/date_formats.ts b/src/core/server/ui_settings/settings/date_formats.ts index c626c4a83cc4cc..039ead326a2361 100644 --- a/src/core/server/ui_settings/settings/date_formats.ts +++ b/src/core/server/ui_settings/settings/date_formats.ts @@ -31,7 +31,7 @@ export const getDateFormatSettings = (): Record => { }), value: 'MMM D, YYYY @ HH:mm:ss.SSS', description: i18n.translate('core.ui_settings.params.dateFormatText', { - defaultMessage: 'When displaying a pretty formatted date, use this {formatLink}', + defaultMessage: 'The {formatLink} for pretty formatted dates.', description: 'Part of composite text: core.ui_settings.params.dateFormatText + ' + 'core.ui_settings.params.dateFormat.optionsLinkText', @@ -48,15 +48,11 @@ export const getDateFormatSettings = (): Record => { }, 'dateFormat:tz': { name: i18n.translate('core.ui_settings.params.dateFormat.timezoneTitle', { - defaultMessage: 'Timezone for date formatting', + defaultMessage: 'Time zone', }), value: 'Browser', description: i18n.translate('core.ui_settings.params.dateFormat.timezoneText', { - defaultMessage: - 'Which timezone should be used. {defaultOption} will use the timezone detected by your browser.', - values: { - defaultOption: '"Browser"', - }, + defaultMessage: 'The default time zone.', }), type: 'select', options: timezones, @@ -115,7 +111,7 @@ export const getDateFormatSettings = (): Record => { }), value: defaultWeekday, description: i18n.translate('core.ui_settings.params.dateFormat.dayOfWeekText', { - defaultMessage: 'What day should weeks start on?', + defaultMessage: 'The day that starts the week.', }), type: 'select', options: weekdays, @@ -141,7 +137,7 @@ export const getDateFormatSettings = (): Record => { }), value: 'MMM D, YYYY @ HH:mm:ss.SSSSSSSSS', description: i18n.translate('core.ui_settings.params.dateNanosFormatText', { - defaultMessage: 'Used for the {dateNanosLink} datatype of Elasticsearch', + defaultMessage: 'The format for {dateNanosLink} data.', values: { dateNanosLink: '' + diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 96327533d4e291..99e4c6a4ca1f30 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -1254,18 +1254,13 @@ "core.toasts.errorToast.seeFullError": "Voir l'erreur en intégralité", "core.ui_settings.params.darkModeText": "Activez le mode sombre pour l'interface utilisateur Kibana. Vous devez actualiser la page pour que ce paramètre s’applique.", "core.ui_settings.params.darkModeTitle": "Mode sombre", - "core.ui_settings.params.dateFormat.dayOfWeekText": "Quel est le premier jour de la semaine ?", "core.ui_settings.params.dateFormat.dayOfWeekTitle": "Jour de la semaine", "core.ui_settings.params.dateFormat.optionsLinkText": "format", "core.ui_settings.params.dateFormat.scaled.intervalsLinkText": "Intervalles ISO8601", "core.ui_settings.params.dateFormat.scaledText": "Les valeurs qui définissent le format utilisé lorsque les données temporelles sont rendues dans l'ordre, et lorsque les horodatages formatés doivent s'adapter à l'intervalle entre les mesures. Les clés sont {intervalsLink}.", "core.ui_settings.params.dateFormat.scaledTitle": "Format de date scalé", "core.ui_settings.params.dateFormat.timezone.invalidValidationMessage": "Fuseau horaire non valide : {timezone}", - "core.ui_settings.params.dateFormat.timezoneText": "Fuseau horaire à utiliser. L’option {defaultOption} utilise le fuseau horaire détecté par le navigateur.", - "core.ui_settings.params.dateFormat.timezoneTitle": "Fuseau horaire pour le format de date", - "core.ui_settings.params.dateFormatText": "{formatLink} utilisé pour les dates formatées", "core.ui_settings.params.dateFormatTitle": "Format de date", - "core.ui_settings.params.dateNanosFormatText": "Utilisé pour le type de données {dateNanosLink} d'Elasticsearch", "core.ui_settings.params.dateNanosFormatTitle": "Date au format nanosecondes", "core.ui_settings.params.dateNanosLinkTitle": "date_nanos", "core.ui_settings.params.dayOfWeekText.invalidValidationMessage": "Jour de la semaine non valide : {dayOfWeek}", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d47ff1ed31496a..70d65550a40563 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1567,18 +1567,13 @@ "core.toasts.errorToast.seeFullError": "完全なエラーを表示", "core.ui_settings.params.darkModeText": "Kibana UIのダークモードを有効にします。この設定を適用するにはページの更新が必要です。", "core.ui_settings.params.darkModeTitle": "ダークモード", - "core.ui_settings.params.dateFormat.dayOfWeekText": "週の初めの曜日を設定します", "core.ui_settings.params.dateFormat.dayOfWeekTitle": "曜日", "core.ui_settings.params.dateFormat.optionsLinkText": "フォーマット", "core.ui_settings.params.dateFormat.scaled.intervalsLinkText": "ISO8601間隔", "core.ui_settings.params.dateFormat.scaledText": "時間ベースのデータが順番にレンダリングされ、フォーマットされたタイムスタンプが測定値の間隔に適応すべき状況で使用されるフォーマットを定義する値です。キーは{intervalsLink}です。", "core.ui_settings.params.dateFormat.scaledTitle": "スケーリングされたデータフォーマットです", "core.ui_settings.params.dateFormat.timezone.invalidValidationMessage": "無効なタイムゾーン:{timezone}", - "core.ui_settings.params.dateFormat.timezoneText": "使用されるタイムゾーンです。{defaultOption}ではご使用のブラウザーにより検知されたタイムゾーンが使用されます。", - "core.ui_settings.params.dateFormat.timezoneTitle": "データフォーマットのタイムゾーン", - "core.ui_settings.params.dateFormatText": "きちんとフォーマットされたデータを表示する際、この{formatLink}を使用します", "core.ui_settings.params.dateFormatTitle": "データフォーマット", - "core.ui_settings.params.dateNanosFormatText": "Elasticsearchの{dateNanosLink}データタイプに使用されます", "core.ui_settings.params.dateNanosFormatTitle": "ナノ秒フォーマットでの日付", "core.ui_settings.params.dateNanosLinkTitle": "date_nanos", "core.ui_settings.params.dayOfWeekText.invalidValidationMessage": "無効な曜日:{dayOfWeek}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 96f9e892ba0b93..90eb0d3c35f653 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1574,18 +1574,13 @@ "core.toasts.errorToast.seeFullError": "请参阅完整的错误信息", "core.ui_settings.params.darkModeText": "对 Kibana UI 启用深色模式。需要刷新页面,才能应用设置。", "core.ui_settings.params.darkModeTitle": "深色模式", - "core.ui_settings.params.dateFormat.dayOfWeekText": "一周应该从哪一天开始?", "core.ui_settings.params.dateFormat.dayOfWeekTitle": "周内日", "core.ui_settings.params.dateFormat.optionsLinkText": "格式", "core.ui_settings.params.dateFormat.scaled.intervalsLinkText": "ISO8601 时间间隔", "core.ui_settings.params.dateFormat.scaledText": "定义在以下场合中采用的格式的值:基于时间的数据按顺序呈现,且经格式化的时间戳应适应度量之间的时间间隔。键是{intervalsLink}。", "core.ui_settings.params.dateFormat.scaledTitle": "标度日期格式", "core.ui_settings.params.dateFormat.timezone.invalidValidationMessage": "时区无效:{timezone}", - "core.ui_settings.params.dateFormat.timezoneText": "应使用哪个时区。{defaultOption} 将使用您的浏览器检测到的时区。", - "core.ui_settings.params.dateFormat.timezoneTitle": "用于设置日期格式的时区", - "core.ui_settings.params.dateFormatText": "显示格式正确的日期时,请使用此{formatLink}", "core.ui_settings.params.dateFormatTitle": "日期格式", - "core.ui_settings.params.dateNanosFormatText": "用于 Elasticsearch 的 {dateNanosLink} 数据类型", "core.ui_settings.params.dateNanosFormatTitle": "纳秒格式的日期", "core.ui_settings.params.dateNanosLinkTitle": "date_nanos", "core.ui_settings.params.dayOfWeekText.invalidValidationMessage": "周内日无效:{dayOfWeek}", From 9514e6be38e2f17c3710f17651e716ce94ec9613 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Fri, 4 Mar 2022 16:07:45 -0500 Subject: [PATCH 29/33] Update and rename project-infra-monitoring-ui.yml to add-to-imui-project.yml (#126963) The previous action was failing with an obscure JSON error, so I've copied the APM and Fleet actions instead. --- .github/workflows/add-to-imui-project.yml | 31 +++++++++++++++++++ .../workflows/project-infra-monitoring-ui.yml | 25 --------------- 2 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/add-to-imui-project.yml delete mode 100644 .github/workflows/project-infra-monitoring-ui.yml diff --git a/.github/workflows/add-to-imui-project.yml b/.github/workflows/add-to-imui-project.yml new file mode 100644 index 00000000000000..3cf120b2e81bc5 --- /dev/null +++ b/.github/workflows/add-to-imui-project.yml @@ -0,0 +1,31 @@ +name: Add to Infra Monitoring UI project +on: + issues: + types: + - labeled +jobs: + add_to_project: + runs-on: ubuntu-latest + if: | + contains(github.event.issue.labels.*.name, 'Team:Infra Monitoring UI') || + contains(github.event.issue.labels.*.name, 'Feature:Stack Monitoring') || + contains(github.event.issue.labels.*.name, 'Feature:Logs UI') || + contains(github.event.issue.labels.*.name, 'Feature:Metrics UI') + steps: + - uses: octokit/graphql-action@v2.x + id: add_to_project + with: + headers: '{"GraphQL-Features": "projects_next_graphql"}' + query: | + mutation add_to_project($projectid:ID!,$contentid:ID!) { + addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { + projectNextItem { + id + } + } + } + projectid: ${{ env.PROJECT_ID }} + contentid: ${{ github.event.issue.node_id }} + env: + PROJECT_ID: "PN_kwDOAGc3Zs1EEA" + GITHUB_TOKEN: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} diff --git a/.github/workflows/project-infra-monitoring-ui.yml b/.github/workflows/project-infra-monitoring-ui.yml deleted file mode 100644 index b9fd04b164a8d5..00000000000000 --- a/.github/workflows/project-infra-monitoring-ui.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Add issues to Infra Monitoring UI project -on: - issues: - types: [labeled] - -jobs: - sync_issues_with_table: - runs-on: ubuntu-latest - name: Add issues to project - steps: - - name: Add - uses: richkuz/projectnext-label-assigner@1.0.2 - id: add_to_projects - with: - config: | - [ - {"label": "Team:Infra Monitoring UI", "projectNumber": 664}, - {"label": "Feature:Stack Monitoring", "projectNumber": 664}, - {"label": "Feature:Logs UI", "projectNumber": 664}, - {"label": "Feature:Metrics UI", "projectNumber": 664}, - ] - env: - GRAPHQL_API_BASE: 'https://api.github.com' - PAT_TOKEN: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 63892cf654fccf56a80a01555190fde84f8e46f1 Mon Sep 17 00:00:00 2001 From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com> Date: Fri, 4 Mar 2022 22:59:08 +0100 Subject: [PATCH 30/33] [Alerting] Paginated http requests for the expensive queries (#126111) * Fetch index patterns as chunks on indices search input change. Use debounce for the http requests that are triggered on input change --- .../components/index_select_popover.test.tsx | 8 + .../components/index_select_popover.tsx | 21 +-- .../es_index/es_index_connector.test.tsx | 67 ++++++-- .../es_index/es_index_connector.tsx | 27 ++-- .../public/common/index_controls/index.ts | 38 ++--- .../public/common/lib/data_apis.test.ts | 148 ++++++++++++++++++ .../public/common/lib/data_apis.ts | 82 ++++++++-- 7 files changed, 304 insertions(+), 87 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.test.ts diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx index e5c8343fddf6d4..7b27167d5f5f91 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx @@ -11,6 +11,14 @@ import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { IndexSelectPopover } from './index_select_popover'; import { EuiComboBox } from '@elastic/eui'; +jest.mock('lodash', () => { + const module = jest.requireActual('lodash'); + return { + ...module, + debounce: (fn: () => unknown) => fn, + }; +}); + jest.mock('../../../../triggers_actions_ui/public', () => { const original = jest.requireActual('../../../../triggers_actions_ui/public'); return { diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx index fbfb296c7b2704..a8b9f3f56dd06e 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { isString } from 'lodash'; +import { isString, debounce } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonIcon, @@ -27,7 +27,6 @@ import { firstFieldOption, getFields, getIndexOptions, - getIndexPatterns, getTimeFieldOptions, IErrorObject, } from '../../../../triggers_actions_ui/public'; @@ -62,16 +61,14 @@ export const IndexSelectPopover: React.FunctionComponent = ({ const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); const [indexOptions, setIndexOptions] = useState([]); - const [indexPatterns, setIndexPatterns] = useState([]); const [areIndicesLoading, setAreIndicesLoading] = useState(false); const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); - useEffect(() => { - const indexPatternsFunction = async () => { - setIndexPatterns(await getIndexPatterns()); - }; - indexPatternsFunction(); - }, []); + const loadIndexOptions = debounce(async (search: string) => { + setAreIndicesLoading(true); + setIndexOptions(await getIndexOptions(http!, search)); + setAreIndicesLoading(false); + }, 250); useEffect(() => { const timeFields = getTimeFieldOptions(esFields); @@ -193,11 +190,7 @@ export const IndexSelectPopover: React.FunctionComponent = ({ setTimeFieldOptions([firstFieldOption, ...timeFields]); } }} - onSearchChange={async (search) => { - setAreIndicesLoading(true); - setIndexOptions(await getIndexOptions(http!, search, indexPatterns)); - setAreIndicesLoading(false); - }} + onSearchChange={loadIndexOptions} onBlur={() => { if (!index) { onIndexChange([]); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx index 6aa7fde6d23e1a..2f6cbabc676cba 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -11,28 +11,32 @@ import { act } from 'react-dom/test-utils'; import { EsIndexActionConnector } from '../types'; import IndexActionConnectorFields from './es_index_connector'; import { EuiComboBox, EuiSwitch, EuiSwitchEvent, EuiSelect } from '@elastic/eui'; +import { screen, render, fireEvent } from '@testing-library/react'; jest.mock('../../../../common/lib/kibana'); +jest.mock('lodash', () => { + const module = jest.requireActual('lodash'); + return { + ...module, + debounce: (fn: () => unknown) => fn, + }; +}); + jest.mock('../../../../common/index_controls', () => ({ firstFieldOption: jest.fn(), getFields: jest.fn(), getIndexOptions: jest.fn(), - getIndexPatterns: jest.fn(), })); -const { getIndexPatterns } = jest.requireMock('../../../../common/index_controls'); -getIndexPatterns.mockResolvedValueOnce([ - { - id: 'indexPattern1', - attributes: { - title: 'indexPattern1', - }, - }, +const { getIndexOptions } = jest.requireMock('../../../../common/index_controls'); + +getIndexOptions.mockResolvedValueOnce([ { - id: 'indexPattern2', - attributes: { - title: 'indexPattern2', - }, + label: 'indexOption', + options: [ + { label: 'indexPattern1', value: 'indexPattern1' }, + { label: 'indexPattern2', value: 'indexPattern2' }, + ], }, ]); @@ -59,6 +63,7 @@ function setupGetFieldsResponse(getFieldsWithDateMapping: boolean) { }, ]); } + describe('IndexActionConnectorFields renders', () => { test('renders correctly when creating connector', async () => { const props = { @@ -281,4 +286,40 @@ describe('IndexActionConnectorFields renders', () => { .filter('[data-test-subj="executionTimeFieldSelect"]'); expect(timeFieldSelect.prop('value')).toEqual('test1'); }); + + test('fetches index names on index combobox input change', async () => { + const mockIndexName = 'test-index'; + const props = { + action: { + actionTypeId: '.index', + config: {}, + secrets: {}, + } as EsIndexActionConnector, + editActionConfig: () => {}, + editActionSecrets: () => {}, + errors: { index: [] }, + readOnly: false, + setCallbacks: () => {}, + isEdit: false, + }; + render(); + + const indexComboBox = await screen.findByTestId('connectorIndexesComboBox'); + + // time field switch should show up if index has date type field mapping + setupGetFieldsResponse(true); + + fireEvent.click(indexComboBox); + + await act(async () => { + const event = { target: { value: mockIndexName } }; + fireEvent.change(screen.getByRole('textbox'), event); + }); + + expect(getIndexOptions).toHaveBeenCalledTimes(1); + expect(getIndexOptions).toHaveBeenCalledWith(expect.anything(), mockIndexName); + expect(await screen.findAllByRole('option')).toHaveLength(2); + expect(screen.getByText('indexPattern1')).toBeInTheDocument(); + expect(screen.getByText('indexPattern2')).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx index c99477bfa83f9f..7b0515d8904e27 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx @@ -19,15 +19,11 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; import { ActionConnectorFieldsProps } from '../../../../types'; import { EsIndexActionConnector } from '.././types'; import { getTimeFieldOptions } from '../../../../common/lib/get_time_options'; -import { - firstFieldOption, - getFields, - getIndexOptions, - getIndexPatterns, -} from '../../../../common/index_controls'; +import { firstFieldOption, getFields, getIndexOptions } from '../../../../common/index_controls'; import { useKibana } from '../../../../common/lib/kibana'; interface TimeFieldOptions { @@ -47,10 +43,9 @@ const IndexActionConnectorFields: React.FunctionComponent< executionTimeField != null ); - const [indexPatterns, setIndexPatterns] = useState([]); const [indexOptions, setIndexOptions] = useState([]); const [timeFieldOptions, setTimeFieldOptions] = useState([]); - const [isIndiciesLoading, setIsIndiciesLoading] = useState(false); + const [areIndiciesLoading, setAreIndicesLoading] = useState(false); const setTimeFields = (fields: TimeFieldOptions[]) => { if (fields.length > 0) { @@ -63,9 +58,14 @@ const IndexActionConnectorFields: React.FunctionComponent< } }; + const loadIndexOptions = debounce(async (search: string) => { + setAreIndicesLoading(true); + setIndexOptions(await getIndexOptions(http!, search)); + setAreIndicesLoading(false); + }, 250); + useEffect(() => { const indexPatternsFunction = async () => { - setIndexPatterns(await getIndexPatterns()); if (index) { const currentEsFields = await getFields(http!, [index]); setTimeFields(getTimeFieldOptions(currentEsFields as any)); @@ -119,11 +119,12 @@ const IndexActionConnectorFields: React.FunctionComponent< fullWidth singleSelection={{ asPlainText: true }} async - isLoading={isIndiciesLoading} + isLoading={areIndiciesLoading} isInvalid={isIndexInvalid} noSuggestions={!indexOptions.length} options={indexOptions} data-test-subj="connectorIndexesComboBox" + data-testid="connectorIndexesComboBox" selectedOptions={ index ? [ @@ -147,11 +148,7 @@ const IndexActionConnectorFields: React.FunctionComponent< const currentEsFields = await getFields(http!, indices); setTimeFields(getTimeFieldOptions(currentEsFields as any)); }} - onSearchChange={async (search) => { - setIsIndiciesLoading(true); - setIndexOptions(await getIndexOptions(http!, search, indexPatterns)); - setIsIndiciesLoading(false); - }} + onSearchChange={loadIndexOptions} onBlur={() => { if (!index) { editActionConfig('index', ''); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts index 072684de68b3ed..b05c3f51de4ab6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts @@ -8,48 +8,30 @@ import { uniq } from 'lodash'; import { HttpSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { - loadIndexPatterns, - getMatchingIndices, - getESIndexFields, - getSavedObjectsClient, -} from '../lib/data_apis'; +import { loadIndexPatterns, getMatchingIndices, getESIndexFields } from '../lib/data_apis'; export interface IOption { label: string; options: Array<{ value: string; label: string }>; } -export const getIndexPatterns = async () => { - // TODO: Implement a possibility to retrive index patterns different way to be able to expose this in consumer plugins - if (getSavedObjectsClient()) { - const indexPatternObjects = await loadIndexPatterns(); - return indexPatternObjects.map((indexPattern: any) => indexPattern.attributes.title); - } - return []; -}; - -export const getIndexOptions = async ( - http: HttpSetup, - pattern: string, - indexPatternsParam: string[] -) => { +export const getIndexOptions = async (http: HttpSetup, pattern: string) => { const options: IOption[] = []; if (!pattern) { return options; } - const matchingIndices = (await getMatchingIndices({ - pattern, - http, - })) as string[]; - const matchingIndexPatterns = indexPatternsParam.filter((anIndexPattern) => { - return anIndexPattern.includes(pattern); - }) as string[]; + const [matchingIndices, matchingIndexPatterns] = await Promise.all([ + getMatchingIndices({ + pattern, + http, + }), + loadIndexPatterns(pattern), + ]); if (matchingIndices.length || matchingIndexPatterns.length) { - const matchingOptions = uniq([...matchingIndices, ...matchingIndexPatterns]); + const matchingOptions = uniq([...(matchingIndices as string[]), ...matchingIndexPatterns]); options.push({ label: i18n.translate( diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.test.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.test.ts new file mode 100644 index 00000000000000..92908dbe4c4c72 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { + loadIndexPatterns, + setSavedObjectsClient, + getMatchingIndices, + getESIndexFields, +} from './data_apis'; +import { httpServiceMock } from 'src/core/public/mocks'; + +const mockFind = jest.fn(); +const perPage = 1000; +const http = httpServiceMock.createStartContract(); +const pattern = 'test-pattern'; +const indexes = ['test-index']; + +const generateIndexPattern = (title: string) => ({ + attributes: { + title, + }, +}); + +const mockIndices = { indices: ['indices1', 'indices2'] }; +const mockFields = { + fields: [ + { name: 'name', type: 'type', normalizedType: 'nType', searchable: true, aggregatable: false }, + ], +}; + +const mockPattern = 'test-pattern'; + +describe('Data API', () => { + describe('index fields', () => { + test('fetches index fields', async () => { + http.post.mockResolvedValueOnce(mockFields); + const fields = await getESIndexFields({ indexes, http }); + + expect(http.post).toHaveBeenCalledWith('/api/triggers_actions_ui/data/_fields', { + body: `{"indexPatterns":${JSON.stringify(indexes)}}`, + }); + expect(fields).toEqual(mockFields.fields); + }); + }); + + describe('matching indices', () => { + test('fetches indices', async () => { + http.post.mockResolvedValueOnce(mockIndices); + const indices = await getMatchingIndices({ pattern, http }); + + expect(http.post).toHaveBeenCalledWith('/api/triggers_actions_ui/data/_indices', { + body: `{"pattern":"*${mockPattern}*"}`, + }); + expect(indices).toEqual(mockIndices.indices); + }); + + test('returns empty array if fetch fails', async () => { + http.post.mockRejectedValueOnce(500); + const indices = await getMatchingIndices({ pattern, http }); + expect(indices).toEqual([]); + }); + }); + + describe('index patterns', () => { + beforeEach(() => { + setSavedObjectsClient({ + find: mockFind, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + test('fetches the index patterns', async () => { + mockFind.mockResolvedValueOnce({ + savedObjects: [generateIndexPattern('index-1'), generateIndexPattern('index-2')], + total: 2, + }); + const results = await loadIndexPatterns(mockPattern); + + expect(mockFind).toBeCalledTimes(1); + expect(mockFind).toBeCalledWith({ + fields: ['title'], + page: 1, + perPage, + search: '*test-pattern*', + type: 'index-pattern', + }); + expect(results).toEqual(['index-1', 'index-2']); + }); + + test(`fetches the index patterns as chunks and merges them, if the total number of index patterns more than ${perPage}`, async () => { + mockFind.mockResolvedValueOnce({ + savedObjects: [generateIndexPattern('index-1'), generateIndexPattern('index-2')], + total: 2010, + }); + mockFind.mockResolvedValueOnce({ + savedObjects: [generateIndexPattern('index-3'), generateIndexPattern('index-4')], + total: 2010, + }); + mockFind.mockResolvedValueOnce({ + savedObjects: [generateIndexPattern('index-5'), generateIndexPattern('index-6')], + total: 2010, + }); + const results = await loadIndexPatterns(mockPattern); + + expect(mockFind).toBeCalledTimes(3); + expect(mockFind).toHaveBeenNthCalledWith(1, { + fields: ['title'], + page: 1, + perPage, + search: '*test-pattern*', + type: 'index-pattern', + }); + expect(mockFind).toHaveBeenNthCalledWith(2, { + fields: ['title'], + page: 2, + perPage, + search: '*test-pattern*', + type: 'index-pattern', + }); + expect(mockFind).toHaveBeenNthCalledWith(3, { + fields: ['title'], + page: 3, + perPage, + search: '*test-pattern*', + type: 'index-pattern', + }); + expect(results).toEqual(['index-1', 'index-2', 'index-3', 'index-4', 'index-5', 'index-6']); + }); + + test('returns an empty array if one of the requests fails', async () => { + mockFind.mockResolvedValueOnce({ + savedObjects: [generateIndexPattern('index-1'), generateIndexPattern('index-2')], + total: 1010, + }); + mockFind.mockRejectedValueOnce(500); + + const results = await loadIndexPatterns(mockPattern); + + expect(results).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts index d8a1ecabcd500b..7ccf3bf71bec7a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts @@ -9,6 +9,17 @@ import { HttpSetup } from 'kibana/public'; const DATA_API_ROOT = '/api/triggers_actions_ui/data'; +const formatPattern = (pattern: string) => { + let formattedPattern = pattern; + if (!formattedPattern.startsWith('*')) { + formattedPattern = `*${formattedPattern}`; + } + if (!formattedPattern.endsWith('*')) { + formattedPattern = `${formattedPattern}*`; + } + return formattedPattern; +}; + export async function getMatchingIndices({ pattern, http, @@ -16,17 +27,17 @@ export async function getMatchingIndices({ pattern: string; http: HttpSetup; }): Promise> { - if (!pattern.startsWith('*')) { - pattern = `*${pattern}`; - } - if (!pattern.endsWith('*')) { - pattern = `${pattern}*`; + try { + const formattedPattern = formatPattern(pattern); + + const { indices } = await http.post>( + `${DATA_API_ROOT}/_indices`, + { body: JSON.stringify({ pattern: formattedPattern }) } + ); + return indices; + } catch (e) { + return []; } - const { indices } = await http.post>( - `${DATA_API_ROOT}/_indices`, - { body: JSON.stringify({ pattern }) } - ); - return indices; } export async function getESIndexFields({ @@ -61,11 +72,48 @@ export const getSavedObjectsClient = () => { return savedObjectsClient; }; -export const loadIndexPatterns = async () => { - const { savedObjects } = await getSavedObjectsClient().find({ - type: 'index-pattern', - fields: ['title'], - perPage: 10000, - }); - return savedObjects; +export const loadIndexPatterns = async (pattern: string) => { + let allSavedObjects = []; + const formattedPattern = formatPattern(pattern); + const perPage = 1000; + + try { + const { savedObjects, total } = await getSavedObjectsClient().find({ + type: 'index-pattern', + fields: ['title'], + page: 1, + search: formattedPattern, + perPage, + }); + + allSavedObjects = savedObjects; + + if (total > perPage) { + let currentPage = 2; + const numberOfPages = Math.ceil(total / perPage); + const promises = []; + + while (currentPage <= numberOfPages) { + promises.push( + getSavedObjectsClient().find({ + type: 'index-pattern', + page: currentPage, + fields: ['title'], + search: formattedPattern, + perPage, + }) + ); + currentPage++; + } + + const paginatedResults = await Promise.all(promises); + + allSavedObjects = paginatedResults.reduce((oldResult, result) => { + return oldResult.concat(result.savedObjects); + }, allSavedObjects); + } + return allSavedObjects.map((indexPattern: any) => indexPattern.attributes.title); + } catch (e) { + return []; + } }; From f8586a87d00082620436f18c5d258c1dfd7a2711 Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Fri, 4 Mar 2022 14:02:09 -0800 Subject: [PATCH 31/33] fix for a skipped test `_scripted_fields_filter` (#126866) * fix for a skipped test * cleanup --- test/functional/apps/management/_scripted_fields_filter.js | 4 ++-- test/functional/page_objects/settings_page.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/test/functional/apps/management/_scripted_fields_filter.js b/test/functional/apps/management/_scripted_fields_filter.js index 117b8747c5a0a8..abae9a300994dc 100644 --- a/test/functional/apps/management/_scripted_fields_filter.js +++ b/test/functional/apps/management/_scripted_fields_filter.js @@ -16,8 +16,7 @@ export default function ({ getService, getPageObjects }) { const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['settings']); - // FLAKY: https://github.com/elastic/kibana/issues/126027 - describe.skip('filter scripted fields', function describeIndexTests() { + describe('filter scripted fields', function describeIndexTests() { before(async function () { // delete .kibana index and then wait for Kibana to re-create it await browser.setWindowSize(1200, 800); @@ -67,6 +66,7 @@ export default function ({ getService, getPageObjects }) { expect(lang).to.be('painless'); } }); + await PageObjects.settings.clearScriptedFieldLanguageFilter('painless'); await PageObjects.settings.setScriptedFieldLanguageFilter('expression'); diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index b1e4aa823821b7..70cdbea7fa8970 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -288,7 +288,10 @@ export class SettingsPageObject extends FtrService { } async setScriptedFieldLanguageFilter(language: string) { - await this.testSubjects.clickWhenNotDisabled('scriptedFieldLanguageFilterDropdown'); + await this.retry.try(async () => { + await this.testSubjects.clickWhenNotDisabled('scriptedFieldLanguageFilterDropdown'); + return await this.find.byCssSelector('div.euiPopover__panel-isOpen'); + }); await this.testSubjects.existOrFail('scriptedFieldLanguageFilterDropdown-popover'); await this.testSubjects.existOrFail(`scriptedFieldLanguageFilterDropdown-option-${language}`); await this.testSubjects.click(`scriptedFieldLanguageFilterDropdown-option-${language}`); From 7af9c37016a373093c58aec5844ee040fcbcae72 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Fri, 4 Mar 2022 15:33:14 -0800 Subject: [PATCH 32/33] [Security Solution][Lists] - Hide exception list delete icon if Kibana read only (#126710) Addresses bug #126313 Even if user is given index privileges to lists, UI should follow Kibana privileges. Checks if user is a read only Kibana user and hides the delete icon from exception list view if true. --- .../exceptions/exceptions_table.spec.ts | 21 ++++++++++ .../rules/all/exceptions/columns.tsx | 5 ++- .../all/exceptions/exceptions_table.test.tsx | 42 ++++++++++++++----- .../rules/all/exceptions/exceptions_table.tsx | 14 +++++-- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts index 69bdafd5dccddd..d2578f91720335 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ROLES } from '../../../common/test'; import { getExceptionList, expectedExportedExceptionList } from '../../objects/exception'; import { getNewRule } from '../../objects/rule'; @@ -25,6 +26,7 @@ import { clearSearchSelection, } from '../../tasks/exceptions_table'; import { + EXCEPTIONS_TABLE_DELETE_BTN, EXCEPTIONS_TABLE_LIST_NAME, EXCEPTIONS_TABLE_SHOWING_LISTS, } from '../../screens/exceptions'; @@ -168,3 +170,22 @@ describe('Exceptions Table', () => { cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); }); }); + +describe('Exceptions Table - read only', () => { + before(() => { + // First we login as a privileged user to create exception list + cleanKibana(); + loginAndWaitForPageWithoutDateRange(EXCEPTIONS_URL, ROLES.platform_engineer); + createExceptionList(getExceptionList(), getExceptionList().list_id); + + // Then we login as read-only user to test. + loginAndWaitForPageWithoutDateRange(EXCEPTIONS_URL, ROLES.reader); + waitForExceptionsTableToBeLoaded(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + }); + + it('Delete icon is not shown', () => { + cy.get(EXCEPTIONS_TABLE_DELETE_BTN).should('not.exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx index 78feb911ee082f..33dff406734c99 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx @@ -27,7 +27,8 @@ export const getAllExceptionListsColumns = ( onExport: (arg: { id: string; listId: string; namespaceType: NamespaceType }) => () => void, onDelete: (arg: { id: string; listId: string; namespaceType: NamespaceType }) => () => void, formatUrl: FormatUrl, - navigateToUrl: (url: string) => Promise + navigateToUrl: (url: string) => Promise, + isKibanaReadOnly: boolean ): AllExceptionListsColumns[] => [ { align: 'left', @@ -155,7 +156,7 @@ export const getAllExceptionListsColumns = ( }, { render: ({ id, list_id: listId, namespace_type: namespaceType }: ExceptionListInfo) => { - return listId === 'endpoint_list' ? ( + return listId === 'endpoint_list' || isKibanaReadOnly ? ( <> ) : ( ({ - useUserData: jest.fn().mockReturnValue([ - { - loading: false, - canUserCRUD: false, - }, - ]), -})); - describe('ExceptionListsTable', () => { const exceptionList1 = getExceptionListSchemaMock(); const exceptionList2 = { ...getExceptionListSchemaMock(), list_id: 'not_endpoint_list', id: '2' }; @@ -86,9 +79,17 @@ describe('ExceptionListsTable', () => { endpoint_list: exceptionList1, }, ]); + + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: false, + canUserREAD: false, + }, + ]); }); - it('does not render delete option disabled if list is "endpoint_list"', async () => { + it('does not render delete option if list is "endpoint_list"', async () => { const wrapper = mount( @@ -106,4 +107,25 @@ describe('ExceptionListsTable', () => { wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(0).prop('disabled') ).toBeFalsy(); }); + + it('does not render delete option if user is read only', async () => { + (useUserData as jest.Mock).mockReturnValue([ + { + loading: false, + canUserCRUD: false, + canUserREAD: true, + }, + ]); + + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsTableListId"]').at(1).text()).toEqual( + 'not_endpoint_list' + ); + expect(wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button')).toHaveLength(0); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 4a7c71a1084a7b..c40b6b95717241 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -60,7 +60,7 @@ const exceptionReferenceModalInitialState: ReferenceModalState = { export const ExceptionListsTable = React.memo(() => { const { formatUrl } = useFormatUrl(SecurityPageName.rules); - const [{ loading: userInfoLoading, canUserCRUD }] = useUserData(); + const [{ loading: userInfoLoading, canUserCRUD, canUserREAD }] = useUserData(); const hasPermissions = userHasPermissions(canUserCRUD); const { loading: listsConfigLoading } = useListsConfig(); @@ -193,8 +193,16 @@ export const ExceptionListsTable = React.memo(() => { ); const exceptionsColumns = useMemo((): AllExceptionListsColumns[] => { - return getAllExceptionListsColumns(handleExport, handleDelete, formatUrl, navigateToUrl); - }, [handleExport, handleDelete, formatUrl, navigateToUrl]); + // Defaulting to true to default to the lower privilege first + const isKibanaReadOnly = (canUserREAD && !canUserCRUD) ?? true; + return getAllExceptionListsColumns( + handleExport, + handleDelete, + formatUrl, + navigateToUrl, + isKibanaReadOnly + ); + }, [handleExport, handleDelete, formatUrl, navigateToUrl, canUserREAD, canUserCRUD]); const handleRefresh = useCallback((): void => { if (refreshExceptions != null) { From 23f7cff88a28fbff83aa466ba38351a649db48cd Mon Sep 17 00:00:00 2001 From: Or Ouziel Date: Sun, 6 Mar 2022 20:18:09 +0200 Subject: [PATCH 33/33] fix SO client bulkUpdate return type (#126349) --- ...kibana-plugin-core-public.savedobjectsclient.bulkupdate.md | 4 ++-- src/core/public/public.api.md | 2 +- src/core/public/saved_objects/saved_objects_client.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md index 0e3bfb2bd896b5..0cbfe4fcdead6e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkupdate.md @@ -9,7 +9,7 @@ Update multiple documents at once Signature: ```typescript -bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; +bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; ``` ## Parameters @@ -20,7 +20,7 @@ bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): PromiseReturns: -Promise<SavedObjectsBatchResponse<unknown>> +Promise<SavedObjectsBatchResponse<T>> The result of the update operation containing both failed and updated saved objects. diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index e3f2822b5a7c8d..b30c009bf25384 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1128,7 +1128,7 @@ export class SavedObjectsClient { }>) => Promise<{ resolved_objects: ResolvedSimpleSavedObject[]; }>; - bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; + bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; create: (type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise>; // Warning: (ae-forgotten-export) The symbol "SavedObjectsDeleteOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "SavedObjectsClientContract" needs to be exported by the entry point index.d.ts diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index c19233809a94be..8509ace0476910 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -596,7 +596,7 @@ export class SavedObjectsClient { return renameKeys< PromiseType>, SavedObjectsBatchResponse - >({ saved_objects: 'savedObjects' }, resp) as SavedObjectsBatchResponse; + >({ saved_objects: 'savedObjects' }, resp) as SavedObjectsBatchResponse; }); }