Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { TestProviders } from '../../../../common/mock';
// Mock the hooks
const mockUseFetchAnonymizationFields = jest.fn();
const mockUseAssistantContext = jest.fn();
const mockUseLoadConnectors = jest.fn();
const mockUseLoadInferenceConnectors = jest.fn();
const mockUseSpaceId = jest.fn();
const mockUseStoredAssistantConnectorId = jest.fn();
const mockUseAssistantAvailability = jest.fn();
Expand All @@ -24,7 +24,6 @@ const mockUseHasEntityHighlightsLicense = jest.fn();
jest.mock('@kbn/elastic-assistant', () => ({
useAssistantContext: () => mockUseAssistantContext(),
useFetchAnonymizationFields: () => mockUseFetchAnonymizationFields(),
useLoadConnectors: () => mockUseLoadConnectors(),
AssistantProvider: ({ children }: { children: React.ReactNode }) => (
<div data-test-subj="assistant-provider">{children}</div>
),
Expand Down Expand Up @@ -74,6 +73,10 @@ jest.mock('../../../../agent_builder/hooks/use_agent_builder_attachment', () =>
}),
}));

jest.mock('../hooks/use_inference_connectors', () => ({
useLoadInferenceConnectors: () => mockUseLoadInferenceConnectors(),
}));

describe('EntityHighlights', () => {
const defaultProps = {
entityIdentifier: 'test-user',
Expand All @@ -100,13 +103,16 @@ describe('EntityHighlights', () => {
settings: { client: { get: jest.fn() } },
};
const defaultLoadConnectors = {
data: [
{
id: 'connector-1',
name: 'Test Connector',
actionTypeId: '.gen-ai',
},
],
data: {
hasConnectors: true,
connectors: [
{
connectorId: 'connector-1',
name: 'Test Connector',
actionTypeId: '.gen-ai',
},
],
},
};
const defaultSpaceId = 'default';
const defaultStoredAssistantConnectorId = ['connector-1', jest.fn()];
Expand All @@ -128,7 +134,7 @@ describe('EntityHighlights', () => {
// Set up default mock implementations
mockUseFetchAnonymizationFields.mockReturnValue(defaultAnonymizationFields);
mockUseAssistantContext.mockReturnValue(defaultAssistantContext);
mockUseLoadConnectors.mockReturnValue(defaultLoadConnectors);
mockUseLoadInferenceConnectors.mockReturnValue(defaultLoadConnectors);
mockUseSpaceId.mockReturnValue(defaultSpaceId);
mockUseStoredAssistantConnectorId.mockReturnValue(defaultStoredAssistantConnectorId);
mockUseAssistantAvailability.mockReturnValue(defaultAssistantAvailability);
Expand Down Expand Up @@ -173,7 +179,25 @@ describe('EntityHighlights', () => {
expect(screen.queryByText('Entity summary')).not.toBeInTheDocument();
});

it('shows generate button when no assistant result and not loading', () => {
it(`shows "Add Connector" button when no assistant result, not loading and no connectors`, () => {
mockUseLoadInferenceConnectors.mockReturnValueOnce({
data: { hasConnectors: false, connectors: [] },
});
render(<EntityHighlightsAccordion {...defaultProps} />, {
wrapper: TestProviders,
});

const addConnectorButton = screen.getByText('Add connector');
expect(addConnectorButton).toBeInTheDocument();
expect(addConnectorButton).not.toBeDisabled();
expect(
screen.getByText(
'No AI connector is configured. Please configure an AI connector to generate a summary.'
)
).toBeInTheDocument();
});

it(`shows "Generate" button when no assistant result and not loading when connectors are available`, () => {
render(<EntityHighlightsAccordion {...defaultProps} />, {
wrapper: TestProviders,
});
Expand All @@ -194,22 +218,6 @@ describe('EntityHighlights', () => {
expect(mockFetchEntityHighlights).toHaveBeenCalled();
});

it('does not render generate button when no connector ID is available', () => {
mockUseLoadConnectors.mockReturnValue({ data: [] });
mockUseStoredAssistantConnectorId.mockReturnValue(['', jest.fn()]);

render(<EntityHighlightsAccordion {...defaultProps} />, {
wrapper: TestProviders,
});

expect(screen.queryByRole('button', { name: 'Generate' })).not.toBeInTheDocument();
expect(
screen.getByText(
'No AI connector is configured. Please configure an AI connector to generate a summary.'
)
).toBeInTheDocument();
});

it('shows loading state with skeleton text and loading message', () => {
mockUseFetchEntityDetailsHighlights.mockReturnValue({
...defaultFetchEntityDetailsHighlights,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ import {
EuiTitle,
EuiFlexGroup,
} from '@elastic/eui';
import {
useAssistantContext,
useFetchAnonymizationFields,
useLoadConnectors,
} from '@kbn/elastic-assistant';
import React, { useCallback, useMemo, useState } from 'react';
import { useFetchAnonymizationFields } from '@kbn/elastic-assistant';
import React, { Suspense, useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { AddConnectorModal } from '@kbn/elastic-assistant/impl/connectorland/add_connector_modal';
import { useLoadActionTypes } from '@kbn/elastic-assistant/impl/connectorland/use_load_action_types';
import type { ActionConnector, ActionType } from '@kbn/triggers-actions-ui-plugin/public';
import { useKibana } from '../../../../common/lib/kibana';
import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
import type { EntityType } from '../../../../../common/search_strategy';
import { useStoredAssistantConnectorId } from '../../../../onboarding/components/hooks/use_stored_state';
Expand All @@ -36,26 +36,44 @@ import { useFetchEntityDetailsHighlights } from '../hooks/use_fetch_entity_detai
import { EntityHighlightsSettings } from './entity_highlights_settings';
import { EntityHighlightsResult } from './entity_highlights_result';
import { useGradientStyles } from './entity_highlights_gradients';
import { useLoadInferenceConnectors } from '../hooks/use_inference_connectors';

export const EntityHighlightsAccordion: React.FC<{
entityIdentifier: string;
entityType: EntityType;
}> = ({ entityType, entityIdentifier }) => {
const { data: anonymizationFields, isLoading: isAnonymizationFieldsLoading } =
useFetchAnonymizationFields();
const { http, settings } = useAssistantContext();
const { data: aiConnectors } = useLoadConnectors({
const {
triggersActionsUi: { actionTypeRegistry },
http,
settings,
});
const firstConnector = aiConnectors?.[0];
} = useKibana().services;
const { data: actionTypes } = useLoadActionTypes({ http });
const {
isLoading: isLoadingConnectors,
data: aiConnectors,
refetch: refetchAiConnectors,
} = useLoadInferenceConnectors();
const spaceId = useSpaceId();
const [selectedConnectorId, setConnectorId] = useStoredAssistantConnectorId(spaceId ?? '');
const connectorId = selectedConnectorId ?? firstConnector?.id ?? '';
const [storedConnectorId, setStoredConnectorId] = useStoredAssistantConnectorId(spaceId ?? '');
const connectorId = useMemo(() => {
if (!aiConnectors || !aiConnectors.connectors) return '';
// try to find the stored connector id in the list of available connectors
const storedConnector = aiConnectors.connectors.find(
(c) => c.connectorId === storedConnectorId
);
const firstConnector = aiConnectors.connectors[0];
const cId = storedConnector?.connectorId ?? firstConnector?.connectorId ?? '';
return cId;
}, [aiConnectors, storedConnectorId]);

const connectorName = useMemo(() => {
if (!aiConnectors || !connectorId) return '';
return aiConnectors.find((c) => c.id === connectorId)?.name ?? '';
if (!aiConnectors || !aiConnectors.connectors) return '';
const cName = aiConnectors.connectors.find((c) => c.connectorId === connectorId)?.name ?? '';
return cName;
}, [aiConnectors, connectorId]);

const [isConnectorModalVisible, setIsConnectorModalVisible] = useState<boolean>(false);
const { hasAssistantPrivilege, isAssistantEnabled, isAssistantVisible } =
useAssistantAvailability();
const hasEntityHighlightsLicense = useHasEntityHighlightsLicense();
Expand All @@ -66,6 +84,7 @@ export const EntityHighlightsAccordion: React.FC<{
gradientSVG,
buttonTextGradientStyle,
} = useGradientStyles();
const [selectedActionType, setSelectedActionType] = useState<ActionType | null>(null);

const [showAnonymizedValues, setShowAnonymizedValues] = useState(false);
const onChangeShowAnonymizedValues = useCallback(
Expand All @@ -87,6 +106,23 @@ export const EntityHighlightsAccordion: React.FC<{
entityIdentifier,
});

const onAddConnectorClick = useCallback(() => {
setIsConnectorModalVisible(true);
}, []);

const closeModal = useCallback(() => {
setIsConnectorModalVisible(false);
}, []);

const onSaveConnector = useCallback(
(connector: ActionConnector) => {
setStoredConnectorId(connector.id);
refetchAiConnectors();
closeModal();
},
[closeModal, setStoredConnectorId, refetchAiConnectors]
);

const [isPopoverOpen, setPopover] = useState(false);
const onButtonClick = useCallback(() => {
setPopover(!isPopoverOpen);
Expand All @@ -102,8 +138,8 @@ export const EntityHighlightsAccordion: React.FC<{
);

const isLoading = useMemo(
() => isChatLoading || isAnonymizationFieldsLoading,
[isAnonymizationFieldsLoading, isChatLoading]
() => isChatLoading || isAnonymizationFieldsLoading || isLoadingConnectors,
[isAnonymizationFieldsLoading, isChatLoading, isLoadingConnectors]
);

const [dismissedError, setDismissedError] = useState<Error | null>(null);
Expand Down Expand Up @@ -135,21 +171,23 @@ export const EntityHighlightsAccordion: React.FC<{
}
data-test-subj="asset-criticality-selector"
extraAction={
<EntityHighlightsSettings
assistantResult={assistantResult}
showAnonymizedValues={showAnonymizedValues}
onChangeShowAnonymizedValues={onChangeShowAnonymizedValues}
setConnectorId={setConnectorId}
connectorId={connectorId}
connectorName={connectorName}
closePopover={closePopover}
openPopover={onButtonClick}
isLoading={isLoading}
isPopoverOpen={isPopoverOpen}
isAssistantVisible={isAssistantVisible}
entityType={entityType}
entityIdentifier={entityIdentifier}
/>
aiConnectors?.hasConnectors && (
<EntityHighlightsSettings
assistantResult={assistantResult}
showAnonymizedValues={showAnonymizedValues}
onChangeShowAnonymizedValues={onChangeShowAnonymizedValues}
setConnectorId={setStoredConnectorId}
connectorId={connectorId}
connectorName={connectorName}
closePopover={closePopover}
openPopover={onButtonClick}
isLoading={isLoading}
isPopoverOpen={isPopoverOpen}
isAssistantVisible={isAssistantVisible}
entityType={entityType}
entityIdentifier={entityIdentifier}
/>
)
}
>
<EuiSpacer size="m" />
Expand Down Expand Up @@ -234,7 +272,7 @@ export const EntityHighlightsAccordion: React.FC<{
)}
</EuiText>
</EuiFlexItem>
{connectorId && (
{aiConnectors?.hasConnectors ? (
<EuiFlexItem grow={1}>
<EuiButton
onClick={fetchEntityHighlights}
Expand All @@ -250,6 +288,32 @@ export const EntityHighlightsAccordion: React.FC<{
</div>
</EuiButton>
</EuiFlexItem>
) : (
<EuiFlexItem grow={1}>
Comment thread
hop-dev marked this conversation as resolved.
<EuiButton onClick={onAddConnectorClick} css={buttonGradientStyle} size="s">
<div css={buttonTextGradientStyle}>
<FormattedMessage
id="xpack.securitySolution.flyout.entityDetails.highlights.addConnectorButton"
defaultMessage="Add connector"
/>
</div>
</EuiButton>
</EuiFlexItem>
)}

{isConnectorModalVisible && (
<Suspense fallback>
<AddConnectorModal
actionTypeRegistry={actionTypeRegistry}
actionTypes={actionTypes}
onClose={closeModal}
onSaveConnector={onSaveConnector}
onSelectActionType={(actionType: ActionType) =>
setSelectedActionType(actionType)
}
selectedActionType={selectedActionType}
/>
</Suspense>
)}
</EuiFlexGroup>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { UseQueryResult } from '@kbn/react-query';
import { useQuery } from '@kbn/react-query';
import type { InferenceConnector } from '@kbn/inference-common';
import { useKibana } from '../../../../common/lib/kibana/kibana_react';

export interface UseInferenceConnectorsResult {
connectors: InferenceConnector[];
hasConnectors: boolean;
}

const QUERY_KEY = ['entity-analytics', 'load-inference-connectors'];

export function useLoadInferenceConnectors(): UseQueryResult<UseInferenceConnectorsResult> {
const { inference } = useKibana().services;
return useQuery(QUERY_KEY, async () => {
const connectors = await inference.getConnectors();
return {
connectors,
hasConnectors: connectors.length > 0,
};
});
}
Loading