From 4395cdd1a0f5645b0c2ce504516b48c39ea41ae5 Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:36:08 +0200 Subject: [PATCH 01/55] [Fleet] removed settings from Agent policy settings config that are not implemented in agent (#180382) ## Summary Remove settings that don't work in agent: https://github.com/elastic/ingest-dev/issues/2471#issuecomment-2045256588 Only keeping `go_max_procs` for now, as that's the only one that seems to work. When changing in agent policy, I could see in agent logs: `[elastic_agent][debug] agent limits have changed: {GoMaxProcs:1} -> {GoMaxProcs:0}` --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../common/settings/agent_policy_settings.ts | 60 ------------------- .../agent_policies/full_agent_policy.test.ts | 8 --- 2 files changed, 68 deletions(-) diff --git a/x-pack/plugins/fleet/common/settings/agent_policy_settings.ts b/x-pack/plugins/fleet/common/settings/agent_policy_settings.ts index 7b170d330e650..f0d7a84d36d2a 100644 --- a/x-pack/plugins/fleet/common/settings/agent_policy_settings.ts +++ b/x-pack/plugins/fleet/common/settings/agent_policy_settings.ts @@ -37,64 +37,4 @@ export const AGENT_POLICY_ADVANCED_SETTINGS: SettingsConfig[] = [ }, schema: z.number().int().min(0).default(0), }, - { - name: 'agent.download.timeout', - title: i18n.translate('xpack.fleet.settings.agentPolicyAdvanced.downloadTimeoutTitle', { - defaultMessage: 'Agent binary download timeout', - }), - description: i18n.translate( - 'xpack.fleet.settings.agentPolicyAdvanced.downloadTimeoutDescription', - { - defaultMessage: 'Timeout in seconds for downloading the agent binary', - } - ), - learnMoreLink: - 'https://www.elastic.co/guide/en/fleet/current/enable-custom-policy-settings.html#configure-agent-download-timeout', - api_field: { - name: 'agent_download_timeout', - }, - schema: zodStringWithDurationValidation.default('120s'), - }, - { - name: 'agent.download.target_directory', - api_field: { - name: 'agent_download_target_directory', - }, - title: i18n.translate( - 'xpack.fleet.settings.agentPolicyAdvanced.agentDownloadTargetDirectoryTitle', - { - defaultMessage: 'Agent binary target directory', - } - ), - description: i18n.translate( - 'xpack.fleet.settings.agentPolicyAdvanced.agentDownloadTargetDirectoryDescription', - { - defaultMessage: 'The disk path to which the agent binary will be downloaded', - } - ), - learnMoreLink: - 'https://www.elastic.co/guide/en/fleet/current/elastic-agent-standalone-download.html', - schema: z.string(), - }, - { - name: 'agent.logging.metrics.period', - api_field: { - name: 'agent_logging_metrics_period', - }, - title: i18n.translate( - 'xpack.fleet.settings.agentPolicyAdvanced.agentLoggingMetricsPeriodTitle', - { - defaultMessage: 'Agent logging metrics period', - } - ), - description: i18n.translate( - 'xpack.fleet.settings.agentPolicyAdvanced.agentLoggingMetricsPeriodDescription', - { - defaultMessage: 'The frequency of agent metrics logging', - } - ), - learnMoreLink: - 'https://www.elastic.co/guide/en/fleet/current/elastic-agent-standalone-logging-config.html#elastic-agent-standalone-logging-settings', - schema: zodStringWithDurationValidation.default('30s'), - }, ]; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts index 7f203dd139954..3a56bf248bcf4 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts @@ -719,9 +719,6 @@ describe('getFullAgentPolicy', () => { mockAgentPolicy({ advanced_settings: { agent_limits_go_max_procs: 2, - agent_download_timeout: '60s', - agent_download_target_directory: '/tmp', - agent_logging_metrics_period: '10s', }, }); const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy'); @@ -729,12 +726,7 @@ describe('getFullAgentPolicy', () => { expect(agentPolicy).toMatchObject({ id: 'agent-policy', agent: { - download: { - timeout: '60s', - target_directory: '/tmp', - }, limits: { go_max_procs: 2 }, - logging: { metrics: { period: '10s' } }, }, }); }); From 2b4aecd36d31f932960cbc7adcdb46db5e993bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Wed, 10 Apr 2024 12:12:52 +0200 Subject: [PATCH 02/55] =?UTF-8?q?[ObsAiAssistant]=20Add=20=E2=80=9CManage?= =?UTF-8?q?=20connectors=E2=80=9D=20button=20to=20flyout=20(#179834)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to https://github.com/elastic/kibana/pull/179380 This adds a link "Manage connectors" to the flyout where users can select a connector. ![image](https://github.com/elastic/kibana/assets/209966/8016b1d2-84d7-4372-909e-636677c0de4d) --- .../components/chat/chat_actions_menu.tsx | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_actions_menu.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_actions_menu.tsx index f06dc126e7667..713a0d2311e3c 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_actions_menu.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_actions_menu.tsx @@ -7,7 +7,14 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonIcon, EuiContextMenu, EuiPanel, EuiPopover, EuiToolTip } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiContextMenu, + EuiPanel, + EuiPopover, + EuiToolTip, +} from '@elastic/eui'; import { ConnectorSelectorBase } from '@kbn/observability-ai-assistant-plugin/public'; import { useKibana } from '../../hooks/use_kibana'; import { getSettingsHref } from '../../utils/get_settings_href'; @@ -26,11 +33,17 @@ export function ChatActionsMenu({ onCopyConversationClick: () => void; }) { const { - application: { navigateToUrl }, + application: { navigateToUrl, navigateToApp }, http, } = useKibana().services; const [isOpen, setIsOpen] = useState(false); + const handleNavigateToConnectors = () => { + navigateToApp('management', { + path: '/insightsAndAlerting/triggersActionsConnectors/connectors', + }); + }; + const toggleActionsMenu = () => { setIsOpen(!isOpen); }; @@ -139,6 +152,18 @@ export function ChatActionsMenu({ content: ( + + + {i18n.translate( + 'xpack.observabilityAiAssistant.settingsPage.goToConnectorsButtonLabel', + { defaultMessage: 'Manage connectors' } + )} + ), }, From 674736d9270f72da5163f51471ef3539101baedf Mon Sep 17 00:00:00 2001 From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:37:34 +0100 Subject: [PATCH 03/55] [Security Solution][Detection Engine] fixes risk score enrichment for rules with suppression (#180359) ## Summary Fixes risk score host enrichment for query, threshold, IM rules when suppression is enabled Setting Feature Flag `isNewRiskScoreModuleAvailable` to [true by default](https://elastic.slack.com/archives/C056TQ5J81Y/p1694699742267509) did not work since enrichment [function](https://github.com/elastic/kibana/blob/8.13/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/index.ts#L41) checks for a flag and if experimentalFeatures property undefined, it sets `isNewRiskScoreModuleAvailable` to `false`. Also adds test coverage for new risk score module and for asset criticality as well ### Flaky tests runner [FTR ESS x200](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5634) [FTR Serverless x200 ](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5635) ### 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 --- .../create_indicator_match_alert_type.ts | 3 +- .../indicator_match/indicator_match.ts | 4 + .../threat_mapping/create_event_signal.ts | 2 + .../threat_mapping/create_threat_signal.ts | 2 + .../threat_mapping/create_threat_signals.ts | 3 + .../indicator_match/threat_mapping/types.ts | 4 + ...bulk_create_suppressed_alerts_in_memory.ts | 2 +- .../new_terms/create_new_terms_alert_type.ts | 3 +- .../group_and_bulk_create.ts | 14 +- .../rule_types/query/query.ts | 1 + ...bulk_create_suppressed_threshold_alerts.ts | 4 + .../threshold/create_threshold_alert_type.ts | 3 +- .../rule_types/threshold/threshold.test.ts | 3 + .../rule_types/threshold/threshold.ts | 4 + ...bulk_create_suppressed_alerts_in_memory.ts | 4 +- .../utils/bulk_create_with_suppression.ts | 2 +- ...rch_after_bulk_create_suppressed_alerts.ts | 4 + .../execution_logic/query.ts | 63 +++++++- .../threat_match_alert_suppression.ts | 141 ++++++++++++++++++ .../threshold_alert_suppression.ts | 77 ++++++++++ 20 files changed, 331 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts index 75d63f149e68a..3ad9925f7f053 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts @@ -20,7 +20,7 @@ import type { BuildReasonMessage } from '../utils/reason_formatters'; export const createIndicatorMatchAlertType = ( createOptions: CreateRuleOptions ): SecurityAlertType => { - const { eventsTelemetry, version, licensing } = createOptions; + const { eventsTelemetry, version, licensing, experimentalFeatures } = createOptions; return { id: INDICATOR_RULE_TYPE_ID, name: 'Indicator Match Rule', @@ -123,6 +123,7 @@ export const createIndicatorMatchAlertType = ( wrapSuppressedHits, runOpts, licensing, + experimentalFeatures, }); return { ...result, state }; }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/indicator_match.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/indicator_match.ts index 029aee57d8025..7423abe73a51d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/indicator_match.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/indicator_match.ts @@ -23,6 +23,7 @@ import type { CompleteRule, ThreatRuleParams } from '../../rule_schema'; import { withSecuritySpan } from '../../../../utils/with_security_span'; import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../../common/constants'; import type { IRuleExecutionLogForExecutors } from '../../rule_monitoring'; +import type { ExperimentalFeatures } from '../../../../../common'; export const indicatorMatchExecutor = async ({ inputIndex, @@ -45,6 +46,7 @@ export const indicatorMatchExecutor = async ({ wrapSuppressedHits, runOpts, licensing, + experimentalFeatures, }: { inputIndex: string[]; runtimeMappings: estypes.MappingRuntimeFields | undefined; @@ -66,6 +68,7 @@ export const indicatorMatchExecutor = async ({ wrapSuppressedHits: WrapSuppressedHits; runOpts: RunOpts; licensing: LicensingPluginSetup; + experimentalFeatures: ExperimentalFeatures; }) => { const ruleParams = completeRule.ruleParams; @@ -105,6 +108,7 @@ export const indicatorMatchExecutor = async ({ inputIndexFields, runOpts, licensing, + experimentalFeatures, }); }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts index acf506b0304c9..8782f34e0afdc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts @@ -56,6 +56,7 @@ export const createEventSignal = async ({ completeRule, sortOrder = 'desc', isAlertSuppressionActive, + experimentalFeatures, }: CreateEventSignalOptions): Promise => { const threatFiltersFromEvents = buildThreatMappingFilter({ threatMapping, @@ -159,6 +160,7 @@ export const createEventSignal = async ({ alertTimestampOverride: runOpts.alertTimestampOverride, alertWithSuppression: runOpts.alertWithSuppression, alertSuppression: completeRule.ruleParams.alertSuppression, + experimentalFeatures, }); } else { createResult = await searchAfterAndBulkCreate(searchAfterBulkCreateParams); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts index 7740bed7777bb..318f8ddfbb759 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts @@ -54,6 +54,7 @@ export const createThreatSignal = async ({ threatIndexFields, sortOrder = 'desc', isAlertSuppressionActive, + experimentalFeatures, }: CreateThreatSignalOptions): Promise => { const threatFilter = buildThreatMappingFilter({ threatMapping, @@ -132,6 +133,7 @@ export const createThreatSignal = async ({ alertTimestampOverride: runOpts.alertTimestampOverride, alertWithSuppression: runOpts.alertWithSuppression, alertSuppression: completeRule.ruleParams.alertSuppression, + experimentalFeatures, }); } else { result = await searchAfterAndBulkCreate(searchAfterBulkCreateParams); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts index bb01cbe9a5b48..894277d5ad17f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts @@ -68,6 +68,7 @@ export const createThreatSignals = async ({ unprocessedExceptions, inputIndexFields, licensing, + experimentalFeatures, }: CreateThreatSignalsOptions): Promise => { const threatMatchedFields = getMatchedFields(threatMapping); const allowedFieldsForTermsQuery = await getAllowedFieldsForTermQuery({ @@ -296,6 +297,7 @@ export const createThreatSignals = async ({ runOpts, sortOrder, isAlertSuppressionActive, + experimentalFeatures, }), }); } else { @@ -361,6 +363,7 @@ export const createThreatSignals = async ({ runOpts, sortOrder, isAlertSuppressionActive, + experimentalFeatures, }), }); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/types.ts index d8a6a97bbc644..e1072f873917a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/types.ts @@ -41,6 +41,7 @@ import type { } from '../../types'; import type { CompleteRule, ThreatRuleParams } from '../../../rule_schema'; import type { IRuleExecutionLogForExecutors } from '../../../rule_monitoring'; +import type { ExperimentalFeatures } from '../../../../../../common'; export type SortOrderOrUndefined = 'asc' | 'desc' | undefined; @@ -79,6 +80,7 @@ export interface CreateThreatSignalsOptions { inputIndexFields: DataViewFieldBase[]; runOpts: RunOpts; licensing: LicensingPluginSetup; + experimentalFeatures: ExperimentalFeatures; } export interface CreateThreatSignalOptions { @@ -122,6 +124,7 @@ export interface CreateThreatSignalOptions { runOpts: RunOpts; sortOrder?: SortOrderOrUndefined; isAlertSuppressionActive: boolean; + experimentalFeatures: ExperimentalFeatures; } export interface CreateEventSignalOptions { @@ -166,6 +169,7 @@ export interface CreateEventSignalOptions { runOpts: RunOpts; sortOrder?: SortOrderOrUndefined; isAlertSuppressionActive: boolean; + experimentalFeatures: ExperimentalFeatures; } type EntryKey = 'field' | 'value'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/bulk_create_suppressed_alerts_in_memory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/bulk_create_suppressed_alerts_in_memory.ts index 8d39fcccee62e..2fffa07f8d684 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/bulk_create_suppressed_alerts_in_memory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/bulk_create_suppressed_alerts_in_memory.ts @@ -54,7 +54,7 @@ export interface BulkCreateSuppressedAlertsParams >; eventsAndTerms: EventsAndTerms[]; toReturn: SearchAfterAndBulkCreateReturnType; - experimentalFeatures: ExperimentalFeatures | undefined; + experimentalFeatures: ExperimentalFeatures; } /** * wraps, bulk create and suppress alerts in memory, also takes care of missing fields logic. diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts index 42ffec0b6236c..f75b4e229f3d8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts @@ -48,7 +48,7 @@ import type { GenericBulkCreateResponse } from '../utils/bulk_create_with_suppre export const createNewTermsAlertType = ( createOptions: CreateRuleOptions ): SecurityAlertType => { - const { logger, licensing } = createOptions; + const { logger, licensing, experimentalFeatures } = createOptions; return { id: NEW_TERMS_RULE_TYPE_ID, name: 'New Terms Rule', @@ -110,7 +110,6 @@ export const createNewTermsAlertType = ( alertTimestampOverride, publicBaseUrl, inputIndexFields, - experimentalFeatures, alertWithSuppression, }, services, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts index ced44553192e6..b1d897250ca4e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts @@ -30,6 +30,8 @@ import { AlertSuppressionMissingFieldsStrategyEnum } from '../../../../../../com import { bulkCreateUnsuppressedAlerts } from './bulk_create_unsuppressed_alerts'; import type { ITelemetryEventsSender } from '../../../../telemetry/sender'; import { DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY } from '../../../../../../common/detection_engine/constants'; +import type { ExperimentalFeatures } from '../../../../../../common'; +import { createEnrichEventsFunction } from '../../utils/enrichments'; export interface BucketHistory { key: Record; @@ -45,6 +47,7 @@ export interface GroupAndBulkCreateParams { bucketHistory?: BucketHistory[]; groupByFields: string[]; eventsTelemetry: ITelemetryEventsSender | undefined; + experimentalFeatures: ExperimentalFeatures; } export interface GroupAndBulkCreateReturnType extends SearchAfterAndBulkCreateReturnType { @@ -123,6 +126,7 @@ export const groupAndBulkCreate = async ({ bucketHistory, groupByFields, eventsTelemetry, + experimentalFeatures, }: GroupAndBulkCreateParams): Promise => { return withSecuritySpan('groupAndBulkCreate', async () => { const tuple = runOpts.tuple; @@ -269,11 +273,19 @@ export const groupAndBulkCreate = async ({ services, suppressionWindow, alertTimestampOverride: runOpts.alertTimestampOverride, + experimentalFeatures, }); addToSearchAfterReturn({ current: toReturn, next: bulkCreateResult }); runOpts.ruleExecutionLogger.debug(`created ${bulkCreateResult.createdItemsCount} signals`); } else { - const bulkCreateResult = await runOpts.bulkCreate(wrappedAlerts); + const bulkCreateResult = await runOpts.bulkCreate( + wrappedAlerts, + undefined, + createEnrichEventsFunction({ + services, + logger: runOpts.ruleExecutionLogger, + }) + ); addToSearchAfterReturn({ current: toReturn, next: bulkCreateResult }); runOpts.ruleExecutionLogger.debug(`created ${bulkCreateResult.createdItemsCount} signals`); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts index cd6139674ecbf..b3de5a39d829f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts @@ -75,6 +75,7 @@ export const queryExecutor = async ({ bucketHistory, groupByFields: ruleParams.alertSuppression.groupBy, eventsTelemetry, + experimentalFeatures, }) : { ...(await searchAfterAndBulkCreate({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/bulk_create_suppressed_threshold_alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/bulk_create_suppressed_threshold_alerts.ts index b2ec6fbc909d3..b5e288909b412 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/bulk_create_suppressed_threshold_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/bulk_create_suppressed_threshold_alerts.ts @@ -23,6 +23,7 @@ import { bulkCreateWithSuppression } from '../utils/bulk_create_with_suppression import type { GenericBulkCreateResponse } from '../utils/bulk_create_with_suppression'; import { wrapSuppressedThresholdALerts } from './wrap_suppressed_threshold_alerts'; import { transformBulkCreatedItemsToHits } from './utils'; +import type { ExperimentalFeatures } from '../../../../../common'; interface BulkCreateSuppressedThresholdAlertsParams { buckets: ThresholdBucket[]; @@ -35,6 +36,7 @@ interface BulkCreateSuppressedThresholdAlertsParams { ruleExecutionLogger: IRuleExecutionLogForExecutors; spaceId: string; runOpts: RunOpts; + experimentalFeatures: ExperimentalFeatures; } /** @@ -53,6 +55,7 @@ export const bulkCreateSuppressedThresholdAlerts = async ({ ruleExecutionLogger, spaceId, runOpts, + experimentalFeatures, }: BulkCreateSuppressedThresholdAlertsParams): Promise<{ bulkCreateResult: GenericBulkCreateResponse; unsuppressedAlerts: Array>; @@ -90,6 +93,7 @@ export const bulkCreateSuppressedThresholdAlerts = async ({ services, suppressionWindow, alertTimestampOverride: runOpts.alertTimestampOverride, + experimentalFeatures, }); return { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts index 11ed8e74e0ca5..84f3a52af95e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts @@ -19,7 +19,7 @@ import { validateIndexPatterns } from '../utils'; export const createThresholdAlertType = ( createOptions: CreateRuleOptions ): SecurityAlertType => { - const { version, licensing } = createOptions; + const { version, licensing, experimentalFeatures } = createOptions; return { id: THRESHOLD_RULE_TYPE_ID, name: 'Threshold Rule', @@ -103,6 +103,7 @@ export const createThresholdAlertType = ( spaceId, runOpts: execOptions.runOpts, licensing, + experimentalFeatures, }); return result; }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/threshold.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/threshold.test.ts index 342776d0fcc1a..50a315a58ef4d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/threshold.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/threshold.test.ts @@ -20,6 +20,7 @@ import { createRuleDataClientMock } from '@kbn/rule-registry-plugin/server/rule_ import { TIMESTAMP } from '@kbn/rule-data-utils'; import { ruleExecutionLogMock } from '../../rule_monitoring/mocks'; import type { RunOpts } from '../types'; +import type { ExperimentalFeatures } from '../../../../../common'; describe('threshold_executor', () => { let alertServices: RuleExecutorServicesMock; @@ -110,6 +111,7 @@ describe('threshold_executor', () => { spaceId: 'default', runOpts: {} as RunOpts, licensing, + experimentalFeatures: {} as ExperimentalFeatures, }); expect(response.state).toEqual({ initialized: true, @@ -175,6 +177,7 @@ describe('threshold_executor', () => { spaceId: 'default', runOpts: {} as RunOpts, licensing, + experimentalFeatures: {} as ExperimentalFeatures, }); expect(result.warningMessages).toEqual([ `The following exceptions won't be applied to rule execution: ${ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/threshold.ts index 4ade5ff1e2f09..ee0e80f03df15 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/threshold.ts @@ -44,6 +44,7 @@ import { withSecuritySpan } from '../../../../utils/with_security_span'; import { buildThresholdSignalHistory } from './build_signal_history'; import type { IRuleExecutionLogForExecutors } from '../../rule_monitoring'; import { getSignalHistory, transformBulkCreatedItemsToHits } from './utils'; +import type { ExperimentalFeatures } from '../../../../../common'; export const thresholdExecutor = async ({ inputIndex, @@ -67,6 +68,7 @@ export const thresholdExecutor = async ({ spaceId, runOpts, licensing, + experimentalFeatures, }: { inputIndex: string[]; runtimeMappings: estypes.MappingRuntimeFields | undefined; @@ -89,6 +91,7 @@ export const thresholdExecutor = async ({ spaceId: string; runOpts: RunOpts; licensing: LicensingPluginSetup; + experimentalFeatures: ExperimentalFeatures; }): Promise => { const result = createSearchAfterReturnType(); const ruleParams = completeRule.ruleParams; @@ -167,6 +170,7 @@ export const thresholdExecutor = async ({ ruleExecutionLogger, spaceId, runOpts, + experimentalFeatures, }); const createResult = suppressedResults.bulkCreateResult; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_suppressed_alerts_in_memory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_suppressed_alerts_in_memory.ts index 94d7003c4eb13..85bfc76e964e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_suppressed_alerts_in_memory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_suppressed_alerts_in_memory.ts @@ -50,7 +50,7 @@ export interface BulkCreateSuppressedAlertsParams > { enrichedEvents: SignalSourceHit[]; toReturn: SearchAfterAndBulkCreateReturnType; - experimentalFeatures?: ExperimentalFeatures; + experimentalFeatures: ExperimentalFeatures; } /** * wraps, bulk create and suppress alerts in memory, also takes care of missing fields logic. @@ -119,7 +119,7 @@ export interface ExecuteBulkCreateAlertsParams>; suppressibleWrappedDocs: Array>; toReturn: SearchAfterAndBulkCreateReturnType; - experimentalFeatures?: ExperimentalFeatures; + experimentalFeatures: ExperimentalFeatures; } /** diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_with_suppression.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_with_suppression.ts index 35d55ec7f39d2..75aa46d039277 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_with_suppression.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_with_suppression.ts @@ -55,7 +55,7 @@ export const bulkCreateWithSuppression = async < alertTimestampOverride: Date | undefined; isSuppressionPerRuleExecution?: boolean; maxAlerts?: number; - experimentalFeatures?: ExperimentalFeatures; + experimentalFeatures: ExperimentalFeatures; }): Promise> => { if (wrappedDocs.length === 0) { return { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create_suppressed_alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create_suppressed_alerts.ts index 0a89dec4bd251..1e63fec53597b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create_suppressed_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create_suppressed_alerts.ts @@ -14,12 +14,14 @@ import type { WrapSuppressedHits, } from '../types'; import type { AlertSuppressionCamel } from '../../../../../common/api/detection_engine/model/rule_schema'; +import type { ExperimentalFeatures } from '../../../../../common'; interface SearchAfterAndBulkCreateSuppressedAlertsParams extends SearchAfterAndBulkCreateParams { wrapSuppressedHits: WrapSuppressedHits; alertTimestampOverride: Date | undefined; alertWithSuppression: SuppressedAlertService; alertSuppression?: AlertSuppressionCamel; + experimentalFeatures: ExperimentalFeatures; } import type { SearchAfterAndBulkCreateFactoryParams } from './search_after_bulk_create_factory'; @@ -44,6 +46,7 @@ export const searchAfterAndBulkCreateSuppressedAlerts = async ( wrapSuppressedHits, alertWithSuppression, alertTimestampOverride, + experimentalFeatures, } = params; const bulkCreateExecutor: SearchAfterAndBulkCreateFactoryParams['bulkCreateExecutor'] = async ({ @@ -63,6 +66,7 @@ export const searchAfterAndBulkCreateSuppressedAlerts = async ( alertTimestampOverride, enrichedEvents, toReturn, + experimentalFeatures, }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/query.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/query.ts index 4e09b5868204f..dd9c0dc624035 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/query.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/query.ts @@ -98,8 +98,7 @@ export default ({ getService }: FtrProviderContext) => { const dataPathBuilder = new EsArchivePathBuilder(isServerless); const auditbeatPath = dataPathBuilder.getPath('auditbeat/hosts'); - // FLAKY: https://github.com/elastic/kibana/issues/177101 - describe.skip('@ess @serverless Query type rules', () => { + describe('@ess @serverless Query type rules', () => { before(async () => { await esArchiver.load(auditbeatPath); await esArchiver.load('x-pack/test/functional/es_archives/security_solution/alerts/8.8.0', { @@ -203,7 +202,8 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('should query and get back expected alert structure when it is a alert on a alert', async () => { + // FLAKY: https://github.com/elastic/kibana/issues/177101 + it.skip('should query and get back expected alert structure when it is a alert on a alert', async () => { const alertId = 'eabbdefc23da981f2b74ab58b82622a97bb9878caa11bc914e2adfacc94780f1'; const rule: QueryRuleCreateProps = { ...getRuleForAlertTesting([`.alerts-security.alerts-default*`]), @@ -278,6 +278,45 @@ export default ({ getService }: FtrProviderContext) => { }; const { previewId } = await previewRule({ supertest, rule }); const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts[0]?._source?.host?.risk?.calculated_level).to.eql('Critical'); + expect(previewAlerts[0]?._source?.host?.risk?.calculated_score_norm).to.eql(96); + expect(previewAlerts[0]?._source?.user?.risk?.calculated_level).to.eql('Low'); + expect(previewAlerts[0]?._source?.user?.risk?.calculated_score_norm).to.eql(11); + }); + + it('should have host and user risk score fields when suppression enabled on interval', async () => { + const rule: QueryRuleCreateProps = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: `_id:${ID}`, + alert_suppression: { + group_by: ['host.name'], + duration: { + value: 300, + unit: 'm', + }, + }, + }; + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts[0]?._source?.host?.risk?.calculated_level).to.eql('Critical'); + expect(previewAlerts[0]?._source?.host?.risk?.calculated_score_norm).to.eql(96); + expect(previewAlerts[0]?._source?.user?.risk?.calculated_level).to.eql('Low'); + expect(previewAlerts[0]?._source?.user?.risk?.calculated_score_norm).to.eql(11); + }); + + it('should have host and user risk score fields when suppression enabled on rule execution only', async () => { + const rule: QueryRuleCreateProps = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: `_id:${ID}`, + alert_suppression: { + group_by: ['host.name'], + }, + }; + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + expect(previewAlerts[0]?._source?.host?.risk?.calculated_level).to.eql('Critical'); expect(previewAlerts[0]?._source?.host?.risk?.calculated_score_norm).to.eql(96); expect(previewAlerts[0]?._source?.user?.risk?.calculated_level).to.eql('Low'); @@ -307,6 +346,24 @@ export default ({ getService }: FtrProviderContext) => { expect(previewAlerts[0]?._source?.['host.asset.criticality']).to.eql('high_impact'); expect(previewAlerts[0]?._source?.['user.asset.criticality']).to.eql('extreme_impact'); }); + + it('should be enriched alert with criticality_level when suppression enabled', async () => { + const rule: QueryRuleCreateProps = { + ...getRuleForAlertTesting(['auditbeat-*']), + query: `_id:${ID}`, + alert_suppression: { + group_by: ['host.name'], + duration: { + value: 300, + unit: 'm', + }, + }, + }; + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + expect(previewAlerts[0]?._source?.['host.asset.criticality']).to.eql('high_impact'); + expect(previewAlerts[0]?._source?.['user.asset.criticality']).to.eql('extreme_impact'); + }); }); /** diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threat_match_alert_suppression.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threat_match_alert_suppression.ts index dec39659ae256..84e6ce0469367 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threat_match_alert_suppression.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threat_match_alert_suppression.ts @@ -20,6 +20,7 @@ import { import { getSuppressionMaxSignalsWarning as getSuppressionMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; import { DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_URL } from '@kbn/security-solution-plugin/common/constants'; +import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; import { ThreatMatchRuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; @@ -42,6 +43,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); + const kibanaServer = getService('kibanaServer'); const { indexListOfDocuments: indexListOfSourceDocuments, @@ -2388,6 +2390,145 @@ export default ({ getService }: FtrProviderContext) => { }); }); }); + + describe('alerts should be enriched', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/entity/risks'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/entity/risks'); + }); + + it('should be enriched with host risk score', async () => { + const id = uuidv4(); + const timestamp = '2020-10-28T06:45:00.000Z'; + const laterTimestamp = '2020-10-28T06:50:00.000Z'; + const doc1 = { + id, + '@timestamp': timestamp, + host: { name: 'zeek-sensor-amsterdam' }, + user: { name: 'root' }, + }; + const doc1WithLaterTimestamp = { + ...doc1, + '@timestamp': laterTimestamp, + }; + + await eventsFiller({ id, count: eventsCount, timestamp: [timestamp] }); + await threatsFiller({ id, count: threatsCount, timestamp }); + + await indexListOfSourceDocuments([doc1, doc1WithLaterTimestamp, doc1]); + + await addThreatDocuments({ + id, + timestamp, + fields: { + host: { + name: 'zeek-sensor-amsterdam', + }, + }, + count: 1, + }); + + const rule: ThreatMatchRuleCreateProps = { + ...indicatorMatchRule(id), + alert_suppression: { + group_by: ['host.name'], + missing_fields_strategy: 'suppress', + }, + from: 'now-35m', + interval: '30m', + }; + + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date('2020-10-28T07:00:00.000Z'), + invocationCount: 1, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + sort: [ALERT_ORIGINAL_TIME], + }); + + expect(previewAlerts[0]?._source?.host?.risk?.calculated_level).toEqual('Critical'); + expect(previewAlerts[0]?._source?.host?.risk?.calculated_score_norm).toEqual(70); + expect(previewAlerts[0]?._source?.user?.risk?.calculated_level).toEqual('Low'); + expect(previewAlerts[0]?._source?.user?.risk?.calculated_score_norm).toEqual(11); + }); + }); + + describe('with asset criticality', async () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); + await kibanaServer.uiSettings.update({ + [ENABLE_ASSET_CRITICALITY_SETTING]: true, + }); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/asset_criticality'); + }); + + it('should be enriched alert with criticality_level', async () => { + const id = uuidv4(); + const timestamp = '2020-10-28T06:45:00.000Z'; + const laterTimestamp = '2020-10-28T06:50:00.000Z'; + const doc1 = { + id, + '@timestamp': timestamp, + host: { name: 'zeek-sensor-amsterdam' }, + user: { name: 'root' }, + }; + const doc1WithLaterTimestamp = { + ...doc1, + '@timestamp': laterTimestamp, + }; + + await eventsFiller({ id, count: eventsCount, timestamp: [timestamp] }); + await threatsFiller({ id, count: threatsCount, timestamp }); + + await indexListOfSourceDocuments([doc1, doc1WithLaterTimestamp, doc1]); + + await addThreatDocuments({ + id, + timestamp, + fields: { + host: { + name: 'zeek-sensor-amsterdam', + }, + }, + count: 1, + }); + + const rule: ThreatMatchRuleCreateProps = { + ...indicatorMatchRule(id), + alert_suppression: { + group_by: ['host.name'], + missing_fields_strategy: 'suppress', + }, + from: 'now-35m', + interval: '30m', + }; + + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date('2020-10-28T07:00:00.000Z'), + invocationCount: 1, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + sort: [ALERT_ORIGINAL_TIME], + }); + + expect(previewAlerts[0]?._source?.['host.asset.criticality']).toEqual('low_impact'); + expect(previewAlerts[0]?._source?.['user.asset.criticality']).toEqual('extreme_impact'); + }); + }); }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold_alert_suppression.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold_alert_suppression.ts index 9a5763e95c51c..11b38b71599df 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold_alert_suppression.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold_alert_suppression.ts @@ -21,6 +21,7 @@ import { DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_U import { ThresholdRuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; +import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_maps/field_names'; import { createRule } from '../../../../../../../common/utils/security_solution'; @@ -34,12 +35,19 @@ import { dataGeneratorFactory, } from '../../../../utils'; import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { EsArchivePathBuilder } from '../../../../../../es_archive_path_builder'; export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); + const kibanaServer = getService('kibanaServer'); + // TODO: add a new service for loading archiver files similar to "getService('es')" + const config = getService('config'); + const isServerless = config.get('serverless'); + const dataPathBuilder = new EsArchivePathBuilder(isServerless); + const path = dataPathBuilder.getPath('auditbeat/hosts'); describe('@ess @serverless Threshold type rules, alert suppression', () => { const { indexListOfDocuments, indexGeneratedDocuments } = dataGeneratorFactory({ @@ -49,10 +57,12 @@ export default ({ getService }: FtrProviderContext) => { }); before(async () => { + await esArchiver.load(path); await esArchiver.load('x-pack/test/functional/es_archives/security_solution/ecs_compliant'); }); after(async () => { + await esArchiver.unload(path); await esArchiver.unload('x-pack/test/functional/es_archives/security_solution/ecs_compliant'); }); @@ -854,5 +864,72 @@ export default ({ getService }: FtrProviderContext) => { }) ); }); + + describe('with host risk index', async () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/entity/risks'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/entity/risks'); + }); + + it('should be enriched with host risk score', async () => { + const rule: ThresholdRuleCreateProps = { + ...getThresholdRuleForAlertTesting(['auditbeat-*']), + threshold: { + field: 'host.name', + value: 100, + }, + alert_suppression: { + duration: { + value: 300, + unit: 'm', + }, + }, + }; + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId, sort: ['host.name'] }); + + expect(previewAlerts[0]?._source?.host?.risk?.calculated_level).toEqual('Low'); + expect(previewAlerts[0]?._source?.host?.risk?.calculated_score_norm).toEqual(20); + expect(previewAlerts[1]?._source?.host?.risk?.calculated_level).toEqual('Critical'); + expect(previewAlerts[1]?._source?.host?.risk?.calculated_score_norm).toEqual(96); + }); + }); + + describe('with asset criticality', async () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); + await kibanaServer.uiSettings.update({ + [ENABLE_ASSET_CRITICALITY_SETTING]: true, + }); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/asset_criticality'); + }); + + it('should be enriched alert with criticality_level', async () => { + const rule: ThresholdRuleCreateProps = { + ...getThresholdRuleForAlertTesting(['auditbeat-*']), + threshold: { + field: 'host.name', + value: 100, + }, + alert_suppression: { + duration: { + value: 300, + unit: 'm', + }, + }, + }; + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId, sort: ['host.name'] }); + const fullAlert = previewAlerts[0]?._source; + + expect(fullAlert?.['host.asset.criticality']).toEqual('high_impact'); + }); + }); }); }; From d3fe5434b34d45cad02b599503a90a132c0f0ae3 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Wed, 10 Apr 2024 12:45:25 +0200 Subject: [PATCH 04/55] [Infra] Implement dashboard tab UI in host details (#178518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #176070 Closes #178319 Closes [#175447](https://github.com/elastic/kibana/issues/175447) ## Summary This PR adds a dashboard tab to the asset details view. This is the first version of the tab and it looks similar to the APM solution in service overview. ⚠️ After [this PR](https://github.com/elastic/kibana/pull/179576) was merged the structure of the API changed and the saved object is now one per linked dashboard (not one per asset type as before). Those changes will give us more flexibility in the future and the endpoints allow us to edit/link/unlink a dashboard easier than before (and we don't have to get and iterate through all dashboards when updating/deleting) The new structure of the saved object is now : ```javascript "properties": { "assetType": { "type": "keyword" }, "dashboardSavedObjectId": { "type": "keyword" }, "dashboardFilterAssetIdEnabled": { "type": "boolean" } } ``` This initial implementation will show the dashboard tab **ONLY** if the feature flag (`enableInfrastructureAssetCustomDashboards`) is enabled (this will change) ## Updates: - #178319 New splash screen image - The new API endpoints added in [this PR](https://github.com/elastic/kibana/pull/179576) are now used - Fix switching dashboards not refreshing the content and not applying filters issue ## Next steps - [ ] [[Infra] Dashboard locator | kibana#178520](https://github.com/elastic/kibana/issues/178520) - [ ] [[Infra] Dashboard feature activation | kibana#175542](https://github.com/elastic/kibana/issues/175542) ## Testing - Generate some hosts with metrics: - `node scripts/synthtrace --clean --scenarioOpts.numServices=5 infra_hosts_with_apm_hosts.ts` - or use metricbeat/remote cluster - Enable the `enableInfrastructureAssetCustomDashboards` feature flag - Go to Hosts view flyout / Go to Asset details page - Link a dashboard - Edit the linked dashboard (enable/disable filter by hostname) https://github.com/elastic/kibana/assets/14139027/ad6b87aa-e2de-42fa-9565-4bfe32ffd146 - Unlink a dashboard: In case of unlinking: - single dashboard -> empty state - multiple dashboards -> other dashboard https://github.com/elastic/kibana/assets/14139027/4f39f3aa-b7fa-407d-8991-79d19d3ee076 - Navigation between Hosts view flyout / Asset details page (Click `Open as page`) and persisting the state: https://github.com/elastic/kibana/assets/14139027/98756cb0-7675-4bc0-9e14-0fb8d95cce30 - Link custom dashboard (create a dashboard and link it after) image --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../actions/save_dashboard_modal.tsx | 2 +- .../infra/common/custom_dashboards.ts | 4 + .../common/http_api/custom_dashboards_api.ts | 3 +- .../observability_solution/infra/kibana.jsonc | 1 + .../asset_details_tabs.tsx | 6 + .../asset_details/content/content.tsx | 15 +- .../hooks/use_asset_details_url_state.ts | 2 + .../hooks/use_custom_dashboards.ts | 211 ++++++++++++++ .../hooks/use_dashboards_fetcher.ts | 69 +++++ .../hooks/use_fetch_custom_dashboards.ts | 57 ++++ .../asset_details/hooks/use_page_header.tsx | 8 +- .../dashboards/actions/edit_dashboard.tsx | 52 ++++ .../actions/goto_dashboard_link.tsx | 40 +++ .../tabs/dashboards/actions/index.ts | 11 + .../dashboards/actions/link_dashboard.tsx | 63 ++++ .../actions/save_dashboard_modal.tsx | 275 ++++++++++++++++++ .../dashboards/actions/unlink_dashboard.tsx | 129 ++++++++ .../tabs/dashboards/context_menu.tsx | 49 ++++ .../tabs/dashboards/dashboard_selector.tsx | 88 ++++++ .../tabs/dashboards/dashboards.tsx | 209 +++++++++++++ .../tabs/dashboards/empty_dashboards.tsx | 65 +++++ .../components/asset_details/tabs/index.ts | 1 + .../public/components/asset_details/types.ts | 1 + .../public/hooks/use_saved_views_notifier.ts | 4 +- .../infra/public/types.ts | 2 + .../infra/public/utils/filters/build.test.ts | 32 ++ .../infra/public/utils/filters/build.ts | 13 +- .../infra/tsconfig.json | 2 + .../test/functional/apps/infra/hosts_view.ts | 21 +- .../functional/page_objects/asset_details.ts | 9 + 30 files changed, 1435 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_custom_dashboards.ts create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_dashboards_fetcher.ts create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_fetch_custom_dashboards.ts create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/edit_dashboard.tsx create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/goto_dashboard_link.tsx create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/index.ts create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/link_dashboard.tsx create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/save_dashboard_modal.tsx create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/unlink_dashboard.tsx create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/context_menu.tsx create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboard_selector.tsx create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboards.tsx create mode 100644 x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/empty_dashboards.tsx create mode 100644 x-pack/plugins/observability_solution/infra/public/utils/filters/build.test.ts diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx index 406a1b3e43190..f95451383ad0d 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx @@ -282,7 +282,7 @@ function getEditSuccessToastLabels(dashboardName: string) { } ), text: i18n.translate('xpack.apm.serviceDashboards.editSuccess.toast.text', { - defaultMessage: 'Your dashboard link have been updated', + defaultMessage: 'Your dashboard link has been updated', }), }; } diff --git a/x-pack/plugins/observability_solution/infra/common/custom_dashboards.ts b/x-pack/plugins/observability_solution/infra/common/custom_dashboards.ts index 41bab533cf7d4..0861934653fe9 100644 --- a/x-pack/plugins/observability_solution/infra/common/custom_dashboards.ts +++ b/x-pack/plugins/observability_solution/infra/common/custom_dashboards.ts @@ -18,3 +18,7 @@ export interface InfraCustomDashboard { export interface InfraSavedCustomDashboard extends InfraCustomDashboard { id: string; } + +export interface DashboardItemWithTitle extends InfraSavedCustomDashboard { + title: string; +} diff --git a/x-pack/plugins/observability_solution/infra/common/http_api/custom_dashboards_api.ts b/x-pack/plugins/observability_solution/infra/common/http_api/custom_dashboards_api.ts index f89f66ca86e37..c302a2a617def 100644 --- a/x-pack/plugins/observability_solution/infra/common/http_api/custom_dashboards_api.ts +++ b/x-pack/plugins/observability_solution/infra/common/http_api/custom_dashboards_api.ts @@ -21,7 +21,7 @@ const SavedObjectIdRT = rt.type({ id: rt.string, }); -const InfraCustomDashboardRT = rt.intersection([AssetTypeRT, PayloadRT, SavedObjectIdRT]); +export const InfraCustomDashboardRT = rt.intersection([AssetTypeRT, PayloadRT, SavedObjectIdRT]); /** GET endpoint @@ -59,3 +59,4 @@ export const InfraDeleteCustomDashboardsRequestParamsRT = rt.intersection([ AssetTypeRT, SavedObjectIdRT, ]); +export const InfraDeleteCustomDashboardsResponseBodyRT = rt.string; diff --git a/x-pack/plugins/observability_solution/infra/kibana.jsonc b/x-pack/plugins/observability_solution/infra/kibana.jsonc index 22035e8d8fc23..973acf3e75d55 100644 --- a/x-pack/plugins/observability_solution/infra/kibana.jsonc +++ b/x-pack/plugins/observability_solution/infra/kibana.jsonc @@ -16,6 +16,7 @@ "data", "dataViews", "dataViewEditor", + "dashboard", "discover", "embeddable", "features", diff --git a/x-pack/plugins/observability_solution/infra/public/common/asset_details_config/asset_details_tabs.tsx b/x-pack/plugins/observability_solution/infra/public/common/asset_details_config/asset_details_tabs.tsx index 312ed7eb04a98..913622a80f9ab 100644 --- a/x-pack/plugins/observability_solution/infra/public/common/asset_details_config/asset_details_tabs.tsx +++ b/x-pack/plugins/observability_solution/infra/public/common/asset_details_config/asset_details_tabs.tsx @@ -51,4 +51,10 @@ export const commonFlyoutTabs: Tab[] = [ defaultMessage: 'Osquery', }), }, + { + id: ContentTabIds.DASHBOARDS, + name: i18n.translate('xpack.infra.infra.nodeDetails.tabs.dashboards', { + defaultMessage: 'Dashboards', + }), + }, ]; diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/content/content.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/content/content.tsx index 659304402060a..6524f34252fa6 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/content/content.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/content/content.tsx @@ -9,7 +9,16 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; import { DatePicker } from '../date_picker/date_picker'; import { useTabSwitcherContext } from '../hooks/use_tab_switcher'; -import { Anomalies, Logs, Metadata, Osquery, Overview, Processes, Profiling } from '../tabs'; +import { + Anomalies, + Dashboards, + Logs, + Metadata, + Osquery, + Overview, + Processes, + Profiling, +} from '../tabs'; import { ContentTabIds } from '../types'; export const Content = () => { @@ -23,6 +32,7 @@ export const Content = () => { ContentTabIds.METADATA, ContentTabIds.PROCESSES, ContentTabIds.ANOMALIES, + ContentTabIds.DASHBOARDS, ]} /> @@ -48,6 +58,9 @@ export const Content = () => { + + + ); diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_asset_details_url_state.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_asset_details_url_state.ts index b7411bb188e20..fc617292ad4e9 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_asset_details_url_state.ts +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_asset_details_url_state.ts @@ -59,6 +59,7 @@ const TabIdRT = rt.union([ rt.literal(ContentTabIds.LOGS), rt.literal(ContentTabIds.ANOMALIES), rt.literal(ContentTabIds.OSQUERY), + rt.literal(ContentTabIds.DASHBOARDS), ]); const AlertStatusRT = rt.union([ @@ -84,6 +85,7 @@ const AssetDetailsUrlStateRT = rt.partial({ logsSearch: rt.string, profilingSearch: rt.string, alertStatus: AlertStatusRT, + dashboardId: rt.string, }); const AssetDetailsUrlRT = rt.union([AssetDetailsUrlStateRT, rt.null]); diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_custom_dashboards.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_custom_dashboards.ts new file mode 100644 index 0000000000000..e7e3f2eec3d87 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_custom_dashboards.ts @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import type { + InfraCustomDashboard, + InfraSavedCustomDashboard, + InfraCustomDashboardAssetType, +} from '../../../../common/custom_dashboards'; +import { + InfraCustomDashboardRT, + InfraDeleteCustomDashboardsResponseBodyRT, +} from '../../../../common/http_api/custom_dashboards_api'; +import { throwErrors, createPlainError } from '../../../../common/runtime_types'; + +type ActionType = 'create' | 'update' | 'delete'; +const errorMessages: Record = { + create: i18n.translate('xpack.infra.linkDashboards.addFailure.toast.title', { + defaultMessage: 'Error while linking dashboards', + }), + update: i18n.translate('xpack.infra.updatingLinkedDashboards.addFailure.toast.title', { + defaultMessage: 'Error while updating linked dashboards', + }), + delete: i18n.translate('xpack.infra.deletingLinkedDashboards.addFailure.toast.title', { + defaultMessage: 'Error while deleting linked dashboards', + }), +}; + +const decodeResponse = (response: any) => { + return pipe( + InfraCustomDashboardRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; + +export const useUpdateCustomDashboard = () => { + const { services } = useKibanaContextForPlugin(); + const { notifications } = useKibana(); + + const onError = useCallback( + (errorMessage: string) => { + if (errorMessage) { + notifications.toasts.danger({ + title: errorMessages.update, + body: errorMessage, + }); + } + }, + [notifications.toasts] + ); + + const [updateCustomDashboardRequest, updateCustomDashboard] = useTrackedPromise( + { + cancelPreviousOn: 'resolution', + createPromise: async ({ + assetType, + id, + dashboardSavedObjectId, + dashboardFilterAssetIdEnabled, + }: InfraSavedCustomDashboard) => { + const rawResponse = await services.http.fetch( + `/api/infra/${assetType}/custom-dashboards/${id}`, + { + method: 'PUT', + body: JSON.stringify({ + assetType, + dashboardSavedObjectId, + dashboardFilterAssetIdEnabled, + }), + } + ); + + return decodeResponse(rawResponse); + }, + onResolve: (response) => response, + onReject: (e: Error | unknown) => onError((e as Error)?.message), + }, + [] + ); + + const isUpdateLoading = updateCustomDashboardRequest.state === 'pending'; + + const hasUpdateError = updateCustomDashboardRequest.state === 'rejected'; + + return { + updateCustomDashboard, + isUpdateLoading, + hasUpdateError, + }; +}; + +export const useDeleteCustomDashboard = () => { + const { services } = useKibanaContextForPlugin(); + const { notifications } = useKibana(); + + const decodeDeleteResponse = (response: any) => { + return pipe( + InfraDeleteCustomDashboardsResponseBodyRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); + }; + + const onError = useCallback( + (errorMessage: string) => { + if (errorMessage) { + notifications.toasts.danger({ + title: errorMessages.delete, + body: errorMessage, + }); + } + }, + [notifications.toasts] + ); + + const [deleteCustomDashboardRequest, deleteCustomDashboard] = useTrackedPromise( + { + cancelPreviousOn: 'resolution', + createPromise: async ({ + assetType, + id, + }: { + assetType: InfraCustomDashboardAssetType; + id: string; + }) => { + const rawResponse = await services.http.fetch( + `/api/infra/${assetType}/custom-dashboards/${id}`, + { + method: 'DELETE', + } + ); + + return decodeDeleteResponse(rawResponse); + }, + onResolve: (response) => response, + onReject: (e: Error | unknown) => onError((e as Error)?.message), + }, + [] + ); + + const isDeleteLoading = deleteCustomDashboardRequest.state === 'pending'; + + const hasDeleteError = deleteCustomDashboardRequest.state === 'rejected'; + + return { + deleteCustomDashboard, + isDeleteLoading, + hasDeleteError, + }; +}; + +export const useCreateCustomDashboard = () => { + const { services } = useKibanaContextForPlugin(); + const { notifications } = useKibana(); + + const onError = useCallback( + (errorMessage: string) => { + if (errorMessage) { + notifications.toasts.danger({ + title: errorMessages.delete, + body: errorMessage, + }); + } + }, + [notifications.toasts] + ); + + const [createCustomDashboardRequest, createCustomDashboard] = useTrackedPromise( + { + cancelPreviousOn: 'resolution', + createPromise: async ({ + assetType, + dashboardSavedObjectId, + dashboardFilterAssetIdEnabled, + }: InfraCustomDashboard) => { + const rawResponse = await services.http.fetch(`/api/infra/${assetType}/custom-dashboards`, { + method: 'POST', + body: JSON.stringify({ + dashboardSavedObjectId, + dashboardFilterAssetIdEnabled, + }), + }); + + return decodeResponse(rawResponse); + }, + onResolve: (response) => response, + onReject: (e: Error | unknown) => onError((e as Error)?.message), + }, + [] + ); + + const isCreateLoading = createCustomDashboardRequest.state === 'pending'; + + const hasCreateError = createCustomDashboardRequest.state === 'rejected'; + + return { + createCustomDashboard, + isCreateLoading, + hasCreateError, + }; +}; diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_dashboards_fetcher.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_dashboards_fetcher.ts new file mode 100644 index 0000000000000..52cce13163361 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_dashboards_fetcher.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; +import type { SearchDashboardsResponse } from '@kbn/dashboard-plugin/public/services/dashboard_content_management/lib/find_dashboards'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; + +export enum FETCH_STATUS { + LOADING = 'loading', + SUCCESS = 'success', + FAILURE = 'failure', + NOT_INITIATED = 'not_initiated', +} + +export interface SearchDashboardsResult { + data: SearchDashboardsResponse['hits']; + status: FETCH_STATUS; +} + +export function useDashboardFetcher(query = ''): SearchDashboardsResult { + const { + services: { dashboard }, + } = useKibanaContextForPlugin(); + const { notifications } = useKibana(); + const [result, setResult] = useState({ + data: [], + status: FETCH_STATUS.NOT_INITIATED, + }); + + useEffect(() => { + const getDashboards = async () => { + setResult({ + data: [], + status: FETCH_STATUS.LOADING, + }); + try { + const findDashboardsService = await dashboard?.findDashboardsService(); + const data = await findDashboardsService.search({ + search: query, + size: 1000, + }); + + setResult({ + data: data.hits, + status: FETCH_STATUS.SUCCESS, + }); + } catch (error) { + setResult({ + data: [], + status: FETCH_STATUS.FAILURE, + }); + notifications.toasts.danger({ + title: i18n.translate('xpack.infra.fetchingDashboards.addFailure.toast.title', { + defaultMessage: 'Error while fetching dashboards', + }), + body: error.message, + }); + } + }; + getDashboards(); + }, [dashboard, notifications.toasts, query]); + return result; +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_fetch_custom_dashboards.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_fetch_custom_dashboards.ts new file mode 100644 index 0000000000000..c285aa9a931bd --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_fetch_custom_dashboards.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect } from 'react'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import type { InventoryItemType } from '@kbn/metrics-data-access-plugin/common'; +import type { InfraSavedCustomDashboard } from '../../../../common/custom_dashboards'; +import { InfraGetCustomDashboardsResponseBodyRT } from '../../../../common/http_api/custom_dashboards_api'; +import { useHTTPRequest } from '../../../hooks/use_http_request'; +import { throwErrors, createPlainError } from '../../../../common/runtime_types'; +import { useRequestObservable } from './use_request_observable'; + +interface UseDashboardProps { + assetType: InventoryItemType; +} + +export function useFetchCustomDashboards({ assetType }: UseDashboardProps) { + const { request$ } = useRequestObservable(); + + const decodeResponse = (response: any) => { + return pipe( + InfraGetCustomDashboardsResponseBodyRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); + }; + + const { error, loading, response, makeRequest } = useHTTPRequest( + `/api/infra/${assetType}/custom-dashboards`, + 'GET', + undefined, + decodeResponse, + undefined, + undefined, + true + ); + + useEffect(() => { + if (request$) { + request$.next(makeRequest); + } else { + makeRequest(); + } + }, [makeRequest, request$]); + + return { + error: (error && error.message) || null, + loading, + dashboards: response, + reload: makeRequest, + }; +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_page_header.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_page_header.tsx index fc4577227f615..c13bffae7ac9f 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_page_header.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_page_header.tsx @@ -12,6 +12,8 @@ import { type EuiPageHeaderProps, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; +import { enableInfrastructureAssetCustomDashboards } from '@kbn/observability-plugin/common'; import { useLinkProps } from '@kbn/observability-shared-plugin/public'; import { capitalize } from 'lodash'; import React, { useCallback, useMemo } from 'react'; @@ -111,13 +113,17 @@ const useRightSideItems = (links?: LinkOptions[]) => { const useFeatureFlagTabs = () => { const { featureFlags } = usePluginConfig(); const isProfilingEnabled = useProfilingIntegrationSetting(); + const isInfrastructureAssetCustomDashboardsEnabled = useUiSetting( + enableInfrastructureAssetCustomDashboards + ); const featureFlagControlledTabs: Partial> = useMemo( () => ({ [ContentTabIds.OSQUERY]: featureFlags.osqueryEnabled, [ContentTabIds.PROFILING]: isProfilingEnabled, + [ContentTabIds.DASHBOARDS]: isInfrastructureAssetCustomDashboardsEnabled, }), - [featureFlags.osqueryEnabled, isProfilingEnabled] + [featureFlags.osqueryEnabled, isInfrastructureAssetCustomDashboardsEnabled, isProfilingEnabled] ); const isTabEnabled = useCallback( diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/edit_dashboard.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/edit_dashboard.tsx new file mode 100644 index 0000000000000..c5748dc641185 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/edit_dashboard.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState, useCallback } from 'react'; +import type { + DashboardItemWithTitle, + InfraCustomDashboardAssetType, +} from '../../../../../../common/custom_dashboards'; +import { SaveDashboardModal } from './save_dashboard_modal'; + +export function EditDashboard({ + onRefresh, + currentDashboard, + assetType, +}: { + onRefresh: () => void; + currentDashboard: DashboardItemWithTitle; + assetType: InfraCustomDashboardAssetType; +}) { + const [isModalVisible, setIsModalVisible] = useState(false); + const onClick = useCallback(() => setIsModalVisible(!isModalVisible), [isModalVisible]); + + return ( + <> + + {i18n.translate('xpack.infra.customDashboards.editEmptyButtonLabel', { + defaultMessage: 'Edit dashboard link', + })} + + + {isModalVisible && ( + + )} + + ); +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/goto_dashboard_link.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/goto_dashboard_link.tsx new file mode 100644 index 0000000000000..562edc282131a --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/goto_dashboard_link.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import type { DashboardItemWithTitle } from '../../../../../../common/custom_dashboards'; +import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana'; + +export function GotoDashboardLink({ + currentDashboard, +}: { + currentDashboard: DashboardItemWithTitle; +}) { + const { + services: { + dashboard: { locator: dashboardLocator }, + }, + } = useKibanaContextForPlugin(); + + const url = dashboardLocator?.getRedirectUrl({ + dashboardId: currentDashboard?.dashboardSavedObjectId, + }); + return ( + + {i18n.translate('xpack.infra.customDashboards.contextMenu.goToDashboard', { + defaultMessage: 'Go to dashboard', + })} + + ); +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/index.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/index.ts new file mode 100644 index 0000000000000..a7cac82c1a84f --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { LinkDashboard } from './link_dashboard'; +export { GotoDashboardLink } from './goto_dashboard_link'; +export { EditDashboard } from './edit_dashboard'; +export { UnlinkDashboard } from './unlink_dashboard'; diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/link_dashboard.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/link_dashboard.tsx new file mode 100644 index 0000000000000..336f418a583f6 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/link_dashboard.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState, useCallback } from 'react'; +import type { + DashboardItemWithTitle, + InfraCustomDashboardAssetType, +} from '../../../../../../common/custom_dashboards'; +import { SaveDashboardModal } from './save_dashboard_modal'; + +export function LinkDashboard({ + onRefresh, + newDashboardButton = false, + customDashboards, + assetType, +}: { + onRefresh: () => void; + newDashboardButton?: boolean; + customDashboards?: DashboardItemWithTitle[]; + assetType: InfraCustomDashboardAssetType; +}) { + const [isModalVisible, setIsModalVisible] = useState(false); + + const onClick = useCallback(() => setIsModalVisible(true), []); + const onClose = useCallback(() => setIsModalVisible(false), []); + + return ( + <> + {newDashboardButton ? ( + + {i18n.translate('xpack.infra.assetDetails.dashboards.linkNewDashboardButtonLabel', { + defaultMessage: 'Link new dashboard', + })} + + ) : ( + + {i18n.translate('xpack.infra.assetDetails.dashboards.linkButtonLabel', { + defaultMessage: 'Link dashboard', + })} + + )} + {isModalVisible && ( + + )} + + ); +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/save_dashboard_modal.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/save_dashboard_modal.tsx new file mode 100644 index 0000000000000..bcc49694ccd32 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/save_dashboard_modal.tsx @@ -0,0 +1,275 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { + EuiButton, + EuiModal, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSwitch, + EuiModalBody, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiToolTip, + EuiIcon, + EuiButtonEmpty, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DashboardItem } from '@kbn/dashboard-plugin/common/content_management'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { + DashboardItemWithTitle, + InfraCustomDashboardAssetType, +} from '../../../../../../common/custom_dashboards'; +import { useDashboardFetcher, FETCH_STATUS } from '../../../hooks/use_dashboards_fetcher'; +import { + useUpdateCustomDashboard, + useCreateCustomDashboard, +} from '../../../hooks/use_custom_dashboards'; +import { useAssetDetailsUrlState } from '../../../hooks/use_asset_details_url_state'; + +interface Props { + onClose: () => void; + onRefresh: () => void; + currentDashboard?: DashboardItemWithTitle; + customDashboards?: DashboardItemWithTitle[]; + assetType: InfraCustomDashboardAssetType; +} + +export function SaveDashboardModal({ + onClose, + onRefresh, + currentDashboard, + customDashboards, + assetType, +}: Props) { + const { notifications } = useKibana(); + const { data: allAvailableDashboards, status } = useDashboardFetcher(); + const [, setUrlState] = useAssetDetailsUrlState(); + const { euiTheme } = useEuiTheme(); + + const [assetNameEnabled, setAssetNameFiltersEnabled] = useState( + currentDashboard?.dashboardFilterAssetIdEnabled ?? true + ); + const [selectedDashboard, setSelectedDashboard] = useState< + Array> + >( + currentDashboard + ? [{ label: currentDashboard.title, value: currentDashboard.dashboardSavedObjectId }] + : [] + ); + + const { isUpdateLoading, updateCustomDashboard } = useUpdateCustomDashboard(); + const { isCreateLoading, createCustomDashboard } = useCreateCustomDashboard(); + + const isEditMode = !!currentDashboard?.id; + const loading = isUpdateLoading || isCreateLoading; + + const options = useMemo( + () => + allAvailableDashboards?.map((dashboardItem: DashboardItem) => ({ + label: dashboardItem.attributes.title, + value: dashboardItem.id, + disabled: + customDashboards?.some( + ({ dashboardSavedObjectId }) => dashboardItem.id === dashboardSavedObjectId + ) ?? false, + })), + [allAvailableDashboards, customDashboards] + ); + + const onChange = useCallback( + () => setAssetNameFiltersEnabled(!assetNameEnabled), + [assetNameEnabled] + ); + const onSelect = useCallback((newSelection) => setSelectedDashboard(newSelection), []); + + const onClickSave = useCallback( + async function () { + const [newDashboard] = selectedDashboard; + try { + if (!newDashboard.value) { + return; + } + + const dashboardParams = { + assetType, + dashboardSavedObjectId: newDashboard.value, + dashboardFilterAssetIdEnabled: assetNameEnabled, + }; + + const result = + isEditMode && currentDashboard?.id + ? await updateCustomDashboard({ + ...dashboardParams, + id: currentDashboard.id, + }) + : await createCustomDashboard(dashboardParams); + + const getToastLabels = isEditMode ? getEditSuccessToastLabels : getLinkSuccessToastLabels; + + if (result && !(isEditMode ? isUpdateLoading : isCreateLoading)) { + notifications.toasts.success(getToastLabels(newDashboard.label)); + } + + setUrlState({ dashboardId: newDashboard.value }); + onRefresh(); + } catch (error) { + notifications.toasts.danger({ + title: i18n.translate('xpack.infra.customDashboards.addFailure.toast.title', { + defaultMessage: 'Error while adding "{dashboardName}" dashboard', + values: { dashboardName: newDashboard.label }, + }), + body: error.message, + }); + } + onClose(); + }, + [ + selectedDashboard, + onClose, + isEditMode, + setUrlState, + onRefresh, + updateCustomDashboard, + assetType, + currentDashboard?.id, + assetNameEnabled, + isUpdateLoading, + notifications.toasts, + createCustomDashboard, + isCreateLoading, + ] + ); + + return ( + + + + {isEditMode + ? i18n.translate('xpack.infra.customDashboards.selectDashboard.modalTitle.edit', { + defaultMessage: 'Edit dashboard', + }) + : i18n.translate('xpack.infra.customDashboards.selectDashboard.modalTitle.link', { + defaultMessage: 'Select dashboard', + })} + + + + + + + + + + {i18n.translate( + 'xpack.infra.customDashboard.addDashboard.useContextFilterLabel', + { + defaultMessage: 'Filter by host name', + } + )} + + + + +

+ } + onChange={onChange} + checked={assetNameEnabled} + /> +
+
+ + + + {i18n.translate('xpack.infra.customDashboards.selectDashboard.cancel', { + defaultMessage: 'Cancel', + })} + + + {isEditMode + ? i18n.translate('xpack.infra.customDashboards.selectDashboard.edit', { + defaultMessage: 'Save', + }) + : i18n.translate('xpack.infra.customDashboards.selectDashboard.add', { + defaultMessage: 'Link dashboard', + })} + + +
+ ); +} + +function getLinkSuccessToastLabels(dashboardName: string) { + return { + title: i18n.translate('xpack.infra.customDashboards.linkSuccess.toast.title', { + defaultMessage: 'Added "{dashboardName}" dashboard', + values: { dashboardName }, + }), + body: i18n.translate('xpack.infra.customDashboards.linkSuccess.toast.text', { + defaultMessage: 'Your dashboard is now visible in the asset details page.', + }), + }; +} + +function getEditSuccessToastLabels(dashboardName: string) { + return { + title: i18n.translate('xpack.infra.customDashboards.editSuccess.toast.title', { + defaultMessage: 'Edited "{dashboardName}" dashboard', + values: { dashboardName }, + }), + body: i18n.translate('xpack.infra.customDashboards.editSuccess.toast.text', { + defaultMessage: 'Your dashboard link has been updated', + }), + }; +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/unlink_dashboard.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/unlink_dashboard.tsx new file mode 100644 index 0000000000000..f29b6f57e670a --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/unlink_dashboard.tsx @@ -0,0 +1,129 @@ +/* + * Copyright 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 { EuiButtonEmpty, EuiConfirmModal } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useState } from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { + DashboardItemWithTitle, + InfraCustomDashboardAssetType, +} from '../../../../../../common/custom_dashboards'; +import { useDeleteCustomDashboard } from '../../../hooks/use_custom_dashboards'; +import { useFetchCustomDashboards } from '../../../hooks/use_fetch_custom_dashboards'; +import { useAssetDetailsUrlState } from '../../../hooks/use_asset_details_url_state'; + +export function UnlinkDashboard({ + currentDashboard, + onRefresh, + assetType, +}: { + currentDashboard: DashboardItemWithTitle; + onRefresh: () => void; + assetType: InfraCustomDashboardAssetType; +}) { + const [isModalVisible, setIsModalVisible] = useState(false); + const { notifications } = useKibana(); + + const [, setUrlState] = useAssetDetailsUrlState(); + const { deleteCustomDashboard, isDeleteLoading } = useDeleteCustomDashboard(); + const { dashboards, loading } = useFetchCustomDashboards({ assetType }); + + const onClick = useCallback(() => setIsModalVisible(true), []); + const onCancel = useCallback(() => setIsModalVisible(false), []); + const onError = useCallback(() => setIsModalVisible(!isModalVisible), [isModalVisible]); + + const onConfirm = useCallback( + async function () { + try { + const linkedDashboards = (dashboards ?? []).filter( + ({ dashboardSavedObjectId }) => + dashboardSavedObjectId !== currentDashboard.dashboardSavedObjectId + ); + const result = await deleteCustomDashboard({ + assetType, + id: currentDashboard.id, + }); + setUrlState({ dashboardId: linkedDashboards[0]?.dashboardSavedObjectId }); + + if (result) { + notifications.toasts.success({ + title: i18n.translate('xpack.infra.customDashboards.unlinkSuccess.toast.title', { + defaultMessage: 'Unlinked "{dashboardName}" dashboard', + values: { dashboardName: currentDashboard?.title }, + }), + }); + onRefresh(); + } + } catch (error) { + notifications.toasts.danger({ + title: i18n.translate('xpack.infra.customDashboards.unlinkFailure.toast.title', { + defaultMessage: 'Error while unlinking "{dashboardName}" dashboard', + values: { dashboardName: currentDashboard?.title }, + }), + body: error.body.message, + }); + } + onError(); + }, + [ + onError, + dashboards, + deleteCustomDashboard, + assetType, + currentDashboard.id, + currentDashboard.dashboardSavedObjectId, + currentDashboard?.title, + setUrlState, + notifications.toasts, + onRefresh, + ] + ); + + return ( + <> + + {i18n.translate('xpack.infra.customDashboards.unlinkEmptyButtonLabel', { + defaultMessage: 'Unlink dashboard', + })} + + {isModalVisible && ( + +

+ {i18n.translate('xpack.infra.customDashboards.unlinkEmptyButtonLabel.confirm.body', { + defaultMessage: `You are about to unlink the dashboard from the {assetType} context`, + values: { assetType }, + })} +

+
+ )} + + ); +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/context_menu.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/context_menu.tsx new file mode 100644 index 0000000000000..d9148633a4b0d --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/context_menu.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +import React from 'react'; +import { EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui'; +import { useBoolean } from '../../../../hooks/use_boolean'; + +interface Props { + items: React.ReactNode[]; +} + +export function ContextMenu({ items }: Props) { + const [isPopoverOpen, { off: closePopover, toggle: togglePopover }] = useBoolean(false); + + return ( + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + ( + + {item} + + ))} + /> + + ); +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboard_selector.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboard_selector.tsx new file mode 100644 index 0000000000000..e8ff7000581da --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboard_selector.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import useMount from 'react-use/lib/useMount'; +import { EuiComboBox } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DashboardItemWithTitle } from '../../../../../common/custom_dashboards'; +import { useAssetDetailsUrlState } from '../../hooks/use_asset_details_url_state'; + +interface Props { + customDashboards: DashboardItemWithTitle[]; + currentDashboardId?: string; + setCurrentDashboard: (newDashboard: DashboardItemWithTitle) => void; + onRefresh: () => void; +} + +export function DashboardSelector({ + customDashboards, + currentDashboardId, + setCurrentDashboard, + onRefresh, +}: Props) { + const [, setUrlState] = useAssetDetailsUrlState(); + + const [selectedDashboard, setSelectedDashboard] = useState(); + + useMount(() => { + if (!currentDashboardId) { + setUrlState({ dashboardId: customDashboards[0]?.dashboardSavedObjectId }); + } + }); + + useEffect(() => { + const preselectedDashboard = customDashboards.find( + ({ dashboardSavedObjectId }) => dashboardSavedObjectId === currentDashboardId + ); + // preselect dashboard + if (preselectedDashboard) { + setSelectedDashboard(preselectedDashboard); + setCurrentDashboard(preselectedDashboard); + } + }, [customDashboards, currentDashboardId, setCurrentDashboard]); + + const onChange = useCallback( + (newDashboardId?: string) => { + setUrlState({ dashboardId: newDashboardId }); + onRefresh(); + }, + [onRefresh, setUrlState] + ); + + return ( + { + return { + label: title, + value: dashboardSavedObjectId, + }; + })} + selectedOptions={ + selectedDashboard + ? [ + { + value: selectedDashboard.dashboardSavedObjectId, + label: selectedDashboard.title, + }, + ] + : [] + } + onChange={([newItem]) => onChange(newItem.value)} + isClearable={false} + /> + ); +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboards.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboards.tsx new file mode 100644 index 0000000000000..495cc306f02b8 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboards.tsx @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useCallback, useEffect, useState } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiSpacer, + EuiEmptyPrompt, + EuiLoadingLogo, +} from '@elastic/eui'; + +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { + AwaitingDashboardAPI, + DashboardCreationOptions, + DashboardRenderer, +} from '@kbn/dashboard-plugin/public'; + +import type { DashboardItem } from '@kbn/dashboard-plugin/common/content_management'; +import { buildAssetIdFilter } from '../../../../utils/filters/build'; +import type { + InfraSavedCustomDashboard, + DashboardItemWithTitle, +} from '../../../../../common/custom_dashboards'; + +import { EmptyDashboards } from './empty_dashboards'; +import { EditDashboard, GotoDashboardLink, LinkDashboard, UnlinkDashboard } from './actions'; +import { useFetchCustomDashboards } from '../../hooks/use_fetch_custom_dashboards'; +import { useDatePickerContext } from '../../hooks/use_date_picker'; +import { useAssetDetailsRenderPropsContext } from '../../hooks/use_asset_details_render_props'; +import { FETCH_STATUS, useDashboardFetcher } from '../../hooks/use_dashboards_fetcher'; +import { useDataViewsContext } from '../../hooks/use_data_views'; +import { DashboardSelector } from './dashboard_selector'; +import { ContextMenu } from './context_menu'; +import { useAssetDetailsUrlState } from '../../hooks/use_asset_details_url_state'; + +export function Dashboards() { + const { dateRange } = useDatePickerContext(); + const { asset } = useAssetDetailsRenderPropsContext(); + const [dashboard, setDashboard] = useState(); + const [customDashboards, setCustomDashboards] = useState([]); + const [currentDashboard, setCurrentDashboard] = useState(); + const { data: allAvailableDashboards, status } = useDashboardFetcher(); + const { metrics } = useDataViewsContext(); + const [urlState, setUrlState] = useAssetDetailsUrlState(); + + const { dashboards, loading, reload } = useFetchCustomDashboards({ assetType: asset.type }); + + useEffect(() => { + const allAvailableDashboardsMap = new Map(); + allAvailableDashboards.forEach((availableDashboard) => { + allAvailableDashboardsMap.set(availableDashboard.id, availableDashboard); + }); + const filteredCustomDashboards = + dashboards?.reduce( + (result: DashboardItemWithTitle[], customDashboard: InfraSavedCustomDashboard) => { + const matchedDashboard = allAvailableDashboardsMap.get( + customDashboard.dashboardSavedObjectId + ); + if (matchedDashboard) { + result.push({ + title: matchedDashboard.attributes.title, + ...customDashboard, + }); + } + return result; + }, + [] + ) ?? []; + setCustomDashboards(filteredCustomDashboards); + // set a default dashboard if there is no selected dashboard + if (!urlState?.dashboardId) { + setUrlState({ + dashboardId: + currentDashboard?.dashboardSavedObjectId ?? + filteredCustomDashboards[0]?.dashboardSavedObjectId, + }); + } + }, [ + allAvailableDashboards, + currentDashboard?.dashboardSavedObjectId, + dashboards, + setUrlState, + urlState?.dashboardId, + ]); + + const getCreationOptions = useCallback((): Promise => { + const getInitialInput = () => ({ + viewMode: ViewMode.VIEW, + timeRange: { from: dateRange.from, to: dateRange.to }, + }); + return Promise.resolve({ getInitialInput }); + }, [dateRange.from, dateRange.to]); + + useEffect(() => { + if (!dashboard) return; + dashboard.updateInput({ + filters: + metrics.dataView && currentDashboard?.dashboardFilterAssetIdEnabled + ? buildAssetIdFilter(asset.name, asset.type, metrics.dataView) + : [], + timeRange: { from: dateRange.from, to: dateRange.to }, + }); + }, [ + metrics.dataView, + asset.name, + dashboard, + dateRange.from, + dateRange.to, + currentDashboard, + asset.type, + ]); + + if (loading || status === FETCH_STATUS.LOADING) { + return ( + + } + title={ +

+ {i18n.translate('xpack.infra.customDashboards.loadingCustomDashboards', { + defaultMessage: 'Loading dashboard', + })} +

+ } + /> +
+ ); + } + + return ( + + {!!dashboards?.length ? ( + <> + + + +

{currentDashboard?.title}

+
+
+ + + + + + {currentDashboard && ( + + , + , + , + , + ]} + /> + + )} +
+ + + {urlState?.dashboardId && ( + + )} + + + ) : ( + + } + /> + )} +
+ ); +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/empty_dashboards.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/empty_dashboards.tsx new file mode 100644 index 0000000000000..019818f6cc524 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/empty_dashboards.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiEmptyPrompt, EuiImage } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { dashboardsDark, dashboardsLight } from '@kbn/shared-svg'; +import { useIsDarkMode } from '../../../../hooks/use_is_dark_mode'; + +interface Props { + actions: React.ReactNode; +} + +export function EmptyDashboards({ actions }: Props) { + const isDarkMode = useIsDarkMode(); + + return ( + + } + title={ +

+ {i18n.translate('xpack.infra.assetDetails.dashboards.emptyTitle', { + defaultMessage: 'Want your own view?', + })} +

+ } + layout="horizontal" + color="plain" + body={ + <> +
    +
  • + {i18n.translate('xpack.infra.assetDetails.dashboards.emptyBody.first', { + defaultMessage: 'Link your own dashboard to this view', + })} +
  • +
  • + {i18n.translate('xpack.infra.assetDetails.dashboards.emptyBody.second', { + defaultMessage: 'Provide the best visualizations relevant to your business', + })} +
  • +
  • + {i18n.translate('xpack.infra.assetDetails.dashboards.emptyBody', { + defaultMessage: 'Add or remove them at any time', + })} +
  • +
+

+ {i18n.translate('xpack.infra.assetDetails.dashboards.emptyBody.getStarted', { + defaultMessage: 'To get started, add your dashboard', + })} +

+ + } + actions={actions} + /> + ); +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/index.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/index.ts index 779531d15910b..0fa4f76f13f7c 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/index.ts +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/index.ts @@ -12,3 +12,4 @@ export { Profiling } from './profiling/profiling'; export { Osquery } from './osquery/osquery'; export { Logs } from './logs/logs'; export { Overview } from './overview/overview'; +export { Dashboards } from './dashboards/dashboards'; diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/types.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/types.ts index 03ae24810e217..5e0124fa8a69c 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/types.ts +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/types.ts @@ -26,6 +26,7 @@ export enum ContentTabIds { OSQUERY = 'osquery', LOGS = 'logs', LINK_TO_APM = 'linkToApm', + DASHBOARDS = 'dashboards', } export type TabIds = `${ContentTabIds}`; diff --git a/x-pack/plugins/observability_solution/infra/public/hooks/use_saved_views_notifier.ts b/x-pack/plugins/observability_solution/infra/public/hooks/use_saved_views_notifier.ts index b7b8822e3ec14..54035bce5f1b1 100644 --- a/x-pack/plugins/observability_solution/infra/public/hooks/use_saved_views_notifier.ts +++ b/x-pack/plugins/observability_solution/infra/public/hooks/use_saved_views_notifier.ts @@ -17,7 +17,7 @@ export const useSavedViewsNotifier = () => { title: message || i18n.translate('xpack.infra.savedView.errorOnDelete.title', { - defaultMessage: `An error occured deleting the view.`, + defaultMessage: `An error occurred deleting the view.`, }), }); }; @@ -39,7 +39,7 @@ export const useSavedViewsNotifier = () => { title: message || i18n.translate('xpack.infra.savedView.errorOnCreate.title', { - defaultMessage: `An error occured saving view.`, + defaultMessage: `An error occurred saving view.`, }), }); }; diff --git a/x-pack/plugins/observability_solution/infra/public/types.ts b/x-pack/plugins/observability_solution/infra/public/types.ts index a3c43378a565b..3ef3dbfdef6b2 100644 --- a/x-pack/plugins/observability_solution/infra/public/types.ts +++ b/x-pack/plugins/observability_solution/infra/public/types.ts @@ -48,6 +48,7 @@ import { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assis import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { LicenseManagementUIPluginSetup } from '@kbn/license-management-plugin/public'; import type { ServerlessPluginStart } from '@kbn/serverless/public'; +import type { DashboardStart } from '@kbn/dashboard-plugin/public'; import type { UnwrapPromise } from '../common/utility_types'; import { InventoryViewsServiceStart } from './services/inventory_views'; import { MetricsExplorerViewsServiceStart } from './services/metrics_explorer_views'; @@ -90,6 +91,7 @@ export interface InfraClientStartDeps { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; discover: DiscoverStart; + dashboard: DashboardStart; embeddable?: EmbeddableStart; lens: LensPublicStart; logsShared: LogsSharedClientStartExports; diff --git a/x-pack/plugins/observability_solution/infra/public/utils/filters/build.test.ts b/x-pack/plugins/observability_solution/infra/public/utils/filters/build.test.ts new file mode 100644 index 0000000000000..44e06bd415d32 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/utils/filters/build.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { buildAssetIdFilter } from './build'; + +describe('buildAssetIdFilter', function () { + it('should build a host id filter', () => { + dataView.getFieldByName = jest.fn().mockReturnValue({ + name: 'host.id', + }); + const result = buildAssetIdFilter('host1', 'host', dataView); + expect(result[0]).toMatchObject({ query: { match_phrase: { 'host.id': 'host1' } } }); + }); + + it('should build a pod id filter', () => { + dataView.getFieldByName = jest.fn().mockReturnValue({ + name: 'kubernetes.pod.uid', + }); + const result = buildAssetIdFilter('pod1', 'pod', dataView); + expect(result[0]).toMatchObject({ query: { match_phrase: { 'kubernetes.pod.uid': 'pod1' } } }); + }); + + it('should return an empty array if the field id is not found', () => { + dataView.getFieldByName = jest.fn().mockReturnValue(undefined); + const result = buildAssetIdFilter('host1', 'host', dataView); + expect(result).toStrictEqual([]); + }); +}); diff --git a/x-pack/plugins/observability_solution/infra/public/utils/filters/build.ts b/x-pack/plugins/observability_solution/infra/public/utils/filters/build.ts index 45879017a5943..b52804390bc98 100644 --- a/x-pack/plugins/observability_solution/infra/public/utils/filters/build.ts +++ b/x-pack/plugins/observability_solution/infra/public/utils/filters/build.ts @@ -9,10 +9,12 @@ import { BooleanRelation, buildCombinedFilter, buildPhraseFilter, - Filter, + type Filter, isCombinedFilter, } from '@kbn/es-query'; import type { DataView } from '@kbn/data-views-plugin/common'; +import { findInventoryFields } from '@kbn/metrics-data-access-plugin/common'; +import type { InfraCustomDashboardAssetType } from '../../../common/custom_dashboards'; export const buildCombinedHostsFilter = ({ field, @@ -52,3 +54,12 @@ export const retrieveFieldsFromFilter = (filters: Filter[], fields: string[] = [ return fields; }; + +export const buildAssetIdFilter = ( + assetId: string, + assetType: InfraCustomDashboardAssetType, + dataView: DataView +): Filter[] => { + const assetIdField = dataView.getFieldByName(findInventoryFields(assetType).id); + return assetIdField ? [buildPhraseFilter(assetIdField, assetId, dataView)] : []; +}; diff --git a/x-pack/plugins/observability_solution/infra/tsconfig.json b/x-pack/plugins/observability_solution/infra/tsconfig.json index 0e5734b31a122..bf079001de494 100644 --- a/x-pack/plugins/observability_solution/infra/tsconfig.json +++ b/x-pack/plugins/observability_solution/infra/tsconfig.json @@ -94,6 +94,8 @@ "@kbn/serverless", "@kbn/core-lifecycle-server", "@kbn/elastic-agent-utils", + "@kbn/dashboard-plugin", + "@kbn/shared-svg", "@kbn/aiops-log-rate-analysis" ], "exclude": [ diff --git a/x-pack/test/functional/apps/infra/hosts_view.ts b/x-pack/test/functional/apps/infra/hosts_view.ts index b452241739163..9ad337dd5596f 100644 --- a/x-pack/test/functional/apps/infra/hosts_view.ts +++ b/x-pack/test/functional/apps/infra/hosts_view.ts @@ -7,10 +7,13 @@ import moment from 'moment'; import expect from '@kbn/expect'; -import { enableInfrastructureHostsView } from '@kbn/observability-plugin/common'; +import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; +import { + enableInfrastructureAssetCustomDashboards, + enableInfrastructureHostsView, +} from '@kbn/observability-plugin/common'; import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils'; import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; -import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; import { FtrProviderContext } from '../../ftr_provider_context'; import { DATES, @@ -121,6 +124,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const setHostViewEnabled = (value: boolean = true) => kibanaServer.uiSettings.update({ [enableInfrastructureHostsView]: value }); + const setCustomDashboardsEnabled = (value: boolean = true) => + kibanaServer.uiSettings.update({ [enableInfrastructureAssetCustomDashboards]: value }); + const returnTo = async (path: string, timeout = 2000) => retry.waitForWithTimeout('returned to hosts view', timeout, async () => { await browser.goBack(); @@ -189,6 +195,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('#Single Host Flyout', () => { before(async () => { await setHostViewEnabled(true); + await setCustomDashboardsEnabled(true); await pageObjects.common.navigateToApp(HOSTS_VIEW_PATH); await pageObjects.header.waitUntilLoadingHasFinished(); }); @@ -318,6 +325,16 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + describe('Dashboards Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickDashboardsTab(); + }); + + it('should render dashboards tab splash screen with option to add dashboard', async () => { + await pageObjects.assetDetails.addDashboardExists(); + }); + }); + describe('Flyout links', () => { it('should navigate to Host Details page after click', async () => { await pageObjects.assetDetails.clickOpenAsPageLink(); diff --git a/x-pack/test/functional/page_objects/asset_details.ts b/x-pack/test/functional/page_objects/asset_details.ts index ce1bbe525d042..9fb4fbc7cfe39 100644 --- a/x-pack/test/functional/page_objects/asset_details.ts +++ b/x-pack/test/functional/page_objects/asset_details.ts @@ -254,6 +254,15 @@ export function AssetDetailsProvider({ getService }: FtrProviderContext) { return testSubjects.click('infraAssetDetailsOsqueryTab'); }, + // Dashboards + async clickDashboardsTab() { + return testSubjects.click('infraAssetDetailsDashboardsTab'); + }, + + async addDashboardExists() { + await testSubjects.existOrFail('infraAddDashboard'); + }, + // APM Tab link async clickApmTabLink() { return testSubjects.click('infraAssetDetailsApmServicesLinkTab'); From f469fc2940dfceeb5624936aea39de2ce24dffdc Mon Sep 17 00:00:00 2001 From: Katerina Date: Wed, 10 Apr 2024 14:49:01 +0300 Subject: [PATCH 05/55] [APM] Format comparison value for all overview mobile metrics (#180353) ## Summary Fixes https://github.com/elastic/kibana/issues/174551 ### before ![Screenshot 2024-04-09 at 12 10 11](https://github.com/elastic/kibana/assets/3369346/4ebc02f0-f2ff-43e6-877b-27756272a5ab) ### after ![Screenshot 2024-04-09 at 12 23 49](https://github.com/elastic/kibana/assets/3369346/990f08da-8e7a-40db-9fdd-708c19fa2c3a) --- .../components/app/mobile/service_overview/stats/stats.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/mobile/service_overview/stats/stats.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/mobile/service_overview/stats/stats.tsx index e75b2ffe38aa9..89efdc87ec7cd 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/mobile/service_overview/stats/stats.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/mobile/service_overview/stats/stats.tsx @@ -73,7 +73,9 @@ export function MobileStats({ return ( {value && comparisonEnabled - ? `${previousPeriodLabel}: ${value}` + ? `${previousPeriodLabel}: ${ + Number.isInteger(value) ? value : value.toFixed(2) + }` : null} ); @@ -132,7 +134,7 @@ export function MobileStats({ : valueFormatter(Number(value.toFixed(1)), 'ms'), trend: data?.currentPeriod?.launchTimes?.timeseries ?? [], extra: getComparisonValueFormatter( - data?.previousPeriod.launchTimes?.value?.toFixed(1) + data?.previousPeriod.launchTimes?.value ), trendShape: MetricTrendShape.Area, }, From eb92484b6cb9c933f41375d1c39a2fac70a70023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 10 Apr 2024 12:49:47 +0100 Subject: [PATCH 06/55] [Serverless] Remove visualisation link from side navs (#180387) --- .../public/navigation_tree.ts | 13 ------------- .../serverless_search/public/navigation_tree.ts | 13 ------------- x-pack/plugins/translations/translations/fr-FR.json | 4 +--- x-pack/plugins/translations/translations/ja-JP.json | 4 +--- x-pack/plugins/translations/translations/zh-CN.json | 4 +--- .../test_suites/search/default_dataview.ts | 9 --------- 6 files changed, 3 insertions(+), 44 deletions(-) diff --git a/x-pack/plugins/serverless_observability/public/navigation_tree.ts b/x-pack/plugins/serverless_observability/public/navigation_tree.ts index b65cbb3437efa..a3421c65bda38 100644 --- a/x-pack/plugins/serverless_observability/public/navigation_tree.ts +++ b/x-pack/plugins/serverless_observability/public/navigation_tree.ts @@ -48,19 +48,6 @@ export const navigationTree: NavigationTreeDefinition = { return pathNameSerialized.startsWith(prepend('/app/dashboards')); }, }, - { - title: i18n.translate('xpack.serverlessObservability.nav.visualizations', { - defaultMessage: 'Visualizations', - }), - link: 'visualize', - getIsActive: ({ pathNameSerialized, prepend }) => { - return ( - pathNameSerialized.startsWith(prepend('/app/visualize')) || - pathNameSerialized.startsWith(prepend('/app/lens')) || - pathNameSerialized.startsWith(prepend('/app/maps')) - ); - }, - }, { link: 'observability-overview:alerts', }, diff --git a/x-pack/plugins/serverless_search/public/navigation_tree.ts b/x-pack/plugins/serverless_search/public/navigation_tree.ts index 17c00fcf71ae2..56d1709967580 100644 --- a/x-pack/plugins/serverless_search/public/navigation_tree.ts +++ b/x-pack/plugins/serverless_search/public/navigation_tree.ts @@ -50,19 +50,6 @@ export const navigationTree: NavigationTreeDefinition = { return pathNameSerialized.startsWith(prepend('/app/dashboards')); }, }, - { - link: 'visualize', - title: i18n.translate('xpack.serverlessSearch.nav.visualize', { - defaultMessage: 'Visualizations', - }), - getIsActive: ({ pathNameSerialized, prepend }) => { - return ( - pathNameSerialized.startsWith(prepend('/app/visualize')) || - pathNameSerialized.startsWith(prepend('/app/lens')) || - pathNameSerialized.startsWith(prepend('/app/maps')) - ); - }, - }, { link: 'management:triggersActions', title: i18n.translate('xpack.serverlessSearch.nav.alerts', { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 30b08ea0f9dec..359026860d16f 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -38330,7 +38330,6 @@ "xpack.serverlessSearch.nav.mngt": "Gestion", "xpack.serverlessSearch.nav.performance": "Performances", "xpack.serverlessSearch.nav.projectSettings": "Paramètres de projet", - "xpack.serverlessSearch.nav.visualize": "Visualisations", "xpack.serverlessSearch.next": "Suivant", "xpack.serverlessSearch.optional": "Facultatif", "xpack.serverlessSearch.overview.footer.description": "Votre point de terminaison Elasticsearch est configuré et vous avez effectué quelques requêtes de base. Vous voilà prêt à approfondir les outils et les cas d'utilisation avancés.", @@ -45116,7 +45115,6 @@ "xpack.serverlessObservability.nav.ml.jobs": "Détection des anomalies", "xpack.serverlessObservability.nav.mngt": "Gestion", "xpack.serverlessObservability.nav.projectSettings": "Paramètres de projet", - "xpack.serverlessObservability.nav.synthetics": "Synthetics", - "xpack.serverlessObservability.nav.visualizations": "Visualisations" + "xpack.serverlessObservability.nav.synthetics": "Synthetics" } } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ab8b03488f6a5..2c3c3d2cf827b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -38298,7 +38298,6 @@ "xpack.serverlessSearch.nav.mngt": "管理", "xpack.serverlessSearch.nav.performance": "パフォーマンス", "xpack.serverlessSearch.nav.projectSettings": "プロジェクト設定", - "xpack.serverlessSearch.nav.visualize": "ビジュアライゼーション", "xpack.serverlessSearch.next": "次へ", "xpack.serverlessSearch.optional": "オプション", "xpack.serverlessSearch.overview.footer.description": "Elasticsearchエンドポイントが設定され、いくつかの基本的なクエリが作成されました。これで、より高度なツールやユースケースを使いこなす準備が整いました。", @@ -45086,7 +45085,6 @@ "xpack.serverlessObservability.nav.ml.jobs": "異常検知", "xpack.serverlessObservability.nav.mngt": "管理", "xpack.serverlessObservability.nav.projectSettings": "プロジェクト設定", - "xpack.serverlessObservability.nav.synthetics": "Synthetics", - "xpack.serverlessObservability.nav.visualizations": "ビジュアライゼーション" + "xpack.serverlessObservability.nav.synthetics": "Synthetics" } } diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 24a4d75a3af2c..448da6450b4d6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -38342,7 +38342,6 @@ "xpack.serverlessSearch.nav.mngt": "管理", "xpack.serverlessSearch.nav.performance": "性能", "xpack.serverlessSearch.nav.projectSettings": "项目设置", - "xpack.serverlessSearch.nav.visualize": "可视化", "xpack.serverlessSearch.next": "下一步", "xpack.serverlessSearch.optional": "可选", "xpack.serverlessSearch.overview.footer.description": "已设置您的 Elasticsearch 终端,并且您已提出一些基本查询。现在您已准备就绪,可以更深入地了解更多高级工具和用例。", @@ -45134,7 +45133,6 @@ "xpack.serverlessObservability.nav.ml.jobs": "异常检测", "xpack.serverlessObservability.nav.mngt": "管理", "xpack.serverlessObservability.nav.projectSettings": "项目设置", - "xpack.serverlessObservability.nav.synthetics": "Synthetics", - "xpack.serverlessObservability.nav.visualizations": "可视化" + "xpack.serverlessObservability.nav.synthetics": "Synthetics" } } diff --git a/x-pack/test_serverless/functional/test_suites/search/default_dataview.ts b/x-pack/test_serverless/functional/test_suites/search/default_dataview.ts index 2beb234f688f4..e2ec4c24e7016 100644 --- a/x-pack/test_serverless/functional/test_suites/search/default_dataview.ts +++ b/x-pack/test_serverless/functional/test_suites/search/default_dataview.ts @@ -43,14 +43,5 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { text: 'Editing New Dashboard', }); }); - - it('should show dashboard but with no data in visualize', async () => { - await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'visualize' }); - await testSubjects.existOrFail('~breadcrumb-deepLinkId-visualize'); - await testSubjects.existOrFail('top-nav'); - await testSubjects.click('newItemButton'); - await testSubjects.existOrFail('visType-lens'); - await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ text: 'Visualizations' }); - }); }); } From ba2c27c95afb08936397e50be15d738a117b98f4 Mon Sep 17 00:00:00 2001 From: Kurt Date: Wed, 10 Apr 2024 08:13:17 -0400 Subject: [PATCH 07/55] Upgrading express from 4.17.3 --> 4.19.2 (#180373) ## Summary Upgrading `express` from 4.17.3. to 4.19.2 [Changelog](https://github.com/expressjs/express/compare/4.17.3...4.19.2) --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index bea08f4cd4b63..19de892abf9c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17027,9 +17027,9 @@ expr-eval@^2.0.2: integrity sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg== express@^4.17.1, express@^4.17.3: - version "4.17.3" - resolved "https://registry.yarnpkg.com/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1" - integrity sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg== + version "4.19.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" + integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== dependencies: accepts "~1.3.8" array-flatten "1.1.1" From 72c80ff74c504e555beed3d857f610e7e20e4e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 10 Apr 2024 13:51:42 +0100 Subject: [PATCH 08/55] [Stateful sidenav] Put new nav behind launch darkly flag (#179913) --- .buildkite/ftr_configs.yml | 1 - .../src/chrome_service.tsx | 1 - .../project_navigation_service.test.ts | 19 -- .../project_navigation_service.ts | 18 +- src/plugins/navigation/common/constants.ts | 6 +- src/plugins/navigation/common/index.ts | 6 +- src/plugins/navigation/kibana.jsonc | 2 +- src/plugins/navigation/public/index.ts | 1 - src/plugins/navigation/public/plugin.test.ts | 302 ++++++++++-------- src/plugins/navigation/public/plugin.tsx | 116 +++---- src/plugins/navigation/public/types.ts | 10 +- src/plugins/navigation/server/config.ts | 7 +- src/plugins/navigation/server/plugin.ts | 26 +- src/plugins/navigation/server/types.ts | 13 +- src/plugins/navigation/server/ui_settings.ts | 76 +---- src/plugins/navigation/tsconfig.json | 1 + test/functional/apps/navigation/config.ts | 35 -- test/functional/apps/navigation/index.ts | 15 - .../test_suites/core_plugins/rendering.ts | 2 - .../cloud_experiments/common/constants.ts | 4 + .../test/functional/apps/navigation/config.ts | 6 +- .../functional/apps/navigation/tests/index.ts | 1 + .../navigation/tests/solution_nav_switcher.ts | 8 +- 23 files changed, 276 insertions(+), 400 deletions(-) delete mode 100644 test/functional/apps/navigation/config.ts delete mode 100644 test/functional/apps/navigation/index.ts rename test/functional/apps/navigation/_solution_nav_switcher.ts => x-pack/test/functional/apps/navigation/tests/solution_nav_switcher.ts (84%) diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index ee6bb5ca8ffc1..4bca3d4b02b04 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -122,7 +122,6 @@ enabled: - test/functional/apps/home/config.ts - test/functional/apps/kibana_overview/config.ts - test/functional/apps/management/config.ts - - test/functional/apps/navigation/config.ts - test/functional/apps/saved_objects_management/config.ts - test/functional/apps/sharing/config.ts - test/functional/apps/status_page/config.ts 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 664230d749d0b..1d48fdbb24dc0 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 @@ -245,7 +245,6 @@ export class ChromeService { http, chromeBreadcrumbs$: breadcrumbs$, logger: this.logger, - setChromeStyle, }); const recentlyAccessed = await this.recentlyAccessed.start({ http }); const docTitle = this.docTitle.start(); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts index 52e22f7aef03a..508e72c5b86b4 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts @@ -60,11 +60,9 @@ const logger = loggerMock.create(); const setup = ({ locationPathName = '/', navLinkIds, - setChromeStyle = jest.fn(), }: { locationPathName?: string; navLinkIds?: Readonly; - setChromeStyle?: () => void; } = {}) => { const history = createMemoryHistory({ initialEntries: [locationPathName], @@ -87,7 +85,6 @@ const setup = ({ http: httpServiceMock.createStartContract(), chromeBreadcrumbs$, logger, - setChromeStyle, }); return { projectNavigation, history, chromeBreadcrumbs$, navLinksService, application }; @@ -1018,22 +1015,6 @@ describe('solution navigations', () => { ); }); - it('should set the Chrome style when the active solution navigation changes', async () => { - const setChromeStyle = jest.fn(); - const { projectNavigation } = setup({ setChromeStyle }); - - expect(setChromeStyle).not.toHaveBeenCalled(); - - projectNavigation.updateSolutionNavigations({ 1: solution1, 2: solution2 }); - expect(setChromeStyle).not.toHaveBeenCalled(); - - projectNavigation.changeActiveSolutionNavigation('2'); - expect(setChromeStyle).toHaveBeenCalledWith('project'); // We have an active solution nav, we should switch to project style - - projectNavigation.changeActiveSolutionNavigation(null); - expect(setChromeStyle).toHaveBeenCalledWith('classic'); // No active solution, we should switch back to classic Kibana - }); - it('should change the active solution if no node match the current Location', async () => { const { projectNavigation, navLinksService } = setup({ locationPathName: '/app/app3', // we are on app3 which only exists in solution3 diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts index 63675ea742aaf..59becdebeb406 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts @@ -16,7 +16,6 @@ import type { ChromeProjectNavigationNode, NavigationTreeDefinition, SolutionNavigationDefinitions, - ChromeStyle, CloudLinks, } from '@kbn/core-chrome-browser'; import type { InternalHttpStart } from '@kbn/core-http-browser-internal'; @@ -57,7 +56,6 @@ interface StartDeps { http: InternalHttpStart; chromeBreadcrumbs$: Observable; logger: Logger; - setChromeStyle: (style: ChromeStyle) => void; } export class ProjectNavigationService { @@ -91,23 +89,14 @@ export class ProjectNavigationService { private http?: InternalHttpStart; private navigationChangeSubscription?: Subscription; private unlistenHistory?: () => void; - private setChromeStyle: StartDeps['setChromeStyle'] = () => {}; - - public start({ - application, - navLinksService, - http, - chromeBreadcrumbs$, - logger, - setChromeStyle, - }: StartDeps) { + + public start({ application, navLinksService, http, chromeBreadcrumbs$, logger }: StartDeps) { this.application = application; this.navLinksService = navLinksService; this.http = http; this.logger = logger; this.onHistoryLocationChange(application.history.location); this.unlistenHistory = application.history.listen(this.onHistoryLocationChange.bind(this)); - this.setChromeStyle = setChromeStyle; this.handleActiveNodesChange(); this.handleEmptyActiveNodes(); @@ -418,7 +407,6 @@ export class ProjectNavigationService { // When we **do** have definitions, then passing `null` does mean we should change to "classic". if (Object.keys(definitions).length > 0) { if (id === null) { - this.setChromeStyle('classic'); this.navigationTree$.next(undefined); this.activeSolutionNavDefinitionId$.next(null); } else { @@ -427,8 +415,6 @@ export class ProjectNavigationService { throw new Error(`Solution navigation definition with id "${id}" does not exist.`); } - this.setChromeStyle('project'); - const { sideNavComponent } = definition; if (sideNavComponent) { this.setSideNavComponent(sideNavComponent); diff --git a/src/plugins/navigation/common/constants.ts b/src/plugins/navigation/common/constants.ts index 891b583b3ad84..be41e97416084 100644 --- a/src/plugins/navigation/common/constants.ts +++ b/src/plugins/navigation/common/constants.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -export const ENABLE_SOLUTION_NAV_UI_SETTING_ID = 'solutionNav:enable'; - -export const OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID = 'solutionNav:optInStatus'; +export const SOLUTION_NAV_FEATURE_FLAG_NAME = 'navigation.solutionNavEnabled'; -export const DEFAULT_SOLUTION_NAV_UI_SETTING_ID = 'solutionNav:default'; +export const ENABLE_SOLUTION_NAV_UI_SETTING_ID = 'solutionNav:enable'; diff --git a/src/plugins/navigation/common/index.ts b/src/plugins/navigation/common/index.ts index 94e93f76ba1e1..e50192be5c7a7 100644 --- a/src/plugins/navigation/common/index.ts +++ b/src/plugins/navigation/common/index.ts @@ -6,8 +6,4 @@ * Side Public License, v 1. */ -export { - DEFAULT_SOLUTION_NAV_UI_SETTING_ID, - ENABLE_SOLUTION_NAV_UI_SETTING_ID, - OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID, -} from './constants'; +export { SOLUTION_NAV_FEATURE_FLAG_NAME, ENABLE_SOLUTION_NAV_UI_SETTING_ID } from './constants'; diff --git a/src/plugins/navigation/kibana.jsonc b/src/plugins/navigation/kibana.jsonc index 73ab422db2fad..73db4955c6ee4 100644 --- a/src/plugins/navigation/kibana.jsonc +++ b/src/plugins/navigation/kibana.jsonc @@ -6,7 +6,7 @@ "id": "navigation", "server": true, "browser": true, - "optionalPlugins": ["cloud","security"], + "optionalPlugins": ["cloud","security", "cloudExperiments"], "requiredPlugins": ["unifiedSearch"], "requiredBundles": [] } diff --git a/src/plugins/navigation/public/index.ts b/src/plugins/navigation/public/index.ts index 44a90ce5598b1..2961c09e33da8 100644 --- a/src/plugins/navigation/public/index.ts +++ b/src/plugins/navigation/public/index.ts @@ -19,7 +19,6 @@ export { TopNavMenu, TopNavMenuItems, TopNavMenuBadges } from './top_nav_menu'; export type { NavigationPublicSetup as NavigationPublicPluginSetup, NavigationPublicStart as NavigationPublicPluginStart, - SolutionNavigationOptInStatus, SolutionType, } from './types'; diff --git a/src/plugins/navigation/public/plugin.test.ts b/src/plugins/navigation/public/plugin.test.ts index bc29086fe77be..84dadf234c21b 100644 --- a/src/plugins/navigation/public/plugin.test.ts +++ b/src/plugins/navigation/public/plugin.test.ts @@ -6,18 +6,15 @@ * Side Public License, v 1. */ +import { firstValueFrom, of } from 'rxjs'; import { coreMock } from '@kbn/core/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; -import { firstValueFrom, of } from 'rxjs'; +import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks'; import type { BuildFlavor } from '@kbn/config'; import type { UserSettingsData } from '@kbn/user-profile-components'; -import { - DEFAULT_SOLUTION_NAV_UI_SETTING_ID, - ENABLE_SOLUTION_NAV_UI_SETTING_ID, - OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID, -} from '../common'; +import { ENABLE_SOLUTION_NAV_UI_SETTING_ID, SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common'; import { NavigationPublicPlugin } from './plugin'; import { ConfigSchema } from './types'; import { SolutionNavUserProfileToggle } from './solution_nav_userprofile_toggle'; @@ -31,9 +28,7 @@ jest.mock('rxjs', () => { }); const defaultConfig: ConfigSchema['solutionNavigation'] = { - featureOn: true, enabled: true, - optInStatus: 'visible', defaultSolution: 'es', }; @@ -44,34 +39,62 @@ const setup = ( { buildFlavor = 'traditional', userSettings = {}, - }: { buildFlavor?: BuildFlavor; userSettings?: UserSettingsData } = {} + uiSettingsValues, + }: { + buildFlavor?: BuildFlavor; + userSettings?: UserSettingsData; + uiSettingsValues?: Record; + } = {} ) => { - const initializerContext = coreMock.createPluginInitializerContext( - { - solutionNavigation: { - ...defaultConfig, - ...partialConfig, - }, + const config = { + solutionNavigation: { + ...defaultConfig, + ...partialConfig, }, - { buildFlavor } - ); + }; + + const initializerContext = coreMock.createPluginInitializerContext(config, { buildFlavor }); const plugin = new NavigationPublicPlugin(initializerContext); + const setChromeStyle = jest.fn(); const coreStart = coreMock.createStart(); const unifiedSearch = unifiedSearchPluginMock.createStartContract(); const cloud = cloudMock.createStart(); const security = securityMock.createStart(); + const cloudExperiments = cloudExperimentsMock.createStartMock(); + cloudExperiments.getVariation.mockImplementation((key) => { + if (key === SOLUTION_NAV_FEATURE_FLAG_NAME) { + return Promise.resolve(partialConfig.featureOn); + } + return Promise.resolve(false); + }); + security.userProfiles.userProfileLoaded$ = of(true); security.userProfiles.userProfile$ = of({ userSettings }); const getGlobalSetting$ = jest.fn(); + if (uiSettingsValues) { + getGlobalSetting$.mockImplementation((settingId: string) => + of(uiSettingsValues[settingId] ?? 'unknown') + ); + } const settingsGlobalClient = { ...coreStart.settings.globalClient, get$: getGlobalSetting$, }; coreStart.settings.globalClient = settingsGlobalClient; + coreStart.chrome.setChromeStyle = setChromeStyle; - return { plugin, coreStart, unifiedSearch, cloud, security, getGlobalSetting$ }; + return { + plugin, + coreStart, + unifiedSearch, + cloud, + security, + cloudExperiments, + config, + setChromeStyle, + }; }; describe('Navigation Plugin', () => { @@ -97,21 +120,22 @@ describe('Navigation Plugin', () => { describe('feature flag enabled', () => { const featureOn = true; - it('should add the default solution navs but **not** set the active nav', () => { - const { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ } = setup({ featureOn }); - + it('should add the default solution navs but **not** set the active nav', async () => { const uiSettingsValues: Record = { [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // NOT enabled, so we should not set the active nav - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', }; - getGlobalSetting$.mockImplementation((settingId: string) => { - const value = uiSettingsValues[settingId]; - return of(value); - }); + const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + { + featureOn, + }, + { uiSettingsValues } + ); - plugin.start(coreStart, { unifiedSearch, cloud }); + plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); + + // We need to wait for the next tick to allow the promise to fetch the feature flag to resolve + await new Promise((resolve) => setTimeout(resolve)); expect(coreStart.chrome.project.updateSolutionNavigations).toHaveBeenCalled(); const [arg] = coreStart.chrome.project.updateSolutionNavigations.mock.calls[0]; @@ -120,73 +144,107 @@ describe('Navigation Plugin', () => { expect(coreStart.chrome.project.changeActiveSolutionNavigation).toHaveBeenCalledWith(null); }); - it('should add the default solution navs **and** set the active nav', () => { - const { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ } = setup({ featureOn }); - + it('should add the default solution navs **and** set the active nav', async () => { const uiSettingsValues: Record = { [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'security', }; - getGlobalSetting$.mockImplementation((settingId: string) => { - const value = uiSettingsValues[settingId] ?? 'unknown'; - return of(value); - }); + const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + { featureOn, defaultSolution: 'security' }, + { uiSettingsValues } + ); - plugin.start(coreStart, { unifiedSearch, cloud }); + plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); + await new Promise((resolve) => setTimeout(resolve)); expect(coreStart.chrome.project.updateSolutionNavigations).toHaveBeenCalled(); expect(coreStart.chrome.project.changeActiveSolutionNavigation).toHaveBeenCalledWith( - uiSettingsValues[DEFAULT_SOLUTION_NAV_UI_SETTING_ID], + 'security', { onlyIfNotSet: true } ); }); - it('if not "visible", should not set the active nav', () => { - const { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ } = setup({ featureOn }); - + it('should add the opt in/out toggle in the user menu', async () => { const uiSettingsValues: Record = { [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'hidden', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'security', }; - getGlobalSetting$.mockImplementation((settingId: string) => { - const value = uiSettingsValues[settingId] ?? 'unknown'; - return of(value); - }); + const { plugin, coreStart, unifiedSearch, cloud, security, cloudExperiments } = setup( + { + featureOn, + }, + { uiSettingsValues } + ); - plugin.start(coreStart, { unifiedSearch, cloud }); + plugin.start(coreStart, { unifiedSearch, cloud, security, cloudExperiments }); + await new Promise((resolve) => setTimeout(resolve)); - expect(coreStart.chrome.project.updateSolutionNavigations).toHaveBeenCalled(); - expect(coreStart.chrome.project.changeActiveSolutionNavigation).toHaveBeenCalledWith(null, { - onlyIfNotSet: true, - }); + expect(security.navControlService.addUserMenuLinks).toHaveBeenCalled(); + const [menuLink] = security.navControlService.addUserMenuLinks.mock.calls[0][0]; + expect((menuLink.content as any)?.type).toBe(SolutionNavUserProfileToggle); }); - it('should add the opt in/out toggle in the user menu', () => { - const { plugin, coreStart, unifiedSearch, cloud, security, getGlobalSetting$ } = setup({ - featureOn, + describe('set Chrome style', () => { + it('should set the Chrome style to "classic" when the feature is not enabled', async () => { + const uiSettingsValues: Record = { + [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, + }; + + const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + { featureOn: false }, // feature not enabled + { uiSettingsValues } + ); + + plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); + await new Promise((resolve) => setTimeout(resolve)); + expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); }); - const uiSettingsValues: Record = { - [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', - }; + it('should set the Chrome style to "classic" when the feature is enabled BUT globalSettings is disabled', async () => { + const uiSettingsValues: Record = { + [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // Global setting disabled + }; - getGlobalSetting$.mockImplementation((settingId: string) => { - const value = uiSettingsValues[settingId] ?? 'unknown'; - return of(value); + const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + { featureOn: true }, // feature enabled + { uiSettingsValues } + ); + + plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); + await new Promise((resolve) => setTimeout(resolve)); + expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); }); - plugin.start(coreStart, { unifiedSearch, cloud, security }); + it('should NOT set the Chrome style when the feature is enabled, globalSettings is enabled BUT on serverless', async () => { + const uiSettingsValues: Record = { + [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, // Global setting enabled + }; - expect(security.navControlService.addUserMenuLinks).toHaveBeenCalled(); - const [menuLink] = security.navControlService.addUserMenuLinks.mock.calls[0][0]; - expect((menuLink.content as any)?.type).toBe(SolutionNavUserProfileToggle); + const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + { featureOn: true }, // feature enabled + { uiSettingsValues, buildFlavor: 'serverless' } + ); + + plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); + await new Promise((resolve) => setTimeout(resolve)); + expect(coreStart.chrome.setChromeStyle).not.toHaveBeenCalled(); + }); + + it('should set the Chrome style to "project" when the feature is enabled', async () => { + const uiSettingsValues: Record = { + [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, + }; + + const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + { featureOn: true }, + { uiSettingsValues } + ); + + plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); + await new Promise((resolve) => setTimeout(resolve)); + expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('project'); + }); }); describe('isSolutionNavEnabled$', () => { @@ -195,8 +253,6 @@ describe('Navigation Plugin', () => { [ { [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', }, 'should be enabled', true, @@ -204,17 +260,6 @@ describe('Navigation Plugin', () => { [ { [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // feature not enabled - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', - }, - 'should not be enabled', - false, - ], - [ - { - [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'hidden', // not visible - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', }, 'should not be enabled', false, @@ -223,19 +268,26 @@ describe('Navigation Plugin', () => { testCases.forEach(([uiSettingsValues, description, expected]) => { it(description, async () => { - const { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ } = setup( + const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( { featureOn, }, - { userSettings: { solutionNavOptOut: undefined } } // user has not opted in or out + { + userSettings: { + // user has not opted in or out + solutionNavOptOut: undefined, + }, + uiSettingsValues, + } ); - getGlobalSetting$.mockImplementation((settingId: string) => { - const value = uiSettingsValues[settingId] ?? 'unknown'; - return of(value); + const { isSolutionNavEnabled$ } = plugin.start(coreStart, { + unifiedSearch, + cloud, + cloudExperiments, }); + await new Promise((resolve) => setTimeout(resolve)); - const { isSolutionNavEnabled$ } = plugin.start(coreStart, { unifiedSearch, cloud }); expect(await firstValueFrom(isSolutionNavEnabled$)).toBe(expected); }); }); @@ -246,8 +298,6 @@ describe('Navigation Plugin', () => { [ { [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', }, 'should be enabled', true, @@ -255,42 +305,35 @@ describe('Navigation Plugin', () => { [ { [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // feature not enabled - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', }, 'should not be enabled', false, ], - [ - { - [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'hidden', // not visible - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', - }, - 'should be enabled', - true, - ], ]; testCases.forEach(([uiSettingsValues, description, expected]) => { it(description, async () => { - const { plugin, coreStart, unifiedSearch, cloud, security, getGlobalSetting$ } = setup( + const { plugin, coreStart, unifiedSearch, cloud, security, cloudExperiments } = setup( { featureOn, }, - { userSettings: { solutionNavOptOut: false } } // user has opted in + { + userSettings: { + // user has opted in + solutionNavOptOut: false, + }, + uiSettingsValues, + } ); - getGlobalSetting$.mockImplementation((settingId: string) => { - const value = uiSettingsValues[settingId] ?? 'unknown'; - return of(value); - }); - const { isSolutionNavEnabled$ } = plugin.start(coreStart, { security, unifiedSearch, cloud, + cloudExperiments, }); + await new Promise((resolve) => setTimeout(resolve)); + expect(await firstValueFrom(isSolutionNavEnabled$)).toBe(expected); }); }); @@ -301,8 +344,6 @@ describe('Navigation Plugin', () => { [ { [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', }, 'should not be enabled', false, @@ -310,17 +351,6 @@ describe('Navigation Plugin', () => { [ { [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: false, // feature not enabled - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', - }, - 'should not be enabled', - false, - ], - [ - { - [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'hidden', - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', }, 'should not be enabled', false, @@ -329,45 +359,43 @@ describe('Navigation Plugin', () => { testCases.forEach(([uiSettingsValues, description, expected]) => { it(description, async () => { - const { plugin, coreStart, unifiedSearch, cloud, security, getGlobalSetting$ } = setup( + const { plugin, coreStart, unifiedSearch, cloud, security, cloudExperiments } = setup( { featureOn, }, - { userSettings: { solutionNavOptOut: true } } // user has opted out + { userSettings: { solutionNavOptOut: true }, uiSettingsValues } // user has opted out ); - getGlobalSetting$.mockImplementation((settingId: string) => { - const value = uiSettingsValues[settingId] ?? 'unknown'; - return of(value); - }); - const { isSolutionNavEnabled$ } = plugin.start(coreStart, { security, unifiedSearch, cloud, + cloudExperiments, }); + await new Promise((resolve) => setTimeout(resolve)); + expect(await firstValueFrom(isSolutionNavEnabled$)).toBe(expected); }); }); }); it('on serverless should flag must be disabled', async () => { - const { plugin, coreStart, unifiedSearch, cloud, getGlobalSetting$ } = setup( - { featureOn }, - { buildFlavor: 'serverless' } - ); const uiSettingsValues: Record = { [ENABLE_SOLUTION_NAV_UI_SETTING_ID]: true, // enabled, but we are on serverless - [OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID]: 'visible', // should not matter - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: 'es', }; - getGlobalSetting$.mockImplementation((settingId: string) => { - const value = uiSettingsValues[settingId] ?? 'unknown'; - return of(value); + const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + { featureOn }, + { buildFlavor: 'serverless', uiSettingsValues } + ); + + const { isSolutionNavEnabled$ } = plugin.start(coreStart, { + unifiedSearch, + cloud, + cloudExperiments, }); + await new Promise((resolve) => setTimeout(resolve)); - const { isSolutionNavEnabled$ } = plugin.start(coreStart, { unifiedSearch, cloud }); const isEnabled = await firstValueFrom(isSolutionNavEnabled$); expect(isEnabled).toBe(false); }); diff --git a/src/plugins/navigation/public/plugin.tsx b/src/plugins/navigation/public/plugin.tsx index 623871dfdcab4..b72c3db318d20 100644 --- a/src/plugins/navigation/public/plugin.tsx +++ b/src/plugins/navigation/public/plugin.tsx @@ -10,12 +10,15 @@ import { combineLatest, debounceTime, distinctUntilChanged, + from, map, Observable, of, ReplaySubject, + shareReplay, skipWhile, switchMap, + take, takeUntil, } from 'rxjs'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; @@ -31,11 +34,7 @@ import { definition as obltDefinition } from '@kbn/solution-nav-oblt'; import { definition as analyticsDefinition } from '@kbn/solution-nav-analytics'; import type { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation'; import { UserProfileData } from '@kbn/user-profile-components'; -import { - ENABLE_SOLUTION_NAV_UI_SETTING_ID, - OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID, - DEFAULT_SOLUTION_NAV_UI_SETTING_ID, -} from '../common'; +import { ENABLE_SOLUTION_NAV_UI_SETTING_ID, SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common'; import type { NavigationPublicSetup, NavigationPublicStart, @@ -43,7 +42,6 @@ import type { NavigationPublicStartDependencies, ConfigSchema, SolutionNavigation, - SolutionNavigationOptInStatus, SolutionType, } from './types'; import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu'; @@ -88,7 +86,7 @@ export class NavigationPublicPlugin this.coreStart = core; this.depsStart = depsStart; - const { unifiedSearch, cloud, security } = depsStart; + const { unifiedSearch, cloud, security, cloudExperiments } = depsStart; const extensions = this.topNavMenuExtensionsRegistry.getAll(); const chrome = core.chrome as InternalChromeStart; @@ -129,39 +127,65 @@ export class NavigationPublicPlugin const config = this.initializerContext.config.get(); const { - solutionNavigation: { featureOn: isSolutionNavigationFeatureOn }, + solutionNavigation: { defaultSolution }, } = config; const onCloud = cloud !== undefined; // The new side nav will initially only be available to cloud users const isServerless = this.initializerContext.env.packageInfo.buildFlavor === 'serverless'; - const isSolutionNavEnabled = isSolutionNavigationFeatureOn && onCloud && !isServerless; - this.isSolutionNavEnabled$ = of(isSolutionNavEnabled); - if (isSolutionNavEnabled) { - chrome.project.setCloudUrls(cloud); - this.addDefaultSolutionNavigation({ chrome }); + let isSolutionNavExperiementEnabled$ = of(false); + this.isSolutionNavEnabled$ = of(false); + + if (cloudExperiments) { + isSolutionNavExperiementEnabled$ = + !onCloud || isServerless + ? of(false) + : from(cloudExperiments.getVariation(SOLUTION_NAV_FEATURE_FLAG_NAME, false)).pipe( + shareReplay(1) + ); - this.isSolutionNavEnabled$ = combineLatest([ - core.settings.globalClient.get$(ENABLE_SOLUTION_NAV_UI_SETTING_ID), - core.settings.globalClient.get$( - OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID - ), - this.userProfileOptOut$, - ]).pipe( - takeUntil(this.stop$), - debounceTime(10), - map(([enabled, status, userOptedOut]) => { - if (!enabled || userOptedOut === true) return false; - if (status === 'hidden' && userOptedOut === undefined) return false; - return true; + this.isSolutionNavEnabled$ = isSolutionNavExperiementEnabled$.pipe( + switchMap((isFeatureEnabled) => { + return !isFeatureEnabled + ? of(false) + : combineLatest([ + core.settings.globalClient.get$(ENABLE_SOLUTION_NAV_UI_SETTING_ID), + this.userProfileOptOut$, + ]).pipe( + takeUntil(this.stop$), + debounceTime(10), + map(([enabled, userOptedOut]) => { + if (!enabled || userOptedOut === true) return false; + return true; + }) + ); }) ); - - this.susbcribeToSolutionNavUiSettings({ core, security }); - } else if (!isServerless) { - chrome.setChromeStyle('classic'); } + this.isSolutionNavEnabled$ + .pipe(takeUntil(this.stop$), distinctUntilChanged()) + .subscribe((isSolutionNavEnabled) => { + if (isServerless) return; // Serverless already controls the chrome style + + chrome.setChromeStyle(isSolutionNavEnabled ? 'project' : 'classic'); + }); + + // Initialize the solution navigation if it is enabled + isSolutionNavExperiementEnabled$.pipe(take(1)).subscribe((isFeatureEnabled) => { + if (!isFeatureEnabled) return; + + chrome.project.setCloudUrls(cloud!); + this.addDefaultSolutionNavigation({ chrome }); + this.susbcribeToSolutionNavUiSettings({ core, security, defaultSolution }); + }); + + // Keep track of the solution navigation enabled state + let isSolutionNavEnabled = false; + this.isSolutionNavEnabled$.pipe(takeUntil(this.stop$)).subscribe((_isSolutionNavEnabled) => { + isSolutionNavEnabled = _isSolutionNavEnabled; + }); + return { ui: { TopNavMenu: createTopNav(unifiedSearch, extensions), @@ -190,25 +214,23 @@ export class NavigationPublicPlugin private susbcribeToSolutionNavUiSettings({ core, security, + defaultSolution, }: { core: CoreStart; + defaultSolution: SolutionType; security?: SecurityPluginStart; }) { const chrome = core.chrome as InternalChromeStart; combineLatest([ core.settings.globalClient.get$(ENABLE_SOLUTION_NAV_UI_SETTING_ID), - core.settings.globalClient.get$( - OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID - ), - core.settings.globalClient.get$(DEFAULT_SOLUTION_NAV_UI_SETTING_ID), this.userProfileOptOut$, ]) .pipe(takeUntil(this.stop$), debounceTime(10)) - .subscribe(([enabled, status, defaultSolution, userOptedOut]) => { + .subscribe(([enabled, userOptedOut]) => { if (enabled) { // Add menu item in the user profile menu to opt in/out of the new navigation - this.addOptInOutUserProfile({ core, security, optInStatusSetting: status, userOptedOut }); + this.addOptInOutUserProfile({ core, security, userOptedOut }); } else { // TODO. Remove the user profile menu item if the feature is disabled. // But first let's wait as maybe there will be a page refresh when opting out. @@ -218,17 +240,7 @@ export class NavigationPublicPlugin chrome.project.changeActiveSolutionNavigation(null); chrome.setChromeStyle('classic'); } else { - const changeToSolutionNav = - status === 'visible' || (status === 'hidden' && userOptedOut === false); - - if (!changeToSolutionNav) { - chrome.setChromeStyle('classic'); - } - - chrome.project.changeActiveSolutionNavigation( - changeToSolutionNav ? defaultSolution : null, - { onlyIfNotSet: true } - ); + chrome.project.changeActiveSolutionNavigation(defaultSolution, { onlyIfNotSet: true }); } }); } @@ -290,29 +302,21 @@ export class NavigationPublicPlugin sideNavComponent: this.getSideNavComponent({ dataTestSubj: 'analyticsSideNav' }), }, }; - chrome.project.updateSolutionNavigations(solutionNavs, true); } private addOptInOutUserProfile({ core, security, - optInStatusSetting, userOptedOut, }: { core: CoreStart; userOptedOut?: boolean; - optInStatusSetting?: SolutionNavigationOptInStatus; security?: SecurityPluginStart; }) { if (!security || this.userProfileMenuItemAdded) return; - let defaultOptOutValue = userOptedOut !== undefined ? userOptedOut : DEFAULT_OPT_OUT_NEW_NAV; - if (optInStatusSetting === 'visible' && userOptedOut === undefined) { - defaultOptOutValue = false; - } else if (optInStatusSetting === 'hidden' && userOptedOut === undefined) { - defaultOptOutValue = true; - } + const defaultOptOutValue = userOptedOut !== undefined ? userOptedOut : DEFAULT_OPT_OUT_NEW_NAV; const menuLink: UserMenuLink = { content: ( diff --git a/src/plugins/navigation/public/types.ts b/src/plugins/navigation/public/types.ts index e9a7c298c1a97..90eb5f8c34c13 100644 --- a/src/plugins/navigation/public/types.ts +++ b/src/plugins/navigation/public/types.ts @@ -12,6 +12,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/ import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; +import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup, createTopNav } from './top_nav_menu'; import type { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data'; @@ -46,17 +47,14 @@ export interface NavigationPublicStartDependencies { unifiedSearch: UnifiedSearchPublicPluginStart; cloud?: CloudStart; security?: SecurityPluginStart; + cloudExperiments?: CloudExperimentsPluginStart; } -export type SolutionNavigationOptInStatus = 'visible' | 'hidden' | 'ask'; - -export type SolutionType = 'es' | 'oblt' | 'security'; +export type SolutionType = 'es' | 'oblt' | 'security' | 'analytics'; export interface ConfigSchema { solutionNavigation: { - featureOn: boolean; enabled: boolean; - optInStatus: SolutionNavigationOptInStatus; - defaultSolution: SolutionType | 'ask'; + defaultSolution: SolutionType; }; } diff --git a/src/plugins/navigation/server/config.ts b/src/plugins/navigation/server/config.ts index abf62b0da37ed..5283da958c677 100644 --- a/src/plugins/navigation/server/config.ts +++ b/src/plugins/navigation/server/config.ts @@ -11,18 +11,13 @@ import type { PluginConfigDescriptor } from '@kbn/core-plugins-server'; const configSchema = schema.object({ solutionNavigation: schema.object({ - featureOn: schema.boolean({ defaultValue: false }), enabled: schema.boolean({ defaultValue: false }), - optInStatus: schema.oneOf( - [schema.literal('visible'), schema.literal('hidden'), schema.literal('ask')], - { defaultValue: 'ask' } - ), defaultSolution: schema.oneOf( [ - schema.literal('ask'), schema.literal('es'), schema.literal('oblt'), schema.literal('security'), + schema.literal('analytics'), ], { defaultValue: 'es' } ), diff --git a/src/plugins/navigation/server/plugin.ts b/src/plugins/navigation/server/plugin.ts index 2220d19cc6768..e5d83b915b897 100644 --- a/src/plugins/navigation/server/plugin.ts +++ b/src/plugins/navigation/server/plugin.ts @@ -7,6 +7,7 @@ */ import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server'; import type { UiSettingsParams } from '@kbn/core/types'; +import { SOLUTION_NAV_FEATURE_FLAG_NAME } from '../common'; import type { NavigationConfig } from './config'; import type { @@ -28,25 +29,28 @@ export class NavigationServerPlugin { constructor(private initializerContext: PluginInitializerContext) {} - setup(core: CoreSetup, plugins: NavigationServerSetupDependencies) { - if (!this.isServerless()) { + setup( + core: CoreSetup, + plugins: NavigationServerSetupDependencies + ) { + if (plugins.cloud?.isCloudEnabled && !this.isServerless()) { const config = this.initializerContext.config.get(); - if (config.solutionNavigation.featureOn) { - core.uiSettings.registerGlobal(getUiSettings(config)); - } + core.getStartServices().then(([coreStart, deps]) => { + deps.cloudExperiments?.getVariation(SOLUTION_NAV_FEATURE_FLAG_NAME, false).then((value) => { + if (value) { + core.uiSettings.registerGlobal(getUiSettings(config)); + } else { + this.removeUiSettings(coreStart, getUiSettings(config)); + } + }); + }); } return {}; } start(core: CoreStart, plugins: NavigationServerStartDependencies) { - const config = this.initializerContext.config.get(); - - if (!Boolean(config.solutionNavigation.featureOn)) { - this.removeUiSettings(core, getUiSettings(config)); - } - return {}; } diff --git a/src/plugins/navigation/server/types.ts b/src/plugins/navigation/server/types.ts index 920e1e5b8cd52..e6fadbc6ad932 100644 --- a/src/plugins/navigation/server/types.ts +++ b/src/plugins/navigation/server/types.ts @@ -5,6 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; +import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface NavigationServerSetup {} @@ -12,8 +14,11 @@ export interface NavigationServerSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface NavigationServerStart {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface NavigationServerSetupDependencies {} +export interface NavigationServerSetupDependencies { + cloud?: CloudSetup; +} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface NavigationServerStartDependencies {} +export interface NavigationServerStartDependencies { + cloudExperiments?: CloudExperimentsPluginStart; + cloud?: CloudStart; +} diff --git a/src/plugins/navigation/server/ui_settings.ts b/src/plugins/navigation/server/ui_settings.ts index 3d40ace802375..efc744ae0dd79 100644 --- a/src/plugins/navigation/server/ui_settings.ts +++ b/src/plugins/navigation/server/ui_settings.ts @@ -10,40 +10,9 @@ import { schema } from '@kbn/config-schema'; import { UiSettingsParams } from '@kbn/core/types'; import { i18n } from '@kbn/i18n'; -import { - ENABLE_SOLUTION_NAV_UI_SETTING_ID, - DEFAULT_SOLUTION_NAV_UI_SETTING_ID, - OPT_IN_STATUS_SOLUTION_NAV_UI_SETTING_ID, -} from '../common/constants'; +import { ENABLE_SOLUTION_NAV_UI_SETTING_ID } from '../common/constants'; import { NavigationConfig } from './config'; -const optInStatusOptionLabels = { - visible: i18n.translate('navigation.advancedSettings.optInVisibleStatus', { - defaultMessage: 'Visible', - }), - hidden: i18n.translate('navigation.advancedSettings.optInHiddenStatus', { - defaultMessage: 'Hidden', - }), - ask: i18n.translate('navigation.advancedSettings.optInAskStatus', { - defaultMessage: 'Ask', - }), -}; - -const solutionsOptionLabels = { - ask: i18n.translate('navigation.advancedSettings.askUserWhichSolution', { - defaultMessage: 'Ask user to choose a solution', - }), - es: i18n.translate('navigation.advancedSettings.searchSolution', { - defaultMessage: 'Search', - }), - oblt: i18n.translate('navigation.advancedSettings.observabilitySolution', { - defaultMessage: 'Observability', - }), - security: i18n.translate('navigation.advancedSettings.securitySolution', { - defaultMessage: 'Security', - }), -}; - const categoryLabel = i18n.translate('navigation.uiSettings.categoryLabel', { defaultMessage: 'Technical preview', }); @@ -59,52 +28,11 @@ export const getUiSettings = (config: NavigationConfig): Record -
  • {visible}: The new navigation is visible immediately to all user. They will be able to opt-out from their user profile.
  • -
  • {hidden}: The new navigation is hidden by default. Users can opt-in from their user profile. No banners are shown.
  • -
  • {ask}: Show a banner to the users inviting them to try the new navigation experience.
  • - `, - values: { - visible: optInStatusOptionLabels.visible, - hidden: optInStatusOptionLabels.hidden, - ask: optInStatusOptionLabels.ask, - }, - }), - name: i18n.translate('navigation.uiSettings.optInStatusSolutionNav.name', { - defaultMessage: 'Opt-in behaviour', - }), - type: 'select', - schema: schema.string(), - value: config.solutionNavigation.optInStatus, - options: ['visible', 'hidden', 'ask'], - optionLabels: optInStatusOptionLabels, - order: 2, - }, - [DEFAULT_SOLUTION_NAV_UI_SETTING_ID]: { - category: [categoryLabel], - description: i18n.translate('navigation.uiSettings.defaultSolutionNav.description', { - defaultMessage: - 'The default solution to display to the users once they opt-in to the new navigation.', - }), - name: i18n.translate('navigation.uiSettings.defaultSolutionNav.name', { - defaultMessage: 'Default solution', - }), - type: 'select', - schema: schema.string(), - value: config.solutionNavigation.defaultSolution, - options: ['ask', 'es', 'oblt', 'security'], - optionLabels: solutionsOptionLabels, - order: 2, - }, }; }; diff --git a/src/plugins/navigation/tsconfig.json b/src/plugins/navigation/tsconfig.json index ecf3aba49c9ce..4a9c4832ea42d 100644 --- a/src/plugins/navigation/tsconfig.json +++ b/src/plugins/navigation/tsconfig.json @@ -31,6 +31,7 @@ "@kbn/security-plugin", "@kbn/user-profile-components", "@kbn/core-lifecycle-browser", + "@kbn/cloud-experiments-plugin", ], "exclude": [ "target/**/*", diff --git a/test/functional/apps/navigation/config.ts b/test/functional/apps/navigation/config.ts deleted file mode 100644 index 19df1f83ec844..0000000000000 --- a/test/functional/apps/navigation/config.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { FtrConfigProviderContext } from '@kbn/test'; - -export default async function ({ readConfigFile }: FtrConfigProviderContext) { - const functionalConfig = await readConfigFile(require.resolve('../../config.base.js')); - - return { - ...functionalConfig.getAll(), - testFiles: [require.resolve('.')], - kbnTestServer: { - ...functionalConfig.get('kbnTestServer'), - serverArgs: [ - ...functionalConfig.get('kbnTestServer.serverArgs'), - '--navigation.solutionNavigation.featureOn=true', - '--navigation.solutionNavigation.enabled=true', - '--navigation.solutionNavigation.optInStatus=visible', - '--navigation.solutionNavigation.defaultSolution=es', - // Note: the base64 string in the cloud.id config contains the ES endpoint required in the functional tests - '--xpack.cloud.id=ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=', - '--xpack.cloud.base_url=https://cloud.elastic.co', - '--xpack.cloud.deployment_url=/deployments/deploymentId', - '--xpack.cloud.organization_url=/organization/organizationId', - '--xpack.cloud.billing_url=/billing', - '--xpack.cloud.profile_url=/user/userId', - ], - }, - }; -} diff --git a/test/functional/apps/navigation/index.ts b/test/functional/apps/navigation/index.ts deleted file mode 100644 index 6a05d098e794e..0000000000000 --- a/test/functional/apps/navigation/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ loadTestFile }: FtrProviderContext) { - describe('navigation app', function () { - loadTestFile(require.resolve('./_solution_nav_switcher')); - }); -} diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index d6277fbaf394c..22066abf17d20 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -160,9 +160,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'monitoring.ui.enabled (boolean)', 'monitoring.ui.min_interval_seconds (number)', 'monitoring.ui.show_license_expiration (boolean)', - 'navigation.solutionNavigation.featureOn (boolean)', 'navigation.solutionNavigation.enabled (boolean)', - 'navigation.solutionNavigation.optInStatus (alternatives)', 'navigation.solutionNavigation.defaultSolution (alternatives)', 'newsfeed.fetchInterval (duration)', 'newsfeed.mainInterval (duration)', diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts index 2ae72dabb6484..d0e77614f4be3 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts @@ -38,6 +38,10 @@ export enum FEATURE_FLAG_NAMES { * Options are: 'header' (the chat button appears as part of the kibana header) and 'bubble' (floating chat button at the bottom of the screen). */ 'cloud-chat.chat-variant' = 'cloud-chat.chat-variant', + /** + * Used to enable the new stack navigation around solutions during the rollout period. + */ + 'navigation.solutionNavEnabled' = 'navigation.solutionNavEnabled', } /** diff --git a/x-pack/test/functional/apps/navigation/config.ts b/x-pack/test/functional/apps/navigation/config.ts index 00b98c1db316a..b90946c8511fa 100644 --- a/x-pack/test/functional/apps/navigation/config.ts +++ b/x-pack/test/functional/apps/navigation/config.ts @@ -36,9 +36,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...kibanaFunctionalConfig.get('kbnTestServer'), serverArgs: [ ...kibanaFunctionalConfig.get('kbnTestServer.serverArgs'), - '--navigation.solutionNavigation.featureOn=true', + '--xpack.cloud_integrations.experiments.enabled=true', + '--xpack.cloud_integrations.experiments.launch_darkly.sdk_key=a_string', + '--xpack.cloud_integrations.experiments.launch_darkly.client_id=a_string', + '--xpack.cloud_integrations.experiments.flag_overrides.navigation.solutionNavEnabled=true', '--navigation.solutionNavigation.enabled=true', - '--navigation.solutionNavigation.optInStatus=visible', '--navigation.solutionNavigation.defaultSolution=es', // Note: the base64 string in the cloud.id config contains the ES endpoint required in the functional tests '--xpack.cloud.id=ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=', diff --git a/x-pack/test/functional/apps/navigation/tests/index.ts b/x-pack/test/functional/apps/navigation/tests/index.ts index 3f0f6acaf2231..f2a1fbc926841 100644 --- a/x-pack/test/functional/apps/navigation/tests/index.ts +++ b/x-pack/test/functional/apps/navigation/tests/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('navigation - functional tests', function () { + loadTestFile(require.resolve('./solution_nav_switcher')); loadTestFile(require.resolve('./user_optin_optout')); }); } diff --git a/test/functional/apps/navigation/_solution_nav_switcher.ts b/x-pack/test/functional/apps/navigation/tests/solution_nav_switcher.ts similarity index 84% rename from test/functional/apps/navigation/_solution_nav_switcher.ts rename to x-pack/test/functional/apps/navigation/tests/solution_nav_switcher.ts index 5cf764aa3f413..b9d9803d043c0 100644 --- a/test/functional/apps/navigation/_solution_nav_switcher.ts +++ b/x-pack/test/functional/apps/navigation/tests/solution_nav_switcher.ts @@ -1,11 +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. + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. */ -import { FtrProviderContext } from '../../ftr_provider_context'; + +import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'header', 'discover', 'dashboard']); From 4ef6fcb371cde0669052773e3941ce6900a2dd39 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Wed, 10 Apr 2024 08:53:29 -0400 Subject: [PATCH 09/55] [Observability Onboading] Export additional types for usage with Package List Grid (#180416) ## Summary Related to https://github.com/elastic/kibana/pull/179573, exporting these additional types make downstream usage of the `PackageListGrid` component and `useAvailablePackages` hook easier. Co-authored-by: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> --- .../epm/components/package_list_grid/index.stories.tsx | 4 ++-- .../sections/epm/components/package_list_grid/index.tsx | 4 ++-- .../epm/components/utils/promote_featured_integrations.ts | 2 +- .../epm/screens/home/hooks/use_available_packages.tsx | 2 ++ x-pack/plugins/fleet/public/index.ts | 4 ++++ 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx index 0cfb804980008..8639d18b1278b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; -import type { Props } from '.'; +import type { PackageListGridProps } from '.'; import { PackageListGrid } from '.'; export default { @@ -18,7 +18,7 @@ export default { }; type Args = Pick< - Props, + PackageListGridProps, 'title' | 'isLoading' | 'showMissingIntegrationMessage' | 'showControls' | 'showSearchTools' >; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx index 0ff1a708dabc2..8a6761d48f9b1 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx @@ -48,7 +48,7 @@ const StickySidebar = styled(EuiFlexItem)` top: 120px; `; -export interface Props { +export interface PackageListGridProps { isLoading?: boolean; controls?: ReactNode | ReactNode[]; list: IntegrationCardItem[]; @@ -71,7 +71,7 @@ export interface Props { showSearchTools?: boolean; } -export const PackageListGrid: FunctionComponent = ({ +export const PackageListGrid: FunctionComponent = ({ isLoading, controls, title, diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.ts index 675d3691c1fd5..07d41ea431dab 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.ts +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.ts @@ -8,7 +8,7 @@ import partition from 'lodash/partition'; import { FEATURED_INTEGRATIONS_BY_CATEGORY } from '@kbn/custom-integrations-plugin/common'; -import type { Props as PackageListGridProps } from '../package_list_grid'; +import type { PackageListGridProps } from '../package_list_grid'; type Category = PackageListGridProps['selectedCategory']; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx index 9aa242f8575b3..c7b1f936e2424 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx @@ -103,6 +103,8 @@ const packageListToIntegrationsList = (packages: PackageList): PackageList => { }, []); }; +export type AvailablePackagesHookType = typeof useAvailablePackages; + export const useAvailablePackages = ({ prereleaseIntegrationsEnabled, }: { diff --git a/x-pack/plugins/fleet/public/index.ts b/x-pack/plugins/fleet/public/index.ts index 003e3de3f35dd..685a50230912b 100644 --- a/x-pack/plugins/fleet/public/index.ts +++ b/x-pack/plugins/fleet/public/index.ts @@ -65,6 +65,10 @@ export type { PackagePolicyEditorDatastreamMappingsProps } from './applications/ export type { DynamicPagePathValues } from './constants'; +export type { PackageListGridProps } from './applications/integrations/sections/epm/components/package_list_grid'; +export type { AvailablePackagesHookType } from './applications/integrations/sections/epm/screens/home/hooks/use_available_packages'; +export type { IntegrationCardItem } from './applications/integrations/sections/epm/screens/home'; + export const PackageList = () => { return import('./applications/integrations/sections/epm/components/package_list_grid'); }; From d42c4bb6b31d39543de40178c914cdbccd8ddd28 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 10 Apr 2024 09:15:42 -0400 Subject: [PATCH 10/55] [Security Solution][Endpoint] Fix Sentinelone `isolate`/`release` new action completion logic so that its gated by feature flag (#180390) ## Summary - Updates both `release` and `isolate` actions for SentinelOne so that the new (`8.14.0`) approach for completing the response actions is gated by the `responseActionsSentinelOneV2Enabled` feature flag --- .../sentinel_one_actions_client.test.ts | 124 +++++++++++++++++- .../sentinel_one_actions_client.ts | 26 ++++ 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts index a0a585b12f7ec..9d96ca462c53d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts @@ -13,6 +13,7 @@ import { ResponseActionsNotSupportedError } from '../errors'; import type { SentinelOneActionsClientOptionsMock } from './mocks'; import { sentinelOneMock } from './mocks'; import { + ENDPOINT_ACTION_RESPONSES_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, ENDPOINT_ACTIONS_INDEX, } from '../../../../../../common/endpoint/constants'; @@ -99,7 +100,67 @@ describe('SentinelOneActionsClient class', () => { }); }); - it('should write action request and response to endpoint indexes', async () => { + it('should write action request and response to endpoint indexes when `responseActionsSentinelOneV2Enabled` FF is Disabled', async () => { + await s1ActionsClient.isolate(createS1IsolationOptions()); + + expect(classConstructorOptions.esClient.index).toHaveBeenCalledTimes(2); + expect(classConstructorOptions.esClient.index).toHaveBeenNthCalledWith( + 1, + { + document: { + '@timestamp': expect.any(String), + EndpointActions: { + action_id: expect.any(String), + data: { + command: 'isolate', + comment: 'test comment', + parameters: undefined, + hosts: { + '1-2-3': { + name: 'sentinelone-1460', + }, + }, + }, + expiration: expect.any(String), + input_type: 'sentinel_one', + type: 'INPUT_ACTION', + }, + agent: { id: ['1-2-3'] }, + user: { id: 'foo' }, + meta: { + agentId: '1845174760470303882', + agentUUID: '1-2-3', + hostName: 'sentinelone-1460', + }, + }, + index: ENDPOINT_ACTIONS_INDEX, + refresh: 'wait_for', + }, + { meta: true } + ); + + expect(classConstructorOptions.esClient.index).toHaveBeenNthCalledWith(2, { + document: { + '@timestamp': expect.any(String), + EndpointActions: { + action_id: expect.any(String), + data: { command: 'isolate' }, + input_type: 'sentinel_one', + started_at: expect.any(String), + completed_at: expect.any(String), + }, + agent: { id: ['1-2-3'] }, + error: undefined, + }, + index: ENDPOINT_ACTION_RESPONSES_INDEX, + refresh: 'wait_for', + }); + }); + + it('should write action request (only) to endpoint indexes when `responseActionsSentinelOneV2Enabled` FF is Enabled', async () => { + // @ts-expect-error updating readonly attribute + classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneV2Enabled = + true; await s1ActionsClient.isolate(createS1IsolationOptions()); expect(classConstructorOptions.esClient.index).toHaveBeenCalledTimes(1); @@ -170,7 +231,66 @@ describe('SentinelOneActionsClient class', () => { }); }); - it('should write action request and response to endpoint indexes', async () => { + it('should write action request and response to endpoint indexes when `responseActionsSentinelOneV2Enabled` is Disabled', async () => { + await s1ActionsClient.release(createS1IsolationOptions()); + + expect(classConstructorOptions.esClient.index).toHaveBeenCalledTimes(2); + expect(classConstructorOptions.esClient.index).toHaveBeenNthCalledWith( + 1, + { + document: { + '@timestamp': expect.any(String), + EndpointActions: { + action_id: expect.any(String), + data: { + command: 'unisolate', + comment: 'test comment', + parameters: undefined, + hosts: { + '1-2-3': { + name: 'sentinelone-1460', + }, + }, + }, + expiration: expect.any(String), + input_type: 'sentinel_one', + type: 'INPUT_ACTION', + }, + agent: { id: ['1-2-3'] }, + user: { id: 'foo' }, + meta: { + agentId: '1845174760470303882', + agentUUID: '1-2-3', + hostName: 'sentinelone-1460', + }, + }, + index: ENDPOINT_ACTIONS_INDEX, + refresh: 'wait_for', + }, + { meta: true } + ); + expect(classConstructorOptions.esClient.index).toHaveBeenNthCalledWith(2, { + document: { + '@timestamp': expect.any(String), + EndpointActions: { + action_id: expect.any(String), + data: { command: 'unisolate' }, + input_type: 'sentinel_one', + started_at: expect.any(String), + completed_at: expect.any(String), + }, + agent: { id: ['1-2-3'] }, + error: undefined, + }, + index: ENDPOINT_ACTION_RESPONSES_INDEX, + refresh: 'wait_for', + }); + }); + + it('should write action request (only) to endpoint indexes when `` is Enabled', async () => { + // @ts-expect-error updating readonly attribute + classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneV2Enabled = + true; await s1ActionsClient.release(createS1IsolationOptions()); expect(classConstructorOptions.esClient.index).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts index cd013128c42cf..f70305e4bac07 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts @@ -252,6 +252,19 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl { comment: reqIndexOptions.comment, }); + if ( + !actionRequestDoc.error && + !this.options.endpointService.experimentalFeatures.responseActionsSentinelOneV2Enabled + ) { + await this.writeActionResponseToEndpointIndex({ + actionId: actionRequestDoc.EndpointActions.action_id, + agentId: actionRequestDoc.agent.id, + data: { + command: actionRequestDoc.EndpointActions.data.command, + }, + }); + } + return this.fetchActionDetails(actionRequestDoc.EndpointActions.action_id); } @@ -303,6 +316,19 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl { comment: reqIndexOptions.comment, }); + if ( + !actionRequestDoc.error && + !this.options.endpointService.experimentalFeatures.responseActionsSentinelOneV2Enabled + ) { + await this.writeActionResponseToEndpointIndex({ + actionId: actionRequestDoc.EndpointActions.action_id, + agentId: actionRequestDoc.agent.id, + data: { + command: actionRequestDoc.EndpointActions.data.command, + }, + }); + } + return this.fetchActionDetails(actionRequestDoc.EndpointActions.action_id); } From 83cb0e440e4082927d231b2b2488ef432cbcaf74 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 10 Apr 2024 07:27:59 -0600 Subject: [PATCH 11/55] [embeddable rebuild] useBatchedOptionalPublishingSubjects (#180221) Fixes https://github.com/elastic/kibana/issues/180088 1. Changes observable pipe from `debounceTime(), skip(1)` to `skip(1), debounceTime()`. Updates test to verify this change results in subscription getting fired when observable.next is called before debounceTime fires. 2. rename `useBatchedPublishingSubjects` to `useBatchedOptionalPublishingSubjects`. Remove `useMemo` since spreading subjects results in new array every time and useMemo does nothing. 3. Update `PresentationPanelInternal` to use `useBatchedOptionalPublishingSubjects` 4. create new `useBatchedPublishingSubjects` that types subjects as `PublishingSubject[]`. New --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../presentation_publishing/index.ts | 1 + .../publishing_subject/index.ts | 5 +- .../publishing_subject/publishing_batcher.ts | 59 +++++++++++-- .../publishing_subject.test.tsx | 88 +++++++++++++------ .../public/components/image_embeddable.tsx | 3 +- .../presentation_panel_context_menu.tsx | 7 +- .../presentation_panel_internal.tsx | 4 +- 7 files changed, 128 insertions(+), 39 deletions(-) diff --git a/packages/presentation/presentation_publishing/index.ts b/packages/presentation/presentation_publishing/index.ts index d3764da74c6d2..e4476cbd2cff9 100644 --- a/packages/presentation/presentation_publishing/index.ts +++ b/packages/presentation/presentation_publishing/index.ts @@ -111,6 +111,7 @@ export { } from './interfaces/titles/publishes_panel_title'; export { initializeTitles, type SerializedTitles } from './interfaces/titles/titles_api'; export { + useBatchedOptionalPublishingSubjects, useBatchedPublishingSubjects, usePublishingSubject, useStateFromPublishingSubject, diff --git a/packages/presentation/presentation_publishing/publishing_subject/index.ts b/packages/presentation/presentation_publishing/publishing_subject/index.ts index 5dbd2eb95579a..022c4170f6cde 100644 --- a/packages/presentation/presentation_publishing/publishing_subject/index.ts +++ b/packages/presentation/presentation_publishing/publishing_subject/index.ts @@ -6,7 +6,10 @@ * Side Public License, v 1. */ -export { useBatchedPublishingSubjects } from './publishing_batcher'; +export { + useBatchedOptionalPublishingSubjects, + useBatchedPublishingSubjects, +} from './publishing_batcher'; export { useStateFromPublishingSubject, usePublishingSubject } from './publishing_subject'; export type { PublishingSubject, diff --git a/packages/presentation/presentation_publishing/publishing_subject/publishing_batcher.ts b/packages/presentation/presentation_publishing/publishing_subject/publishing_batcher.ts index 4624e43c2a0d1..f04661573d918 100644 --- a/packages/presentation/presentation_publishing/publishing_subject/publishing_batcher.ts +++ b/packages/presentation/presentation_publishing/publishing_subject/publishing_batcher.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { combineLatest, debounceTime, skip } from 'rxjs'; import { AnyPublishingSubject, PublishingSubject, UnwrapPublishingSubjectTuple } from './types'; @@ -25,25 +25,28 @@ const hasSubjectsArrayChanged = ( /** * Batches the latest values of multiple publishing subjects into a single object. Use this to avoid unnecessary re-renders. - * You should avoid using this hook with subjects that your component pushes values to on user interaction, as it can cause a slight delay. + * Use when `subjects` may not be defined on initial component render. + * * @param subjects Publishing subjects array. * When 'subjects' is expected to change, 'subjects' must be part of component react state. */ -export const useBatchedPublishingSubjects = ( +export const useBatchedOptionalPublishingSubjects = < + SubjectsType extends [...AnyPublishingSubject[]] +>( ...subjects: [...SubjectsType] ): UnwrapPublishingSubjectTuple => { const isFirstRender = useRef(true); - /** - * memoize and deep diff subjects to avoid rebuilding the subscription when the subjects are the same. - */ + const previousSubjects = useRef(subjects); - const subjectsToUse = useMemo(() => { + // Can not use 'useMemo' because 'subjects' gets a new reference on each call because of spread + const subjectsToUse = (() => { + // avoid rebuilding the subscription when the subjects are the same if (!hasSubjectsArrayChanged(previousSubjects.current ?? [], subjects)) { return previousSubjects.current; } previousSubjects.current = subjects; return subjects; - }, [subjects]); + })(); /** * Set up latest published values state, initialized with the current values of the subjects. @@ -94,6 +97,46 @@ export const useBatchedPublishingSubjects = >] +>( + ...subjects: [...SubjectsType] +): UnwrapPublishingSubjectTuple => { + /** + * Set up latest published values state, initialized with the current values of the subjects. + */ + const [latestPublishedValues, setLatestPublishedValues] = useState< + UnwrapPublishingSubjectTuple + >(() => unwrapPublishingSubjectArray(subjects)); + + /** + * Subscribe to all subjects and update the latest values when any of them change. + */ + useEffect(() => { + const subscription = combineLatest(subjects) + .pipe( + // When a new observer subscribes to a BehaviorSubject, it immediately receives the current value. Skip this emit. + skip(1), + debounceTime(0) + ) + .subscribe((values) => { + setLatestPublishedValues(values as UnwrapPublishingSubjectTuple); + }); + return () => subscription.unsubscribe(); + // 'subjects' gets a new reference on each call because of spread + // Use 'useBatchedOptionalPublishingSubjects' when 'subjects' are expected to change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return latestPublishedValues; +}; + const unwrapPublishingSubjectArray = ( subjects: T ): UnwrapPublishingSubjectTuple => { diff --git a/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.test.tsx b/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.test.tsx index e58ca06d54f9b..ec0d80c0dd3c9 100644 --- a/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.test.tsx +++ b/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.test.tsx @@ -11,11 +11,14 @@ import { BehaviorSubject } from 'rxjs'; import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import userEvent from '@testing-library/user-event'; -import { useBatchedPublishingSubjects } from './publishing_batcher'; +import { + useBatchedPublishingSubjects, + useBatchedOptionalPublishingSubjects, +} from './publishing_batcher'; import { useStateFromPublishingSubject } from './publishing_subject'; import { PublishingSubject } from './types'; -describe('useBatchedPublishingSubjects', () => { +describe('publishing subject', () => { describe('render', () => { let subject1: BehaviorSubject; let subject2: BehaviorSubject; @@ -56,7 +59,6 @@ describe('useBatchedPublishingSubjects', () => { <>