From 559d928705d1e5d9510229b745612a17346181a1 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 26 Apr 2023 18:02:44 +0200 Subject: [PATCH 01/29] [Synthetics] Skip package installation on CI (#155854) --- .buildkite/disabled_jest_configs.json | 3 +-- .../server/synthetics_service/synthetics_service.ts | 4 ++++ x-pack/test/api_integration/config.ts | 3 --- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.buildkite/disabled_jest_configs.json b/.buildkite/disabled_jest_configs.json index 4b37f3d9be6b6..a64c34ae741b4 100644 --- a/.buildkite/disabled_jest_configs.json +++ b/.buildkite/disabled_jest_configs.json @@ -1,4 +1,3 @@ [ - "x-pack/plugins/watcher/jest.config.js", - "src/core/server/integration_tests/ui_settings/jest.integration.config.js" + "x-pack/plugins/watcher/jest.config.js" ] diff --git a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.ts b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.ts index 3e420bf478dec..69fc7ce0ee6dc 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.ts @@ -108,6 +108,10 @@ export class SyntheticsService { } public async setupIndexTemplates() { + if (process.env.CI && !this.config?.manifestUrl) { + // skip installation on CI + return; + } if (this.indexTemplateExists) { // if already installed, don't need to reinstall return; diff --git a/x-pack/test/api_integration/config.ts b/x-pack/test/api_integration/config.ts index 5766a9efdf982..e43c76d42adfa 100644 --- a/x-pack/test/api_integration/config.ts +++ b/x-pack/test/api_integration/config.ts @@ -30,9 +30,6 @@ export async function getApiIntegrationConfig({ readConfigFile }: FtrConfigProvi '--xpack.ruleRegistry.write.enabled=true', '--xpack.ruleRegistry.write.enabled=true', '--xpack.ruleRegistry.write.cache.enabled=false', - '--xpack.uptime.service.password=test', - '--xpack.uptime.service.username=localKibanaIntegrationTestsUser', - '--xpack.uptime.service.devUrl=mockDevUrl', '--monitoring_collection.opentelemetry.metrics.prometheus.enabled=true', ], }, From 53daa334f45a3855c84779ccadadf9ac8f48e493 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 26 Apr 2023 18:05:02 +0200 Subject: [PATCH 02/29] Add Locator for Rules page (#155799) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/observability/common/index.ts | 1 + .../public/locators/rules.test.ts | 55 +++++++++++++++++++ .../observability/public/locators/rules.ts | 54 ++++++++++++++++++ .../slo_details/components/header_control.tsx | 32 +++++++---- .../pages/slo_details/slo_details.test.tsx | 26 +++++++++ .../pages/slos/components/slo_list_item.tsx | 21 +++++-- .../public/pages/slos/slos.test.tsx | 33 +++++++++++ x-pack/plugins/observability/public/plugin.ts | 5 ++ 8 files changed, 210 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/observability/public/locators/rules.test.ts create mode 100644 x-pack/plugins/observability/public/locators/rules.ts diff --git a/x-pack/plugins/observability/common/index.ts b/x-pack/plugins/observability/common/index.ts index 7ff22e2c50264..628b33ef0a11f 100644 --- a/x-pack/plugins/observability/common/index.ts +++ b/x-pack/plugins/observability/common/index.ts @@ -64,6 +64,7 @@ export const syntheticsMonitorDetailLocatorID = 'SYNTHETICS_MONITOR_DETAIL_LOCAT export const syntheticsEditMonitorLocatorID = 'SYNTHETICS_EDIT_MONITOR_LOCATOR'; export const syntheticsSettingsLocatorID = 'SYNTHETICS_SETTINGS'; export const ruleDetailsLocatorID = 'RULE_DETAILS_LOCATOR'; +export const rulesLocatorID = 'RULES_LOCATOR'; export { NETWORK_TIMINGS_FIELDS, diff --git a/x-pack/plugins/observability/public/locators/rules.test.ts b/x-pack/plugins/observability/public/locators/rules.test.ts new file mode 100644 index 0000000000000..86d4303f054b9 --- /dev/null +++ b/x-pack/plugins/observability/public/locators/rules.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RulesLocatorDefinition, RULES_PATH } from './rules'; + +describe('RulesLocator', () => { + const locator = new RulesLocatorDefinition(); + + it('should return correct url when empty params are provided', async () => { + const location = await locator.getLocation({}); + expect(location.app).toEqual('observability'); + expect(location.path).toEqual( + `${RULES_PATH}?_a=(lastResponse:!(),params:(),search:'',status:!(),type:!())` + ); + }); + + it('should return correct url when lastResponse is provided', async () => { + const location = await locator.getLocation({ lastResponse: ['foo'] }); + expect(location.path).toEqual( + `${RULES_PATH}?_a=(lastResponse:!(foo),params:(),search:'',status:!(),type:!())` + ); + }); + + it('should return correct url when params is provided', async () => { + const location = await locator.getLocation({ params: { sloId: 'foo' } }); + expect(location.path).toEqual( + `${RULES_PATH}?_a=(lastResponse:!(),params:(sloId:foo),search:'',status:!(),type:!())` + ); + }); + + it('should return correct url when search is provided', async () => { + const location = await locator.getLocation({ search: 'foo' }); + expect(location.path).toEqual( + `${RULES_PATH}?_a=(lastResponse:!(),params:(),search:foo,status:!(),type:!())` + ); + }); + + it('should return correct url when status is provided', async () => { + const location = await locator.getLocation({ status: ['enabled'] }); + expect(location.path).toEqual( + `${RULES_PATH}?_a=(lastResponse:!(),params:(),search:'',status:!(enabled),type:!())` + ); + }); + + it('should return correct url when type is provided', async () => { + const location = await locator.getLocation({ type: ['foo'] }); + expect(location.path).toEqual( + `${RULES_PATH}?_a=(lastResponse:!(),params:(),search:'',status:!(),type:!(foo))` + ); + }); +}); diff --git a/x-pack/plugins/observability/public/locators/rules.ts b/x-pack/plugins/observability/public/locators/rules.ts new file mode 100644 index 0000000000000..533f4ccc98fd1 --- /dev/null +++ b/x-pack/plugins/observability/public/locators/rules.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; +import type { SerializableRecord } from '@kbn/utility-types'; +import type { LocatorDefinition } from '@kbn/share-plugin/public'; +import type { RuleStatus } from '@kbn/triggers-actions-ui-plugin/public'; +import { rulesLocatorID } from '../../common'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type RulesParams = { + lastResponse?: string[]; + params?: Record; + search?: string; + status?: RuleStatus[]; + type?: string[]; +}; + +export interface RulesLocatorParams extends RulesParams, SerializableRecord {} + +export const RULES_PATH = '/alerts/rules'; + +export class RulesLocatorDefinition implements LocatorDefinition { + public readonly id = rulesLocatorID; + + public readonly getLocation = async ({ + lastResponse = [], + params = {}, + search = '', + status = [], + type = [], + }: RulesLocatorParams) => { + return { + app: 'observability', + path: setStateToKbnUrl( + '_a', + { + lastResponse, + params, + search, + status, + type, + }, + { useHash: false, storeInHashQuery: false }, + RULES_PATH + ), + state: {}, + }; + }; +} diff --git a/x-pack/plugins/observability/public/pages/slo_details/components/header_control.tsx b/x-pack/plugins/observability/public/pages/slo_details/components/header_control.tsx index 99ce1ba03f9b6..0bac038c43088 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/components/header_control.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/components/header_control.tsx @@ -10,14 +10,14 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { useCapabilities } from '../../../hooks/slo/use_capabilities'; +import { useKibana } from '../../../utils/kibana_react'; import { isApmIndicatorType } from '../../../utils/slo/indicator'; import { convertSliApmParamsToApmAppDeeplinkUrl } from '../../../utils/slo/convert_sli_apm_params_to_apm_app_deeplink_url'; import { SLO_BURN_RATE_RULE_ID } from '../../../../common/constants'; -import { sloFeatureId } from '../../../../common'; +import { rulesLocatorID, sloFeatureId } from '../../../../common'; import { paths } from '../../../config/paths'; -import { useKibana } from '../../../utils/kibana_react'; -import { ObservabilityAppServices } from '../../../application/types'; -import { useCapabilities } from '../../../hooks/slo/use_capabilities'; +import type { RulesParams } from '../../../locators/rules'; export interface Props { slo: SLOWithSummaryResponse | undefined; @@ -28,8 +28,11 @@ export function HeaderControl({ isLoading, slo }: Props) { const { application: { navigateToUrl }, http: { basePath }, + share: { + url: { locators }, + }, triggersActionsUi: { getAddRuleFlyout: AddRuleFlyout }, - } = useKibana().services; + } = useKibana().services; const { hasWriteCapabilities } = useCapabilities(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isRuleFlyoutVisible, setRuleFlyoutVisibility] = useState(false); @@ -52,12 +55,19 @@ export function HeaderControl({ isLoading, slo }: Props) { setRuleFlyoutVisibility(true); }; - const handleNavigateToRules = () => { - navigateToUrl( - basePath.prepend( - `${paths.observability.rules}?_a=(lastResponse:!(),search:%27%27,params:(sloId:%27${slo?.id}%27),status:!(),type:!())` - ) - ); + const handleNavigateToRules = async () => { + const locator = locators.get(rulesLocatorID); + + if (slo?.id) { + locator?.navigate( + { + params: { sloId: slo.id }, + }, + { + replace: true, + } + ); + } }; const handleNavigateToApm = () => { diff --git a/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx b/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx index 4028b80203591..9cd8836c6cdf7 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx @@ -49,6 +49,7 @@ const useCapabilitiesMock = useCapabilities as jest.Mock; const mockNavigate = jest.fn(); const mockBasePathPrepend = jest.fn(); +const mockLocator = jest.fn(); const mockKibana = () => { useKibanaMock.mockReturnValue({ @@ -60,6 +61,13 @@ const mockKibana = () => { prepend: mockBasePathPrepend, }, }, + share: { + url: { + locators: { + get: mockLocator, + }, + }, + }, triggersActionsUi: { getAddRuleFlyout: jest.fn(() => (
mocked component
@@ -182,6 +190,24 @@ describe('SLO Details Page', () => { expect(screen.queryByTestId('sloDetailsHeaderControlPopoverCreateRule')).toBeTruthy(); }); + it("renders a 'Manage rules' button under actions menu", async () => { + const slo = buildSlo(); + useParamsMock.mockReturnValue(slo.id); + useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo }); + useLicenseMock.mockReturnValue({ hasAtLeast: () => true }); + + render(); + + fireEvent.click(screen.getByTestId('o11yHeaderControlActionsButton')); + + const manageRulesButton = screen.queryByTestId('sloDetailsHeaderControlPopoverManageRules'); + expect(manageRulesButton).toBeTruthy(); + + fireEvent.click(manageRulesButton!); + + expect(mockLocator).toBeCalled(); + }); + it('renders the Overview tab by default', async () => { const slo = buildSlo(); useParamsMock.mockReturnValue(slo.id); diff --git a/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx b/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx index 049481486eb9e..0e58573b740c2 100644 --- a/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx +++ b/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx @@ -34,10 +34,11 @@ import { transformValuesToCreateSLOInput, } from '../../slo_edit/helpers/process_slo_form_values'; import { SLO_BURN_RATE_RULE_ID } from '../../../../common/constants'; -import { sloFeatureId } from '../../../../common'; +import { rulesLocatorID, sloFeatureId } from '../../../../common'; import { paths } from '../../../config/paths'; import type { ActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts'; import type { SloRule } from '../../../hooks/slo/use_fetch_rules_for_slo'; +import type { RulesParams } from '../../../locators/rules'; export interface SloListItemProps { slo: SLOWithSummaryResponse; @@ -57,6 +58,9 @@ export function SloListItem({ const { application: { navigateToUrl }, http: { basePath }, + share: { + url: { locators }, + }, triggersActionsUi: { getAddRuleFlyout: AddRuleFlyout }, } = useKibana().services; const { hasWriteCapabilities } = useCapabilities(); @@ -92,11 +96,16 @@ export function SloListItem({ queryClient.invalidateQueries(['fetchRulesForSlo']); }; - const handleNavigateToRules = () => { - navigateToUrl( - basePath.prepend( - `${paths.observability.rules}?_a=(lastResponse:!(),search:%27%27,params:(sloId:%27${slo?.id}%27),status:!(),type:!())` - ) + const handleNavigateToRules = async () => { + const locator = locators.get(rulesLocatorID); + + locator?.navigate( + { + params: { sloId: slo.id }, + }, + { + replace: true, + } ); }; diff --git a/x-pack/plugins/observability/public/pages/slos/slos.test.tsx b/x-pack/plugins/observability/public/pages/slos/slos.test.tsx index a3e01f0b00458..12a6f2762de58 100644 --- a/x-pack/plugins/observability/public/pages/slos/slos.test.tsx +++ b/x-pack/plugins/observability/public/pages/slos/slos.test.tsx @@ -60,6 +60,7 @@ useDeleteSloMock.mockReturnValue({ mutate: mockDeleteSlo }); const mockNavigate = jest.fn(); const mockAddSuccess = jest.fn(); const mockAddError = jest.fn(); +const mockLocator = jest.fn(); const mockGetAddRuleFlyout = jest.fn().mockReturnValue(() =>
Add rule flyout
); const mockKibana = () => { @@ -78,6 +79,13 @@ const mockKibana = () => { addError: mockAddError, }, }, + share: { + url: { + locators: { + get: mockLocator, + }, + }, + }, triggersActionsUi: { getAddRuleFlyout: mockGetAddRuleFlyout }, uiSettings: { get: (settings: string) => { @@ -226,6 +234,31 @@ describe('SLOs Page', () => { expect(mockGetAddRuleFlyout).toBeCalled(); }); + it('allows managing rules for an SLO', async () => { + useFetchSloListMock.mockReturnValue({ isLoading: false, sloList }); + + useFetchHistoricalSummaryMock.mockReturnValue({ + isLoading: false, + sloHistoricalSummaryResponse: historicalSummaryData, + }); + + await act(async () => { + render(); + }); + + screen.getAllByLabelText('Actions').at(0)?.click(); + + await waitForEuiPopoverOpen(); + + const button = screen.getByTestId('sloActionsManageRules'); + + expect(button).toBeTruthy(); + + button.click(); + + expect(mockLocator).toBeCalled(); + }); + it('allows deleting an SLO', async () => { useFetchSloListMock.mockReturnValue({ isLoading: false, sloList }); diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 190c646bebbcc..3ea0c8b797c40 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -50,6 +50,7 @@ import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { ExploratoryViewPublicStart } from '@kbn/exploratory-view-plugin/public'; import { RuleDetailsLocatorDefinition } from './locators/rule_details'; +import { RulesLocatorDefinition } from './locators/rules'; import { observabilityAppId, observabilityFeatureId, casesPath } from '../common'; import { registerDataHandler } from './data_handler'; import { @@ -190,6 +191,9 @@ export class Plugin this.observabilityRuleTypeRegistry = createObservabilityRuleTypeRegistry( pluginsSetup.triggersActionsUi.ruleTypeRegistry ); + + const locator = pluginsSetup.share.url.locators.create(new RulesLocatorDefinition()); + pluginsSetup.share.url.locators.create(new RuleDetailsLocatorDefinition()); const mount = async (params: AppMountParameters) => { @@ -310,6 +314,7 @@ export class Plugin dashboard: { register: registerDataHandler }, observabilityRuleTypeRegistry: this.observabilityRuleTypeRegistry, useRulesLink: createUseRulesLink(), + rulesLocator: locator, }; } From 8f597207a222f02b1c7664bc555a9f6e744bc4aa Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 26 Apr 2023 18:16:41 +0200 Subject: [PATCH 03/29] [Security Solution][Alerts] Format alerts for per-alert action context variables (#155829) ## Summary Closes [#155812](https://github.com/elastic/kibana/issues/155812) In https://github.com/elastic/kibana/pull/155384, detection rules were switched to support per-alert actions. When passing the context variable, it was suggested that we should be calling formatAlert to format the alert for notifications, however doing that causes some test failures because formatAlert is fairly heavyweight and bunch of tests were timing out. Thanks to @marshallmain we have this much faster `expandDottedObject` that solves the issue with the very slow `formatAlert`. --- .../create_persistence_rule_type_wrapper.ts | 4 ++-- .../common/utils/expand_dotted.test.ts | 12 +++++++++++ .../common/utils/expand_dotted.ts | 20 ++++++------------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts index cb4d9d734bd02..a69e3b657aeb2 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts @@ -195,7 +195,7 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper start: Date.parse(alert[TIMESTAMP]), end: Date.parse(alert[TIMESTAMP]), }), - alerts: [alert], + alerts: [formatAlert?.(alert) ?? alert], }) ); @@ -387,7 +387,7 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper start: Date.parse(alert[TIMESTAMP]), end: Date.parse(alert[TIMESTAMP]), }), - alerts: [alert], + alerts: [formatAlert?.(alert) ?? alert], }) ); diff --git a/x-pack/plugins/security_solution/common/utils/expand_dotted.test.ts b/x-pack/plugins/security_solution/common/utils/expand_dotted.test.ts index 018220e400937..d3f227274e7f1 100644 --- a/x-pack/plugins/security_solution/common/utils/expand_dotted.test.ts +++ b/x-pack/plugins/security_solution/common/utils/expand_dotted.test.ts @@ -70,6 +70,18 @@ describe('Expand Dotted', () => { }); }); + it('overwrites earlier fields when later fields conflict', () => { + const simpleDottedObj = { + 'kibana.test.1': 'the spice must flow', + 'kibana.test': 2, + }; + expect(expandDottedObject(simpleDottedObj)).toEqual({ + kibana: { + test: 2, + }, + }); + }); + it('expands non dotted field without changing it other than reference', () => { const simpleDottedObj = { test: { value: '123' }, diff --git a/x-pack/plugins/security_solution/common/utils/expand_dotted.ts b/x-pack/plugins/security_solution/common/utils/expand_dotted.ts index f90f589486ff5..4aa56b021244b 100644 --- a/x-pack/plugins/security_solution/common/utils/expand_dotted.ts +++ b/x-pack/plugins/security_solution/common/utils/expand_dotted.ts @@ -5,16 +5,7 @@ * 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) }; - } -}; +import { setWith } from 'lodash'; /* * Expands an object with "dotted" fields to a nested object with unflattened fields. @@ -48,8 +39,9 @@ export const expandDottedObject = (dottedObj: object) => { if (Array.isArray(dottedObj)) { return dottedObj; } - return Object.entries(dottedObj).reduce( - (acc, [key, val]) => merge(acc, expandDottedField(key, val)), - {} - ); + const returnObj = {}; + Object.entries(dottedObj).forEach(([key, value]) => { + setWith(returnObj, key, value, Object); + }); + return returnObj; }; From 947b75741ca1c88db3f56b53f7dd76aea8de4079 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 26 Apr 2023 10:25:41 -0600 Subject: [PATCH 04/29] [maps] prepare for spatial join (#155575) PR does some refactoring required to support [spatial joins](https://github.com/elastic/kibana/issues/154605) * separate `ITermJoinSource` into a generic interface `IJoinSource` * Update `InnerJoin` to use `IJoinSource` * Consolidate join sources into `join_sources` folder * Rename `JoinTooltipProperty` -> `TermJoinTooltipProperty` * Convert any effected file to TS --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../layer_descriptor_types.ts | 4 +- .../source_descriptor_types.ts | 2 +- ...{inner_join.test.js => inner_join.test.ts} | 89 ++++++++----------- .../maps/public/classes/joins/inner_join.ts | 19 ++-- .../layers/build_vector_request_meta.ts | 4 +- .../perform_inner_joins.ts | 22 +++-- .../mvt_vector_layer.test.tsx | 6 +- .../layers/vector_layer/vector_layer.tsx | 30 +++++-- .../es_term_source/es_term_source.test.ts} | 18 ++-- .../es_term_source/es_term_source.ts | 26 +++--- .../es_term_source/index.ts | 0 .../index.ts | 6 +- .../{ => join_sources}/table_source/index.ts | 0 .../table_source/table_source.test.ts | 4 +- .../table_source/table_source.ts | 18 ++-- .../types.ts} | 11 ++- .../index.ts | 2 +- .../term_join_key_label.tsx} | 11 ++- .../term_join_tooltip_property.tsx} | 21 +++-- 19 files changed, 155 insertions(+), 138 deletions(-) rename x-pack/plugins/maps/public/classes/joins/{inner_join.test.js => inner_join.test.ts} (68%) rename x-pack/plugins/maps/public/classes/sources/{es_term_source/es_term_source.test.js => join_sources/es_term_source/es_term_source.test.ts} (86%) rename x-pack/plugins/maps/public/classes/sources/{ => join_sources}/es_term_source/es_term_source.ts (88%) rename x-pack/plugins/maps/public/classes/sources/{ => join_sources}/es_term_source/index.ts (100%) rename x-pack/plugins/maps/public/classes/sources/{term_join_source => join_sources}/index.ts (55%) rename x-pack/plugins/maps/public/classes/sources/{ => join_sources}/table_source/index.ts (100%) rename x-pack/plugins/maps/public/classes/sources/{ => join_sources}/table_source/table_source.test.ts (97%) rename x-pack/plugins/maps/public/classes/sources/{ => join_sources}/table_source/table_source.ts (92%) rename x-pack/plugins/maps/public/classes/sources/{term_join_source/term_join_source.ts => join_sources/types.ts} (87%) rename x-pack/plugins/maps/public/classes/tooltips/{join_tooltip_property => term_join_tooltip_property}/index.ts (77%) rename x-pack/plugins/maps/public/classes/tooltips/{join_tooltip_property/join_key_label.tsx => term_join_tooltip_property/term_join_key_label.tsx} (84%) rename x-pack/plugins/maps/public/classes/tooltips/{join_tooltip_property/join_tooltip_property.tsx => term_join_tooltip_property/term_join_tooltip_property.tsx} (74%) diff --git a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts index a547ef9c6d93a..b14efda88cad4 100644 --- a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts @@ -16,7 +16,7 @@ import { VectorStyleDescriptor, } from './style_property_descriptor_types'; import { DataRequestDescriptor } from './data_request_descriptor_types'; -import { AbstractSourceDescriptor, TermJoinSourceDescriptor } from './source_descriptor_types'; +import { AbstractSourceDescriptor, JoinSourceDescriptor } from './source_descriptor_types'; import { LAYER_TYPE } from '../constants'; export type Attribution = { @@ -26,7 +26,7 @@ export type Attribution = { export type JoinDescriptor = { leftField?: string; - right: TermJoinSourceDescriptor; + right: JoinSourceDescriptor; }; export type TileMetaFeature = Feature & { diff --git a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts index c76bc8f6a6e17..1dabde9bc0a64 100644 --- a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts @@ -183,4 +183,4 @@ export type TableSourceDescriptor = { term: string; }; -export type TermJoinSourceDescriptor = ESTermSourceDescriptor | TableSourceDescriptor; +export type JoinSourceDescriptor = ESTermSourceDescriptor | TableSourceDescriptor; diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.test.js b/x-pack/plugins/maps/public/classes/joins/inner_join.test.ts similarity index 68% rename from x-pack/plugins/maps/public/classes/joins/inner_join.test.js rename to x-pack/plugins/maps/public/classes/joins/inner_join.test.ts index b01d977972a68..160ced8f46b9d 100644 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.test.js +++ b/x-pack/plugins/maps/public/classes/joins/inner_join.test.ts @@ -5,8 +5,15 @@ * 2.0. */ -import { createJoinTermSource, InnerJoin } from './inner_join'; -import { SOURCE_TYPES } from '../../../common/constants'; +import type { Feature } from 'geojson'; +import type { + ESTermSourceDescriptor, + JoinSourceDescriptor, +} from '../../../common/descriptor_types'; +import type { IVectorSource } from '../sources/vector_source'; +import type { IField } from '../fields/field'; +import { createJoinSource, InnerJoin } from './inner_join'; +import { AGG_TYPE, SOURCE_TYPES } from '../../../common/constants'; jest.mock('../../kibana_services', () => {}); jest.mock('../layers/vector_layer', () => {}); @@ -16,20 +23,20 @@ const rightSource = { id: 'd3625663-5b34-4d50-a784-0d743f676a0c', indexPatternId: '90943e30-9a47-11e8-b64d-95841ca0b247', term: 'geo.dest', - metrics: [{ type: 'count' }], -}; + metrics: [{ type: AGG_TYPE.COUNT }], +} as ESTermSourceDescriptor; const mockSource = { - createField({ fieldName: name }) { + createField({ fieldName }: { fieldName: string }) { return { getName() { - return name; + return fieldName; }, - }; + } as unknown as IField; }, -}; +} as unknown as IVectorSource; -const leftJoin = new InnerJoin( +const iso2LeftJoin = new InnerJoin( { leftField: 'iso2', right: rightSource, @@ -38,27 +45,27 @@ const leftJoin = new InnerJoin( ); const COUNT_PROPERTY_NAME = '__kbnjoin__count__d3625663-5b34-4d50-a784-0d743f676a0c'; -describe('createJoinTermSource', () => { +describe('createJoinSource', () => { test('Should return undefined when descriptor is not provided', () => { - expect(createJoinTermSource(undefined)).toBe(undefined); + expect(createJoinSource(undefined)).toBe(undefined); }); test('Should return undefined with unmatched source type', () => { expect( - createJoinTermSource({ + createJoinSource({ type: SOURCE_TYPES.WMS, - }) + } as unknown as Partial) ).toBe(undefined); }); describe('EsTermSource', () => { test('Should return EsTermSource', () => { - expect(createJoinTermSource(rightSource).constructor.name).toBe('ESTermSource'); + expect(createJoinSource(rightSource)?.constructor.name).toBe('ESTermSource'); }); test('Should return undefined when indexPatternId is undefined', () => { expect( - createJoinTermSource({ + createJoinSource({ ...rightSource, indexPatternId: undefined, }) @@ -67,7 +74,7 @@ describe('createJoinTermSource', () => { test('Should return undefined when term is undefined', () => { expect( - createJoinTermSource({ + createJoinSource({ ...rightSource, term: undefined, }) @@ -78,9 +85,9 @@ describe('createJoinTermSource', () => { describe('TableSource', () => { test('Should return TableSource', () => { expect( - createJoinTermSource({ + createJoinSource({ type: SOURCE_TYPES.TABLE_SOURCE, - }).constructor.name + })?.constructor.name ).toBe('TableSource'); }); }); @@ -92,15 +99,11 @@ describe('joinPropertiesToFeature', () => { properties: { iso2: 'CN', }, - }; + } as unknown as Feature; const propertiesMap = new Map(); propertiesMap.set('CN', { [COUNT_PROPERTY_NAME]: 61 }); - leftJoin.joinPropertiesToFeature(feature, propertiesMap, [ - { - propertyKey: COUNT_PROPERTY_NAME, - }, - ]); + iso2LeftJoin.joinPropertiesToFeature(feature, propertiesMap); expect(feature.properties).toEqual({ iso2: 'CN', [COUNT_PROPERTY_NAME]: 61, @@ -114,21 +117,17 @@ describe('joinPropertiesToFeature', () => { [COUNT_PROPERTY_NAME]: 61, [`__kbn__dynamic__${COUNT_PROPERTY_NAME}__fillColor`]: 1, }, - }; + } as unknown as Feature; const propertiesMap = new Map(); - leftJoin.joinPropertiesToFeature(feature, propertiesMap, [ - { - propertyKey: COUNT_PROPERTY_NAME, - }, - ]); + iso2LeftJoin.joinPropertiesToFeature(feature, propertiesMap); expect(feature.properties).toEqual({ iso2: 'CN', }); }); test('Should coerce to string before joining', () => { - const leftJoin = new InnerJoin( + const zipCodeLeftJoin = new InnerJoin( { leftField: 'zipcode', right: rightSource, @@ -140,15 +139,11 @@ describe('joinPropertiesToFeature', () => { properties: { zipcode: 40204, }, - }; + } as unknown as Feature; const propertiesMap = new Map(); propertiesMap.set('40204', { [COUNT_PROPERTY_NAME]: 61 }); - leftJoin.joinPropertiesToFeature(feature, propertiesMap, [ - { - propertyKey: COUNT_PROPERTY_NAME, - }, - ]); + zipCodeLeftJoin.joinPropertiesToFeature(feature, propertiesMap); expect(feature.properties).toEqual({ zipcode: 40204, [COUNT_PROPERTY_NAME]: 61, @@ -157,26 +152,22 @@ describe('joinPropertiesToFeature', () => { test('Should handle undefined values', () => { const feature = { - //this feature does not have the iso2 field + // this feature does not have the iso2 field properties: { zipcode: 40204, }, - }; + } as unknown as Feature; const propertiesMap = new Map(); propertiesMap.set('40204', { [COUNT_PROPERTY_NAME]: 61 }); - leftJoin.joinPropertiesToFeature(feature, propertiesMap, [ - { - propertyKey: COUNT_PROPERTY_NAME, - }, - ]); + iso2LeftJoin.joinPropertiesToFeature(feature, propertiesMap); expect(feature.properties).toEqual({ zipcode: 40204, }); }); test('Should handle falsy values', () => { - const leftJoin = new InnerJoin( + const codeLeftJoin = new InnerJoin( { leftField: 'code', right: rightSource, @@ -188,15 +179,11 @@ describe('joinPropertiesToFeature', () => { properties: { code: 0, }, - }; + } as unknown as Feature; const propertiesMap = new Map(); propertiesMap.set('0', { [COUNT_PROPERTY_NAME]: 61 }); - leftJoin.joinPropertiesToFeature(feature, propertiesMap, [ - { - propertyKey: COUNT_PROPERTY_NAME, - }, - ]); + codeLeftJoin.joinPropertiesToFeature(feature, propertiesMap); expect(feature.properties).toEqual({ code: 0, [COUNT_PROPERTY_NAME]: 61, diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.ts b/x-pack/plugins/maps/public/classes/joins/inner_join.ts index a754650cdef80..6ac9a674efc8d 100644 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.ts +++ b/x-pack/plugins/maps/public/classes/joins/inner_join.ts @@ -8,7 +8,6 @@ import type { KibanaExecutionContext } from '@kbn/core/public'; import type { Query } from '@kbn/es-query'; import { Feature, GeoJsonProperties } from 'geojson'; -import { ESTermSource } from '../sources/es_term_source'; import { getComputedFieldNamePrefix } from '../styles/vector/style_util'; import { FORMATTERS_DATA_REQUEST_ID_SUFFIX, @@ -18,18 +17,18 @@ import { import { ESTermSourceDescriptor, JoinDescriptor, + JoinSourceDescriptor, TableSourceDescriptor, - TermJoinSourceDescriptor, } from '../../../common/descriptor_types'; import { IVectorSource } from '../sources/vector_source'; import { IField } from '../fields/field'; import { PropertiesMap } from '../../../common/elasticsearch_util'; -import { ITermJoinSource } from '../sources/term_join_source'; -import { TableSource } from '../sources/table_source'; +import { IJoinSource } from '../sources/join_sources'; +import { ESTermSource, TableSource } from '../sources/join_sources'; -export function createJoinTermSource( - descriptor: Partial | undefined -): ITermJoinSource | undefined { +export function createJoinSource( + descriptor: Partial | undefined +): IJoinSource | undefined { if (!descriptor) { return; } @@ -47,12 +46,12 @@ export function createJoinTermSource( export class InnerJoin { private readonly _descriptor: JoinDescriptor; - private readonly _rightSource?: ITermJoinSource; + private readonly _rightSource?: IJoinSource; private readonly _leftField?: IField; constructor(joinDescriptor: JoinDescriptor, leftSource: IVectorSource) { this._descriptor = joinDescriptor; - this._rightSource = createJoinTermSource(this._descriptor.right); + this._rightSource = createJoinSource(this._descriptor.right); this._leftField = joinDescriptor.leftField ? leftSource.createField({ fieldName: joinDescriptor.leftField }) : undefined; @@ -127,7 +126,7 @@ export class InnerJoin { return joinKey === undefined || joinKey === null ? null : joinKey.toString(); } - getRightJoinSource(): ITermJoinSource { + getRightJoinSource(): IJoinSource { if (!this._rightSource) { throw new Error('Cannot get rightSource from InnerJoin with incomplete config'); } diff --git a/x-pack/plugins/maps/public/classes/layers/build_vector_request_meta.ts b/x-pack/plugins/maps/public/classes/layers/build_vector_request_meta.ts index e0df7ab8d6dd5..adb53e76c060a 100644 --- a/x-pack/plugins/maps/public/classes/layers/build_vector_request_meta.ts +++ b/x-pack/plugins/maps/public/classes/layers/build_vector_request_meta.ts @@ -9,10 +9,10 @@ import _ from 'lodash'; import type { Query } from '@kbn/data-plugin/common'; import { DataFilters, VectorSourceRequestMeta } from '../../../common/descriptor_types'; import { IVectorSource } from '../sources/vector_source'; -import { ITermJoinSource } from '../sources/term_join_source'; +import { IJoinSource } from '../sources/join_sources'; export function buildVectorRequestMeta( - source: IVectorSource | ITermJoinSource, + source: IVectorSource | IJoinSource, fieldNames: string[], dataFilters: DataFilters, sourceQuery: Query | null | undefined, diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.ts index 8640dbc5954aa..8a4f3e575d1c9 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { FEATURE_VISIBLE_PROPERTY_NAME } from '../../../../../common/constants'; import { DataRequestContext } from '../../../../actions'; import { JoinState } from '../types'; +import { isTermJoinSource, type ITermJoinSource } from '../../../sources/join_sources'; interface SourceResult { refreshed: boolean; @@ -77,13 +78,22 @@ export async function performInnerJoins( updateSourceData({ ...sourceResult.featureCollection }); } - const joinStatusesWithoutAnyMatches = joinStatuses.filter((joinStatus) => { + // + // term joins are easy to misconfigure. + // Users often are unaware of left values and right values and whether they allign for joining + // Provide messaging that helps users debug term joins with no matches + // + const termJoinStatusesWithoutAnyMatches = joinStatuses.filter((joinStatus) => { + if (!isTermJoinSource(joinStatus.joinState.join.getRightJoinSource())) { + return false; + } + const hasTerms = joinStatus.joinState.propertiesMap && joinStatus.joinState.propertiesMap.size > 0; return !joinStatus.joinedWithAtLeastOneFeature && hasTerms; }); - if (joinStatusesWithoutAnyMatches.length) { + if (termJoinStatusesWithoutAnyMatches.length) { function prettyPrintArray(array: unknown[]) { return array.length <= 5 ? array.join(',') @@ -94,12 +104,10 @@ export async function performInnerJoins( }); } - const joinStatus = joinStatusesWithoutAnyMatches[0]; + const joinStatus = termJoinStatusesWithoutAnyMatches[0]; const leftFieldName = await joinStatus.joinState.join.getLeftField().getLabel(); - const rightFieldName = await joinStatus.joinState.join - .getRightJoinSource() - .getTermField() - .getLabel(); + const termJoinSource = joinStatus.joinState.join.getRightJoinSource() as ITermJoinSource; + const rightFieldName = await termJoinSource.getTermField().getLabel(); const reason = joinStatus.keys.length === 0 ? i18n.translate('xpack.maps.vectorLayer.joinError.noLeftFieldValuesMsg', { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.test.tsx index 82bb15c19ffca..dab086b86b472 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.test.tsx @@ -25,7 +25,7 @@ import { } from '../../../../../common/descriptor_types'; import { LAYER_TYPE, SOURCE_TYPES } from '../../../../../common/constants'; import { MvtVectorLayer } from './mvt_vector_layer'; -import { ITermJoinSource } from '../../../sources/term_join_source'; +import { IJoinSource } from '../../../sources/join_sources'; const defaultConfig = { urlTemplate: 'https://example.com/{x}/{y}/{z}.pbf', @@ -178,7 +178,7 @@ describe('isLayerLoading', () => { return 'join_source_a0b0da65-5e1a-4967-9dbe-74f24391afe2'; }, getRightJoinSource: () => { - return {} as unknown as ITermJoinSource; + return {} as unknown as IJoinSource; }, } as unknown as InnerJoin, ], @@ -217,7 +217,7 @@ describe('isLayerLoading', () => { return 'join_source_a0b0da65-5e1a-4967-9dbe-74f24391afe2'; }, getRightJoinSource: () => { - return {} as unknown as ITermJoinSource; + return {} as unknown as IJoinSource; }, } as unknown as InnerJoin, ], diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index 366c9cde6eee6..a18f525e66ae0 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -27,7 +27,7 @@ import { STYLE_TYPE, VECTOR_STYLES, } from '../../../../common/constants'; -import { JoinTooltipProperty } from '../../tooltips/join_tooltip_property'; +import { TermJoinTooltipProperty } from '../../tooltips/term_join_tooltip_property'; import { DataRequestAbortError } from '../../util/data_request'; import { canSkipStyleMetaUpdate, canSkipFormattersUpdate } from '../../util/can_skip_fetch'; import { @@ -57,7 +57,8 @@ import { DataRequestContext } from '../../../actions'; import { ITooltipProperty } from '../../tooltips/tooltip_property'; import { IDynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property'; import { IESSource } from '../../sources/es_source'; -import { ITermJoinSource } from '../../sources/term_join_source'; +import type { IJoinSource, ITermJoinSource } from '../../sources/join_sources'; +import { isTermJoinSource } from '../../sources/join_sources'; import type { IESAggSource } from '../../sources/es_agg_source'; import { buildVectorRequestMeta } from '../build_vector_request_meta'; import { getJoinAggKey } from '../../../../common/get_agg_key'; @@ -430,7 +431,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { }: { dataRequestId: string; dynamicStyleProps: Array>; - source: IVectorSource | ITermJoinSource; + source: IVectorSource | IJoinSource; sourceQuery?: Query; style: IVectorStyle; } & DataRequestContext) { @@ -511,7 +512,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { }: { dataRequestId: string; fields: IField[]; - source: IVectorSource | ITermJoinSource; + source: IVectorSource | IJoinSource; } & DataRequestContext) { if (fields.length === 0) { return; @@ -983,17 +984,28 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { return this.getId() === mbSourceId; } + /* + * Replaces source property tooltips with join property tooltips + * join property tooltips allow tooltips to + * 1) Create filter from right source context + * 2) Display tooltip with right source context + */ _addJoinsToSourceTooltips(tooltipsFromSource: ITooltipProperty[]) { for (let i = 0; i < tooltipsFromSource.length; i++) { const tooltipProperty = tooltipsFromSource[i]; - const matchingJoins = []; + const matchingTermJoins: ITermJoinSource[] = []; for (let j = 0; j < this.getJoins().length; j++) { - if (this.getJoins()[j].getLeftField().getName() === tooltipProperty.getPropertyKey()) { - matchingJoins.push(this.getJoins()[j]); + const join = this.getJoins()[j]; + const joinRightSource = join.getRightJoinSource(); + if ( + isTermJoinSource(joinRightSource) && + this.getJoins()[j].getLeftField().getName() === tooltipProperty.getPropertyKey() + ) { + matchingTermJoins.push(joinRightSource as ITermJoinSource); } } - if (matchingJoins.length) { - tooltipsFromSource[i] = new JoinTooltipProperty(tooltipProperty, matchingJoins); + if (matchingTermJoins.length) { + tooltipsFromSource[i] = new TermJoinTooltipProperty(tooltipProperty, matchingTermJoins); } } } diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.test.js b/x-pack/plugins/maps/public/classes/sources/join_sources/es_term_source/es_term_source.test.ts similarity index 86% rename from x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.test.js rename to x-pack/plugins/maps/public/classes/sources/join_sources/es_term_source/es_term_source.test.ts index 173ee5bff725f..dc93153c7c411 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.test.js +++ b/x-pack/plugins/maps/public/classes/sources/join_sources/es_term_source/es_term_source.test.ts @@ -5,25 +5,27 @@ * 2.0. */ +import { AGG_TYPE } from '../../../../../common/constants'; +import type { BucketProperties, PropertiesMap } from '../../../../../common/elasticsearch_util'; import { ESTermSource, extractPropertiesMap } from './es_term_source'; -jest.mock('../../layers/vector_layer', () => {}); +jest.mock('../../../layers/vector_layer', () => {}); const termFieldName = 'myTermField'; const sumFieldName = 'myFieldGettingSummed'; const metricExamples = [ { - type: 'sum', + type: AGG_TYPE.SUM, field: sumFieldName, label: 'my custom label', }, { // metric config is invalid beause field is missing - type: 'max', + type: AGG_TYPE.MAX, }, { // metric config is valid because "count" metric does not need to provide field - type: 'count', + type: AGG_TYPE.COUNT, label: '', // should ignore empty label fields }, ]; @@ -81,7 +83,7 @@ describe('extractPropertiesMap', () => { }; const countPropName = '__kbnjoin__count__1234'; - let propertiesMap; + let propertiesMap: PropertiesMap = new Map(); beforeAll(() => { propertiesMap = extractPropertiesMap(responseWithNumberTypes, countPropName); }); @@ -93,17 +95,17 @@ describe('extractPropertiesMap', () => { it('should extract count property', () => { const properties = propertiesMap.get('109'); - expect(properties[countPropName]).toBe(1130); + expect(properties?.[countPropName]).toBe(1130); }); it('should extract min property', () => { const properties = propertiesMap.get('109'); - expect(properties[minPropName]).toBe(36); + expect(properties?.[minPropName]).toBe(36); }); it('should extract property with value of "0"', () => { const properties = propertiesMap.get('62'); - expect(properties[minPropName]).toBe(0); + expect(properties?.[minPropName]).toBe(0); }); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts b/x-pack/plugins/maps/public/classes/sources/join_sources/es_term_source/es_term_source.ts similarity index 88% rename from x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts rename to x-pack/plugins/maps/public/classes/sources/join_sources/es_term_source/es_term_source.ts index 4c7793e1b01cb..54ca553035574 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/join_sources/es_term_source/es_term_source.ts @@ -14,26 +14,26 @@ import { DEFAULT_MAX_BUCKETS_LIMIT, FIELD_ORIGIN, SOURCE_TYPES, -} from '../../../../common/constants'; -import { getJoinAggKey } from '../../../../common/get_agg_key'; -import { ESDocField } from '../../fields/es_doc_field'; -import { AbstractESAggSource } from '../es_agg_source'; +} from '../../../../../common/constants'; +import { getJoinAggKey } from '../../../../../common/get_agg_key'; +import { ESDocField } from '../../../fields/es_doc_field'; +import { AbstractESAggSource } from '../../es_agg_source'; import { getField, addFieldToDSL, extractPropertiesFromBucket, BucketProperties, -} from '../../../../common/elasticsearch_util'; +} from '../../../../../common/elasticsearch_util'; import { ESTermSourceDescriptor, VectorSourceRequestMeta, -} from '../../../../common/descriptor_types'; -import { PropertiesMap } from '../../../../common/elasticsearch_util'; -import { isValidStringConfig } from '../../util/valid_string_config'; -import { ITermJoinSource } from '../term_join_source'; -import type { IESAggSource } from '../es_agg_source'; -import { IField } from '../../fields/field'; -import { mergeExecutionContext } from '../execution_context_utils'; +} from '../../../../../common/descriptor_types'; +import { PropertiesMap } from '../../../../../common/elasticsearch_util'; +import { isValidStringConfig } from '../../../util/valid_string_config'; +import { ITermJoinSource } from '../types'; +import type { IESAggSource } from '../../es_agg_source'; +import { IField } from '../../../fields/field'; +import { mergeExecutionContext } from '../../execution_context_utils'; const TERMS_AGG_NAME = 'join'; const TERMS_BUCKET_KEYS_TO_IGNORE = ['key', 'doc_count']; @@ -71,7 +71,7 @@ export class ESTermSource extends AbstractESAggSource implements ITermJoinSource private readonly _termField: ESDocField; readonly _descriptor: ESTermSourceDescriptor; - constructor(descriptor: ESTermSourceDescriptor) { + constructor(descriptor: Partial) { const sourceDescriptor = ESTermSource.createDescriptor(descriptor); super(sourceDescriptor); this._descriptor = sourceDescriptor; diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/index.ts b/x-pack/plugins/maps/public/classes/sources/join_sources/es_term_source/index.ts similarity index 100% rename from x-pack/plugins/maps/public/classes/sources/es_term_source/index.ts rename to x-pack/plugins/maps/public/classes/sources/join_sources/es_term_source/index.ts diff --git a/x-pack/plugins/maps/public/classes/sources/term_join_source/index.ts b/x-pack/plugins/maps/public/classes/sources/join_sources/index.ts similarity index 55% rename from x-pack/plugins/maps/public/classes/sources/term_join_source/index.ts rename to x-pack/plugins/maps/public/classes/sources/join_sources/index.ts index 78f7705104f73..c916b2befca41 100644 --- a/x-pack/plugins/maps/public/classes/sources/term_join_source/index.ts +++ b/x-pack/plugins/maps/public/classes/sources/join_sources/index.ts @@ -5,4 +5,8 @@ * 2.0. */ -export type { ITermJoinSource } from './term_join_source'; +export type { IJoinSource, ITermJoinSource } from './types'; + +export { isTermJoinSource } from './types'; +export { ESTermSource } from './es_term_source'; +export { TableSource } from './table_source'; diff --git a/x-pack/plugins/maps/public/classes/sources/table_source/index.ts b/x-pack/plugins/maps/public/classes/sources/join_sources/table_source/index.ts similarity index 100% rename from x-pack/plugins/maps/public/classes/sources/table_source/index.ts rename to x-pack/plugins/maps/public/classes/sources/join_sources/table_source/index.ts diff --git a/x-pack/plugins/maps/public/classes/sources/table_source/table_source.test.ts b/x-pack/plugins/maps/public/classes/sources/join_sources/table_source/table_source.test.ts similarity index 97% rename from x-pack/plugins/maps/public/classes/sources/table_source/table_source.test.ts rename to x-pack/plugins/maps/public/classes/sources/join_sources/table_source/table_source.test.ts index e43d3de268d31..df9c11a99daa4 100644 --- a/x-pack/plugins/maps/public/classes/sources/table_source/table_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/join_sources/table_source/table_source.test.ts @@ -6,8 +6,8 @@ */ import { TableSource } from './table_source'; -import { FIELD_ORIGIN } from '../../../../common/constants'; -import { VectorSourceRequestMeta } from '../../../../common/descriptor_types'; +import { FIELD_ORIGIN } from '../../../../../common/constants'; +import { VectorSourceRequestMeta } from '../../../../../common/descriptor_types'; describe('TableSource', () => { describe('getName', () => { diff --git a/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts b/x-pack/plugins/maps/public/classes/sources/join_sources/table_source/table_source.ts similarity index 92% rename from x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts rename to x-pack/plugins/maps/public/classes/sources/join_sources/table_source/table_source.ts index 0bfdbc90be847..ed3015dca17e4 100644 --- a/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/join_sources/table_source/table_source.ts @@ -8,25 +8,25 @@ import { v4 as uuidv4 } from 'uuid'; import { GeoJsonProperties } from 'geojson'; import type { Query } from '@kbn/data-plugin/common'; -import { FIELD_ORIGIN, SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; +import { FIELD_ORIGIN, SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../../common/constants'; import { MapExtent, TableSourceDescriptor, VectorSourceRequestMeta, -} from '../../../../common/descriptor_types'; -import { ITermJoinSource } from '../term_join_source'; -import { BucketProperties, PropertiesMap } from '../../../../common/elasticsearch_util'; -import { IField } from '../../fields/field'; +} from '../../../../../common/descriptor_types'; +import { ITermJoinSource } from '../types'; +import { BucketProperties, PropertiesMap } from '../../../../../common/elasticsearch_util'; +import { IField } from '../../../fields/field'; import { AbstractVectorSource, BoundsRequestMeta, GeoJsonWithMeta, IVectorSource, SourceStatus, -} from '../vector_source'; -import { DataRequest } from '../../util/data_request'; -import { InlineField } from '../../fields/inline_field'; -import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property'; +} from '../../vector_source'; +import { DataRequest } from '../../../util/data_request'; +import { InlineField } from '../../../fields/inline_field'; +import { ITooltipProperty, TooltipProperty } from '../../../tooltips/tooltip_property'; export class TableSource extends AbstractVectorSource implements ITermJoinSource, IVectorSource { static type = SOURCE_TYPES.TABLE_SOURCE; diff --git a/x-pack/plugins/maps/public/classes/sources/term_join_source/term_join_source.ts b/x-pack/plugins/maps/public/classes/sources/join_sources/types.ts similarity index 87% rename from x-pack/plugins/maps/public/classes/sources/term_join_source/term_join_source.ts rename to x-pack/plugins/maps/public/classes/sources/join_sources/types.ts index 30e834fdf11f5..05ec53cc14e6a 100644 --- a/x-pack/plugins/maps/public/classes/sources/term_join_source/term_join_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/join_sources/types.ts @@ -15,9 +15,8 @@ import { PropertiesMap } from '../../../../common/elasticsearch_util'; import { ITooltipProperty } from '../../tooltips/tooltip_property'; import { ISource } from '../source'; -export interface ITermJoinSource extends ISource { +export interface IJoinSource extends ISource { hasCompleteConfig(): boolean; - getTermField(): IField; getWhereQuery(): Query | undefined; getPropertiesMap( requestMeta: VectorSourceRequestMeta, @@ -41,3 +40,11 @@ export interface ITermJoinSource extends ISource { ): Promise; getFieldByName(fieldName: string): IField | null; } + +export interface ITermJoinSource extends IJoinSource { + getTermField(): IField; +} + +export function isTermJoinSource(joinSource: IJoinSource) { + return 'getTermField' in (joinSource as ITermJoinSource); +} diff --git a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/index.ts b/x-pack/plugins/maps/public/classes/tooltips/term_join_tooltip_property/index.ts similarity index 77% rename from x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/index.ts rename to x-pack/plugins/maps/public/classes/tooltips/term_join_tooltip_property/index.ts index 4e48d96b032fe..d28cdbbf58d08 100644 --- a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/index.ts +++ b/x-pack/plugins/maps/public/classes/tooltips/term_join_tooltip_property/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { JoinTooltipProperty } from './join_tooltip_property'; +export { TermJoinTooltipProperty } from './term_join_tooltip_property'; diff --git a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_key_label.tsx b/x-pack/plugins/maps/public/classes/tooltips/term_join_tooltip_property/term_join_key_label.tsx similarity index 84% rename from x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_key_label.tsx rename to x-pack/plugins/maps/public/classes/tooltips/term_join_tooltip_property/term_join_key_label.tsx index 624b7515c04c6..822a966814138 100644 --- a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_key_label.tsx +++ b/x-pack/plugins/maps/public/classes/tooltips/term_join_tooltip_property/term_join_key_label.tsx @@ -16,18 +16,18 @@ import React, { Component } from 'react'; import { asyncMap } from '@kbn/std'; import { i18n } from '@kbn/i18n'; import { EuiIcon, EuiToolTip } from '@elastic/eui'; -import { InnerJoin } from '../../joins/inner_join'; +import type { ITermJoinSource } from '../../sources/join_sources'; interface Props { leftFieldName: string; - innerJoins: InnerJoin[]; + termJoins: ITermJoinSource[]; } interface State { rightSourceLabels: string[]; } -export class JoinKeyLabel extends Component { +export class TermJoinKeyLabel extends Component { private _isMounted = false; state: State = { rightSourceLabels: [] }; @@ -42,9 +42,8 @@ export class JoinKeyLabel extends Component { } async _loadRightSourceLabels() { - const rightSourceLabels = await asyncMap(this.props.innerJoins, async (innerJoin) => { - const rightSource = innerJoin.getRightJoinSource(); - const termField = rightSource.getTermField(); + const rightSourceLabels = await asyncMap(this.props.termJoins, async (termJoin) => { + const termField = termJoin.getTermField(); return `'${await termField.getLabel()}'`; }); diff --git a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_tooltip_property.tsx b/x-pack/plugins/maps/public/classes/tooltips/term_join_tooltip_property/term_join_tooltip_property.tsx similarity index 74% rename from x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_tooltip_property.tsx rename to x-pack/plugins/maps/public/classes/tooltips/term_join_tooltip_property/term_join_tooltip_property.tsx index c6ca5e9b3d5f9..efc15e5a012a2 100644 --- a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_tooltip_property.tsx +++ b/x-pack/plugins/maps/public/classes/tooltips/term_join_tooltip_property/term_join_tooltip_property.tsx @@ -8,16 +8,16 @@ import React, { ReactNode } from 'react'; import { Filter } from '@kbn/es-query'; import { ITooltipProperty } from '../tooltip_property'; -import { InnerJoin } from '../../joins/inner_join'; -import { JoinKeyLabel } from './join_key_label'; +import { TermJoinKeyLabel } from './term_join_key_label'; +import type { ITermJoinSource } from '../../sources/join_sources'; -export class JoinTooltipProperty implements ITooltipProperty { +export class TermJoinTooltipProperty implements ITooltipProperty { private readonly _tooltipProperty: ITooltipProperty; - private readonly _innerJoins: InnerJoin[]; + private readonly _termJoins: ITermJoinSource[]; - constructor(tooltipProperty: ITooltipProperty, innerJoins: InnerJoin[]) { + constructor(tooltipProperty: ITooltipProperty, termJoins: ITermJoinSource[]) { this._tooltipProperty = tooltipProperty; - this._innerJoins = innerJoins; + this._termJoins = termJoins; } isFilterable(): boolean { @@ -30,9 +30,9 @@ export class JoinTooltipProperty implements ITooltipProperty { getPropertyName(): ReactNode { return ( - ); } @@ -50,9 +50,8 @@ export class JoinTooltipProperty implements ITooltipProperty { // only create filters for right sources. // do not create filters for left source. - for (let i = 0; i < this._innerJoins.length; i++) { - const rightSource = this._innerJoins[i].getRightJoinSource(); - const termField = rightSource.getTermField(); + for (let i = 0; i < this._termJoins.length; i++) { + const termField = this._termJoins[i].getTermField(); try { const esTooltipProperty = await termField.createTooltipProperty( this._tooltipProperty.getRawValue() From 4211e03a5f7651afeea492e44116eb614b54f00c Mon Sep 17 00:00:00 2001 From: Dzmitry Lemechko Date: Wed, 26 Apr 2023 18:33:54 +0200 Subject: [PATCH 05/29] [ftr] fix scripts/functional_tests to respect cli flags (#155734) ## Summary This [failure](https://buildkite.com/elastic/kibana-on-merge/builds/29091#01879988-ecd2-4e4f-bfb4-108939f145a1) clearly shows that `--bail` flag is ignored when passed to `scripts/functional_tests.js` script, and since `scripts/functional_tests.js --help` list these flags I think we need to fix it: ``` --include-tag Tags that suites must include to be run, can be included multiple times --exclude-tag Tags that suites must NOT include to be run, can be included multiple times --include Files that must included to be run, can be included multiple times --exclude Files that must NOT be included to be run, can be included multiple times --grep Pattern to select which tests to run --bail Stop the test run at the first failure --dry-run Report tests without executing them --updateBaselines Replace baseline screenshots with whatever is generated from the test --updateSnapshots Replace inline and file snapshots with whatever is generated from the test ``` I was able to reproduce it locally: 1. Break [test/functional/apps/console/_console.ts](test/functional/apps/console/_console.ts) by adding `expect(1).to.be(2);` in the first `it` function 2. Run `node scripts/functional_tests.js --bail --config test/functional/apps/console/config.ts` Actual: Tests continue to run after failure Expected: Stop tests after first failure It turned out `scripts/functional_test_runner.js` respects the flags so I just copied the logic from [packages/kbn-test/src/functional_test_runner/cli.ts](https://github.com/elastic/kibana/blob/main/packages/kbn-test/src/functional_test_runner/cli.ts#L41-L63) Let me know if you think we need to add jest tests. Tested: ``` node scripts/functional_tests.js --bail --config test/functional/apps/console/config.ts --grep "multiple requests output" ``` --- .../src/functional_tests/run_tests/flags.ts | 8 +++---- .../functional_tests/run_tests/run_tests.ts | 23 ++++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/kbn-test/src/functional_tests/run_tests/flags.ts b/packages/kbn-test/src/functional_tests/run_tests/flags.ts index 5d7fffc2a965b..9f91bf2728cbe 100644 --- a/packages/kbn-test/src/functional_tests/run_tests/flags.ts +++ b/packages/kbn-test/src/functional_tests/run_tests/flags.ts @@ -78,12 +78,12 @@ export function parseFlags(flags: FlagsReader) { installDir: flags.path('kibana-install-dir'), grep: flags.string('grep'), suiteTags: { - include: flags.arrayOfStrings('include-tag'), - exclude: flags.arrayOfStrings('exclude-tag'), + include: flags.arrayOfStrings('include-tag') ?? [], + exclude: flags.arrayOfStrings('exclude-tag') ?? [], }, suiteFilters: { - include: flags.arrayOfPaths('include'), - exclude: flags.arrayOfPaths('exclude'), + include: flags.arrayOfPaths('include') ?? [], + exclude: flags.arrayOfPaths('exclude') ?? [], }, }; } diff --git a/packages/kbn-test/src/functional_tests/run_tests/run_tests.ts b/packages/kbn-test/src/functional_tests/run_tests/run_tests.ts index e936264d8bf04..b8edfeadbdf08 100644 --- a/packages/kbn-test/src/functional_tests/run_tests/run_tests.ts +++ b/packages/kbn-test/src/functional_tests/run_tests/run_tests.ts @@ -36,6 +36,27 @@ export async function runTests(log: ToolingLog, options: RunTestsOptions) { log.warning('❗️❗️❗️'); } + const settingOverrides = { + mochaOpts: { + bail: options.bail, + dryRun: options.dryRun, + grep: options.grep, + }, + kbnTestServer: { + installDir: options.installDir, + }, + suiteFiles: { + include: options.suiteFilters.include, + exclude: options.suiteFilters.exclude, + }, + suiteTags: { + include: options.suiteTags.include, + exclude: options.suiteTags.exclude, + }, + updateBaselines: options.updateBaselines, + updateSnapshots: options.updateSnapshots, + }; + for (const [i, path] of options.configs.entries()) { await log.indent(0, async () => { if (options.configs.length > 1) { @@ -43,7 +64,7 @@ export async function runTests(log: ToolingLog, options: RunTestsOptions) { log.write(`--- [${progress}] Running ${Path.relative(REPO_ROOT, path)}`); } - const config = await readConfigFile(log, options.esVersion, path); + const config = await readConfigFile(log, options.esVersion, path, settingOverrides); const hasTests = await checkForEnabledTestsInFtrConfig({ config, From 3dfaf432f0abda60d824c853149910d1e484af1f Mon Sep 17 00:00:00 2001 From: Marco Antonio Ghiani Date: Wed, 26 Apr 2023 18:43:36 +0200 Subject: [PATCH 06/29] [Infrastructure UI] Fix hosts sticky search bar position (#155896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📓 Summary The sticky search bar was miscalculating the top position because it was referring to the wrong root element. --------- Co-authored-by: Marco Antonio Ghiani Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../metrics/hosts/components/search_bar/unified_search_bar.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx index ef515cc018839..2ae8d8743b37d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx @@ -16,7 +16,6 @@ import { EuiFlexItem, } from '@elastic/eui'; import { css } from '@emotion/react'; -import { METRICS_APP_DATA_TEST_SUBJ } from '../../../../../apps/metrics_app'; import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana'; import { useUnifiedSearchContext } from '../../hooks/use_unified_search'; import { ControlsContent } from './controls_content'; @@ -101,7 +100,7 @@ const StickyContainer = (props: { children: React.ReactNode }) => { const { euiTheme } = useEuiTheme(); const top = useMemo(() => { - const wrapper = document.querySelector(`[data-test-subj="${METRICS_APP_DATA_TEST_SUBJ}"]`); + const wrapper = document.querySelector(`[data-test-subj="kibanaChrome"]`); if (!wrapper) { return `calc(${euiTheme.size.xxxl} * 2)`; } From 5c1912ccc59f78685c573fdeea5049e12f494ba0 Mon Sep 17 00:00:00 2001 From: Alexi Doak <109488926+doakalexi@users.noreply.github.com> Date: Wed, 26 Apr 2023 10:03:06 -0700 Subject: [PATCH 07/29] [ResponseOps][Window Maintenance] Update licensing on the front end for maintenance windows (#155664) Resolves https://github.com/elastic/kibana/issues/153974 ## Summary Adds a platinum license check for maintenance windows on the front end. When you navigate to maintenance windows the following prompt will render: Screen Shot 2023-04-24 at 12 50 55 PM ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Lisa Cawley --- .../application/maintenance_windows.tsx | 39 +++++++---- .../public/hooks/use_license.test.tsx | 55 +++++++++++++++ .../alerting/public/hooks/use_license.tsx | 34 +++++++++ .../alerting/public/lib/test_utils.tsx | 20 +++++- .../components/license_prompt.test.tsx | 27 ++++++++ .../components/license_prompt.tsx | 69 +++++++++++++++++++ .../pages/maintenance_windows/index.tsx | 11 ++- .../pages/maintenance_windows/translations.ts | 28 ++++++++ 8 files changed, 265 insertions(+), 18 deletions(-) create mode 100644 x-pack/plugins/alerting/public/hooks/use_license.test.tsx create mode 100644 x-pack/plugins/alerting/public/hooks/use_license.tsx create mode 100644 x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.test.tsx create mode 100644 x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.tsx diff --git a/x-pack/plugins/alerting/public/application/maintenance_windows.tsx b/x-pack/plugins/alerting/public/application/maintenance_windows.tsx index 4005ee739a12b..58cc533d78a28 100644 --- a/x-pack/plugins/alerting/public/application/maintenance_windows.tsx +++ b/x-pack/plugins/alerting/public/application/maintenance_windows.tsx @@ -7,7 +7,7 @@ import React, { Suspense } from 'react'; import ReactDOM from 'react-dom'; -import { Redirect, Router, Switch } from 'react-router-dom'; +import { Router, Switch } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Route } from '@kbn/shared-ux-router'; import { CoreStart } from '@kbn/core/public'; @@ -18,6 +18,7 @@ import { ManagementAppMountParams } from '@kbn/management-plugin/public'; import { EuiLoadingSpinner } from '@elastic/eui'; import { AlertingPluginStart } from '../plugin'; import { paths } from '../config'; +import { useLicense } from '../hooks/use_license'; const MaintenanceWindowsLazy: React.FC = React.lazy(() => import('../pages/maintenance_windows')); const MaintenanceWindowsCreateLazy: React.FC = React.lazy( @@ -28,27 +29,39 @@ const MaintenanceWindowsEditLazy: React.FC = React.lazy( ); const App = React.memo(() => { + const { isAtLeastPlatinum } = useLicense(); + const hasLicense = isAtLeastPlatinum(); + return ( - <> - - - }> - - - - + + {hasLicense ? ( + }> - + ) : null} + {hasLicense ? ( + }> - - - + ) : null} + + }> + + + + ); }); App.displayName = 'App'; diff --git a/x-pack/plugins/alerting/public/hooks/use_license.test.tsx b/x-pack/plugins/alerting/public/hooks/use_license.test.tsx new file mode 100644 index 0000000000000..0611a6ba86aec --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_license.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { renderHook } from '@testing-library/react-hooks'; +import { useLicense } from './use_license'; +import { AppMockRenderer, createAppMockRenderer } from '../lib/test_utils'; + +let appMockRenderer: AppMockRenderer; + +describe('useLicense', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isAtLeastPlatinum', () => { + it('returns true on a valid platinum license', () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + appMockRenderer = createAppMockRenderer({ license }); + + const { result } = renderHook( + () => { + return useLicense(); + }, + { + wrapper: appMockRenderer.AppWrapper, + } + ); + + expect(result.current.isAtLeastPlatinum()).toBeTruthy(); + }); + + it('returns false on a valid gold license', () => { + const license = licensingMock.createLicense({ + license: { type: 'gold' }, + }); + appMockRenderer = createAppMockRenderer({ license }); + + const { result } = renderHook( + () => { + return useLicense(); + }, + { wrapper: appMockRenderer.AppWrapper } + ); + + expect(result.current.isAtLeastPlatinum()).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/alerting/public/hooks/use_license.tsx b/x-pack/plugins/alerting/public/hooks/use_license.tsx new file mode 100644 index 0000000000000..b9d299776e348 --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_license.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ILicense, LicenseType } from '@kbn/licensing-plugin/public'; +import { useCallback } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; +import { useKibana } from '../utils/kibana_react'; + +interface UseLicenseReturnValue { + isAtLeastPlatinum: () => boolean; +} + +export const useLicense = (): UseLicenseReturnValue => { + const { licensing } = useKibana().services; + const license = useObservable(licensing?.license$ ?? new Observable(), null); + + const isAtLeast = useCallback( + (level: LicenseType): boolean => { + return !!license && license.isAvailable && license.isActive && license.hasAtLeast(level); + }, + [license] + ); + + const isAtLeastPlatinum = useCallback(() => isAtLeast('platinum'), [isAtLeast]); + + return { + isAtLeastPlatinum, + }; +}; diff --git a/x-pack/plugins/alerting/public/lib/test_utils.tsx b/x-pack/plugins/alerting/public/lib/test_utils.tsx index 2dfd1f37066bf..56485d7c88ad1 100644 --- a/x-pack/plugins/alerting/public/lib/test_utils.tsx +++ b/x-pack/plugins/alerting/public/lib/test_utils.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { of } from 'rxjs'; +import { of, BehaviorSubject } from 'rxjs'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; @@ -14,11 +14,17 @@ import { render as reactRender, RenderOptions, RenderResult } from '@testing-lib import { CoreStart } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; import { euiDarkVars } from '@kbn/ui-theme'; +import type { ILicense } from '@kbn/licensing-plugin/public'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; /* eslint-disable no-console */ type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; +interface AppMockRendererArgs { + license?: ILicense | null; +} + export interface AppMockRenderer { render: UiRender; coreStart: CoreStart; @@ -26,9 +32,11 @@ export interface AppMockRenderer { AppWrapper: React.FC<{ children: React.ReactElement }>; } -export const createAppMockRenderer = (): AppMockRenderer => { +export const createAppMockRenderer = ({ license }: AppMockRendererArgs = {}): AppMockRenderer => { const theme$ = of({ eui: euiDarkVars, darkMode: true }); + const licensingPluginMock = licensingMock.createStart(); + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -46,7 +54,13 @@ export const createAppMockRenderer = (): AppMockRenderer => { }, }); const core = coreMock.createStart(); - const services = { ...core }; + const services = { + ...core, + licensing: + license != null + ? { ...licensingPluginMock, license$: new BehaviorSubject(license) } + : licensingPluginMock, + }; const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => ( diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.test.tsx new file mode 100644 index 0000000000000..3a5ecc97ae1dc --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils'; +import { LicensePrompt } from './license_prompt'; + +describe('LicensePrompt', () => { + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + test('it renders', () => { + const result = appMockRenderer.render(); + + expect(result.getByTestId('license-prompt-title')).toBeInTheDocument(); + expect(result.getByTestId('license-prompt-upgrade')).toBeInTheDocument(); + expect(result.getByTestId('license-prompt-trial')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.tsx new file mode 100644 index 0000000000000..61de0593e387b --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/license_prompt.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiPageTemplate, + EuiText, +} from '@elastic/eui'; +import * as i18n from '../translations'; +import { useKibana } from '../../../utils/kibana_react'; + +const title =

{i18n.UPGRADE_TO_PLATINUM}

; + +export const LicensePrompt = React.memo(() => { + const { application } = useKibana().services; + + return ( + + + +

{i18n.UPGRADE_TO_PLATINUM_SUBTITLE}

+
+
+ + + + + {i18n.UPGRADE_SUBSCRIPTION} + + , + + + + {i18n.START_TRIAL} + + , + + + + + } + /> + ); +}); +LicensePrompt.displayName = 'LicensePrompt'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx index fa9b54122562d..ac6d0b5534b9a 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx @@ -26,9 +26,13 @@ import { MaintenanceWindowsList } from './components/maintenance_windows_list'; import { useFindMaintenanceWindows } from '../../hooks/use_find_maintenance_windows'; import { CenterJustifiedSpinner } from './components/center_justified_spinner'; import { ExperimentalBadge } from './components/page_header'; +import { useLicense } from '../../hooks/use_license'; +import { LicensePrompt } from './components/license_prompt'; export const MaintenanceWindowsPage = React.memo(() => { const { docLinks } = useKibana().services; + const { isAtLeastPlatinum } = useLicense(); + const { navigateToCreateMaintenanceWindow } = useCreateMaintenanceWindowNavigation(); const { isLoading, maintenanceWindows, refetch } = useFindMaintenanceWindows(); @@ -42,6 +46,7 @@ export const MaintenanceWindowsPage = React.memo(() => { const refreshData = useCallback(() => refetch(), [refetch]); const showEmptyPrompt = !isLoading && maintenanceWindows.length === 0; + const hasLicense = isAtLeastPlatinum(); if (isLoading) { return ; @@ -66,7 +71,7 @@ export const MaintenanceWindowsPage = React.memo(() => {

{i18n.MAINTENANCE_WINDOWS_DESCRIPTION}

- {!showEmptyPrompt ? ( + {!showEmptyPrompt && hasLicense ? ( {i18n.CREATE_NEW_BUTTON} @@ -74,7 +79,9 @@ export const MaintenanceWindowsPage = React.memo(() => { ) : null} - {showEmptyPrompt ? ( + {!hasLicense ? ( + + ) : showEmptyPrompt ? ( ) : ( <> diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts index 65f24411a2a1a..7e0c2fa484f7a 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts @@ -568,3 +568,31 @@ export const EXPERIMENTAL_DESCRIPTION = i18n.translate( export const UPCOMING = i18n.translate('xpack.alerting.maintenanceWindows.upcoming', { defaultMessage: 'Upcoming', }); + +export const UPGRADE_TO_PLATINUM = i18n.translate( + 'xpack.alerting.maintenanceWindows.licenseCallout.updgradeToPlatinumTitle', + { + defaultMessage: 'Maintenance Windows are a subscription feature.', + } +); + +export const UPGRADE_TO_PLATINUM_SUBTITLE = i18n.translate( + 'xpack.alerting.maintenanceWindows.licenseCallout.upgradeToPlatinumSubtitle', + { + defaultMessage: 'Select an option to unlock it.', + } +); + +export const UPGRADE_SUBSCRIPTION = i18n.translate( + 'xpack.alerting.maintenanceWindows.licenseCallout.upgradeSubscription', + { + defaultMessage: 'Upgrade subscription', + } +); + +export const START_TRIAL = i18n.translate( + 'xpack.alerting.maintenanceWindows.licenseCallout.startTrial', + { + defaultMessage: 'Start trial', + } +); From ab4ae2e67c41283c5bedf4695f5420c0feb49904 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 26 Apr 2023 18:09:07 +0100 Subject: [PATCH 08/29] [ML] Fixing shared trained models types (#155822) Update to https://github.com/elastic/kibana/pull/154974, types were missing from update. Also shares the `putTrainedModel` function. --- .../routes/enterprise_search/indices.test.ts | 12 +++++++ .../plugins/ml/server/lib/ml_client/types.ts | 5 ++- .../providers/trained_models.ts | 33 +++++++++++++++++-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts index 92977df6021d6..4875c7872a20c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts @@ -166,6 +166,12 @@ describe('Enterprise Search Managed Indices', () => { mockTrainedModelsProvider = { getTrainedModels: jest.fn(), getTrainedModelsStats: jest.fn(), + startTrainedModelDeployment: jest.fn(), + stopTrainedModelDeployment: jest.fn(), + inferTrainedModel: jest.fn(), + deleteTrainedModel: jest.fn(), + updateTrainedModelDeployment: jest.fn(), + putTrainedModel: jest.fn(), } as MlTrainedModels; mockMl = { @@ -1060,6 +1066,12 @@ describe('Enterprise Search Managed Indices', () => { mockTrainedModelsProvider = { getTrainedModels: jest.fn(), getTrainedModelsStats: jest.fn(), + startTrainedModelDeployment: jest.fn(), + stopTrainedModelDeployment: jest.fn(), + inferTrainedModel: jest.fn(), + deleteTrainedModel: jest.fn(), + updateTrainedModelDeployment: jest.fn(), + putTrainedModel: jest.fn(), } as MlTrainedModels; mockMl = { diff --git a/x-pack/plugins/ml/server/lib/ml_client/types.ts b/x-pack/plugins/ml/server/lib/ml_client/types.ts index bba5e9b49a0ac..88d1475b23ff7 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/types.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/types.ts @@ -13,12 +13,15 @@ export interface UpdateTrainedModelDeploymentRequest { model_id: string; number_of_allocations: number; } +export interface UpdateTrainedModelDeploymentResponse { + acknowledge: boolean; +} export interface MlClient extends OrigMlClient { anomalySearch: ReturnType['anomalySearch']; updateTrainedModelDeployment: ( payload: UpdateTrainedModelDeploymentRequest - ) => Promise<{ acknowledge: boolean }>; + ) => Promise; } export type MlClientParams = diff --git a/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts b/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts index 808b1a2f0d4a4..ca2b08702ce72 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/trained_models.ts @@ -7,7 +7,10 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server'; -import { UpdateTrainedModelDeploymentRequest } from '../../lib/ml_client/types'; +import type { + UpdateTrainedModelDeploymentRequest, + UpdateTrainedModelDeploymentResponse, +} from '../../lib/ml_client/types'; import type { GetGuards } from '../shared_services'; export interface TrainedModelsProvider { @@ -21,6 +24,24 @@ export interface TrainedModelsProvider { getTrainedModelsStats( params: estypes.MlGetTrainedModelsStatsRequest ): Promise; + startTrainedModelDeployment( + params: estypes.MlStartTrainedModelDeploymentRequest + ): Promise; + stopTrainedModelDeployment( + params: estypes.MlStopTrainedModelDeploymentRequest + ): Promise; + inferTrainedModel( + params: estypes.MlInferTrainedModelRequest + ): Promise; + deleteTrainedModel( + params: estypes.MlDeleteTrainedModelRequest + ): Promise; + updateTrainedModelDeployment( + params: UpdateTrainedModelDeploymentRequest + ): Promise; + putTrainedModel( + params: estypes.MlPutTrainedModelRequest + ): Promise; }; } @@ -80,11 +101,19 @@ export function getTrainedModelsProvider(getGuards: GetGuards): TrainedModelsPro async updateTrainedModelDeployment(params: UpdateTrainedModelDeploymentRequest) { return await guards .isFullLicense() - .hasMlCapabilities(['canStartStopTrainedModels']) + .hasMlCapabilities(['canCreateTrainedModels']) .ok(async ({ mlClient }) => { return mlClient.updateTrainedModelDeployment(params); }); }, + async putTrainedModel(params: estypes.MlPutTrainedModelRequest) { + return await guards + .isFullLicense() + .hasMlCapabilities(['canCreateTrainedModels']) + .ok(async ({ mlClient }) => { + return mlClient.putTrainedModel(params); + }); + }, }; }, }; From 1dcf1f86a856a7251df7bd06b47820ed64d033b8 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 26 Apr 2023 19:20:43 +0200 Subject: [PATCH 09/29] [SLO][SLO Detail] Make SLO Detail > Header Actions menu and SLO List > SLO List item > Actions menu more consistent (#155868) Co-authored-by: Kevin Delemme Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../slo/feedback_button/feedback_button.tsx | 9 +- .../slo_details/components/header_control.tsx | 126 +++++++++++++++--- .../pages/slo_details/slo_details.test.tsx | 31 +++++ .../public/pages/slo_details/slo_details.tsx | 13 +- .../badges/slo_indicator_type_badge.tsx | 4 +- .../slo_delete_confirmation_modal.tsx | 3 + .../pages/slos/components/slo_list_item.tsx | 8 +- .../observability/public/pages/slos/slos.tsx | 4 +- 8 files changed, 163 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/observability/public/components/slo/feedback_button/feedback_button.tsx b/x-pack/plugins/observability/public/components/slo/feedback_button/feedback_button.tsx index f72e4b008f390..dbd45bf74dee4 100644 --- a/x-pack/plugins/observability/public/components/slo/feedback_button/feedback_button.tsx +++ b/x-pack/plugins/observability/public/components/slo/feedback_button/feedback_button.tsx @@ -5,16 +5,21 @@ * 2.0. */ +import React from 'react'; import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; const SLO_FEEDBACK_LINK = 'https://ela.st/slo-feedback'; -export function FeedbackButton() { +interface Props { + disabled?: boolean; +} + +export function FeedbackButton({ disabled }: Props) { return ( (false); + const [isDeleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = useState(false); + + const { mutateAsync: cloneSlo } = useCloneSlo(); + const isDeleting = Boolean(useIsMutating(['deleteSlo', slo?.id])); const handleActionsClick = () => setIsPopoverOpen((value) => !value); const closePopover = () => setIsPopoverOpen(false); @@ -95,6 +108,40 @@ export function HeaderControl({ isLoading, slo }: Props) { } }; + const handleClone = async () => { + if (slo) { + setIsPopoverOpen(false); + + const newSlo = transformValuesToCreateSLOInput( + transformSloResponseToCreateSloInput({ ...slo, name: `[Copy] ${slo.name}` })! + ); + + await cloneSlo({ slo: newSlo, idToCopyFrom: slo.id }); + + toasts.addSuccess( + i18n.translate('xpack.observability.slo.sloDetails.headerControl.cloneSuccess', { + defaultMessage: 'Successfully created {name}', + values: { name: newSlo.name }, + }) + ); + + navigateToUrl(basePath.prepend(paths.observability.slos)); + } + }; + + const handleDelete = () => { + setDeleteConfirmationModalOpen(true); + setIsPopoverOpen(false); + }; + + const handleDeleteCancel = () => { + setDeleteConfirmationModalOpen(false); + }; + + const handleDeleteSuccess = () => { + navigateToUrl(basePath.prepend(paths.observability.slos)); + }; + return ( <> {i18n.translate('xpack.observability.slo.sloDetails.headerControl.actions', { defaultMessage: 'Actions', @@ -118,11 +165,12 @@ export function HeaderControl({ isLoading, slo }: Props) { closePopover={closePopover} > @@ -133,6 +181,7 @@ export function HeaderControl({ isLoading, slo }: Props) { @@ -146,6 +195,7 @@ export function HeaderControl({ isLoading, slo }: Props) { @@ -153,24 +203,50 @@ export function HeaderControl({ isLoading, slo }: Props) { defaultMessage: 'Manage rules', })} , - ].concat( - !!slo && isApmIndicatorType(slo.indicator.type) - ? [ - - {i18n.translate( - 'xpack.observability.slos.sloDetails.headerControl.exploreInApm', - { - defaultMessage: 'Explore in APM', - } - )} - , - ] - : [] - )} + ] + .concat( + !!slo && isApmIndicatorType(slo.indicator.type) ? ( + + {i18n.translate( + 'xpack.observability.slos.sloDetails.headerControl.exploreInApm', + { + defaultMessage: 'Service details', + } + )} + + ) : ( + [] + ) + ) + .concat( + + {i18n.translate('xpack.observability.slo.slo.item.actions.clone', { + defaultMessage: 'Clone', + })} + , + + {i18n.translate('xpack.observability.slo.slo.item.actions.delete', { + defaultMessage: 'Delete', + })} + + )} /> @@ -183,6 +259,14 @@ export function HeaderControl({ isLoading, slo }: Props) { initialValues={{ name: `${slo.name} burn rate`, params: { sloId: slo.id } }} /> ) : null} + + {slo && isDeleteConfirmationModalOpen ? ( + + ) : null} ); } diff --git a/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx b/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx index 9cd8836c6cdf7..1dc86a2901a44 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx @@ -61,6 +61,12 @@ const mockKibana = () => { prepend: mockBasePathPrepend, }, }, + notifications: { + toasts: { + addSuccess: jest.fn(), + addError: jest.fn(), + }, + }, share: { url: { locators: { @@ -199,6 +205,31 @@ describe('SLO Details Page', () => { render(); fireEvent.click(screen.getByTestId('o11yHeaderControlActionsButton')); + expect(screen.queryByTestId('sloDetailsHeaderControlPopoverManageRules')).toBeTruthy(); + }); + + it("renders a 'Clone' button under actions menu", async () => { + const slo = buildSlo(); + useParamsMock.mockReturnValue(slo.id); + useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo }); + useLicenseMock.mockReturnValue({ hasAtLeast: () => true }); + + render(); + + fireEvent.click(screen.getByTestId('o11yHeaderControlActionsButton')); + expect(screen.queryByTestId('sloDetailsHeaderControlPopoverClone')).toBeTruthy(); + }); + + it("renders a 'Delete' button under actions menu", async () => { + const slo = buildSlo(); + useParamsMock.mockReturnValue(slo.id); + useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo }); + useLicenseMock.mockReturnValue({ hasAtLeast: () => true }); + + render(); + + fireEvent.click(screen.getByTestId('o11yHeaderControlActionsButton')); + expect(screen.queryByTestId('sloDetailsHeaderControlPopoverDelete')).toBeTruthy(); const manageRulesButton = screen.queryByTestId('sloDetailsHeaderControlPopoverManageRules'); expect(manageRulesButton).toBeTruthy(); diff --git a/x-pack/plugins/observability/public/pages/slo_details/slo_details.tsx b/x-pack/plugins/observability/public/pages/slo_details/slo_details.tsx index 48401ca29f94e..29a7f573e3c14 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/slo_details.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/slo_details.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useIsMutating } from '@tanstack/react-query'; import { EuiBreadcrumbProps } from '@elastic/eui/src/components/breadcrumbs/breadcrumb'; import { EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -44,6 +45,8 @@ export function SloDetailsPage() { const { isLoading, slo } = useFetchSloDetails({ sloId, shouldRefetch: isAutoRefreshing }); + const isCloningOrDeleting = Boolean(useIsMutating()); + useBreadcrumbs(getBreadcrumbs(basePath, slo)); const isSloNotFound = !isLoading && slo === undefined; @@ -55,6 +58,8 @@ export function SloDetailsPage() { navigateToUrl(basePath.prepend(paths.observability.slos)); } + const isPerformingAction = isLoading || isCloningOrDeleting; + const handleToggleAutoRefresh = () => { setIsAutoRefreshing(!isAutoRefreshing); }; @@ -62,15 +67,15 @@ export function SloDetailsPage() { return ( , + pageTitle: , rightSideItems: [ - , + , , - , + , ], bottomBorder: false, }} diff --git a/x-pack/plugins/observability/public/pages/slos/components/badges/slo_indicator_type_badge.tsx b/x-pack/plugins/observability/public/pages/slos/components/badges/slo_indicator_type_badge.tsx index 87f005e2fa5c0..ad73af3d73bfa 100644 --- a/x-pack/plugins/observability/public/pages/slos/components/badges/slo_indicator_type_badge.tsx +++ b/x-pack/plugins/observability/public/pages/slos/components/badges/slo_indicator_type_badge.tsx @@ -63,7 +63,7 @@ export function SloIndicatorTypeBadge({ slo }: Props) { @@ -73,7 +73,7 @@ export function SloIndicatorTypeBadge({ slo }: Props) { onClickAriaLabel={i18n.translate( 'xpack.observability.slo.indicatorTypeBadge.exploreInApm', { - defaultMessage: 'Explore {service} in APM', + defaultMessage: 'View {service} details', values: { service: slo.indicator.params.service }, } )} diff --git a/x-pack/plugins/observability/public/pages/slos/components/slo_delete_confirmation_modal.tsx b/x-pack/plugins/observability/public/pages/slos/components/slo_delete_confirmation_modal.tsx index 4a292bef8c14b..b1b2d341a9bd8 100644 --- a/x-pack/plugins/observability/public/pages/slos/components/slo_delete_confirmation_modal.tsx +++ b/x-pack/plugins/observability/public/pages/slos/components/slo_delete_confirmation_modal.tsx @@ -15,11 +15,13 @@ import { useDeleteSlo } from '../../../hooks/slo/use_delete_slo'; export interface SloDeleteConfirmationModalProps { slo: SLOWithSummaryResponse; onCancel: () => void; + onSuccess?: () => void; } export function SloDeleteConfirmationModal({ slo: { id, name }, onCancel, + onSuccess, }: SloDeleteConfirmationModalProps) { const { notifications: { toasts }, @@ -31,6 +33,7 @@ export function SloDeleteConfirmationModal({ if (isSuccess) { toasts.addSuccess(getDeleteSuccesfulMessage(name)); + onSuccess?.(); } if (isError) { diff --git a/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx b/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx index 0e58573b740c2..3fe0f5d31f543 100644 --- a/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx +++ b/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx @@ -184,12 +184,12 @@ export function SloListItem({ onClick={handleClickActions} /> } - panelPaddingSize="none" + panelPaddingSize="m" closePopover={handleClickActions} isOpen={isActionsPopoverOpen} > {i18n.translate('xpack.observability.slo.slo.item.actions.createRule', { - defaultMessage: 'Create new Alert rule', + defaultMessage: 'Create new alert rule', })} , Date: Wed, 26 Apr 2023 18:38:17 +0100 Subject: [PATCH 10/29] skip failing version bump suite (#155914) --- .../test/functional/apps/ingest_pipelines/ingest_pipelines.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts index baae09f47c530..2aaee38790403 100644 --- a/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts @@ -26,7 +26,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const es = getService('es'); const security = getService('security'); - describe('Ingest Pipelines', function () { + // FAILING VERSION BUMP: https://github.com/elastic/kibana/issues/155914 + describe.skip('Ingest Pipelines', function () { this.tags('smoke'); before(async () => { await security.testUser.setRoles(['ingest_pipelines_user']); From 6690c445e3302b9fe04237a1dc77c5b19f81931b Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Wed, 26 Apr 2023 13:44:31 -0400 Subject: [PATCH 11/29] [serverless] Add ability to disable certain plugins for Serverless. (#155583) > Derived from https://github.com/elastic/kibana/pull/153274 ## Summary This PR extracts configuration settings for enabling/disabling plugins in Serverless projects based on current requirements. It seemed prudent to create an independent PR to K.I.S.S, rather than include in PRs with more ornate changes, (e.g. https://github.com/elastic/kibana/pull/155582) --- config/serverless.yml | 15 +++++++++++++++ x-pack/plugins/apm/server/index.ts | 1 + .../cross_cluster_replication/server/config.ts | 5 +++++ .../__mocks__/kea_logic/kibana_logic.mock.ts | 8 ++++---- .../applications/shared/kibana/kibana_logic.ts | 2 +- x-pack/plugins/enterprise_search/server/index.ts | 1 + x-pack/plugins/fleet/common/authz.ts | 2 +- x-pack/plugins/fleet/server/config.ts | 1 + .../index_lifecycle_management/server/config.ts | 5 +++++ .../plugins/license_management/server/config.ts | 5 +++++ x-pack/plugins/observability/server/index.ts | 1 + x-pack/plugins/remote_clusters/server/config.ts | 5 +++++ x-pack/plugins/rollup/server/config.ts | 5 +++++ .../security_solution/server/config.mock.ts | 1 + x-pack/plugins/security_solution/server/config.ts | 1 + x-pack/plugins/snapshot_restore/server/config.ts | 5 +++++ x-pack/plugins/synthetics/common/config.ts | 1 + x-pack/plugins/synthetics/kibana.jsonc | 4 +++- .../get_service_locations.test.ts | 3 +++ .../synthetics_service/synthetics_service.test.ts | 8 +++++++- x-pack/plugins/upgrade_assistant/server/config.ts | 8 ++++++++ x-pack/plugins/watcher/server/index.ts | 8 ++++++++ 22 files changed, 87 insertions(+), 8 deletions(-) diff --git a/config/serverless.yml b/config/serverless.yml index d38863072d8a0..e65b15f064328 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -1,2 +1,17 @@ +newsfeed.enabled: false xpack.fleet.enableExperimental: ['fleetServerStandalone'] xpack.fleet.internal.disableILMPolicies: true + +# Management team plugins +xpack.upgrade_assistant.enabled: false +xpack.rollup.enabled: false +xpack.watcher.enabled: false +xpack.ccr.enabled: false +xpack.ilm.enabled: false +xpack.remote_clusters.enabled: false +xpack.snapshot_restore.enabled: false +xpack.license_management.enabled: false + +# Other disabled plugins +xpack.canvas.enabled: false +xpack.reporting.enabled: false diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index bfd7f1c19e376..d13bc2e708efe 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -56,6 +56,7 @@ const configSchema = schema.object({ latestAgentVersionsUrl: schema.string({ defaultValue: 'https://apm-agent-versions.elastic.co/versions.json', }), + enabled: schema.boolean({ defaultValue: true }), }); // plugin config diff --git a/x-pack/plugins/cross_cluster_replication/server/config.ts b/x-pack/plugins/cross_cluster_replication/server/config.ts index bac5f917f22a6..4cba6d0707abb 100644 --- a/x-pack/plugins/cross_cluster_replication/server/config.ts +++ b/x-pack/plugins/cross_cluster_replication/server/config.ts @@ -22,6 +22,11 @@ const schemaLatest = schema.object( ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), }), + /** + * Disables the plugin. + * Added back in 8.8. + */ + enabled: schema.boolean({ defaultValue: true }), }, { defaultValue: undefined } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts index 5cfd5e7029459..9c1b0575694c4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts @@ -24,13 +24,13 @@ export const mockKibanaValues = { ), } as unknown as ApplicationStart, capabilities: {} as Capabilities, - config: { host: 'http://localhost:3002' }, charts: chartPluginMock.createStartContract(), cloud: { ...cloudMock.createSetup(), - isCloudEnabled: false, deployment_url: 'https://cloud.elastic.co/deployments/some-id', + isCloudEnabled: false, }, + config: { host: 'http://localhost:3002' }, data: dataPluginMock.createStartContract(), guidedOnboarding: {}, history: mockHistory, @@ -50,12 +50,12 @@ export const mockKibanaValues = { hasNativeConnectors: true, hasWebCrawler: true, }, - uiSettings: uiSettingsServiceMock.createStartContract(), + renderHeaderActions: jest.fn(), security: securityMock.createStart(), setBreadcrumbs: jest.fn(), setChromeIsVisible: jest.fn(), setDocTitle: jest.fn(), - renderHeaderActions: jest.fn(), + uiSettings: uiSettingsServiceMock.createStartContract(), }; jest.mock('../../shared/kibana', () => ({ diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts index 2a400ce2d0269..b7d264ba43e99 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts @@ -67,9 +67,9 @@ export const KibanaLogic = kea>({ reducers: ({ props }) => ({ application: [props.application || {}, {}], capabilities: [props.capabilities || {}, {}], - config: [props.config || {}, {}], charts: [props.charts, {}], cloud: [props.cloud || {}, {}], + config: [props.config || {}, {}], data: [props.data, {}], guidedOnboarding: [props.guidedOnboarding, {}], history: [props.history, {}], diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index 19019841976d4..b4c86696c4858 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -19,6 +19,7 @@ export const configSchema = schema.object({ accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }), canDeployEntSearch: schema.boolean({ defaultValue: true }), customHeaders: schema.maybe(schema.object({}, { unknowns: 'allow' })), + enabled: schema.boolean({ defaultValue: true }), hasConnectors: schema.boolean({ defaultValue: true }), hasDefaultIngestPipeline: schema.boolean({ defaultValue: true }), hasNativeConnectors: schema.boolean({ defaultValue: true }), diff --git a/x-pack/plugins/fleet/common/authz.ts b/x-pack/plugins/fleet/common/authz.ts index 83d337c00368b..3fbfd614d8038 100644 --- a/x-pack/plugins/fleet/common/authz.ts +++ b/x-pack/plugins/fleet/common/authz.ts @@ -107,7 +107,7 @@ export function calculatePackagePrivilegesFromCapabilities( return { ...acc, [privilege]: { - executePackageAction: capabilities.siem[privilegeName] || false, + executePackageAction: (capabilities.siem && capabilities.siem[privilegeName]) || false, }, }; }, diff --git a/x-pack/plugins/fleet/server/config.ts b/x-pack/plugins/fleet/server/config.ts index 267d9873aaa2d..a370c0825664c 100644 --- a/x-pack/plugins/fleet/server/config.ts +++ b/x-pack/plugins/fleet/server/config.ts @@ -166,6 +166,7 @@ export const config: PluginConfigDescriptor = { }), }) ), + enabled: schema.boolean({ defaultValue: true }), }), }; diff --git a/x-pack/plugins/index_lifecycle_management/server/config.ts b/x-pack/plugins/index_lifecycle_management/server/config.ts index 737cc6a472c7a..7fdec20bbb050 100644 --- a/x-pack/plugins/index_lifecycle_management/server/config.ts +++ b/x-pack/plugins/index_lifecycle_management/server/config.ts @@ -24,6 +24,11 @@ const schemaLatest = schema.object( }), // Cloud requires the ability to hide internal node attributes from users. filteredNodeAttributes: schema.arrayOf(schema.string(), { defaultValue: [] }), + /** + * Disables the plugin. + * Added back in 8.8. + */ + enabled: schema.boolean({ defaultValue: true }), }, { defaultValue: undefined } ); diff --git a/x-pack/plugins/license_management/server/config.ts b/x-pack/plugins/license_management/server/config.ts index 42beba0ea5c09..23449bc19e793 100644 --- a/x-pack/plugins/license_management/server/config.ts +++ b/x-pack/plugins/license_management/server/config.ts @@ -22,6 +22,11 @@ const schemaLatest = schema.object( ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), }), + /** + * Disables the plugin. + * Added back in 8.8. + */ + enabled: schema.boolean({ defaultValue: true }), }, { defaultValue: undefined } ); diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index fea9b0ab14195..33ef9d2c62d4d 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -41,6 +41,7 @@ const configSchema = schema.object({ }), }), }), + enabled: schema.boolean({ defaultValue: true }), }); export const config: PluginConfigDescriptor = { diff --git a/x-pack/plugins/remote_clusters/server/config.ts b/x-pack/plugins/remote_clusters/server/config.ts index 32db006e8171a..4f6c56191cd89 100644 --- a/x-pack/plugins/remote_clusters/server/config.ts +++ b/x-pack/plugins/remote_clusters/server/config.ts @@ -22,6 +22,11 @@ const schemaLatest = schema.object( ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), }), + /** + * Disables the plugin. + * Added back in 8.8. + */ + enabled: schema.boolean({ defaultValue: true }), }, { defaultValue: undefined } ); diff --git a/x-pack/plugins/rollup/server/config.ts b/x-pack/plugins/rollup/server/config.ts index 235202a23db24..953cd4b283f97 100644 --- a/x-pack/plugins/rollup/server/config.ts +++ b/x-pack/plugins/rollup/server/config.ts @@ -22,6 +22,11 @@ const schemaLatest = schema.object( ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), }), + /** + * Disables the plugin. + * Added back in 8.8. + */ + enabled: schema.boolean({ defaultValue: true }), }, { defaultValue: undefined } ); diff --git a/x-pack/plugins/security_solution/server/config.mock.ts b/x-pack/plugins/security_solution/server/config.mock.ts index c1faa6f401a1d..6fcaa94629643 100644 --- a/x-pack/plugins/security_solution/server/config.mock.ts +++ b/x-pack/plugins/security_solution/server/config.mock.ts @@ -31,6 +31,7 @@ export const createMockConfig = (): ConfigType => { alertIgnoreFields: [], experimentalFeatures: parseExperimentalConfigValue(enableExperimental), + enabled: true, }; }; diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts index 26f1be4f014b3..a0283858590cb 100644 --- a/x-pack/plugins/security_solution/server/config.ts +++ b/x-pack/plugins/security_solution/server/config.ts @@ -122,6 +122,7 @@ export const configSchema = schema.object({ * the package is not already installed. */ prebuiltRulesPackageVersion: schema.maybe(schema.string()), + enabled: schema.boolean({ defaultValue: true }), }); export type ConfigSchema = TypeOf; diff --git a/x-pack/plugins/snapshot_restore/server/config.ts b/x-pack/plugins/snapshot_restore/server/config.ts index d259b6674391a..e2452e5b58e54 100644 --- a/x-pack/plugins/snapshot_restore/server/config.ts +++ b/x-pack/plugins/snapshot_restore/server/config.ts @@ -25,6 +25,11 @@ const schemaLatest = schema.object( slm_ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), }), + /** + * Disables the plugin. + * Added back in 8.8. + */ + enabled: schema.boolean({ defaultValue: true }), }, { defaultValue: undefined } ); diff --git a/x-pack/plugins/synthetics/common/config.ts b/x-pack/plugins/synthetics/common/config.ts index c9c48e5878391..9da43f8bf9a08 100644 --- a/x-pack/plugins/synthetics/common/config.ts +++ b/x-pack/plugins/synthetics/common/config.ts @@ -23,6 +23,7 @@ const serviceConfig = schema.object({ const uptimeConfig = schema.object({ index: schema.maybe(schema.string()), service: schema.maybe(serviceConfig), + enabled: schema.boolean({ defaultValue: true }), }); export const config: PluginConfigDescriptor = { diff --git a/x-pack/plugins/synthetics/kibana.jsonc b/x-pack/plugins/synthetics/kibana.jsonc index a70202e3126a1..1cc3b45f4637d 100644 --- a/x-pack/plugins/synthetics/kibana.jsonc +++ b/x-pack/plugins/synthetics/kibana.jsonc @@ -12,6 +12,8 @@ "actions", "alerting", "cases", + "data", + "fleet", "embeddable", "discover", "dataViews", @@ -44,4 +46,4 @@ "indexLifecycleManagement" ] } -} +} \ No newline at end of file diff --git a/x-pack/plugins/synthetics/server/synthetics_service/get_service_locations.test.ts b/x-pack/plugins/synthetics/server/synthetics_service/get_service_locations.test.ts index 1fed640bcb4e8..58faf6ba14877 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/get_service_locations.test.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/get_service_locations.test.ts @@ -50,6 +50,7 @@ describe('getServiceLocations', function () { manifestUrl: 'http://local.dev', showExperimentalLocations: false, }, + enabled: true, }, // @ts-ignore logger: { @@ -101,6 +102,7 @@ describe('getServiceLocations', function () { manifestUrl: 'http://local.dev', showExperimentalLocations: false, }, + enabled: true, }, // @ts-ignore logger: { @@ -138,6 +140,7 @@ describe('getServiceLocations', function () { manifestUrl: 'http://local.dev', showExperimentalLocations: true, }, + enabled: true, }, // @ts-ignore logger: { diff --git a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts index 35e31add55aa0..86933fc51e4fd 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_service.test.ts @@ -80,6 +80,7 @@ describe('SyntheticsService', () => { password: '12345', manifestUrl: 'http://localhost:8080/api/manifest', }, + enabled: true, }, coreStart: mockCoreStart, encryptedSavedObjects: mockEncryptedSO(), @@ -101,7 +102,11 @@ describe('SyntheticsService', () => { }; }); serverMock.config = { - service: { devUrl: 'http://localhost', manifestUrl: 'https://test-manifest.com' }, + service: { + devUrl: 'http://localhost', + manifestUrl: 'https://test-manifest.com', + }, + enabled: true, }; if (serverMock.savedObjectsClient) { serverMock.savedObjectsClient.find = jest.fn().mockResolvedValue({ @@ -165,6 +170,7 @@ describe('SyntheticsService', () => { username: 'dev', password: '12345', }, + enabled: true, }; const service = new SyntheticsService(serverMock); diff --git a/x-pack/plugins/upgrade_assistant/server/config.ts b/x-pack/plugins/upgrade_assistant/server/config.ts index 6202a6680708a..bf872f50b5222 100644 --- a/x-pack/plugins/upgrade_assistant/server/config.ts +++ b/x-pack/plugins/upgrade_assistant/server/config.ts @@ -12,6 +12,11 @@ import { PluginConfigDescriptor } from '@kbn/core/server'; // even for minor releases. // ------------------------------- const configSchema = schema.object({ + /** + * Disables the plugin. + */ + enabled: schema.boolean({ defaultValue: true }), + featureSet: schema.object({ /** * Ml Snapshot should only be enabled for major version upgrades. Currently this @@ -39,6 +44,9 @@ const configSchema = schema.object({ */ reindexCorrectiveActions: schema.boolean({ defaultValue: false }), }), + /** + * This config allows to hide the UI without disabling the plugin. + */ ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), }), diff --git a/x-pack/plugins/watcher/server/index.ts b/x-pack/plugins/watcher/server/index.ts index 0aba44ed82838..36453f571f162 100644 --- a/x-pack/plugins/watcher/server/index.ts +++ b/x-pack/plugins/watcher/server/index.ts @@ -6,6 +6,14 @@ */ import { PluginInitializerContext } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; + import { WatcherServerPlugin } from './plugin'; export const plugin = (ctx: PluginInitializerContext) => new WatcherServerPlugin(ctx); + +export const config = { + schema: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), +}; From 4ff9a6f60ab77d2fda110eeec1c752fda9e26fc3 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 26 Apr 2023 19:59:40 +0200 Subject: [PATCH 12/29] [Synthetics] Handle invalid license state (#155851) --- .../plugins/synthetics/common/constants/ui.ts | 4 ++ .../apps/synthetics/hooks/use_enablement.ts | 4 +- .../hooks/use_synthetics_priviliges.tsx | 64 ++++++++++++++++++- .../legacy_uptime/lib/domains/license.ts | 11 +++- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/synthetics/common/constants/ui.ts b/x-pack/plugins/synthetics/common/constants/ui.ts index 40c1d26c58cbc..d014b8b8ea6ff 100644 --- a/x-pack/plugins/synthetics/common/constants/ui.ts +++ b/x-pack/plugins/synthetics/common/constants/ui.ts @@ -105,3 +105,7 @@ export const FILTER_FIELDS = { }; export const SYNTHETICS_INDEX_PATTERN = 'synthetics-*'; + +export const LICENSE_NOT_ACTIVE_ERROR = 'License not active'; +export const LICENSE_MISSING_ERROR = 'Missing license information'; +export const LICENSE_NOT_SUPPORTED_ERROR = 'License not supported'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_enablement.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_enablement.ts index fe726d0cbe3d2..057d15218ac2e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_enablement.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_enablement.ts @@ -15,10 +15,10 @@ export function useEnablement() { const { loading, error, enablement } = useSelector(selectSyntheticsEnablement); useEffect(() => { - if (!enablement && !loading) { + if (!enablement && !loading && !error) { dispatch(getSyntheticsEnablement()); } - }, [dispatch, enablement, loading]); + }, [dispatch, enablement, error, loading]); return { enablement: { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_synthetics_priviliges.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_synthetics_priviliges.tsx index b1f1d57a33fd7..26f77151010e9 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_synthetics_priviliges.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_synthetics_priviliges.tsx @@ -12,13 +12,20 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiButton, EuiMarkdownFormat, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import { selectOverviewStatus } from '../state/overview_status'; -import { SYNTHETICS_INDEX_PATTERN } from '../../../../common/constants'; +import { + LICENSE_MISSING_ERROR, + LICENSE_NOT_ACTIVE_ERROR, + LICENSE_NOT_SUPPORTED_ERROR, + SYNTHETICS_INDEX_PATTERN, +} from '../../../../common/constants'; +import { useSyntheticsSettingsContext } from '../contexts'; export const useSyntheticsPrivileges = () => { const { error } = useSelector(selectOverviewStatus); @@ -36,6 +43,24 @@ export const useSyntheticsPrivileges = () => { ); } + if ( + error?.body?.message && + [LICENSE_NOT_ACTIVE_ERROR, LICENSE_MISSING_ERROR, LICENSE_NOT_SUPPORTED_ERROR].includes( + error?.body?.message + ) + ) { + return ( + + + + + + ); + } }; const Unprivileged = ({ unprivilegedIndices }: { unprivilegedIndices: string[] }) => ( @@ -77,3 +102,40 @@ const Unprivileged = ({ unprivilegedIndices }: { unprivilegedIndices: string[] } } /> ); + +const LicenseExpired = () => { + const { basePath } = useSyntheticsSettingsContext(); + return ( + + + + } + body={ +

+ +

+ } + actions={[ + + {i18n.translate('xpack.synthetics.invalidLicense.licenseManagementLink', { + defaultMessage: 'Manage your license', + })} + , + ]} + /> + ); +}; diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/domains/license.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/domains/license.ts index d921766529e8f..6a55b0459c632 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/domains/license.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/domains/license.ts @@ -6,6 +6,11 @@ */ import { ILicense } from '@kbn/licensing-plugin/server'; +import { + LICENSE_MISSING_ERROR, + LICENSE_NOT_ACTIVE_ERROR, + LICENSE_NOT_SUPPORTED_ERROR, +} from '../../../../common/constants'; export interface UMLicenseStatusResponse { statusCode: number; @@ -18,19 +23,19 @@ export type UMLicenseCheck = ( export const licenseCheck: UMLicenseCheck = (license) => { if (license === undefined) { return { - message: 'Missing license information', + message: LICENSE_MISSING_ERROR, statusCode: 400, }; } if (!license.hasAtLeast('basic')) { return { - message: 'License not supported', + message: LICENSE_NOT_SUPPORTED_ERROR, statusCode: 401, }; } if (license.isActive === false) { return { - message: 'License not active', + message: LICENSE_NOT_ACTIVE_ERROR, statusCode: 403, }; } From cbcaab419bd84c54a67a707618f9665bb188d88b Mon Sep 17 00:00:00 2001 From: Antonio Date: Wed, 26 Apr 2023 20:10:22 +0200 Subject: [PATCH 13/29] [Cases ]Add e2e tests for case files view (#155634) ## Summary One of the leftover issues from https://github.com/elastic/kibana/pull/154436 was adding a few e2e tests. This PR adds e2e tests for: - Attaching a file to a case - Deleting a file attached to a case - Opening and closing the file preview in the Cases detail view - Guarantee the File User Activity is rendered correctly ## Flaky Test Runner https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2177#0187bd93-edc9-4af0-85c4-5df3f55c418c Flaky tests: (`x-pack/test/functional_with_es_ssl/apps/cases/group1/config.ts` x 50) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/components/files/files_table.tsx | 10 ++- .../test/functional/services/cases/files.ts | 68 ++++++++++++++++++ .../test/functional/services/cases/index.ts | 2 + .../apps/cases/group1/elastic_logo.png | Bin 0 -> 34043 bytes .../apps/cases/group1/view_case.ts | 61 ++++++++++++++++ 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 x-pack/test/functional/services/cases/files.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/cases/group1/elastic_logo.png diff --git a/x-pack/plugins/cases/public/components/files/files_table.tsx b/x-pack/plugins/cases/public/components/files/files_table.tsx index 6433d90a91d44..18a9e7f196661 100644 --- a/x-pack/plugins/cases/public/components/files/files_table.tsx +++ b/x-pack/plugins/cases/public/components/files/files_table.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import type { Pagination, EuiBasicTableProps } from '@elastic/eui'; import type { FileJSON } from '@kbn/shared-ux-file-types'; @@ -46,6 +46,13 @@ export const FilesTable = ({ caseId, items, pagination, onChange, isLoading }: F showPreview(); }; + const filesTableRowProps = useCallback( + (file: FileJSON) => ({ + 'data-test-subj': `cases-files-table-row-${file.id}`, + }), + [] + ); + const columns = useFilesTableColumns({ caseId, showPreview: displayPreview }); return isLoading ? ( @@ -72,6 +79,7 @@ export const FilesTable = ({ caseId, items, pagination, onChange, isLoading }: F onChange={onChange} data-test-subj="cases-files-table" noItemsMessage={} + rowProps={filesTableRowProps} /> {isPreviewVisible && selectedFile !== undefined && ( diff --git a/x-pack/test/functional/services/cases/files.ts b/x-pack/test/functional/services/cases/files.ts new file mode 100644 index 0000000000000..14c922c3f21ea --- /dev/null +++ b/x-pack/test/functional/services/cases/files.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export function CasesFilesTableServiceProvider({ getService, getPageObject }: FtrProviderContext) { + const common = getPageObject('common'); + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const browser = getService('browser'); + + const assertFileExists = (index: number, totalFiles: number) => { + if (index > totalFiles - 1) { + throw new Error('Cannot get file from table. Index is greater than the length of all rows'); + } + }; + + return { + async addFile(fileInputPath: string) { + // click the AddFile button + await testSubjects.click('cases-files-add'); + await find.byCssSelector('[aria-label="Upload a file"]'); + + // upload a file + await common.setFileInputPath(fileInputPath); + await testSubjects.click('uploadButton'); + }, + + async searchByFileName(fileName: string) { + const searchField = await testSubjects.find('cases-files-search'); + + searchField.clearValue(); + + await searchField.type(fileName); + await searchField.pressKeys(browser.keys.ENTER); + }, + + async deleteFile(index: number = 0) { + const row = await this.getFileByIndex(index); + + (await row.findByCssSelector('[data-test-subj="cases-files-delete-button"]')).click(); + + await testSubjects.click('confirmModalConfirmButton'); + }, + + async openFilePreview(index: number = 0) { + const row = await this.getFileByIndex(index); + + (await row.findByCssSelector('[data-test-subj="cases-files-name-link"]')).click(); + }, + + async emptyOrFail() { + await testSubjects.existOrFail('cases-files-table-empty'); + }, + + async getFileByIndex(index: number) { + const rows = await find.allByCssSelector('[data-test-subj*="cases-files-table-row-"', 100); + + assertFileExists(index, rows.length); + + return rows[index] ?? null; + }, + }; +} diff --git a/x-pack/test/functional/services/cases/index.ts b/x-pack/test/functional/services/cases/index.ts index 8ecabdac8c4c5..27df56d546090 100644 --- a/x-pack/test/functional/services/cases/index.ts +++ b/x-pack/test/functional/services/cases/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { CasesAPIServiceProvider } from './api'; import { CasesCommonServiceProvider } from './common'; import { CasesCreateViewServiceProvider } from './create'; +import { CasesFilesTableServiceProvider } from './files'; import { CasesTableServiceProvider } from './list'; import { CasesNavigationProvider } from './navigation'; import { CasesSingleViewServiceProvider } from './single_case_view'; @@ -21,6 +22,7 @@ export function CasesServiceProvider(context: FtrProviderContext) { api: CasesAPIServiceProvider(context), common: casesCommon, casesTable: CasesTableServiceProvider(context, casesCommon), + casesFilesTable: CasesFilesTableServiceProvider(context), create: CasesCreateViewServiceProvider(context, casesCommon), navigation: CasesNavigationProvider(context), singleCase: CasesSingleViewServiceProvider(context), diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/elastic_logo.png b/x-pack/test/functional_with_es_ssl/apps/cases/group1/elastic_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..085012eac378818bf04ffde92a9950243942003d GIT binary patch literal 34043 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 literal 0 HcmV?d00001 diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index 233157e57e518..8c9f7a4450f83 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -618,6 +618,67 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.click('case-view-tab-title-alerts'); await testSubjects.existOrFail('case-view-tab-content-alerts'); }); + + it("shows the 'files' tab when clicked", async () => { + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + }); + }); + + describe('Files', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService); + + it('adds a file to the case', async () => { + // navigate to files tab + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + + await cases.casesFilesTable.addFile(require.resolve('./elastic_logo.png')); + + // make sure the uploaded file is displayed on the table + await find.byButtonText('elastic_logo.png'); + }); + + it('search by file name', async () => { + await cases.casesFilesTable.searchByFileName('foobar'); + + await cases.casesFilesTable.emptyOrFail(); + + await cases.casesFilesTable.searchByFileName('elastic'); + + await find.byButtonText('elastic_logo.png'); + }); + + it('displays the file preview correctly', async () => { + await cases.casesFilesTable.openFilePreview(0); + + await testSubjects.existOrFail('cases-files-image-preview'); + }); + + it('pressing escape key closes the file preview', async () => { + await testSubjects.existOrFail('cases-files-image-preview'); + + await browser.pressKeys(browser.keys.ESCAPE); + + await testSubjects.missingOrFail('cases-files-image-preview'); + }); + + it('files added to a case can be deleted', async () => { + await cases.casesFilesTable.deleteFile(0); + + await cases.casesFilesTable.emptyOrFail(); + }); + + describe('Files User Activity', () => { + it('file user action is displayed correctly', async () => { + await cases.casesFilesTable.addFile(require.resolve('./elastic_logo.png')); + + await testSubjects.click('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + + await find.byButtonText('elastic_logo.png'); + }); + }); }); }); }; From b3f65f79e5017b70fe26e5aa1c2ee1085e68c138 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 26 Apr 2023 12:19:46 -0600 Subject: [PATCH 14/29] [Controls] Fix sorting of numeric keyword fields (#155207) Closes https://github.com/elastic/kibana/issues/155073 ## Summary ### Before Previously, the options list suggestions were stored as a dictionary (i.e. an object of key+value pairs) - while this worked for most fields, unbeknownst to us, Javascript tries to sort numeric keys (regardless of if they are of type `string` or `number`) based on their value. This meant that, as part of the parsing process when using an options list control for a numeric `keyword` field, the results returned by the ES query were **always** sorted in ascending numeric order regardless of the sorting method that was picked (note that this is especially obvious once you "load more", which is what I did for the following screenshots): | | Ascending | Descending | |--------------|-----------|------------| | Alphabetical | | | | Doc count | | | ### After This PR converts the options list suggestions to be stored as an **array** of key/value pairs in order to preserve the order returned from Elasticsearch - now, you get the expected string-sorted ordering when using numeric `keyword` fields in an options list control: | | Ascending | Descending | |--------------|-----------|------------| | Alphabetical | | | | Doc count | | | Notice in the above that we are now using **string sorting** for the numeric values when alphabetical sorting is selected, which means you aren't getting the expected "numeric" sorting - so for example, when sorted ascending, `"6" > "52"` because it is only comparing the first character and `"6" > "5"`. This will be handled much better once [numeric field support](https://github.com/elastic/kibana/issues/126795) is added to options lists. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../controls/common/options_list/mocks.tsx | 14 +- .../controls/common/options_list/types.ts | 4 +- .../public/__stories__/controls.stories.tsx | 10 +- .../components/options_list_popover.test.tsx | 12 +- .../options_list_popover_suggestions.tsx | 24 +-- .../embeddable/options_list_embeddable.tsx | 2 +- .../options_list/options_list.story.ts | 2 +- ...ions_list_cheap_suggestion_queries.test.ts | 142 +++++++++--------- .../options_list_cheap_suggestion_queries.ts | 33 ++-- ..._list_expensive_suggestion_queries.test.ts | 123 +++++++-------- ...tions_list_expensive_suggestion_queries.ts | 21 ++- 11 files changed, 196 insertions(+), 191 deletions(-) diff --git a/src/plugins/controls/common/options_list/mocks.tsx b/src/plugins/controls/common/options_list/mocks.tsx index 936a620ec288c..d0e2977a9b439 100644 --- a/src/plugins/controls/common/options_list/mocks.tsx +++ b/src/plugins/controls/common/options_list/mocks.tsx @@ -17,13 +17,13 @@ const mockOptionsListComponentState = { searchString: { value: '', valid: true }, field: undefined, totalCardinality: 0, - availableOptions: { - woof: { doc_count: 100 }, - bark: { doc_count: 75 }, - meow: { doc_count: 50 }, - quack: { doc_count: 25 }, - moo: { doc_count: 5 }, - }, + availableOptions: [ + { value: 'woof', docCount: 100 }, + { value: 'bark', docCount: 75 }, + { value: 'meow', docCount: 50 }, + { value: 'quack', docCount: 25 }, + { value: 'moo', docCount: 5 }, + ], invalidSelections: [], allowExpensiveQueries: true, popoverOpen: false, diff --git a/src/plugins/controls/common/options_list/types.ts b/src/plugins/controls/common/options_list/types.ts index 510dac280fe76..8437eb0382b6e 100644 --- a/src/plugins/controls/common/options_list/types.ts +++ b/src/plugins/controls/common/options_list/types.ts @@ -28,9 +28,7 @@ export interface OptionsListEmbeddableInput extends DataControlInput { placeholder?: string; } -export interface OptionsListSuggestions { - [key: string]: { doc_count: number }; -} +export type OptionsListSuggestions = Array<{ value: string; docCount?: number }>; /** * The Options list response is returned from the serverside Options List route. diff --git a/src/plugins/controls/public/__stories__/controls.stories.tsx b/src/plugins/controls/public/__stories__/controls.stories.tsx index 4326ce056d118..a0ba30622e150 100644 --- a/src/plugins/controls/public/__stories__/controls.stories.tsx +++ b/src/plugins/controls/public/__stories__/controls.stories.tsx @@ -35,7 +35,11 @@ import { injectStorybookDataView } from '../services/data_views/data_views.story import { replaceOptionsListMethod } from '../services/options_list/options_list.story'; import { populateStorybookControlFactories } from './storybook_control_factories'; import { replaceValueSuggestionMethod } from '../services/unified_search/unified_search.story'; -import { OptionsListResponse, OptionsListRequest } from '../../common/options_list/types'; +import { + OptionsListResponse, + OptionsListRequest, + OptionsListSuggestions, +} from '../../common/options_list/types'; export default { title: 'Controls', @@ -56,9 +60,9 @@ const storybookStubOptionsListRequest = async ( r({ suggestions: getFlightSearchOptions(request.field.name, request.searchString).reduce( (o, current, index) => { - return { ...o, [current]: { doc_count: index } }; + return [...o, { value: current, docCount: index }]; }, - {} + [] as OptionsListSuggestions ), totalCardinality: 100, }), diff --git a/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx b/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx index ffdb1045cad88..e2fa74dfbf2f1 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx @@ -70,7 +70,7 @@ describe('Options list popover', () => { }); test('no available options', async () => { - const popover = await mountComponent({ componentState: { availableOptions: {} } }); + const popover = await mountComponent({ componentState: { availableOptions: [] } }); const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options'); const noOptionsDiv = findTestSubject( availableOptionsDiv, @@ -127,9 +127,7 @@ describe('Options list popover', () => { selectedOptions: ['bark', 'woof'], }, componentState: { - availableOptions: { - bark: { doc_count: 75 }, - }, + availableOptions: [{ value: 'bark', docCount: 75 }], validSelections: ['bark'], invalidSelections: ['woof'], }, @@ -154,9 +152,7 @@ describe('Options list popover', () => { const popover = await mountComponent({ explicitInput: { selectedOptions: ['bark', 'woof', 'meow'] }, componentState: { - availableOptions: { - bark: { doc_count: 75 }, - }, + availableOptions: [{ value: 'bark', docCount: 75 }], validSelections: ['bark'], invalidSelections: ['woof', 'meow'], }, @@ -219,7 +215,7 @@ describe('Options list popover', () => { test('if existsSelected = false and no suggestions, then "Exists" does not show up', async () => { const popover = await mountComponent({ - componentState: { availableOptions: {} }, + componentState: { availableOptions: [] }, explicitInput: { existsSelected: false }, }); const existsOption = findTestSubject(popover, 'optionsList-control-selection-exists'); diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx index 8d727bed55e20..e5c14e5ea70fc 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover_suggestions.tsx @@ -48,7 +48,7 @@ export const OptionsListPopoverSuggestions = ({ const canLoadMoreSuggestions = useMemo( () => totalCardinality - ? Object.keys(availableOptions ?? {}).length < + ? (availableOptions ?? []).length < Math.min(totalCardinality, MAX_OPTIONS_LIST_REQUEST_SIZE) : false, [availableOptions, totalCardinality] @@ -61,7 +61,7 @@ export const OptionsListPopoverSuggestions = ({ [invalidSelections] ); const suggestions = useMemo(() => { - return showOnlySelected ? selectedOptions : Object.keys(availableOptions ?? {}); + return showOnlySelected ? selectedOptions : availableOptions ?? []; }, [availableOptions, selectedOptions, showOnlySelected]); const existsSelectableOption = useMemo(() => { @@ -79,19 +79,23 @@ export const OptionsListPopoverSuggestions = ({ const [selectableOptions, setSelectableOptions] = useState([]); // will be set in following useEffect useEffect(() => { /* This useEffect makes selectableOptions responsive to search, show only selected, and clear selections */ - const options: EuiSelectableOption[] = (suggestions ?? []).map((key) => { + const options: EuiSelectableOption[] = (suggestions ?? []).map((suggestion) => { + if (typeof suggestion === 'string') { + // this means that `showOnlySelected` is true, and doc count is not known when this is the case + suggestion = { value: suggestion }; + } return { - key, - label: key, - checked: selectedOptionsSet?.has(key) ? 'on' : undefined, - 'data-test-subj': `optionsList-control-selection-${key}`, + key: suggestion.value, + label: suggestion.value, + checked: selectedOptionsSet?.has(suggestion.value) ? 'on' : undefined, + 'data-test-subj': `optionsList-control-selection-${suggestion.value}`, className: - showOnlySelected && invalidSelectionsSet.has(key) + showOnlySelected && invalidSelectionsSet.has(suggestion.value) ? 'optionsList__selectionInvalid' : 'optionsList__validSuggestion', append: - !showOnlySelected && availableOptions?.[key] ? ( - + !showOnlySelected && suggestion?.docCount ? ( + ) : undefined, }; }); diff --git a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable.tsx b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable.tsx index e2503b4e530e8..08d5f1150baf5 100644 --- a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable.tsx +++ b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable.tsx @@ -371,7 +371,7 @@ export class OptionsListEmbeddable extends Embeddable { this.dispatch.updateQueryResults({ - availableOptions: {}, + availableOptions: [], }); this.dispatch.setLoading(false); }); diff --git a/src/plugins/controls/public/services/options_list/options_list.story.ts b/src/plugins/controls/public/services/options_list/options_list.story.ts index 6d3305f97b9aa..cf674887a0ba0 100644 --- a/src/plugins/controls/public/services/options_list/options_list.story.ts +++ b/src/plugins/controls/public/services/options_list/options_list.story.ts @@ -18,7 +18,7 @@ let optionsListRequestMethod = async (request: OptionsListRequest, abortSignal: setTimeout( () => r({ - suggestions: {}, + suggestions: [], totalCardinality: 100, }), 120 diff --git a/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.test.ts b/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.test.ts index 31783a1267aca..0476788791f69 100644 --- a/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.test.ts +++ b/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.test.ts @@ -388,17 +388,20 @@ describe('options list cheap queries', () => { expect( suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock).suggestions ).toMatchInlineSnapshot(` - Object { - "cool1": Object { - "doc_count": 5, + Array [ + Object { + "docCount": 5, + "value": "cool1", }, - "cool2": Object { - "doc_count": 15, + Object { + "docCount": 15, + "value": "cool2", }, - "cool3": Object { - "doc_count": 10, + Object { + "docCount": 10, + "value": "cool3", }, - } + ] `); }); @@ -421,14 +424,16 @@ describe('options list cheap queries', () => { expect( suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock).suggestions ).toMatchInlineSnapshot(` - Object { - "false": Object { - "doc_count": 55, + Array [ + Object { + "docCount": 55, + "value": "false", }, - "true": Object { - "doc_count": 155, + Object { + "docCount": 155, + "value": "true", }, - } + ] `); }); @@ -455,17 +460,20 @@ describe('options list cheap queries', () => { expect( suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock).suggestions ).toMatchInlineSnapshot(` - Object { - "cool1": Object { - "doc_count": 5, + Array [ + Object { + "docCount": 5, + "value": "cool1", }, - "cool2": Object { - "doc_count": 15, + Object { + "docCount": 15, + "value": "cool2", }, - "cool3": Object { - "doc_count": 10, + Object { + "docCount": 10, + "value": "cool3", }, - } + ] `); }); @@ -490,17 +498,20 @@ describe('options list cheap queries', () => { expect( suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock).suggestions ).toMatchInlineSnapshot(` - Object { - "cool1": Object { - "doc_count": 5, + Array [ + Object { + "docCount": 5, + "value": "cool1", }, - "cool2": Object { - "doc_count": 15, + Object { + "docCount": 15, + "value": "cool2", }, - "cool3": Object { - "doc_count": 10, + Object { + "docCount": 10, + "value": "cool3", }, - } + ] `); }); }); @@ -552,55 +563,50 @@ describe('options list cheap queries', () => { rawSearchResponseMock, optionsListRequestBodyMock ).suggestions; - /** first, verify that the sorting worked as expected */ - expect(Object.keys(parsed)).toMatchInlineSnapshot(` - Array [ - "52:ae76:5947:5e2a:551:fe6a:712a:c72", - "111.52.174.2", - "196.162.13.39", - "f7a9:640b:b5a0:1219:8d75:ed94:3c3e:2e63", - "23.216.241.120", - "28c7:c9a4:42fd:16b0:4de5:e41e:28d9:9172", - "21.35.91.62", - "21.35.91.61", - "203.88.33.151", - "1ec:aa98:b0a6:d07c:590:18a0:8a33:2eb8", - ] - `); - /** then, make sure the object is structured properly */ + expect(parsed).toMatchInlineSnapshot(` - Object { - "111.52.174.2": Object { - "doc_count": 11, + Array [ + Object { + "docCount": 12, + "value": "52:ae76:5947:5e2a:551:fe6a:712a:c72", }, - "196.162.13.39": Object { - "doc_count": 10, + Object { + "docCount": 11, + "value": "111.52.174.2", }, - "1ec:aa98:b0a6:d07c:590:18a0:8a33:2eb8": Object { - "doc_count": 6, + Object { + "docCount": 10, + "value": "196.162.13.39", }, - "203.88.33.151": Object { - "doc_count": 7, + Object { + "docCount": 10, + "value": "f7a9:640b:b5a0:1219:8d75:ed94:3c3e:2e63", }, - "21.35.91.61": Object { - "doc_count": 8, + Object { + "docCount": 9, + "value": "23.216.241.120", }, - "21.35.91.62": Object { - "doc_count": 8, + Object { + "docCount": 9, + "value": "28c7:c9a4:42fd:16b0:4de5:e41e:28d9:9172", }, - "23.216.241.120": Object { - "doc_count": 9, + Object { + "docCount": 8, + "value": "21.35.91.62", }, - "28c7:c9a4:42fd:16b0:4de5:e41e:28d9:9172": Object { - "doc_count": 9, + Object { + "docCount": 8, + "value": "21.35.91.61", }, - "52:ae76:5947:5e2a:551:fe6a:712a:c72": Object { - "doc_count": 12, + Object { + "docCount": 7, + "value": "203.88.33.151", }, - "f7a9:640b:b5a0:1219:8d75:ed94:3c3e:2e63": Object { - "doc_count": 10, + Object { + "docCount": 6, + "value": "1ec:aa98:b0a6:d07c:590:18a0:8a33:2eb8", }, - } + ] `); }); }); diff --git a/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.ts b/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.ts index 3a302cf62d04b..3b69b2818b909 100644 --- a/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.ts +++ b/src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.ts @@ -51,11 +51,11 @@ const cheapSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregat }, }), parse: (rawEsResult) => ({ - suggestions: get(rawEsResult, 'aggregations.suggestions.buckets').reduce( - (suggestions: OptionsListSuggestions, suggestion: EsBucket) => { - return { ...suggestions, [suggestion.key]: { doc_count: suggestion.doc_count } }; + suggestions: get(rawEsResult, 'aggregations.suggestions.buckets')?.reduce( + (acc: OptionsListSuggestions, suggestion: EsBucket) => { + return [...acc, { value: suggestion.key, docCount: suggestion.doc_count }]; }, - {} + [] ), }), }, @@ -75,13 +75,10 @@ const cheapSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregat }), parse: (rawEsResult) => ({ suggestions: get(rawEsResult, 'aggregations.suggestions.buckets')?.reduce( - (suggestions: OptionsListSuggestions, suggestion: EsBucket & { key_as_string: string }) => { - return { - ...suggestions, - [suggestion.key_as_string]: { doc_count: suggestion.doc_count }, - }; + (acc: OptionsListSuggestions, suggestion: EsBucket & { key_as_string: string }) => { + return [...acc, { value: suggestion.key_as_string, docCount: suggestion.doc_count }]; }, - {} + [] ), }), }, @@ -134,7 +131,7 @@ const cheapSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregat if (!Boolean(rawEsResult.aggregations?.suggestions)) { // if this is happens, that means there is an invalid search that snuck through to the server side code; // so, might as well early return with no suggestions - return { suggestions: {} }; + return { suggestions: [] }; } const buckets: EsBucket[] = []; @@ -153,9 +150,9 @@ const cheapSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregat return { suggestions: sortedSuggestions .slice(0, 10) // only return top 10 results - .reduce((suggestions, suggestion: EsBucket) => { - return { ...suggestions, [suggestion.key]: { doc_count: suggestion.doc_count } }; - }, {}), + .reduce((acc: OptionsListSuggestions, suggestion: EsBucket) => { + return [...acc, { value: suggestion.key, docCount: suggestion.doc_count }]; + }, []), }; }, }, @@ -190,11 +187,11 @@ const cheapSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggregat }; }, parse: (rawEsResult) => ({ - suggestions: get(rawEsResult, 'aggregations.nestedSuggestions.suggestions.buckets').reduce( - (suggestions: OptionsListSuggestions, suggestion: EsBucket) => { - return { ...suggestions, [suggestion.key]: { doc_count: suggestion.doc_count } }; + suggestions: get(rawEsResult, 'aggregations.nestedSuggestions.suggestions.buckets')?.reduce( + (acc: OptionsListSuggestions, suggestion: EsBucket) => { + return [...acc, { value: suggestion.key, docCount: suggestion.doc_count }]; }, - {} + [] ), }), }, diff --git a/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.test.ts b/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.test.ts index 7026359e10ee4..5638cbc347366 100644 --- a/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.test.ts +++ b/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.test.ts @@ -466,17 +466,20 @@ describe('options list expensive queries', () => { expect(suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock)) .toMatchInlineSnapshot(` Object { - "suggestions": Object { - "cool1": Object { - "doc_count": 5, + "suggestions": Array [ + Object { + "docCount": 5, + "value": "cool1", }, - "cool2": Object { - "doc_count": 15, + Object { + "docCount": 15, + "value": "cool2", }, - "cool3": Object { - "doc_count": 10, + Object { + "docCount": 10, + "value": "cool3", }, - }, + ], "totalCardinality": 3, } `); @@ -503,14 +506,16 @@ describe('options list expensive queries', () => { expect(suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock)) .toMatchInlineSnapshot(` Object { - "suggestions": Object { - "false": Object { - "doc_count": 55, + "suggestions": Array [ + Object { + "docCount": 55, + "value": "false", }, - "true": Object { - "doc_count": 155, + Object { + "docCount": 155, + "value": "true", }, - }, + ], "totalCardinality": 2, } `); @@ -546,17 +551,20 @@ describe('options list expensive queries', () => { expect(suggestionAggBuilder.parse(rawSearchResponseMock, optionsListRequestBodyMock)) .toMatchInlineSnapshot(` Object { - "suggestions": Object { - "cool1": Object { - "doc_count": 5, + "suggestions": Array [ + Object { + "docCount": 5, + "value": "cool1", }, - "cool2": Object { - "doc_count": 15, + Object { + "docCount": 15, + "value": "cool2", }, - "cool3": Object { - "doc_count": 10, + Object { + "docCount": 10, + "value": "cool3", }, - }, + ], "totalCardinality": 3, } `); @@ -621,55 +629,50 @@ describe('options list expensive queries', () => { rawSearchResponseMock, optionsListRequestBodyMock ).suggestions; - /** first, verify that the sorting worked as expected */ - expect(Object.keys(parsed)).toMatchInlineSnapshot(` - Array [ - "52:ae76:5947:5e2a:551:fe6a:712a:c72", - "111.52.174.2", - "196.162.13.39", - "f7a9:640b:b5a0:1219:8d75:ed94:3c3e:2e63", - "23.216.241.120", - "28c7:c9a4:42fd:16b0:4de5:e41e:28d9:9172", - "21.35.91.62", - "21.35.91.61", - "203.88.33.151", - "1ec:aa98:b0a6:d07c:590:18a0:8a33:2eb8", - ] - `); - /** then, make sure the object is structured properly */ + expect(parsed).toMatchInlineSnapshot(` - Object { - "111.52.174.2": Object { - "doc_count": 11, + Array [ + Object { + "docCount": 12, + "value": "52:ae76:5947:5e2a:551:fe6a:712a:c72", }, - "196.162.13.39": Object { - "doc_count": 10, + Object { + "docCount": 11, + "value": "111.52.174.2", }, - "1ec:aa98:b0a6:d07c:590:18a0:8a33:2eb8": Object { - "doc_count": 6, + Object { + "docCount": 10, + "value": "196.162.13.39", }, - "203.88.33.151": Object { - "doc_count": 7, + Object { + "docCount": 10, + "value": "f7a9:640b:b5a0:1219:8d75:ed94:3c3e:2e63", }, - "21.35.91.61": Object { - "doc_count": 8, + Object { + "docCount": 9, + "value": "23.216.241.120", }, - "21.35.91.62": Object { - "doc_count": 8, + Object { + "docCount": 9, + "value": "28c7:c9a4:42fd:16b0:4de5:e41e:28d9:9172", }, - "23.216.241.120": Object { - "doc_count": 9, + Object { + "docCount": 8, + "value": "21.35.91.62", }, - "28c7:c9a4:42fd:16b0:4de5:e41e:28d9:9172": Object { - "doc_count": 9, + Object { + "docCount": 8, + "value": "21.35.91.61", }, - "52:ae76:5947:5e2a:551:fe6a:712a:c72": Object { - "doc_count": 12, + Object { + "docCount": 7, + "value": "203.88.33.151", }, - "f7a9:640b:b5a0:1219:8d75:ed94:3c3e:2e63": Object { - "doc_count": 10, + Object { + "docCount": 6, + "value": "1ec:aa98:b0a6:d07c:590:18a0:8a33:2eb8", }, - } + ] `); }); }); diff --git a/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.ts b/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.ts index 63347f8d436d3..a1114191d1fa8 100644 --- a/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.ts +++ b/src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.ts @@ -93,9 +93,9 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr const suggestions = get(rawEsResult, `${basePath}.suggestions.buckets`)?.reduce( (acc: OptionsListSuggestions, suggestion: EsBucket) => { - return { ...acc, [suggestion.key]: { doc_count: suggestion.doc_count } }; + return [...acc, { value: suggestion.key, docCount: suggestion.doc_count }]; }, - {} + [] ); return { suggestions, @@ -120,14 +120,11 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr parse: (rawEsResult) => { const suggestions = get(rawEsResult, 'aggregations.suggestions.buckets')?.reduce( (acc: OptionsListSuggestions, suggestion: EsBucket & { key_as_string: string }) => { - return { - ...acc, - [suggestion.key_as_string]: { doc_count: suggestion.doc_count }, - }; + return [...acc, { value: suggestion.key_as_string, docCount: suggestion.doc_count }]; }, - {} + [] ); - return { suggestions, totalCardinality: Object.keys(suggestions).length }; // cardinality is only ever 0, 1, or 2 so safe to use length here + return { suggestions, totalCardinality: suggestions.length }; // cardinality is only ever 0, 1, or 2 so safe to use length here }, }, @@ -185,7 +182,7 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr if (!Boolean(rawEsResult.aggregations?.suggestions)) { // if this is happens, that means there is an invalid search that snuck through to the server side code; // so, might as well early return with no suggestions - return { suggestions: {}, totalCardinality: 0 }; + return { suggestions: [], totalCardinality: 0 }; } const buckets: EsBucket[] = []; getIpBuckets(rawEsResult, buckets, 'ipv4'); // modifies buckets array directly, i.e. "by reference" @@ -200,11 +197,11 @@ const expensiveSuggestionAggSubtypes: { [key: string]: OptionsListSuggestionAggr (bucketA: EsBucket, bucketB: EsBucket) => bucketB.doc_count - bucketA.doc_count ); - const suggestions: OptionsListSuggestions = sortedSuggestions + const suggestions = sortedSuggestions .slice(0, request.size) .reduce((acc: OptionsListSuggestions, suggestion: EsBucket) => { - return { ...acc, [suggestion.key]: { doc_count: suggestion.doc_count } }; - }, {}); + return [...acc, { value: suggestion.key, docCount: suggestion.doc_count }]; + }, []); const totalCardinality = (get(rawEsResult, `aggregations.suggestions.buckets.ipv4.unique_terms.value`) ?? 0) + (get(rawEsResult, `aggregations.suggestions.buckets.ipv6.unique_terms.value`) ?? 0); From 6ec97802d97d54599546e58374866a389ec412ed Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 26 Apr 2023 19:44:22 +0100 Subject: [PATCH 15/29] skip failing version bump suite (#155924) --- x-pack/test/accessibility/apps/ingest_node_pipelines.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/ingest_node_pipelines.ts b/x-pack/test/accessibility/apps/ingest_node_pipelines.ts index 4bbd9cde06d2d..a2aa7e3c860fe 100644 --- a/x-pack/test/accessibility/apps/ingest_node_pipelines.ts +++ b/x-pack/test/accessibility/apps/ingest_node_pipelines.ts @@ -14,7 +14,8 @@ export default function ({ getService, getPageObjects }: any) { const log = getService('log'); const a11y = getService('a11y'); /* this is the wrapping service around axe */ - describe('Ingest Pipelines Accessibility', async () => { + // FAILING VERSION BUMP: https://github.com/elastic/kibana/issues/155924 + describe.skip('Ingest Pipelines Accessibility', async () => { before(async () => { await putSamplePipeline(esClient); await common.navigateToApp('ingestPipelines'); From 5e713fb225f5cd2312878c515f61f7f01b4a5e49 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 26 Apr 2023 14:45:25 -0400 Subject: [PATCH 16/29] [Fleet] Add unit test for fleet agent id verification config flag (#155720) --- .../elasticsearch/template/template.test.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index 8ef565ccd320a..5668ebacd5258 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -17,6 +17,10 @@ import { appContextService } from '../../..'; import type { RegistryDataStream } from '../../../../types'; import { processFields } from '../../fields/field'; import type { Field } from '../../fields/field'; +import { + FLEET_COMPONENT_TEMPLATES, + FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME, +} from '../../../../constants'; import { generateMappings, @@ -26,7 +30,9 @@ import { updateCurrentWriteIndices, } from './template'; -const FLEET_COMPONENT_TEMPLATES = ['.fleet_globals-1', '.fleet_agent_id_verification-1']; +const FLEET_COMPONENT_TEMPLATES_NAMES = FLEET_COMPONENT_TEMPLATES.map( + (componentTemplate) => componentTemplate.name +); // Add our own serialiser to just do JSON.stringify expect.addSnapshotSerializer({ @@ -69,7 +75,28 @@ describe('EPM template', () => { }); expect(template.composed_of).toStrictEqual([ ...composedOfTemplates, - ...FLEET_COMPONENT_TEMPLATES, + ...FLEET_COMPONENT_TEMPLATES_NAMES, + ]); + }); + + it('does not create fleet agent id verification component template if agentIdVerification is disabled', () => { + appContextService.start( + createAppContextStartContractMock({ + agentIdVerificationEnabled: false, + }) + ); + const composedOfTemplates = ['component1', 'component2']; + + const template = getTemplate({ + templateIndexPattern: 'name-*', + packageName: 'nginx', + composedOfTemplates, + templatePriority: 200, + mappings: { properties: [] }, + }); + expect(template.composed_of).toStrictEqual([ + ...composedOfTemplates, + FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME, ]); }); @@ -83,7 +110,7 @@ describe('EPM template', () => { templatePriority: 200, mappings: { properties: [] }, }); - expect(template.composed_of).toStrictEqual(FLEET_COMPONENT_TEMPLATES); + expect(template.composed_of).toStrictEqual(FLEET_COMPONENT_TEMPLATES_NAMES); }); it('adds hidden field correctly', () => { From 8e37b3841701e0343f265e544920697f1f6a8f59 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Wed, 26 Apr 2023 14:48:23 -0400 Subject: [PATCH 17/29] [serverless] Create the Serverless Plugin (#155582) > Derived from https://github.com/elastic/kibana/pull/153274 for production. ## Summary This PR creates the `serverless` plugin for Kibana Serverless projects. ![image](https://user-images.githubusercontent.com/297604/233892935-b3713575-a2f7-4e82-a9dd-e8c11823683f.png) It uses the methodology proven out in the proof-of-concept (https://github.com/elastic/kibana/pull/153274) and prepares it for production: - Adds chrome style and related API to the `chrome` services. - Creates the `serverless` plugin. - Invokes the new chrome style API for all serverless projects. - Alters `yarn` scripts to support all project types, and switching between them. - Creates the new "Project Switcher" component for use in the new chrome header for Serverless. - Creates a Storybook config for this and future components. - Adds API endpoint to trigger project switching and `Watcher` restarts. Screenshot 2023-04-26 at 10 44 01 AM ## Next steps - [x] Creating a PR for enabling/disabling related plugins for Serverless. (https://github.com/elastic/kibana/pull/155583) - [ ] Creating product plugin PR based on https://github.com/elastic/kibana/pull/153274. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../steps/storybooks/build_and_upload.ts | 1 + .github/CODEOWNERS | 4 + .i18nrc.json | 1 + config/serverless.es.yml | 1 + config/serverless.oblt.yml | 1 + config/serverless.security.yml | 1 + config/serverless.yml | 2 + docs/developer/plugin-list.asciidoc | 4 + package.json | 5 + .../src/chrome_service.test.ts | 2 + .../src/chrome_service.tsx | 99 ++++++++---- .../src/ui/index.ts | 1 + .../src/ui/project/header.tsx | 90 +++++++++++ .../src/ui/project/index.ts | 9 ++ .../src/ui/project/navigation.tsx | 76 +++++++++ .../tsconfig.json | 5 +- .../src/chrome_service.mock.ts | 2 + .../core/chrome/core-chrome-browser/index.ts | 27 ++-- .../core-chrome-browser/src/contracts.ts | 13 +- .../chrome/core-chrome-browser/src/index.ts | 2 +- .../chrome/core-chrome-browser/src/types.ts | 3 + packages/kbn-optimizer/limits.yml | 1 + .../serverless/project_switcher/README.mdx | 12 ++ packages/serverless/project_switcher/index.ts | 11 ++ .../project_switcher/jest.config.js | 13 ++ .../serverless/project_switcher/kibana.jsonc | 5 + .../project_switcher/mocks/jest.mock.ts | 23 +++ .../project_switcher/mocks/storybook.mock.ts | 51 ++++++ .../serverless/project_switcher/package.json | 6 + .../project_switcher/src/constants.ts | 24 +++ .../project_switcher/src/header_button.tsx | 31 ++++ .../serverless/project_switcher/src/index.ts | 12 ++ .../serverless/project_switcher/src/item.tsx | 33 ++++ .../project_switcher/src/loader.tsx | 21 +++ .../serverless/project_switcher/src/logo.tsx | 32 ++++ .../project_switcher/src/services.tsx | 63 ++++++++ .../src/switcher.component.tsx | 73 +++++++++ .../project_switcher/src/switcher.stories.tsx | 42 +++++ .../project_switcher/src/switcher.test.tsx | 151 ++++++++++++++++++ .../project_switcher/src/switcher.tsx | 21 +++ .../serverless/project_switcher/src/types.ts | 39 +++++ .../serverless/project_switcher/tsconfig.json | 23 +++ .../serverless/storybook/config/README.mdx | 5 + .../serverless/storybook/config/constants.ts | 13 ++ packages/serverless/storybook/config/index.ts | 9 ++ .../serverless/storybook/config/kibana.jsonc | 6 + packages/serverless/storybook/config/main.ts | 17 ++ .../serverless/storybook/config/manager.ts | 23 +++ .../serverless/storybook/config/package.json | 6 + .../serverless/storybook/config/preview.ts | 22 +++ .../serverless/storybook/config/tsconfig.json | 19 +++ packages/serverless/types/README.mdx | 10 ++ packages/serverless/types/index.d.ts | 9 ++ packages/serverless/types/kibana.jsonc | 5 + packages/serverless/types/package.json | 6 + packages/serverless/types/tsconfig.json | 17 ++ src/cli/serve/serve.js | 50 +++++- src/core/public/styles/rendering/_base.scss | 3 + src/dev/storybook/aliases.ts | 1 + .../test_suites/core_plugins/rendering.ts | 2 + tsconfig.base.json | 8 + x-pack/.i18nrc.json | 1 + x-pack/plugins/security/public/config.ts | 1 + .../nav_control/nav_control_service.tsx | 7 +- x-pack/plugins/security/public/plugin.tsx | 1 + x-pack/plugins/security/server/config.test.ts | 6 + x-pack/plugins/security/server/config.ts | 2 + x-pack/plugins/security/server/index.ts | 1 + x-pack/plugins/serverless/README.mdx | 22 +++ x-pack/plugins/serverless/assets/diagram.png | Bin 0 -> 438787 bytes x-pack/plugins/serverless/common/index.ts | 12 ++ x-pack/plugins/serverless/kibana.jsonc | 21 +++ x-pack/plugins/serverless/package.json | 11 ++ x-pack/plugins/serverless/public/config.ts | 15 ++ x-pack/plugins/serverless/public/index.ts | 15 ++ x-pack/plugins/serverless/public/plugin.tsx | 65 ++++++++ x-pack/plugins/serverless/public/types.ts | 12 ++ x-pack/plugins/serverless/server/config.ts | 58 +++++++ x-pack/plugins/serverless/server/index.ts | 16 ++ x-pack/plugins/serverless/server/plugin.ts | 92 +++++++++++ x-pack/plugins/serverless/server/types.ts | 12 ++ x-pack/plugins/serverless/tsconfig.json | 24 +++ yarn.lock | 16 ++ 83 files changed, 1621 insertions(+), 56 deletions(-) create mode 100644 packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx create mode 100644 packages/core/chrome/core-chrome-browser-internal/src/ui/project/index.ts create mode 100644 packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx create mode 100644 packages/serverless/project_switcher/README.mdx create mode 100644 packages/serverless/project_switcher/index.ts create mode 100644 packages/serverless/project_switcher/jest.config.js create mode 100644 packages/serverless/project_switcher/kibana.jsonc create mode 100644 packages/serverless/project_switcher/mocks/jest.mock.ts create mode 100644 packages/serverless/project_switcher/mocks/storybook.mock.ts create mode 100644 packages/serverless/project_switcher/package.json create mode 100644 packages/serverless/project_switcher/src/constants.ts create mode 100644 packages/serverless/project_switcher/src/header_button.tsx create mode 100644 packages/serverless/project_switcher/src/index.ts create mode 100644 packages/serverless/project_switcher/src/item.tsx create mode 100644 packages/serverless/project_switcher/src/loader.tsx create mode 100644 packages/serverless/project_switcher/src/logo.tsx create mode 100644 packages/serverless/project_switcher/src/services.tsx create mode 100644 packages/serverless/project_switcher/src/switcher.component.tsx create mode 100644 packages/serverless/project_switcher/src/switcher.stories.tsx create mode 100644 packages/serverless/project_switcher/src/switcher.test.tsx create mode 100644 packages/serverless/project_switcher/src/switcher.tsx create mode 100644 packages/serverless/project_switcher/src/types.ts create mode 100644 packages/serverless/project_switcher/tsconfig.json create mode 100644 packages/serverless/storybook/config/README.mdx create mode 100644 packages/serverless/storybook/config/constants.ts create mode 100755 packages/serverless/storybook/config/index.ts create mode 100644 packages/serverless/storybook/config/kibana.jsonc create mode 100644 packages/serverless/storybook/config/main.ts create mode 100644 packages/serverless/storybook/config/manager.ts create mode 100644 packages/serverless/storybook/config/package.json create mode 100644 packages/serverless/storybook/config/preview.ts create mode 100644 packages/serverless/storybook/config/tsconfig.json create mode 100644 packages/serverless/types/README.mdx create mode 100644 packages/serverless/types/index.d.ts create mode 100644 packages/serverless/types/kibana.jsonc create mode 100644 packages/serverless/types/package.json create mode 100644 packages/serverless/types/tsconfig.json create mode 100755 x-pack/plugins/serverless/README.mdx create mode 100644 x-pack/plugins/serverless/assets/diagram.png create mode 100644 x-pack/plugins/serverless/common/index.ts create mode 100644 x-pack/plugins/serverless/kibana.jsonc create mode 100644 x-pack/plugins/serverless/package.json create mode 100644 x-pack/plugins/serverless/public/config.ts create mode 100644 x-pack/plugins/serverless/public/index.ts create mode 100644 x-pack/plugins/serverless/public/plugin.tsx create mode 100644 x-pack/plugins/serverless/public/types.ts create mode 100644 x-pack/plugins/serverless/server/config.ts create mode 100644 x-pack/plugins/serverless/server/index.ts create mode 100644 x-pack/plugins/serverless/server/plugin.ts create mode 100644 x-pack/plugins/serverless/server/types.ts create mode 100644 x-pack/plugins/serverless/tsconfig.json diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.ts b/.buildkite/scripts/steps/storybooks/build_and_upload.ts index b16e75abdb8a1..3796076d0a3cd 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.ts +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.ts @@ -43,6 +43,7 @@ const STORYBOOKS = [ 'observability', 'presentation', 'security_solution', + 'serverless', 'shared_ux', 'triggers_actions_ui', 'ui_actions_enhanced', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 93f49f1277ac7..d76ed31b2b2b1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -572,6 +572,10 @@ packages/kbn-securitysolution-t-grid @elastic/security-solution-platform packages/kbn-securitysolution-utils @elastic/security-solution-platform packages/kbn-server-http-tools @elastic/kibana-core packages/kbn-server-route-repository @elastic/apm-ui +x-pack/plugins/serverless @elastic/appex-sharedux +packages/serverless/project_switcher @elastic/appex-sharedux +packages/serverless/storybook/config @elastic/appex-sharedux +packages/serverless/types @elastic/appex-sharedux test/plugin_functional/plugins/session_notifications @elastic/kibana-core x-pack/plugins/session_view @elastic/sec-cloudnative-integrations packages/kbn-set-map @elastic/kibana-operations diff --git a/.i18nrc.json b/.i18nrc.json index 31ab7a91e206c..c2bf494c68c06 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -83,6 +83,7 @@ "share": "src/plugins/share", "sharedUXPackages": "packages/shared-ux", "securitySolutionPackages": "x-pack/packages/security-solution", + "serverlessPackages": "packages/serverless", "coloring": "packages/kbn-coloring/src", "languageDocumentationPopover": "packages/kbn-language-documentation-popover/src", "statusPage": "src/legacy/core_plugins/status_page", diff --git a/config/serverless.es.yml b/config/serverless.es.yml index e69de29bb2d1d..71b4f03446401 100644 --- a/config/serverless.es.yml +++ b/config/serverless.es.yml @@ -0,0 +1 @@ +xpack.serverless.plugin.developer.projectSwitcher.currentType: 'search' diff --git a/config/serverless.oblt.yml b/config/serverless.oblt.yml index ba76648238348..ddf1066edb882 100644 --- a/config/serverless.oblt.yml +++ b/config/serverless.oblt.yml @@ -1 +1,2 @@ xpack.infra.logs.app_target: discover +xpack.serverless.plugin.developer.projectSwitcher.currentType: 'observability' diff --git a/config/serverless.security.yml b/config/serverless.security.yml index e69de29bb2d1d..efa3558e0e9d9 100644 --- a/config/serverless.security.yml +++ b/config/serverless.security.yml @@ -0,0 +1 @@ +xpack.serverless.plugin.developer.projectSwitcher.currentType: 'security' diff --git a/config/serverless.yml b/config/serverless.yml index e65b15f064328..ec24139422975 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -1,4 +1,6 @@ newsfeed.enabled: false +xpack.security.showNavLinks: false +xpack.serverless.plugin.enabled: true xpack.fleet.enableExperimental: ['fleetServerStandalone'] xpack.fleet.internal.disableILMPolicies: true diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 6b534f45b4f2d..412a9e8b5569e 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -706,6 +706,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/serverless/README.mdx[serverless] +| + + |{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. diff --git a/package.json b/package.json index e6761869fa3c7..0751d8d84ea09 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "lint:es": "node scripts/eslint", "lint:style": "node scripts/stylelint", "makelogs": "node scripts/makelogs", + "serverless": "node scripts/kibana --dev --serverless", "serverless-es": "node scripts/kibana --dev --serverless=es", "serverless-oblt": "node scripts/kibana --dev --serverless=oblt", "serverless-security": "node scripts/kibana --dev --serverless=security", @@ -573,6 +574,9 @@ "@kbn/securitysolution-utils": "link:packages/kbn-securitysolution-utils", "@kbn/server-http-tools": "link:packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", + "@kbn/serverless": "link:x-pack/plugins/serverless", + "@kbn/serverless-project-switcher": "link:packages/serverless/project_switcher", + "@kbn/serverless-types": "link:packages/serverless/types", "@kbn/session-notifications-plugin": "link:test/plugin_functional/plugins/session_notifications", "@kbn/session-view-plugin": "link:x-pack/plugins/session_view", "@kbn/set-map": "link:packages/kbn-set-map", @@ -1112,6 +1116,7 @@ "@kbn/repo-source-classifier": "link:packages/kbn-repo-source-classifier", "@kbn/repo-source-classifier-cli": "link:packages/kbn-repo-source-classifier-cli", "@kbn/security-api-integration-helpers": "link:x-pack/test/security_api_integration/packages/helpers", + "@kbn/serverless-storybook-config": "link:packages/serverless/storybook/config", "@kbn/some-dev-log": "link:packages/kbn-some-dev-log", "@kbn/sort-package-json": "link:packages/kbn-sort-package-json", "@kbn/spec-to-console": "link:packages/kbn-spec-to-console", diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.ts b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.ts index 37b1b9a2eab7d..0087c5d019f98 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.ts @@ -126,6 +126,7 @@ describe('start', () => { Array [ Array [ "kbnBody", + "kbnBody--classicLayout", "kbnBody--noHeaderBanner", "kbnBody--chromeHidden", "kbnVersion-1-2-3", @@ -143,6 +144,7 @@ describe('start', () => { Array [ Array [ "kbnBody", + "kbnBody--classicLayout", "kbnBody--noHeaderBanner", "kbnBody--chromeHidden", "kbnVersion-8-0-0", diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx index 4e0762aee8620..41362b3d80dcd 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx @@ -26,6 +26,7 @@ import type { ChromeGlobalHelpExtensionMenuLink, ChromeHelpExtension, ChromeUserBanner, + ChromeStyle, } from '@kbn/core-chrome-browser'; import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser'; import { KIBANA_ASK_ELASTIC_LINK } from './constants'; @@ -33,7 +34,7 @@ import { DocTitleService } from './doc_title'; import { NavControlsService } from './nav_controls'; import { NavLinksService } from './nav_links'; import { RecentlyAccessedService } from './recently_accessed'; -import { Header } from './ui'; +import { Header, ProjectHeader } from './ui'; import type { InternalChromeStart } from './types'; const IS_LOCKED_KEY = 'core.chrome.isLocked'; @@ -119,6 +120,7 @@ export class ChromeService { const customNavLink$ = new BehaviorSubject(undefined); const helpSupportUrl$ = new BehaviorSubject(KIBANA_ASK_ELASTIC_LINK); const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true'); + const chromeStyle$ = new BehaviorSubject('classic'); const getKbnVersionClass = () => { // we assume that the version is valid and has the form 'X.X.X' @@ -131,10 +133,11 @@ export class ChromeService { }; const headerBanner$ = new BehaviorSubject(undefined); - const bodyClasses$ = combineLatest([headerBanner$, this.isVisible$!]).pipe( - map(([headerBanner, isVisible]) => { + const bodyClasses$ = combineLatest([headerBanner$, this.isVisible$!, chromeStyle$]).pipe( + map(([headerBanner, isVisible, chromeStyle]) => { return [ 'kbnBody', + chromeStyle === 'project' ? 'kbnBody--projectLayout' : 'kbnBody--classicLayout', headerBanner ? 'kbnBody--hasHeaderBanner' : 'kbnBody--noHeaderBanner', isVisible ? 'kbnBody--chromeVisible' : 'kbnBody--chromeHidden', getKbnVersionClass(), @@ -163,6 +166,10 @@ export class ChromeService { const getIsNavDrawerLocked$ = isNavDrawerLocked$.pipe(takeUntil(this.stop$)); + const setChromeStyle = (style: ChromeStyle) => { + chromeStyle$.next(style); + }; + const isIE = () => { const ua = window.navigator.userAgent; const msie = ua.indexOf('MSIE '); // IE 10 or older @@ -203,41 +210,65 @@ export class ChromeService { }); } + const getHeaderComponent = () => { + const Component = ({ style$ }: { style$: typeof chromeStyle$ }) => { + if (style$.getValue() === 'project') { + return ( + + ); + } + + return ( +
+ ); + }; + return ; + }; + return { navControls, navLinks, recentlyAccessed, docTitle, - - getHeaderComponent: () => ( -
- ), + getHeaderComponent, getIsVisible$: () => this.isVisible$, @@ -302,6 +333,8 @@ export class ChromeService { }, getBodyClasses$: () => bodyClasses$.pipe(takeUntil(this.stop$)), + setChromeStyle, + getChromeStyle$: () => chromeStyle$.pipe(takeUntil(this.stop$)), }; } diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/index.ts b/packages/core/chrome/core-chrome-browser-internal/src/ui/index.ts index 5afd3e0f587bb..7a5ecadd26f23 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/index.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/index.ts @@ -7,5 +7,6 @@ */ export { Header } from './header'; +export { ProjectHeader } from './project'; export { LoadingIndicator } from './loading_indicator'; export type { NavType } from './header'; diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx new file mode 100644 index 0000000000000..e85ae262c3bb7 --- /dev/null +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { Router } from 'react-router-dom'; +import { EuiHeader, EuiHeaderLogo, EuiHeaderSection, EuiHeaderSectionItem } from '@elastic/eui'; +import { + ChromeBreadcrumb, + ChromeGlobalHelpExtensionMenuLink, + ChromeHelpExtension, + ChromeNavControl, +} from '@kbn/core-chrome-browser/src'; +import { Observable } from 'rxjs'; +import { MountPoint } from '@kbn/core-mount-utils-browser'; +import { InternalApplicationStart } from '@kbn/core-application-browser-internal'; +import { HeaderBreadcrumbs } from '../header/header_breadcrumbs'; +import { HeaderActionMenu } from '../header/header_action_menu'; +import { HeaderHelpMenu } from '../header/header_help_menu'; +import { HeaderNavControls } from '../header/header_nav_controls'; +import { ProjectNavigation } from './navigation'; + +interface Props { + breadcrumbs$: Observable; + actionMenu$: Observable; + kibanaDocLink: string; + globalHelpExtensionMenuLinks$: Observable; + helpExtension$: Observable; + helpSupportUrl$: Observable; + kibanaVersion: string; + application: InternalApplicationStart; + navControlsRight$: Observable; +} + +export const ProjectHeader = ({ + application, + kibanaDocLink, + kibanaVersion, + ...observables +}: Props) => { + const renderLogo = () => ( + e.preventDefault()} + aria-label="Go to home page" + /> + ); + + return ( + <> + + + {renderLogo()} + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/index.ts b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/index.ts new file mode 100644 index 0000000000000..af18e057731b0 --- /dev/null +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/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 { ProjectHeader } from './header'; diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx new file mode 100644 index 0000000000000..20549325ec851 --- /dev/null +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.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 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, { useCallback } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { css } from '@emotion/react'; + +import { i18n } from '@kbn/i18n'; +import { EuiButtonIcon, EuiCollapsibleNav, EuiThemeProvider, useEuiTheme } from '@elastic/eui'; + +const LOCAL_STORAGE_IS_OPEN_KEY = 'PROJECT_NAVIGATION_OPEN' as const; +const SIZE_OPEN = 248; +const SIZE_CLOSED = 40; + +const buttonCSS = css` + margin-left: -32px; + margin-top: 12px; + position: fixed; + z-index: 1000; +`; + +const openAriaLabel = i18n.translate('core.ui.chrome.projectNav.collapsibleNavOpenAriaLabel', { + defaultMessage: 'Close navigation', +}); + +const closedAriaLabel = i18n.translate('core.ui.chrome.projectNav.collapsibleNavClosedAriaLabel', { + defaultMessage: 'Open navigation', +}); + +export const ProjectNavigation: React.FC = ({ children }) => { + const { euiTheme, colorMode } = useEuiTheme(); + + const [isOpen, setIsOpen] = useLocalStorage(LOCAL_STORAGE_IS_OPEN_KEY, true); + + const toggleOpen = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen, setIsOpen]); + + const collabsibleNavCSS = css` + border-inline-end-width: 1, + background: ${euiTheme.colors.darkestShade}, + display: flex, + flex-direction: row, + `; + + return ( + + + + + } + > + {isOpen && children} + + + ); +}; diff --git a/packages/core/chrome/core-chrome-browser-internal/tsconfig.json b/packages/core/chrome/core-chrome-browser-internal/tsconfig.json index 4d4d6cad3bc21..cd27209bef12c 100644 --- a/packages/core/chrome/core-chrome-browser-internal/tsconfig.json +++ b/packages/core/chrome/core-chrome-browser-internal/tsconfig.json @@ -5,7 +5,10 @@ "types": [ "jest", "node", - "react" + "react", + "@kbn/ambient-ui-types", + "@kbn/ambient-storybook-types", + "@emotion/react/types/css-prop" ] }, "include": [ diff --git a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts index 2f5c4deb1f38d..c7c62c7811277 100644 --- a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts +++ b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts @@ -61,6 +61,8 @@ const createStartContractMock = () => { setHeaderBanner: jest.fn(), hasHeaderBanner$: jest.fn(), getBodyClasses$: jest.fn(), + getChromeStyle$: jest.fn(), + setChromeStyle: jest.fn(), }; startContract.navLinks.getAll.mockReturnValue([]); startContract.getIsVisible$.mockReturnValue(new BehaviorSubject(false)); diff --git a/packages/core/chrome/core-chrome-browser/index.ts b/packages/core/chrome/core-chrome-browser/index.ts index 3fbef34126a4a..1d2dca4c957bc 100644 --- a/packages/core/chrome/core-chrome-browser/index.ts +++ b/packages/core/chrome/core-chrome-browser/index.ts @@ -7,25 +7,26 @@ */ export type { - ChromeUserBanner, + ChromeBadge, ChromeBreadcrumb, + ChromeBreadcrumbsAppendExtension, + ChromeDocTitle, + ChromeGlobalHelpExtensionMenuLink, ChromeHelpExtension, - ChromeHelpExtensionMenuLink, ChromeHelpExtensionLinkBase, + ChromeHelpExtensionMenuCustomLink, + ChromeHelpExtensionMenuDiscussLink, + ChromeHelpExtensionMenuDocumentationLink, + ChromeHelpExtensionMenuGitHubLink, + ChromeHelpExtensionMenuLink, ChromeHelpMenuActions, - ChromeNavLink, - ChromeBreadcrumbsAppendExtension, - ChromeNavLinks, ChromeNavControl, ChromeNavControls, - ChromeBadge, - ChromeHelpExtensionMenuGitHubLink, - ChromeHelpExtensionMenuDocumentationLink, - ChromeHelpExtensionMenuDiscussLink, - ChromeHelpExtensionMenuCustomLink, - ChromeGlobalHelpExtensionMenuLink, - ChromeDocTitle, - ChromeStart, + ChromeNavLink, + ChromeNavLinks, ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, + ChromeStart, + ChromeStyle, + ChromeUserBanner, } from './src'; diff --git a/packages/core/chrome/core-chrome-browser/src/contracts.ts b/packages/core/chrome/core-chrome-browser/src/contracts.ts index a81d9c3c6338f..3f6f756e2d2b1 100644 --- a/packages/core/chrome/core-chrome-browser/src/contracts.ts +++ b/packages/core/chrome/core-chrome-browser/src/contracts.ts @@ -13,7 +13,7 @@ import type { ChromeDocTitle } from './doc_title'; import type { ChromeNavControls } from './nav_controls'; import type { ChromeHelpExtension } from './help_extension'; import type { ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension } from './breadcrumb'; -import type { ChromeBadge, ChromeUserBanner } from './types'; +import type { ChromeBadge, ChromeStyle, ChromeUserBanner } from './types'; import { ChromeGlobalHelpExtensionMenuLink } from './help_extension'; /** @@ -150,4 +150,15 @@ export interface ChromeStart { * Get an observable of the current header banner presence state. */ hasHeaderBanner$(): Observable; + + /** + * Sets the style type of the chrome. + * @param style The style type to apply to the chrome. + */ + setChromeStyle(style: ChromeStyle): void; + + /** + * Get an observable of the current style type of the chrome. + */ + getChromeStyle$(): Observable; } diff --git a/packages/core/chrome/core-chrome-browser/src/index.ts b/packages/core/chrome/core-chrome-browser/src/index.ts index 716af097fded7..89ba12d616d0e 100644 --- a/packages/core/chrome/core-chrome-browser/src/index.ts +++ b/packages/core/chrome/core-chrome-browser/src/index.ts @@ -26,4 +26,4 @@ export type { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, } from './recently_accessed'; -export type { ChromeBadge, ChromeUserBanner } from './types'; +export type { ChromeBadge, ChromeUserBanner, ChromeStyle } from './types'; diff --git a/packages/core/chrome/core-chrome-browser/src/types.ts b/packages/core/chrome/core-chrome-browser/src/types.ts index 81b8c32a1a04c..d4374687ff828 100644 --- a/packages/core/chrome/core-chrome-browser/src/types.ts +++ b/packages/core/chrome/core-chrome-browser/src/types.ts @@ -20,3 +20,6 @@ export interface ChromeBadge { export interface ChromeUserBanner { content: MountPoint; } + +/** @public */ +export type ChromeStyle = 'classic' | 'project'; diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 56259881447db..af6396c11e063 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -115,6 +115,7 @@ pageLoadAssetSize: searchprofiler: 67080 security: 65433 securitySolution: 66738 + serverless: 16573 sessionView: 77750 share: 71239 snapshotRestore: 79032 diff --git a/packages/serverless/project_switcher/README.mdx b/packages/serverless/project_switcher/README.mdx new file mode 100644 index 0000000000000..240988346458c --- /dev/null +++ b/packages/serverless/project_switcher/README.mdx @@ -0,0 +1,12 @@ +--- +id: serverless/components/ProjectSwitcher +slug: /serverless/components/project-switcher +title: Project Switcher +description: A popup which allows a developer to switch between project types on their dev server. +tags: ['serverless', 'component'] +date: 2023-04-23 +--- + +When working on Serverless instances of Kibana, developers likely want to switch between different project types to test changes. This Project Switcher is intended to be placed into the header bar by the Serverless plugin when the server is in development mode to allow "quick switching" between configurations. + +The connected component uses `http` to post a selection to a given API endpoint, intended to alter the YML configuration and trigger Watcher to restart the server. To that end, it will post its message to a given API endpoint and replace the content of `document.body`. The remainder of the process is left to the Serverless plugin. diff --git a/packages/serverless/project_switcher/index.ts b/packages/serverless/project_switcher/index.ts new file mode 100644 index 0000000000000..69148308099a5 --- /dev/null +++ b/packages/serverless/project_switcher/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 { ProjectSwitcherProps, KibanaDependencies } from './src'; + +export { ProjectSwitcher, ProjectSwitcherKibanaProvider, ProjectSwitcherProvider } from './src'; diff --git a/packages/serverless/project_switcher/jest.config.js b/packages/serverless/project_switcher/jest.config.js new file mode 100644 index 0000000000000..713bdaaedaca2 --- /dev/null +++ b/packages/serverless/project_switcher/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/packages/serverless/project_switcher'], +}; diff --git a/packages/serverless/project_switcher/kibana.jsonc b/packages/serverless/project_switcher/kibana.jsonc new file mode 100644 index 0000000000000..6e37bb95cafda --- /dev/null +++ b/packages/serverless/project_switcher/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/serverless-project-switcher", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/serverless/project_switcher/mocks/jest.mock.ts b/packages/serverless/project_switcher/mocks/jest.mock.ts new file mode 100644 index 0000000000000..935b89b63dd13 --- /dev/null +++ b/packages/serverless/project_switcher/mocks/jest.mock.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 { Services, KibanaDependencies } from '../src/types'; + +export const getProjectSwitcherServicesMock: () => jest.Mocked = () => ({ + setProjectType: jest.fn(), +}); + +export const getProjectSwitcherKibanaDependenciesMock: () => jest.Mocked = + () => ({ + coreStart: { + http: { + post: jest.fn(() => Promise.resolve({ data: {} })), + }, + }, + projectChangeAPIUrl: 'serverless/change_project', + }); diff --git a/packages/serverless/project_switcher/mocks/storybook.mock.ts b/packages/serverless/project_switcher/mocks/storybook.mock.ts new file mode 100644 index 0000000000000..08faa007c5eb5 --- /dev/null +++ b/packages/serverless/project_switcher/mocks/storybook.mock.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 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 } from '@storybook/addon-actions'; +import { AbstractStorybookMock } from '@kbn/shared-ux-storybook-mock'; + +import type { ProjectSwitcherProps, Services } from '../src/types'; + +type PropArguments = Pick; + +/** + * Storybook parameters provided from the controls addon. + */ +export type ProjectSwitcherStorybookParams = Record; + +/** + * Storybook mocks for the `NoDataCard` component. + */ +export class ProjectSwitcherStorybookMock extends AbstractStorybookMock< + ProjectSwitcherProps, + Services, + PropArguments, + {} +> { + propArguments = { + currentProjectType: { + control: { type: 'radio' }, + options: ['observability', 'security', 'search'], + defaultValue: 'observability', + }, + }; + serviceArguments = {}; + dependencies = []; + + getProps(params?: ProjectSwitcherStorybookParams): ProjectSwitcherProps { + return { + currentProjectType: this.getArgumentValue('currentProjectType', params), + }; + } + + getServices(_params: ProjectSwitcherStorybookParams): Services { + return { + setProjectType: action('setProjectType'), + }; + } +} diff --git a/packages/serverless/project_switcher/package.json b/packages/serverless/project_switcher/package.json new file mode 100644 index 0000000000000..5910a823783d5 --- /dev/null +++ b/packages/serverless/project_switcher/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/serverless-project-switcher", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/serverless/project_switcher/src/constants.ts b/packages/serverless/project_switcher/src/constants.ts new file mode 100644 index 0000000000000..e3a277bfc7953 --- /dev/null +++ b/packages/serverless/project_switcher/src/constants.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. + */ + +import type { IconType } from '@elastic/eui'; +import type { ProjectType } from '@kbn/serverless-types'; + +export const icons: Record = { + observability: 'logoObservability', + security: 'logoSecurity', + search: 'logoEnterpriseSearch', +} as const; + +export const labels: Record = { + observability: 'Observability', + security: 'Security', + search: 'Enterprise Search', +} as const; + +export const projectTypes: ProjectType[] = ['security', 'observability', 'search']; diff --git a/packages/serverless/project_switcher/src/header_button.tsx b/packages/serverless/project_switcher/src/header_button.tsx new file mode 100644 index 0000000000000..ee1bd0acc5888 --- /dev/null +++ b/packages/serverless/project_switcher/src/header_button.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, { MouseEventHandler } from 'react'; +import { EuiHeaderSectionItemButton, EuiIcon } from '@elastic/eui'; + +import { ProjectType } from '@kbn/serverless-types'; + +import { icons } from './constants'; + +export const TEST_ID = 'projectSwitcherButton'; + +export interface Props { + onClick: MouseEventHandler; + currentProjectType: ProjectType; +} + +export const HeaderButton = ({ onClick, currentProjectType }: Props) => ( + + + +); diff --git a/packages/serverless/project_switcher/src/index.ts b/packages/serverless/project_switcher/src/index.ts new file mode 100644 index 0000000000000..adb6beef6514f --- /dev/null +++ b/packages/serverless/project_switcher/src/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 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 { KibanaDependencies, ProjectSwitcherProps } from './types'; + +export { ProjectSwitcher } from './switcher'; +export { ProjectSwitcherKibanaProvider, ProjectSwitcherProvider } from './services'; diff --git a/packages/serverless/project_switcher/src/item.tsx b/packages/serverless/project_switcher/src/item.tsx new file mode 100644 index 0000000000000..71bca220c450a --- /dev/null +++ b/packages/serverless/project_switcher/src/item.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 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 { EuiIcon, EuiKeyPadMenuItem, type EuiIconProps } from '@elastic/eui'; +import { ProjectType } from '@kbn/serverless-types'; + +import { labels, icons } from './constants'; + +type OnChangeType = (id: string, value?: any) => void; + +interface ItemProps extends Pick { + type: ProjectType; + onChange: (type: ProjectType) => void; + isSelected: boolean; +} + +export const SwitcherItem = ({ type: id, onChange, isSelected }: ItemProps) => ( + + + +); diff --git a/packages/serverless/project_switcher/src/loader.tsx b/packages/serverless/project_switcher/src/loader.tsx new file mode 100644 index 0000000000000..854f6e6d9f61b --- /dev/null +++ b/packages/serverless/project_switcher/src/loader.tsx @@ -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 React from 'react'; + +import { Logo, type Props } from './logo'; + +export const Loader = (props: Props) => ( +
+
+ +
Loading Project
+
+
+
+); diff --git a/packages/serverless/project_switcher/src/logo.tsx b/packages/serverless/project_switcher/src/logo.tsx new file mode 100644 index 0000000000000..e0d827b18f4f2 --- /dev/null +++ b/packages/serverless/project_switcher/src/logo.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { EuiIcon } from '@elastic/eui'; +import type { ProjectType } from '@kbn/serverless-types'; + +export interface Props { + project: ProjectType; +} + +export const Logo = ({ project }: Props) => { + let type = 'logoElastic'; + switch (project) { + case 'search': + type = 'logoElasticsearch'; + break; + case 'security': + type = 'logoSecurity'; + break; + case 'observability': + type = 'logoObservability'; + break; + } + + return ; +}; diff --git a/packages/serverless/project_switcher/src/services.tsx b/packages/serverless/project_switcher/src/services.tsx new file mode 100644 index 0000000000000..413de92d4f1ad --- /dev/null +++ b/packages/serverless/project_switcher/src/services.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useContext } from 'react'; +import ReactDOM from 'react-dom'; +import { Loader } from './loader'; + +import type { Services, KibanaDependencies } from './types'; + +const Context = React.createContext(null); + +/** + * A Context Provider that provides services to the component and its dependencies. + */ +export const ProjectSwitcherProvider: FC = ({ children, ...services }) => { + return {children}; +}; + +/** + * Kibana-specific Provider that maps dependencies to services. + */ +export const ProjectSwitcherKibanaProvider: FC = ({ + children, + coreStart, + projectChangeAPIUrl, +}) => { + const value: Services = { + setProjectType: (projectType) => { + coreStart.http + .post(projectChangeAPIUrl, { body: JSON.stringify({ id: projectType }) }) + .then(() => { + ReactDOM.render(, document.body); + + // Give the watcher a couple of seconds to see the file change. + setTimeout(() => { + window.location.href = '/'; + }, 2000); + }); + }, + }; + + return {children}; +}; + +/** + * React hook for accessing pre-wired services. + */ +export function useServices() { + const context = useContext(Context); + + if (!context) { + throw new Error( + 'ProjectSwitcher Context is missing. Ensure your component or React root is wrapped with ProjectSwitcherContext.' + ); + } + + return context; +} diff --git a/packages/serverless/project_switcher/src/switcher.component.tsx b/packages/serverless/project_switcher/src/switcher.component.tsx new file mode 100644 index 0000000000000..fd319f3839d6d --- /dev/null +++ b/packages/serverless/project_switcher/src/switcher.component.tsx @@ -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 React, { useState } from 'react'; +import { css } from '@emotion/react'; +import { EuiPopover, useGeneratedHtmlId, EuiPopoverTitle, EuiKeyPadMenu } from '@elastic/eui'; + +import { ProjectType } from '@kbn/serverless-types'; + +import { SwitcherItem } from './item'; +import type { ProjectSwitcherComponentProps } from './types'; +import { HeaderButton } from './header_button'; +import { projectTypes } from './constants'; + +export { TEST_ID as TEST_ID_BUTTON } from './header_button'; +export const TEST_ID_ITEM_GROUP = 'projectSwitcherItemGroup'; + +const switcherCSS = css` + min-width: 240px; +`; + +export const ProjectSwitcher = ({ + currentProjectType, + onProjectChange, +}: ProjectSwitcherComponentProps) => { + const [isOpen, setIsOpen] = useState(false); + const id = useGeneratedHtmlId({ + prefix: 'switcherPopover', + }); + + const closePopover = () => { + setIsOpen(false); + }; + + const onButtonClick = () => { + setIsOpen(!isOpen); + }; + + const onChange = (projectType: ProjectType) => { + closePopover(); + onProjectChange(projectType); + return false; + }; + + const items = projectTypes.map((type) => ( + + )); + + const button = ; + + return ( + + Switch Project Type + + {items} + + + ); +}; diff --git a/packages/serverless/project_switcher/src/switcher.stories.tsx b/packages/serverless/project_switcher/src/switcher.stories.tsx new file mode 100644 index 0000000000000..09bece7b00f27 --- /dev/null +++ b/packages/serverless/project_switcher/src/switcher.stories.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { + ProjectSwitcherStorybookMock, + type ProjectSwitcherStorybookParams, +} from '../mocks/storybook.mock'; + +import { ProjectSwitcher as Component } from './switcher'; +import { ProjectSwitcherProvider as Provider } from './services'; + +import mdx from '../README.mdx'; + +export default { + title: 'Developer/Project Switcher', + description: '', + parameters: { + docs: { + page: mdx, + }, + }, +}; + +const mock = new ProjectSwitcherStorybookMock(); +const argTypes = mock.getArgumentTypes(); + +export const ProjectSwitcher = (params: ProjectSwitcherStorybookParams) => { + return ( + + + + ); +}; + +ProjectSwitcher.argTypes = argTypes; diff --git a/packages/serverless/project_switcher/src/switcher.test.tsx b/packages/serverless/project_switcher/src/switcher.test.tsx new file mode 100644 index 0000000000000..5c4fac1d8c161 --- /dev/null +++ b/packages/serverless/project_switcher/src/switcher.test.tsx @@ -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 React from 'react'; +import { render, RenderResult, screen, within, waitFor, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ProjectType } from '@kbn/serverless-types'; + +import { ProjectSwitcherKibanaProvider, ProjectSwitcherProvider } from './services'; +import { + getProjectSwitcherKibanaDependenciesMock, + getProjectSwitcherServicesMock, +} from '../mocks/jest.mock'; +import { ProjectSwitcher } from './switcher'; +import { + ProjectSwitcher as ProjectSwitcherComponent, + TEST_ID_BUTTON, + TEST_ID_ITEM_GROUP, +} from './switcher.component'; +import { KibanaDependencies, Services } from './types'; + +const renderKibanaProjectSwitcher = ( + currentProjectType: ProjectType = 'observability' +): [RenderResult, jest.Mocked] => { + const mock = getProjectSwitcherKibanaDependenciesMock(); + return [ + render( + + + + ), + mock, + ]; +}; + +const renderProjectSwitcher = ( + currentProjectType: ProjectType = 'observability' +): [RenderResult, jest.Mocked] => { + const mock = getProjectSwitcherServicesMock(); + return [ + render( + + + + ), + mock, + ]; +}; + +describe('ProjectSwitcher', () => { + describe('Component', () => { + test('is rendered', () => { + expect(() => + render( + + ) + ).not.toThrowError(); + }); + }); + + describe('Connected Component', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + test("doesn't render if the Provider is missing", () => { + expect(() => render()).toThrowError(); + }); + + describe('with Services', () => { + test('is rendered', () => { + renderProjectSwitcher(); + const button = screen.queryByTestId(TEST_ID_BUTTON); + expect(button).not.toBeNull(); + }); + + test('opens', async () => { + renderProjectSwitcher(); + + let group = screen.queryByTestId(TEST_ID_ITEM_GROUP); + expect(group).toBeNull(); + + const button = screen.getByTestId(TEST_ID_BUTTON); + await waitFor(() => userEvent.click(button)); + + group = screen.queryByTestId(TEST_ID_ITEM_GROUP); + expect(group).not.toBeNull(); + }); + + test('calls setProjectType when clicked', async () => { + const [_, mock] = renderProjectSwitcher(); + + const button = screen.getByTestId(TEST_ID_BUTTON); + await waitFor(() => userEvent.click(button)); + + const group = screen.getByTestId(TEST_ID_ITEM_GROUP); + const project = await within(group).findByLabelText('Security'); + await waitFor(() => userEvent.click(project)); + + expect(mock.setProjectType).toHaveBeenCalled(); + }); + }); + }); + + describe('with Kibana Dependencies', () => { + beforeEach(() => { + cleanup(); + }); + + test('is rendered', () => { + renderKibanaProjectSwitcher(); + const button = screen.queryByTestId(TEST_ID_BUTTON); + expect(button).not.toBeNull(); + }); + + test('opens', async () => { + renderKibanaProjectSwitcher(); + + let group = screen.queryByTestId(TEST_ID_ITEM_GROUP); + expect(group).toBeNull(); + + const button = screen.getByTestId(TEST_ID_BUTTON); + userEvent.click(button); + + group = screen.queryByTestId(TEST_ID_ITEM_GROUP); + expect(group).not.toBeNull(); + }); + + test('posts message to change project', async () => { + const [_, mock] = renderKibanaProjectSwitcher(); + + const button = screen.getByTestId(TEST_ID_BUTTON); + await waitFor(() => userEvent.click(button)); + + const group = screen.getByTestId(TEST_ID_ITEM_GROUP); + const project = await within(group).findByLabelText('Security'); + await waitFor(() => userEvent.click(project)); + + expect(mock.coreStart.http.post).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/serverless/project_switcher/src/switcher.tsx b/packages/serverless/project_switcher/src/switcher.tsx new file mode 100644 index 0000000000000..4fdacf31987b6 --- /dev/null +++ b/packages/serverless/project_switcher/src/switcher.tsx @@ -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 React from 'react'; +import { ProjectType } from '@kbn/serverless-types'; +import { ProjectSwitcher as Component } from './switcher.component'; + +import { useServices } from './services'; +import type { ProjectSwitcherProps } from './types'; + +export const ProjectSwitcher = (props: ProjectSwitcherProps) => { + const { setProjectType } = useServices(); + const onProjectChange = (projectType: ProjectType) => setProjectType(projectType); + + return ; +}; diff --git a/packages/serverless/project_switcher/src/types.ts b/packages/serverless/project_switcher/src/types.ts new file mode 100644 index 0000000000000..5f0b8fcf55c15 --- /dev/null +++ b/packages/serverless/project_switcher/src/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ProjectType } from '@kbn/serverless-types'; + +/** + * A list of services that are consumed by this component. + */ +export interface Services { + setProjectType: (projectType: ProjectType) => void; +} + +/** + * An interface containing a collection of Kibana plugins and services required to + * render this component. + */ +export interface KibanaDependencies { + coreStart: { + http: { + post: (path: string, options: { body: string }) => Promise; + }; + }; + projectChangeAPIUrl: string; +} + +/** + * Props for the `ProjectSwitcher` pure component. + */ +export interface ProjectSwitcherComponentProps { + onProjectChange: (projectType: ProjectType) => void; + currentProjectType: ProjectType; +} + +export type ProjectSwitcherProps = Pick; diff --git a/packages/serverless/project_switcher/tsconfig.json b/packages/serverless/project_switcher/tsconfig.json new file mode 100644 index 0000000000000..8fd6b54236754 --- /dev/null +++ b/packages/serverless/project_switcher/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/shared-ux-storybook-mock", + "@kbn/serverless-types", + ] +} diff --git a/packages/serverless/storybook/config/README.mdx b/packages/serverless/storybook/config/README.mdx new file mode 100644 index 0000000000000..bba4efa56f3ae --- /dev/null +++ b/packages/serverless/storybook/config/README.mdx @@ -0,0 +1,5 @@ +# Serverless Storybook config + +This directory contains the configuration for the Storybook deployment for all Serverless component packages. + +For more information, refer to the [Storybook documentation](https://storybook.js.org/docs/react/configure/overview) and the `@kbn/storybook` package. diff --git a/packages/serverless/storybook/config/constants.ts b/packages/serverless/storybook/config/constants.ts new file mode 100644 index 0000000000000..be7ae8926447f --- /dev/null +++ b/packages/serverless/storybook/config/constants.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. + */ + +/** The title of the Storybook. */ +export const TITLE = 'Serverless Storybook'; + +/** The remote URL of the root from which Storybook loads stories for Serverless. */ +export const URL = 'https://github.com/elastic/kibana/tree/main/packages/serverless'; diff --git a/packages/serverless/storybook/config/index.ts b/packages/serverless/storybook/config/index.ts new file mode 100755 index 0000000000000..5a73da614bf27 --- /dev/null +++ b/packages/serverless/storybook/config/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 { TITLE, URL } from './constants'; diff --git a/packages/serverless/storybook/config/kibana.jsonc b/packages/serverless/storybook/config/kibana.jsonc new file mode 100644 index 0000000000000..a141e67afd745 --- /dev/null +++ b/packages/serverless/storybook/config/kibana.jsonc @@ -0,0 +1,6 @@ +{ + "type": "shared-common", + "id": "@kbn/serverless-storybook-config", + "owner": "@elastic/appex-sharedux", + "devOnly": true +} diff --git a/packages/serverless/storybook/config/main.ts b/packages/serverless/storybook/config/main.ts new file mode 100644 index 0000000000000..47a47a5a802b3 --- /dev/null +++ b/packages/serverless/storybook/config/main.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 { defaultConfig } from '@kbn/storybook'; + +module.exports = { + ...defaultConfig, + stories: ['../../**/*.stories.+(tsx|mdx)'], + reactOptions: { + strictMode: true, + }, +}; diff --git a/packages/serverless/storybook/config/manager.ts b/packages/serverless/storybook/config/manager.ts new file mode 100644 index 0000000000000..fb973258b9053 --- /dev/null +++ b/packages/serverless/storybook/config/manager.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 { addons } from '@storybook/addons'; +import { create } from '@storybook/theming'; +import { PANEL_ID as selectedPanel } from '@storybook/addon-actions'; + +import { TITLE as brandTitle, URL as brandUrl } from './constants'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle, + brandUrl, + }), + selectedPanel, + showPanel: true.valueOf, +}); diff --git a/packages/serverless/storybook/config/package.json b/packages/serverless/storybook/config/package.json new file mode 100644 index 0000000000000..3ff7ce7258729 --- /dev/null +++ b/packages/serverless/storybook/config/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/serverless-storybook-config", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/serverless/storybook/config/preview.ts b/packages/serverless/storybook/config/preview.ts new file mode 100644 index 0000000000000..ee65b88614fb9 --- /dev/null +++ b/packages/serverless/storybook/config/preview.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 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 @typescript-eslint/no-namespace,@typescript-eslint/no-empty-interface */ +declare global { + namespace NodeJS { + interface Global {} + interface InspectOptions {} + type ConsoleConstructor = console.ConsoleConstructor; + } +} + +/* eslint-enable */ +import jest from 'jest-mock'; + +/* @ts-expect-error TS doesn't see jest as a property of window, and I don't want to edit our global config. */ +window.jest = jest; diff --git a/packages/serverless/storybook/config/tsconfig.json b/packages/serverless/storybook/config/tsconfig.json new file mode 100644 index 0000000000000..1d676d9c2948d --- /dev/null +++ b/packages/serverless/storybook/config/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/storybook", + ] +} diff --git a/packages/serverless/types/README.mdx b/packages/serverless/types/README.mdx new file mode 100644 index 0000000000000..b12bc55becfc0 --- /dev/null +++ b/packages/serverless/types/README.mdx @@ -0,0 +1,10 @@ +--- +id: serverless/packages/types +slug: /serverless/packages/types +title: Serverless Typescript Types +description: A package of common types for Serverless projects. +tags: ['serverless', 'package'] +date: 2023-04-23 +--- + +This package contains common types for Serverless projects. \ No newline at end of file diff --git a/packages/serverless/types/index.d.ts b/packages/serverless/types/index.d.ts new file mode 100644 index 0000000000000..b384e7a59fbba --- /dev/null +++ b/packages/serverless/types/index.d.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 type ProjectType = 'observability' | 'security' | 'search'; diff --git a/packages/serverless/types/kibana.jsonc b/packages/serverless/types/kibana.jsonc new file mode 100644 index 0000000000000..0b5a8fffe84be --- /dev/null +++ b/packages/serverless/types/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/serverless-types", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/serverless/types/package.json b/packages/serverless/types/package.json new file mode 100644 index 0000000000000..c9b7b0810fdfb --- /dev/null +++ b/packages/serverless/types/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/serverless-types", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/serverless/types/tsconfig.json b/packages/serverless/types/tsconfig.json new file mode 100644 index 0000000000000..6d27b06d5f8ba --- /dev/null +++ b/packages/serverless/types/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 9facf94408235..46f4de867ed58 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -8,7 +8,7 @@ import { set as lodashSet } from '@kbn/safer-lodash-set'; import _ from 'lodash'; -import { statSync } from 'fs'; +import { statSync, copyFileSync, existsSync, readFileSync, writeFileSync } from 'fs'; import { resolve } from 'path'; import url from 'url'; @@ -29,7 +29,7 @@ function getServerlessProjectMode(opts) { return null; } - if (VALID_SERVERLESS_PROJECT_MODE.includes(opts.serverless)) { + if (VALID_SERVERLESS_PROJECT_MODE.includes(opts.serverless) || opts.serverless === true) { return opts.serverless; } @@ -115,6 +115,38 @@ function maybeAddConfig(name, configs, method) { } } +/** + * @param {string} file + * @param {'es' | 'security' | 'oblt' | true} mode + * @param {string[]} configs + * @param {'push' | 'unshift'} method + */ +function maybeSetRecentConfig(file, mode, configs, method) { + const path = resolve(getConfigDirectory(), file); + + try { + if (mode === true) { + if (!existsSync(path)) { + const data = readFileSync(path.replace('recent', 'es'), 'utf-8'); + writeFileSync( + path, + `${data}\nxpack.serverless.plugin.developer.projectSwitcher.enabled: true\n` + ); + } + } else { + copyFileSync(path.replace('recent', mode), path); + } + + configs[method](path); + } catch (err) { + if (err.code === 'ENOENT') { + return; + } + + throw err; + } +} + /** * @returns {string[]} */ @@ -255,7 +287,15 @@ export default function (program) { } if (isServerlessCapableDistribution()) { - command.option('--serverless ', 'Start Kibana in a serverless project mode'); + command + .option( + '--serverless', + 'Start Kibana in the most recent serverless project mode, (default is es)' + ) + .option( + '--serverless ', + 'Start Kibana in a specific serverless project mode' + ); } if (DEV_MODE_SUPPORTED) { @@ -285,7 +325,7 @@ export default function (program) { // we "unshift" .serverless. config so that it only overrides defaults if (serverlessMode) { maybeAddConfig(`serverless.yml`, configs, 'push'); - maybeAddConfig(`serverless.${serverlessMode}.yml`, configs, 'unshift'); + maybeSetRecentConfig('serverless.recent.yml', serverlessMode, configs, 'unshift'); } // .dev. configs are "pushed" so that they override all other config files @@ -293,7 +333,7 @@ export default function (program) { maybeAddConfig('kibana.dev.yml', configs, 'push'); if (serverlessMode) { maybeAddConfig(`serverless.dev.yml`, configs, 'push'); - maybeAddConfig(`serverless.${serverlessMode}.dev.yml`, configs, 'push'); + maybeSetRecentConfig('serverless.recent.dev.yml', serverlessMode, configs, 'unshift'); } } diff --git a/src/core/public/styles/rendering/_base.scss b/src/core/public/styles/rendering/_base.scss index 9d4296ca3b4ef..a9ece9955e6ca 100644 --- a/src/core/public/styles/rendering/_base.scss +++ b/src/core/public/styles/rendering/_base.scss @@ -75,6 +75,9 @@ &.kbnBody--chromeHidden { @include kbnAffordForHeader(0); } + &.kbnBody--projectLayout { + @include kbnAffordForHeader($euiHeaderHeightCompensation); + } &.kbnBody--chromeHidden.kbnBody--hasHeaderBanner { @include kbnAffordForHeader($kbnHeaderBannerHeight); } diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 24dc93c33894e..e050926610259 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -46,6 +46,7 @@ export const storybookAliases = { presentation: 'src/plugins/presentation_util/storybook', security_solution: 'x-pack/plugins/security_solution/.storybook', security_solution_packages: 'x-pack/packages/security-solution/storybook/config', + serverless: 'packages/serverless/storybook/config', shared_ux: 'packages/shared-ux/storybook/config', threat_intelligence: 'x-pack/plugins/threat_intelligence/.storybook', triggers_actions_ui: 'x-pack/plugins/triggers_actions_ui/.storybook', diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 7d624e7d1c94f..a4591f6f5f47e 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -223,6 +223,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.security.loginAssistanceMessage (string)', 'xpack.security.sameSiteCookies (alternatives)', 'xpack.security.showInsecureClusterWarning (boolean)', + 'xpack.security.showNavLinks (boolean)', 'xpack.securitySolution.enableExperimental (array)', 'xpack.securitySolution.prebuiltRulesPackageVersion (string)', 'xpack.snapshot_restore.slm_ui.enabled (boolean)', @@ -270,6 +271,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.security.loginAssistanceMessage (string)', 'xpack.security.sameSiteCookies (alternatives)', 'xpack.security.showInsecureClusterWarning (boolean)', + 'xpack.security.showNavLinks (boolean)', ]; // We don't assert that actualExposedConfigKeys and expectedExposedConfigKeys are equal, because test failure messages with large // arrays are hard to grok. Instead, we take the difference between the two arrays and assert them separately, that way it's diff --git a/tsconfig.base.json b/tsconfig.base.json index a5de10e9f4fe3..f333d6f58b34f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1138,6 +1138,14 @@ "@kbn/server-http-tools/*": ["packages/kbn-server-http-tools/*"], "@kbn/server-route-repository": ["packages/kbn-server-route-repository"], "@kbn/server-route-repository/*": ["packages/kbn-server-route-repository/*"], + "@kbn/serverless": ["x-pack/plugins/serverless"], + "@kbn/serverless/*": ["x-pack/plugins/serverless/*"], + "@kbn/serverless-project-switcher": ["packages/serverless/project_switcher"], + "@kbn/serverless-project-switcher/*": ["packages/serverless/project_switcher/*"], + "@kbn/serverless-storybook-config": ["packages/serverless/storybook/config"], + "@kbn/serverless-storybook-config/*": ["packages/serverless/storybook/config/*"], + "@kbn/serverless-types": ["packages/serverless/types"], + "@kbn/serverless-types/*": ["packages/serverless/types/*"], "@kbn/session-notifications-plugin": ["test/plugin_functional/plugins/session_notifications"], "@kbn/session-notifications-plugin/*": ["test/plugin_functional/plugins/session_notifications/*"], "@kbn/session-view-plugin": ["x-pack/plugins/session_view"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 8071085e9cc8b..8e85011f366d8 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -62,6 +62,7 @@ "xpack.searchProfiler": "plugins/searchprofiler", "xpack.security": "plugins/security", "xpack.server": "legacy/server", + "xpack.serverless": "plugins/serverless", "xpack.securitySolution": "plugins/security_solution", "xpack.sessionView": "plugins/session_view", "xpack.snapshotRestore": "plugins/snapshot_restore", diff --git a/x-pack/plugins/security/public/config.ts b/x-pack/plugins/security/public/config.ts index 440bd8da27d90..6a5a8dac01500 100644 --- a/x-pack/plugins/security/public/config.ts +++ b/x-pack/plugins/security/public/config.ts @@ -9,4 +9,5 @@ export interface ConfigType { loginAssistanceMessage: string; showInsecureClusterWarning: boolean; sameSiteCookies: 'Strict' | 'Lax' | 'None' | undefined; + showNavLinks: boolean; } diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 91d0c33ade107..e1af50e986450 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -29,6 +29,7 @@ interface SetupDeps { securityLicense: SecurityLicense; logoutUrl: string; securityApiClients: SecurityApiClients; + showNavLinks?: boolean; } interface StartDeps { @@ -54,16 +55,18 @@ export class SecurityNavControlService { private securityApiClients!: SecurityApiClients; private navControlRegistered!: boolean; + private showNavLinks!: boolean; private securityFeaturesSubscription?: Subscription; private readonly stop$ = new ReplaySubject(1); private userMenuLinks$ = new BehaviorSubject([]); - public setup({ securityLicense, logoutUrl, securityApiClients }: SetupDeps) { + public setup({ securityLicense, logoutUrl, securityApiClients, showNavLinks = true }: SetupDeps) { this.securityLicense = securityLicense; this.logoutUrl = logoutUrl; this.securityApiClients = securityApiClients; + this.showNavLinks = showNavLinks; } public start({ core, authc }: StartDeps): SecurityNavControlServiceStart { @@ -72,7 +75,7 @@ export class SecurityNavControlService { const isAnonymousPath = core.http.anonymousPaths.isAnonymous(window.location.pathname); const shouldRegisterNavControl = - !isAnonymousPath && showLinks && !this.navControlRegistered; + this.showNavLinks && !isAnonymousPath && showLinks && !this.navControlRegistered; if (shouldRegisterNavControl) { this.registerSecurityNavControl(core, authc); } diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index c56c40f63b4d0..084a34e635dcf 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -107,6 +107,7 @@ export class SecurityPlugin securityLicense: license, logoutUrl: getLogoutUrl(core.http), securityApiClients: this.securityApiClients, + showNavLinks: this.config.showNavLinks, }); this.analyticsService.setup({ diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 8b7324e70d646..ea255d61ee255 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -60,6 +60,7 @@ describe('config schema', () => { "selector": Object {}, }, "cookieName": "sid", + "enabled": true, "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "loginAssistanceMessage": "", "public": Object {}, @@ -70,6 +71,7 @@ describe('config schema', () => { "lifespan": "P30D", }, "showInsecureClusterWarning": true, + "showNavLinks": true, } `); @@ -113,6 +115,7 @@ describe('config schema', () => { "selector": Object {}, }, "cookieName": "sid", + "enabled": true, "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "loginAssistanceMessage": "", "public": Object {}, @@ -123,6 +126,7 @@ describe('config schema', () => { "lifespan": "P30D", }, "showInsecureClusterWarning": true, + "showNavLinks": true, } `); @@ -166,6 +170,7 @@ describe('config schema', () => { "selector": Object {}, }, "cookieName": "sid", + "enabled": true, "loginAssistanceMessage": "", "public": Object {}, "secureCookies": false, @@ -175,6 +180,7 @@ describe('config schema', () => { "lifespan": "P30D", }, "showInsecureClusterWarning": true, + "showNavLinks": true, } `); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index e3584427964f3..91abf77a376f8 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -204,6 +204,7 @@ export const ConfigSchema = schema.object({ loginAssistanceMessage: schema.string({ defaultValue: '' }), showInsecureClusterWarning: schema.boolean({ defaultValue: true }), loginHelp: schema.maybe(schema.string()), + showNavLinks: schema.boolean({ defaultValue: true }), cookieName: schema.string({ defaultValue: 'sid' }), encryptionKey: schema.conditional( schema.contextRef('dist'), @@ -295,6 +296,7 @@ export const ConfigSchema = schema.object({ ) ), }), + enabled: schema.boolean({ defaultValue: true }), }); export function createConfig( diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 89ddb41375a91..06ba1e77118e9 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -52,6 +52,7 @@ export const config: PluginConfigDescriptor> = { loginAssistanceMessage: true, showInsecureClusterWarning: true, sameSiteCookies: true, + showNavLinks: true, }, }; export const plugin: PluginInitializer< diff --git a/x-pack/plugins/serverless/README.mdx b/x-pack/plugins/serverless/README.mdx new file mode 100755 index 0000000000000..f3b9940e4b371 --- /dev/null +++ b/x-pack/plugins/serverless/README.mdx @@ -0,0 +1,22 @@ +--- +id: serverless/plugin +slug: /serverless/plugin +title: Serverless Plugin +description: The plugin responsible for managing Serverless settings and providing services to all product serverless plugins. +tags: ['serverless', 'plugin'] +date: 2023-04-23 +--- + +![diagram](./assets/diagram.png) + +a. `serverless.yml` config enables Serverless plugin, provides settings for *all* projects, (e.g. disabling Reporting). + +b. Product-specific `yml` file enables corresponding Project plugin, provides settings for a specific project, (e.g. disabling Observability). + +c. Project plugin interacts with Serverless plugin to customize Serverless Kibana. + +d. Serverless plugin interacts with Kibana Core to customize Classic Kibana. + +e. Project plugin interacts with corresponding Solution plugin to customize the Solution experience for Serverless. + +Communication occurs in a *single direction*. While it would be tempting to add a global flag to check if Serverless is enabled, doing so short-circuits the "affecting" model. \ No newline at end of file diff --git a/x-pack/plugins/serverless/assets/diagram.png b/x-pack/plugins/serverless/assets/diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..57dda7513bb4bde8317575a5237bb98aef9d0d70 GIT binary patch literal 438787 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