+
+
+
+
+
+
+ );
+};
+
+export const RiskSummary = React.memo(RiskSummaryComponent);
RiskSummary.displayName = 'RiskSummary';
diff --git a/x-pack/plugins/security_solution/public/explore/hosts/containers/hosts/details/index.tsx b/x-pack/plugins/security_solution/public/explore/hosts/containers/hosts/details/index.tsx
index f6ea1ff14a264a..490891732abd45 100644
--- a/x-pack/plugins/security_solution/public/explore/hosts/containers/hosts/details/index.tsx
+++ b/x-pack/plugins/security_solution/public/explore/hosts/containers/hosts/details/index.tsx
@@ -42,7 +42,7 @@ export const useHostDetails = ({
id = ID,
skip = false,
startDate,
-}: UseHostDetails): [boolean, HostDetailsArgs] => {
+}: UseHostDetails): [boolean, HostDetailsArgs, inputsModel.Refetch] => {
const {
loading,
result: response,
@@ -91,5 +91,5 @@ export const useHostDetails = ({
}
}, [hostDetailsRequest, search, skip]);
- return [loading, hostDetailsResponse];
+ return [loading, hostDetailsResponse, refetch];
};
diff --git a/x-pack/plugins/security_solution/public/explore/network/components/details/index.tsx b/x-pack/plugins/security_solution/public/explore/network/components/details/index.tsx
index 62c6a6f462d142..1c909a8eff31b2 100644
--- a/x-pack/plugins/security_solution/public/explore/network/components/details/index.tsx
+++ b/x-pack/plugins/security_solution/public/explore/network/components/details/index.tsx
@@ -8,9 +8,8 @@
import { euiLightVars as lightTheme, euiDarkVars as darkTheme } from '@kbn/ui-theme';
import React from 'react';
-import { DEFAULT_DARK_MODE } from '../../../../../common/constants';
import type { DescriptionList } from '../../../../../common/utility_types';
-import { useUiSetting$ } from '../../../../common/lib/kibana';
+import { useDarkMode } from '../../../../common/lib/kibana';
import type {
FlowTargetSourceDest,
NetworkDetailsStrategyResponse,
@@ -79,7 +78,7 @@ export const IpOverview = React.memo
(
}) => {
const capabilities = useMlCapabilities();
const userPermissions = hasMlUserPermissions(capabilities);
- const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE);
+ const darkMode = useDarkMode();
const typeData = data[flowTarget];
const column: DescriptionList[] = [
{
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_details_left/index.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_details_left/index.test.tsx
new file mode 100644
index 00000000000000..82cf0f8ff9f569
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_details_left/index.test.tsx
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { RISK_INPUTS_TAB_TEST_ID } from '../../../entity_analytics/components/entity_details_flyout';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { HostDetailsPanel } from '.';
+import { TestProviders } from '../../../common/mock';
+
+describe('HostDetailsPanel', () => {
+ it('render risk inputs panel', () => {
+ const { getByTestId } = render(
+ ,
+ { wrapper: TestProviders }
+ );
+ expect(getByTestId(RISK_INPUTS_TAB_TEST_ID)).toBeInTheDocument();
+ });
+
+ it("doesn't render risk inputs panel when no alerts ids are provided", () => {
+ const { queryByTestId } = render(
+ ,
+ { wrapper: TestProviders }
+ );
+ expect(queryByTestId(RISK_INPUTS_TAB_TEST_ID)).not.toBeInTheDocument();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_details_left/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_details_left/index.tsx
new file mode 100644
index 00000000000000..3214dec23bdd67
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_details_left/index.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 React, { useMemo } from 'react';
+import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
+import { getRiskInputTab } from '../../../entity_analytics/components/entity_details_flyout';
+import { LeftPanelContent } from '../shared/components/left_panel/left_panel_content';
+import {
+ EntityDetailsLeftPanelTab,
+ LeftPanelHeader,
+} from '../shared/components/left_panel/left_panel_header';
+
+interface RiskInputsParam {
+ alertIds: string[];
+}
+
+export interface HostDetailsPanelProps extends Record {
+ riskInputs: RiskInputsParam;
+}
+export interface HostDetailsExpandableFlyoutProps extends FlyoutPanelProps {
+ key: 'host_details';
+ params: HostDetailsPanelProps;
+}
+export const HostDetailsPanelKey: HostDetailsExpandableFlyoutProps['key'] = 'host_details';
+
+export const HostDetailsPanel = ({ riskInputs }: HostDetailsPanelProps) => {
+ // Temporary implementation while Host details left panel don't have Asset tabs
+ const [tabs, selectedTabId, setSelectedTabId] = useMemo(() => {
+ return [
+ riskInputs.alertIds.length > 0 ? [getRiskInputTab(riskInputs.alertIds)] : [],
+ EntityDetailsLeftPanelTab.RISK_INPUTS,
+ () => {},
+ ];
+ }, [riskInputs.alertIds]);
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+HostDetailsPanel.displayName = 'HostDetailsPanel';
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx
new file mode 100644
index 00000000000000..9bea5cb2a4ac2e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx
@@ -0,0 +1,88 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import { EuiFlyout } from '@elastic/eui';
+import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context';
+import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context';
+import { StorybookProviders } from '../../../common/mock/storybook_providers';
+import { mockRiskScoreState } from '../../../timelines/components/side_panel/new_user_detail/__mocks__';
+import { HostPanelContent } from './content';
+import { mockObservedHostData } from '../mocks';
+
+const flyoutContextValue = {
+ openLeftPanel: () => window.alert('openLeftPanel called'),
+ panels: {},
+} as unknown as ExpandableFlyoutContextValue;
+
+const riskScoreData = { ...mockRiskScoreState, data: [] };
+
+storiesOf('Components/HostPanelContent', module)
+ .addDecorator((storyFn) => (
+
+
+ {}}>
+ {storyFn()}
+
+
+
+ ))
+ .add('default', () => (
+ {}}
+ />
+ ))
+ .add('no observed data', () => (
+ {}}
+ />
+ ))
+ .add('loading', () => (
+ {}}
+ />
+ ));
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx
new file mode 100644
index 00000000000000..eb7d5f3fda26d4
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx
@@ -0,0 +1,64 @@
+/*
+ * Copyright 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 { EuiHorizontalRule } from '@elastic/eui';
+
+import React from 'react';
+import { RiskSummary } from '../../../entity_analytics/components/risk_summary_flyout/risk_summary';
+import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score';
+import type { RiskScoreEntity, HostItem } from '../../../../common/search_strategy';
+import { FlyoutBody } from '../../shared/components/flyout_body';
+import { ObservedEntity } from '../shared/components/observed_entity';
+import { HOST_PANEL_OBSERVED_HOST_QUERY_ID, HOST_PANEL_RISK_SCORE_QUERY_ID } from '.';
+import type { ObservedEntityData } from '../shared/components/observed_entity/types';
+import { useObservedHostFields } from './hooks/use_observed_host_fields';
+import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
+
+interface HostPanelContentProps {
+ observedHost: ObservedEntityData;
+ riskScoreState: RiskScoreState;
+ contextID: string;
+ scopeId: string;
+ isDraggable: boolean;
+ openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void;
+}
+
+export const HostPanelContent = ({
+ observedHost,
+ riskScoreState,
+ contextID,
+ scopeId,
+ isDraggable,
+ openDetailsPanel,
+}: HostPanelContentProps) => {
+ const observedFields = useObservedHostFields(observedHost);
+
+ return (
+
+ {riskScoreState.isModuleEnabled && riskScoreState.data?.length !== 0 && (
+ <>
+ {
+
+ }
+
+ >
+ )}
+
+
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/basic_host_fields.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/basic_host_fields.tsx
new file mode 100644
index 00000000000000..1229a08e7c9567
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/basic_host_fields.tsx
@@ -0,0 +1,80 @@
+/*
+ * Copyright 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 { getEmptyTagValue } from '../../../../common/components/empty_value';
+import type { HostItem } from '../../../../../common/search_strategy';
+import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
+import { NetworkDetailsLink } from '../../../../common/components/links';
+import * as i18n from './translations';
+import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
+import type { EntityTableRows } from '../../shared/components/entity_table/types';
+
+export const basicHostFields: EntityTableRows> = [
+ {
+ label: i18n.HOST_ID,
+ getValues: (hostData: ObservedEntityData) => hostData.details.host?.id,
+ field: 'host.id',
+ },
+ {
+ label: i18n.FIRST_SEEN,
+ render: (hostData: ObservedEntityData) =>
+ hostData.firstSeen.date ? (
+
+ ) : (
+ getEmptyTagValue()
+ ),
+ },
+ {
+ label: i18n.LAST_SEEN,
+ render: (hostData: ObservedEntityData) =>
+ hostData.lastSeen.date ? (
+
+ ) : (
+ getEmptyTagValue()
+ ),
+ },
+ {
+ label: i18n.IP_ADDRESSES,
+ field: 'host.ip',
+ getValues: (hostData: ObservedEntityData) => hostData.details.host?.ip,
+ renderField: (ip: string) => {
+ return ;
+ },
+ },
+ {
+ label: i18n.MAC_ADDRESSES,
+ getValues: (hostData: ObservedEntityData) => hostData.details.host?.mac,
+ field: 'host.mac',
+ },
+ {
+ label: i18n.PLATFORM,
+ getValues: (hostData: ObservedEntityData) => hostData.details.host?.os?.platform,
+ field: 'host.os.platform',
+ },
+ {
+ label: i18n.OS,
+ getValues: (hostData: ObservedEntityData) => hostData.details.host?.os?.name,
+ field: 'host.os.name',
+ },
+ {
+ label: i18n.FAMILY,
+ getValues: (hostData: ObservedEntityData) => hostData.details.host?.os?.family,
+ field: 'host.os.family',
+ },
+ {
+ label: i18n.VERSION,
+ getValues: (hostData: ObservedEntityData) => hostData.details.host?.os?.version,
+ field: 'host.os.version',
+ },
+ {
+ label: i18n.ARCHITECTURE,
+ getValues: (hostData: ObservedEntityData) => hostData.details.host?.architecture,
+ field: 'host.architecture',
+ },
+];
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/cloud_fields.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/cloud_fields.ts
new file mode 100644
index 00000000000000..c4ea144a7db06f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/cloud_fields.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { HostItem } from '../../../../../common/search_strategy';
+import type { EntityTableRows } from '../../shared/components/entity_table/types';
+import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
+import * as i18n from './translations';
+
+export const cloudFields: EntityTableRows> = [
+ {
+ label: i18n.CLOUD_PROVIDER,
+ getValues: (hostData: ObservedEntityData) => hostData.details.cloud?.provider,
+ field: 'cloud.provider',
+ },
+ {
+ label: i18n.REGION,
+ getValues: (hostData: ObservedEntityData) => hostData.details.cloud?.region,
+ field: 'cloud.region',
+ },
+ {
+ label: i18n.INSTANCE_ID,
+ getValues: (hostData: ObservedEntityData) => hostData.details.cloud?.instance?.id,
+ field: 'cloud.instance.id',
+ },
+ {
+ label: i18n.MACHINE_TYPE,
+ getValues: (hostData: ObservedEntityData) => hostData.details.cloud?.machine?.type,
+ field: 'cloud.machine.type',
+ },
+];
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/endpoint_policy_fields.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/endpoint_policy_fields.test.tsx
new file mode 100644
index 00000000000000..a8ba12de451e33
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/endpoint_policy_fields.test.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { TestProviders } from '../../../../common/mock';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { mockObservedHostData } from '../../mocks';
+import { policyFields } from './endpoint_policy_fields';
+
+const TestWrapper = ({ el }: { el: JSX.Element | undefined }) => <>{el}>;
+
+jest.mock(
+ '../../../../management/hooks/response_actions/use_get_endpoint_pending_actions_summary',
+ () => {
+ const original = jest.requireActual(
+ '../../../../management/hooks/response_actions/use_get_endpoint_pending_actions_summary'
+ );
+ return {
+ ...original,
+ useGetEndpointPendingActionsSummary: () => ({
+ pendingActions: [],
+ isLoading: false,
+ isError: false,
+ isTimeout: false,
+ fetch: jest.fn(),
+ }),
+ };
+ }
+);
+
+describe('Endpoint Policy Fields', () => {
+ it('renders policy name', () => {
+ const policyName = policyFields[0];
+
+ const { container } = render();
+
+ expect(container).toHaveTextContent('policy-name');
+ });
+
+ it('renders policy status', () => {
+ const policyStatus = policyFields[1];
+
+ const { container } = render();
+
+ expect(container).toHaveTextContent('failure');
+ });
+
+ it('renders agent status', () => {
+ const agentStatus = policyFields[3];
+
+ const { container } = render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(container).toHaveTextContent('Healthy');
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/endpoint_policy_fields.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/endpoint_policy_fields.tsx
new file mode 100644
index 00000000000000..fd9b8c744a7b24
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/endpoint_policy_fields.tsx
@@ -0,0 +1,69 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { EuiHealth } from '@elastic/eui';
+
+import type { EntityTableRows } from '../../shared/components/entity_table/types';
+import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
+import { EndpointAgentStatus } from '../../../../common/components/endpoint/endpoint_agent_status';
+import { getEmptyTagValue } from '../../../../common/components/empty_value';
+import type { HostItem } from '../../../../../common/search_strategy';
+import { HostPolicyResponseActionStatus } from '../../../../../common/search_strategy';
+import * as i18n from './translations';
+
+export const policyFields: EntityTableRows> = [
+ {
+ label: i18n.ENDPOINT_POLICY,
+ render: (hostData: ObservedEntityData) => {
+ const appliedPolicy = hostData.details.endpoint?.hostInfo?.metadata.Endpoint.policy.applied;
+ return appliedPolicy?.name ? <>{appliedPolicy.name}> : getEmptyTagValue();
+ },
+ isVisible: (hostData: ObservedEntityData) => hostData.details.endpoint != null,
+ },
+ {
+ label: i18n.POLICY_STATUS,
+ render: (hostData: ObservedEntityData) => {
+ const appliedPolicy = hostData.details.endpoint?.hostInfo?.metadata.Endpoint.policy.applied;
+ const policyColor =
+ appliedPolicy?.status === HostPolicyResponseActionStatus.failure
+ ? 'danger'
+ : appliedPolicy?.status;
+
+ return appliedPolicy?.status ? (
+
+ {appliedPolicy?.status}
+
+ ) : (
+ getEmptyTagValue()
+ );
+ },
+ isVisible: (hostData: ObservedEntityData) => hostData.details.endpoint != null,
+ },
+ {
+ label: i18n.SENSORVERSION,
+ getValues: (hostData: ObservedEntityData) =>
+ hostData.details.endpoint?.hostInfo?.metadata.agent.version
+ ? [hostData.details.endpoint?.hostInfo?.metadata.agent.version]
+ : undefined,
+ field: 'agent.version',
+ isVisible: (hostData: ObservedEntityData) => hostData.details.endpoint != null,
+ },
+ {
+ label: i18n.FLEET_AGENT_STATUS,
+ render: (hostData: ObservedEntityData) =>
+ hostData.details.endpoint?.hostInfo ? (
+
+ ) : (
+ getEmptyTagValue()
+ ),
+ isVisible: (hostData: ObservedEntityData) => hostData.details.endpoint != null,
+ },
+];
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/translations.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/translations.ts
new file mode 100644
index 00000000000000..dac45a3a6202cf
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/fields/translations.ts
@@ -0,0 +1,131 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const HOST_ID = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.hostIdTitle',
+ {
+ defaultMessage: 'Host ID',
+ }
+);
+
+export const FIRST_SEEN = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.firstSeenTitle',
+ {
+ defaultMessage: 'First seen',
+ }
+);
+
+export const LAST_SEEN = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.lastSeenTitle',
+ {
+ defaultMessage: 'Last seen',
+ }
+);
+
+export const IP_ADDRESSES = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.ipAddressesTitle',
+ {
+ defaultMessage: 'IP addresses',
+ }
+);
+
+export const MAC_ADDRESSES = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.macAddressesTitle',
+ {
+ defaultMessage: 'MAC addresses',
+ }
+);
+
+export const PLATFORM = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.platformTitle',
+ {
+ defaultMessage: 'Platform',
+ }
+);
+
+export const OS = i18n.translate('xpack.securitySolution.flyout.entityDetails.host.osTitle', {
+ defaultMessage: 'Operating system',
+});
+
+export const FAMILY = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.familyTitle',
+ {
+ defaultMessage: 'Family',
+ }
+);
+
+export const VERSION = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.versionLabel',
+ {
+ defaultMessage: 'Version',
+ }
+);
+
+export const ARCHITECTURE = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.architectureLabel',
+ {
+ defaultMessage: 'Architecture',
+ }
+);
+
+export const CLOUD_PROVIDER = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.cloudProviderTitle',
+ {
+ defaultMessage: 'Cloud provider',
+ }
+);
+
+export const REGION = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.regionTitle',
+ {
+ defaultMessage: 'Region',
+ }
+);
+
+export const INSTANCE_ID = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.instanceIdTitle',
+ {
+ defaultMessage: 'Instance ID',
+ }
+);
+
+export const MACHINE_TYPE = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.machineTypeTitle',
+ {
+ defaultMessage: 'Machine type',
+ }
+);
+
+export const ENDPOINT_POLICY = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.endpoint.endpointPolicy',
+ {
+ defaultMessage: 'Endpoint integration policy',
+ }
+);
+
+export const POLICY_STATUS = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.endpoint.policyStatus',
+ {
+ defaultMessage: 'Policy Status',
+ }
+);
+
+export const SENSORVERSION = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.endpoint.sensorversion',
+ {
+ defaultMessage: 'Endpoint version',
+ }
+);
+
+export const FLEET_AGENT_STATUS = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.host.endpoint.fleetAgentStatus',
+ {
+ defaultMessage: 'Agent status',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/header.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/header.test.tsx
new file mode 100644
index 00000000000000..418ec64cb67096
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/header.test.tsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { render } from '@testing-library/react';
+import React from 'react';
+import { TestProviders } from '../../../common/mock';
+import { HostPanelHeader } from './header';
+import { mockObservedHostData } from '../mocks';
+
+const mockProps = {
+ hostName: 'test',
+ observedHost: mockObservedHostData,
+};
+
+jest.mock('../../../common/components/visualization_actions/visualization_embeddable');
+
+describe('HostPanelHeader', () => {
+ it('renders', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('host-panel-header')).toBeInTheDocument();
+ });
+
+ it('renders observed date', () => {
+ const futureDay = '2989-03-07T20:00:00.000Z';
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('host-panel-header-lastSeen').textContent).toContain('Mar 7, 2989');
+ });
+
+ it('renders observed badge when lastSeen is defined', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('host-panel-header-observed-badge')).toBeInTheDocument();
+ });
+
+ it('does not render observed badge when lastSeen date is undefined', () => {
+ const { queryByTestId } = render(
+
+
+
+ );
+
+ expect(queryByTestId('host-panel-header-observed-badge')).not.toBeInTheDocument();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx
new file mode 100644
index 00000000000000..e8785a92acb6da
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { EuiSpacer, EuiBadge, EuiText, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n-react';
+import React, { useMemo } from 'react';
+import { SecurityPageName } from '@kbn/security-solution-navigation';
+import type { HostItem } from '../../../../common/search_strategy';
+import { getHostDetailsUrl } from '../../../common/components/link_to';
+import { SecuritySolutionLinkAnchor } from '../../../common/components/links';
+import { PreferenceFormattedDate } from '../../../common/components/formatted_date';
+import { FlyoutHeader } from '../../shared/components/flyout_header';
+import { FlyoutTitle } from '../../shared/components/flyout_title';
+import type { ObservedEntityData } from '../shared/components/observed_entity/types';
+
+interface HostPanelHeaderProps {
+ hostName: string;
+ observedHost: ObservedEntityData;
+}
+
+export const HostPanelHeader = ({ hostName, observedHost }: HostPanelHeaderProps) => {
+ const lastSeenDate = useMemo(
+ () => observedHost.lastSeen.date && new Date(observedHost.lastSeen.date),
+ [observedHost.lastSeen.date]
+ );
+
+ return (
+
+
+
+
+ {lastSeenDate && }
+
+
+
+
+
+
+
+
+
+
+
+ {observedHost.lastSeen.date && (
+
+
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_observed_host.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_observed_host.ts
new file mode 100644
index 00000000000000..980407b0346491
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_observed_host.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useMemo } from 'react';
+import { useHostDetails } from '../../../../explore/hosts/containers/hosts/details';
+import { useFirstLastSeen } from '../../../../common/containers/use_first_last_seen';
+import { useGlobalTime } from '../../../../common/containers/use_global_time';
+import { useSourcererDataView } from '../../../../common/containers/sourcerer';
+import type { HostItem } from '../../../../../common/search_strategy';
+import { Direction, NOT_EVENT_KIND_ASSET_FILTER } from '../../../../../common/search_strategy';
+import { HOST_PANEL_OBSERVED_HOST_QUERY_ID, HOST_PANEL_RISK_SCORE_QUERY_ID } from '..';
+import { useQueryInspector } from '../../../../common/components/page/manage_query';
+import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
+
+export const useObservedHost = (
+ hostName: string
+): Omit, 'anomalies'> => {
+ const { to, from, isInitializing, setQuery, deleteQuery } = useGlobalTime();
+ const { selectedPatterns } = useSourcererDataView();
+
+ const [isLoading, { hostDetails, inspect: inspectObservedHost }, refetch] = useHostDetails({
+ endDate: to,
+ hostName,
+ indexNames: selectedPatterns,
+ id: HOST_PANEL_RISK_SCORE_QUERY_ID,
+ skip: isInitializing,
+ startDate: from,
+ });
+
+ useQueryInspector({
+ deleteQuery,
+ inspect: inspectObservedHost,
+ loading: isLoading,
+ queryId: HOST_PANEL_OBSERVED_HOST_QUERY_ID,
+ refetch,
+ setQuery,
+ });
+
+ const [loadingFirstSeen, { firstSeen }] = useFirstLastSeen({
+ field: 'host.name',
+ value: hostName,
+ defaultIndex: selectedPatterns,
+ order: Direction.asc,
+ filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
+ });
+
+ const [loadingLastSeen, { lastSeen }] = useFirstLastSeen({
+ field: 'host.name',
+ value: hostName,
+ defaultIndex: selectedPatterns,
+ order: Direction.desc,
+ filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
+ });
+
+ return useMemo(
+ () => ({
+ details: hostDetails,
+ isLoading: isLoading || loadingLastSeen || loadingFirstSeen,
+ firstSeen: {
+ date: firstSeen,
+ isLoading: loadingFirstSeen,
+ },
+ lastSeen: { date: lastSeen, isLoading: loadingLastSeen },
+ }),
+ [firstSeen, hostDetails, isLoading, lastSeen, loadingFirstSeen, loadingLastSeen]
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_observed_host_fields.test.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_observed_host_fields.test.ts
new file mode 100644
index 00000000000000..ea37bf40bfeefb
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_observed_host_fields.test.ts
@@ -0,0 +1,143 @@
+/*
+ * Copyright 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 { renderHook } from '@testing-library/react-hooks';
+import { useObservedHostFields } from './use_observed_host_fields';
+import { TestProviders } from '@kbn/timelines-plugin/public/mock';
+import { mockObservedHostData } from '../../mocks';
+
+describe('useManagedUserItems', () => {
+ it('returns managed user items for Entra user', () => {
+ const { result } = renderHook(() => useObservedHostFields(mockObservedHostData), {
+ wrapper: TestProviders,
+ });
+
+ expect(result.current).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "field": "host.id",
+ "getValues": [Function],
+ "label": "Host ID",
+ },
+ Object {
+ "label": "First seen",
+ "render": [Function],
+ },
+ Object {
+ "label": "Last seen",
+ "render": [Function],
+ },
+ Object {
+ "field": "host.ip",
+ "getValues": [Function],
+ "label": "IP addresses",
+ "renderField": [Function],
+ },
+ Object {
+ "field": "host.mac",
+ "getValues": [Function],
+ "label": "MAC addresses",
+ },
+ Object {
+ "field": "host.os.platform",
+ "getValues": [Function],
+ "label": "Platform",
+ },
+ Object {
+ "field": "host.os.name",
+ "getValues": [Function],
+ "label": "Operating system",
+ },
+ Object {
+ "field": "host.os.family",
+ "getValues": [Function],
+ "label": "Family",
+ },
+ Object {
+ "field": "host.os.version",
+ "getValues": [Function],
+ "label": "Version",
+ },
+ Object {
+ "field": "host.architecture",
+ "getValues": [Function],
+ "label": "Architecture",
+ },
+ Object {
+ "isVisible": [Function],
+ "label": "Max anomaly score by job",
+ "render": [Function],
+ },
+ Object {
+ "field": "cloud.provider",
+ "getValues": [Function],
+ "label": "Cloud provider",
+ },
+ Object {
+ "field": "cloud.region",
+ "getValues": [Function],
+ "label": "Region",
+ },
+ Object {
+ "field": "cloud.instance.id",
+ "getValues": [Function],
+ "label": "Instance ID",
+ },
+ Object {
+ "field": "cloud.machine.type",
+ "getValues": [Function],
+ "label": "Machine type",
+ },
+ Object {
+ "isVisible": [Function],
+ "label": "Endpoint integration policy",
+ "render": [Function],
+ },
+ Object {
+ "isVisible": [Function],
+ "label": "Policy Status",
+ "render": [Function],
+ },
+ Object {
+ "field": "agent.version",
+ "getValues": [Function],
+ "isVisible": [Function],
+ "label": "Endpoint version",
+ },
+ Object {
+ "isVisible": [Function],
+ "label": "Agent status",
+ "render": [Function],
+ },
+ ]
+ `);
+
+ expect(
+ result.current.map(({ getValues }) => getValues && getValues(mockObservedHostData))
+ ).toEqual([
+ ['host-id'],
+ undefined, // First seen doesn't implement getValues
+ undefined, // Last seen doesn't implement getValues
+ ['host-ip'],
+ ['host-mac'],
+ ['host-platform'],
+ ['os-name'],
+ ['host-family'],
+ ['host-version'],
+ ['host-architecture'],
+ undefined, // Max anomaly score by job doesn't implement getValues
+ ['cloud-provider'],
+ ['cloud-region'],
+ ['cloud-instance-id'],
+ ['cloud-machine-type'],
+ undefined, // Endpoint integration policy doesn't implement getValues
+ undefined, // Policy Status doesn't implement getValues
+ ['endpoint-agent-version'],
+ undefined, // Agent status doesn't implement getValues
+ ]);
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_observed_host_fields.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_observed_host_fields.ts
new file mode 100644
index 00000000000000..255bb54c2c58ad
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_observed_host_fields.ts
@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useMemo } from 'react';
+import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities';
+import type { HostItem } from '../../../../../common/search_strategy';
+import { getAnomaliesFields } from '../../shared/common';
+import type { EntityTableRows } from '../../shared/components/entity_table/types';
+import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
+import { policyFields } from '../fields/endpoint_policy_fields';
+import { basicHostFields } from '../fields/basic_host_fields';
+import { cloudFields } from '../fields/cloud_fields';
+
+export const useObservedHostFields = (
+ hostData: ObservedEntityData
+): EntityTableRows> => {
+ const mlCapabilities = useMlCapabilities();
+
+ return useMemo(() => {
+ if (hostData == null) {
+ return [];
+ }
+
+ return [
+ ...basicHostFields,
+ ...getAnomaliesFields(mlCapabilities),
+ ...cloudFields,
+ ...policyFields,
+ ];
+ }, [hostData, mlCapabilities]);
+};
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx
new file mode 100644
index 00000000000000..467a1be82a44cf
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { render } from '@testing-library/react';
+import React from 'react';
+import { TestProviders } from '../../../common/mock';
+import { mockHostRiskScoreState, mockObservedHostData } from '../mocks';
+
+import type { HostPanelProps } from '.';
+import { HostPanel } from '.';
+
+const mockProps: HostPanelProps = {
+ hostName: 'test',
+ contextID: 'test-host -panel',
+ scopeId: 'test-scope-id',
+ isDraggable: false,
+};
+
+jest.mock('../../../common/components/visualization_actions/visualization_embeddable');
+
+const mockedHostRiskScore = jest.fn().mockReturnValue(mockHostRiskScoreState);
+jest.mock('../../../entity_analytics/api/hooks/use_risk_score', () => ({
+ useRiskScore: () => mockedHostRiskScore(),
+}));
+
+const mockedUseObservedHost = jest.fn().mockReturnValue(mockObservedHostData);
+
+jest.mock('./hooks/use_observed_host', () => ({
+ useObservedHost: () => mockedUseObservedHost(),
+}));
+
+describe('HostPanel', () => {
+ beforeEach(() => {
+ mockedHostRiskScore.mockReturnValue(mockHostRiskScoreState);
+ mockedUseObservedHost.mockReturnValue(mockObservedHostData);
+ });
+
+ it('renders', () => {
+ const { getByTestId, queryByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('host-panel-header')).toBeInTheDocument();
+ expect(queryByTestId('securitySolutionFlyoutLoading')).not.toBeInTheDocument();
+ expect(getByTestId('securitySolutionFlyoutNavigationExpandDetailButton')).toBeInTheDocument();
+ });
+
+ it('renders loading state when risk score is loading', () => {
+ mockedHostRiskScore.mockReturnValue({
+ ...mockHostRiskScoreState,
+ data: undefined,
+ loading: true,
+ });
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('securitySolutionFlyoutLoading')).toBeInTheDocument();
+ });
+
+ it('renders loading state when observed host is loading', () => {
+ mockedUseObservedHost.mockReturnValue({
+ ...mockObservedHostData,
+ isLoading: true,
+ });
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('securitySolutionFlyoutLoading')).toBeInTheDocument();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx
new file mode 100644
index 00000000000000..783a9ce5983813
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx
@@ -0,0 +1,141 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { useCallback, useMemo } from 'react';
+import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
+import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
+
+import { hostToCriteria } from '../../../common/components/ml/criteria/host_to_criteria';
+import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score';
+import { useQueryInspector } from '../../../common/components/page/manage_query';
+import { useGlobalTime } from '../../../common/containers/use_global_time';
+import type { HostItem } from '../../../../common/search_strategy';
+import { buildHostNamesFilter } from '../../../../common/search_strategy';
+import { RiskScoreEntity } from '../../../../common/entity_analytics/risk_engine';
+import { FlyoutLoading } from '../../shared/components/flyout_loading';
+import { FlyoutNavigation } from '../../shared/components/flyout_navigation';
+import { HostPanelContent } from './content';
+import { HostPanelHeader } from './header';
+import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider';
+import type { ObservedEntityData } from '../shared/components/observed_entity/types';
+import { useObservedHost } from './hooks/use_observed_host';
+import { HostDetailsPanelKey } from '../host_details_left';
+import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
+
+export interface HostPanelProps extends Record {
+ contextID: string;
+ scopeId: string;
+ hostName: string;
+ isDraggable?: boolean;
+}
+
+export interface HostPanelExpandableFlyoutProps extends FlyoutPanelProps {
+ key: 'host-panel';
+ params: HostPanelProps;
+}
+
+export const HostPanelKey: HostPanelExpandableFlyoutProps['key'] = 'host-panel';
+export const HOST_PANEL_RISK_SCORE_QUERY_ID = 'HostPanelRiskScoreQuery';
+export const HOST_PANEL_OBSERVED_HOST_QUERY_ID = 'HostPanelObservedHostQuery';
+
+const FIRST_RECORD_PAGINATION = {
+ cursorStart: 0,
+ querySize: 1,
+};
+
+export const HostPanel = ({ contextID, scopeId, hostName, isDraggable }: HostPanelProps) => {
+ const { openLeftPanel } = useExpandableFlyoutContext();
+ const { to, from, isInitializing, setQuery, deleteQuery } = useGlobalTime();
+ const hostNameFilterQuery = useMemo(
+ () => (hostName ? buildHostNamesFilter([hostName]) : undefined),
+ [hostName]
+ );
+
+ const riskScoreState = useRiskScore({
+ riskEntity: RiskScoreEntity.host,
+ filterQuery: hostNameFilterQuery,
+ onlyLatest: false,
+ pagination: FIRST_RECORD_PAGINATION,
+ });
+
+ const { data: hostRisk, inspect: inspectRiskScore, refetch, loading } = riskScoreState;
+ const hostRiskData = hostRisk && hostRisk.length > 0 ? hostRisk[0] : undefined;
+
+ useQueryInspector({
+ deleteQuery,
+ inspect: inspectRiskScore,
+ loading,
+ queryId: HOST_PANEL_RISK_SCORE_QUERY_ID,
+ refetch,
+ setQuery,
+ });
+
+ const openTabPanel = useCallback(
+ (tab?: EntityDetailsLeftPanelTab) => {
+ openLeftPanel({
+ id: HostDetailsPanelKey,
+ params: {
+ riskInputs: {
+ alertIds: hostRiskData?.host.risk.inputs?.map(({ id }) => id) ?? [],
+ host: {
+ name: hostName,
+ },
+ },
+ path: tab ? { tab } : undefined,
+ },
+ });
+ },
+ [openLeftPanel, hostRiskData?.host.risk.inputs, hostName]
+ );
+
+ const openDefaultPanel = useCallback(() => openTabPanel(), [openTabPanel]);
+ const observedHost = useObservedHost(hostName);
+
+ if (riskScoreState.loading || observedHost.isLoading) {
+ return ;
+ }
+
+ return (
+
+ {({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => {
+ const observedHostWithAnomalies: ObservedEntityData = {
+ ...observedHost,
+ anomalies: {
+ isLoading: isLoadingAnomaliesData,
+ anomalies: anomaliesData,
+ jobNameById,
+ },
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+ }}
+
+ );
+};
+
+HostPanel.displayName = 'HostPanel';
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/mocks/index.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/mocks/index.ts
new file mode 100644
index 00000000000000..01dafb9d6b47ab
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/mocks/index.ts
@@ -0,0 +1,168 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { HostMetadataInterface } from '../../../../common/endpoint/types';
+import { EndpointStatus, HostStatus } from '../../../../common/endpoint/types';
+import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score';
+import type {
+ HostItem,
+ HostRiskScore,
+ RiskScoreEntity,
+ UserRiskScore,
+} from '../../../../common/search_strategy';
+import { HostPolicyResponseActionStatus, RiskSeverity } from '../../../../common/search_strategy';
+import { RiskCategories } from '../../../../common/entity_analytics/risk_engine';
+import type { ObservedEntityData } from '../shared/components/observed_entity/types';
+
+const userRiskScore: UserRiskScore = {
+ '@timestamp': '1989-11-08T23:00:00.000Z',
+ user: {
+ name: 'test',
+ risk: {
+ rule_risks: [],
+ calculated_score_norm: 70,
+ multipliers: [],
+ calculated_level: RiskSeverity.high,
+ inputs: [
+ {
+ id: '_id',
+ index: '_index',
+ category: RiskCategories.category_1,
+ description: 'Alert from Rule: My rule',
+ risk_score: 30,
+ timestamp: '2021-08-19T18:55:59.000Z',
+ },
+ ],
+ },
+ },
+ alertsCount: 0,
+ oldestAlertTimestamp: '1989-11-08T23:00:00.000Z',
+};
+
+const hostRiskScore: HostRiskScore = {
+ '@timestamp': '1989-11-08T23:00:00.000Z',
+ host: {
+ name: 'test',
+ risk: {
+ rule_risks: [],
+ calculated_score_norm: 70,
+ multipliers: [],
+ calculated_level: RiskSeverity.high,
+ inputs: [
+ {
+ id: '_id',
+ index: '_index',
+ category: RiskCategories.category_1,
+ description: 'Alert from Rule: My rule',
+ risk_score: 30,
+ timestamp: '2021-08-19T18:55:59.000Z',
+ },
+ ],
+ },
+ },
+ alertsCount: 0,
+ oldestAlertTimestamp: '1989-11-08T23:00:00.000Z',
+};
+
+export const mockUserRiskScoreState: RiskScoreState = {
+ data: [userRiskScore],
+ inspect: {
+ dsl: [],
+ response: [],
+ },
+ isInspected: false,
+ refetch: () => {},
+ totalCount: 0,
+ isModuleEnabled: true,
+ isAuthorized: true,
+ isDeprecated: false,
+ loading: false,
+};
+
+export const mockHostRiskScoreState: RiskScoreState = {
+ data: [hostRiskScore],
+ inspect: {
+ dsl: [],
+ response: [],
+ },
+ isInspected: false,
+ refetch: () => {},
+ totalCount: 0,
+ isModuleEnabled: true,
+ isAuthorized: true,
+ isDeprecated: false,
+ loading: false,
+};
+
+const hostMetadata: HostMetadataInterface = {
+ '@timestamp': 1036358673463478,
+
+ agent: {
+ id: 'endpoint-agent-id',
+ version: 'endpoint-agent-version',
+ type: 'endpoint-agent-type',
+ },
+ Endpoint: {
+ status: EndpointStatus.enrolled,
+ policy: {
+ applied: {
+ name: 'policy-name',
+ id: 'C2A9093E-E289-4C0A-AA44-8C32A414FA7A',
+ endpoint_policy_version: 3,
+ version: 5,
+ status: HostPolicyResponseActionStatus.failure,
+ },
+ },
+ },
+} as HostMetadataInterface;
+
+export const mockObservedHost: HostItem = {
+ host: {
+ id: ['host-id'],
+ mac: ['host-mac'],
+ architecture: ['host-architecture'],
+ os: {
+ platform: ['host-platform'],
+ name: ['os-name'],
+ version: ['host-version'],
+ family: ['host-family'],
+ },
+ ip: ['host-ip'],
+ name: ['host-name'],
+ },
+ cloud: {
+ instance: {
+ id: ['cloud-instance-id'],
+ },
+ provider: ['cloud-provider'],
+ region: ['cloud-region'],
+ machine: {
+ type: ['cloud-machine-type'],
+ },
+ },
+ endpoint: {
+ hostInfo: {
+ metadata: hostMetadata,
+ host_status: HostStatus.HEALTHY,
+ last_checkin: 'host-last-checkin',
+ },
+ },
+};
+
+export const mockObservedHostData: ObservedEntityData = {
+ details: mockObservedHost,
+ isLoading: false,
+ firstSeen: {
+ isLoading: false,
+ date: '2023-02-23T20:03:17.489Z',
+ },
+ lastSeen: {
+ isLoading: false,
+ date: '2023-02-23T20:03:17.489Z',
+ },
+ anomalies: { isLoading: false, anomalies: null, jobNameById: {} },
+};
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/common.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/common.test.tsx
new file mode 100644
index 00000000000000..70b37fd8ba7222
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/common.test.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { getAnomaliesFields } from './common';
+import { emptyMlCapabilities } from '../../../../common/machine_learning/empty_ml_capabilities';
+
+const emptyMlCapabilitiesProvider = {
+ ...emptyMlCapabilities,
+ capabilitiesFetched: false,
+};
+
+describe('getAnomaliesFields', () => {
+ it('returns max anomaly score', () => {
+ const field = getAnomaliesFields(emptyMlCapabilitiesProvider);
+
+ expect(field[0].label).toBe('Max anomaly score by job');
+ });
+
+ it('hides anomalies field when user has no permissions', () => {
+ const field = getAnomaliesFields(emptyMlCapabilitiesProvider);
+
+ expect(field[0].isVisible()).toBeFalsy();
+ });
+
+ it('shows anomalies field when user has permissions', () => {
+ const mlCapabilitiesProvider = {
+ ...emptyMlCapabilities,
+ capabilitiesFetched: false,
+ capabilities: {
+ ...emptyMlCapabilities.capabilities,
+ canGetJobs: true,
+ canGetDatafeeds: true,
+ canGetCalendars: true,
+ },
+ };
+
+ const field = getAnomaliesFields(mlCapabilitiesProvider);
+
+ expect(field[0].isVisible()).toBeTruthy();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/common.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/common.tsx
new file mode 100644
index 00000000000000..95d4758c2c449e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/common.tsx
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import React from 'react';
+
+import { i18n } from '@kbn/i18n';
+import type { ObservedEntityData } from './components/observed_entity/types';
+import type { MlCapabilitiesProvider } from '../../../common/components/ml/permissions/ml_capabilities_provider';
+import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions';
+import { getEmptyTagValue } from '../../../common/components/empty_value';
+import type { HostItem } from '../../../../common/search_strategy';
+import { AnomaliesField } from './components/anomalies_field';
+
+export const getAnomaliesFields = (mlCapabilities: MlCapabilitiesProvider) => [
+ {
+ label: i18n.translate('xpack.securitySolution.timeline.sidePanel.maxAnomalyScoreByJobTitle', {
+ defaultMessage: 'Max anomaly score by job',
+ }),
+ render: (hostData: ObservedEntityData) =>
+ hostData.anomalies ? : getEmptyTagValue(),
+ isVisible: () => hasMlUserPermissions(mlCapabilities),
+ },
+];
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/anomalies_field.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/anomalies_field.test.tsx
new file mode 100644
index 00000000000000..c8a2cdbb71dae4
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/anomalies_field.test.tsx
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { mockAnomalies } from '../../../../common/components/ml/mock';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { AnomaliesField } from './anomalies_field';
+import { TestProviders } from '../../../../common/mock';
+
+jest.mock('../../../../common/components/cell_actions', () => {
+ const actual = jest.requireActual('../../../../common/components/cell_actions');
+ return {
+ ...actual,
+ SecurityCellActions: () => <>>,
+ };
+});
+
+describe('getAnomaliesFields', () => {
+ it('returns max anomaly score', () => {
+ const { getByTestId } = render(
+ ,
+ {
+ wrapper: TestProviders,
+ }
+ );
+
+ expect(getByTestId('anomaly-scores')).toBeInTheDocument();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/anomalies_field.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/anomalies_field.tsx
new file mode 100644
index 00000000000000..ea5e7b17202f09
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/anomalies_field.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { useCallback } from 'react';
+import { useDispatch } from 'react-redux';
+import type { EntityAnomalies } from './observed_entity/types';
+import { AnomalyScores } from '../../../../common/components/ml/score/anomaly_scores';
+import { useGlobalTime } from '../../../../common/containers/use_global_time';
+import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime';
+import { InputsModelId } from '../../../../common/store/inputs/constants';
+import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions';
+
+export const AnomaliesField = ({ anomalies }: { anomalies: EntityAnomalies }) => {
+ const { to, from } = useGlobalTime();
+ const dispatch = useDispatch();
+
+ const narrowDateRange = useCallback(
+ (score, interval) => {
+ const fromTo = scoreIntervalToDateTime(score, interval);
+ dispatch(
+ setAbsoluteRangeDatePicker({
+ id: InputsModelId.global,
+ from: fromTo.from,
+ to: fromTo.to,
+ })
+ );
+ },
+ [dispatch]
+ );
+
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/columns.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/columns.tsx
new file mode 100644
index 00000000000000..e97ab9b4accaec
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/columns.tsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { css } from '@emotion/react';
+import React from 'react';
+import { euiLightVars } from '@kbn/ui-theme';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { DefaultFieldRenderer } from '../../../../../timelines/components/field_renderers/field_renderers';
+import { getEmptyTagValue } from '../../../../../common/components/empty_value';
+import { getSourcererScopeId } from '../../../../../helpers';
+import type { BasicEntityData, EntityTableColumns } from './types';
+
+export const getEntityTableColumns = (
+ contextID: string,
+ scopeId: string,
+ isDraggable: boolean,
+ data: T
+): EntityTableColumns => [
+ {
+ name: (
+
+ ),
+ field: 'label',
+ render: (label: string, { field }) => (
+
+ {label ?? field}
+
+ ),
+ },
+ {
+ name: (
+
+ ),
+ field: 'field',
+ render: (field: string | undefined, { getValues, render, renderField }) => {
+ const values = getValues && getValues(data);
+
+ if (field) {
+ return (
+
+ );
+ }
+
+ if (render) {
+ return render(data);
+ }
+
+ return getEmptyTagValue();
+ },
+ },
+];
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/index.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/index.test.tsx
new file mode 100644
index 00000000000000..d6243abb39e9f9
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/index.test.tsx
@@ -0,0 +1,94 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { render } from '@testing-library/react';
+import React from 'react';
+import { EntityTable } from '.';
+import { TestProviders } from '../../../../../common/mock';
+import type { BasicEntityData, EntityTableRow } from './types';
+
+const renderedFieldValue = 'testValue1';
+
+const testField: EntityTableRow = {
+ label: 'testLabel',
+ field: 'testField',
+ getValues: (data: unknown) => [renderedFieldValue],
+ renderField: (field: string) => <>{field}>,
+};
+
+const mockProps = {
+ contextID: 'testContextID',
+ scopeId: 'testScopeId',
+ isDraggable: false,
+ data: { isLoading: false },
+ entityFields: [testField],
+};
+
+describe('EntityTable', () => {
+ it('renders correctly', () => {
+ const { queryByTestId, queryAllByTestId } = render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(queryByTestId('entity-table')).toBeInTheDocument();
+ expect(queryAllByTestId('entity-table-label')).toHaveLength(1);
+ });
+
+ it("it doesn't render fields when isVisible returns false", () => {
+ const props = {
+ ...mockProps,
+ entityFields: [
+ {
+ ...testField,
+ isVisible: () => false,
+ },
+ ],
+ };
+
+ const { queryAllByTestId } = render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(queryAllByTestId('entity-table-label')).toHaveLength(0);
+ });
+
+ it('it renders the field label', () => {
+ const { queryByTestId } = render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(queryByTestId('entity-table-label')).toHaveTextContent('testLabel');
+ });
+
+ it('it renders the field value', () => {
+ const { queryByTestId } = render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(queryByTestId('DefaultFieldRendererComponent')).toHaveTextContent(renderedFieldValue);
+ });
+
+ it('it call render function when field is undefined', () => {
+ const props = {
+ ...mockProps,
+ entityFields: [
+ {
+ label: 'testLabel',
+ render: (data: unknown) => (
+ {'test-custom-render'}
+ ),
+ },
+ ],
+ };
+
+ const { queryByTestId } = render(, {
+ wrapper: TestProviders,
+ });
+
+ expect(queryByTestId('test-custom-render')).toBeInTheDocument();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/index.tsx
new file mode 100644
index 00000000000000..84075071e7a9f6
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/index.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { useMemo } from 'react';
+import { BasicTable } from '../../../../../common/components/ml/tables/basic_table';
+import { getEntityTableColumns } from './columns';
+import type { BasicEntityData, EntityTableRows } from './types';
+
+interface EntityTableProps {
+ contextID: string;
+ scopeId: string;
+ isDraggable: boolean;
+ data: T;
+ entityFields: EntityTableRows;
+}
+
+export const EntityTable = ({
+ contextID,
+ scopeId,
+ isDraggable,
+ data,
+ entityFields,
+}: EntityTableProps) => {
+ const items = useMemo(
+ () => entityFields.filter(({ isVisible }) => (isVisible ? isVisible(data) : true)),
+ [data, entityFields]
+ );
+
+ const entityTableColumns = useMemo(
+ () => getEntityTableColumns(contextID, scopeId, isDraggable, data),
+ [contextID, scopeId, isDraggable, data]
+ );
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/types.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/types.ts
new file mode 100644
index 00000000000000..690a99ec92ce35
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/entity_table/types.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { EuiBasicTableColumn } from '@elastic/eui';
+import type { XOR } from '../../../../../../common/utility_types';
+
+export type EntityTableRow = XOR<
+ {
+ label: string;
+ /**
+ * The field name. It is used for displaying CellActions.
+ */
+ field: string;
+ /**
+ * It extracts an array of strings from the data. Each element is a valid field value.
+ * It is used for displaying MoreContainer.
+ */
+ getValues: (data: T) => string[] | null | undefined;
+ /**
+ * It allows the customization of the rendered field.
+ * The element is still rendered inside `DefaultFieldRenderer` getting `CellActions` and `MoreContainer` capabilities.
+ */
+ renderField?: (value: string) => JSX.Element;
+ /**
+ * It hides the row when `isVisible` returns false.
+ */
+ isVisible?: (data: T) => boolean;
+ },
+ {
+ label: string;
+ /**
+ * It takes complete control over the rendering.
+ * `getValues` and `renderField` are not called when this property is used.
+ */
+ render: (data: T) => JSX.Element;
+ /**
+ * It hides the row when `isVisible` returns false.
+ */
+ isVisible?: (data: T) => boolean;
+ }
+>;
+
+export type EntityTableColumns = Array<
+ EuiBasicTableColumn>
+>;
+export type EntityTableRows = Array>;
+
+export interface BasicEntityData {
+ isLoading: boolean;
+}
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/content.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_content.tsx
similarity index 70%
rename from x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/content.tsx
rename to x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_content.tsx
index 991592bd1ea0ce..5a66a5b3056116 100644
--- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/content.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_content.tsx
@@ -9,19 +9,19 @@ import { useEuiBackgroundColor } from '@elastic/eui';
import type { VFC } from 'react';
import React, { useMemo } from 'react';
import { css } from '@emotion/react';
-import type { LeftPanelTabsType, UserDetailsLeftPanelTab } from './tabs';
-import { FlyoutBody } from '../../shared/components/flyout_body';
+import { FlyoutBody } from '../../../../shared/components/flyout_body';
+import type { EntityDetailsLeftPanelTab, LeftPanelTabsType } from './left_panel_header';
export interface PanelContentProps {
- selectedTabId: UserDetailsLeftPanelTab;
+ selectedTabId: EntityDetailsLeftPanelTab;
tabs: LeftPanelTabsType;
}
/**
- * User details expandable flyout left section.
+ * Content for a entity left panel.
* Appears after the user clicks on the expand details button in the right section.
*/
-export const PanelContent: VFC = ({ selectedTabId, tabs }) => {
+export const LeftPanelContent: VFC = ({ selectedTabId, tabs }) => {
const selectedTabContent = useMemo(() => {
return tabs.find((tab) => tab.id === selectedTabId)?.content;
}, [selectedTabId, tabs]);
@@ -37,4 +37,4 @@ export const PanelContent: VFC = ({ selectedTabId, tabs }) =>
);
};
-PanelContent.displayName = 'PanelContent';
+LeftPanelContent.displayName = 'LeftPanelContent';
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/header.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx
similarity index 67%
rename from x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/header.tsx
rename to x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx
index 2f807ca1d0a7d7..ea62ce25f3ca47 100644
--- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/header.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx
@@ -6,21 +6,33 @@
*/
import { EuiTab, EuiTabs, useEuiBackgroundColor } from '@elastic/eui';
-import type { VFC } from 'react';
+import type { ReactElement, VFC } from 'react';
import React, { memo } from 'react';
import { css } from '@emotion/react';
-import type { LeftPanelTabsType, UserDetailsLeftPanelTab } from './tabs';
-import { FlyoutHeader } from '../../shared/components/flyout_header';
+import { FlyoutHeader } from '../../../../shared/components/flyout_header';
+
+export type LeftPanelTabsType = Array<{
+ id: EntityDetailsLeftPanelTab;
+ 'data-test-subj': string;
+ name: ReactElement;
+ content: React.ReactElement;
+}>;
+
+export enum EntityDetailsLeftPanelTab {
+ RISK_INPUTS = 'risk_inputs',
+ OKTA = 'okta_document',
+ ENTRA = 'entra_document',
+}
export interface PanelHeaderProps {
/**
* Id of the tab selected in the parent component to display its content
*/
- selectedTabId: UserDetailsLeftPanelTab;
+ selectedTabId: EntityDetailsLeftPanelTab;
/**
* Callback to set the selected tab id in the parent component
*/
- setSelectedTabId: (selected: UserDetailsLeftPanelTab) => void;
+ setSelectedTabId: (selected: EntityDetailsLeftPanelTab) => void;
/**
* List of tabs to display in the header
*/
@@ -31,9 +43,9 @@ export interface PanelHeaderProps {
* Header at the top of the left section.
* Displays the investigation and insights tabs (visualize is hidden for 8.9).
*/
-export const PanelHeader: VFC = memo(
+export const LeftPanelHeader: VFC = memo(
({ selectedTabId, setSelectedTabId, tabs }) => {
- const onSelectedTabChanged = (id: UserDetailsLeftPanelTab) => setSelectedTabId(id);
+ const onSelectedTabChanged = (id: EntityDetailsLeftPanelTab) => setSelectedTabId(id);
const renderTabs = tabs.map((tab, index) => (
onSelectedTabChanged(tab.id)}
@@ -61,4 +73,4 @@ export const PanelHeader: VFC = memo(
}
);
-PanelHeader.displayName = 'PanelHeader';
+LeftPanelHeader.displayName = 'LeftPanelHeader';
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/observed_entity/index.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/observed_entity/index.test.tsx
new file mode 100644
index 00000000000000..f3cdfefa6c74ff
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/observed_entity/index.test.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { render } from '@testing-library/react';
+import React from 'react';
+import { ObservedEntity } from '.';
+import { TestProviders } from '../../../../../common/mock';
+import { mockObservedHostData } from '../../../mocks';
+
+describe('ObservedHost', () => {
+ const mockProps = {
+ observedData: mockObservedHostData,
+ contextID: '',
+ scopeId: '',
+ isDraggable: false,
+ queryId: 'TEST_QUERY_ID',
+ observedFields: [],
+ };
+
+ it('renders', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('observedEntity-accordion')).toBeInTheDocument();
+ });
+
+ it('renders the formatted date', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('observedEntity-accordion')).toHaveTextContent('Updated Feb 23, 2023');
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/observed_user.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/observed_entity/index.tsx
similarity index 51%
rename from x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/observed_user.tsx
rename to x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/observed_entity/index.tsx
index 411e516b570f38..792ad322e631bd 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/observed_user.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/observed_entity/index.tsx
@@ -5,39 +5,34 @@
* 2.0.
*/
-import { EuiAccordion, EuiSpacer, EuiTitle, useEuiTheme, useEuiFontSize } from '@elastic/eui';
+import { EuiAccordion, EuiSpacer, EuiTitle, useEuiFontSize, useEuiTheme } from '@elastic/eui';
-import React, { useMemo } from 'react';
+import React from 'react';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
-import * as i18n from './translations';
-import type { ObservedUserData } from './types';
-import { BasicTable } from '../../../../common/components/ml/tables/basic_table';
-import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
-import { getObservedUserTableColumns } from './columns';
-import { ONE_WEEK_IN_HOURS } from './constants';
-import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect';
-import { OBSERVED_USER_QUERY_ID } from '../../../../explore/users/containers/users/observed_details';
-import { useObservedUserItems } from './hooks/use_observed_user_items';
+import { EntityTable } from '../entity_table';
+import { FormattedRelativePreferenceDate } from '../../../../../common/components/formatted_date';
+import { InspectButton, InspectButtonContainer } from '../../../../../common/components/inspect';
+import type { EntityTableRows } from '../entity_table/types';
+import { ONE_WEEK_IN_HOURS } from '../../constants';
+import type { ObservedEntityData } from './types';
-export const ObservedUser = ({
- observedUser,
+export const ObservedEntity = ({
+ observedData,
contextID,
scopeId,
isDraggable,
+ observedFields,
+ queryId,
}: {
- observedUser: ObservedUserData;
+ observedData: ObservedEntityData;
contextID: string;
scopeId: string;
isDraggable: boolean;
+ observedFields: EntityTableRows>;
+ queryId: string;
}) => {
const { euiTheme } = useEuiTheme();
- const observedItems = useObservedUserItems(observedUser);
-
- const observedUserTableColumns = useMemo(
- () => getObservedUserTableColumns(contextID, scopeId, isDraggable),
- [contextID, scopeId, isDraggable]
- );
const xsFontSize = useEuiFontSize('xxs').fontSize;
return (
@@ -45,18 +40,23 @@ export const ObservedUser = ({
- {i18n.OBSERVED_DATA_TITLE}
+
+
+
}
extraAction={
@@ -67,23 +67,28 @@ export const ObservedUser = ({
`}
>
+ }
/>
- {observedUser.lastSeen.date && (
+ {observedData.lastSeen.date && (
@@ -101,17 +106,12 @@ export const ObservedUser = ({
`}
>
-
-
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/observed_entity/types.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/observed_entity/types.ts
new file mode 100644
index 00000000000000..f9d9db179d3f60
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/observed_entity/types.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { BasicEntityData } from '../entity_table/types';
+import type { AnomalyTableProviderChildrenProps } from '../../../../../common/components/ml/anomaly/anomaly_table_provider';
+
+export interface FirstLastSeenData {
+ date: string | null | undefined;
+ isLoading: boolean;
+}
+
+export interface EntityAnomalies {
+ isLoading: AnomalyTableProviderChildrenProps['isLoadingAnomaliesData'];
+ anomalies: AnomalyTableProviderChildrenProps['anomaliesData'];
+ jobNameById: AnomalyTableProviderChildrenProps['jobNameById'];
+}
+
+export interface ObservedEntityData extends BasicEntityData {
+ firstSeen: FirstLastSeenData;
+ lastSeen: FirstLastSeenData;
+ anomalies: EntityAnomalies;
+ details: T;
+}
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/constants.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/constants.ts
new file mode 100644
index 00000000000000..bad35d36578912
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/constants.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export const ONE_WEEK_IN_HOURS = 24 * 7;
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/index.tsx
index ae96a76c68d4e6..c2591eab2c914a 100644
--- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/index.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/index.tsx
@@ -9,11 +9,14 @@ import React, { useMemo } from 'react';
import type { FlyoutPanelProps, PanelPath } from '@kbn/expandable-flyout';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { useManagedUser } from '../../../timelines/components/side_panel/new_user_detail/hooks/use_managed_user';
-import { PanelHeader } from './header';
-import { PanelContent } from './content';
-import type { LeftPanelTabsType, UserDetailsLeftPanelTab } from './tabs';
import { useTabs } from './tabs';
import { FlyoutLoading } from '../../shared/components/flyout_loading';
+import type {
+ EntityDetailsLeftPanelTab,
+ LeftPanelTabsType,
+} from '../shared/components/left_panel/left_panel_header';
+import { LeftPanelHeader } from '../shared/components/left_panel/left_panel_header';
+import { LeftPanelContent } from '../shared/components/left_panel/left_panel_content';
interface RiskInputsParam {
alertIds: string[];
@@ -44,8 +47,12 @@ export const UserDetailsPanel = ({ riskInputs, user, path }: UserDetailsPanelPro
return (
<>
-
-
+
+
>
);
};
@@ -65,7 +72,7 @@ const useSelectedTab = (
return tabs.find((tab) => tab.id === path.tab)?.id ?? defaultTab;
}, [path, tabs]);
- const setSelectedTabId = (tabId: UserDetailsLeftPanelTab) => {
+ const setSelectedTabId = (tabId: EntityDetailsLeftPanelTab) => {
openLeftPanel({
id: UserDetailsPanelKey,
path: {
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs.tsx
index 61f408a5c0ade3..3867afb4470e23 100644
--- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs.tsx
@@ -5,7 +5,6 @@
* 2.0.
*/
-import type { ReactElement } from 'react';
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
@@ -19,19 +18,8 @@ import type {
import { ENTRA_TAB_TEST_ID, OKTA_TAB_TEST_ID } from './test_ids';
import { AssetDocumentTab } from './tabs/asset_document';
import { RightPanelProvider } from '../../document_details/right/context';
-
-export type LeftPanelTabsType = Array<{
- id: UserDetailsLeftPanelTab;
- 'data-test-subj': string;
- name: ReactElement;
- content: React.ReactElement;
-}>;
-
-export enum UserDetailsLeftPanelTab {
- RISK_INPUTS = 'risk_inputs',
- OKTA = 'okta_document',
- ENTRA = 'entra_document',
-}
+import type { LeftPanelTabsType } from '../shared/components/left_panel/left_panel_header';
+import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
export const useTabs = (managedUser: ManagedUserHits, alertIds: string[]): LeftPanelTabsType =>
useMemo(() => {
@@ -55,7 +43,7 @@ export const useTabs = (managedUser: ManagedUserHits, alertIds: string[]): LeftP
}, [alertIds, managedUser]);
const getOktaTab = (oktaManagedUser: ManagedUserHit) => ({
- id: UserDetailsLeftPanelTab.OKTA,
+ id: EntityDetailsLeftPanelTab.OKTA,
'data-test-subj': OKTA_TAB_TEST_ID,
name: (
({
const getEntraTab = (entraManagedUser: ManagedUserHit) => {
return {
- id: UserDetailsLeftPanelTab.ENTRA,
+ id: EntityDetailsLeftPanelTab.ENTRA,
'data-test-subj': ENTRA_TAB_TEST_ID,
name: (
window.alert('openLeftPanel called'),
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx
index 9b99c42aeac7a5..f1f7916d3907c5 100644
--- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx
@@ -8,27 +8,27 @@
import { EuiHorizontalRule } from '@elastic/eui';
import React from 'react';
+import { OBSERVED_USER_QUERY_ID } from '../../../explore/users/containers/users/observed_details';
import { RiskSummary } from '../../../entity_analytics/components/risk_summary_flyout/risk_summary';
import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score';
import { ManagedUser } from '../../../timelines/components/side_panel/new_user_detail/managed_user';
-import type {
- ManagedUserData,
- ObservedUserData,
-} from '../../../timelines/components/side_panel/new_user_detail/types';
-import { ObservedUser } from '../../../timelines/components/side_panel/new_user_detail/observed_user';
-import type { RiskScoreEntity } from '../../../../common/search_strategy';
+import type { ManagedUserData } from '../../../timelines/components/side_panel/new_user_detail/types';
+import type { RiskScoreEntity, UserItem } from '../../../../common/search_strategy';
import { USER_PANEL_RISK_SCORE_QUERY_ID } from '.';
import { FlyoutBody } from '../../shared/components/flyout_body';
-import type { UserDetailsLeftPanelTab } from '../user_details_left/tabs';
+import { ObservedEntity } from '../shared/components/observed_entity';
+import type { ObservedEntityData } from '../shared/components/observed_entity/types';
+import { useObservedUserItems } from './hooks/use_observed_user_items';
+import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
interface UserPanelContentProps {
- observedUser: ObservedUserData;
+ observedUser: ObservedEntityData;
managedUser: ManagedUserData;
riskScoreState: RiskScoreState;
contextID: string;
scopeId: string;
isDraggable: boolean;
- openDetailsPanel: (tab: UserDetailsLeftPanelTab) => void;
+ openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void;
}
export const UserPanelContent = ({
@@ -40,6 +40,8 @@ export const UserPanelContent = ({
isDraggable,
openDetailsPanel,
}: UserPanelContentProps) => {
+ const observedFields = useObservedUserItems(observedUser);
+
return (
{riskScoreState.isModuleEnabled && riskScoreState.data?.length !== 0 && (
@@ -52,11 +54,13 @@ export const UserPanelContent = ({
>
)}
-
;
managedUser: ManagedUserData;
}
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/translations.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/translations.ts
new file mode 100644
index 00000000000000..9c7637d75f543f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/translations.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const USER_ID = i18n.translate('xpack.securitySolution.flyout.entityDetails.user.idLabel', {
+ defaultMessage: 'User ID',
+});
+
+export const MAX_ANOMALY_SCORE_BY_JOB = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.user.maxAnomalyScoreByJobLabel',
+ {
+ defaultMessage: 'Max anomaly score by job',
+ }
+);
+
+export const FIRST_SEEN = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.user.firstSeenLabel',
+ {
+ defaultMessage: 'First seen',
+ }
+);
+
+export const LAST_SEEN = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.user.lastSeenLabel',
+ {
+ defaultMessage: 'Last seen',
+ }
+);
+
+export const OPERATING_SYSTEM_TITLE = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.user.hostOsNameLabel',
+ {
+ defaultMessage: 'Operating system',
+ }
+);
+
+export const FAMILY = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.user.familyLabel',
+ {
+ defaultMessage: 'Family',
+ }
+);
+
+export const IP_ADDRESSES = i18n.translate(
+ 'xpack.securitySolution.flyout.entityDetails.user.ipAddressesLabel',
+ {
+ defaultMessage: 'IP addresses',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_observed_user.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user.ts
similarity index 65%
rename from x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_observed_user.ts
rename to x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user.ts
index d3d2c4fde90a10..6d1ae0ab11e003 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_observed_user.ts
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user.ts
@@ -6,28 +6,18 @@
*/
import { useMemo } from 'react';
-import { useObservedUserDetails } from '../../../../../explore/users/containers/users/observed_details';
-import type { UserItem } from '../../../../../../common/search_strategy';
-import { Direction, NOT_EVENT_KIND_ASSET_FILTER } from '../../../../../../common/search_strategy';
-import { useSourcererDataView } from '../../../../../common/containers/sourcerer';
-import { useGlobalTime } from '../../../../../common/containers/use_global_time';
-import { useFirstLastSeen } from '../../../../../common/containers/use_first_last_seen';
-import { useQueryInspector } from '../../../../../common/components/page/manage_query';
+import { useQueryInspector } from '../../../../common/components/page/manage_query';
+import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
+import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details';
+import type { UserItem } from '../../../../../common/search_strategy';
+import { Direction, NOT_EVENT_KIND_ASSET_FILTER } from '../../../../../common/search_strategy';
+import { useSourcererDataView } from '../../../../common/containers/sourcerer';
+import { useGlobalTime } from '../../../../common/containers/use_global_time';
+import { useFirstLastSeen } from '../../../../common/containers/use_first_last_seen';
-export interface ObserverUser {
- details: UserItem;
- isLoading: boolean;
- firstSeen: {
- date: string | null | undefined;
- isLoading: boolean;
- };
- lastSeen: {
- date: string | null | undefined;
- isLoading: boolean;
- };
-}
-
-export const useObservedUser = (userName: string): ObserverUser => {
+export const useObservedUser = (
+ userName: string
+): Omit, 'anomalies'> => {
const { selectedPatterns } = useSourcererDataView();
const { to, from, isInitializing, deleteQuery, setQuery } = useGlobalTime();
@@ -68,7 +58,7 @@ export const useObservedUser = (userName: string): ObserverUser => {
return useMemo(
() => ({
details: observedUserDetails,
- isLoading: loadingObservedUser,
+ isLoading: loadingObservedUser || loadingLastSeen || loadingFirstSeen,
firstSeen: {
date: firstSeen,
isLoading: loadingFirstSeen,
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_observed_user_items.test.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user_items.test.ts
similarity index 53%
rename from x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_observed_user_items.test.ts
rename to x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user_items.test.ts
index 40fd3c60890399..1c7b5557dd90a2 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_observed_user_items.test.ts
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user_items.test.ts
@@ -5,13 +5,13 @@
* 2.0.
*/
+import { TestProviders } from '@kbn/timelines-plugin/public/mock';
import { renderHook } from '@testing-library/react-hooks';
-import { TestProviders } from '../../../../../common/mock';
-import { mockObservedUser } from '../__mocks__';
+import { mockObservedUser } from '../mocks';
import { useObservedUserItems } from './use_observed_user_items';
describe('useManagedUserItems', () => {
- it('returns managed user items for Entra user', () => {
+ it('returns observed user fields', () => {
const { result } = renderHook(() => useObservedUserItems(mockObservedUser), {
wrapper: TestProviders,
});
@@ -20,43 +20,58 @@ describe('useManagedUserItems', () => {
{
field: 'user.id',
label: 'User ID',
- values: ['1234', '321'],
+ getValues: expect.any(Function),
},
{
field: 'user.domain',
label: 'Domain',
- values: ['test domain', 'another test domain'],
- },
- {
- field: 'anomalies',
- label: 'Max anomaly score by job',
- values: mockObservedUser.anomalies,
+ getValues: expect.any(Function),
},
{
field: '@timestamp',
label: 'First seen',
- values: ['2023-02-23T20:03:17.489Z'],
+ getValues: expect.any(Function),
},
{
field: '@timestamp',
label: 'Last seen',
- values: ['2023-02-23T20:03:17.489Z'],
+ getValues: expect.any(Function),
},
{
field: 'host.os.name',
label: 'Operating system',
- values: ['testOs'],
+ getValues: expect.any(Function),
},
{
field: 'host.os.family',
label: 'Family',
- values: ['testFamily'],
+
+ getValues: expect.any(Function),
},
{
field: 'host.ip',
label: 'IP addresses',
- values: ['10.0.0.1', '127.0.0.1'],
+
+ getValues: expect.any(Function),
+ },
+ {
+ label: 'Max anomaly score by job',
+ isVisible: expect.any(Function),
+ render: expect.any(Function),
},
]);
+
+ expect(result.current.map(({ getValues }) => getValues && getValues(mockObservedUser))).toEqual(
+ [
+ ['1234', '321'], // id
+ ['test domain', 'another test domain'], // domain
+ ['2023-02-23T20:03:17.489Z'], // First seen
+ ['2023-02-23T20:03:17.489Z'], // Last seen
+ ['testOs'], // OS name
+ ['testFamily'], // os family
+ ['10.0.0.1', '127.0.0.1'], // IP addresses
+ undefined, // Max anomaly score by job doesn't implement getValues
+ ]
+ );
});
});
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user_items.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user_items.ts
new file mode 100644
index 00000000000000..7275b2ca555700
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user_items.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useMemo } from 'react';
+import type { UserItem } from '../../../../../common/search_strategy';
+import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities';
+import { getAnomaliesFields } from '../../shared/common';
+import * as i18n from './translations';
+import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
+import type { EntityTableRows } from '../../shared/components/entity_table/types';
+
+const basicUserFields: EntityTableRows> = [
+ {
+ label: i18n.USER_ID,
+ getValues: (userData: ObservedEntityData) => userData.details.user?.id,
+ field: 'user.id',
+ },
+ {
+ label: 'Domain',
+ getValues: (userData: ObservedEntityData) => userData.details.user?.domain,
+ field: 'user.domain',
+ },
+ {
+ label: i18n.FIRST_SEEN,
+ getValues: (userData: ObservedEntityData) =>
+ userData.firstSeen.date ? [userData.firstSeen.date] : undefined,
+ field: '@timestamp',
+ },
+ {
+ label: i18n.LAST_SEEN,
+ getValues: (userData: ObservedEntityData) =>
+ userData.lastSeen.date ? [userData.lastSeen.date] : undefined,
+ field: '@timestamp',
+ },
+ {
+ label: i18n.OPERATING_SYSTEM_TITLE,
+ getValues: (userData: ObservedEntityData) => userData.details.host?.os?.name,
+ field: 'host.os.name',
+ },
+ {
+ label: i18n.FAMILY,
+ getValues: (userData: ObservedEntityData) => userData.details.host?.os?.family,
+ field: 'host.os.family',
+ },
+ {
+ label: i18n.IP_ADDRESSES,
+ getValues: (userData: ObservedEntityData) => userData.details.host?.ip,
+ field: 'host.ip',
+ },
+];
+
+export const useObservedUserItems = (
+ userData: ObservedEntityData
+): EntityTableRows> => {
+ const mlCapabilities = useMlCapabilities();
+
+ const fields: EntityTableRows> = useMemo(
+ () => [...basicUserFields, ...getAnomaliesFields(mlCapabilities)],
+ [mlCapabilities]
+ );
+
+ if (!userData.details) {
+ return [];
+ }
+
+ return fields;
+};
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.test.tsx
index 1c74e4ed23ea57..9961b3ea086e20 100644
--- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.test.tsx
@@ -10,12 +10,12 @@ import React from 'react';
import { TestProviders } from '../../../common/mock';
import type { UserPanelProps } from '.';
import { UserPanel } from '.';
-import { mockRiskScoreState } from './mocks';
import {
mockManagedUserData,
- mockObservedUser,
+ mockRiskScoreState,
} from '../../../timelines/components/side_panel/new_user_detail/__mocks__';
+import { mockObservedUser } from './mocks';
const mockProps: UserPanelProps = {
userName: 'test',
@@ -41,12 +41,9 @@ jest.mock(
})
);
-jest.mock(
- '../../../timelines/components/side_panel/new_user_detail/hooks/use_observed_user',
- () => ({
- useObservedUser: () => mockedUseObservedUser(),
- })
-);
+jest.mock('./hooks/use_observed_user', () => ({
+ useObservedUser: () => mockedUseObservedUser(),
+}));
describe('UserPanel', () => {
beforeEach(() => {
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx
index 76168bc01c8421..abe3ee47930169 100644
--- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx
@@ -11,7 +11,6 @@ import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score';
import { ManagedUserDatasetKey } from '../../../../common/search_strategy/security_solution/users/managed_details';
import { useManagedUser } from '../../../timelines/components/side_panel/new_user_detail/hooks/use_managed_user';
-import { useObservedUser } from '../../../timelines/components/side_panel/new_user_detail/hooks/use_observed_user';
import { useQueryInspector } from '../../../common/components/page/manage_query';
import { UsersType } from '../../../explore/users/store/model';
import { getCriteriaFromUsersType } from '../../../common/components/ml/criteria/get_criteria_from_users_type';
@@ -24,7 +23,8 @@ import { FlyoutNavigation } from '../../shared/components/flyout_navigation';
import { UserPanelContent } from './content';
import { UserPanelHeader } from './header';
import { UserDetailsPanelKey } from '../user_details_left';
-import type { UserDetailsLeftPanelTab } from '../user_details_left/tabs';
+import { useObservedUser } from './hooks/use_observed_user';
+import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
export interface UserPanelProps extends Record {
contextID: string;
@@ -79,7 +79,7 @@ export const UserPanel = ({ contextID, scopeId, userName, isDraggable }: UserPan
const { openLeftPanel } = useExpandableFlyoutContext();
const openPanelTab = useCallback(
- (tab?: UserDetailsLeftPanelTab) => {
+ (tab?: EntityDetailsLeftPanelTab) => {
openLeftPanel({
id: UserDetailsPanelKey,
params: {
diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/mocks/index.ts b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/mocks/index.ts
index 88ab3c10241cb6..b58c94c5772ff3 100644
--- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/mocks/index.ts
+++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/mocks/index.ts
@@ -5,47 +5,43 @@
* 2.0.
*/
-import type { RiskScoreState } from '../../../../entity_analytics/api/hooks/use_risk_score';
-import type { RiskScoreEntity, UserRiskScore } from '../../../../../common/search_strategy';
-import { RiskSeverity } from '../../../../../common/search_strategy';
-import { RiskCategories } from '../../../../../common/entity_analytics/risk_engine';
+import { mockAnomalies } from '../../../../common/components/ml/mock';
+import type { UserItem } from '../../../../../common/search_strategy';
+import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
-const userRiskScore: UserRiskScore = {
- '@timestamp': '626569200000',
+const anomaly = mockAnomalies.anomalies[0];
+
+const observedUserDetails = {
user: {
- name: 'test',
- risk: {
- rule_risks: [],
- calculated_score_norm: 70,
- multipliers: [],
- calculated_level: RiskSeverity.high,
- inputs: [
- {
- id: '_id',
- index: '_index',
- category: RiskCategories.category_1,
- description: 'Alert from Rule: My rule',
- risk_score: 30,
- timestamp: '2021-08-19T18:55:59.000Z',
- },
- ],
+ id: ['1234', '321'],
+ domain: ['test domain', 'another test domain'],
+ },
+ host: {
+ ip: ['10.0.0.1', '127.0.0.1'],
+ os: {
+ name: ['testOs'],
+ family: ['testFamily'],
},
},
- alertsCount: 0,
- oldestAlertTimestamp: '626569200000',
};
-export const mockRiskScoreState: RiskScoreState = {
- data: [userRiskScore],
- inspect: {
- dsl: [],
- response: [],
+export const mockObservedUser: ObservedEntityData = {
+ details: observedUserDetails,
+ isLoading: false,
+ firstSeen: {
+ isLoading: false,
+ date: '2023-02-23T20:03:17.489Z',
+ },
+ lastSeen: {
+ isLoading: false,
+ date: '2023-02-23T20:03:17.489Z',
+ },
+ anomalies: {
+ isLoading: false,
+ anomalies: {
+ anomalies: [anomaly],
+ interval: '',
+ },
+ jobNameById: { [anomaly.jobId]: 'job_name' },
},
- isInspected: false,
- refetch: () => {},
- totalCount: 0,
- isModuleEnabled: true,
- isAuthorized: true,
- isDeprecated: false,
- loading: false,
};
diff --git a/x-pack/plugins/security_solution/public/flyout/index.tsx b/x-pack/plugins/security_solution/public/flyout/index.tsx
index ef7e182324c630..c72417bd2004bc 100644
--- a/x-pack/plugins/security_solution/public/flyout/index.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/index.tsx
@@ -26,6 +26,11 @@ import type { UserPanelExpandableFlyoutProps } from './entity_details/user_right
import { UserPanel, UserPanelKey } from './entity_details/user_right';
import type { UserDetailsPanelProps } from './entity_details/user_details_left';
import { UserDetailsPanel, UserDetailsPanelKey } from './entity_details/user_details_left';
+import type { HostPanelExpandableFlyoutProps } from './entity_details/host_right';
+import { HostPanel, HostPanelKey } from './entity_details/host_right';
+import type { HostDetailsExpandableFlyoutProps } from './entity_details/host_details_left';
+import { HostDetailsPanel, HostDetailsPanelKey } from './entity_details/host_details_left';
+
/**
* List of all panels that will be used within the document details expandable flyout.
* This needs to be passed to the expandable flyout registeredPanels property.
@@ -73,6 +78,16 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels']
),
},
+ {
+ key: HostPanelKey,
+ component: (props) => ,
+ },
+ {
+ key: HostDetailsPanelKey,
+ component: (props) => (
+
+ ),
+ },
];
export const SecuritySolutionFlyout = memo(() => (
diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx
index a6409c587e0a6e..92ff1435851173 100644
--- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx
+++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx
@@ -13,9 +13,8 @@ import styled from 'styled-components';
import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score';
import type { HostItem } from '../../../../common/search_strategy';
import { buildHostNamesFilter, RiskScoreEntity } from '../../../../common/search_strategy';
-import { DEFAULT_DARK_MODE } from '../../../../common/constants';
import type { DescriptionList } from '../../../../common/utility_types';
-import { useUiSetting$ } from '../../../common/lib/kibana';
+import { useDarkMode } from '../../../common/lib/kibana';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import {
DefaultFieldRenderer,
@@ -84,7 +83,7 @@ export const HostOverview = React.memo(
}) => {
const capabilities = useMlCapabilities();
const userPermissions = hasMlUserPermissions(capabilities);
- const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE);
+ const darkMode = useDarkMode();
const filterQuery = useMemo(
() => (hostName ? buildHostNamesFilter([hostName]) : undefined),
[hostName]
diff --git a/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx
index 572e4aab7e6dfa..bd9e56dffee8d4 100644
--- a/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx
+++ b/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx
@@ -12,9 +12,8 @@ import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score';
import { buildUserNamesFilter, RiskScoreEntity } from '../../../../common/search_strategy';
-import { DEFAULT_DARK_MODE } from '../../../../common/constants';
import type { DescriptionList } from '../../../../common/utility_types';
-import { useUiSetting$ } from '../../../common/lib/kibana';
+import { useDarkMode } from '../../../common/lib/kibana';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { DefaultFieldRenderer } from '../../../timelines/components/field_renderers/field_renderers';
import {
@@ -82,7 +81,7 @@ export const UserOverview = React.memo(
}) => {
const capabilities = useMlCapabilities();
const userPermissions = hasMlUserPermissions(capabilities);
- const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE);
+ const darkMode = useDarkMode();
const filterQuery = useMemo(
() => (userName ? buildUserNamesFilter([userName]) : undefined),
[userName]
diff --git a/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx b/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx
index 926227f6fcfd7d..8576c6a364a963 100644
--- a/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx
+++ b/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx
@@ -8,7 +8,7 @@
import React, { memo } from 'react';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
-import { useUiSetting } from '@kbn/kibana-react-plugin/public';
+import { useDarkMode } from '@kbn/kibana-react-plugin/public';
import { useSymbolIDs } from './use_symbol_ids';
import { usePaintServerIDs } from './use_paint_server_ids';
@@ -435,7 +435,7 @@ const SymbolsAndShapes = memo(({ id, isDarkMode }: { id: string; isDarkMode: boo
*/
// eslint-disable-next-line react/display-name
export const SymbolDefinitions = memo(({ id }: { id: string }) => {
- const isDarkMode = useUiSetting('theme:darkMode');
+ const isDarkMode = useDarkMode();
return (
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/__mocks__/index.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/__mocks__/index.ts
index 65c6bd974b83ad..43abce11044671 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/__mocks__/index.ts
+++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/__mocks__/index.ts
@@ -11,8 +11,7 @@ import type {
} from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import { ManagedUserDatasetKey } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import { RiskSeverity } from '../../../../../../common/search_strategy';
-import { mockAnomalies } from '../../../../../common/components/ml/mock';
-import type { ManagedUserData, ObservedUserData } from '../types';
+import type { ManagedUserData } from '../types';
const userRiskScore = {
'@timestamp': '123456',
@@ -44,43 +43,6 @@ export const mockRiskScoreState = {
loading: false,
};
-const anomaly = mockAnomalies.anomalies[0];
-
-export const observedUserDetails = {
- user: {
- id: ['1234', '321'],
- domain: ['test domain', 'another test domain'],
- },
- host: {
- ip: ['10.0.0.1', '127.0.0.1'],
- os: {
- name: ['testOs'],
- family: ['testFamily'],
- },
- },
-};
-
-export const mockObservedUser: ObservedUserData = {
- details: observedUserDetails,
- isLoading: false,
- firstSeen: {
- isLoading: false,
- date: '2023-02-23T20:03:17.489Z',
- },
- lastSeen: {
- isLoading: false,
- date: '2023-02-23T20:03:17.489Z',
- },
- anomalies: {
- isLoading: false,
- anomalies: {
- anomalies: [anomaly],
- interval: '',
- },
- jobNameById: { [anomaly.jobId]: 'job_name' },
- },
-};
-
export const mockOktaUserFields: ManagedUserFields = {
'@timestamp': ['2023-11-16T13:42:23.074Z'],
'event.dataset': [ManagedUserDatasetKey.OKTA],
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/columns.tsx
index 8c4f31ea12141e..da4e82976d5155 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/columns.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/columns.tsx
@@ -6,31 +6,16 @@
*/
import { css } from '@emotion/react';
-import React, { useCallback } from 'react';
-import { head } from 'lodash/fp';
+import React from 'react';
import { euiLightVars } from '@kbn/ui-theme';
import type { EuiBasicTableColumn } from '@elastic/eui';
-import { useDispatch } from 'react-redux';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { DefaultFieldRenderer } from '../../field_renderers/field_renderers';
-import type {
- ManagedUsersTableColumns,
- ManagedUserTable,
- ObservedUsersTableColumns,
- ObservedUserTable,
- UserAnomalies,
-} from './types';
+import type { ManagedUsersTableColumns, ManagedUserTable } from './types';
import * as i18n from './translations';
import { defaultToEmptyTag } from '../../../../common/components/empty_value';
-import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
-import { AnomalyScores } from '../../../../common/components/ml/score/anomaly_scores';
-import { useGlobalTime } from '../../../../common/containers/use_global_time';
-import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime';
-import { InputsModelId } from '../../../../common/store/inputs/constants';
-import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions';
-import { getSourcererScopeId } from '../../../../helpers';
-const fieldColumn: EuiBasicTableColumn = {
+const fieldColumn: EuiBasicTableColumn = {
name: i18n.FIELD_COLUMN_TITLE,
field: 'label',
render: (label: string, { field }) => (
@@ -68,71 +53,3 @@ export const getManagedUserTableColumns = (
},
},
];
-
-function isAnomalies(
- field: string | undefined,
- values: UserAnomalies | unknown
-): values is UserAnomalies {
- return field === 'anomalies';
-}
-
-export const getObservedUserTableColumns = (
- contextID: string,
- scopeId: string,
- isDraggable: boolean
-): ObservedUsersTableColumns => [
- fieldColumn,
- {
- name: i18n.VALUES_COLUMN_TITLE,
- field: 'values',
- render: (values: ObservedUserTable['values'], { field }) => {
- if (isAnomalies(field, values) && values) {
- return ;
- }
-
- if (field === '@timestamp') {
- return ;
- }
-
- return (
-
- );
- },
- },
-];
-
-const AnomaliesField = ({ anomalies }: { anomalies: UserAnomalies }) => {
- const { to, from } = useGlobalTime();
- const dispatch = useDispatch();
-
- const narrowDateRange = useCallback(
- (score, interval) => {
- const fromTo = scoreIntervalToDateTime(score, interval);
- dispatch(
- setAbsoluteRangeDatePicker({
- id: InputsModelId.global,
- from: fromTo.from,
- to: fromTo.to,
- })
- );
- },
- [dispatch]
- );
-
- return (
-
- );
-};
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_observed_user_items.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_observed_user_items.ts
deleted file mode 100644
index d6390b210d5862..00000000000000
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_observed_user_items.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import { useMemo } from 'react';
-import * as i18n from '../translations';
-import type { ObservedUserData, ObservedUserTable } from '../types';
-
-export const useObservedUserItems = (userData: ObservedUserData): ObservedUserTable[] =>
- useMemo(
- () =>
- !userData.details
- ? []
- : [
- { label: i18n.USER_ID, values: userData.details.user?.id, field: 'user.id' },
- { label: 'Domain', values: userData.details.user?.domain, field: 'user.domain' },
- {
- label: i18n.MAX_ANOMALY_SCORE_BY_JOB,
- field: 'anomalies',
- values: userData.anomalies,
- },
- {
- label: i18n.FIRST_SEEN,
- values: userData.firstSeen.date ? [userData.firstSeen.date] : undefined,
- field: '@timestamp',
- },
- {
- label: i18n.LAST_SEEN,
- values: userData.lastSeen.date ? [userData.lastSeen.date] : undefined,
- field: '@timestamp',
- },
- {
- label: i18n.OPERATING_SYSTEM_TITLE,
- values: userData.details.host?.os?.name,
- field: 'host.os.name',
- },
- {
- label: i18n.FAMILY,
- values: userData.details.host?.os?.family,
- field: 'host.os.family',
- },
- { label: i18n.IP_ADDRESSES, values: userData.details.host?.ip, field: 'host.ip' },
- ],
- [userData.details, userData.anomalies, userData.firstSeen, userData.lastSeen]
- );
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/managed_user.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/managed_user.tsx
index 590f120b196878..635cf6a2868fd8 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/managed_user.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/managed_user.tsx
@@ -18,7 +18,7 @@ import {
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/css';
-import type { UserDetailsLeftPanelTab } from '../../../../flyout/entity_details/user_details_left/tabs';
+import type { EntityDetailsLeftPanelTab } from '../../../../flyout/entity_details/shared/components/left_panel/left_panel_header';
import { UserAssetTableType } from '../../../../explore/users/store/model';
import type { ManagedUserFields } from '../../../../../common/search_strategy/security_solution/users/managed_details';
import { ManagedUserDatasetKey } from '../../../../../common/search_strategy/security_solution/users/managed_details';
@@ -47,7 +47,7 @@ export const ManagedUser = ({
managedUser: ManagedUserData;
contextID: string;
isDraggable: boolean;
- openDetailsPanel: (tab: UserDetailsLeftPanelTab) => void;
+ openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void;
}) => {
const entraManagedUser = managedUser.data?.[ManagedUserDatasetKey.ENTRA];
const oktaManagedUser = managedUser.data?.[ManagedUserDatasetKey.OKTA];
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/managed_user_accordion.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/managed_user_accordion.tsx
index a03775f61cf26b..ad8b089adc1684 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/managed_user_accordion.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/managed_user_accordion.tsx
@@ -11,7 +11,7 @@ import React from 'react';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import { get } from 'lodash/fp';
-import { UserDetailsLeftPanelTab } from '../../../../flyout/entity_details/user_details_left/tabs';
+import { EntityDetailsLeftPanelTab } from '../../../../flyout/entity_details/shared/components/left_panel/left_panel_header';
import { ExpandablePanel } from '../../../../flyout/shared/components/expandable_panel';
import type { ManagedUserFields } from '../../../../../common/search_strategy/security_solution/users/managed_details';
@@ -23,7 +23,7 @@ interface ManagedUserAccordionProps {
title: string;
managedUser: ManagedUserFields;
tableType: UserAssetTableType;
- openDetailsPanel: (tab: UserDetailsLeftPanelTab) => void;
+ openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void;
}
export const ManagedUserAccordion: React.FC = ({
@@ -66,8 +66,8 @@ export const ManagedUserAccordion: React.FC = ({
callback: () =>
openDetailsPanel(
tableType === UserAssetTableType.assetOkta
- ? UserDetailsLeftPanelTab.OKTA
- : UserDetailsLeftPanelTab.ENTRA
+ ? EntityDetailsLeftPanelTab.OKTA
+ : EntityDetailsLeftPanelTab.ENTRA
),
tooltip: (
{
- const mockProps = {
- observedUser: mockObservedUser,
- contextID: '',
- scopeId: '',
- isDraggable: false,
- };
-
- it('renders', () => {
- const { getByTestId } = render(
-
-
-
- );
-
- expect(getByTestId('observedUser-data')).toBeInTheDocument();
- });
-
- it('renders the formatted date', () => {
- const { getByTestId } = render(
-
-
-
- );
-
- expect(getByTestId('observedUser-data')).toHaveTextContent('Updated Feb 23, 2023');
- });
-
- it('renders anomaly score', () => {
- const { getByTestId } = render(
-
-
-
- );
-
- expect(getByTestId('anomaly-score')).toHaveTextContent('17');
- });
-});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/risk_score_field.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/risk_score_field.test.tsx
deleted file mode 100644
index 48d927c97030c3..00000000000000
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/risk_score_field.test.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import { render } from '@testing-library/react';
-import React from 'react';
-import { TestProviders } from '../../../../common/mock';
-import { RiskScoreField } from './risk_score_field';
-import { mockRiskScoreState } from './__mocks__';
-import { getEmptyValue } from '../../../../common/components/empty_value';
-
-describe('RiskScoreField', () => {
- it('renders', () => {
- const { getByTestId } = render(
-
-
-
- );
-
- expect(getByTestId('user-details-risk-score')).toBeInTheDocument();
- expect(getByTestId('user-details-risk-score')).toHaveTextContent('70');
- });
-
- it('does not render content when the license is invalid', () => {
- const { queryByTestId } = render(
-
-
-
- );
-
- expect(queryByTestId('user-details-risk-score')).not.toBeInTheDocument();
- });
-
- it('renders empty tag when risk score is undefined', () => {
- const { getByTestId } = render(
-
-
-
- );
-
- expect(getByTestId('user-details-risk-score')).toHaveTextContent(getEmptyValue());
- });
-});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/risk_score_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/risk_score_field.tsx
deleted file mode 100644
index fab77b92582f67..00000000000000
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/risk_score_field.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import { EuiFlexItem, EuiFlexGroup, useEuiFontSize, useEuiTheme } from '@elastic/eui';
-
-import React from 'react';
-import { css } from '@emotion/react';
-
-import styled from 'styled-components';
-import * as i18n from './translations';
-
-import { RiskScoreEntity } from '../../../../../common/search_strategy';
-import { getEmptyTagValue } from '../../../../common/components/empty_value';
-import { RiskScoreLevel } from '../../../../entity_analytics/components/severity/common';
-import type { RiskScoreState } from '../../../../entity_analytics/api/hooks/use_risk_score';
-import { RiskScoreDocTooltip } from '../../../../overview/components/common';
-
-export const TooltipContainer = styled.div`
- padding: ${({ theme }) => theme.eui.euiSizeS};
-`;
-
-export const RiskScoreField = ({
- riskScoreState,
-}: {
- riskScoreState: RiskScoreState;
-}) => {
- const { euiTheme } = useEuiTheme();
- const { fontSize: xsFontSize } = useEuiFontSize('xs');
- const { data: userRisk, isAuthorized: isRiskScoreAuthorized } = riskScoreState;
- const userRiskData = userRisk && userRisk.length > 0 ? userRisk[0] : undefined;
-
- if (!isRiskScoreAuthorized) {
- return null;
- }
-
- return (
-
-
-
- {i18n.RISK_SCORE}
- {': '}
-
-
- {userRiskData ? (
-
-
- {Math.round(userRiskData.user.risk.calculated_score_norm)}
-
-
-
-
-
-
-
-
- ) : (
- getEmptyTagValue()
- )}
-
- );
-};
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/translations.ts
index ce5e34ce3249b4..ebeb5d26cf362f 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/translations.ts
+++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/translations.ts
@@ -81,49 +81,6 @@ export const FIELD_COLUMN_TITLE = i18n.translate(
}
);
-export const USER_ID = i18n.translate('xpack.securitySolution.timeline.userDetails.userIdLabel', {
- defaultMessage: 'User ID',
-});
-
-export const MAX_ANOMALY_SCORE_BY_JOB = i18n.translate(
- 'xpack.securitySolution.timeline.userDetails.maxAnomalyScoreByJobLabel',
- {
- defaultMessage: 'Max anomaly score by job',
- }
-);
-
-export const FIRST_SEEN = i18n.translate(
- 'xpack.securitySolution.timeline.userDetails.firstSeenLabel',
- {
- defaultMessage: 'First seen',
- }
-);
-
-export const LAST_SEEN = i18n.translate(
- 'xpack.securitySolution.timeline.userDetails.lastSeenLabel',
- {
- defaultMessage: 'Last seen',
- }
-);
-
-export const OPERATING_SYSTEM_TITLE = i18n.translate(
- 'xpack.securitySolution.timeline.userDetails.hostOsNameLabel',
- {
- defaultMessage: 'Operating system',
- }
-);
-
-export const FAMILY = i18n.translate('xpack.securitySolution.timeline.userDetails.familyLabel', {
- defaultMessage: 'Family',
-});
-
-export const IP_ADDRESSES = i18n.translate(
- 'xpack.securitySolution.timeline.userDetails.ipAddressesLabel',
- {
- defaultMessage: 'IP addresses',
- }
-);
-
export const NO_ACTIVE_INTEGRATION_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.noActiveIntegrationTitle',
{
@@ -168,13 +125,6 @@ export const CLOSE_BUTTON = i18n.translate(
}
);
-export const OBSERVED_USER_INSPECT_TITLE = i18n.translate(
- 'xpack.securitySolution.timeline.userDetails.observedUserInspectTitle',
- {
- defaultMessage: 'Observed user',
- }
-);
-
export const MANAGED_USER_INSPECT_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.managedUserInspectTitle',
{
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/types.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/types.ts
index edefb6ac75100b..721ba17370709f 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/types.ts
+++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/types.ts
@@ -7,44 +7,17 @@
import type { EuiBasicTableColumn } from '@elastic/eui';
import type { SearchTypes } from '../../../../../common/detection_engine/types';
-import type { UserItem } from '../../../../../common/search_strategy';
import type { ManagedUserHits } from '../../../../../common/search_strategy/security_solution/users/managed_details';
-import type { AnomalyTableProviderChildrenProps } from '../../../../common/components/ml/anomaly/anomaly_table_provider';
-
-export interface ObservedUserTable {
- values: string[] | null | undefined | UserAnomalies;
- field: string;
-}
export interface ManagedUserTable {
value: SearchTypes[];
field?: string;
}
-export type ObservedUsersTableColumns = Array>;
export type ManagedUsersTableColumns = Array>;
-export interface ObservedUserData {
- isLoading: boolean;
- details: UserItem;
- firstSeen: FirstLastSeenData;
- lastSeen: FirstLastSeenData;
- anomalies: UserAnomalies;
-}
-
export interface ManagedUserData {
isLoading: boolean;
data: ManagedUserHits | undefined;
isIntegrationEnabled: boolean;
}
-
-export interface FirstLastSeenData {
- date: string | null | undefined;
- isLoading: boolean;
-}
-
-export interface UserAnomalies {
- isLoading: AnomalyTableProviderChildrenProps['isLoadingAnomaliesData'];
- anomalies: AnomalyTableProviderChildrenProps['anomaliesData'];
- jobNameById: AnomalyTableProviderChildrenProps['jobNameById'];
-}
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx
index 1535b05a97a4f1..437f8be9de10cf 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx
@@ -17,6 +17,23 @@ import { StatefulEventContext } from '../../../../../common/components/events_vi
import { createTelemetryServiceMock } from '../../../../../common/lib/telemetry/telemetry_service.mock';
const mockedTelemetry = createTelemetryServiceMock();
+const mockUseIsExperimentalFeatureEnabled = jest.fn();
+const mockOpenRightPanel = jest.fn();
+
+jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
+ useIsExperimentalFeatureEnabled: () => mockUseIsExperimentalFeatureEnabled,
+}));
+
+jest.mock('@kbn/expandable-flyout/src/context', () => {
+ const original = jest.requireActual('@kbn/expandable-flyout/src/context');
+
+ return {
+ ...original,
+ useExpandableFlyoutContext: () => ({
+ openRightPanel: mockOpenRightPanel,
+ }),
+ };
+});
jest.mock('react-redux', () => {
const origin = jest.requireActual('react-redux');
@@ -197,4 +214,27 @@ describe('HostName', () => {
expect(toggleExpandedDetail).not.toHaveBeenCalled();
});
});
+
+ test('it should open expandable flyout if timeline is not in context and experimental flag is enabled', async () => {
+ mockUseIsExperimentalFeatureEnabled.mockReturnValue(true);
+ const context = {
+ enableHostDetailsFlyout: true,
+ enableIpDetailsFlyout: true,
+ timelineID: 'fake-timeline',
+ tabType: TimelineTabs.query,
+ };
+ const wrapper = mount(
+
+
+
+
+
+ );
+
+ wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
+ await waitFor(() => {
+ expect(mockOpenRightPanel).toHaveBeenCalled();
+ expect(toggleExpandedDetail).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx
index 37503f7b905ec7..c6b8d4f2d4cd3b 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx
@@ -9,9 +9,13 @@ import React, { useCallback, useContext, useMemo } from 'react';
import type { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { isString } from 'lodash/fp';
+import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
+import { TableId } from '@kbn/securitysolution-data-table';
+import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
+import { HostPanelKey } from '../../../../../flyout/entity_details/host_right';
import type { ExpandedDetailType } from '../../../../../../common/types';
import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
-import { getScopedActions } from '../../../../../helpers';
+import { getScopedActions, isTimelineScope } from '../../../../../helpers';
import { HostDetailsLink } from '../../../../../common/components/links';
import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
import { DefaultDraggable } from '../../../../../common/components/draggables';
@@ -46,6 +50,9 @@ const HostNameComponent: React.FC = ({
title,
value,
}) => {
+ const isNewHostDetailsFlyoutEnabled = useIsExperimentalFeatureEnabled('newHostDetailsFlyout');
+ const { openRightPanel } = useExpandableFlyoutContext();
+
const dispatch = useDispatch();
const eventContext = useContext(StatefulEventContext);
const hostName = `${value}`;
@@ -58,31 +65,55 @@ const HostNameComponent: React.FC = ({
if (onClick) {
onClick();
}
+
if (eventContext && isInTimelineContext) {
const { timelineID, tabType } = eventContext;
- const updatedExpandedDetail: ExpandedDetailType = {
- panelView: 'hostDetail',
- params: {
- hostName,
- },
- };
- const scopedActions = getScopedActions(timelineID);
- if (scopedActions) {
- dispatch(
- scopedActions.toggleDetailPanel({
- ...updatedExpandedDetail,
- id: timelineID,
- tabType: tabType as TimelineTabs,
- })
- );
- }
- if (timelineID === TimelineId.active && tabType === TimelineTabs.query) {
- activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail });
+ if (isNewHostDetailsFlyoutEnabled && !isTimelineScope(timelineID)) {
+ openRightPanel({
+ id: HostPanelKey,
+ params: {
+ hostName,
+ contextID: contextId,
+ scopeId: TableId.alertsOnAlertsPage,
+ isDraggable,
+ },
+ });
+ } else {
+ const updatedExpandedDetail: ExpandedDetailType = {
+ panelView: 'hostDetail',
+ params: {
+ hostName,
+ },
+ };
+ const scopedActions = getScopedActions(timelineID);
+ if (scopedActions) {
+ dispatch(
+ scopedActions.toggleDetailPanel({
+ ...updatedExpandedDetail,
+ id: timelineID,
+ tabType: tabType as TimelineTabs,
+ })
+ );
+ }
+
+ if (timelineID === TimelineId.active && tabType === TimelineTabs.query) {
+ activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail });
+ }
}
}
},
- [onClick, eventContext, isInTimelineContext, hostName, dispatch]
+ [
+ onClick,
+ eventContext,
+ isInTimelineContext,
+ isNewHostDetailsFlyoutEnabled,
+ openRightPanel,
+ hostName,
+ contextId,
+ isDraggable,
+ dispatch,
+ ]
);
// The below is explicitly defined this way as the onClick takes precedence when it and the href are both defined
diff --git a/x-pack/plugins/serverless_search/README.mdx b/x-pack/plugins/serverless_search/README.mdx
index 0b89338e5cae5f..d902ab55c41082 100755
--- a/x-pack/plugins/serverless_search/README.mdx
+++ b/x-pack/plugins/serverless_search/README.mdx
@@ -1,3 +1,10 @@
# Serverless Search project plugin
-This plugin contains configuration and code used to create a Serverless Search project. It leverages universal configuration and other APIs in the [`serverless`](../serverless/README.mdx) plugin to configure Kibana.
\ No newline at end of file
+This plugin contains configuration and code used to create a Serverless Search project. It leverages universal configuration and other APIs in the [`serverless`](../serverless/README.mdx) plugin to configure Kibana.
+
+## Code guidance
+
+We use Tanstack React Query to handle all API calls in the browser. To create consistent error behavior, all APIs will flash an error toast if they encounter an error, except for 404 errors on GET calls.
+To skip the error message, add a boolean property `skipToast` to your error and set it to `true`.
+To change the toast message, change the `name` property on your error to change the title and the `body.message` property to change the message contents.
+You can do this by catching the error in your fetch function and modifying the error in your catch before rethrowing.
diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_index_name.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_index_name.tsx
index f755e7f5fd50fe..aa4b9a69755bde 100644
--- a/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_index_name.tsx
+++ b/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_index_name.tsx
@@ -16,7 +16,6 @@ import { useConnector } from '../../../hooks/api/use_connector';
import { useKibanaServices } from '../../../hooks/use_kibana';
import { ApiKeyPanel } from './api_key_panel';
import { ConnectorIndexNameForm } from './connector_index_name_form';
-import { useShowErrorToast } from '../../../hooks/use_error_toast';
import { SyncScheduledCallOut } from './sync_scheduled_callout';
interface ConnectorIndexNameProps {
@@ -27,7 +26,6 @@ export const ConnectorIndexName: React.FC = ({ connecto
const { http } = useKibanaServices();
const queryClient = useQueryClient();
const { queryKey } = useConnector(connector.id);
- const showErrorToast = useShowErrorToast();
const { data, isLoading, isSuccess, mutate } = useMutation({
mutationFn: async ({ inputName, sync }: { inputName: string | null; sync?: boolean }) => {
if (inputName && inputName !== connector.index_name) {
@@ -41,13 +39,6 @@ export const ConnectorIndexName: React.FC = ({ connecto
}
return inputName;
},
- onError: (error) =>
- showErrorToast(
- error,
- i18n.translate('xpack.serverlessSearch.connectors.config.connectorIndexNameError', {
- defaultMessage: 'Error updating index name',
- })
- ),
onSuccess: () => {
queryClient.setQueryData(queryKey, { connector: { ...connector, index_name: data } });
queryClient.invalidateQueries(queryKey);
diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_index_name_panel.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_index_name_panel.tsx
index 2f5e3f4829d48c..ac472d9a4b440a 100644
--- a/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_index_name_panel.tsx
+++ b/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_index_name_panel.tsx
@@ -6,7 +6,6 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiSpacer } from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
import { Connector } from '@kbn/search-connectors';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useEffect, useState } from 'react';
@@ -21,8 +20,8 @@ interface ConnectorIndexNamePanelProps {
}
export const ConnectorIndexnamePanel: React.FC = ({ connector }) => {
- const { http, notifications } = useKibanaServices();
- const { data, error, isLoading, isSuccess, mutate, reset } = useMutation({
+ const { http } = useKibanaServices();
+ const { data, isLoading, isSuccess, mutate, reset } = useMutation({
mutationFn: async (inputName: string) => {
if (inputName && inputName !== connector.index_name) {
const body = { index_name: inputName };
@@ -44,16 +43,6 @@ export const ConnectorIndexnamePanel: React.FC = (
}
}, [data, isSuccess, connector, queryClient, queryKey, reset]);
- useEffect(() => {
- if (error) {
- notifications.toasts.addError(error as Error, {
- title: i18n.translate('xpack.serverlessSearch.connectors.config.connectorIndexNameError', {
- defaultMessage: 'Error updating index name',
- }),
- });
- }
- }, [error, notifications]);
-
const [newIndexName, setNewIndexName] = useState(connector.index_name || '');
return (
diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_overview.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_overview.tsx
index d141ae51c25cee..f75a6d405c43e9 100644
--- a/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_overview.tsx
+++ b/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_overview.tsx
@@ -17,7 +17,6 @@ import { useQueryClient, useMutation } from '@tanstack/react-query';
import React, { useState } from 'react';
import { useConnector } from '../../../hooks/api/use_connector';
import { useSyncJobs } from '../../../hooks/api/use_sync_jobs';
-import { useShowErrorToast } from '../../../hooks/use_error_toast';
import { useKibanaServices } from '../../../hooks/use_kibana';
import { SyncScheduledCallOut } from './sync_scheduled_callout';
@@ -29,18 +28,10 @@ export const ConnectorOverview: React.FC = ({ connector
const { http } = useKibanaServices();
const queryClient = useQueryClient();
const { queryKey } = useConnector(connector.id);
- const showErrorToast = useShowErrorToast();
const { data, isLoading, isSuccess, mutate } = useMutation({
mutationFn: async () => {
await http.post(`/internal/serverless_search/connectors/${connector.id}/sync`);
},
- onError: (error) =>
- showErrorToast(
- error,
- i18n.translate('xpack.serverlessSearch.connectors.config.connectorSyncError', {
- defaultMessage: 'Error scheduling sync',
- })
- ),
onSuccess: () => {
queryClient.setQueryData(queryKey, { connector: { ...connector, index_name: data } });
queryClient.invalidateQueries(queryKey);
diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/edit_connector.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/edit_connector.tsx
index 78d47421420e08..927d3d795aaed3 100644
--- a/x-pack/plugins/serverless_search/public/application/components/connectors/edit_connector.tsx
+++ b/x-pack/plugins/serverless_search/public/application/components/connectors/edit_connector.tsx
@@ -66,7 +66,7 @@ export const EditConnector: React.FC = () => {
title={
{i18n.translate('xpack.serverlessSearch.connectors.notFound', {
- defaultMessage: 'Could not find a connector with id {id}',
+ defaultMessage: 'Could not find connector {id}',
values: { id },
})}
diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/edit_description.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/edit_description.tsx
index 0c22fcae8945fb..111d91b74d0435 100644
--- a/x-pack/plugins/serverless_search/public/application/components/connectors/edit_description.tsx
+++ b/x-pack/plugins/serverless_search/public/application/components/connectors/edit_description.tsx
@@ -24,7 +24,6 @@ import { Connector } from '@kbn/search-connectors';
import { CANCEL_LABEL, EDIT_LABEL, SAVE_LABEL } from '../../../../common/i18n_string';
import { useKibanaServices } from '../../hooks/use_kibana';
import { useConnector } from '../../hooks/api/use_connector';
-import { useShowErrorToast } from '../../hooks/use_error_toast';
interface EditDescriptionProps {
connector: Connector;
@@ -34,7 +33,6 @@ export const EditDescription: React.FC = ({ connector }) =
const [isEditing, setIsEditing] = useState(false);
const [newDescription, setNewDescription] = useState(connector.description || '');
const { http } = useKibanaServices();
- const showErrorToast = useShowErrorToast();
const queryClient = useQueryClient();
const { queryKey } = useConnector(connector.id);
@@ -48,13 +46,6 @@ export const EditDescription: React.FC = ({ connector }) =
});
return inputDescription;
},
- onError: (error) =>
- showErrorToast(
- error,
- i18n.translate('xpack.serverlessSearch.connectors.config.connectorDescription', {
- defaultMessage: 'Error updating description',
- })
- ),
onSuccess: (successData) => {
queryClient.setQueryData(queryKey, {
connector: { ...connector, description: successData },
diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/edit_name.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/edit_name.tsx
index fbab2b15d3434f..642814a35c9e79 100644
--- a/x-pack/plugins/serverless_search/public/application/components/connectors/edit_name.tsx
+++ b/x-pack/plugins/serverless_search/public/application/components/connectors/edit_name.tsx
@@ -24,7 +24,6 @@ import { Connector } from '@kbn/search-connectors';
import { CANCEL_LABEL, CONNECTOR_LABEL, SAVE_LABEL } from '../../../../common/i18n_string';
import { useKibanaServices } from '../../hooks/use_kibana';
import { useConnector } from '../../hooks/api/use_connector';
-import { useShowErrorToast } from '../../hooks/use_error_toast';
interface EditNameProps {
connector: Connector;
@@ -34,7 +33,6 @@ export const EditName: React.FC = ({ connector }) => {
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(connector.name || CONNECTOR_LABEL);
const { http } = useKibanaServices();
- const showErrorToast = useShowErrorToast();
const queryClient = useQueryClient();
const { queryKey } = useConnector(connector.id);
@@ -48,13 +46,6 @@ export const EditName: React.FC = ({ connector }) => {
});
return inputName;
},
- onError: (error) =>
- showErrorToast(
- error,
- i18n.translate('xpack.serverlessSearch.connectors.config.connectorNameError', {
- defaultMessage: 'Error updating name',
- })
- ),
onSuccess: (successData) => {
queryClient.setQueryData(queryKey, {
connector: { ...connector, service_type: successData },
diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/edit_service_type.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/edit_service_type.tsx
index a2df8f7c35bfe4..e00cda1e20956f 100644
--- a/x-pack/plugins/serverless_search/public/application/components/connectors/edit_service_type.tsx
+++ b/x-pack/plugins/serverless_search/public/application/components/connectors/edit_service_type.tsx
@@ -19,7 +19,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Connector } from '@kbn/search-connectors';
import { useKibanaServices } from '../../hooks/use_kibana';
import { useConnectorTypes } from '../../hooks/api/use_connector_types';
-import { useShowErrorToast } from '../../hooks/use_error_toast';
import { useConnector } from '../../hooks/api/use_connector';
interface EditServiceTypeProps {
@@ -29,7 +28,6 @@ interface EditServiceTypeProps {
export const EditServiceType: React.FC = ({ connector }) => {
const { http } = useKibanaServices();
const { data: connectorTypes } = useConnectorTypes();
- const showErrorToast = useShowErrorToast();
const queryClient = useQueryClient();
const { queryKey } = useConnector(connector.id);
@@ -62,13 +60,6 @@ export const EditServiceType: React.FC = ({ connector }) =
});
return inputServiceType;
},
- onError: (error) =>
- showErrorToast(
- error,
- i18n.translate('xpack.serverlessSearch.connectors.config.connectorServiceTypeError', {
- defaultMessage: 'Error updating service type',
- })
- ),
onSuccess: (successData) => {
queryClient.setQueryData(queryKey, {
connector: { ...connector, service_type: successData },
diff --git a/x-pack/plugins/serverless_search/public/application/connectors.tsx b/x-pack/plugins/serverless_search/public/application/connectors.tsx
index fe78131ea45474..44d125117d2a84 100644
--- a/x-pack/plugins/serverless_search/public/application/connectors.tsx
+++ b/x-pack/plugins/serverless_search/public/application/connectors.tsx
@@ -22,10 +22,11 @@ import { ServerlessSearchContext } from './hooks/use_kibana';
export async function renderApp(
element: HTMLElement,
core: CoreStart,
- services: ServerlessSearchContext
+ services: ServerlessSearchContext,
+ queryClient: QueryClient
) {
const { ConnectorsRouter } = await import('./components/connectors_router');
- const queryClient = new QueryClient();
+
ReactDOM.render(
diff --git a/x-pack/plugins/serverless_search/public/application/elasticsearch.tsx b/x-pack/plugins/serverless_search/public/application/elasticsearch.tsx
index 711f4a4c51f1fa..5413ff6361cf9c 100644
--- a/x-pack/plugins/serverless_search/public/application/elasticsearch.tsx
+++ b/x-pack/plugins/serverless_search/public/application/elasticsearch.tsx
@@ -19,10 +19,10 @@ import { ServerlessSearchContext } from './hooks/use_kibana';
export async function renderApp(
element: HTMLElement,
core: CoreStart,
- services: ServerlessSearchContext
+ services: ServerlessSearchContext,
+ queryClient: QueryClient
) {
const { ElasticsearchOverview } = await import('./components/overview');
- const queryClient = new QueryClient();
ReactDOM.render(
diff --git a/x-pack/plugins/serverless_search/public/application/hooks/use_error_toast.tsx b/x-pack/plugins/serverless_search/public/application/hooks/use_error_toast.tsx
deleted file mode 100644
index 84d65989042bc8..00000000000000
--- a/x-pack/plugins/serverless_search/public/application/hooks/use_error_toast.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import { getErrorMessage } from '../../utils/get_error_message';
-import { useKibanaServices } from './use_kibana';
-
-export const useShowErrorToast = () => {
- const { notifications } = useKibanaServices();
-
- return (error: unknown, errorTitle?: string) =>
- notifications.toasts.addError(new Error(getErrorMessage(error)), {
- title: errorTitle || getErrorMessage(error),
- });
-};
diff --git a/x-pack/plugins/serverless_search/public/plugin.ts b/x-pack/plugins/serverless_search/public/plugin.ts
index 6c6328931915eb..31068557ce42e9 100644
--- a/x-pack/plugins/serverless_search/public/plugin.ts
+++ b/x-pack/plugins/serverless_search/public/plugin.ts
@@ -15,6 +15,7 @@ import {
import { i18n } from '@kbn/i18n';
import { appIds } from '@kbn/management-cards-navigation';
import { AuthenticatedUser } from '@kbn/security-plugin/common';
+import { QueryClient, MutationCache, QueryCache } from '@tanstack/react-query';
import { createIndexMappingsDocsLinkContent as createIndexMappingsContent } from './application/components/index_management/index_mappings_docs_link';
import { createIndexOverviewContent } from './application/components/index_management/index_overview_content';
import { createServerlessSearchSideNavComponent as createComponent } from './layout/nav';
@@ -26,6 +27,7 @@ import {
ServerlessSearchPluginStartDependencies,
} from './types';
import { createIndexDocumentsContent } from './application/components/index_documents/documents_tab';
+import { getErrorCode, getErrorMessage, isKibanaServerError } from './utils/get_error_message';
export class ServerlessSearchPlugin
implements
@@ -40,6 +42,32 @@ export class ServerlessSearchPlugin
core: CoreSetup,
_setupDeps: ServerlessSearchPluginSetupDependencies
): ServerlessSearchPluginSetup {
+ const queryClient = new QueryClient({
+ mutationCache: new MutationCache({
+ onError: (error) => {
+ core.notifications.toasts.addError(error as Error, {
+ title: (error as Error).name,
+ toastMessage: getErrorMessage(error),
+ toastLifeTimeMs: 1000,
+ });
+ },
+ }),
+ queryCache: new QueryCache({
+ onError: (error) => {
+ // 404s are often functionally okay and shouldn't show toasts by default
+ if (getErrorCode(error) === 404) {
+ return;
+ }
+ if (isKibanaServerError(error) && !error.skipToast) {
+ core.notifications.toasts.addError(error, {
+ title: error.name,
+ toastMessage: getErrorMessage(error),
+ toastLifeTimeMs: 1000,
+ });
+ }
+ },
+ }),
+ });
core.application.register({
id: 'serverlessElasticsearch',
title: i18n.translate('xpack.serverlessSearch.app.elasticsearch.title', {
@@ -61,7 +89,7 @@ export class ServerlessSearchPlugin
user = undefined;
}
- return await renderApp(element, coreStart, { history, user, ...services });
+ return await renderApp(element, coreStart, { history, user, ...services }, queryClient);
},
});
@@ -79,7 +107,7 @@ export class ServerlessSearchPlugin
const [coreStart, services] = await core.getStartServices();
docLinks.setDocLinks(coreStart.docLinks.links);
- return await renderApp(element, coreStart, { history, ...services });
+ return await renderApp(element, coreStart, { history, ...services }, queryClient);
},
});
return {};
diff --git a/x-pack/plugins/serverless_search/public/utils/get_error_message.ts b/x-pack/plugins/serverless_search/public/utils/get_error_message.ts
index 5dd1b9ffcb0222..0a73af9e544cef 100644
--- a/x-pack/plugins/serverless_search/public/utils/get_error_message.ts
+++ b/x-pack/plugins/serverless_search/public/utils/get_error_message.ts
@@ -11,9 +11,33 @@ export function getErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
- if (typeof error === 'object') {
- return (error as { body: KibanaServerError })?.body?.message || '';
+ if (isKibanaServerError(error)) {
+ return error.body.message;
+ }
+
+ if (typeof error === 'object' && (error as { name: string }).name) {
+ return (error as { name: string }).name;
}
return '';
}
+
+export function getErrorCode(error: unknown): number | undefined {
+ if (isKibanaServerError(error)) {
+ return error.body.statusCode;
+ }
+ return undefined;
+}
+
+export function isKibanaServerError(
+ input: unknown
+): input is Error & { body: KibanaServerError; name: string; skipToast?: boolean } {
+ if (
+ typeof input === 'object' &&
+ (input as { body: KibanaServerError }).body &&
+ typeof (input as { body: KibanaServerError }).body.message === 'string'
+ ) {
+ return true;
+ }
+ return false;
+}
diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json
index 1996568bbbb476..cbbd515a86e1bd 100644
--- a/x-pack/plugins/translations/translations/fr-FR.json
+++ b/x-pack/plugins/translations/translations/fr-FR.json
@@ -18076,7 +18076,6 @@
"xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "Mettre à niveau l'agent",
"xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "Erreur lors de la mise à niveau de {count, plural, one {l'agent} other {{count} agents} =true {tous les agents sélectionnés}}",
"xpack.fleet.upgradeAgents.noMaintenanceWindowOption": "Immédiatement",
- "xpack.fleet.upgradeAgents.noVersionsText": "Aucun agent sélectionné ne peut bénéficier d’une mise à niveau. Sélectionnez un ou plusieurs agents éligibles.",
"xpack.fleet.upgradeAgents.restartConfirmMultipleButtonLabel": "Relancer la mise à niveau {count, plural, one {l'agent} other {{count} agents} =true {tous les agents sélectionnés}}",
"xpack.fleet.upgradeAgents.restartUpgradeMultipleTitle": "Relancer la mise à niveau {updating} sur {count, plural, one {l’agent} other {{count} agents} =true {tous les agents}} bloqué lors de la mise à niveau",
"xpack.fleet.upgradeAgents.restartUpgradeSingleTitle": "Relancer la mise à niveau",
@@ -32021,7 +32020,6 @@
"xpack.securitySolution.timeline.properties.timelineToggleButtonAriaLabel": "{isOpen, select, false {Ouvrir} true {Fermer} other {Bascule}} la chronologie {title}",
"xpack.securitySolution.timeline.saveTimeline.modal.warning.title": "Vous avez une {timeline} non enregistrée. Voulez-vous l'enregistrer ?",
"xpack.securitySolution.timeline.searchBoxPlaceholder": "par ex. nom ou description de {timeline}",
- "xpack.securitySolution.timeline.userDetails.observedUserUpdatedTime": "Mis à jour {time}",
"xpack.securitySolution.timeline.userDetails.updatedTime": "Mis à jour {time}",
"xpack.securitySolution.timeline.youAreInAnEventRendererScreenReaderOnly": "Vous êtes dans un outil de rendu d'événement pour la ligne : {row}. Appuyez sur la touche fléchée vers le haut pour quitter et revenir à la ligne en cours, ou sur la touche fléchée vers le bas pour quitter et passer à la ligne suivante.",
"xpack.securitySolution.timeline.youAreInATableCellScreenReaderOnly": "Vous êtes dans une cellule de tableau. Ligne : {row}, colonne : {column}",
@@ -36362,25 +36360,17 @@
"xpack.securitySolution.timeline.userDetails.addExternalIntegrationButton": "Ajouter des intégrations externes",
"xpack.securitySolution.timeline.userDetails.closeButton": "fermer",
"xpack.securitySolution.timeline.userDetails.failManagedUserDescription": "Impossible de lancer la recherche sur des données gérées par l'utilisateur",
- "xpack.securitySolution.timeline.userDetails.familyLabel": "Famille",
"xpack.securitySolution.timeline.userDetails.fieldColumnTitle": "Champ",
- "xpack.securitySolution.timeline.userDetails.firstSeenLabel": "Vu en premier",
- "xpack.securitySolution.timeline.userDetails.hostOsNameLabel": "Système d'exploitation",
- "xpack.securitySolution.timeline.userDetails.ipAddressesLabel": "Adresses IP",
- "xpack.securitySolution.timeline.userDetails.lastSeenLabel": "Vu en dernier",
"xpack.securitySolution.timeline.userDetails.managedBadge": "GÉRÉ",
"xpack.securitySolution.timeline.userDetails.managedDataTitle": "Données gérées",
"xpack.securitySolution.timeline.userDetails.managedUserInspectTitle": "Géré par l'utilisateur",
- "xpack.securitySolution.timeline.userDetails.maxAnomalyScoreByJobLabel": "Score maximal d'anomalie par tâche",
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationText": "Les intégrations externes peuvent fournir des métadonnées supplémentaires et vous aider à gérer les utilisateurs.",
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationTitle": "Vous n'avez aucune intégration active.",
"xpack.securitySolution.timeline.userDetails.noAzureDataText": "Si vous vous attendiez à voir des métadonnées pour cet utilisateur, assurez-vous d'avoir correctement configuré vos intégrations.",
"xpack.securitySolution.timeline.userDetails.noAzureDataTitle": "Métadonnées introuvables pour cet utilisateur",
"xpack.securitySolution.timeline.userDetails.observedBadge": "OBSERVÉ",
"xpack.securitySolution.timeline.userDetails.observedDataTitle": "Données observées",
- "xpack.securitySolution.timeline.userDetails.observedUserInspectTitle": "Utilisateur observé",
"xpack.securitySolution.timeline.userDetails.riskScoreLabel": "Score de risque",
- "xpack.securitySolution.timeline.userDetails.userIdLabel": "ID utilisateur",
"xpack.securitySolution.timeline.userDetails.userLabel": "Utilisateur",
"xpack.securitySolution.timeline.userDetails.valuesColumnTitle": "Valeurs",
"xpack.securitySolution.timelineEvents.errorSearchDescription": "Une erreur s'est produite lors de la recherche d'événements de la chronologie",
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 376f095421c63f..f0575cc0496fb3 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -18089,7 +18089,6 @@
"xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "エージェントをアップグレード",
"xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "{count, plural, other {{count}個のエージェント} =true {すべての選択されたエージェント}}のアップグレードエラー",
"xpack.fleet.upgradeAgents.noMaintenanceWindowOption": "直ちに実行",
- "xpack.fleet.upgradeAgents.noVersionsText": "アップグレード対象の選択したエージェントはありません。1つ以上の対象のエージェントを選択してください。",
"xpack.fleet.upgradeAgents.restartConfirmMultipleButtonLabel": "{count, plural, other {{count}個のエージェント} =true {すべての選択されたエージェント}}を再起動",
"xpack.fleet.upgradeAgents.restartUpgradeMultipleTitle": "更新が停止している{count, plural, one {エージェント} other {{count}個のエージェント} =true {すべてのエージェント}}のうち{updating}でアップグレードを再開",
"xpack.fleet.upgradeAgents.restartUpgradeSingleTitle": "アップグレードを再開",
@@ -32020,7 +32019,6 @@
"xpack.securitySolution.timeline.properties.timelineToggleButtonAriaLabel": "タイムライン\"{title}\"を{isOpen, select, false {開く} true {閉じる} other {切り替え}}",
"xpack.securitySolution.timeline.saveTimeline.modal.warning.title": "保存されていない{timeline}があります。保存しますか?",
"xpack.securitySolution.timeline.searchBoxPlaceholder": "例:{timeline}名または説明",
- "xpack.securitySolution.timeline.userDetails.observedUserUpdatedTime": "{time}を更新しました",
"xpack.securitySolution.timeline.userDetails.updatedTime": "{time}を更新しました",
"xpack.securitySolution.timeline.youAreInAnEventRendererScreenReaderOnly": "行{row}のイベントレンダラーを表示しています。上矢印キーを押すと、終了して現在の行に戻ります。下矢印キーを押すと、終了して次の行に進みます。",
"xpack.securitySolution.timeline.youAreInATableCellScreenReaderOnly": "表セルの行{row}、列{column}にいます",
@@ -36361,25 +36359,17 @@
"xpack.securitySolution.timeline.userDetails.addExternalIntegrationButton": "外部統合を追加",
"xpack.securitySolution.timeline.userDetails.closeButton": "閉じる",
"xpack.securitySolution.timeline.userDetails.failManagedUserDescription": "ユーザーが管理するデータで検索を実行できませんでした",
- "xpack.securitySolution.timeline.userDetails.familyLabel": "ファミリー",
"xpack.securitySolution.timeline.userDetails.fieldColumnTitle": "フィールド",
- "xpack.securitySolution.timeline.userDetails.firstSeenLabel": "初回の認識",
- "xpack.securitySolution.timeline.userDetails.hostOsNameLabel": "オペレーティングシステム",
- "xpack.securitySolution.timeline.userDetails.ipAddressesLabel": "IP アドレス",
- "xpack.securitySolution.timeline.userDetails.lastSeenLabel": "前回の認識",
"xpack.securitySolution.timeline.userDetails.managedBadge": "管理対象",
"xpack.securitySolution.timeline.userDetails.managedDataTitle": "管理対象のデータ",
"xpack.securitySolution.timeline.userDetails.managedUserInspectTitle": "管理対象のユーザー",
- "xpack.securitySolution.timeline.userDetails.maxAnomalyScoreByJobLabel": "ジョブ別の最高異常スコア",
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationText": "外部統合は追加のメタデータを提供し、ユーザーの管理を支援できます。",
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationTitle": "アクティブな統合がありません",
"xpack.securitySolution.timeline.userDetails.noAzureDataText": "このユーザーのメタデータが表示されることが想定される場合は、統合を正しく構成したことを確認してください。",
"xpack.securitySolution.timeline.userDetails.noAzureDataTitle": "このユーザーのメタデータが見つかりません",
"xpack.securitySolution.timeline.userDetails.observedBadge": "観測済み",
"xpack.securitySolution.timeline.userDetails.observedDataTitle": "観測されたデータ",
- "xpack.securitySolution.timeline.userDetails.observedUserInspectTitle": "観測されたユーザー",
"xpack.securitySolution.timeline.userDetails.riskScoreLabel": "リスクスコア",
- "xpack.securitySolution.timeline.userDetails.userIdLabel": "ユーザーID",
"xpack.securitySolution.timeline.userDetails.userLabel": "ユーザー",
"xpack.securitySolution.timeline.userDetails.valuesColumnTitle": "値",
"xpack.securitySolution.timelineEvents.errorSearchDescription": "タイムラインイベント検索でエラーが発生しました",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 485ca11b4fbfc3..2a5c081a8d0148 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -18156,7 +18156,6 @@
"xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "升级代理",
"xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "升级{count, plural, one {代理} other { {count} 个代理} =true {所有选定代理}}时出错",
"xpack.fleet.upgradeAgents.noMaintenanceWindowOption": "立即",
- "xpack.fleet.upgradeAgents.noVersionsText": "没有任何选定的代理符合升级条件。请选择一个或多个符合条件的代理。",
"xpack.fleet.upgradeAgents.restartConfirmMultipleButtonLabel": "重新开始升级{count, plural, one {代理} other { {count} 个代理} =true {所有选定代理}}",
"xpack.fleet.upgradeAgents.restartUpgradeMultipleTitle": "在{count, plural, one {代理} other {{count} 个代理} =true {所有代理}}中的 {updating} 个的更新陷入停滞时重新开始升级",
"xpack.fleet.upgradeAgents.restartUpgradeSingleTitle": "重新开始升级",
@@ -32002,7 +32001,6 @@
"xpack.securitySolution.timeline.properties.timelineToggleButtonAriaLabel": "{isOpen, select, false {打开} true {关闭} other {切换}}时间线 {title}",
"xpack.securitySolution.timeline.saveTimeline.modal.warning.title": "您的 {timeline} 未保存。是否保存?",
"xpack.securitySolution.timeline.searchBoxPlaceholder": "例如 {timeline} 名称或描述",
- "xpack.securitySolution.timeline.userDetails.observedUserUpdatedTime": "已更新 {time}",
"xpack.securitySolution.timeline.userDetails.updatedTime": "已更新 {time}",
"xpack.securitySolution.timeline.youAreInAnEventRendererScreenReaderOnly": "您正处于第 {row} 行的事件呈现器中。按向上箭头键退出并返回当前行,或按向下箭头键退出并前进到下一行。",
"xpack.securitySolution.timeline.youAreInATableCellScreenReaderOnly": "您处在表单元格中。行:{row},列:{column}",
@@ -36343,25 +36341,17 @@
"xpack.securitySolution.timeline.userDetails.addExternalIntegrationButton": "添加外部集成",
"xpack.securitySolution.timeline.userDetails.closeButton": "关闭",
"xpack.securitySolution.timeline.userDetails.failManagedUserDescription": "无法对用户托管数据执行搜索",
- "xpack.securitySolution.timeline.userDetails.familyLabel": "系列",
"xpack.securitySolution.timeline.userDetails.fieldColumnTitle": "字段",
- "xpack.securitySolution.timeline.userDetails.firstSeenLabel": "首次看到时间",
- "xpack.securitySolution.timeline.userDetails.hostOsNameLabel": "操作系统",
- "xpack.securitySolution.timeline.userDetails.ipAddressesLabel": "IP 地址",
- "xpack.securitySolution.timeline.userDetails.lastSeenLabel": "最后看到时间",
"xpack.securitySolution.timeline.userDetails.managedBadge": "托管",
"xpack.securitySolution.timeline.userDetails.managedDataTitle": "托管数据",
"xpack.securitySolution.timeline.userDetails.managedUserInspectTitle": "托管用户",
- "xpack.securitySolution.timeline.userDetails.maxAnomalyScoreByJobLabel": "最大异常分数(按作业)",
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationText": "外部集成可提供其他元数据并帮助您管理用户。",
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationTitle": "您没有任何活动集成",
"xpack.securitySolution.timeline.userDetails.noAzureDataText": "如果计划查看此用户的元数据,请确保已正确配置集成。",
"xpack.securitySolution.timeline.userDetails.noAzureDataTitle": "找不到此用户的元数据",
"xpack.securitySolution.timeline.userDetails.observedBadge": "已观察",
"xpack.securitySolution.timeline.userDetails.observedDataTitle": "观察数据",
- "xpack.securitySolution.timeline.userDetails.observedUserInspectTitle": "已观察用户",
"xpack.securitySolution.timeline.userDetails.riskScoreLabel": "风险分数",
- "xpack.securitySolution.timeline.userDetails.userIdLabel": "用户 ID",
"xpack.securitySolution.timeline.userDetails.userLabel": "用户",
"xpack.securitySolution.timeline.userDetails.valuesColumnTitle": "值",
"xpack.securitySolution.timelineEvents.errorSearchDescription": "搜索时间线事件时发生错误",