[Security Solution] [Elastic AI Assistant] Data anonymization (#159857)

## [Security Solution] [Elastic AI Assistant] Data anonymization

The PR introduces the _Data anonymization_ feature to the _Elastic AI Assistant_:

![data-anonymization](fa5147bb-e306-48e5-9819-2018e1ceaba3)

_Above: Data anonymization in the Elastic AI Assistant_

![toggle_show_anonymized](7b31a939-1960-41bb-9cf1-1431d14ecc1f)

_Above: Viewing the anonymized `host.name`, `user.name`, and `user.domain` fields in a conversation_

Use this feature to:

- Control which fields are sent from a context to the assistant
- Toggle anonymization on or off for specific fields
- Set defaults for the above

### How it works

When data anonymization is enabled for a context (e.g. an alert or an event), only a subset of the fields in the alert or event will be sent by default.

Some fields will also be anonymized by default. When a field is anonymized, UUIDs are sent to the assistant in lieu of actual values. When responses are received from the assistant, the UUIDs are automatically translated back to their original values.

- Elastic Security ships with a recommended set of default fields configured for anonymization
- Simply accept the defaults, or edit any message before it's sent
- Customize the defaults at any time

### See what was actually sent

The `Show anonymized` toggle reveals the anonymized data that was sent, per the animated gif below:

![toggle_show_anonymized](7b31a939-1960-41bb-9cf1-1431d14ecc1f)

_Above: The `Show anonymized` toggle reveals the anonymized data_

### Use Bulk actions to quickly customize what's sent

![bluk-actions](55317830-b123-4631-8bb6-bea5dc36483b)

_Above: bulk actions_

Apply the following bulk actions to customize any context sent to the assistant:

- Allow
- Deny
- Anonymize
- Unonymize

### Use Bulk actions to quickly customize defaults

![bulk-actions-default](baa002d8-e3da-4ad7-ad2e-7ec611515bcc)

_Above: Customize defaults with bulk actions_

Apply the following bulk actions to customize defaults:

- Allow by default
- Deny by default
- Anonymize by default
- Unonymize by default

### Row actions

![row-actions](76496c07-1acf-4f71-a00c-fbd3ee7b30cc)

_Above: The row actions overflow menu_

The following row actions are available on every row:

- Allow
- Deny
- Anonymize
- Unonymize
- Allow by default
- Deny by default
- Anonymize by default
- Unonymize by default

### Restore the "factory defaults"

The _Anonymization defaults_ setting, shown in the screenshot below, may be used to restore the Elastic-provided defaults for which fields are allowed and anonymized:

![restore-defaults](91f6762d-72eb-4e91-b2b9-d6001cf9171f)

_Above: restoring the Elastic defaults_

See epic <https://github.com/elastic/security-team/issues/6775> (internal) for additional details.
This commit is contained in:
Andrew Macri 2023-06-18 02:34:23 -06:00 committed by GitHub
parent da187b2675
commit 9ea864cc05
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
99 changed files with 5802 additions and 294 deletions

View file

@ -24,7 +24,7 @@ const StyledEuiModal = styled(EuiModal)`
`;
/**
* Modal container for Security Assistant conversations, receiving the page contents as context, plus whatever
* Modal container for Elastic AI Assistant conversations, receiving the page contents as context, plus whatever
* component currently has focus and any specific context it may provide through the SAssInterface.
*/
export const AssistantOverlay: React.FC = React.memo(() => {

View file

@ -6,11 +6,11 @@
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestProviders } from '../../mock/test_providers/test_providers';
import type { PromptContext } from '../prompt_context/types';
import type { PromptContext, SelectedPromptContext } from '../prompt_context/types';
import { ContextPills } from '.';
const mockPromptContexts: Record<string, PromptContext> = {
@ -30,6 +30,12 @@ const mockPromptContexts: Record<string, PromptContext> = {
},
};
const defaultProps = {
defaultAllow: [],
defaultAllowReplacement: [],
promptContexts: mockPromptContexts,
};
describe('ContextPills', () => {
beforeEach(() => jest.clearAllMocks());
@ -37,9 +43,9 @@ describe('ContextPills', () => {
render(
<TestProviders>
<ContextPills
promptContexts={mockPromptContexts}
selectedPromptContextIds={[]}
setSelectedPromptContextIds={jest.fn()}
{...defaultProps}
selectedPromptContexts={{}}
setSelectedPromptContexts={jest.fn()}
/>
</TestProviders>
);
@ -49,35 +55,45 @@ describe('ContextPills', () => {
});
});
it('invokes setSelectedPromptContextIds() when the prompt is NOT already selected', () => {
it('invokes setSelectedPromptContexts() when the prompt is NOT already selected', async () => {
const context = mockPromptContexts.context1;
const setSelectedPromptContextIds = jest.fn();
const setSelectedPromptContexts = jest.fn();
render(
<TestProviders>
<ContextPills
promptContexts={mockPromptContexts}
selectedPromptContextIds={[]} // <-- the prompt is NOT selected
setSelectedPromptContextIds={setSelectedPromptContextIds}
{...defaultProps}
selectedPromptContexts={{}} // <-- the prompt is NOT selected
setSelectedPromptContexts={setSelectedPromptContexts}
/>
</TestProviders>
);
userEvent.click(screen.getByTestId(`pillButton-${context.id}`));
expect(setSelectedPromptContextIds).toBeCalled();
await waitFor(() => {
expect(setSelectedPromptContexts).toBeCalled();
});
});
it('it does NOT invoke setSelectedPromptContextIds() when the prompt is already selected', () => {
it('it does NOT invoke setSelectedPromptContexts() when the prompt is already selected', async () => {
const context = mockPromptContexts.context1;
const setSelectedPromptContextIds = jest.fn();
const mockSelectedPromptContext: SelectedPromptContext = {
allow: [],
allowReplacement: [],
promptContextId: context.id,
rawData: 'test-raw-data',
};
const setSelectedPromptContexts = jest.fn();
render(
<TestProviders>
<ContextPills
promptContexts={mockPromptContexts}
selectedPromptContextIds={[context.id]} // <-- the context is already selected
setSelectedPromptContextIds={setSelectedPromptContextIds}
{...defaultProps}
selectedPromptContexts={{
[context.id]: mockSelectedPromptContext,
}} // <-- the context is already selected
setSelectedPromptContexts={setSelectedPromptContexts}
/>
</TestProviders>
);
@ -85,18 +101,28 @@ describe('ContextPills', () => {
// NOTE: this test uses `fireEvent` instead of `userEvent` to bypass the disabled button:
fireEvent.click(screen.getByTestId(`pillButton-${context.id}`));
expect(setSelectedPromptContextIds).not.toBeCalled();
await waitFor(() => {
expect(setSelectedPromptContexts).not.toBeCalled();
});
});
it('disables selected context pills', () => {
const context = mockPromptContexts.context1;
const mockSelectedPromptContext: SelectedPromptContext = {
allow: [],
allowReplacement: [],
promptContextId: context.id,
rawData: 'test-raw-data',
};
render(
<TestProviders>
<ContextPills
promptContexts={mockPromptContexts}
selectedPromptContextIds={[context.id]} // <-- context1 is selected
setSelectedPromptContextIds={jest.fn()}
{...defaultProps}
selectedPromptContexts={{
[context.id]: mockSelectedPromptContext,
}} // <-- the context is selected
setSelectedPromptContexts={jest.fn()}
/>
</TestProviders>
);
@ -110,9 +136,9 @@ describe('ContextPills', () => {
render(
<TestProviders>
<ContextPills
promptContexts={mockPromptContexts}
selectedPromptContextIds={['context2']} // context1 is NOT selected
setSelectedPromptContextIds={jest.fn()}
{...defaultProps}
selectedPromptContexts={{}} // context1 is NOT selected
setSelectedPromptContexts={jest.fn()}
/>
</TestProviders>
);

View file

@ -11,22 +11,29 @@ import React, { useCallback, useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import type { PromptContext } from '../prompt_context/types';
import { getNewSelectedPromptContext } from '../../data_anonymization/get_new_selected_prompt_context';
import type { PromptContext, SelectedPromptContext } from '../prompt_context/types';
const PillButton = styled(EuiButton)`
margin-right: ${({ theme }) => theme.eui.euiSizeXS};
`;
interface Props {
defaultAllow: string[];
defaultAllowReplacement: string[];
promptContexts: Record<string, PromptContext>;
selectedPromptContextIds: string[];
setSelectedPromptContextIds: React.Dispatch<React.SetStateAction<string[]>>;
selectedPromptContexts: Record<string, SelectedPromptContext>;
setSelectedPromptContexts: React.Dispatch<
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
}
const ContextPillsComponent: React.FC<Props> = ({
defaultAllow,
defaultAllowReplacement,
promptContexts,
selectedPromptContextIds,
setSelectedPromptContextIds,
selectedPromptContexts,
setSelectedPromptContexts,
}) => {
const sortedPromptContexts = useMemo(
() => sortBy('description', Object.values(promptContexts)),
@ -34,12 +41,27 @@ const ContextPillsComponent: React.FC<Props> = ({
);
const selectPromptContext = useCallback(
(id: string) => {
if (!selectedPromptContextIds.includes(id)) {
setSelectedPromptContextIds((prev) => [...prev, id]);
async (id: string) => {
if (selectedPromptContexts[id] == null && promptContexts[id] != null) {
const newSelectedPromptContext = await getNewSelectedPromptContext({
defaultAllow,
defaultAllowReplacement,
promptContext: promptContexts[id],
});
setSelectedPromptContexts((prev) => ({
...prev,
[id]: newSelectedPromptContext,
}));
}
},
[selectedPromptContextIds, setSelectedPromptContextIds]
[
defaultAllow,
defaultAllowReplacement,
promptContexts,
selectedPromptContexts,
setSelectedPromptContexts,
]
);
return (
@ -49,7 +71,7 @@ const ContextPillsComponent: React.FC<Props> = ({
<EuiToolTip content={tooltip}>
<PillButton
data-test-subj={`pillButton-${id}`}
disabled={selectedPromptContextIds.includes(id)}
disabled={selectedPromptContexts[id] != null}
iconSide="left"
iconType="plus"
onClick={() => selectPromptContext(id)}

View file

@ -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 { invert } from 'lodash/fp';
import { getAnonymizedValue } from '.';
jest.mock('uuid', () => ({
v4: () => 'test-uuid',
}));
describe('getAnonymizedValue', () => {
beforeEach(() => jest.clearAllMocks());
it('returns a new UUID when currentReplacements is not provided', () => {
const currentReplacements = undefined;
const rawValue = 'test';
const result = getAnonymizedValue({ currentReplacements, rawValue });
expect(result).toBe('test-uuid');
});
it('returns an existing anonymized value when currentReplacements contains an entry for it', () => {
const rawValue = 'test';
const currentReplacements = { anonymized: 'test' };
const rawValueToReplacement = invert(currentReplacements);
const result = getAnonymizedValue({ currentReplacements, rawValue });
expect(result).toBe(rawValueToReplacement[rawValue]);
});
it('returns a new UUID with currentReplacements if no existing match', () => {
const rawValue = 'test';
const currentReplacements = { anonymized: 'other' };
const result = getAnonymizedValue({ currentReplacements, rawValue });
expect(result).toBe('test-uuid');
});
});

View file

@ -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 { invert } from 'lodash/fp';
import { v4 } from 'uuid';
export const getAnonymizedValue = ({
currentReplacements,
rawValue,
}: {
currentReplacements: Record<string, string> | undefined;
rawValue: string;
}): string => {
if (currentReplacements != null) {
const rawValueToReplacement: Record<string, string> = invert(currentReplacements);
const existingReplacement: string | undefined = rawValueToReplacement[rawValue];
return existingReplacement != null ? existingReplacement : v4();
}
return v4();
};

View file

@ -15,6 +15,8 @@ import {
EuiCommentList,
EuiToolTip,
EuiSplitPanel,
EuiSwitchEvent,
EuiSwitch,
EuiCallOut,
EuiIcon,
EuiTitle,
@ -32,8 +34,10 @@ import { getMessageFromRawResponse } from './helpers';
import { ConversationSettingsPopover } from './conversation_settings_popover/conversation_settings_popover';
import { useAssistantContext } from '../assistant_context';
import { ContextPills } from './context_pills';
import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selected_prompt_context';
import { SettingsPopover } from '../data_anonymization/settings/settings_popover';
import { PromptTextArea } from './prompt_textarea';
import type { PromptContext } from './prompt_context/types';
import type { PromptContext, SelectedPromptContext } from './prompt_context/types';
import { useConversation } from './use_conversation';
import { CodeBlockDetails } from './use_conversation/helpers';
import { useSendMessages } from './use_send_messages';
@ -85,14 +89,23 @@ const AssistantComponent: React.FC<Props> = ({
actionTypeRegistry,
augmentMessageCodeBlocks,
conversations,
defaultAllow,
defaultAllowReplacement,
getComments,
http,
promptContexts,
title,
} = useAssistantContext();
const [selectedPromptContextIds, setSelectedPromptContextIds] = useState<string[]>([]);
const [selectedPromptContexts, setSelectedPromptContexts] = useState<
Record<string, SelectedPromptContext>
>({});
const selectedPromptContextsCount = useMemo(
() => Object.keys(selectedPromptContexts).length,
[selectedPromptContexts]
);
const { appendMessage, clearConversation, createConversation } = useConversation();
const { appendMessage, appendReplacements, clearConversation, createConversation } =
useConversation();
const { isLoading, sendMessages } = useSendMessages();
const [selectedConversationId, setSelectedConversationId] = useState<string>(conversationId);
@ -132,6 +145,8 @@ const AssistantComponent: React.FC<Props> = ({
const [showMissingConnectorCallout, setShowMissingConnectorCallout] = useState<boolean>(false);
const [showAnonymizedValues, setShowAnonymizedValues] = useState<boolean>(false);
const [messageCodeBlocks, setMessageCodeBlocks] = useState<CodeBlockDetails[][]>(
augmentMessageCodeBlocks(currentConversation)
);
@ -179,17 +194,24 @@ const AssistantComponent: React.FC<Props> = ({
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
promptTextAreaRef?.current?.focus();
}, 0);
}, [currentConversation.messages.length, selectedPromptContextIds.length]);
}, [currentConversation.messages.length, selectedPromptContextsCount]);
////
// Handles sending latest user prompt to API
const handleSendMessage = useCallback(
async (promptText) => {
const onNewReplacements = (newReplacements: Record<string, string>) =>
appendReplacements({
conversationId: selectedConversationId,
replacements: newReplacements,
});
const message = await getCombinedMessage({
isNewChat: currentConversation.messages.length === 0,
promptContexts,
currentReplacements: currentConversation.replacements,
onNewReplacements,
promptText,
selectedPromptContextIds,
selectedPromptContexts,
selectedSystemPrompt: currentConversation.apiConfig.defaultSystemPrompt,
});
@ -199,7 +221,7 @@ const AssistantComponent: React.FC<Props> = ({
});
// Reset prompt context selection and preview before sending:
setSelectedPromptContextIds([]);
setSelectedPromptContexts({});
setPromptTextPreview('');
const rawResponse = await sendMessages({
@ -212,12 +234,13 @@ const AssistantComponent: React.FC<Props> = ({
},
[
appendMessage,
appendReplacements,
currentConversation.apiConfig,
currentConversation.messages.length,
currentConversation.replacements,
http,
promptContexts,
selectedConversationId,
selectedPromptContextIds,
selectedPromptContexts,
sendMessages,
]
);
@ -237,7 +260,24 @@ const AssistantComponent: React.FC<Props> = ({
codeBlockContainers.forEach((e) => (e.style.minHeight = '75px'));
////
const comments = getComments({ currentConversation, lastCommentRef });
const onToggleShowAnonymizedValues = useCallback(
(e: EuiSwitchEvent) => {
if (setShowAnonymizedValues != null) {
setShowAnonymizedValues(e.target.checked);
}
},
[setShowAnonymizedValues]
);
const comments = useMemo(
() =>
getComments({
currentConversation,
lastCommentRef,
showAnonymizedValues,
}),
[currentConversation, getComments, showAnonymizedValues]
);
useEffect(() => {
// Adding `conversationId !== selectedConversationId` to prevent auto-run still executing after changing selected conversation
@ -253,12 +293,24 @@ const AssistantComponent: React.FC<Props> = ({
if (promptContext != null) {
setAutoPopulatedOnce(true);
// select this prompt context
if (!selectedPromptContextIds.includes(promptContext.id)) {
setSelectedPromptContextIds((prev) => [...prev, promptContext.id]);
if (!Object.keys(selectedPromptContexts).includes(promptContext.id)) {
const addNewSelectedPromptContext = async () => {
const newSelectedPromptContext = await getNewSelectedPromptContext({
defaultAllow,
defaultAllowReplacement,
promptContext,
});
setSelectedPromptContexts((prev) => ({
...prev,
[promptContext.id]: newSelectedPromptContext,
}));
};
addNewSelectedPromptContext();
}
if (promptContext?.suggestedUserPrompt != null) {
if (promptContext.suggestedUserPrompt != null) {
setSuggestedUserPrompt(promptContext.suggestedUserPrompt);
}
}
@ -269,8 +321,10 @@ const AssistantComponent: React.FC<Props> = ({
handleSendMessage,
conversationId,
selectedConversationId,
selectedPromptContextIds,
selectedPromptContexts,
autoPopulatedOnce,
defaultAllow,
defaultAllowReplacement,
]);
// Show missing connector callout if no connectors are configured
@ -319,6 +373,35 @@ const AssistantComponent: React.FC<Props> = ({
shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys}
isDisabled={isWelcomeSetup}
/>
<>
<EuiSpacer size={'s'} />
<EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.SHOW_ANONYMIZED_TOOLTIP}
position="left"
repositionOnScroll={true}
>
<EuiSwitch
checked={
currentConversation.replacements != null &&
Object.keys(currentConversation.replacements).length > 0 &&
showAnonymizedValues
}
compressed={true}
disabled={currentConversation.replacements == null}
label={i18n.SHOW_ANONYMIZED}
onChange={onToggleShowAnonymizedValues}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SettingsPopover />
</EuiFlexItem>
</EuiFlexGroup>
</>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin={'m'} />
@ -354,9 +437,11 @@ const AssistantComponent: React.FC<Props> = ({
{!isWelcomeSetup && (
<>
<ContextPills
defaultAllow={defaultAllow}
defaultAllowReplacement={defaultAllowReplacement}
promptContexts={promptContexts}
selectedPromptContextIds={selectedPromptContextIds}
setSelectedPromptContextIds={setSelectedPromptContextIds}
selectedPromptContexts={selectedPromptContexts}
setSelectedPromptContexts={setSelectedPromptContexts}
/>
{Object.keys(promptContexts).length > 0 && <EuiSpacer size={'s'} />}
</>
@ -375,21 +460,22 @@ const AssistantComponent: React.FC<Props> = ({
<CommentsContainer className="eui-scrollBar">
<>
<StyledCommentList comments={comments} />
<div ref={bottomRef} />
<EuiSpacer size={'m'} />
{(currentConversation.messages.length === 0 ||
selectedPromptContextIds.length > 0) && (
Object.keys(selectedPromptContexts).length > 0) && (
<PromptEditor
conversation={currentConversation}
isNewConversation={currentConversation.messages.length === 0}
promptContexts={promptContexts}
promptTextPreview={promptTextPreview}
selectedPromptContextIds={selectedPromptContextIds}
conversation={currentConversation}
setSelectedPromptContextIds={setSelectedPromptContextIds}
selectedPromptContexts={selectedPromptContexts}
setSelectedPromptContexts={setSelectedPromptContexts}
/>
)}
<div ref={bottomRef} />
</>
</CommentsContainer>
)}
@ -426,7 +512,7 @@ const AssistantComponent: React.FC<Props> = ({
onClick={() => {
setPromptTextPreview('');
clearConversation(selectedConversationId);
setSelectedPromptContextIds([]);
setSelectedPromptContexts({});
setSuggestedUserPrompt('');
}}
/>

View file

@ -7,10 +7,21 @@
import type { Message } from '../../assistant_context/types';
import { getCombinedMessage, getSystemMessages } from './helpers';
import { mockGetAnonymizedValue } from '../../mock/get_anonymized_value';
import { mockSystemPrompt } from '../../mock/system_prompt';
import { mockAlertPromptContext, mockEventPromptContext } from '../../mock/prompt_context';
import { mockAlertPromptContext } from '../../mock/prompt_context';
import type { SelectedPromptContext } from '../prompt_context/types';
const mockSelectedAlertPromptContext: SelectedPromptContext = {
allow: [],
allowReplacement: [],
promptContextId: mockAlertPromptContext.id,
rawData: 'alert data',
};
describe('helpers', () => {
beforeEach(() => jest.clearAllMocks());
describe('getSystemMessages', () => {
it('should return an empty array if isNewChat is false', () => {
const result = getSystemMessages({
@ -51,17 +62,15 @@ describe('helpers', () => {
});
describe('getCombinedMessage', () => {
const mockPromptContexts = {
[mockAlertPromptContext.id]: mockAlertPromptContext,
[mockEventPromptContext.id]: mockEventPromptContext,
};
it('returns correct content for a new chat with a system prompt', async () => {
const message: Message = await getCombinedMessage({
currentReplacements: {},
isNewChat: true,
promptContexts: mockPromptContexts,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContextIds: [mockAlertPromptContext.id],
selectedPromptContexts: {
[mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext,
},
selectedSystemPrompt: mockSystemPrompt,
});
@ -78,10 +87,13 @@ User prompt text`);
it('returns correct content for a new chat WITHOUT a system prompt', async () => {
const message: Message = await getCombinedMessage({
currentReplacements: {},
isNewChat: true,
promptContexts: mockPromptContexts,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContextIds: [mockAlertPromptContext.id],
selectedPromptContexts: {
[mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext,
},
selectedSystemPrompt: undefined, // <-- no system prompt
});
@ -97,10 +109,13 @@ User prompt text`);
it('returns the correct content for an existing chat', async () => {
const message: Message = await getCombinedMessage({
currentReplacements: {},
isNewChat: false,
promptContexts: mockPromptContexts,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContextIds: [mockAlertPromptContext.id],
selectedPromptContexts: {
[mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext,
},
selectedSystemPrompt: mockSystemPrompt,
});
@ -109,36 +124,89 @@ User prompt text`);
alert data
"""
CONTEXT:
"""
alert data
"""
User prompt text`);
});
test('getCombinedMessage returns the expected role', async () => {
it('returns the expected role', async () => {
const message: Message = await getCombinedMessage({
currentReplacements: {},
isNewChat: true,
promptContexts: mockPromptContexts,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContextIds: [mockAlertPromptContext.id],
selectedPromptContexts: {
[mockSelectedAlertPromptContext.promptContextId]: mockSelectedAlertPromptContext,
},
selectedSystemPrompt: mockSystemPrompt,
});
expect(message.role).toBe('user');
});
test('getCombinedMessage returns a valid timestamp', async () => {
it('returns a valid timestamp', async () => {
const message: Message = await getCombinedMessage({
currentReplacements: {},
isNewChat: true,
promptContexts: mockPromptContexts,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContextIds: [mockAlertPromptContext.id],
selectedPromptContexts: {},
selectedSystemPrompt: mockSystemPrompt,
});
expect(Date.parse(message.timestamp)).not.toBeNaN();
});
describe('when there is data to anonymize', () => {
const onNewReplacements = jest.fn();
const mockPromptContextWithDataToAnonymize: SelectedPromptContext = {
allow: ['field1', 'field2'],
allowReplacement: ['field1', 'field2'],
promptContextId: 'test-prompt-context-id',
rawData: {
field1: ['foo', 'bar', 'baz'],
field2: ['foozle'],
},
};
it('invokes `onNewReplacements` with the expected replacements', async () => {
await getCombinedMessage({
currentReplacements: {},
getAnonymizedValue: mockGetAnonymizedValue,
isNewChat: true,
onNewReplacements,
promptText: 'User prompt text',
selectedPromptContexts: {
[mockPromptContextWithDataToAnonymize.promptContextId]:
mockPromptContextWithDataToAnonymize,
},
selectedSystemPrompt: mockSystemPrompt,
});
expect(onNewReplacements).toBeCalledWith({
elzoof: 'foozle',
oof: 'foo',
rab: 'bar',
zab: 'baz',
});
});
it('returns the expected content when `isNewChat` is false', async () => {
const isNewChat = false; // <-- not a new chat
const message: Message = await getCombinedMessage({
currentReplacements: {},
getAnonymizedValue: mockGetAnonymizedValue,
isNewChat,
onNewReplacements: jest.fn(),
promptText: 'User prompt text',
selectedPromptContexts: {},
selectedSystemPrompt: mockSystemPrompt,
});
expect(message.content).toEqual(`
User prompt text`);
});
});
});
});

View file

@ -7,7 +7,10 @@
import type { Message } from '../../assistant_context/types';
import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../content/prompts/system/translations';
import type { PromptContext } from '../prompt_context/types';
import { transformRawData } from '../../data_anonymization/transform_raw_data';
import { getAnonymizedValue as defaultGetAnonymizedValue } from '../get_anonymized_value';
import type { SelectedPromptContext } from '../prompt_context/types';
import type { Prompt } from '../types';
export const getSystemMessages = ({
@ -31,35 +34,45 @@ export const getSystemMessages = ({
};
export async function getCombinedMessage({
currentReplacements,
getAnonymizedValue = defaultGetAnonymizedValue,
isNewChat,
promptContexts,
onNewReplacements,
promptText,
selectedPromptContextIds,
selectedPromptContexts,
selectedSystemPrompt,
}: {
currentReplacements: Record<string, string> | undefined;
getAnonymizedValue?: ({
currentReplacements,
rawValue,
}: {
currentReplacements: Record<string, string> | undefined;
rawValue: string;
}) => string;
isNewChat: boolean;
promptContexts: Record<string, PromptContext>;
onNewReplacements: (newReplacements: Record<string, string>) => void;
promptText: string;
selectedPromptContextIds: string[];
selectedPromptContexts: Record<string, SelectedPromptContext>;
selectedSystemPrompt: Prompt | undefined;
}): Promise<Message> {
const selectedPromptContexts = selectedPromptContextIds.reduce<PromptContext[]>((acc, id) => {
const promptContext = promptContexts[id];
return promptContext != null ? [...acc, promptContext] : acc;
}, []);
const promptContextsContent = await Promise.all(
selectedPromptContexts.map(async ({ getPromptContext }) => {
const promptContext = await getPromptContext();
const promptContextsContent = Object.keys(selectedPromptContexts)
.sort()
.map((id) => {
const promptContext = transformRawData({
currentReplacements,
getAnonymizedValue,
onNewReplacements,
selectedPromptContext: selectedPromptContexts[id],
});
return `${SYSTEM_PROMPT_CONTEXT_NON_I18N(promptContext)}`;
})
);
});
return {
content: `${isNewChat ? `${selectedSystemPrompt?.content ?? ''}` : `${promptContextsContent}`}
${promptContextsContent}
content: `${
isNewChat ? `${selectedSystemPrompt?.content ?? ''}\n\n` : ''
}${promptContextsContent}
${promptText}`,
role: 'user', // we are combining the system and user messages into one message

View file

@ -8,7 +8,7 @@
import type { ReactNode } from 'react';
/**
* helps the Elastic Assistant display the most relevant user prompts
* helps the Elastic AI Assistant display the most relevant user prompts
*/
export type PromptContextCategory =
| 'alert'
@ -19,7 +19,7 @@ export type PromptContextCategory =
| string;
/**
* This interface is used to pass context to the Elastic Assistant,
* This interface is used to pass context to the Elastic AI Assistant,
* for the purpose of building prompts. Examples of context include:
* - a single alert
* - multiple alerts
@ -33,39 +33,53 @@ export interface PromptContext {
/**
* The category of data, e.g. `alert | alerts | event | events | string`
*
* `category` helps the Elastic Assistant display the most relevant user prompts
* `category` helps the Elastic AI Assistant display the most relevant user prompts
*/
category: PromptContextCategory;
/**
* The Elastic Assistant will display this **short**, static description
* The Elastic AI Assistant will display this **short**, static description
* in the context pill
*/
description: string;
/**
* The Elastic Assistant will invoke this function to retrieve the context data,
* The Elastic AI Assistant will invoke this function to retrieve the context data,
* which will be included in a prompt (e.g. the contents of an alert or an event)
*/
getPromptContext: () => Promise<string>;
getPromptContext: () => Promise<string> | Promise<Record<string, string[]>>;
/**
* A unique identifier for this prompt context
*/
id: string;
/**
* An optional user prompt that's filled in, but not sent, when the Elastic Assistant opens
* An optional user prompt that's filled in, but not sent, when the Elastic AI Assistant opens
*/
suggestedUserPrompt?: string;
/**
* The Elastic Assistant will display this tooltip when the user hovers over the context pill
* The Elastic AI Assistant will display this tooltip when the user hovers over the context pill
*/
tooltip: ReactNode;
}
/**
* This interface is used to pass a default or base set of contexts to the Elastic Assistant when
* A prompt context that was added from the pills to the current conversation, but not yet sent
*/
export interface SelectedPromptContext {
/** fields allowed to be included in a conversation */
allow: string[];
/** fields that will be anonymized */
allowReplacement: string[];
/** unique id of the selected `PromptContext` */
promptContextId: string;
/** this data is not anonymized */
rawData: string | Record<string, string[]>;
}
/**
* This interface is used to pass a default or base set of contexts to the Elastic AI Assistant when
* initializing it. This is used to provide 'category' options when users create Quick Prompts.
* Also, useful for collating all of a solutions' prompts in one place.
*

View file

@ -10,8 +10,23 @@ import { render, screen, waitFor } from '@testing-library/react';
import { mockAlertPromptContext, mockEventPromptContext } from '../../mock/prompt_context';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { SelectedPromptContext } from '../prompt_context/types';
import { PromptEditor, Props } from '.';
const mockSelectedAlertPromptContext: SelectedPromptContext = {
allow: [],
allowReplacement: [],
promptContextId: mockAlertPromptContext.id,
rawData: 'alert data',
};
const mockSelectedEventPromptContext: SelectedPromptContext = {
allow: [],
allowReplacement: [],
promptContextId: mockEventPromptContext.id,
rawData: 'event data',
};
const defaultProps: Props = {
conversation: undefined,
isNewConversation: true,
@ -20,8 +35,8 @@ const defaultProps: Props = {
[mockEventPromptContext.id]: mockEventPromptContext,
},
promptTextPreview: 'Preview text',
selectedPromptContextIds: [],
setSelectedPromptContextIds: jest.fn(),
selectedPromptContexts: {},
setSelectedPromptContexts: jest.fn(),
};
describe('PromptEditorComponent', () => {
@ -52,16 +67,19 @@ describe('PromptEditorComponent', () => {
});
it('renders the selected prompt contexts', async () => {
const selectedPromptContextIds = [mockAlertPromptContext.id, mockEventPromptContext.id];
const selectedPromptContexts = {
[mockAlertPromptContext.id]: mockSelectedAlertPromptContext,
[mockEventPromptContext.id]: mockSelectedEventPromptContext,
};
render(
<TestProviders>
<PromptEditor {...defaultProps} selectedPromptContextIds={selectedPromptContextIds} />
<PromptEditor {...defaultProps} selectedPromptContexts={selectedPromptContexts} />
</TestProviders>
);
await waitFor(() => {
selectedPromptContextIds.forEach((id) =>
Object.keys(selectedPromptContexts).forEach((id) =>
expect(screen.queryByTestId(`selectedPromptContext-${id}`)).toBeInTheDocument()
);
});

View file

@ -11,7 +11,7 @@ import React, { useMemo } from 'react';
import styled from 'styled-components';
import { Conversation } from '../../..';
import type { PromptContext } from '../prompt_context/types';
import type { PromptContext, SelectedPromptContext } from '../prompt_context/types';
import { SystemPrompt } from './system_prompt';
import * as i18n from './translations';
@ -22,8 +22,10 @@ export interface Props {
isNewConversation: boolean;
promptContexts: Record<string, PromptContext>;
promptTextPreview: string;
selectedPromptContextIds: string[];
setSelectedPromptContextIds: React.Dispatch<React.SetStateAction<string[]>>;
selectedPromptContexts: Record<string, SelectedPromptContext>;
setSelectedPromptContexts: React.Dispatch<
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
}
const PreviewText = styled(EuiText)`
@ -35,8 +37,8 @@ const PromptEditorComponent: React.FC<Props> = ({
isNewConversation,
promptContexts,
promptTextPreview,
selectedPromptContextIds,
setSelectedPromptContextIds,
selectedPromptContexts,
setSelectedPromptContexts,
}) => {
const commentBody = useMemo(
() => (
@ -46,8 +48,8 @@ const PromptEditorComponent: React.FC<Props> = ({
<SelectedPromptContexts
isNewConversation={isNewConversation}
promptContexts={promptContexts}
selectedPromptContextIds={selectedPromptContextIds}
setSelectedPromptContextIds={setSelectedPromptContextIds}
selectedPromptContexts={selectedPromptContexts}
setSelectedPromptContexts={setSelectedPromptContexts}
/>
<PreviewText color="subdued" data-test-subj="previewText">
@ -60,8 +62,8 @@ const PromptEditorComponent: React.FC<Props> = ({
isNewConversation,
promptContexts,
promptTextPreview,
selectedPromptContextIds,
setSelectedPromptContextIds,
selectedPromptContexts,
setSelectedPromptContexts,
]
);

View file

@ -11,6 +11,7 @@ import userEvent from '@testing-library/user-event';
import { mockAlertPromptContext, mockEventPromptContext } from '../../../mock/prompt_context';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import type { SelectedPromptContext } from '../../prompt_context/types';
import { Props, SelectedPromptContexts } from '.';
const defaultProps: Props = {
@ -19,8 +20,22 @@ const defaultProps: Props = {
[mockAlertPromptContext.id]: mockAlertPromptContext,
[mockEventPromptContext.id]: mockEventPromptContext,
},
selectedPromptContextIds: [],
setSelectedPromptContextIds: jest.fn(),
selectedPromptContexts: {},
setSelectedPromptContexts: jest.fn(),
};
const mockSelectedAlertPromptContext: SelectedPromptContext = {
allow: [],
allowReplacement: [],
promptContextId: mockAlertPromptContext.id,
rawData: 'test-raw-data',
};
const mockSelectedEventPromptContext: SelectedPromptContext = {
allow: [],
allowReplacement: [],
promptContextId: mockEventPromptContext.id,
rawData: 'test-raw-data',
};
describe('SelectedPromptContexts', () => {
@ -44,7 +59,9 @@ describe('SelectedPromptContexts', () => {
<SelectedPromptContexts
{...defaultProps}
isNewConversation={false} // <--
selectedPromptContextIds={[mockAlertPromptContext.id]} // <-- length 1
selectedPromptContexts={{
[mockAlertPromptContext.id]: mockSelectedAlertPromptContext,
}} // <-- length 1
/>
</TestProviders>
);
@ -60,7 +77,9 @@ describe('SelectedPromptContexts', () => {
<SelectedPromptContexts
{...defaultProps}
isNewConversation={true} // <--
selectedPromptContextIds={[mockAlertPromptContext.id]} // <-- length 1
selectedPromptContexts={{
[mockAlertPromptContext.id]: mockSelectedAlertPromptContext,
}} // <-- length 1
/>
</TestProviders>
);
@ -76,7 +95,10 @@ describe('SelectedPromptContexts', () => {
<SelectedPromptContexts
{...defaultProps}
isNewConversation={false} // <--
selectedPromptContextIds={[mockAlertPromptContext.id, mockEventPromptContext.id]} // <-- length 2
selectedPromptContexts={{
[mockAlertPromptContext.id]: mockSelectedAlertPromptContext,
[mockEventPromptContext.id]: mockSelectedEventPromptContext,
}} // <-- length 2
/>
</TestProviders>
);
@ -87,57 +109,67 @@ describe('SelectedPromptContexts', () => {
});
it('renders the selected prompt contexts', async () => {
const selectedPromptContextIds = [mockAlertPromptContext.id, mockEventPromptContext.id];
const selectedPromptContexts = {
[mockAlertPromptContext.id]: mockSelectedAlertPromptContext,
[mockEventPromptContext.id]: mockSelectedEventPromptContext,
};
render(
<TestProviders>
<SelectedPromptContexts
{...defaultProps}
selectedPromptContextIds={selectedPromptContextIds}
/>
<SelectedPromptContexts {...defaultProps} selectedPromptContexts={selectedPromptContexts} />
</TestProviders>
);
await waitFor(() => {
selectedPromptContextIds.forEach((id) =>
Object.keys(selectedPromptContexts).forEach((id) =>
expect(screen.getByTestId(`selectedPromptContext-${id}`)).toBeInTheDocument()
);
});
});
it('removes a prompt context when the remove button is clicked', async () => {
const setSelectedPromptContextIds = jest.fn();
const setSelectedPromptContexts = jest.fn();
const promptContextId = mockAlertPromptContext.id;
const selectedPromptContexts = {
[mockAlertPromptContext.id]: mockSelectedAlertPromptContext,
[mockEventPromptContext.id]: mockSelectedEventPromptContext,
};
render(
<SelectedPromptContexts
{...defaultProps}
selectedPromptContextIds={[promptContextId, mockEventPromptContext.id]}
setSelectedPromptContextIds={setSelectedPromptContextIds}
/>
<TestProviders>
<SelectedPromptContexts
{...defaultProps}
selectedPromptContexts={selectedPromptContexts}
setSelectedPromptContexts={setSelectedPromptContexts}
/>
</TestProviders>
);
userEvent.click(screen.getByTestId(`removePromptContext-${promptContextId}`));
await waitFor(() => {
expect(setSelectedPromptContextIds).toHaveBeenCalled();
expect(setSelectedPromptContexts).toHaveBeenCalled();
});
});
it('displays the correct accordion content', async () => {
render(
<SelectedPromptContexts
{...defaultProps}
selectedPromptContextIds={[mockAlertPromptContext.id]}
/>
<TestProviders>
<SelectedPromptContexts
{...defaultProps}
selectedPromptContexts={{
[mockAlertPromptContext.id]: mockSelectedAlertPromptContext,
}}
/>
</TestProviders>
);
userEvent.click(screen.getByText(mockAlertPromptContext.description));
const codeBlock = screen.getByTestId('promptCodeBlock');
const codeBlock = screen.getByTestId('readOnlyContextViewer');
await waitFor(() => {
expect(codeBlock).toHaveTextContent('alert data');
expect(codeBlock).toHaveTextContent('CONTEXT: """ test-raw-data """');
});
});
});

View file

@ -8,114 +8,98 @@
import {
EuiAccordion,
EuiButtonIcon,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { isEmpty, omit } from 'lodash/fp';
import React, { useCallback } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../../content/prompts/system/translations';
import type { PromptContext } from '../../prompt_context/types';
import { DataAnonymizationEditor } from '../../../data_anonymization_editor';
import type { PromptContext, SelectedPromptContext } from '../../prompt_context/types';
import * as i18n from './translations';
const PromptContextContainer = styled.div`
max-width: 60vw;
overflow-x: auto;
`;
export interface Props {
isNewConversation: boolean;
promptContexts: Record<string, PromptContext>;
selectedPromptContextIds: string[];
setSelectedPromptContextIds: React.Dispatch<React.SetStateAction<string[]>>;
selectedPromptContexts: Record<string, SelectedPromptContext>;
setSelectedPromptContexts: React.Dispatch<
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
}
export const EditorContainer = styled.div<{
$accordionState: 'closed' | 'open';
}>`
${({ $accordionState }) => ($accordionState === 'closed' ? 'height: 0px;' : '')}
${({ $accordionState }) => ($accordionState === 'closed' ? 'overflow: hidden;' : '')}
${({ $accordionState }) => ($accordionState === 'closed' ? 'position: absolute;' : '')}
`;
const SelectedPromptContextsComponent: React.FC<Props> = ({
isNewConversation,
promptContexts,
selectedPromptContextIds,
setSelectedPromptContextIds,
selectedPromptContexts,
setSelectedPromptContexts,
}) => {
const selectedPromptContexts = useMemo(
() => selectedPromptContextIds.map((id) => promptContexts[id]),
[promptContexts, selectedPromptContextIds]
);
const [accordionState, setAccordionState] = React.useState<'closed' | 'open'>('closed');
const [accordionContent, setAccordionContent] = useState<Record<string, string>>({});
const onToggle = useCallback(
() => setAccordionState((prev) => (prev === 'open' ? 'closed' : 'open')),
[]
);
const unselectPromptContext = useCallback(
(unselectedId: string) => {
setSelectedPromptContextIds((prev) => prev.filter((id) => id !== unselectedId));
setSelectedPromptContexts((prev) => omit(unselectedId, prev));
},
[setSelectedPromptContextIds]
[setSelectedPromptContexts]
);
useEffect(() => {
const abortController = new AbortController();
const fetchAccordionContent = async () => {
const newAccordionContent = await Promise.all(
selectedPromptContexts.map(async ({ getPromptContext, id }) => ({
[id]: await getPromptContext(),
}))
);
if (!abortController.signal.aborted) {
setAccordionContent(newAccordionContent.reduce((acc, curr) => ({ ...acc, ...curr }), {}));
}
};
fetchAccordionContent();
return () => {
abortController.abort();
};
}, [selectedPromptContexts]);
if (isEmpty(promptContexts)) {
return null;
}
return (
<EuiFlexGroup data-test-subj="selectedPromptContexts" direction="column" gutterSize="none">
{selectedPromptContexts.map(({ description, id }) => (
<EuiFlexItem data-test-subj={`selectedPromptContext-${id}`} grow={false} key={id}>
{isNewConversation || selectedPromptContexts.length > 1 ? (
<EuiSpacer data-test-subj="spacer" />
) : null}
<EuiAccordion
buttonContent={description}
extraAction={
<EuiToolTip content={i18n.REMOVE_CONTEXT}>
<EuiButtonIcon
aria-label={i18n.REMOVE_CONTEXT}
data-test-subj={`removePromptContext-${id}`}
iconType="cross"
onClick={() => unselectPromptContext(id)}
{Object.keys(selectedPromptContexts)
.sort()
.map((id) => (
<EuiFlexItem data-test-subj={`selectedPromptContext-${id}`} grow={false} key={id}>
{isNewConversation || Object.keys(selectedPromptContexts).length > 1 ? (
<EuiSpacer data-test-subj="spacer" />
) : null}
<EuiAccordion
buttonContent={promptContexts[id]?.description}
forceState={accordionState}
extraAction={
<EuiToolTip content={i18n.REMOVE_CONTEXT}>
<EuiButtonIcon
aria-label={i18n.REMOVE_CONTEXT}
data-test-subj={`removePromptContext-${id}`}
iconType="cross"
onClick={() => unselectPromptContext(id)}
/>
</EuiToolTip>
}
id={id}
onToggle={onToggle}
paddingSize="s"
>
<EditorContainer $accordionState={accordionState}>
<DataAnonymizationEditor
selectedPromptContext={selectedPromptContexts[id]}
setSelectedPromptContexts={setSelectedPromptContexts}
/>
</EuiToolTip>
}
id={id}
paddingSize="s"
>
<PromptContextContainer>
<EuiCodeBlock data-test-subj="promptCodeBlock" isCopyable>
{id != null && accordionContent[id] != null
? SYSTEM_PROMPT_CONTEXT_NON_I18N(accordionContent[id])
: ''}
</EuiCodeBlock>
</PromptContextContainer>
</EuiAccordion>
</EuiFlexItem>
))}
</EditorContainer>
</EuiAccordion>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};
SelectedPromptContextsComponent.displayName = 'SelectedPromptContextsComponent';
export const SelectedPromptContexts = React.memo(SelectedPromptContextsComponent);

View file

@ -14,7 +14,7 @@ export const CLEAR_CHAT = i18n.translate('xpack.elasticAssistant.assistant.clear
export const DEFAULT_ASSISTANT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.defaultAssistantTitle',
{
defaultMessage: 'Elastic Assistant',
defaultMessage: 'Elastic AI Assistant',
}
);
@ -58,6 +58,20 @@ export const SETTINGS_PROMPT_HELP_TEXT_TITLE = i18n.translate(
}
);
export const SHOW_ANONYMIZED = i18n.translate(
'xpack.elasticAssistant.assistant.settings.showAnonymizedToggleLabel',
{
defaultMessage: 'Show anonymized',
}
);
export const SHOW_ANONYMIZED_TOOLTIP = i18n.translate(
'xpack.elasticAssistant.assistant.settings.showAnonymizedTooltip',
{
defaultMessage: 'Show the anonymized values sent to and from the assistant',
}
);
export const SUBMIT_MESSAGE = i18n.translate('xpack.elasticAssistant.assistant.submitMessage', {
defaultMessage: 'Submit message',
});

View file

@ -58,7 +58,7 @@ export const useAssistantOverlay = (
id: PromptContext['id'] | null,
/**
* An optional user prompt that's filled in, but not sent, when the Elastic Assistant opens
* An optional user prompt that's filled in, but not sent, when the Elastic AI Assistant opens
*/
suggestedUserPrompt: PromptContext['suggestedUserPrompt'] | null,

View file

@ -10,17 +10,17 @@ import { useCallback } from 'react';
import { useAssistantContext } from '../../assistant_context';
import { Conversation, Message } from '../../assistant_context/types';
import * as i18n from './translations';
import { ELASTIC_SECURITY_ASSISTANT, ELASTIC_SECURITY_ASSISTANT_TITLE } from './translations';
import { ELASTIC_AI_ASSISTANT, ELASTIC_AI_ASSISTANT_TITLE } from './translations';
export const DEFAULT_CONVERSATION_STATE: Conversation = {
id: i18n.DEFAULT_CONVERSATION_TITLE,
messages: [],
apiConfig: {},
theme: {
title: ELASTIC_SECURITY_ASSISTANT_TITLE,
title: ELASTIC_AI_ASSISTANT_TITLE,
titleIcon: 'logoSecurity',
assistant: {
name: ELASTIC_SECURITY_ASSISTANT,
name: ELASTIC_AI_ASSISTANT,
icon: 'logoSecurity',
},
system: {
@ -35,6 +35,11 @@ interface AppendMessageProps {
message: Message;
}
interface AppendReplacementsProps {
conversationId: string;
replacements: Record<string, string>;
}
interface CreateConversationProps {
conversationId: string;
messages?: Message[];
@ -51,6 +56,10 @@ interface SetConversationProps {
interface UseConversation {
appendMessage: ({ conversationId: string, message: Message }: AppendMessageProps) => Message[];
appendReplacements: ({
conversationId,
replacements,
}: AppendReplacementsProps) => Record<string, string>;
clearConversation: (conversationId: string) => void;
createConversation: ({
conversationId,
@ -93,9 +102,38 @@ export const useConversation = (): UseConversation => {
[setConversations]
);
/**
* Clear the messages[] for a given conversationId
*/
const appendReplacements = useCallback(
({ conversationId, replacements }: AppendReplacementsProps): Record<string, string> => {
let allReplacements = replacements;
setConversations((prev: Record<string, Conversation>) => {
const prevConversation: Conversation | undefined = prev[conversationId];
if (prevConversation != null) {
allReplacements = {
...prevConversation.replacements,
...replacements,
};
const newConversation = {
...prevConversation,
replacements: allReplacements,
};
return {
...prev,
[conversationId]: newConversation,
};
} else {
return prev;
}
});
return allReplacements;
},
[setConversations]
);
const clearConversation = useCallback(
(conversationId: string) => {
setConversations((prev: Record<string, Conversation>) => {
@ -105,6 +143,7 @@ export const useConversation = (): UseConversation => {
const newConversation = {
...prevConversation,
messages: [],
replacements: undefined,
};
return {
@ -210,6 +249,7 @@ export const useConversation = (): UseConversation => {
return {
appendMessage,
appendReplacements,
clearConversation,
createConversation,
deleteConversation,

View file

@ -9,8 +9,8 @@ import { Conversation } from '../../assistant_context/types';
import * as i18n from '../../content/prompts/welcome/translations';
import {
DEFAULT_CONVERSATION_TITLE,
ELASTIC_SECURITY_ASSISTANT,
ELASTIC_SECURITY_ASSISTANT_TITLE,
ELASTIC_AI_ASSISTANT,
ELASTIC_AI_ASSISTANT_TITLE,
WELCOME_CONVERSATION_TITLE,
} from './translations';
@ -87,10 +87,10 @@ export const BASE_CONVERSATIONS: Record<string, Conversation> = {
[WELCOME_CONVERSATION_TITLE]: {
id: WELCOME_CONVERSATION_TITLE,
theme: {
title: ELASTIC_SECURITY_ASSISTANT_TITLE,
title: ELASTIC_AI_ASSISTANT_TITLE,
titleIcon: 'logoSecurity',
assistant: {
name: ELASTIC_SECURITY_ASSISTANT,
name: ELASTIC_AI_ASSISTANT,
icon: 'logoSecurity',
},
system: {

View file

@ -20,15 +20,15 @@ export const DEFAULT_CONVERSATION_TITLE = i18n.translate(
}
);
export const ELASTIC_SECURITY_ASSISTANT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.useConversation.elasticSecurityAssistantTitle',
export const ELASTIC_AI_ASSISTANT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.useConversation.elasticAiAssistantTitle',
{
defaultMessage: 'Elastic Security Assistant',
defaultMessage: 'Elastic AI Assistant',
}
);
export const ELASTIC_SECURITY_ASSISTANT = i18n.translate(
'xpack.elasticAssistant.assistant.useConversation.elasticSecurityAssistantName',
export const ELASTIC_AI_ASSISTANT = i18n.translate(
'xpack.elasticAssistant.assistant.useConversation.elasticAiAssistantName',
{
defaultMessage: 'Assistant',
}

View file

@ -21,10 +21,16 @@ const ContextWrapper: React.FC = ({ children }) => (
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={jest.fn()}
baseAllow={[]}
baseAllowReplacement={[]}
defaultAllow={[]}
defaultAllowReplacement={[]}
getInitialConversations={mockGetInitialConversations}
getComments={mockGetComments}
http={mockHttp}
setConversations={jest.fn()}
setDefaultAllow={jest.fn()}
setDefaultAllowReplacement={jest.fn()}
>
{children}
</AssistantProvider>

View file

@ -7,7 +7,7 @@
import { EuiCommentProps } from '@elastic/eui';
import type { HttpSetup } from '@kbn/core-http-browser';
import { omit } from 'lodash/fp';
import { omit, uniq } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
@ -45,6 +45,10 @@ type ShowAssistantOverlay = ({
interface AssistantProviderProps {
actionTypeRegistry: ActionTypeRegistryContract;
augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][];
baseAllow: string[];
baseAllowReplacement: string[];
defaultAllow: string[];
defaultAllowReplacement: string[];
basePromptContexts?: PromptContextTemplate[];
baseQuickPrompts?: QuickPrompt[];
baseSystemPrompts?: Prompt[];
@ -52,14 +56,18 @@ interface AssistantProviderProps {
getComments: ({
currentConversation,
lastCommentRef,
showAnonymizedValues,
}: {
currentConversation: Conversation;
lastCommentRef: React.MutableRefObject<HTMLDivElement | null>;
showAnonymizedValues: boolean;
}) => EuiCommentProps[];
http: HttpSetup;
getInitialConversations: () => Record<string, Conversation>;
nameSpace?: string;
setConversations: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
setDefaultAllow: React.Dispatch<React.SetStateAction<string[]>>;
setDefaultAllowReplacement: React.Dispatch<React.SetStateAction<string[]>>;
title?: string;
}
@ -68,6 +76,10 @@ interface UseAssistantContext {
augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][];
allQuickPrompts: QuickPrompt[];
allSystemPrompts: Prompt[];
baseAllow: string[];
baseAllowReplacement: string[];
defaultAllow: string[];
defaultAllowReplacement: string[];
basePromptContexts: PromptContextTemplate[];
baseQuickPrompts: QuickPrompt[];
baseSystemPrompts: Prompt[];
@ -76,9 +88,12 @@ interface UseAssistantContext {
getComments: ({
currentConversation,
lastCommentRef,
showAnonymizedValues,
}: {
currentConversation: Conversation;
lastCommentRef: React.MutableRefObject<HTMLDivElement | null>;
showAnonymizedValues: boolean;
}) => EuiCommentProps[];
http: HttpSetup;
promptContexts: Record<string, PromptContext>;
@ -87,6 +102,8 @@ interface UseAssistantContext {
setAllQuickPrompts: React.Dispatch<React.SetStateAction<QuickPrompt[] | undefined>>;
setAllSystemPrompts: React.Dispatch<React.SetStateAction<Prompt[] | undefined>>;
setConversations: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
setDefaultAllow: React.Dispatch<React.SetStateAction<string[]>>;
setDefaultAllowReplacement: React.Dispatch<React.SetStateAction<string[]>>;
setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void;
showAssistantOverlay: ShowAssistantOverlay;
title: string;
@ -98,6 +115,10 @@ const AssistantContext = React.createContext<UseAssistantContext | undefined>(un
export const AssistantProvider: React.FC<AssistantProviderProps> = ({
actionTypeRegistry,
augmentMessageCodeBlocks,
baseAllow,
baseAllowReplacement,
defaultAllow,
defaultAllowReplacement,
basePromptContexts = [],
baseQuickPrompts = [],
baseSystemPrompts = BASE_SYSTEM_PROMPTS,
@ -107,6 +128,8 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
getInitialConversations,
nameSpace = DEFAULT_ASSISTANT_NAMESPACE,
setConversations,
setDefaultAllow,
setDefaultAllowReplacement,
title = DEFAULT_ASSISTANT_TITLE,
}) => {
/**
@ -202,11 +225,15 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
augmentMessageCodeBlocks,
allQuickPrompts: localStorageQuickPrompts ?? [],
allSystemPrompts: localStorageSystemPrompts ?? [],
baseAllow: uniq(baseAllow),
baseAllowReplacement: uniq(baseAllowReplacement),
basePromptContexts,
baseQuickPrompts,
baseSystemPrompts,
conversationIds,
conversations,
defaultAllow: uniq(defaultAllow),
defaultAllowReplacement: uniq(defaultAllowReplacement),
getComments,
http,
promptContexts,
@ -215,6 +242,8 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
setAllQuickPrompts: setLocalStorageQuickPrompts,
setAllSystemPrompts: setLocalStorageSystemPrompts,
setConversations: onConversationsUpdated,
setDefaultAllow,
setDefaultAllowReplacement,
setShowAssistantOverlay,
showAssistantOverlay,
title,
@ -223,19 +252,25 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
[
actionTypeRegistry,
augmentMessageCodeBlocks,
baseAllow,
baseAllowReplacement,
basePromptContexts,
baseQuickPrompts,
baseSystemPrompts,
conversationIds,
conversations,
defaultAllow,
defaultAllowReplacement,
getComments,
http,
localStorageQuickPrompts,
localStorageSystemPrompts,
promptContexts,
nameSpace,
registerPromptContext,
onConversationsUpdated,
promptContexts,
registerPromptContext,
setDefaultAllow,
setDefaultAllowReplacement,
setLocalStorageQuickPrompts,
setLocalStorageSystemPrompts,
showAssistantOverlay,

View file

@ -51,6 +51,7 @@ export interface Conversation {
};
id: string;
messages: Message[];
replacements?: Record<string, string>;
theme?: ConversationTheme;
isDefault?: boolean;
}

View file

@ -11,7 +11,7 @@ export const LOAD_ACTIONS_ERROR_MESSAGE = i18n.translate(
'xpack.elasticAssistant.connectors.useLoadActionTypes.errorMessage',
{
defaultMessage:
'Welcome to your Elastic Assistant! I am your 100% open-source portal into your Elastic Life. ',
'Welcome to your Elastic AI Assistant! I am your 100% open-source portal into your Elastic Life. ',
}
);
@ -19,7 +19,7 @@ export const LOAD_CONNECTORS_ERROR_MESSAGE = i18n.translate(
'xpack.elasticAssistant.connectors.useLoadConnectors.errorMessage',
{
defaultMessage:
'Welcome to your Elastic Assistant! I am your 100% open-source portal into your Elastic Life. ',
'Welcome to your Elastic AI Assistant! I am your 100% open-source portal into your Elastic Life. ',
}
);
@ -27,7 +27,7 @@ export const WELCOME_SECURITY = i18n.translate(
'xpack.elasticAssistant.content.prompts.welcome.welcomeSecurityPrompt',
{
defaultMessage:
'Welcome to your Elastic Assistant! I am your 100% open-source portal into Elastic Security. ',
'Welcome to your Elastic AI Assistant! I am your 100% open-source portal into Elastic Security. ',
}
);

View file

@ -14,7 +14,7 @@ import {
} from './translations';
/**
* Base System Prompts for Elastic Assistant (if not overridden on initialization).
* Base System Prompts for Elastic AI Assistant (if not overridden on initialization).
*/
export const BASE_SYSTEM_PROMPTS: Prompt[] = [
{

View file

@ -11,7 +11,7 @@ export const YOU_ARE_A_HELPFUL_EXPERT_ASSISTANT = i18n.translate(
'xpack.elasticAssistant.assistant.content.prompts.system.youAreAHelpfulExpertAssistant',
{
defaultMessage:
'You are a helpful, expert assistant who only answers questions about Elastic Security.',
'You are a helpful, expert assistant who answers questions about Elastic Security.',
}
);

View file

@ -11,7 +11,7 @@ export const WELCOME_GENERAL = i18n.translate(
'xpack.elasticAssistant.securityAssistant.content.prompts.welcome.welcomeGeneralPrompt',
{
defaultMessage:
'Welcome to your Elastic Assistant! I am your 100% open-code portal into your Elastic life. In time, I will be able to answer questions and provide assistance across all your information in Elastic, and oh-so much more. Till then, I hope this early preview will open your mind to the possibilities of what we can create when we work together, in the open. Cheers!',
'Welcome to your Elastic AI Assistant! I am your 100% open-code portal into your Elastic life. In time, I will be able to answer questions and provide assistance across all your information in Elastic, and oh-so much more. Till then, I hope this early preview will open your mind to the possibilities of what we can create when we work together, in the open. Cheers!',
}
);

View file

@ -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 { getAnonymizedValues } from '../get_anonymized_values';
import { mockGetAnonymizedValue } from '../../mock/get_anonymized_value';
import { getAnonymizedData } from '.';
describe('getAnonymizedData', () => {
const rawData: Record<string, string[]> = {
doNotReplace: ['this-will-not-be-replaced', 'neither-will-this'],
empty: [],
'host.ip': ['127.0.0.1', '10.0.0.1'],
'host.name': ['test-host'],
doNotInclude: ['this-will-not-be-included', 'neither-will-this'],
};
const commonArgs = {
allow: ['doNotReplace', 'empty', 'host.ip', 'host.name'],
allowReplacement: ['empty', 'host.ip', 'host.name'],
currentReplacements: {},
rawData,
getAnonymizedValue: mockGetAnonymizedValue,
getAnonymizedValues,
};
it('returns the expected anonymized data', () => {
const result = getAnonymizedData({
...commonArgs,
});
expect(result.anonymizedData).toEqual({
doNotReplace: ['this-will-not-be-replaced', 'neither-will-this'],
empty: [],
'host.ip': ['1.0.0.721', '1.0.0.01'],
'host.name': ['tsoh-tset'],
});
});
it('returns the expected map of replaced value to original value', () => {
const result = getAnonymizedData({
...commonArgs,
});
expect(result.replacements).toEqual({
'1.0.0.721': '127.0.0.1',
'1.0.0.01': '10.0.0.1',
'tsoh-tset': 'test-host',
});
});
});

View file

@ -0,0 +1,66 @@
/*
* 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 { SelectedPromptContext } from '../../assistant/prompt_context/types';
import { isAllowed } from '../../data_anonymization_editor/helpers';
import type { AnonymizedData, GetAnonymizedValues } from '../types';
export const getAnonymizedData = ({
allow,
allowReplacement,
currentReplacements,
getAnonymizedValue,
getAnonymizedValues,
rawData,
}: {
allow: SelectedPromptContext['allow'];
allowReplacement: SelectedPromptContext['allowReplacement'];
currentReplacements: Record<string, string> | undefined;
getAnonymizedValue: ({
currentReplacements,
rawValue,
}: {
currentReplacements: Record<string, string> | undefined;
rawValue: string;
}) => string;
getAnonymizedValues: GetAnonymizedValues;
rawData: Record<string, string[]>;
}): AnonymizedData =>
Object.keys(rawData).reduce<AnonymizedData>(
(acc, field) => {
const allowReplacementSet = new Set(allowReplacement);
const allowSet = new Set(allow);
if (isAllowed({ allowSet, field })) {
const { anonymizedValues, replacements } = getAnonymizedValues({
allowReplacementSet,
allowSet,
currentReplacements,
field,
getAnonymizedValue,
rawData,
});
return {
anonymizedData: {
...acc.anonymizedData,
[field]: anonymizedValues,
},
replacements: {
...acc.replacements,
...replacements,
},
};
} else {
return acc;
}
},
{
anonymizedData: {},
replacements: {},
}
);

View file

@ -0,0 +1,98 @@
/*
* 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 { getAnonymizedValues } from '.';
import { mockGetAnonymizedValue } from '../../mock/get_anonymized_value';
describe('getAnonymizedValues', () => {
it('returns empty anonymizedValues and replacements when provided with empty raw data', () => {
const result = getAnonymizedValues({
allowReplacementSet: new Set(),
allowSet: new Set(),
currentReplacements: {},
field: 'test.field',
getAnonymizedValue: jest.fn(),
rawData: {},
});
expect(result).toEqual({
anonymizedValues: [],
replacements: {},
});
});
it('returns the expected anonymized values', () => {
const rawData = {
'test.field': ['test1', 'test2'],
};
const result = getAnonymizedValues({
allowReplacementSet: new Set(['test.field']),
allowSet: new Set(['test.field']),
currentReplacements: {},
field: 'test.field',
getAnonymizedValue: mockGetAnonymizedValue,
rawData,
});
expect(result.anonymizedValues).toEqual(['1tset', '2tset']);
});
it('returns the expected replacements', () => {
const rawData = {
'test.field': ['test1', 'test2'],
};
const result = getAnonymizedValues({
allowReplacementSet: new Set(['test.field']),
allowSet: new Set(['test.field']),
currentReplacements: {},
field: 'test.field',
getAnonymizedValue: mockGetAnonymizedValue,
rawData,
});
expect(result.replacements).toEqual({
'1tset': 'test1',
'2tset': 'test2',
});
});
it('returns non-anonymized values when the field is not a member of the `allowReplacementSet`', () => {
const rawData = {
'test.field': ['test1', 'test2'],
};
const result = getAnonymizedValues({
allowReplacementSet: new Set(), // does NOT include `test.field`
allowSet: new Set(['test.field']),
currentReplacements: {},
field: 'test.field',
getAnonymizedValue: mockGetAnonymizedValue,
rawData,
});
expect(result.anonymizedValues).toEqual(['test1', 'test2']); // no anonymization
});
it('does NOT allow a field to be included in `anonymizedValues` when the field is not a member of the `allowSet`', () => {
const rawData = {
'test.field': ['test1', 'test2'],
};
const result = getAnonymizedValues({
allowReplacementSet: new Set(['test.field']),
allowSet: new Set(), // does NOT include `test.field`
currentReplacements: {},
field: 'test.field',
getAnonymizedValue: mockGetAnonymizedValue,
rawData,
});
expect(result.anonymizedValues).toEqual([]);
});
});

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isAllowed, isAnonymized } from '../../data_anonymization_editor/helpers';
import { AnonymizedValues, GetAnonymizedValues } from '../types';
export const getAnonymizedValues: GetAnonymizedValues = ({
allowSet,
allowReplacementSet,
currentReplacements,
field,
getAnonymizedValue,
rawData,
}): AnonymizedValues => {
const rawValues = rawData[field] ?? [];
return rawValues.reduce<AnonymizedValues>(
(acc, rawValue) => {
if (isAllowed({ allowSet, field }) && isAnonymized({ allowReplacementSet, field })) {
const anonymizedValue = getAnonymizedValue({ currentReplacements, rawValue });
return {
anonymizedValues: [...acc.anonymizedValues, anonymizedValue],
replacements: {
...acc.replacements,
[anonymizedValue]: rawValue,
},
};
} else if (isAllowed({ allowSet, field })) {
return {
anonymizedValues: [...acc.anonymizedValues, rawValue], // no anonymization for this value
replacements: {
...acc.replacements, // no additional replacements
},
};
} else {
return acc;
}
},
{
anonymizedValues: [],
replacements: {},
}
);
};

View file

@ -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 { getCsvFromData } from '.';
describe('getCsvFromData', () => {
it('returns the expected csv', () => {
const data: Record<string, string[]> = {
a: ['1', '2', '3'],
b: ['4', '5', '6'],
c: ['7', '8', '9'],
};
const result = getCsvFromData(data);
expect(result).toBe('a,1,2,3\nb,4,5,6\nc,7,8,9');
});
it('returns an empty string for empty data', () => {
const data: Record<string, string[]> = {};
const result = getCsvFromData(data);
expect(result).toBe('');
});
it('sorts the keys alphabetically', () => {
const data: Record<string, string[]> = {
b: ['1', '2', '3'],
a: ['4', '5', '6'],
c: ['7', '8', '9'],
};
const result = getCsvFromData(data);
expect(result).toBe('a,4,5,6\nb,1,2,3\nc,7,8,9');
});
it('correctly handles single-element arrays', () => {
const data: Record<string, string[]> = {
a: ['1'],
b: ['2'],
c: ['3'],
};
const result = getCsvFromData(data);
expect(result).toBe('a,1\nb,2\nc,3');
});
});

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const getCsvFromData = (data: Record<string, string[]>): string =>
Object.keys(data)
.sort()
.map((key) => `${key},${data[key].join(',')}`)
.join('\n');

View file

@ -0,0 +1,74 @@
/*
* 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 { PromptContext, SelectedPromptContext } from '../../assistant/prompt_context/types';
import { mockAlertPromptContext } from '../../mock/prompt_context';
import { getNewSelectedPromptContext } from '.';
describe('getNewSelectedPromptContext', () => {
const defaultAllow = ['field1', 'field2'];
const defaultAllowReplacement = ['field3', 'field4'];
it("returns empty `allow` and `allowReplacement` for string `rawData`, because it's not anonymized", async () => {
const promptContext: PromptContext = {
...mockAlertPromptContext,
getPromptContext: () => Promise.resolve('string data'), // not anonymized
};
const result = await getNewSelectedPromptContext({
defaultAllow,
defaultAllowReplacement,
promptContext,
});
const excepted: SelectedPromptContext = {
allow: [],
allowReplacement: [],
promptContextId: promptContext.id,
rawData: 'string data',
};
expect(result).toEqual(excepted);
});
it('returns `allow` and `allowReplacement` with the contents of `defaultAllow` and `defaultAllowReplacement` for object rawData, which is anonymized', async () => {
const promptContext: PromptContext = {
...mockAlertPromptContext,
getPromptContext: () => Promise.resolve({ field1: ['value1'], field2: ['value2'] }),
};
const excepted: SelectedPromptContext = {
allow: [...defaultAllow],
allowReplacement: [...defaultAllowReplacement],
promptContextId: promptContext.id,
rawData: { field1: ['value1'], field2: ['value2'] },
};
const result = await getNewSelectedPromptContext({
defaultAllow,
defaultAllowReplacement,
promptContext,
});
expect(result).toEqual(excepted);
});
it('calls getPromptContext from the given promptContext', async () => {
const promptContext: PromptContext = {
...mockAlertPromptContext,
getPromptContext: jest.fn(() => Promise.resolve('string data')),
};
await getNewSelectedPromptContext({
defaultAllow,
defaultAllowReplacement,
promptContext,
});
expect(promptContext.getPromptContext).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PromptContext, SelectedPromptContext } from '../../assistant/prompt_context/types';
export async function getNewSelectedPromptContext({
defaultAllow,
defaultAllowReplacement,
promptContext,
}: {
defaultAllow: string[];
defaultAllowReplacement: string[];
promptContext: PromptContext;
}): Promise<SelectedPromptContext> {
const rawData = await promptContext.getPromptContext();
if (typeof rawData === 'string') {
return {
allow: [],
allowReplacement: [],
promptContextId: promptContext.id,
rawData,
};
} else {
return {
allow: [...defaultAllow],
allowReplacement: [...defaultAllowReplacement],
promptContextId: promptContext.id,
rawData,
};
}
}

View file

@ -0,0 +1,159 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { AnonymizationSettings } from '.';
const mockUseAssistantContext = {
allSystemPrompts: [
{
id: 'default-system-prompt',
content: 'default',
name: 'default',
promptType: 'system',
isDefault: true,
isNewConversationDefault: true,
},
{
id: 'CB9FA555-B59F-4F71-AFF9-8A891AC5BC28',
content: 'superhero',
name: 'superhero',
promptType: 'system',
isDefault: true,
},
],
baseAllow: ['@timestamp', 'event.category', 'user.name'],
baseAllowReplacement: ['user.name', 'host.ip'],
defaultAllow: ['foo', 'bar', 'baz', '@baz'],
defaultAllowReplacement: ['bar'],
setAllSystemPrompts: jest.fn(),
setDefaultAllow: jest.fn(),
setDefaultAllowReplacement: jest.fn(),
};
jest.mock('../../../assistant_context', () => {
const original = jest.requireActual('../../../assistant_context');
return {
...original,
useAssistantContext: () => mockUseAssistantContext,
};
});
describe('AnonymizationSettings', () => {
const closeModal = jest.fn();
beforeEach(() => jest.clearAllMocks());
it('renders the editor', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings />
</TestProviders>
);
expect(getByTestId('contextEditor')).toBeInTheDocument();
});
it('does NOT call `setDefaultAllow` when `Reset` is clicked, because only local state is reset until the user clicks save', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings />
</TestProviders>
);
fireEvent.click(getByTestId('reset'));
expect(mockUseAssistantContext.setDefaultAllow).not.toHaveBeenCalled();
});
it('does NOT call `setDefaultAllowReplacement` when `Reset` is clicked, because only local state is reset until the user clicks save', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings />
</TestProviders>
);
fireEvent.click(getByTestId('reset'));
expect(mockUseAssistantContext.setDefaultAllowReplacement).not.toHaveBeenCalled();
});
it('renders the expected allowed stat content', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings />
</TestProviders>
);
expect(getByTestId('allowedStat')).toHaveTextContent(
`${mockUseAssistantContext.defaultAllow.length}Allowed`
);
});
it('renders the expected anonymized stat content', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings />
</TestProviders>
);
expect(getByTestId('anonymizedFieldsStat')).toHaveTextContent(
`${mockUseAssistantContext.defaultAllowReplacement.length}Anonymized`
);
});
it('calls closeModal is called when the cancel button is clicked', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings closeModal={closeModal} />
</TestProviders>
);
fireEvent.click(getByTestId('cancel'));
expect(closeModal).toHaveBeenCalledTimes(1);
});
it('calls closeModal is called when the save button is clicked', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings closeModal={closeModal} />
</TestProviders>
);
fireEvent.click(getByTestId('cancel'));
expect(closeModal).toHaveBeenCalledTimes(1);
});
it('calls setDefaultAllow with the expected values when the save button is clicked', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings closeModal={closeModal} />
</TestProviders>
);
fireEvent.click(getByTestId('save'));
expect(mockUseAssistantContext.setDefaultAllow).toHaveBeenCalledWith(
mockUseAssistantContext.defaultAllow
);
});
it('calls setDefaultAllowReplacement with the expected values when the save button is clicked', () => {
const { getByTestId } = render(
<TestProviders>
<AnonymizationSettings closeModal={closeModal} />
</TestProviders>
);
fireEvent.click(getByTestId('save'));
expect(mockUseAssistantContext.setDefaultAllowReplacement).toHaveBeenCalledWith(
mockUseAssistantContext.defaultAllowReplacement
);
});
});

View file

@ -0,0 +1,147 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { useAssistantContext } from '../../../assistant_context';
import { ContextEditor } from '../../../data_anonymization_editor/context_editor';
import type { BatchUpdateListItem } from '../../../data_anonymization_editor/context_editor/types';
import { updateDefaults } from '../../../data_anonymization_editor/helpers';
import { AllowedStat } from '../../../data_anonymization_editor/stats/allowed_stat';
import { AnonymizedStat } from '../../../data_anonymization_editor/stats/anonymized_stat';
import { CANCEL, SAVE } from '../anonymization_settings_modal/translations';
import * as i18n from './translations';
const StatFlexItem = styled(EuiFlexItem)`
margin-right: ${({ theme }) => theme.eui.euiSizeL};
`;
interface Props {
closeModal?: () => void;
}
const AnonymizationSettingsComponent: React.FC<Props> = ({ closeModal }) => {
const {
baseAllow,
baseAllowReplacement,
defaultAllow,
defaultAllowReplacement,
setDefaultAllow,
setDefaultAllowReplacement,
} = useAssistantContext();
// Local state for default allow and default allow replacement to allow for intermediate changes
const [localDefaultAllow, setLocalDefaultAllow] = useState<string[]>(defaultAllow);
const [localDefaultAllowReplacement, setLocalDefaultAllowReplacement] =
useState<string[]>(defaultAllowReplacement);
const onListUpdated = useCallback(
(updates: BatchUpdateListItem[]) => {
updateDefaults({
defaultAllow: localDefaultAllow,
defaultAllowReplacement: localDefaultAllowReplacement,
setDefaultAllow: setLocalDefaultAllow,
setDefaultAllowReplacement: setLocalDefaultAllowReplacement,
updates,
});
},
[localDefaultAllow, localDefaultAllowReplacement]
);
const onReset = useCallback(() => {
setLocalDefaultAllow(baseAllow);
setLocalDefaultAllowReplacement(baseAllowReplacement);
}, [baseAllow, baseAllowReplacement]);
const onSave = useCallback(() => {
setDefaultAllow(localDefaultAllow);
setDefaultAllowReplacement(localDefaultAllowReplacement);
closeModal?.();
}, [
closeModal,
localDefaultAllow,
localDefaultAllowReplacement,
setDefaultAllow,
setDefaultAllowReplacement,
]);
const anonymized: number = useMemo(() => {
const allowSet = new Set(localDefaultAllow);
return localDefaultAllowReplacement.reduce(
(acc, field) => (allowSet.has(field) ? acc + 1 : acc),
0
);
}, [localDefaultAllow, localDefaultAllowReplacement]);
return (
<>
<EuiCallOut
data-test-subj="anonymizationSettingsCallout"
iconType="eyeClosed"
size="s"
title={i18n.CALLOUT_TITLE}
>
<p>{i18n.CALLOUT_PARAGRAPH1}</p>
<EuiButton data-test-subj="reset" onClick={onReset} size="s">
{i18n.RESET}
</EuiButton>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="center" data-test-subj="summary" gutterSize="none">
<StatFlexItem grow={false}>
<AllowedStat allowed={localDefaultAllow.length} total={localDefaultAllow.length} />
</StatFlexItem>
<StatFlexItem grow={false}>
<AnonymizedStat anonymized={anonymized} isDataAnonymizable={true} />
</StatFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<ContextEditor
allow={localDefaultAllow}
allowReplacement={localDefaultAllowReplacement}
onListUpdated={onListUpdated}
rawData={null}
/>
<EuiFlexGroup alignItems="center" gutterSize="xs" justifyContent="flexEnd">
{closeModal != null && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="cancel" onClick={closeModal}>
{CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButton fill data-test-subj="save" onClick={onSave} size="s">
{SAVE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
AnonymizationSettingsComponent.displayName = 'AnonymizationSettingsComponent';
export const AnonymizationSettings = React.memo(AnonymizationSettingsComponent);

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const CALLOUT_PARAGRAPH1 = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutParagraph1',
{
defaultMessage: 'The fields below are allowed by default',
}
);
export const CALLOUT_PARAGRAPH2 = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutParagraph2',
{
defaultMessage: 'Optionally enable anonymization for these fields',
}
);
export const CALLOUT_TITLE = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutTitle',
{
defaultMessage: 'Anonymization defaults',
}
);
export const RESET = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.resetButton',
{
defaultMessage: 'Reset',
}
);

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { AnonymizationSettingsModal } from '.';
import { TestProviders } from '../../../mock/test_providers/test_providers';
describe('AnonymizationSettingsModal', () => {
const closeModal = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
render(
<TestProviders>
<AnonymizationSettingsModal closeModal={closeModal} />
</TestProviders>
);
});
it('renders the anonymizationSettings', () => {
expect(screen.getByTestId('anonymizationSettingsCallout')).toBeInTheDocument();
});
it('calls closeModal when Cancel is clicked', () => {
fireEvent.click(screen.getByTestId('cancel'));
expect(closeModal).toHaveBeenCalledTimes(1);
});
it('calls closeModal when Save is clicked', () => {
fireEvent.click(screen.getByTestId('save'));
expect(closeModal).toHaveBeenCalledTimes(1);
});
});

View file

@ -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 { EuiModal, EuiModalBody, EuiModalHeader } from '@elastic/eui';
import React from 'react';
import { AnonymizationSettings } from '../anonymization_settings';
interface Props {
closeModal: () => void;
}
const AnonymizationSettingsModalComponent: React.FC<Props> = ({ closeModal }) => (
<EuiModal onClose={closeModal}>
<EuiModalHeader />
<EuiModalBody>
<AnonymizationSettings closeModal={closeModal} />
</EuiModalBody>
</EuiModal>
);
export const AnonymizationSettingsModal = React.memo(AnonymizationSettingsModalComponent);

View file

@ -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 { i18n } from '@kbn/i18n';
export const ANONYMIZATION = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.anonymizationModalTitle',
{
defaultMessage: 'Anonymization',
}
);
export const CANCEL = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.cancelButton',
{
defaultMessage: 'Cancel',
}
);
export const SAVE = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettingsModal.saveButton',
{
defaultMessage: 'Save',
}
);

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import * as i18n from './translations';
import { SettingsPopover } from '.';
describe('SettingsPopover', () => {
beforeEach(() => {
render(
<TestProviders>
<SettingsPopover />
</TestProviders>
);
});
it('renders the settings button', () => {
const settingsButton = screen.getByTestId('settings');
expect(settingsButton).toBeInTheDocument();
});
it('opens the popover when the settings button is clicked', () => {
const settingsButton = screen.getByTestId('settings');
userEvent.click(settingsButton);
const popover = screen.queryByText(i18n.ANONYMIZATION);
expect(popover).toBeInTheDocument();
});
});

View file

@ -0,0 +1,89 @@
/*
* 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 {
EuiButtonIcon,
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiPopover,
useGeneratedHtmlId,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { AnonymizationSettingsModal } from '../anonymization_settings_modal';
import * as i18n from './translations';
const SettingsPopoverComponent: React.FC = () => {
const [showAnonymizationSettingsModal, setShowAnonymizationSettingsModal] = useState(false);
const closeAnonymizationSettingsModal = useCallback(
() => setShowAnonymizationSettingsModal(false),
[]
);
const contextMenuPopoverId = useGeneratedHtmlId();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const onButtonClick = useCallback(() => setIsPopoverOpen((prev) => !prev), []);
const button = useMemo(
() => (
<EuiButtonIcon
aria-label={i18n.SETTINGS}
data-test-subj="settings"
iconType="gear"
onClick={onButtonClick}
/>
),
[onButtonClick]
);
const panels: EuiContextMenuPanelDescriptor[] = useMemo(
() => [
{
id: 0,
items: [
{
icon: 'eyeClosed',
name: i18n.ANONYMIZATION,
onClick: () => {
closePopover();
setShowAnonymizationSettingsModal(true);
},
},
],
size: 's',
width: 150,
},
],
[closePopover]
);
return (
<>
<EuiPopover
anchorPosition="downLeft"
button={button}
closePopover={closePopover}
id={contextMenuPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenu initialPanelId={0} panels={panels} size="s" />
</EuiPopover>
{showAnonymizationSettingsModal && (
<AnonymizationSettingsModal closeModal={closeAnonymizationSettingsModal} />
)}
</>
);
};
SettingsPopoverComponent.displayName = 'SettingsPopoverComponent';
export const SettingsPopover = React.memo(SettingsPopoverComponent);

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const ANONYMIZATION = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.settingsPopover.anonymizationMenuItem',
{
defaultMessage: 'Anonymization',
}
);
export const SETTINGS = i18n.translate(
'xpack.elasticAssistant.dataAnonymization.settings.settingsPopover.settingsAriaLabel',
{
defaultMessage: 'Settings',
}
);

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SelectedPromptContext } from '../../assistant/prompt_context/types';
import { mockGetAnonymizedValue } from '../../mock/get_anonymized_value';
import { transformRawData } from '.';
describe('transformRawData', () => {
it('returns non-anonymized data when rawData is a string', () => {
const inputRawData: SelectedPromptContext = {
allow: ['field1'],
allowReplacement: ['field1', 'field2'],
promptContextId: 'abcd',
rawData: 'this will not be anonymized',
};
const result = transformRawData({
currentReplacements: {},
getAnonymizedValue: mockGetAnonymizedValue,
onNewReplacements: () => {},
selectedPromptContext: inputRawData,
});
expect(result).toEqual('this will not be anonymized');
});
it('calls onNewReplacements with the expected replacements', () => {
const inputRawData: SelectedPromptContext = {
allow: ['field1'],
allowReplacement: ['field1'],
promptContextId: 'abcd',
rawData: { field1: ['value1'] },
};
const onNewReplacements = jest.fn();
transformRawData({
currentReplacements: {},
getAnonymizedValue: mockGetAnonymizedValue,
onNewReplacements,
selectedPromptContext: inputRawData,
});
expect(onNewReplacements).toHaveBeenCalledWith({ '1eulav': 'value1' });
});
it('returns the expected mix of anonymized and non-anonymized data as a CSV string', () => {
const inputRawData: SelectedPromptContext = {
allow: ['field1', 'field2'],
allowReplacement: ['field1'], // only field 1 will be anonymized
promptContextId: 'abcd',
rawData: { field1: ['value1', 'value2'], field2: ['value3', 'value4'] },
};
const result = transformRawData({
currentReplacements: {},
getAnonymizedValue: mockGetAnonymizedValue,
onNewReplacements: () => {},
selectedPromptContext: inputRawData,
});
expect(result).toEqual('field1,1eulav,2eulav\nfield2,value3,value4'); // only field 1 is anonymized
});
it('omits fields that are not included in the `allow` list, even if they are members of `allowReplacement`', () => {
const inputRawData: SelectedPromptContext = {
allow: ['field1', 'field2'], // field3 is NOT allowed
allowReplacement: ['field1', 'field3'], // field3 is requested to be anonymized
promptContextId: 'abcd',
rawData: {
field1: ['value1', 'value2'],
field2: ['value3', 'value4'],
field3: ['value5', 'value6'], // this data should NOT be included in the output
},
};
const result = transformRawData({
currentReplacements: {},
getAnonymizedValue: mockGetAnonymizedValue,
onNewReplacements: () => {},
selectedPromptContext: inputRawData,
});
expect(result).toEqual('field1,1eulav,2eulav\nfield2,value3,value4'); // field 3 is not included
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 { SelectedPromptContext } from '../../assistant/prompt_context/types';
import { getAnonymizedData } from '../get_anonymized_data';
import { getAnonymizedValues } from '../get_anonymized_values';
import { getCsvFromData } from '../get_csv_from_data';
export const transformRawData = ({
currentReplacements,
getAnonymizedValue,
onNewReplacements,
selectedPromptContext,
}: {
currentReplacements: Record<string, string> | undefined;
getAnonymizedValue: ({
currentReplacements,
rawValue,
}: {
currentReplacements: Record<string, string> | undefined;
rawValue: string;
}) => string;
onNewReplacements?: (replacements: Record<string, string>) => void;
selectedPromptContext: SelectedPromptContext;
}): string => {
if (typeof selectedPromptContext.rawData === 'string') {
return selectedPromptContext.rawData;
}
const anonymizedData = getAnonymizedData({
allow: selectedPromptContext.allow,
allowReplacement: selectedPromptContext.allowReplacement,
currentReplacements,
rawData: selectedPromptContext.rawData,
getAnonymizedValue,
getAnonymizedValues,
});
if (onNewReplacements != null) {
onNewReplacements(anonymizedData.replacements);
}
return getCsvFromData(anonymizedData.anonymizedData);
};

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface AnonymizedValues {
/** The original values were transformed to these anonymized values */
anonymizedValues: string[];
/** A map from replacement value to original value */
replacements: Record<string, string>;
}
export interface AnonymizedData {
/** The original data was transformed to this anonymized data */
anonymizedData: Record<string, string[]>;
/** A map from replacement value to original value */
replacements: Record<string, string>;
}
export type GetAnonymizedValues = ({
allowReplacementSet,
allowSet,
currentReplacements,
field,
getAnonymizedValue,
rawData,
}: {
allowReplacementSet: Set<string>;
allowSet: Set<string>;
currentReplacements: Record<string, string> | undefined;
field: string;
getAnonymizedValue: ({
currentReplacements,
rawValue,
}: {
currentReplacements: Record<string, string> | undefined;
rawValue: string;
}) => string;
rawData: Record<string, string[]>;
}) => AnonymizedValues;

View file

@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BulkActions } from '.';
const selected = [
{
allowed: true,
anonymized: false,
denied: false,
field: 'process.args',
rawValues: ['abc', 'def'],
},
{
allowed: false,
anonymized: true,
denied: true,
field: 'user.name',
rawValues: ['fooUser'],
},
];
const defaultProps = {
appliesTo: 'multipleRows' as const,
disabled: false,
onListUpdated: jest.fn(),
onlyDefaults: false,
selected,
};
describe('BulkActions', () => {
beforeEach(() => jest.clearAllMocks());
it('calls onListUpdated with the expected updates when Allow is clicked', () => {
const { getByTestId, getByText } = render(<BulkActions {...defaultProps} />);
userEvent.click(getByTestId('bulkActionsButton'));
fireEvent.click(getByText(/^Allow$/));
expect(defaultProps.onListUpdated).toBeCalledWith([
{ field: 'process.args', operation: 'add', update: 'allow' },
{ field: 'user.name', operation: 'add', update: 'allow' },
]);
});
it('calls onListUpdated with the expected updates when Deny is clicked', () => {
const { getByTestId, getByText } = render(<BulkActions {...defaultProps} />);
userEvent.click(getByTestId('bulkActionsButton'));
fireEvent.click(getByText(/^Deny$/));
expect(defaultProps.onListUpdated).toBeCalledWith([
{ field: 'process.args', operation: 'remove', update: 'allow' },
{ field: 'user.name', operation: 'remove', update: 'allow' },
]);
});
it('calls onListUpdated with the expected updates when Anonymize is clicked', () => {
const { getByTestId, getByText } = render(<BulkActions {...defaultProps} />);
userEvent.click(getByTestId('bulkActionsButton'));
fireEvent.click(getByText(/^Anonymize$/));
expect(defaultProps.onListUpdated).toBeCalledWith([
{ field: 'process.args', operation: 'add', update: 'allowReplacement' },
{ field: 'user.name', operation: 'add', update: 'allowReplacement' },
]);
});
it('calls onListUpdated with the expected updates when Unanonymize is clicked', () => {
const { getByTestId, getByText } = render(<BulkActions {...defaultProps} />);
userEvent.click(getByTestId('bulkActionsButton'));
fireEvent.click(getByText(/^Unanonymize$/));
expect(defaultProps.onListUpdated).toBeCalledWith([
{ field: 'process.args', operation: 'remove', update: 'allowReplacement' },
{ field: 'user.name', operation: 'remove', update: 'allowReplacement' },
]);
});
it('calls onListUpdated with the expected updates when Deny by default is clicked', () => {
const { getByTestId, getByText } = render(
<BulkActions {...defaultProps} onlyDefaults={true} />
);
userEvent.click(getByTestId('bulkActionsButton'));
fireEvent.click(getByText(/^Deny by default$/));
expect(defaultProps.onListUpdated).toBeCalledWith([
{ field: 'process.args', operation: 'remove', update: 'allow' },
{ field: 'user.name', operation: 'remove', update: 'allow' },
{ field: 'process.args', operation: 'remove', update: 'defaultAllow' },
{ field: 'user.name', operation: 'remove', update: 'defaultAllow' },
]);
});
it('calls onListUpdated with the expected updates when Anonymize by default is clicked', () => {
const { getByTestId, getByText } = render(
<BulkActions {...defaultProps} onlyDefaults={true} />
);
userEvent.click(getByTestId('bulkActionsButton'));
fireEvent.click(getByText(/^Defaults$/));
fireEvent.click(getByText(/^Anonymize by default$/));
expect(defaultProps.onListUpdated).toBeCalledWith([
{ field: 'process.args', operation: 'add', update: 'allowReplacement' },
{ field: 'user.name', operation: 'add', update: 'allowReplacement' },
{ field: 'process.args', operation: 'add', update: 'defaultAllowReplacement' },
{ field: 'user.name', operation: 'add', update: 'defaultAllowReplacement' },
]);
});
it('calls onListUpdated with the expected updates when Unanonymize by default is clicked', () => {
const { getByTestId, getByText } = render(
<BulkActions {...defaultProps} onlyDefaults={true} />
);
userEvent.click(getByTestId('bulkActionsButton'));
fireEvent.click(getByText(/^Defaults$/));
fireEvent.click(getByText(/^Unanonymize by default$/));
expect(defaultProps.onListUpdated).toBeCalledWith([
{ field: 'process.args', operation: 'remove', update: 'allowReplacement' },
{ field: 'user.name', operation: 'remove', update: 'allowReplacement' },
{ field: 'process.args', operation: 'remove', update: 'defaultAllowReplacement' },
{ field: 'user.name', operation: 'remove', update: 'defaultAllowReplacement' },
]);
});
});

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButtonEmpty,
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiPopover,
EuiToolTip,
useGeneratedHtmlId,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { getContextMenuPanels, PRIMARY_PANEL_ID } from '../get_context_menu_panels';
import * as i18n from '../translations';
import { BatchUpdateListItem, ContextEditorRow } from '../types';
export interface Props {
appliesTo: 'multipleRows' | 'singleRow';
disabled: boolean;
disableAllow?: boolean;
disableAnonymize?: boolean;
disableDeny?: boolean;
disableUnanonymize?: boolean;
onListUpdated: (updates: BatchUpdateListItem[]) => void;
onlyDefaults: boolean;
selected: ContextEditorRow[];
}
const BulkActionsComponent: React.FC<Props> = ({
appliesTo,
disabled,
disableAllow = false,
disableAnonymize = false,
disableDeny = false,
disableUnanonymize = false,
onListUpdated,
onlyDefaults,
selected,
}) => {
const [isPopoverOpen, setPopover] = useState(false);
const contextMenuPopoverId = useGeneratedHtmlId({
prefix: 'contextEditorBulkActions',
});
const closePopover = useCallback(() => setPopover(false), []);
const onButtonClick = useCallback(() => setPopover((isOpen) => !isOpen), []);
const button = useMemo(
() => (
<EuiToolTip content={appliesTo === 'multipleRows' ? undefined : i18n.ALL_ACTIONS}>
<EuiButtonEmpty
data-test-subj="bulkActionsButton"
disabled={disabled}
iconType={appliesTo === 'multipleRows' ? 'arrowDown' : 'boxesVertical'}
iconSide={appliesTo === 'multipleRows' ? 'right' : undefined}
onClick={onButtonClick}
size="xs"
>
{appliesTo === 'multipleRows' ? i18n.BULK_ACTIONS : null}
</EuiButtonEmpty>
</EuiToolTip>
),
[appliesTo, disabled, onButtonClick]
);
const panels: EuiContextMenuPanelDescriptor[] = useMemo(
() =>
getContextMenuPanels({
disableAllow,
disableAnonymize,
disableDeny,
disableUnanonymize,
closePopover,
onListUpdated,
onlyDefaults,
selected,
}),
[
closePopover,
disableAllow,
disableAnonymize,
disableDeny,
disableUnanonymize,
onListUpdated,
onlyDefaults,
selected,
]
);
return (
<EuiPopover
anchorPosition="downLeft"
button={button}
closePopover={closePopover}
data-test-subj="bulkActions"
id={contextMenuPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenu initialPanelId={PRIMARY_PANEL_ID} panels={panels} size="s" />
</EuiPopover>
);
};
export const BulkActions = React.memo(BulkActionsComponent);

View file

@ -0,0 +1,318 @@
/*
* 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 { EuiBasicTableColumn } from '@elastic/eui';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { ContextEditorRow } from '../types';
import { getColumns } from '.';
interface ColumnWithRender {
render: (_: unknown, row: ContextEditorRow) => React.ReactNode;
}
const fieldIsNotAllowed: ContextEditorRow = {
allowed: false, // the field is not allowed
anonymized: false,
denied: false,
field: 'event.category',
rawValues: ['authentication'],
};
const fieldIsAllowedButNotAnonymized: ContextEditorRow = {
allowed: true, // the field is allowed
anonymized: false,
denied: false,
field: 'event.category',
rawValues: ['authentication'],
};
const rowWhereFieldIsAnonymized: ContextEditorRow = {
allowed: true,
anonymized: true, // the field is anonymized
denied: false,
field: 'user.name',
rawValues: ['rawUsername'],
};
describe('getColumns', () => {
const onListUpdated = jest.fn();
const rawData: Record<string, string[]> = {
'field.name': ['value1', 'value2'],
};
const row: ContextEditorRow = {
allowed: true,
anonymized: false,
denied: false,
field: 'event.category',
rawValues: ['authentication'],
};
it('includes the values column when rawData is NOT null', () => {
const columns: Array<EuiBasicTableColumn<ContextEditorRow> & { field?: string }> = getColumns({
onListUpdated,
rawData,
});
expect(columns.some(({ field }) => field === 'rawValues')).toBe(true);
});
it('does NOT include the values column when rawData is null', () => {
const columns: Array<EuiBasicTableColumn<ContextEditorRow> & { field?: string }> = getColumns({
onListUpdated,
rawData: null,
});
expect(columns.some(({ field }) => field === 'rawValues')).toBe(false);
});
describe('allowed column render()', () => {
it('calls onListUpdated with a `remove` operation when the toggle is clicked on field that is allowed', () => {
const columns = getColumns({ onListUpdated, rawData });
const anonymizedColumn: ColumnWithRender = columns[0] as ColumnWithRender;
const allowedRow = {
...row,
allowed: true, // the field is allowed
};
const { getByTestId } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, allowedRow)}</>
</TestProviders>
);
fireEvent.click(getByTestId('allowed'));
expect(onListUpdated).toBeCalledWith([
{ field: 'event.category', operation: 'remove', update: 'allow' },
]);
});
it('calls onListUpdated with an `add` operation when the toggle is clicked on a field that is NOT allowed', () => {
const columns = getColumns({ onListUpdated, rawData });
const anonymizedColumn: ColumnWithRender = columns[0] as ColumnWithRender;
const notAllowedRow = {
...row,
allowed: false, // the field is NOT allowed
};
const { getByTestId } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, notAllowedRow)}</>
</TestProviders>
);
fireEvent.click(getByTestId('allowed'));
expect(onListUpdated).toBeCalledWith([
{ field: 'event.category', operation: 'add', update: 'allow' },
]);
});
it('calls onListUpdated with a `remove` operation to update the `defaultAllowReplacement` list when the toggle is clicked on a default field that is allowed', () => {
const columns = getColumns({ onListUpdated, rawData: null }); // null raw data means the field is a default field
const anonymizedColumn: ColumnWithRender = columns[0] as ColumnWithRender;
const allowedRow = {
...row,
allowed: true, // the field is allowed
};
const { getByTestId } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, allowedRow)}</>
</TestProviders>
);
fireEvent.click(getByTestId('allowed'));
expect(onListUpdated).toBeCalledWith([
{ field: 'event.category', operation: 'remove', update: 'defaultAllowReplacement' },
]);
});
});
describe('anonymized column render()', () => {
it('disables the button when the field is not allowed', () => {
const columns = getColumns({ onListUpdated, rawData });
const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender;
const { getByTestId } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, fieldIsNotAllowed)}</>
</TestProviders>
);
expect(getByTestId('anonymized')).toBeDisabled();
});
it('enables the button when the field is allowed', () => {
const columns = getColumns({ onListUpdated, rawData });
const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender;
const { getByTestId } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, fieldIsAllowedButNotAnonymized)}</>
</TestProviders>
);
expect(getByTestId('anonymized')).not.toBeDisabled();
});
it('calls onListUpdated with an `add` operation when an unanonymized field is toggled', () => {
const columns = getColumns({ onListUpdated, rawData });
const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender;
const { getByTestId } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, row)}</>
</TestProviders>
);
fireEvent.click(getByTestId('anonymized'));
expect(onListUpdated).toBeCalledWith([
{ field: 'event.category', operation: 'add', update: 'allowReplacement' },
]);
});
it('calls onListUpdated with a `remove` operation when an anonymized field is toggled', () => {
const columns = getColumns({ onListUpdated, rawData });
const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender;
const anonymizedRow = {
...row,
anonymized: true,
};
const { getByTestId } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, anonymizedRow)}</>
</TestProviders>
);
fireEvent.click(getByTestId('anonymized'));
expect(onListUpdated).toBeCalledWith([
{ field: 'event.category', operation: 'remove', update: 'allowReplacement' },
]);
});
it('calls onListUpdated with an update to the `defaultAllowReplacement` list when rawData is null, because the field is a default', () => {
const columns = getColumns({ onListUpdated, rawData: null }); // null raw data means the field is a default field
const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender;
const { getByTestId } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, row)}</>
</TestProviders>
);
fireEvent.click(getByTestId('anonymized'));
expect(onListUpdated).toBeCalledWith([
{ field: 'event.category', operation: 'add', update: 'allowReplacement' },
]);
});
it('displays a closed eye icon when the field is anonymized', () => {
const columns = getColumns({ onListUpdated, rawData });
const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender;
const { container } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, rowWhereFieldIsAnonymized)}</>
</TestProviders>
);
expect(container.getElementsByClassName('euiButtonContent__icon')[0]).toHaveAttribute(
'data-euiicon-type',
'eyeClosed'
);
});
it('displays a open eye icon when the field is NOT anonymized', () => {
const columns = getColumns({ onListUpdated, rawData });
const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender;
const { container } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, fieldIsAllowedButNotAnonymized)}</>
</TestProviders>
);
expect(container.getElementsByClassName('euiButtonContent__icon')[0]).toHaveAttribute(
'data-euiicon-type',
'eye'
);
});
it('displays Yes when the field is anonymized', () => {
const columns = getColumns({ onListUpdated, rawData });
const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender;
const { getByTestId } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, rowWhereFieldIsAnonymized)}</>
</TestProviders>
);
expect(getByTestId('anonymized')).toHaveTextContent('Yes');
});
it('displays No when the field is NOT anonymized', () => {
const columns = getColumns({ onListUpdated, rawData });
const anonymizedColumn: ColumnWithRender = columns[1] as ColumnWithRender;
const { getByTestId } = render(
<TestProviders>
<>{anonymizedColumn.render(undefined, fieldIsAllowedButNotAnonymized)}</>
</TestProviders>
);
expect(getByTestId('anonymized')).toHaveTextContent('No');
});
});
describe('values column render()', () => {
it('joins values with a comma', () => {
const columns = getColumns({ onListUpdated, rawData });
const valuesColumn: ColumnWithRender = columns[3] as ColumnWithRender;
const rowWithMultipleValues = {
...row,
field: 'user.name',
rawValues: ['abe', 'bart'],
};
render(
<TestProviders>
<>{valuesColumn.render(rowWithMultipleValues.rawValues, rowWithMultipleValues)}</>
</TestProviders>
);
expect(screen.getByTestId('rawValues')).toHaveTextContent('abe,bart');
});
});
describe('actions column render()', () => {
it('renders the bulk actions', () => {
const columns = getColumns({ onListUpdated, rawData });
const actionsColumn: ColumnWithRender = columns[4] as ColumnWithRender;
render(
<TestProviders>
<>{actionsColumn.render(null, row)}</>
</TestProviders>
);
expect(screen.getByTestId('bulkActions')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,132 @@
/*
* 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 { EuiBasicTableColumn, EuiButtonEmpty, EuiCode, EuiSwitch, EuiText } from '@elastic/eui';
import React from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { BulkActions } from '../bulk_actions';
import * as i18n from '../translations';
import { BatchUpdateListItem, ContextEditorRow, FIELDS } from '../types';
const AnonymizedButton = styled(EuiButtonEmpty)`
max-height: 24px;
`;
export const getColumns = ({
onListUpdated,
rawData,
}: {
onListUpdated: (updates: BatchUpdateListItem[]) => void;
rawData: Record<string, string[]> | null;
}): Array<EuiBasicTableColumn<ContextEditorRow>> => {
const actionsColumn: EuiBasicTableColumn<ContextEditorRow> = {
field: FIELDS.ACTIONS,
name: '',
render: (_, row) => {
return (
<BulkActions
appliesTo="singleRow"
disabled={false}
disableAllow={row.allowed}
disableDeny={!row.allowed}
disableAnonymize={!row.allowed || (row.allowed && row.anonymized)}
disableUnanonymize={!row.allowed || (row.allowed && !row.anonymized)}
onListUpdated={onListUpdated}
onlyDefaults={rawData == null}
selected={[row]}
/>
);
},
sortable: false,
width: '36px',
};
const valuesColumn: EuiBasicTableColumn<ContextEditorRow> = {
field: FIELDS.RAW_VALUES,
name: i18n.VALUES,
render: (rawValues: ContextEditorRow['rawValues']) => (
<EuiCode data-test-subj="rawValues">{rawValues.join(',')}</EuiCode>
),
sortable: false,
};
const baseColumns: Array<EuiBasicTableColumn<ContextEditorRow>> = [
{
field: FIELDS.ALLOWED,
name: i18n.ALLOWED,
render: (_, { allowed, field }) => (
<EuiSwitch
data-test-subj="allowed"
checked={allowed}
label=""
showLabel={false}
onChange={() => {
onListUpdated([
{
field,
operation: allowed ? 'remove' : 'add',
update: rawData == null ? 'defaultAllow' : 'allow',
},
]);
if (rawData == null && allowed) {
// when editing defaults, remove the default replacement if the field is no longer allowed
onListUpdated([
{
field,
operation: 'remove',
update: 'defaultAllowReplacement',
},
]);
}
}}
/>
),
sortable: true,
width: '75px',
},
{
field: FIELDS.ANONYMIZED,
name: i18n.ANONYMIZED,
render: (_, { allowed, anonymized, field }) => (
<AnonymizedButton
data-test-subj="anonymized"
disabled={!allowed}
color={anonymized ? 'primary' : 'text'}
flush="both"
iconType={anonymized ? 'eyeClosed' : 'eye'}
isSelected={anonymized ? true : false}
onClick={() =>
onListUpdated([
{
field,
operation: anonymized ? 'remove' : 'add',
update: rawData == null ? 'defaultAllowReplacement' : 'allowReplacement',
},
])
}
>
<EuiText size="xs">{anonymized ? i18n.YES : i18n.NO}</EuiText>
</AnonymizedButton>
),
sortable: true,
width: '102px',
},
{
field: FIELDS.FIELD,
name: i18n.FIELD,
sortable: true,
width: '260px',
},
];
return rawData == null
? [...baseColumns, actionsColumn]
: [...baseColumns, valuesColumn, actionsColumn];
};

View file

@ -0,0 +1,677 @@
/*
* 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 { getContextMenuPanels, PRIMARY_PANEL_ID, SECONDARY_PANEL_ID } from '.';
import * as i18n from '../translations';
import { ContextEditorRow } from '../types';
describe('getContextMenuPanels', () => {
const closePopover = jest.fn();
const onListUpdated = jest.fn();
const selected: ContextEditorRow[] = [
{
allowed: true,
anonymized: true,
denied: false,
field: 'user.name',
rawValues: ['jodi'],
},
];
beforeEach(() => jest.clearAllMocks());
it('the first panel has a `primary-panel-id` when onlyDefaults is true', () => {
const onlyDefaults = true;
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults,
});
expect(panels[0].id).toEqual(PRIMARY_PANEL_ID);
});
it('the first panel also has a `primary-panel-id` when onlyDefaults is false', () => {
const onlyDefaults = false;
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults,
});
expect(panels[0].id).toEqual(PRIMARY_PANEL_ID); // first panel is always the primary panel
});
it('the second panel has a `secondary-panel-id` when onlyDefaults is false', () => {
const onlyDefaults = false;
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults,
});
expect(panels[1].id).toEqual(SECONDARY_PANEL_ID);
});
it('the second panel is not rendered when onlyDefaults is true', () => {
const onlyDefaults = true;
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults,
});
expect(panels.length).toEqual(1);
});
describe('allow by default', () => {
it('calls closePopover when allow by default is clicked', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const allowByDefaultItem = panels[1].items?.find(
(item) => item.name === i18n.ALLOW_BY_DEFAULT
);
allowByDefaultItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(closePopover).toHaveBeenCalled();
});
it('calls onListUpdated to add the field to both the `allow` and `defaultAllow` lists', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const allowByDefaultItem = panels[1].items?.find(
(item) => item.name === i18n.ALLOW_BY_DEFAULT
);
allowByDefaultItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(onListUpdated).toHaveBeenCalledWith([
{ field: 'user.name', operation: 'add', update: 'allow' },
{ field: 'user.name', operation: 'add', update: 'defaultAllow' },
]);
});
});
describe('deny by default', () => {
it('calls closePopover when deny by default is clicked', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const denyByDefaultItem = panels[1].items?.find((item) => item.name === i18n.DENY_BY_DEFAULT);
denyByDefaultItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(closePopover).toHaveBeenCalled();
});
it('calls onListUpdated to remove the field from both the `allow` and `defaultAllow` lists', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const denyByDefaultItem = panels[1].items?.find((item) => item.name === i18n.DENY_BY_DEFAULT);
denyByDefaultItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(onListUpdated).toHaveBeenCalledWith([
{ field: 'user.name', operation: 'remove', update: 'allow' },
{ field: 'user.name', operation: 'remove', update: 'defaultAllow' },
]);
});
});
describe('anonymize by default', () => {
it('calls closePopover when anonymize by default is clicked', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const anonymizeByDefaultItem = panels[1].items?.find(
(item) => item.name === i18n.ANONYMIZE_BY_DEFAULT
);
anonymizeByDefaultItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(closePopover).toHaveBeenCalled();
});
it('calls onListUpdated to add the field to both the `allowReplacement` and `defaultAllowReplacement` lists', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const anonymizeByDefaultItem = panels[1].items?.find(
(item) => item.name === i18n.ANONYMIZE_BY_DEFAULT
);
anonymizeByDefaultItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(onListUpdated).toHaveBeenCalledWith([
{ field: 'user.name', operation: 'add', update: 'allowReplacement' },
{ field: 'user.name', operation: 'add', update: 'defaultAllowReplacement' },
]);
});
});
describe('unanonymize by default', () => {
it('calls closePopover when unanonymize by default is clicked', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const unAnonymizeByDefaultItem = panels[1].items?.find(
(item) => item.name === i18n.UNANONYMIZE_BY_DEFAULT
);
unAnonymizeByDefaultItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(closePopover).toHaveBeenCalled();
});
it('calls onListUpdated to remove the field from both the `allowReplacement` and `defaultAllowReplacement` lists', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const unAnonymizeByDefaultItem = panels[1].items?.find(
(item) => item.name === i18n.UNANONYMIZE_BY_DEFAULT
);
unAnonymizeByDefaultItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(onListUpdated).toHaveBeenCalledWith([
{ field: 'user.name', operation: 'remove', update: 'allowReplacement' },
{ field: 'user.name', operation: 'remove', update: 'defaultAllowReplacement' },
]);
});
});
describe('allow', () => {
it('is disabled when `disableAlow` is true', () => {
const disableAllow = true;
const panels = getContextMenuPanels({
disableAllow,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const allowItem = panels[0].items?.find((item) => item.name === i18n.ALLOW);
expect(allowItem?.disabled).toBe(true);
});
it('is NOT disabled when `disableAlow` is false', () => {
const disableAllow = false;
const panels = getContextMenuPanels({
disableAllow,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const allowItem = panels[0].items?.find((item) => item.name === i18n.ALLOW);
expect(allowItem?.disabled).toBe(false);
});
it('calls closePopover when allow is clicked', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const allowItem = panels[0].items?.find((item) => item.name === i18n.ALLOW);
allowItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(closePopover).toHaveBeenCalled();
});
it('calls onListUpdated to add the field to the `allow` list', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const allowItem = panels[0].items?.find((item) => item.name === i18n.ALLOW);
allowItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(onListUpdated).toHaveBeenCalledWith([
{ field: 'user.name', operation: 'add', update: 'allow' },
]);
});
});
describe('deny', () => {
it('is disabled when `disableDeny` is true', () => {
const disableDeny = true;
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const denyItem = panels[0].items?.find((item) => item.name === i18n.DENY);
expect(denyItem?.disabled).toBe(true);
});
it('is NOT disabled when `disableDeny` is false', () => {
const disableDeny = false;
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const denyItem = panels[0].items?.find((item) => item.name === i18n.DENY);
expect(denyItem?.disabled).toBe(false);
});
it('calls closePopover when deny is clicked', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const denyByDefaultItem = panels[0].items?.find((item) => item.name === i18n.DENY);
denyByDefaultItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(closePopover).toHaveBeenCalled();
});
it('calls onListUpdated to remove the field from the `allow` list', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const denyItem = panels[0].items?.find((item) => item.name === i18n.DENY);
denyItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(onListUpdated).toHaveBeenCalledWith([
{ field: 'user.name', operation: 'remove', update: 'allow' },
]);
});
});
describe('anonymize', () => {
it('is disabled when `disableAnonymize` is true', () => {
const disableAnonymize = true;
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const anonymizeItem = panels[0].items?.find((item) => item.name === i18n.ANONYMIZE);
expect(anonymizeItem?.disabled).toBe(true);
});
it('is NOT disabled when `disableAnonymize` is false', () => {
const disableAnonymize = false;
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const anonymizeItem = panels[0].items?.find((item) => item.name === i18n.ANONYMIZE);
expect(anonymizeItem?.disabled).toBe(false);
});
it('calls closePopover when anonymize is clicked', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const anonymizeItem = panels[0].items?.find((item) => item.name === i18n.ANONYMIZE);
anonymizeItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(closePopover).toHaveBeenCalled();
});
it('calls onListUpdated to add the field to both the `allowReplacement` and `defaultAllowReplacement` lists', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const anonymizeItem = panels[0].items?.find((item) => item.name === i18n.ANONYMIZE);
anonymizeItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(onListUpdated).toHaveBeenCalledWith([
{ field: 'user.name', operation: 'add', update: 'allowReplacement' },
]);
});
});
describe('unanonymize', () => {
it('is disabled when `disableUnanonymize` is true', () => {
const disableUnanonymize = true;
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const unanonymizeItem = panels[0].items?.find((item) => item.name === i18n.UNANONYMIZE);
expect(unanonymizeItem?.disabled).toBe(true);
});
it('is NOT disabled when `disableUnanonymize` is false', () => {
const disableUnanonymize = false;
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const unanonymizeItem = panels[0].items?.find((item) => item.name === i18n.UNANONYMIZE);
expect(unanonymizeItem?.disabled).toBe(false);
});
it('calls closePopover when unanonymize is clicked', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const unAnonymizeItem = panels[0].items?.find((item) => item.name === i18n.UNANONYMIZE);
unAnonymizeItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(closePopover).toHaveBeenCalled();
});
it('calls onListUpdated to remove the field from the `allowReplacement` list', () => {
const panels = getContextMenuPanels({
disableAllow: false,
disableAnonymize: false,
disableDeny: false,
disableUnanonymize: false,
closePopover,
onListUpdated,
selected,
onlyDefaults: false,
});
const unAnonymizeItem = panels[0].items?.find((item) => item.name === i18n.UNANONYMIZE);
unAnonymizeItem?.onClick!(
new MouseEvent('click', { bubbles: true }) as unknown as React.MouseEvent<
HTMLHRElement,
MouseEvent
>
);
expect(onListUpdated).toHaveBeenCalledWith([
{ field: 'user.name', operation: 'remove', update: 'allowReplacement' },
]);
});
});
});

View file

@ -0,0 +1,223 @@
/*
* 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 { EuiContextMenuPanelDescriptor } from '@elastic/eui';
import * as i18n from '../translations';
import { BatchUpdateListItem, ContextEditorRow } from '../types';
export const PRIMARY_PANEL_ID = 'primary-panel-id';
export const SECONDARY_PANEL_ID = 'secondary-panel-id';
export const getContextMenuPanels = ({
disableAllow,
disableAnonymize,
disableDeny,
disableUnanonymize,
closePopover,
onListUpdated,
onlyDefaults,
selected,
}: {
disableAllow: boolean;
disableAnonymize: boolean;
disableDeny: boolean;
disableUnanonymize: boolean;
closePopover: () => void;
onListUpdated: (updates: BatchUpdateListItem[]) => void;
selected: ContextEditorRow[];
onlyDefaults: boolean;
}): EuiContextMenuPanelDescriptor[] => {
const defaultsPanelId = onlyDefaults ? PRIMARY_PANEL_ID : SECONDARY_PANEL_ID;
const nonDefaultsPanelId = onlyDefaults ? SECONDARY_PANEL_ID : PRIMARY_PANEL_ID;
const allowByDefault = [
!onlyDefaults
? {
icon: 'check',
name: i18n.ALLOW_BY_DEFAULT,
onClick: () => {
closePopover();
const updateAllow = selected.map<BatchUpdateListItem>(({ field }) => ({
field,
operation: 'add',
update: 'allow',
}));
const updateDefaultAllow = selected.map<BatchUpdateListItem>(({ field }) => ({
field,
operation: 'add',
update: 'defaultAllow',
}));
onListUpdated([...updateAllow, ...updateDefaultAllow]);
},
}
: [],
].flat();
const defaultsPanelItems: EuiContextMenuPanelDescriptor[] = [
{
id: defaultsPanelId,
title: i18n.DEFAULTS,
items: [
...allowByDefault,
{
icon: 'cross',
name: i18n.DENY_BY_DEFAULT,
onClick: () => {
closePopover();
const updateAllow = selected.map<BatchUpdateListItem>(({ field }) => ({
field,
operation: 'remove',
update: 'allow',
}));
const updateDefaultAllow = selected.map<BatchUpdateListItem>(({ field }) => ({
field,
operation: 'remove',
update: 'defaultAllow',
}));
onListUpdated([...updateAllow, ...updateDefaultAllow]);
},
},
{
icon: 'eyeClosed',
name: i18n.ANONYMIZE_BY_DEFAULT,
onClick: () => {
closePopover();
const updateAllowReplacement = selected.map<BatchUpdateListItem>(({ field }) => ({
field,
operation: 'add',
update: 'allowReplacement',
}));
const updateDefaultAllowReplacement = selected.map<BatchUpdateListItem>(
({ field }) => ({
field,
operation: 'add',
update: 'defaultAllowReplacement',
})
);
onListUpdated([...updateAllowReplacement, ...updateDefaultAllowReplacement]);
},
},
{
icon: 'eye',
name: i18n.UNANONYMIZE_BY_DEFAULT,
onClick: () => {
closePopover();
const updateAllowReplacement = selected.map<BatchUpdateListItem>(({ field }) => ({
field,
operation: 'remove',
update: 'allowReplacement',
}));
const updateDefaultAllowReplacement = selected.map<BatchUpdateListItem>(
({ field }) => ({
field,
operation: 'remove',
update: 'defaultAllowReplacement',
})
);
onListUpdated([...updateAllowReplacement, ...updateDefaultAllowReplacement]);
},
},
],
},
];
const nonDefaultsPanelItems: EuiContextMenuPanelDescriptor[] = [
{
id: nonDefaultsPanelId,
items: [
{
disabled: disableAllow,
icon: 'check',
name: i18n.ALLOW,
onClick: () => {
closePopover();
const updates = selected.map<BatchUpdateListItem>(({ field }) => ({
field,
operation: 'add',
update: 'allow',
}));
onListUpdated(updates);
},
},
{
disabled: disableDeny,
icon: 'cross',
name: i18n.DENY,
onClick: () => {
closePopover();
const updates = selected.map<BatchUpdateListItem>(({ field }) => ({
field,
operation: 'remove',
update: 'allow',
}));
onListUpdated(updates);
},
},
{
disabled: disableAnonymize,
icon: 'eyeClosed',
name: i18n.ANONYMIZE,
onClick: () => {
closePopover();
const updates = selected.map<BatchUpdateListItem>(({ field }) => ({
field,
operation: 'add',
update: 'allowReplacement',
}));
onListUpdated(updates);
},
},
{
disabled: disableUnanonymize,
icon: 'eye',
name: i18n.UNANONYMIZE,
onClick: () => {
closePopover();
const updates = selected.map<BatchUpdateListItem>(({ field }) => ({
field,
operation: 'remove',
update: 'allowReplacement',
}));
onListUpdated(updates);
},
},
{
isSeparator: true,
key: 'sep',
},
{
name: i18n.DEFAULTS,
panel: defaultsPanelId,
},
],
},
...defaultsPanelItems,
];
return onlyDefaults ? defaultsPanelItems : nonDefaultsPanelItems;
};

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SelectedPromptContext } from '../../../assistant/prompt_context/types';
import { ContextEditorRow } from '../types';
import { getRows } from '.';
describe('getRows', () => {
const defaultArgs: {
allow: SelectedPromptContext['allow'];
allowReplacement: SelectedPromptContext['allowReplacement'];
rawData: Record<string, string[]> | null;
} = {
allow: ['event.action', 'user.name', 'other.field'], // other.field is not in the rawData
allowReplacement: ['user.name', 'host.ip'], // host.ip is not in the rawData
rawData: {
'event.category': ['process'], // event.category is not in the allow list, nor is it in the allowReplacement list
'event.action': ['process_stopped', 'stop'], // event.action is in the allow list, but not the allowReplacement list
'user.name': ['max'], // user.name is in the allow list and the allowReplacement list
},
};
it('returns only the allowed fields if no rawData is provided', () => {
const expected: ContextEditorRow[] = [
{
allowed: true,
anonymized: false,
denied: false,
field: 'event.action',
rawValues: [],
},
{
allowed: true,
anonymized: false,
denied: false,
field: 'other.field',
rawValues: [],
},
{
allowed: true,
anonymized: true,
denied: false,
field: 'user.name',
rawValues: [],
},
];
const nullRawData: {
allow: SelectedPromptContext['allow'];
allowReplacement: SelectedPromptContext['allowReplacement'];
rawData: Record<string, string[]> | null;
} = {
...defaultArgs,
rawData: null,
};
const rows = getRows(nullRawData);
expect(rows).toEqual(expected);
});
it('returns the expected metadata and raw values', () => {
const expected: ContextEditorRow[] = [
{
allowed: true,
anonymized: false,
denied: false,
field: 'event.action',
rawValues: ['process_stopped', 'stop'],
},
{
allowed: false,
anonymized: false,
denied: true,
field: 'event.category',
rawValues: ['process'],
},
{
allowed: true,
anonymized: true,
denied: false,
field: 'user.name',
rawValues: ['max'],
},
];
const rows = getRows(defaultArgs);
expect(rows).toEqual(expected);
});
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SelectedPromptContext } from '../../../assistant/prompt_context/types';
import { isAllowed, isAnonymized, isDenied } from '../../helpers';
import { ContextEditorRow } from '../types';
export const getRows = ({
allow,
allowReplacement,
rawData,
}: {
allow: SelectedPromptContext['allow'];
allowReplacement: SelectedPromptContext['allowReplacement'];
rawData: Record<string, string[]> | null;
}): ContextEditorRow[] => {
const allowReplacementSet = new Set(allowReplacement);
const allowSet = new Set(allow);
if (rawData !== null && typeof rawData === 'object') {
const rawFields = Object.keys(rawData).sort();
return rawFields.reduce<ContextEditorRow[]>(
(acc, field) => [
...acc,
{
field,
allowed: isAllowed({ allowSet, field }),
anonymized: isAnonymized({ allowReplacementSet, field }),
denied: isDenied({ allowSet, field }),
rawValues: rawData[field],
},
],
[]
);
} else {
return allow.sort().reduce<ContextEditorRow[]>(
(acc, field) => [
...acc,
{
field,
allowed: true,
anonymized: allowReplacementSet.has(field),
denied: false,
rawValues: [],
},
],
[]
);
}
};

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { ContextEditor } from '.';
describe('ContextEditor', () => {
const allow = ['field1', 'field2'];
const allowReplacement = ['field1'];
const rawData = { field1: ['value1'], field2: ['value2'] };
const onListUpdated = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
render(
<ContextEditor
allow={allow}
allowReplacement={allowReplacement}
onListUpdated={onListUpdated}
rawData={rawData}
/>
);
});
it('renders the expected selected field count', () => {
expect(screen.getByTestId('selectedFields')).toHaveTextContent('Selected 0 fields');
});
it('renders the select all fields button with the expected count', () => {
expect(screen.getByTestId('selectAllFields')).toHaveTextContent('Select all 2 fields');
});
it('updates the table selection when "Select all n fields" is clicked', () => {
userEvent.click(screen.getByTestId('selectAllFields'));
expect(screen.getByTestId('selectedFields')).toHaveTextContent('Selected 2 fields');
});
it('calls onListUpdated with the expected values when the update button is clicked', () => {
userEvent.click(screen.getAllByTestId('allowed')[0]);
expect(onListUpdated).toHaveBeenCalledWith([
{
field: 'field1',
operation: 'remove',
update: 'allow',
},
]);
});
});

View file

@ -0,0 +1,125 @@
/*
* 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 { EuiInMemoryTable } from '@elastic/eui';
import type { EuiSearchBarProps, EuiTableSelectionType } from '@elastic/eui';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { getColumns } from './get_columns';
import { getRows } from './get_rows';
import { Toolbar } from './toolbar';
import * as i18n from './translations';
import { BatchUpdateListItem, ContextEditorRow, FIELDS, SortConfig } from './types';
export const DEFAULT_PAGE_SIZE = 10;
const pagination = {
initialPageSize: DEFAULT_PAGE_SIZE,
pageSizeOptions: [5, DEFAULT_PAGE_SIZE, 25, 50],
};
const defaultSort: SortConfig = {
sort: {
direction: 'desc',
field: FIELDS.ALLOWED,
},
};
export interface Props {
allow: string[];
allowReplacement: string[];
onListUpdated: (updates: BatchUpdateListItem[]) => void;
rawData: Record<string, string[]> | null;
}
const search: EuiSearchBarProps = {
box: {
incremental: true,
},
filters: [
{
field: FIELDS.ALLOWED,
type: 'is',
name: i18n.ALLOWED,
},
{
field: FIELDS.ANONYMIZED,
type: 'is',
name: i18n.ANONYMIZED,
},
],
};
const ContextEditorComponent: React.FC<Props> = ({
allow,
allowReplacement,
onListUpdated,
rawData,
}) => {
const [selected, setSelection] = useState<ContextEditorRow[]>([]);
const selectionValue: EuiTableSelectionType<ContextEditorRow> = useMemo(
() => ({
selectable: () => true,
onSelectionChange: (newSelection) => setSelection(newSelection),
initialSelected: [],
}),
[]
);
const tableRef = useRef<EuiInMemoryTable<ContextEditorRow> | null>(null);
const columns = useMemo(() => getColumns({ onListUpdated, rawData }), [onListUpdated, rawData]);
const rows = useMemo(
() =>
getRows({
allow,
allowReplacement,
rawData,
}),
[allow, allowReplacement, rawData]
);
const onSelectAll = useCallback(() => {
tableRef.current?.setSelection(rows); // updates selection in the EuiInMemoryTable
setTimeout(() => setSelection(rows), 0); // updates selection in the component state
}, [rows]);
const toolbar = useMemo(
() => (
<Toolbar
onListUpdated={onListUpdated}
onlyDefaults={rawData == null}
onSelectAll={onSelectAll}
selected={selected}
totalFields={rows.length}
/>
),
[onListUpdated, onSelectAll, rawData, rows.length, selected]
);
return (
<EuiInMemoryTable
allowNeutralSort={false}
childrenBetween={toolbar}
columns={columns}
compressed={true}
data-test-subj="contextEditor"
isSelectable={true}
itemId={FIELDS.FIELD}
items={rows}
pagination={pagination}
ref={tableRef}
search={search}
selection={selectionValue}
sorting={defaultSort}
/>
);
};
ContextEditorComponent.displayName = 'ContextEditorComponent';
export const ContextEditor = React.memo(ContextEditorComponent);

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { Toolbar } from '.';
import * as i18n from '../translations';
import { ContextEditorRow } from '../types';
const selected: ContextEditorRow[] = [
{
allowed: true,
anonymized: false,
denied: false,
field: 'event.action',
rawValues: ['process_stopped', 'stop'],
},
{
allowed: false,
anonymized: false,
denied: true,
field: 'event.category',
rawValues: ['process'],
},
{
allowed: true,
anonymized: true,
denied: false,
field: 'user.name',
rawValues: ['max'],
},
];
describe('Toolbar', () => {
const defaultProps = {
onListUpdated: jest.fn(),
onlyDefaults: false,
onSelectAll: jest.fn(),
selected: [], // no rows selected
totalFields: 5,
};
it('displays the number of selected fields', () => {
const { getByText } = render(<Toolbar {...defaultProps} selected={selected} />);
const selectedCount = selected.length;
const selectedFieldsText = getByText(i18n.SELECTED_FIELDS(selectedCount));
expect(selectedFieldsText).toBeInTheDocument();
});
it('disables bulk actions when no rows are selected', () => {
const { getByTestId } = render(<Toolbar {...defaultProps} />);
const bulkActionsButton = getByTestId('bulkActionsButton');
expect(bulkActionsButton).toBeDisabled();
});
it('enables bulk actions when some fields are selected', () => {
const { getByTestId } = render(<Toolbar {...defaultProps} selected={selected} />);
const bulkActionsButton = getByTestId('bulkActionsButton');
expect(bulkActionsButton).not.toBeDisabled();
});
it('calls onSelectAll when the Select All Fields button is clicked', () => {
const { getByText } = render(<Toolbar {...defaultProps} />);
const selectAllButton = getByText(i18n.SELECT_ALL_FIELDS(defaultProps.totalFields));
fireEvent.click(selectAllButton);
expect(defaultProps.onSelectAll).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import React from 'react';
import { BulkActions } from '../bulk_actions';
import * as i18n from '../translations';
import { BatchUpdateListItem, ContextEditorRow } from '../types';
export interface Props {
onListUpdated: (updates: BatchUpdateListItem[]) => void;
onlyDefaults: boolean;
onSelectAll: () => void;
selected: ContextEditorRow[];
totalFields: number;
}
const ToolbarComponent: React.FC<Props> = ({
onListUpdated,
onlyDefaults,
onSelectAll,
selected,
totalFields,
}) => (
<EuiFlexGroup alignItems="center" data-test-subj="toolbar" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiText color="subdued" data-test-subj="selectedFields" size="xs">
{i18n.SELECTED_FIELDS(selected.length)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="selectAllFields"
iconType="pagesSelect"
onClick={onSelectAll}
size="xs"
>
{i18n.SELECT_ALL_FIELDS(totalFields)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<BulkActions
appliesTo="multipleRows"
disabled={selected.length === 0}
onListUpdated={onListUpdated}
onlyDefaults={onlyDefaults}
selected={selected}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
ToolbarComponent.displayName = 'ToolbarComponent';
export const Toolbar = React.memo(ToolbarComponent);

View file

@ -0,0 +1,146 @@
/*
* 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 ALL_ACTIONS = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.allActionsTooltip',
{
defaultMessage: 'All actions',
}
);
export const ALLOW = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.allowAction',
{
defaultMessage: 'Allow',
}
);
export const ALLOW_BY_DEFAULT = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.allowByDefaultAction',
{
defaultMessage: 'Allow by default',
}
);
export const ALLOWED = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.allowedColumnTitle',
{
defaultMessage: 'Allowed',
}
);
export const ALWAYS = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.alwaysSubmenu',
{
defaultMessage: 'Always',
}
);
export const ANONYMIZE = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.anonymizeAction',
{
defaultMessage: 'Anonymize',
}
);
export const ANONYMIZE_BY_DEFAULT = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.anonymizeByDefaultAction',
{
defaultMessage: 'Anonymize by default',
}
);
export const ANONYMIZED = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.anonymizedColumnTitle',
{
defaultMessage: 'Anonymized',
}
);
export const BULK_ACTIONS = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.bulkActions',
{
defaultMessage: 'Bulk actions',
}
);
export const DEFAULTS = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.defaultsSubmenu',
{
defaultMessage: 'Defaults',
}
);
export const DENY = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.denyAction',
{
defaultMessage: 'Deny',
}
);
export const DENY_BY_DEFAULT = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.denyByDefaultAction',
{
defaultMessage: 'Deny by default',
}
);
export const FIELD = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.fieldColumnTitle',
{
defaultMessage: 'Field',
}
);
export const NO = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.noButtonLabel',
{
defaultMessage: 'No',
}
);
export const SELECT_ALL_FIELDS = (totalFields: number) =>
i18n.translate('xpack.elasticAssistant.dataAnonymizationEditor.contextEditor.selectAllFields', {
values: { totalFields },
defaultMessage: 'Select all {totalFields} fields',
});
export const SELECTED_FIELDS = (selected: number) =>
i18n.translate('xpack.elasticAssistant.dataAnonymizationEditor.contextEditor.selectedFields', {
values: { selected },
defaultMessage: 'Selected {selected} fields',
});
export const UNANONYMIZE = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.unanonymizeAction',
{
defaultMessage: 'Unanonymize',
}
);
export const UNANONYMIZE_BY_DEFAULT = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.unanonymizeByDefaultAction',
{
defaultMessage: 'Unanonymize by default',
}
);
export const VALUES = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.valuesColumnTitle',
{
defaultMessage: 'Values',
}
);
export const YES = i18n.translate(
'xpack.elasticAssistant.assistant.dataAnonymizationEditor.contextEditor.yesButtonLabel',
{
defaultMessage: 'Yes',
}
);

View file

@ -0,0 +1,50 @@
/*
* 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 { Direction } from '@elastic/eui';
export interface ContextEditorRow {
/** Is the field is allowed to be included in the context sent to the assistant */
allowed: boolean;
/** Are the field's values anonymized */
anonymized: boolean;
/** Is the field is denied to be included in the context sent to the assistant */
denied: boolean;
/** The name of the field, e.g. `user.name` */
field: string;
/** The raw, NOT anonymized values */
rawValues: string[];
}
export const FIELDS = {
ACTIONS: 'actions',
ALLOWED: 'allowed',
ANONYMIZED: 'anonymized',
DENIED: 'denied',
FIELD: 'field',
RAW_VALUES: 'rawValues',
};
export interface SortConfig {
sort: {
direction: Direction;
field: string;
};
}
/** The `field` in the specified `list` will be added or removed, as specified by the `operation` */
export interface BatchUpdateListItem {
field: string;
operation: 'add' | 'remove';
update:
| 'allow'
| 'allowReplacement'
| 'defaultAllow'
| 'defaultAllowReplacement'
| 'deny'
| 'denyReplacement';
}

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SelectedPromptContext } from '../../assistant/prompt_context/types';
import type { Stats } from '../helpers';
import { getStats } from '.';
describe('getStats', () => {
it('returns ZERO_STATS for string rawData', () => {
const context: SelectedPromptContext = {
allow: [],
allowReplacement: [],
promptContextId: 'abcd',
rawData: 'this will not be anonymized',
};
const expectedResult: Stats = {
allowed: 0,
anonymized: 0,
denied: 0,
total: 0,
};
expect(getStats(context)).toEqual(expectedResult);
});
it('returns the expected stats for object rawData', () => {
const context: SelectedPromptContext = {
allow: ['event.category', 'event.action', 'user.name'],
allowReplacement: ['user.name', 'host.ip'], // only user.name is allowed to be sent
promptContextId: 'abcd',
rawData: {
'event.category': ['process'],
'event.action': ['process_stopped'],
'user.name': ['sean'],
other: ['this', 'is', 'not', 'allowed'],
},
};
const expectedResult: Stats = {
allowed: 3,
anonymized: 1,
denied: 1,
total: 4,
};
expect(getStats(context)).toEqual(expectedResult);
});
});

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SelectedPromptContext } from '../../assistant/prompt_context/types';
import { Stats, isAllowed, isAnonymized, isDenied } from '../helpers';
export const getStats = ({ allow, allowReplacement, rawData }: SelectedPromptContext): Stats => {
const ZERO_STATS = {
allowed: 0,
anonymized: 0,
denied: 0,
total: 0,
};
if (typeof rawData === 'string') {
return ZERO_STATS;
} else {
const rawFields = Object.keys(rawData);
const allowReplacementSet = new Set(allowReplacement);
const allowSet = new Set(allow);
return rawFields.reduce<Stats>(
(acc, field) => ({
allowed: acc.allowed + (isAllowed({ allowSet, field }) ? 1 : 0),
anonymized:
acc.anonymized +
(isAllowed({ allowSet, field }) && isAnonymized({ allowReplacementSet, field }) ? 1 : 0),
denied: acc.denied + (isDenied({ allowSet, field }) ? 1 : 0),
total: acc.total + 1,
}),
ZERO_STATS
);
}
};

View file

@ -0,0 +1,304 @@
/*
* 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 { SelectedPromptContext } from '../../assistant/prompt_context/types';
import {
isAllowed,
isAnonymized,
isDenied,
getIsDataAnonymizable,
updateDefaultList,
updateDefaults,
updateList,
updateSelectedPromptContext,
} from '.';
import { BatchUpdateListItem } from '../context_editor/types';
describe('helpers', () => {
beforeEach(() => jest.clearAllMocks());
describe('getIsDataAnonymizable', () => {
it('returns false for string data', () => {
const rawData = 'this will not be anonymized';
const result = getIsDataAnonymizable(rawData);
expect(result).toBe(false);
});
it('returns true for key / values data', () => {
const rawData = { key: ['value1', 'value2'] };
const result = getIsDataAnonymizable(rawData);
expect(result).toBe(true);
});
});
describe('isAllowed', () => {
it('returns true when the field is present in the allowSet', () => {
const allowSet = new Set(['fieldName1', 'fieldName2', 'fieldName3']);
expect(isAllowed({ allowSet, field: 'fieldName1' })).toBe(true);
});
it('returns false when the field is NOT present in the allowSet', () => {
const allowSet = new Set(['fieldName1', 'fieldName2', 'fieldName3']);
expect(isAllowed({ allowSet, field: 'nonexistentField' })).toBe(false);
});
});
describe('isDenied', () => {
it('returns true when the field is NOT in the allowSet', () => {
const allowSet = new Set(['field1', 'field2']);
const field = 'field3';
expect(isDenied({ allowSet, field })).toBe(true);
});
it('returns false when the field is in the allowSet', () => {
const allowSet = new Set(['field1', 'field2']);
const field = 'field1';
expect(isDenied({ allowSet, field })).toBe(false);
});
it('returns true for an empty allowSet', () => {
const allowSet = new Set<string>();
const field = 'field1';
expect(isDenied({ allowSet, field })).toBe(true);
});
it('returns false when the field is an empty string and allowSet contains the empty string', () => {
const allowSet = new Set(['', 'field1']);
const field = '';
expect(isDenied({ allowSet, field })).toBe(false);
});
});
describe('isAnonymized', () => {
const allowReplacementSet = new Set(['user.name', 'host.name']);
it('returns true when the field is in the allowReplacementSet', () => {
const field = 'user.name';
expect(isAnonymized({ allowReplacementSet, field })).toBe(true);
});
it('returns false when the field is NOT in the allowReplacementSet', () => {
const field = 'foozle';
expect(isAnonymized({ allowReplacementSet, field })).toBe(false);
});
it('returns false when allowReplacementSet is empty', () => {
const emptySet = new Set<string>();
const field = 'user.name';
expect(isAnonymized({ allowReplacementSet: emptySet, field })).toBe(false);
});
});
describe('updateList', () => {
it('adds a new field to the list when the operation is `add`', () => {
const result = updateList({
field: 'newField',
list: ['field1', 'field2'],
operation: 'add',
});
expect(result).toEqual(['field1', 'field2', 'newField']);
});
it('does NOT add a duplicate field to the list when the operation is `add`', () => {
const result = updateList({
field: 'field1',
list: ['field1', 'field2'],
operation: 'add',
});
expect(result).toEqual(['field1', 'field2']);
});
it('removes an existing field from the list when the operation is `remove`', () => {
const result = updateList({
field: 'field1',
list: ['field1', 'field2'],
operation: 'remove',
});
expect(result).toEqual(['field2']);
});
it('should NOT modify the list when removing a non-existent field', () => {
const result = updateList({
field: 'host.name',
list: ['field1', 'field2'],
operation: 'remove',
});
expect(result).toEqual(['field1', 'field2']);
});
});
describe('updateSelectedPromptContext', () => {
const selectedPromptContext: SelectedPromptContext = {
allow: ['user.name', 'event.category'],
allowReplacement: ['user.name'],
promptContextId: 'testId',
rawData: {},
};
it('updates the allow list when update is `allow` and the operation is `add`', () => {
const result = updateSelectedPromptContext({
field: 'event.action',
operation: 'add',
selectedPromptContext,
update: 'allow',
});
expect(result.allow).toEqual(['user.name', 'event.category', 'event.action']);
});
it('updates the allow list when update is `allow` and the operation is `remove`', () => {
const result = updateSelectedPromptContext({
field: 'user.name',
operation: 'remove',
selectedPromptContext,
update: 'allow',
});
expect(result.allow).toEqual(['event.category']);
});
it('updates the allowReplacement list when update is `allowReplacement` and the operation is `add`', () => {
const result = updateSelectedPromptContext({
field: 'event.type',
operation: 'add',
selectedPromptContext,
update: 'allowReplacement',
});
expect(result.allowReplacement).toEqual(['user.name', 'event.type']);
});
it('updates the allowReplacement list when update is `allowReplacement` and the operation is `remove`', () => {
const result = updateSelectedPromptContext({
field: 'user.name',
operation: 'remove',
selectedPromptContext,
update: 'allowReplacement',
});
expect(result.allowReplacement).toEqual([]);
});
it('does not update selectedPromptContext when update is not "allow" or "allowReplacement"', () => {
const result = updateSelectedPromptContext({
field: 'user.name',
operation: 'add',
selectedPromptContext,
update: 'deny',
});
expect(result).toEqual(selectedPromptContext);
});
});
describe('updateDefaultList', () => {
it('updates the `defaultAllow` list to add a field when the operation is add', () => {
const currentList = ['test1', 'test2'];
const setDefaultList = jest.fn();
const update = 'defaultAllow';
const updates: BatchUpdateListItem[] = [{ field: 'test3', operation: 'add', update }];
updateDefaultList({ currentList, setDefaultList, update, updates });
expect(setDefaultList).toBeCalledWith([...currentList, 'test3']);
});
it('updates the `defaultAllow` list to remove a field when the operation is remove', () => {
const currentList = ['test1', 'test2'];
const setDefaultList = jest.fn();
const update = 'defaultAllow';
const updates: BatchUpdateListItem[] = [{ field: 'test1', operation: 'remove', update }];
updateDefaultList({ currentList, setDefaultList, update, updates });
expect(setDefaultList).toBeCalledWith(['test2']);
});
it('does NOT invoke `setDefaultList` when `update` does NOT match any of the batched `updates` types', () => {
const currentList = ['test1', 'test2'];
const setDefaultList = jest.fn();
const update = 'allow';
const updates: BatchUpdateListItem[] = [
{ field: 'test1', operation: 'remove', update: 'defaultAllow' }, // update does not match
];
updateDefaultList({ currentList, setDefaultList, update, updates });
expect(setDefaultList).not.toBeCalled();
});
it('does NOT invoke `setDefaultList` when `updates` is empty', () => {
const currentList = ['test1', 'test2'];
const setDefaultList = jest.fn();
const update = 'defaultAllow';
const updates: BatchUpdateListItem[] = []; // no updates
updateDefaultList({ currentList, setDefaultList, update, updates });
expect(setDefaultList).not.toBeCalled();
});
});
describe('updateDefaults', () => {
const setDefaultAllow = jest.fn();
const setDefaultAllowReplacement = jest.fn();
const defaultAllow = ['field1', 'field2'];
const defaultAllowReplacement = ['field2'];
const batchUpdateListItems: BatchUpdateListItem[] = [
{
field: 'field1',
operation: 'remove',
update: 'defaultAllow',
},
{
field: 'host.name',
operation: 'add',
update: 'defaultAllowReplacement',
},
];
it('updates defaultAllow with filtered updates', () => {
updateDefaults({
defaultAllow,
defaultAllowReplacement,
setDefaultAllow,
setDefaultAllowReplacement,
updates: batchUpdateListItems,
});
expect(setDefaultAllow).toHaveBeenCalledWith(['field2']);
});
it('updates defaultAllowReplacement with filtered updates', () => {
updateDefaults({
defaultAllow,
defaultAllowReplacement,
setDefaultAllow,
setDefaultAllowReplacement,
updates: batchUpdateListItems,
});
expect(setDefaultAllowReplacement).toHaveBeenCalledWith(['field2', 'host.name']);
});
});
});

View file

@ -0,0 +1,135 @@
/*
* 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 { SelectedPromptContext } from '../../assistant/prompt_context/types';
import type { BatchUpdateListItem } from '../context_editor/types';
export const getIsDataAnonymizable = (rawData: string | Record<string, string[]>): boolean =>
typeof rawData !== 'string';
export interface Stats {
allowed: number;
anonymized: number;
denied: number;
total: number;
}
export const isAllowed = ({ allowSet, field }: { allowSet: Set<string>; field: string }): boolean =>
allowSet.has(field);
export const isDenied = ({ allowSet, field }: { allowSet: Set<string>; field: string }): boolean =>
!allowSet.has(field);
export const isAnonymized = ({
allowReplacementSet,
field,
}: {
allowReplacementSet: Set<string>;
field: string;
}): boolean => allowReplacementSet.has(field);
export const updateList = ({
field,
list,
operation,
}: {
field: string;
list: string[];
operation: 'add' | 'remove';
}): string[] => {
if (operation === 'add') {
return list.includes(field) ? list : [...list, field];
} else {
return list.filter((x) => x !== field);
}
};
export const updateSelectedPromptContext = ({
field,
operation,
selectedPromptContext,
update,
}: {
field: string;
operation: 'add' | 'remove';
selectedPromptContext: SelectedPromptContext;
update:
| 'allow'
| 'allowReplacement'
| 'defaultAllow'
| 'defaultAllowReplacement'
| 'deny'
| 'denyReplacement';
}): SelectedPromptContext => {
const { allow, allowReplacement } = selectedPromptContext;
switch (update) {
case 'allow':
return {
...selectedPromptContext,
allow: updateList({ field, list: allow, operation }),
};
case 'allowReplacement':
return {
...selectedPromptContext,
allowReplacement: updateList({ field, list: allowReplacement, operation }),
};
default:
return selectedPromptContext;
}
};
export const updateDefaultList = ({
currentList,
setDefaultList,
update,
updates,
}: {
currentList: string[];
setDefaultList: React.Dispatch<React.SetStateAction<string[]>>;
update: 'allow' | 'allowReplacement' | 'defaultAllow' | 'defaultAllowReplacement' | 'deny';
updates: BatchUpdateListItem[];
}): void => {
const filteredUpdates = updates.filter((x) => x.update === update);
if (filteredUpdates.length > 0) {
const updatedList = filteredUpdates.reduce(
(acc, { field, operation }) => updateList({ field, list: acc, operation }),
currentList
);
setDefaultList(updatedList);
}
};
export const updateDefaults = ({
defaultAllow,
defaultAllowReplacement,
setDefaultAllow,
setDefaultAllowReplacement,
updates,
}: {
defaultAllow: string[];
defaultAllowReplacement: string[];
setDefaultAllow: React.Dispatch<React.SetStateAction<string[]>>;
setDefaultAllowReplacement: React.Dispatch<React.SetStateAction<string[]>>;
updates: BatchUpdateListItem[];
}): void => {
updateDefaultList({
currentList: defaultAllow,
setDefaultList: setDefaultAllow,
update: 'defaultAllow',
updates,
});
updateDefaultList({
currentList: defaultAllowReplacement,
setDefaultList: setDefaultAllowReplacement,
update: 'defaultAllowReplacement',
updates,
});
};

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SelectedPromptContext } from '../assistant/prompt_context/types';
import { TestProviders } from '../mock/test_providers/test_providers';
import { DataAnonymizationEditor } from '.';
describe('DataAnonymizationEditor', () => {
const mockSelectedPromptContext: SelectedPromptContext = {
allow: ['field1', 'field2'],
allowReplacement: ['field1'],
promptContextId: 'test-id',
rawData: 'test-raw-data',
};
it('renders stats', () => {
render(
<TestProviders>
<DataAnonymizationEditor
selectedPromptContext={mockSelectedPromptContext}
setSelectedPromptContexts={jest.fn()}
/>
</TestProviders>
);
expect(screen.getByTestId('stats')).toBeInTheDocument();
});
describe('when rawData is a string (non-anonymized data)', () => {
it('renders the ReadOnlyContextViewer when rawData is (non-anonymized data)', () => {
render(
<TestProviders>
<DataAnonymizationEditor
selectedPromptContext={mockSelectedPromptContext}
setSelectedPromptContexts={jest.fn()}
/>
</TestProviders>
);
expect(screen.getByTestId('readOnlyContextViewer')).toBeInTheDocument();
});
it('does NOT render the ContextEditor when rawData is non-anonymized data', () => {
render(
<TestProviders>
<DataAnonymizationEditor
selectedPromptContext={mockSelectedPromptContext}
setSelectedPromptContexts={jest.fn()}
/>
</TestProviders>
);
expect(screen.queryByTestId('contextEditor')).not.toBeInTheDocument();
});
});
describe('when rawData is a `Record<string, string[]>` (anonymized data)', () => {
const setSelectedPromptContexts = jest.fn();
const mockRawData: Record<string, string[]> = {
field1: ['value1', 'value2'],
field2: ['value3', 'value4', 'value5'],
field3: ['value6'],
};
const selectedPromptContextWithAnonymized: SelectedPromptContext = {
...mockSelectedPromptContext,
rawData: mockRawData,
};
beforeEach(() => {
render(
<TestProviders>
<DataAnonymizationEditor
selectedPromptContext={selectedPromptContextWithAnonymized}
setSelectedPromptContexts={setSelectedPromptContexts}
/>
</TestProviders>
);
});
it('renders the ContextEditor when rawData is anonymized data', () => {
expect(screen.getByTestId('contextEditor')).toBeInTheDocument();
});
it('does NOT render the ReadOnlyContextViewer when rawData is anonymized data', () => {
expect(screen.queryByTestId('readOnlyContextViewer')).not.toBeInTheDocument();
});
it('calls setSelectedPromptContexts when a field is toggled', () => {
userEvent.click(screen.getAllByTestId('allowed')[0]); // toggle the first field
expect(setSelectedPromptContexts).toBeCalled();
});
});
});

View file

@ -0,0 +1,102 @@
/*
* 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 } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { useAssistantContext } from '../assistant_context';
import type { SelectedPromptContext } from '../assistant/prompt_context/types';
import { ContextEditor } from './context_editor';
import { BatchUpdateListItem } from './context_editor/types';
import { getIsDataAnonymizable, updateDefaults, updateSelectedPromptContext } from './helpers';
import { ReadOnlyContextViewer } from './read_only_context_viewer';
import { Stats } from './stats';
const EditorContainer = styled.div`
overflow-x: auto;
`;
export interface Props {
selectedPromptContext: SelectedPromptContext;
setSelectedPromptContexts: React.Dispatch<
React.SetStateAction<Record<string, SelectedPromptContext>>
>;
}
const DataAnonymizationEditorComponent: React.FC<Props> = ({
selectedPromptContext,
setSelectedPromptContexts,
}) => {
const { defaultAllow, defaultAllowReplacement, setDefaultAllow, setDefaultAllowReplacement } =
useAssistantContext();
const isDataAnonymizable = useMemo<boolean>(
() => getIsDataAnonymizable(selectedPromptContext.rawData),
[selectedPromptContext]
);
const onListUpdated = useCallback(
(updates: BatchUpdateListItem[]) => {
const updatedPromptContext = updates.reduce<SelectedPromptContext>(
(acc, { field, operation, update }) =>
updateSelectedPromptContext({
field,
operation,
selectedPromptContext: acc,
update,
}),
selectedPromptContext
);
setSelectedPromptContexts((prev) => ({
...prev,
[selectedPromptContext.promptContextId]: updatedPromptContext,
}));
updateDefaults({
defaultAllow,
defaultAllowReplacement,
setDefaultAllow,
setDefaultAllowReplacement,
updates,
});
},
[
defaultAllow,
defaultAllowReplacement,
selectedPromptContext,
setDefaultAllow,
setDefaultAllowReplacement,
setSelectedPromptContexts,
]
);
return (
<EditorContainer data-test-subj="dataAnonymizationEditor">
<Stats
isDataAnonymizable={isDataAnonymizable}
selectedPromptContext={selectedPromptContext}
/>
<EuiSpacer size="s" />
{typeof selectedPromptContext.rawData === 'string' ? (
<ReadOnlyContextViewer rawData={selectedPromptContext.rawData} />
) : (
<ContextEditor
allow={selectedPromptContext.allow}
allowReplacement={selectedPromptContext.allowReplacement}
onListUpdated={onListUpdated}
rawData={selectedPromptContext.rawData}
/>
)}
</EditorContainer>
);
};
export const DataAnonymizationEditor = React.memo(DataAnonymizationEditorComponent);

View file

@ -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 { render, screen } from '@testing-library/react';
import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../content/prompts/system/translations';
import { ReadOnlyContextViewer, Props } from '.';
const defaultProps: Props = {
rawData: 'this content is NOT anonymized',
};
describe('ReadOnlyContextViewer', () => {
it('renders the context with the correct formatting', () => {
render(<ReadOnlyContextViewer {...defaultProps} />);
const contextBlock = screen.getByTestId('readOnlyContextViewer');
expect(contextBlock.textContent).toBe(SYSTEM_PROMPT_CONTEXT_NON_I18N(defaultProps.rawData));
});
});

View file

@ -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 { EuiCodeBlock } from '@elastic/eui';
import React from 'react';
import { SYSTEM_PROMPT_CONTEXT_NON_I18N } from '../../content/prompts/system/translations';
export interface Props {
rawData: string;
}
const ReadOnlyContextViewerComponent: React.FC<Props> = ({ rawData }) => {
return (
<EuiCodeBlock data-test-subj="readOnlyContextViewer" isCopyable>
{SYSTEM_PROMPT_CONTEXT_NON_I18N(rawData)}
</EuiCodeBlock>
);
};
ReadOnlyContextViewerComponent.displayName = 'ReadOnlyContextViewerComponent';
export const ReadOnlyContextViewer = React.memo(ReadOnlyContextViewerComponent);

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { AllowedStat } from '.';
import * as i18n from './translations';
describe('AllowedStat', () => {
const defaultProps = {
allowed: 3,
total: 5,
};
it('renders the expected stat content', () => {
render(<AllowedStat {...defaultProps} />);
expect(screen.getByTestId('allowedStat')).toHaveTextContent('3Allowed');
});
it('displays the correct tooltip content', async () => {
render(<AllowedStat {...defaultProps} />);
userEvent.hover(screen.getByTestId('allowedStat'));
await waitFor(() => {
expect(screen.getByText(i18n.ALLOWED_TOOLTIP(defaultProps))).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiStat, EuiToolTip } from '@elastic/eui';
import React, { useMemo } from 'react';
import { TITLE_SIZE } from '../constants';
import * as i18n from './translations';
interface Props {
allowed: number;
total: number;
}
const AllowedStatComponent: React.FC<Props> = ({ allowed, total }) => {
const tooltipContent = useMemo(() => i18n.ALLOWED_TOOLTIP({ allowed, total }), [allowed, total]);
return (
<EuiToolTip content={tooltipContent}>
<EuiStat
data-test-subj="allowedStat"
description={i18n.ALLOWED}
reverse
title={allowed}
titleSize={TITLE_SIZE}
/>
</EuiToolTip>
);
};
AllowedStatComponent.displayName = 'AllowedStatComponent';
export const AllowedStat = React.memo(AllowedStatComponent);

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const ALLOWED_TOOLTIP = ({ allowed, total }: { allowed: number; total: number }) =>
i18n.translate(
'xpack.elasticAssistant.dataAnonymizationEditor.stats.allowedStat.allowedTooltip',
{
values: { allowed, total },
defaultMessage:
'{allowed} of {total} fields in this context are allowed to be included in the conversation',
}
);
export const ALLOWED = i18n.translate(
'xpack.elasticAssistant.dataAnonymizationEditor.stats.allowedStat.allowedDescription',
{
defaultMessage: 'Allowed',
}
);

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getColor, getTooltipContent } from './helpers';
describe('helpers', () => {
describe('getColor', () => {
it('returns `default` when isDataAnonymizable is true', () => {
const result = getColor(true);
expect(result).toBe('default');
});
it('returns `subdued` when isDataAnonymizable is false', () => {
const result = getColor(false);
expect(result).toBe('subdued');
});
});
describe('getTooltipContent', () => {
it('informs the user that the context cannot be anonymized when isDataAnonymizable is false', () => {
const result = getTooltipContent({ anonymized: 0, isDataAnonymizable: false });
expect(result).toEqual('This context cannot be anonymized');
});
it('returns the expected message when the data is anonymizable, but no data has been anonymized', () => {
const result = getTooltipContent({ anonymized: 0, isDataAnonymizable: true });
expect(result).toEqual(
'Select fields to be replaced with random values. Responses are automatically translated back to the original values.'
);
});
it('returns the correct plural form of "field" when one field has been anonymized', () => {
const result = getTooltipContent({ anonymized: 1, isDataAnonymizable: true });
expect(result).toEqual(
'1 field in this context will be replaced with random values. Responses are automatically translated back to the original values.'
);
});
it('returns the correct plural form of "field" when more than one field has been anonymized', () => {
const result = getTooltipContent({ anonymized: 2, isDataAnonymizable: true });
expect(result).toEqual(
'2 fields in this context will be replaced with random values. Responses are automatically translated back to the original values.'
);
});
});
});

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as i18n from './translations';
export const getColor = (isDataAnonymizable: boolean): 'default' | 'subdued' =>
isDataAnonymizable ? 'default' : 'subdued';
export const getTooltipContent = ({
anonymized,
isDataAnonymizable,
}: {
anonymized: number;
isDataAnonymizable: boolean;
}): string =>
!isDataAnonymizable || anonymized === 0
? i18n.NONE_OF_THE_DATA_WILL_BE_ANONYMIZED(isDataAnonymizable)
: i18n.FIELDS_WILL_BE_ANONYMIZED(anonymized);

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiToolTip } from '@elastic/eui';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { getTooltipContent } from './helpers';
import * as i18n from './translations';
import { AnonymizedStat } from '.';
import { TestProviders } from '../../../mock/test_providers/test_providers';
const defaultProps = {
anonymized: 0,
isDataAnonymizable: false,
showIcon: false,
};
describe('AnonymizedStat', () => {
it('renders the expected content when the data is NOT anonymizable', () => {
render(
<TestProviders>
<AnonymizedStat {...defaultProps} />
</TestProviders>
);
expect(screen.getByTestId('anonymizedFieldsStat')).toHaveTextContent('0Anonymized');
});
it('shows the anonymization icon when showIcon is true', () => {
render(
<TestProviders>
<AnonymizedStat {...defaultProps} showIcon={true} />
</TestProviders>
);
expect(screen.getByTestId('anonymizationIcon')).toBeInTheDocument();
});
it('does NOT show the anonymization icon when showIcon is false', () => {
render(
<TestProviders>
<AnonymizedStat {...defaultProps} showIcon={false} />
</TestProviders>
);
expect(screen.queryByTestId('anonymizationIcon')).not.toBeInTheDocument();
});
it('shows the correct tooltip content when anonymized is 0 and isDataAnonymizable is false', async () => {
render(
<EuiToolTip content={getTooltipContent({ anonymized: 0, isDataAnonymizable: false })}>
<AnonymizedStat {...defaultProps} />
</EuiToolTip>
);
userEvent.hover(screen.getByTestId('anonymizedFieldsStat'));
await waitFor(() => {
expect(screen.getByText(i18n.NONE_OF_THE_DATA_WILL_BE_ANONYMIZED(false))).toBeInTheDocument();
});
});
it('shows correct tooltip content when anonymized is positive and isDataAnonymizable is true', async () => {
const anonymized = 3;
const isDataAnonymizable = true;
render(
<EuiToolTip content={getTooltipContent({ anonymized, isDataAnonymizable })}>
<AnonymizedStat {...defaultProps} anonymized={anonymized} />
</EuiToolTip>
);
userEvent.hover(screen.getByTestId('anonymizedFieldsStat'));
await waitFor(() => {
expect(screen.getByText(i18n.FIELDS_WILL_BE_ANONYMIZED(anonymized))).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiStat, EuiText, EuiToolTip } from '@elastic/eui';
import React, { useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { getColor, getTooltipContent } from './helpers';
import { TITLE_SIZE } from '../constants';
import * as i18n from './translations';
const ANONYMIZATION_ICON = 'eyeClosed';
const AnonymizationIconFlexItem = styled(EuiFlexItem)`
margin-right: ${({ theme }) => theme.eui.euiSizeS};
`;
interface Props {
anonymized: number;
isDataAnonymizable: boolean;
showIcon?: boolean;
}
const AnonymizedStatComponent: React.FC<Props> = ({
anonymized,
isDataAnonymizable,
showIcon = false,
}) => {
const color = useMemo(() => getColor(isDataAnonymizable), [isDataAnonymizable]);
const tooltipContent = useMemo(
() => getTooltipContent({ anonymized, isDataAnonymizable }),
[anonymized, isDataAnonymizable]
);
const description = useMemo(
() => (
<EuiFlexGroup alignItems="center" gutterSize="none">
{showIcon && (
<AnonymizationIconFlexItem grow={false}>
<EuiIcon
color={color}
data-test-subj="anonymizationIcon"
size="m"
type={ANONYMIZATION_ICON}
/>
</AnonymizationIconFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiText color={color} data-test-subj="description" size="s">
{i18n.ANONYMIZED_FIELDS}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
),
[color, showIcon]
);
return (
<EuiToolTip content={tooltipContent}>
<EuiStat
data-test-subj="anonymizedFieldsStat"
description={description}
reverse
titleColor={color}
title={anonymized}
titleSize={TITLE_SIZE}
/>
</EuiToolTip>
);
};
AnonymizedStatComponent.displayName = 'AnonymizedStatComponent';
export const AnonymizedStat = React.memo(AnonymizedStatComponent);

View file

@ -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 { i18n } from '@kbn/i18n';
export const ANONYMIZED_FIELDS = i18n.translate(
'xpack.elasticAssistant.dataAnonymizationEditor.stats.anonymizedStat.anonymizeFieldsdDescription',
{
defaultMessage: 'Anonymized',
}
);
export const FIELDS_WILL_BE_ANONYMIZED = (anonymized: number) =>
i18n.translate(
'xpack.elasticAssistant.dataAnonymizationEditor.stats.anonymizedStat.fieldsWillBeAnonymizedTooltip',
{
values: { anonymized },
defaultMessage:
'{anonymized} {anonymized, plural, =1 {field} other {fields}} in this context will be replaced with random values. Responses are automatically translated back to the original values.',
}
);
export const NONE_OF_THE_DATA_WILL_BE_ANONYMIZED = (isDataAnonymizable: boolean) =>
i18n.translate(
'xpack.elasticAssistant.dataAnonymizationEditor.stats.anonymizedStat.noneOfTheDataWillBeAnonymizedTooltip',
{
values: { isDataAnonymizable },
defaultMessage:
'{isDataAnonymizable, select, true {Select fields to be replaced with random values. Responses are automatically translated back to the original values.} other {This context cannot be anonymized}}',
}
);

View file

@ -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 { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { AvailableStat } from '.';
import * as i18n from './translations';
describe('AvailableStat component', () => {
const total = 5;
it('renders the expected stat content', () => {
render(<AvailableStat total={total} />);
expect(screen.getByTestId('availableStat')).toHaveTextContent(`${total}Available`);
});
it('displays the tooltip with the correct content', async () => {
render(<AvailableStat total={total} />);
userEvent.hover(screen.getByTestId('availableStat'));
await waitFor(() => {
const tooltipContent = i18n.AVAILABLE_TOOLTIP(total);
expect(screen.getByText(tooltipContent)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiStat, EuiToolTip } from '@elastic/eui';
import React, { useMemo } from 'react';
import { TITLE_SIZE } from '../constants';
import * as i18n from './translations';
interface Props {
total: number;
}
const AvailableStatComponent: React.FC<Props> = ({ total }) => {
const tooltipContent = useMemo(() => i18n.AVAILABLE_TOOLTIP(total), [total]);
return (
<EuiToolTip content={tooltipContent}>
<EuiStat
data-test-subj="availableStat"
description={i18n.AVAILABLE}
reverse
title={total}
titleSize={TITLE_SIZE}
/>
</EuiToolTip>
);
};
AvailableStatComponent.displayName = 'AvailableStatComponent';
export const AvailableStat = React.memo(AvailableStatComponent);

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const AVAILABLE_TOOLTIP = (total: number) =>
i18n.translate(
'xpack.elasticAssistant.dataAnonymizationEditor.stats.availableStat.availableTooltip',
{
values: { total },
defaultMessage:
'{total} fields in this context are available to be included in the conversation',
}
);
export const AVAILABLE = i18n.translate(
'xpack.elasticAssistant.dataAnonymizationEditor.stats.availableStat.availableDescription',
{
defaultMessage: 'Available',
}
);

View file

@ -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 TITLE_SIZE = 'xs';

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { SelectedPromptContext } from '../../assistant/prompt_context/types';
import { TestProviders } from '../../mock/test_providers/test_providers';
import { Stats } from '.';
describe('Stats', () => {
const selectedPromptContext: SelectedPromptContext = {
allow: ['field1', 'field2'],
allowReplacement: ['field1'],
promptContextId: 'abcd',
rawData: {
field1: ['value1', 'value2'],
field2: ['value3, value4', 'value5'],
field3: ['value6'],
},
};
it('renders the expected allowed stat content', () => {
render(
<TestProviders>
<Stats isDataAnonymizable={true} selectedPromptContext={selectedPromptContext} />
</TestProviders>
);
expect(screen.getByTestId('allowedStat')).toHaveTextContent('2Allowed');
});
it('renders the expected anonymized stat content', () => {
render(
<TestProviders>
<Stats isDataAnonymizable={true} selectedPromptContext={selectedPromptContext} />
</TestProviders>
);
expect(screen.getByTestId('anonymizedFieldsStat')).toHaveTextContent('1Anonymized');
});
it('renders the expected available stat content', () => {
render(
<TestProviders>
<Stats isDataAnonymizable={true} selectedPromptContext={selectedPromptContext} />
</TestProviders>
);
expect(screen.getByTestId('availableStat')).toHaveTextContent('3Available');
});
it('should not display the allowed stat when isDataAnonymizable is false', () => {
render(
<TestProviders>
<Stats isDataAnonymizable={false} selectedPromptContext={selectedPromptContext} />
</TestProviders>
);
expect(screen.queryByTestId('allowedStat')).not.toBeInTheDocument();
});
it('should not display the available stat when isDataAnonymizable is false', () => {
render(
<TestProviders>
<Stats isDataAnonymizable={false} selectedPromptContext={selectedPromptContext} />
</TestProviders>
);
expect(screen.queryByTestId('availableStat')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useMemo } from 'react';
// eslint-disable-next-line @kbn/eslint/module_migration
import styled from 'styled-components';
import { AllowedStat } from './allowed_stat';
import { AnonymizedStat } from './anonymized_stat';
import type { SelectedPromptContext } from '../../assistant/prompt_context/types';
import { getStats } from '../get_stats';
import { AvailableStat } from './available_stat';
const StatFlexItem = styled(EuiFlexItem)`
margin-right: ${({ theme }) => theme.eui.euiSizeL};
`;
interface Props {
isDataAnonymizable: boolean;
selectedPromptContext: SelectedPromptContext;
}
const StatsComponent: React.FC<Props> = ({ isDataAnonymizable, selectedPromptContext }) => {
const { allowed, anonymized, total } = useMemo(
() => getStats(selectedPromptContext),
[selectedPromptContext]
);
return (
<EuiFlexGroup alignItems="center" data-test-subj="stats" gutterSize="none">
{isDataAnonymizable && (
<StatFlexItem grow={false}>
<AllowedStat allowed={allowed} total={total} />
</StatFlexItem>
)}
<StatFlexItem grow={false}>
<AnonymizedStat anonymized={anonymized} isDataAnonymizable={isDataAnonymizable} />
</StatFlexItem>
{isDataAnonymizable && (
<StatFlexItem grow={false}>
<AvailableStat total={total} />
</StatFlexItem>
)}
</EuiFlexGroup>
);
};
StatsComponent.displayName = 'StatsComponent';
export const Stats = React.memo(StatsComponent);

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/** This mock returns the reverse of `value` */
export const mockGetAnonymizedValue = ({
currentReplacements,
rawValue,
}: {
currentReplacements: Record<string, string> | undefined;
rawValue: string;
}): string => rawValue.split('').reverse().join('');

View file

@ -34,9 +34,15 @@ export const TestProvidersComponent: React.FC<Props> = ({ children }) => {
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={jest.fn()}
baseAllow={[]}
baseAllowReplacement={[]}
defaultAllow={[]}
defaultAllowReplacement={[]}
getComments={mockGetComments}
getInitialConversations={mockGetInitialConversations}
setConversations={jest.fn()}
setDefaultAllow={jest.fn()}
setDefaultAllowReplacement={jest.fn()}
http={mockHttp}
>
{children}

View file

@ -35,9 +35,15 @@ export const TestProvidersComponent: React.FC<Props> = ({ children }) => {
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={jest.fn()}
baseAllow={[]}
baseAllowReplacement={[]}
defaultAllow={[]}
defaultAllowReplacement={[]}
getComments={mockGetComments}
getInitialConversations={mockGetInitialConversations}
setConversations={jest.fn()}
setDefaultAllow={jest.fn()}
setDefaultAllowReplacement={jest.fn()}
http={mockHttp}
>
<DataQualityProvider httpFetch={http.fetch}>{children}</DataQualityProvider>

View file

@ -82,7 +82,7 @@ export const allowedExperimentalValues = Object.freeze({
securityFlyoutEnabled: false,
/**
* Enables the Elastic Security Assistant
* Enables the Elastic AI Assistant
*/
assistantEnabled: false,

View file

@ -34,9 +34,11 @@ import type { StartServices } from '../types';
import { PageRouter } from './routes';
import { UserPrivilegesProvider } from '../common/components/user_privileges/user_privileges_context';
import { ReactQueryClientProvider } from '../common/containers/query_client/query_client_provider';
import { DEFAULT_ALLOW, DEFAULT_ALLOW_REPLACEMENT } from '../assistant/content/anonymization';
import { PROMPT_CONTEXTS } from '../assistant/content/prompt_contexts';
import { BASE_SECURITY_QUICK_PROMPTS } from '../assistant/content/quick_prompts';
import { BASE_SECURITY_SYSTEM_PROMPTS } from '../assistant/content/prompts/system';
import { useAnonymizationStore } from '../assistant/use_anonymization_store';
interface StartAppComponent {
children: React.ReactNode;
@ -64,6 +66,9 @@ const StartAppComponent: FC<StartAppComponent> = ({
} = useKibana().services;
const { conversations, setConversations } = useConversationStore();
const { defaultAllow, defaultAllowReplacement, setDefaultAllow, setDefaultAllowReplacement } =
useAnonymizationStore();
const getInitialConversation = useCallback(() => {
return conversations;
}, [conversations]);
@ -81,6 +86,10 @@ const StartAppComponent: FC<StartAppComponent> = ({
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={augmentMessageCodeBlocks}
defaultAllow={defaultAllow}
defaultAllowReplacement={defaultAllowReplacement}
baseAllow={DEFAULT_ALLOW}
baseAllowReplacement={DEFAULT_ALLOW_REPLACEMENT}
basePromptContexts={Object.values(PROMPT_CONTEXTS)}
baseQuickPrompts={BASE_SECURITY_QUICK_PROMPTS}
baseSystemPrompts={BASE_SECURITY_SYSTEM_PROMPTS}
@ -89,6 +98,8 @@ const StartAppComponent: FC<StartAppComponent> = ({
http={http}
nameSpace={nameSpace}
setConversations={setConversations}
setDefaultAllow={setDefaultAllow}
setDefaultAllowReplacement={setDefaultAllowReplacement}
title={ASSISTANT_TITLE}
>
<MlCapabilitiesProvider>

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
export const ASSISTANT_TITLE = i18n.translate('xpack.securitySolution.assistant.title', {
defaultMessage: 'Elastic Security Assistant',
defaultMessage: 'Elastic AI Assistant',
});
export const OVERVIEW = i18n.translate('xpack.securitySolution.navigation.overview', {

View file

@ -4,7 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonIcon, EuiCopy, EuiToolTip } from '@elastic/eui';
import { EuiButtonIcon, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { CommentType } from '@kbn/cases-plugin/common';
import type { Message } from '@kbn/elastic-assistant';
import React, { useCallback } from 'react';
@ -61,45 +62,51 @@ const CommentActionsComponent: React.FC<Props> = ({ message }) => {
{
comment: message.content,
type: CommentType.user,
owner: i18n.ELASTIC_SECURITY_ASSISTANT,
owner: i18n.ELASTIC_AI_ASSISTANT,
},
],
});
}, [message.content, selectCaseModal]);
return (
<>
<EuiToolTip position="top" content={i18n.ADD_NOTE_TO_TIMELINE}>
<EuiButtonIcon
aria-label={i18n.ADD_MESSAGE_CONTENT_AS_TIMELINE_NOTE}
color="primary"
iconType="editorComment"
onClick={onAddNoteToTimeline}
/>
</EuiToolTip>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiToolTip position="top" content={i18n.ADD_NOTE_TO_TIMELINE}>
<EuiButtonIcon
aria-label={i18n.ADD_MESSAGE_CONTENT_AS_TIMELINE_NOTE}
color="primary"
iconType="editorComment"
onClick={onAddNoteToTimeline}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiToolTip position="top" content={i18n.ADD_TO_CASE_EXISTING_CASE}>
<EuiButtonIcon
aria-label={i18n.ADD_TO_CASE_EXISTING_CASE}
color="primary"
iconType="addDataApp"
onClick={onAddToExistingCase}
/>
</EuiToolTip>
<EuiFlexItem grow={false}>
<EuiToolTip position="top" content={i18n.ADD_TO_CASE_EXISTING_CASE}>
<EuiButtonIcon
aria-label={i18n.ADD_TO_CASE_EXISTING_CASE}
color="primary"
iconType="addDataApp"
onClick={onAddToExistingCase}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiToolTip position="top" content={i18n.COPY_TO_CLIPBOARD}>
<EuiCopy textToCopy={message.content}>
{(copy) => (
<EuiButtonIcon
aria-label={i18n.COPY_TO_CLIPBOARD}
color="primary"
iconType="copyClipboard"
onClick={copy}
/>
)}
</EuiCopy>
</EuiToolTip>
</>
<EuiFlexItem grow={false}>
<EuiToolTip position="top" content={i18n.COPY_TO_CLIPBOARD}>
<EuiCopy textToCopy={message.content}>
{(copy) => (
<EuiButtonIcon
aria-label={i18n.COPY_TO_CLIPBOARD}
color="primary"
iconType="copyClipboard"
onClick={copy}
/>
)}
</EuiCopy>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -35,10 +35,10 @@ export const ADD_TO_CASE_EXISTING_CASE = i18n.translate(
}
);
export const ELASTIC_SECURITY_ASSISTANT = i18n.translate(
'xpack.securitySolution.assistant.commentActions.elasticSecurityAssistantTitle',
export const ELASTIC_AI_ASSISTANT = i18n.translate(
'xpack.securitySolution.assistant.commentActions.elasticAiAssistantTitle',
{
defaultMessage: 'Elastic Security Assistant',
defaultMessage: 'Elastic AI Assistant',
}
);

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/** By default, these fields are allowed to be sent to the assistant */
export const DEFAULT_ALLOW = [
'@timestamp',
'cloud.availability_zone',
'cloud.provider',
'cloud.region',
'destination.ip',
'dns.question.name',
'dns.question.type',
'event.action',
'event.category',
'event.dataset',
'event.module',
'event.outcome',
'event.type',
'file.Ext.original.path',
'file.hash.sha256',
'file.name',
'file.path',
'host.name',
'kibana.alert.rule.name',
'network.protocol',
'process.args',
'process.exit_code',
'process.hash.md5',
'process.hash.sha1',
'process.hash.sha256',
'process.parent.name',
'process.parent.pid',
'process.name',
'process.pid',
'source.ip',
'user.domain',
'user.name',
];
/** By default, these fields will be anonymized */
export const DEFAULT_ALLOW_REPLACEMENT = [
'cloud.availability_zone',
'cloud.provider',
'cloud.region',
'destination.ip',
'file.Ext.original.path',
'file.name',
'file.path',
'host.ip', // not a default allow field, but anonymized by default
'host.name',
'source.ip',
'user.domain',
'user.name',
];

View file

@ -6,7 +6,7 @@
*/
import {
ELASTIC_SECURITY_ASSISTANT_TITLE,
ELASTIC_AI_ASSISTANT_TITLE,
WELCOME_CONVERSATION_TITLE,
} from '@kbn/elastic-assistant/impl/assistant/use_conversation/translations';
import type { Conversation } from '@kbn/elastic-assistant';
@ -21,7 +21,7 @@ import {
ALERT_SUMMARY_CONVERSATION_ID,
EVENT_SUMMARY_CONVERSATION_ID,
} from '../../../common/components/event_details/translations';
import { ELASTIC_SECURITY_ASSISTANT } from '../../comment_actions/translations';
import { ELASTIC_AI_ASSISTANT } from '../../comment_actions/translations';
import { TIMELINE_CONVERSATION_TITLE } from './translations';
export const BASE_SECURITY_CONVERSATIONS: Record<string, Conversation> = {
@ -59,10 +59,10 @@ export const BASE_SECURITY_CONVERSATIONS: Record<string, Conversation> = {
id: WELCOME_CONVERSATION_TITLE,
isDefault: true,
theme: {
title: ELASTIC_SECURITY_ASSISTANT_TITLE,
title: ELASTIC_AI_ASSISTANT_TITLE,
titleIcon: 'logoSecurity',
assistant: {
name: ELASTIC_SECURITY_ASSISTANT,
name: ELASTIC_AI_ASSISTANT,
icon: 'logoSecurity',
},
system: {

View file

@ -11,7 +11,7 @@ export const YOU_ARE_A_HELPFUL_EXPERT_ASSISTANT = i18n.translate(
'xpack.securitySolution.assistant.content.prompts.system.youAreAHelpfulExpertAssistant',
{
defaultMessage:
'You are a helpful, expert assistant who only answers questions about Elastic Security.',
'You are a helpful, expert assistant who answers questions about Elastic Security.',
}
);

View file

@ -16,23 +16,41 @@ import * as i18n from './translations';
export const getComments = ({
currentConversation,
lastCommentRef,
showAnonymizedValues,
}: {
currentConversation: Conversation;
lastCommentRef: React.MutableRefObject<HTMLDivElement | null>;
showAnonymizedValues: boolean;
}): EuiCommentProps[] =>
currentConversation.messages.map((message, index) => {
const isUser = message.role === 'user';
const replacements = currentConversation.replacements;
const messageContentWithReplacements =
replacements != null
? Object.keys(replacements).reduce(
(acc, replacement) => acc.replaceAll(replacement, replacements[replacement]),
message.content
)
: message.content;
const transformedMessage = {
...message,
content: messageContentWithReplacements,
};
return {
actions: <CommentActions message={message} />,
actions: <CommentActions message={transformedMessage} />,
children:
index !== currentConversation.messages.length - 1 ? (
<EuiText>
<EuiMarkdownFormat className={`message-${index}`}>{message.content}</EuiMarkdownFormat>
<EuiMarkdownFormat className={`message-${index}`}>
{showAnonymizedValues ? message.content : transformedMessage.content}
</EuiMarkdownFormat>
</EuiText>
) : (
<EuiText>
<EuiMarkdownFormat className={`message-${index}`}>{message.content}</EuiMarkdownFormat>
<EuiMarkdownFormat className={`message-${index}`}>
{showAnonymizedValues ? message.content : transformedMessage.content}
</EuiMarkdownFormat>
<span ref={lastCommentRef} />
</EuiText>
),

View file

@ -34,6 +34,11 @@ export const getAllFields = (data: TimelineEventsDetailsItem[]): QueryField[] =>
.filter(({ field }) => !field.startsWith('signal.'))
.map(({ field, values }) => ({ field, values: values?.join(',') ?? '' }));
export const getRawData = (data: TimelineEventsDetailsItem[]): Record<string, string[]> =>
data
.filter(({ field }) => !field.startsWith('signal.'))
.reduce((acc, { field, values }) => ({ ...acc, [field]: values ?? [] }), {});
export const getFieldsAsCsv = (queryFields: QueryField[]): string =>
queryFields.map(({ field, values }) => `${field},${values}`).join('\n');

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useLocalStorage } from '../../common/components/local_storage';
import { DEFAULT_ALLOW, DEFAULT_ALLOW_REPLACEMENT } from '../content/anonymization';
import { LOCAL_STORAGE_KEY } from '../helpers';
export interface UseAnonymizationStore {
defaultAllow: string[];
defaultAllowReplacement: string[];
setDefaultAllow: React.Dispatch<React.SetStateAction<string[]>>;
setDefaultAllowReplacement: React.Dispatch<React.SetStateAction<string[]>>;
}
const DEFAULT_ALLOW_KEY = `${LOCAL_STORAGE_KEY}.defaultAllow`;
const DEFAULT_ALLOW_REPLACEMENT_KEY = `${LOCAL_STORAGE_KEY}.defaultAllowReplacement`;
export const useAnonymizationStore = (): UseAnonymizationStore => {
const [defaultAllow, setDefaultAllow] = useLocalStorage<string[]>({
defaultValue: DEFAULT_ALLOW,
key: DEFAULT_ALLOW_KEY,
isInvalidDefault: (valueFromStorage) => !Array.isArray(valueFromStorage),
});
const [defaultAllowReplacement, setDefaultAllowReplacement] = useLocalStorage<string[]>({
defaultValue: DEFAULT_ALLOW_REPLACEMENT,
key: DEFAULT_ALLOW_REPLACEMENT_KEY,
isInvalidDefault: (valueFromStorage) => !Array.isArray(valueFromStorage),
});
return {
defaultAllow,
defaultAllowReplacement,
setDefaultAllow,
setDefaultAllowReplacement,
};
};

View file

@ -69,9 +69,15 @@ export const StorybookProviders: React.FC = ({ children }) => {
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={jest.fn()}
baseAllow={[]}
baseAllowReplacement={[]}
defaultAllow={[]}
defaultAllowReplacement={[]}
getComments={mockGetComments}
getInitialConversations={mockGetInitialConversations}
setConversations={jest.fn()}
setDefaultAllow={jest.fn()}
setDefaultAllowReplacement={jest.fn()}
http={mockHttp}
>
{children}

View file

@ -77,9 +77,15 @@ export const TestProvidersComponent: React.FC<Props> = ({
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={jest.fn()}
baseAllow={[]}
baseAllowReplacement={[]}
defaultAllow={[]}
defaultAllowReplacement={[]}
getComments={mockGetComments}
getInitialConversations={mockGetInitialConversations}
setConversations={jest.fn()}
setDefaultAllow={jest.fn()}
setDefaultAllowReplacement={jest.fn()}
http={mockHttp}
>
<QueryClientProvider client={queryClient}>
@ -124,9 +130,15 @@ const TestProvidersWithPrivilegesComponent: React.FC<Props> = ({
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={jest.fn()}
baseAllow={[]}
baseAllowReplacement={[]}
defaultAllow={[]}
defaultAllowReplacement={[]}
getComments={mockGetComments}
getInitialConversations={mockGetInitialConversations}
setConversations={jest.fn()}
setDefaultAllow={jest.fn()}
setDefaultAllowReplacement={jest.fn()}
http={mockHttp}
>
<UserPrivilegesProvider

View file

@ -42,9 +42,15 @@ const renderHeader = (contextValue: RightPanelContext) =>
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={jest.fn()}
baseAllow={[]}
baseAllowReplacement={[]}
defaultAllow={[]}
defaultAllowReplacement={[]}
getComments={mockGetComments}
getInitialConversations={mockGetInitialConversations}
setConversations={jest.fn()}
setDefaultAllow={jest.fn()}
setDefaultAllowReplacement={jest.fn()}
http={mockHttp}
>
<RightPanelContext.Provider value={contextValue}>

View file

@ -12,7 +12,7 @@ import React, { useCallback, useMemo } from 'react';
import deepEqual from 'fast-deep-equal';
import type { EntityType } from '@kbn/timelines-plugin/common';
import { getPromptContextFromEventDetailsItem } from '../../../../assistant/helpers';
import { getRawData } from '../../../../assistant/helpers';
import type { BrowserFields } from '../../../../common/containers/source';
import { ExpandableEvent, ExpandableEventTitle } from './expandable_event';
import { useTimelineEventsDetails } from '../../../containers/details';
@ -102,10 +102,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
const view = useMemo(() => (isFlyoutView ? SUMMARY_VIEW : TIMELINE_VIEW), [isFlyoutView]);
const getPromptContext = useCallback(
async () => getPromptContextFromEventDetailsItem(detailsData ?? []),
[detailsData]
);
const getPromptContext = useCallback(async () => getRawData(detailsData ?? []), [detailsData]);
const { promptContextId } = useAssistant(
isAlert ? 'alert' : 'event',