[Attack Discovery][Scheduling] UI: Schedule details and editing flows (#12006) (#218572)

## Summary

Main ticket ([Internal
link](https://github.com/elastic/security-team/issues/12006))

These changes add Schedule Details and Editing workflows allowing users
to see schedule information in a separate flyout and/or update the
schedule parameters within it.

## NOTES

The feature is hidden behind the feature flag (in `kibana.dev.yml`):

```
feature_flags.overrides:
  securitySolution.assistantAttackDiscoverySchedulingEnabled: true
```

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ievgen Sorokopud 2025-04-17 21:19:22 +02:00 committed by GitHub
parent 906c8978e7
commit 85093e5de7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2611 additions and 467 deletions

View file

@ -19,6 +19,21 @@ import { z } from '@kbn/zod';
import { ApiConfig } from '../conversations/common_attributes.gen';
import { NonEmptyString } from '../common_attributes.gen';
/**
* An query condition to filter alerts
*/
export type Query = z.infer<typeof Query>;
export const Query = z.object({
query: z.union([z.string(), z.object({}).catchall(z.unknown())]),
language: z.string(),
});
/**
* The filter array used to define the conditions for when alerts are selected as an attack discovery context. Defaults to an empty array.
*/
export type Filters = z.infer<typeof Filters>;
export const Filters = z.array(z.unknown());
/**
* An attack discovery schedule params
*/
@ -40,7 +55,9 @@ export const AttackDiscoveryScheduleParams = z.object({
})
),
end: z.string().optional(),
filter: z.object({}).catchall(z.unknown()).optional(),
query: Query.optional(),
filters: Filters.optional(),
combinedFilter: z.object({}).catchall(z.unknown()).optional(),
size: z.number(),
start: z.string().optional(),
});

View file

@ -83,7 +83,11 @@ components:
type: string
end:
type: string
filter:
query:
$ref: '#/components/schemas/Query'
filters:
$ref: '#/components/schemas/Filters'
combinedFilter:
type: object
additionalProperties: true
size:
@ -91,6 +95,26 @@ components:
start:
type: string
Query:
type: object
description: An query condition to filter alerts
required:
- query
- language
properties:
query:
oneOf:
- type: string
- type: object
additionalProperties: true # { [key: string]: any }
language:
type: string
Filters:
description: The filter array used to define the conditions for when alerts are selected as an attack discovery context. Defaults to an empty array.
type: array
items: {} # unknown
IntervalSchedule:
type: object
required:

View file

@ -105,6 +105,7 @@ export {
START_LOCAL_STORAGE_KEY,
} from './impl/assistant_context/constants';
export type { AIConnector } from './impl/connectorland/connector_selector';
export { useLoadConnectors } from './impl/connectorland/use_load_connectors';
export type {

View file

@ -36,12 +36,52 @@ describe('attackDiscoveryScheduleExecutor', () => {
const actionsClient = actionsClientMock.create();
const spaceId = 'test-space';
const params = {
alertsIndexPattern: 'test-index-*',
apiConfig: {
connectorId: 'test-connector',
actionTypeId: 'testing',
model: 'model-1',
name: 'Test Connector',
},
query: 'host.name : *',
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
index: '530a0bfd-8996-441a-b788-c8bba251bdf3',
key: '@timestamp',
field: '@timestamp',
value: 'exists',
type: 'exists',
},
query: {
exists: {
field: '@timestamp',
},
},
$state: {
store: 'appState',
},
},
],
combinedFilter: {
bool: {
must: [],
filter: [
{
exists: {
field: '@timestamp',
},
},
],
should: [],
must_not: [],
},
},
size: '123',
start: 'now-24h',
};
const executorOptions = {
params,
@ -125,9 +165,10 @@ describe('attackDiscoveryScheduleExecutor', () => {
},
];
const { query, filters, combinedFilter, ...restParams } = params;
expect(generateAttackDiscoveries).toHaveBeenCalledWith({
actionsClient,
config: { ...params, anonymizationFields, subAction: 'invokeAI' },
config: { ...restParams, filter: combinedFilter, anonymizationFields, subAction: 'invokeAI' },
esClient: services.scopedClusterClient.asCurrentUser,
logger: mockLogger,
savedObjectsClient: services.savedObjectsClient,

View file

@ -70,9 +70,16 @@ export const attackDiscoveryScheduleExecutor = async ({
});
const anonymizationFields = transformESSearchToAnonymizationFields(result.data);
const { query, filters, combinedFilter, ...restParams } = params;
const { anonymizedAlerts, attackDiscoveries, replacements } = await generateAttackDiscoveries({
actionsClient,
config: { ...params, anonymizationFields, subAction: 'invokeAI' },
config: {
...restParams,
filter: combinedFilter,
anonymizationFields,
subAction: 'invokeAI',
},
esClient,
logger,
savedObjectsClient,

View file

@ -20,10 +20,12 @@ import {
} from '@kbn/elastic-assistant-common';
import {
ALERTING_RULE_TYPES_URL,
createAttackDiscoverySchedule,
deleteAttackDiscoverySchedule,
disableAttackDiscoverySchedule,
enableAttackDiscoverySchedule,
fetchRuleTypes,
findAttackDiscoverySchedule,
getAttackDiscoverySchedule,
updateAttackDiscoverySchedule,
@ -113,4 +115,10 @@ describe('Schedule API', () => {
query: { ...params },
});
});
it('should send a fetch rule types GET request', async () => {
await fetchRuleTypes();
expect(mockKibanaServices().http.get).toHaveBeenCalledWith(ALERTING_RULE_TYPES_URL, {});
});
});

View file

@ -7,6 +7,9 @@
import { replaceParams } from '@kbn/openapi-common/shared';
import type { AsApiContract } from '@kbn/actions-types';
import { BASE_ALERTING_API_PATH } from '@kbn/alerting-plugin/common';
import type { RuleType } from '@kbn/triggers-actions-ui-types';
import type {
AttackDiscoveryScheduleCreateProps,
AttackDiscoveryScheduleUpdateProps,
@ -27,6 +30,8 @@ import {
} from '@kbn/elastic-assistant-common';
import { KibanaServices } from '../../../../../common/lib/kibana';
export const ALERTING_RULE_TYPES_URL = `${BASE_ALERTING_API_PATH}/rule_types` as const;
export interface CreateAttackDiscoveryScheduleParams {
/** The body containing the schedule attributes */
body: AttackDiscoveryScheduleCreateProps;
@ -157,3 +162,14 @@ export const findAttackDiscoverySchedule = async ({
{ version: '1', query: { page, perPage, sortField, sortDirection }, signal }
);
};
/** Retrieves registered rule types. */
export const fetchRuleTypes = async (params?: {
signal?: AbortSignal;
}): Promise<Array<AsApiContract<RuleType<string, string>>>> => {
const { signal } = params ?? {};
return KibanaServices.get().http.get<Array<AsApiContract<RuleType<string, string>>>>(
ALERTING_RULE_TYPES_URL,
{ signal }
);
};

View file

@ -9,8 +9,8 @@ import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { StatusBadge } from '.';
import { TestProviders } from '../../../../../../../common/mock';
import { mockAttackDiscoverySchedule } from '../../../../../mock/mock_attack_discovery_schedule';
import { TestProviders } from '../../../../../../common/mock';
import { mockAttackDiscoverySchedule } from '../../../../mock/mock_attack_discovery_schedule';
import { waitForEuiToolTipVisible } from '@elastic/eui/lib/test/rtl';
const renderScheduleStatus = (

View file

@ -7,51 +7,20 @@
import React from 'react';
import type { EuiThemeComputed } from '@elastic/eui';
import { EuiHealth, EuiToolTip, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
import type {
AttackDiscoverySchedule,
AttackDiscoveryScheduleExecutionStatus,
} from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import {
getExecutionStatusHealthColor,
getExecutionStatusLabel,
} from '../../utils/execution_status';
const statusTextWrapperClassName = css`
width: 100%;
display: inline-grid;
`;
const getExecutionStatusHealthColor = (
status: AttackDiscoveryScheduleExecutionStatus,
euiTheme: EuiThemeComputed
) => {
switch (status) {
case 'active':
case 'ok':
return euiTheme.colors.success;
case 'error':
return euiTheme.colors.danger;
case 'warning':
return euiTheme.colors.warning;
default:
return 'subdued';
}
};
const getExecutionStatusLabel = (status: AttackDiscoveryScheduleExecutionStatus) => {
switch (status) {
case 'active':
case 'ok':
return i18n.STATUS_SUCCESS;
case 'error':
return i18n.STATUS_FAILED;
case 'warning':
return i18n.STATUS_WARNING;
default:
return i18n.STATUS_UNKNOWN;
}
};
interface StatusBadgeProps {
schedule: AttackDiscoverySchedule;
}

View file

@ -6,8 +6,9 @@
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import { useLoadConnectors } from '@kbn/elastic-assistant/impl/connectorland/use_load_connectors';
import { CreateFlyout } from '.';
import * as i18n from './translations';
@ -15,7 +16,10 @@ import * as i18n from './translations';
import { useKibana } from '../../../../../common/lib/kibana';
import { TestProviders } from '../../../../../common/mock';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { useCreateAttackDiscoverySchedule } from '../logic/use_create_schedule';
jest.mock('@kbn/elastic-assistant/impl/connectorland/use_load_connectors');
jest.mock('../logic/use_create_schedule');
jest.mock('../../../../../common/lib/kibana');
jest.mock('../../../../../sourcerer/containers');
jest.mock('react-router-dom', () => ({
@ -26,21 +30,48 @@ jest.mock('react-router-dom', () => ({
withRouter: jest.fn(),
}));
const mockConnectors: unknown[] = [
{
id: 'test-id',
name: 'OpenAI connector',
actionTypeId: '.gen-ai',
config: {
apiProvider: 'OpenAI',
},
},
];
const mockUseKibana = useKibana as jest.MockedFunction<typeof useKibana>;
const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction<
typeof useSourcererDataView
>;
const getBooleanValueMock = jest.fn();
const defaultProps = {
onClose: jest.fn(),
};
const renderComponent = async () => {
await act(() => {
render(
<TestProviders>
<CreateFlyout {...defaultProps} />
</TestProviders>
);
});
};
describe('CreateFlyout', () => {
beforeEach(() => {
jest.clearAllMocks();
getBooleanValueMock.mockReturnValue(true);
mockUseKibana.mockReturnValue({
services: {
featureFlags: {
getBooleanValue: getBooleanValueMock,
},
lens: {
EmbeddableComponent: () => <div data-test-subj="mockEmbeddableComponent" />,
},
@ -63,51 +94,91 @@ describe('CreateFlyout', () => {
loading: false,
} as unknown as jest.Mocked<ReturnType<typeof useSourcererDataView>>);
render(
<TestProviders>
<CreateFlyout {...defaultProps} />
</TestProviders>
);
(useLoadConnectors as jest.Mock).mockReturnValue({
isLoading: false,
data: mockConnectors,
});
(useCreateAttackDiscoverySchedule as jest.Mock).mockReturnValue({
isLoading: false,
mutateAsync: jest.fn(),
});
});
it('should render the flyout title', () => {
expect(screen.getAllByTestId('title')[0]).toHaveTextContent(i18n.SCHEDULE_CREATE_TITLE);
it('should render the flyout title', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getAllByTestId('title')[0]).toHaveTextContent(i18n.SCHEDULE_CREATE_TITLE);
});
});
it('should invoke onClose when the close button is clicked', async () => {
const closeButton = screen.getByTestId('euiFlyoutCloseButton');
fireEvent.click(closeButton);
await renderComponent();
expect(defaultProps.onClose).toHaveBeenCalled();
const closeButton = screen.getByTestId('euiFlyoutCloseButton');
act(() => {
fireEvent.click(closeButton);
});
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
describe('schedule form', () => {
it('should render schedule form', () => {
expect(screen.getByTestId('attackDiscoveryScheduleForm')).toBeInTheDocument();
it('should render schedule form', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryScheduleForm')).toBeInTheDocument();
});
});
it('should render schedule name field component', () => {
expect(screen.getByTestId('attackDiscoveryFormNameField')).toBeInTheDocument();
it('should render schedule name field component', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryFormNameField')).toBeInTheDocument();
});
});
it('should render connector selector component', () => {
expect(screen.getByTestId('attackDiscoveryConnectorSelectorField')).toBeInTheDocument();
it('should render connector selector component', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryConnectorSelectorField')).toBeInTheDocument();
});
});
it('should render `alertSelection` component', () => {
expect(screen.getByTestId('alertSelection')).toBeInTheDocument();
it('should render `alertSelection` component', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('alertSelection')).toBeInTheDocument();
});
});
it('should render schedule (`run every`) component', () => {
expect(screen.getByTestId('attackDiscoveryScheduleField')).toBeInTheDocument();
it('should render schedule (`run every`) component', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryScheduleField')).toBeInTheDocument();
});
});
it('should render actions component', () => {
expect(screen.getByText('Select a connector type')).toBeInTheDocument();
it('should render actions component', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByText('Select a connector type')).toBeInTheDocument();
});
});
it('should render "Create and enable" button', () => {
expect(screen.getByTestId('save')).toHaveTextContent(i18n.SCHEDULE_CREATE_BUTTON_TITLE);
it('should render "Create and enable" button', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('save')).toHaveTextContent(i18n.SCHEDULE_CREATE_BUTTON_TITLE);
});
});
});
});

View file

@ -15,22 +15,18 @@ import {
EuiTitle,
useGeneratedHtmlId,
} from '@elastic/eui';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import { useAssistantContext, useLoadConnectors } from '@kbn/elastic-assistant';
import * as i18n from './translations';
import { useKibana } from '../../../../../common/lib/kibana';
import { convertToBuildEsQuery } from '../../../../../common/lib/kuery';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { Footer } from '../../footer';
import { MIN_FLYOUT_WIDTH } from '../../constants';
import { useEditForm } from '../edit_form';
import type { AttackDiscoveryScheduleSchema } from '../edit_form/types';
import { useCreateAttackDiscoverySchedule } from '../logic/use_create_schedule';
import { parseFilterQuery } from '../../parse_filter_query';
import { getGenAiConfig } from '../../../use_attack_discovery/helpers';
import { convertFormDataInBaseSchedule } from '../utils/convert_form_data';
interface Props {
onClose: () => void;
@ -63,37 +59,13 @@ export const CreateFlyout: React.FC<Props> = React.memo(({ onClose }) => {
}
try {
const genAiConfig = getGenAiConfig(connector);
const [filterQuery, kqlError] = convertToBuildEsQuery({
config: getEsQueryConfig(uiSettings),
dataViewSpec: sourcererDataView,
queries: [scheduleData.alertsSelectionSettings.query],
filters: scheduleData.alertsSelectionSettings.filters,
});
const filter = parseFilterQuery({ filterQuery, kqlError });
const apiConfig = {
connectorId: connector.id,
name: connector.name,
actionTypeId: connector.actionTypeId,
provider: connector.apiProvider,
model: genAiConfig?.defaultModel,
};
const scheduleToCreate = {
name: scheduleData.name,
enabled: true,
params: {
alertsIndexPattern: alertsIndexPattern ?? '',
apiConfig,
end: scheduleData.alertsSelectionSettings.end,
filter,
size: scheduleData.alertsSelectionSettings.size,
start: scheduleData.alertsSelectionSettings.start,
},
schedule: { interval: scheduleData.interval },
actions: scheduleData.actions,
};
const scheduleToCreate = convertFormDataInBaseSchedule(
scheduleData,
alertsIndexPattern ?? '',
connector,
sourcererDataView,
uiSettings
);
await createAttackDiscoverySchedule({ scheduleToCreate });
onClose();
} catch (err) {
@ -111,8 +83,8 @@ export const CreateFlyout: React.FC<Props> = React.memo(({ onClose }) => {
);
const { editForm, actionButtons } = useEditForm({
isLoading: isLoadingConnectors || isLoadingQuery,
onSave: onCreateSchedule,
saveButtonDisabled: isLoadingConnectors || isLoadingQuery,
saveButtonTitle: i18n.SCHEDULE_CREATE_BUTTON_TITLE,
});

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 React from 'react';
import { render, screen } from '@testing-library/react';
import { CommonField } from './common_field';
import { TestProviders } from '../../../../../../common/mock';
describe('CommonField', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render value', () => {
render(
<TestProviders>
{<CommonField value={'Test field value'} data-test-subj="testField" />}
</TestProviders>
);
expect(screen.getByTestId('testField')).toHaveTextContent('Test field value');
});
});

View file

@ -0,0 +1,20 @@
/*
* 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';
interface Props {
value: string;
'data-test-subj'?: string;
}
export const CommonField: React.FC<Props> = React.memo(
({ value, 'data-test-subj': dataTestSubj }) => {
return <div data-test-subj={dataTestSubj}>{value}</div>;
}
);
CommonField.displayName = 'CommonField';

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { Filters } from './filters';
import { useDataView } from '../../../alert_selection/use_data_view';
import { TestProviders } from '../../../../../../common/mock';
import { useSourcererDataView } from '../../../../../../sourcerer/containers';
jest.mock('../../../alert_selection/use_data_view');
jest.mock('../../../../../../sourcerer/containers');
const mockUseDataView = useDataView as jest.MockedFunction<typeof useDataView>;
const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction<
typeof useSourcererDataView
>;
const renderComponent = async () => {
await act(() => {
render(
<TestProviders>
{
<Filters
filters={[{ meta: { index: 'logstash-*' }, query: { exists: { field: '_type' } } }]}
/>
}
</TestProviders>
);
});
};
describe('Filters', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseDataView.mockReturnValue({
getIndexPattern: () => 'logstash-*',
fields: [{ name: '_type' }],
} as unknown as jest.Mocked<ReturnType<typeof useDataView>>);
mockUseSourcererDataView.mockReturnValue({
sourcererDataView: {},
loading: false,
} as unknown as jest.Mocked<ReturnType<typeof useSourcererDataView>>);
});
it('should render filters component', async () => {
await renderComponent();
expect(screen.getByTestId('filters')).toBeInTheDocument();
});
it('should render correct filter', async () => {
await renderComponent();
expect(screen.getByTestId('filters')).toHaveTextContent('_type: exists');
});
});

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { EuiFlexGroup } from '@elastic/eui';
import { mapAndFlattenFilters } from '@kbn/data-plugin/public';
import type { Filter } from '@kbn/es-query';
import { FilterItems } from '@kbn/unified-search-plugin/public';
import { SourcererScopeName } from '../../../../../../sourcerer/store/model';
import { useSourcererDataView } from '../../../../../../sourcerer/containers';
import { useDataView } from '../../../alert_selection/use_data_view';
interface FiltersProps {
filters: Filter[];
}
export const Filters: React.FC<FiltersProps> = React.memo(({ filters }) => {
// get the sourcerer `DataViewSpec` for alerts:
const { sourcererDataView, loading: isLoadingIndexPattern } = useSourcererDataView(
SourcererScopeName.detections
);
// create a `DataView` from the `DataViewSpec`:
const alertsDataView = useDataView({
dataViewSpec: sourcererDataView,
loading: isLoadingIndexPattern,
});
const isEsql = filters.some((filter) => filter?.query?.language === 'esql');
const searchBarFilters = useMemo(() => {
if (!alertsDataView || isEsql) {
return filters;
}
const index = alertsDataView.getIndexPattern();
const filtersWithUpdatedMetaIndex = filters.map((filter) => {
return {
...filter,
meta: {
...filter.meta,
index,
},
};
});
return filtersWithUpdatedMetaIndex;
}, [alertsDataView, filters, isEsql]);
if (!alertsDataView) {
return null;
}
const flattenedFilters = mapAndFlattenFilters(searchBarFilters);
return (
<EuiFlexGroup data-test-subj={'filters'} wrap responsive={false} gutterSize="xs">
<FilterItems filters={flattenedFilters} indexPatterns={[alertsDataView]} readOnly />
</EuiFlexGroup>
);
});
Filters.displayName = 'Filters';

View file

@ -0,0 +1,186 @@
/*
* 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 { act, render, screen } from '@testing-library/react';
import { ScheduleDefinition } from '.';
import { useDataView } from '../../../alert_selection/use_data_view';
import { TestProviders } from '../../../../../../common/mock';
import { useSourcererDataView } from '../../../../../../sourcerer/containers';
import { mockAttackDiscoverySchedule } from '../../../../mock/mock_attack_discovery_schedule';
jest.mock('../../../alert_selection/use_data_view');
jest.mock('../../../../../../sourcerer/containers');
const mockUseDataView = useDataView as jest.MockedFunction<typeof useDataView>;
const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction<
typeof useSourcererDataView
>;
const renderComponent = async (schedule = mockAttackDiscoverySchedule) => {
await act(() => {
render(<TestProviders>{<ScheduleDefinition schedule={schedule} />}</TestProviders>);
});
};
describe('ScheduleDefinition', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseDataView.mockReturnValue({
getIndexPattern: () => 'logstash-*',
fields: [{ name: '_type' }],
} as unknown as jest.Mocked<ReturnType<typeof useDataView>>);
mockUseSourcererDataView.mockReturnValue({
sourcererDataView: {},
loading: false,
} as unknown as jest.Mocked<ReturnType<typeof useSourcererDataView>>);
});
it('should render definition title', async () => {
await renderComponent();
expect(screen.getByTestId('definitionTitle')).toBeInTheDocument();
});
it('should render definition details section', async () => {
await renderComponent();
expect(screen.getByTestId('scheduleDetailsDefinition')).toBeInTheDocument();
});
it('should render details list', async () => {
await renderComponent();
expect(screen.getByTestId('listItemColumnScheduleDescription')).toBeInTheDocument();
});
it('should render filters title if any set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.filters = [
{ meta: { index: 'logstash-*' }, query: { exists: { field: '_type' } } },
];
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('filtersTitle')).toBeInTheDocument();
});
it('should render filters value if any set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.filters = [
{ meta: { index: 'logstash-*' }, query: { exists: { field: '_type' } } },
];
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('filtersValue')).toBeInTheDocument();
});
it('should not render filters title if none set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.filters = [];
await renderComponent(scheduleWithFilters);
expect(screen.queryByTestId('filtersTitle')).not.toBeInTheDocument();
});
it('should not render filters value if none set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.filters = undefined;
await renderComponent(scheduleWithFilters);
expect(screen.queryByTestId('filtersValue')).not.toBeInTheDocument();
});
it('should render query title if set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.query = { query: 'host.name: *', language: 'kuery' };
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('queryTitle')).toBeInTheDocument();
});
it('should render query value if set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.query = { query: 'host.name: *', language: 'kuery' };
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('queryValue')).toBeInTheDocument();
});
it('should not render query title if not set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.query = { query: '', language: 'kuery' };
await renderComponent(scheduleWithFilters);
expect(screen.queryByTestId('queryTitle')).not.toBeInTheDocument();
});
it('should not render query value if not set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.query = undefined;
await renderComponent(scheduleWithFilters);
expect(screen.queryByTestId('queryValue')).not.toBeInTheDocument();
});
it('should render query language title if query is set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.query = { query: 'host.name: *', language: 'kuery' };
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('queryLanguageTitle')).toBeInTheDocument();
});
it('should render query language value if query is set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.query = { query: 'host.name: *', language: 'kuery' };
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('queryLanguageValue')).toBeInTheDocument();
});
it('should not render query language title if query is not set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.query = { query: '', language: 'kuery' };
await renderComponent(scheduleWithFilters);
expect(screen.queryByTestId('queryLanguageTitle')).not.toBeInTheDocument();
});
it('should not render query language value if query is not set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.params.query = undefined;
await renderComponent(scheduleWithFilters);
expect(screen.queryByTestId('queryLanguageValue')).not.toBeInTheDocument();
});
it('should render interval title', async () => {
await renderComponent();
expect(screen.getByTestId('scheduleIntervalTitle')).toBeInTheDocument();
});
it('should render interval value', async () => {
await renderComponent();
expect(screen.getByTestId('scheduleIntervalValue')).toBeInTheDocument();
});
it('should render connector title', async () => {
await renderComponent();
expect(screen.getByTestId('connectorTitle')).toBeInTheDocument();
});
it('should render connector value', async () => {
await renderComponent();
expect(screen.getByTestId('connectorValue')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import type { EuiDescriptionListProps } from '@elastic/eui';
import { EuiDescriptionList, EuiSpacer, EuiTitle } from '@elastic/eui';
import { formatDuration } from '@kbn/alerting-plugin/common';
import type { Filter } from '@kbn/es-query';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { Filters } from './filters';
import { CommonField } from './common_field';
const DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['30%', '70%'];
interface Props {
schedule: AttackDiscoverySchedule;
}
export function getQueryLanguageLabel(language: string) {
switch (language) {
case 'kuery':
return i18n.KUERY_LANGUAGE_LABEL;
case 'lucene':
return i18n.LUCENE_LANGUAGE_LABEL;
default:
return language;
}
}
export const ScheduleDefinition: React.FC<Props> = React.memo(({ schedule }) => {
const definitionSectionListItems = useMemo(() => {
const items: EuiDescriptionListProps['listItems'] = [];
if (schedule.params.filters?.length) {
items.push({
title: <span data-test-subj="filtersTitle">{i18n.FILTERS_LABEL}</span>,
description: (
<span data-test-subj="filtersValue">
<Filters filters={schedule.params.filters as Filter[]} />
</span>
),
});
}
if (schedule.params.query?.query.length) {
const query = schedule.params.query;
items.push(
{
title: <span data-test-subj="queryTitle">{i18n.QUERY_LABEL}</span>,
description: (
<CommonField
value={typeof query.query === 'string' ? query.query : JSON.stringify(query.query)}
data-test-subj="queryValue"
/>
),
},
{
title: <span data-test-subj="queryLanguageTitle">{i18n.QUERY_LANGUAGE_LABEL}</span>,
description: (
<span data-test-subj="queryLanguageValue">{getQueryLanguageLabel(query.language)}</span>
),
}
);
}
items.push({
title: <span data-test-subj="scheduleIntervalTitle">{i18n.SCHEDULE_INTERVAL_LABEL}</span>,
description: (
<CommonField
value={formatDuration(schedule.schedule.interval, true)}
data-test-subj="scheduleIntervalValue"
/>
),
});
items.push({
title: <span data-test-subj="connectorTitle">{i18n.CONNECTOR_LABEL}</span>,
description: (
<CommonField value={schedule.params.apiConfig.name} data-test-subj="connectorValue" />
),
});
return items;
}, [schedule]);
return (
<>
<EuiTitle data-test-subj="definitionTitle" size="s">
<h3>{i18n.DEFINITION_TITLE}</h3>
</EuiTitle>
<EuiSpacer size="m" />
<div data-test-subj="scheduleDetailsDefinition">
<EuiDescriptionList
type={'column'}
rowGutterSize={'m'}
listItems={definitionSectionListItems}
columnWidths={DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS}
data-test-subj="listItemColumnScheduleDescription"
/>
</div>
</>
);
});
ScheduleDefinition.displayName = 'ScheduleDefinition';

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const DEFINITION_TITLE = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.definition.title',
{
defaultMessage: 'Definition',
}
);
export const FILTERS_LABEL = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.definition.filtersLabel',
{
defaultMessage: 'Filters',
}
);
export const QUERY_LABEL = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.definition.queryLabel',
{
defaultMessage: 'Query',
}
);
export const QUERY_LANGUAGE_LABEL = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.definition.queryLanguageLabel',
{
defaultMessage: 'Query language',
}
);
export const KUERY_LANGUAGE_LABEL = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.definition.kqlLanguageLabel',
{
defaultMessage: 'KQL',
}
);
export const LUCENE_LANGUAGE_LABEL = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.definition.luceneLanguageLabel',
{
defaultMessage: 'Lucene',
}
);
export const CONNECTOR_LABEL = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.definition.connectorLabel',
{
defaultMessage: 'Connector',
}
);
export const SCHEDULE_INTERVAL_LABEL = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.definition.scheduleIntervalLabel',
{
defaultMessage: 'Runs every',
}
);

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 React from 'react';
import { act, render, screen } from '@testing-library/react';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import { ScheduleExecutionLogs } from '.';
import { TestProviders } from '../../../../../../common/mock';
import { useKibana } from '../../../../../../common/lib/kibana';
import { mockAttackDiscoverySchedule } from '../../../../mock/mock_attack_discovery_schedule';
import { useFetchScheduleRuleType } from '../../logic/use_fetch_schedule_rule_type';
jest.mock('../../../../../../common/lib/kibana');
jest.mock('../../logic/use_fetch_schedule_rule_type');
const mockUseKibana = useKibana as jest.MockedFunction<typeof useKibana>;
const renderComponent = async (schedule = mockAttackDiscoverySchedule) => {
await act(() => {
render(<TestProviders>{<ScheduleExecutionLogs schedule={schedule} />}</TestProviders>);
});
};
describe('ScheduleExecutionLogs', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseKibana.mockReturnValue({
services: {
triggersActionsUi: {
...triggersActionsUiMock.createStart(),
getRuleEventLogList: () => <></>,
},
},
} as unknown as jest.Mocked<ReturnType<typeof useKibana>>);
(useFetchScheduleRuleType as jest.Mock).mockReturnValue({
isLoading: false,
data: { id: 'test-rule-type' },
});
});
it('should render title', async () => {
await renderComponent();
expect(screen.getByTestId('executionLogsTitle')).toBeInTheDocument();
});
it('should render execution event logs', async () => {
await renderComponent();
expect(screen.getByTestId('executionEventLogs')).toBeInTheDocument();
});
});

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 React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiTitle } from '@elastic/eui';
import { type AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { useKibana } from '../../../../../../common/lib/kibana';
import { useFetchScheduleRuleType } from '../../logic/use_fetch_schedule_rule_type';
interface Props {
schedule: AttackDiscoverySchedule;
}
export const ScheduleExecutionLogs: React.FC<Props> = React.memo(({ schedule }) => {
const {
triggersActionsUi: { getRuleEventLogList: RuleEventLogList },
} = useKibana().services;
const { data: scheduleRuleType } = useFetchScheduleRuleType();
return (
<>
<EuiTitle data-test-subj="executionLogsTitle" size="s">
<h3>{i18n.EXECUTION_LOGS_TITLE}</h3>
</EuiTitle>
<EuiHorizontalRule />
<EuiFlexGroup
css={{ minHeight: 600 }}
direction={'column'}
data-test-subj={'executionEventLogs'}
>
<EuiFlexItem>
{scheduleRuleType && (
<RuleEventLogList ruleId={schedule.id} ruleType={scheduleRuleType} />
)}
</EuiFlexItem>
</EuiFlexGroup>
</>
);
});
ScheduleExecutionLogs.displayName = 'ScheduleExecutionLogs';

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.
*/
import { i18n } from '@kbn/i18n';
export const EXECUTION_LOGS_TITLE = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.executionLogs.title',
{
defaultMessage: 'Execution logs',
}
);

View file

@ -0,0 +1,46 @@
/*
* 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 { act, render, screen } from '@testing-library/react';
import { CreatedBy } from './created_info';
import { TestProviders } from '../../../../../../common/mock';
const renderComponent = async (
createdBy = 'test',
createdAt = new Date().toISOString(),
dataTestId = 'testComponent'
) => {
await act(() => {
render(
<TestProviders>
{<CreatedBy createdBy={createdBy} createdAt={createdAt} data-test-subj={dataTestId} />}
</TestProviders>
);
});
};
describe('CreatedBy', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render component', async () => {
await renderComponent();
expect(screen.getByTestId('testComponent')).toBeInTheDocument();
});
it('should render create by message', async () => {
await renderComponent('elastic', '2025-04-17T11:54:13.531Z', 'createByContainer');
expect(screen.getByTestId('createByContainer')).toHaveTextContent(
'Created by: elastic on Apr 17, 2025 @ 11:54:13.531'
);
});
});

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 { FormattedMessage } from '@kbn/i18n-react';
import * as i18n from './translations';
import { FormattedDate } from '../../../../../../common/components/formatted_date';
interface Props {
createdBy?: string;
createdAt?: string;
'data-test-subj'?: string;
}
/**
* Created by and created at text that are shown on schedule details flyout
*/
export const CreatedBy: React.FC<Props> = React.memo(
({ createdBy, createdAt, 'data-test-subj': dataTestSubj }) => {
return (
<div data-test-subj={dataTestSubj}>
<FormattedMessage
id="xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.header.scheduleCreationDescription"
defaultMessage="{created_by}: {by} on {date}"
values={{
created_by: <strong>{i18n.CREATED_BY}</strong>,
by: createdBy ?? i18n.UNKNOWN_TEXT,
date: (
<FormattedDate value={createdAt ?? new Date().toISOString()} fieldName="createdAt" />
),
}}
/>
</div>
);
}
);
CreatedBy.displayName = 'CreatedBy';

View file

@ -0,0 +1,122 @@
/*
* 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 { act, render, screen, within } from '@testing-library/react';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import { Header } from '.';
import { TestProviders } from '../../../../../../common/mock';
import { mockAttackDiscoverySchedule } from '../../../../mock/mock_attack_discovery_schedule';
const renderComponent = async (params?: {
isEditing?: boolean;
isLoading?: boolean;
schedule?: AttackDiscoverySchedule;
}) => {
const { isEditing = false, isLoading = false, schedule } = params ?? {};
await act(() => {
render(
<TestProviders>
{
<Header
schedule={schedule}
isEditing={isEditing}
isLoading={isLoading}
titleId={'test-1'}
/>
}
</TestProviders>
);
});
};
describe('Header', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render title container', async () => {
await renderComponent();
expect(screen.getByTestId('scheduleDetailsTitle')).toBeInTheDocument();
});
it('should render empty title when editing', async () => {
await renderComponent({ isEditing: true });
expect(screen.getByTestId('scheduleDetailsTitle')).toHaveTextContent('Edit');
});
it('should render empty title when not editing', async () => {
await renderComponent();
expect(screen.getByTestId('scheduleDetailsTitle')).toHaveTextContent('');
});
it('should render non-empty title when editing', async () => {
await renderComponent({ isEditing: true, schedule: mockAttackDiscoverySchedule });
expect(screen.getByTestId('scheduleDetailsTitle')).toHaveTextContent(
`Edit ${mockAttackDiscoverySchedule.name}`
);
});
it('should render non-empty title when not editing', async () => {
await renderComponent({ schedule: mockAttackDiscoverySchedule });
expect(screen.getByTestId('scheduleDetailsTitle')).toHaveTextContent(
mockAttackDiscoverySchedule.name
);
});
it('should render first subtitle container', async () => {
await renderComponent();
expect(screen.getByTestId('header-subtitle')).toBeInTheDocument();
});
it('should render create and update info within first subtitle if schedule is specified', async () => {
await renderComponent({ schedule: mockAttackDiscoverySchedule });
expect(screen.getByTestId('header-subtitle')).toHaveTextContent(
'Created by: elastic on Apr 9, 2025 @ 08:51:04.697Updated by: elastic on Apr 9, 2025 @ 21:10:16.483'
);
});
it('should render loader within first subtitle if schedule is undefined and `isLoading` is true', async () => {
await renderComponent({ isLoading: true });
expect(
within(screen.getByTestId('header-subtitle')).getByTestId('spinner')
).toBeInTheDocument();
});
it('should render second subtitle container', async () => {
await renderComponent();
expect(screen.getByTestId('header-subtitle-2')).toBeInTheDocument();
});
it('should render loader within second subtitle if `isLoading` is true', async () => {
await renderComponent({ isLoading: true });
expect(
within(screen.getByTestId('header-subtitle-2')).getByTestId('spinner')
).toBeInTheDocument();
});
it('should render status message within second subtitle if `isLoading` is false', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.lastExecution = { status: 'active', date: '2025-04-17T11:54:13.531Z' };
await renderComponent({ schedule: scheduleWithFilters });
expect(screen.getByTestId('header-subtitle-2')).toHaveTextContent(
'Last run:SuccessatApr 17, 2025 @ 11:54:13.531'
);
});
});

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer, EuiTitle } from '@elastic/eui';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { CreatedBy } from './created_info';
import { UpdatedBy } from './updated_info';
import { Status } from './status';
import { Subtitle } from '../../../../../../common/components/subtitle';
interface Props {
isEditing: boolean;
isLoading: boolean;
schedule?: AttackDiscoverySchedule;
titleId: string;
}
export const Header: React.FC<Props> = React.memo(({ isEditing, isLoading, schedule, titleId }) => {
const title = useMemo(() => {
const scheduleName = schedule?.name ?? '';
return isEditing
? i18n.SCHEDULE_UPDATE_TITLE(scheduleName)
: i18n.SCHEDULE_DETAILS_TITLE(scheduleName);
}, [isEditing, schedule]);
const infoSubtitle = useMemo(
() =>
schedule ? (
[
<CreatedBy createdBy={schedule.createdBy} createdAt={schedule.createdAt} />,
<UpdatedBy updatedBy={schedule.updatedBy} updatedAt={schedule.updatedAt} />,
]
) : isLoading ? (
<EuiLoadingSpinner size="m" data-test-subj="spinner" />
) : null,
[isLoading, schedule]
);
const statusSubtitle = useMemo(() => {
return (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>
<strong>
{i18n.STATUS}
{':'}
</strong>
</EuiFlexItem>
{isLoading ? (
<EuiFlexItem>
<EuiLoadingSpinner size="m" data-test-subj="spinner" />
</EuiFlexItem>
) : (
<Status schedule={schedule} />
)}
</EuiFlexGroup>
);
}, [isLoading, schedule]);
return (
<>
<EuiSpacer size="s" />
<EuiTitle data-test-subj="scheduleDetailsTitle" size="m">
<h2 id={titleId}>{title}</h2>
</EuiTitle>
<EuiSpacer size="s" />
<div data-test-subj="header-subtitle">
<Subtitle items={infoSubtitle} />
</div>
<EuiSpacer size="xs" />
<div data-test-subj="header-subtitle-2">
<Subtitle items={statusSubtitle} />
</div>
</>
);
});
Header.displayName = 'Header';

View file

@ -0,0 +1,99 @@
/*
* 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 { act, render, screen } from '@testing-library/react';
import { Status } from './status';
import { TestProviders } from '../../../../../../common/mock';
import { mockAttackDiscoverySchedule } from '../../../../mock/mock_attack_discovery_schedule';
const renderComponent = async (schedule = mockAttackDiscoverySchedule) => {
await act(() => {
render(<TestProviders>{<Status schedule={schedule} />}</TestProviders>);
});
};
describe('Status', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should not render component if schedule does not has last execution set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.lastExecution = undefined;
await renderComponent(scheduleWithFilters);
expect(screen.queryByTestId('executionStatus')).not.toBeInTheDocument();
});
it('should render component if schedule has last execution set', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.lastExecution = { status: 'ok', date: '2025-04-17T11:54:13.531Z' };
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('executionStatus')).toBeInTheDocument();
});
it('should render `ok` execution status message', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.lastExecution = { status: 'ok', date: '2025-04-17T11:54:13.531Z' };
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('executionStatus')).toHaveTextContent(
'SuccessatApr 17, 2025 @ 11:54:13.531'
);
});
it('should render `active` execution status message', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.lastExecution = { status: 'active', date: '2025-04-17T11:54:13.531Z' };
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('executionStatus')).toHaveTextContent(
'SuccessatApr 17, 2025 @ 11:54:13.531'
);
});
it('should render `error` execution status message', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.lastExecution = {
status: 'error',
date: '2025-04-17T11:54:13.531Z',
message: 'Test error!',
};
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('executionStatus')).toHaveTextContent(
'FailedatApr 17, 2025 @ 11:54:13.531'
);
});
it('should render `warning` execution status message', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.lastExecution = {
status: 'warning',
date: '2025-04-17T11:54:13.531Z',
message: 'Test warning!',
};
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('executionStatus')).toHaveTextContent(
'WarningatApr 17, 2025 @ 11:54:13.531'
);
});
it('should render `unknown` execution status message', async () => {
const scheduleWithFilters = { ...mockAttackDiscoverySchedule };
scheduleWithFilters.lastExecution = { status: 'unknown', date: '2025-04-17T11:54:13.531Z' };
await renderComponent(scheduleWithFilters);
expect(screen.getByTestId('executionStatus')).toHaveTextContent(
'UnknownatApr 17, 2025 @ 11:54:13.531'
);
});
});

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { FormattedDate } from '../../../../../../common/components/formatted_date';
import { StatusBadge } from '../../common/status_badge';
interface Props {
schedule?: AttackDiscoverySchedule;
}
export const Status: React.FC<Props> = React.memo(({ schedule }) => {
if (!schedule?.lastExecution) {
return null;
}
const executionDate = schedule.lastExecution.date;
return (
<EuiFlexGroup gutterSize="xs" alignItems="center" data-test-subj="executionStatus">
<EuiFlexItem grow={false}>
<StatusBadge schedule={schedule} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<>{i18n.STATUS_AT}</>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FormattedDate value={executionDate} fieldName={i18n.STATUS_DATE} />
</EuiFlexItem>
</EuiFlexGroup>
);
});
Status.displayName = 'Status';

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SCHEDULE_DETAILS_TITLE = (scheduleName: string) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.header.title', {
defaultMessage: '{scheduleName}',
values: { scheduleName },
});
export const SCHEDULE_UPDATE_TITLE = (scheduleName: string) =>
i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.header.updateScheduleTitle',
{
defaultMessage: 'Edit {scheduleName}',
values: { scheduleName },
}
);
export const CREATED_BY = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.header.createdByDescription',
{
defaultMessage: 'Created by',
}
);
export const UPDATED_BY = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.header.updatedByDescription',
{
defaultMessage: 'Updated by',
}
);
export const STATUS = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.header.statusDescription',
{
defaultMessage: 'Last run',
}
);
export const STATUS_AT = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.header.statusAtDescription',
{
defaultMessage: 'at',
}
);
export const STATUS_DATE = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.header.statusDateDescription',
{
defaultMessage: 'Status date',
}
);
export const UNKNOWN_TEXT = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.header.unknownText',
{
defaultMessage: 'Unknown',
}
);

View file

@ -0,0 +1,46 @@
/*
* 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 { act, render, screen } from '@testing-library/react';
import { UpdatedBy } from './updated_info';
import { TestProviders } from '../../../../../../common/mock';
const renderComponent = async (
updatedBy = 'test',
updatedAt = new Date().toISOString(),
dataTestId = 'testComponent'
) => {
await act(() => {
render(
<TestProviders>
{<UpdatedBy updatedBy={updatedBy} updatedAt={updatedAt} data-test-subj={dataTestId} />}
</TestProviders>
);
});
};
describe('UpdatedBy', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render component', async () => {
await renderComponent();
expect(screen.getByTestId('testComponent')).toBeInTheDocument();
});
it('should render updated by message', async () => {
await renderComponent('elastic', '2025-04-17T11:54:13.531Z', 'updatedByContainer');
expect(screen.getByTestId('updatedByContainer')).toHaveTextContent(
'Updated by: elastic on Apr 17, 2025 @ 11:54:13.531'
);
});
});

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 { FormattedMessage } from '@kbn/i18n-react';
import * as i18n from './translations';
import { FormattedDate } from '../../../../../../common/components/formatted_date';
interface Props {
updatedBy?: string;
updatedAt?: string;
'data-test-subj'?: string;
}
/**
* Updated by and updated at text that are shown on schedule details flyout
*/
export const UpdatedBy: React.FC<Props> = React.memo(
({ updatedBy, updatedAt, 'data-test-subj': dataTestSubj }) => {
return (
<div data-test-subj={dataTestSubj}>
<FormattedMessage
id="xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.header.scheduleUpdateDescription"
defaultMessage="{updated_by}: {by} on {date}"
values={{
updated_by: <strong>{i18n.UPDATED_BY}</strong>,
by: updatedBy ?? i18n.UNKNOWN_TEXT,
date: (
<FormattedDate value={updatedAt ?? new Date().toISOString()} fieldName="updatedAt" />
),
}}
/>
</div>
);
}
);
UpdatedBy.displayName = 'UpdatedBy';

View file

@ -0,0 +1,177 @@
/*
* 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 { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import { useLoadConnectors } from '@kbn/elastic-assistant/impl/connectorland/use_load_connectors';
import { DetailsFlyout } from '.';
import { useKibana } from '../../../../../common/lib/kibana';
import { TestProviders } from '../../../../../common/mock';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { useUpdateAttackDiscoverySchedule } from '../logic/use_update_schedule';
import { useGetAttackDiscoverySchedule } from '../logic/use_get_schedule';
import { mockAttackDiscoverySchedule } from '../../../mock/mock_attack_discovery_schedule';
jest.mock('@kbn/elastic-assistant/impl/connectorland/use_load_connectors');
jest.mock('../logic/use_update_schedule');
jest.mock('../logic/use_get_schedule');
jest.mock('../../../../../common/lib/kibana');
jest.mock('../../../../../sourcerer/containers');
jest.mock('react-router-dom', () => ({
matchPath: jest.fn(),
useLocation: jest.fn().mockReturnValue({
search: '',
}),
withRouter: jest.fn(),
}));
const mockConnectors: unknown[] = [
{
id: 'test-id',
name: 'OpenAI connector',
actionTypeId: '.gen-ai',
config: {
apiProvider: 'OpenAI',
},
},
];
const mockUseKibana = useKibana as jest.MockedFunction<typeof useKibana>;
const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction<
typeof useSourcererDataView
>;
const getBooleanValueMock = jest.fn();
const updateAttackDiscoveryScheduleMock = jest.fn();
const defaultProps = {
scheduleId: mockAttackDiscoverySchedule.id,
onClose: jest.fn(),
};
const renderComponent = async () => {
await act(() => {
render(
<TestProviders>
<DetailsFlyout {...defaultProps} />
</TestProviders>
);
});
};
describe('DetailsFlyout', () => {
beforeEach(() => {
jest.clearAllMocks();
getBooleanValueMock.mockReturnValue(true);
mockUseKibana.mockReturnValue({
services: {
featureFlags: {
getBooleanValue: getBooleanValueMock,
},
lens: {
EmbeddableComponent: () => <div data-test-subj="mockEmbeddableComponent" />,
},
triggersActionsUi: {
...triggersActionsUiMock.createStart(),
},
uiSettings: {
get: jest.fn(),
},
unifiedSearch: {
ui: {
SearchBar: () => <div data-test-subj="mockSearchBar" />,
},
},
},
} as unknown as jest.Mocked<ReturnType<typeof useKibana>>);
mockUseSourcererDataView.mockReturnValue({
sourcererDataView: {},
loading: false,
} as unknown as jest.Mocked<ReturnType<typeof useSourcererDataView>>);
(useLoadConnectors as jest.Mock).mockReturnValue({
isLoading: false,
data: mockConnectors,
});
(useUpdateAttackDiscoverySchedule as jest.Mock).mockReturnValue({
isLoading: false,
mutateAsync: updateAttackDiscoveryScheduleMock,
});
(useGetAttackDiscoverySchedule as jest.Mock).mockReturnValue({
isLoading: false,
data: { schedule: mockAttackDiscoverySchedule },
});
});
it('should render the flyout title', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('scheduleDetailsTitle')).toHaveTextContent(
mockAttackDiscoverySchedule.name
);
});
});
it('should render schedule details container', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('scheduleDetails')).toBeInTheDocument();
});
});
it('should render edit button', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('edit')).toBeInTheDocument();
});
});
it('should invoke onClose when the close button is clicked', async () => {
await renderComponent();
const closeButton = screen.getByTestId('euiFlyoutCloseButton');
act(() => {
fireEvent.click(closeButton);
});
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it('should render edit form while editing', async () => {
await renderComponent();
const editButton = screen.getByTestId('edit');
act(() => {
fireEvent.click(editButton);
});
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryScheduleForm')).toBeInTheDocument();
});
});
it('should render save button while editing', async () => {
await renderComponent();
const editButton = screen.getByTestId('edit');
act(() => {
fireEvent.click(editButton);
});
await waitFor(() => {
expect(screen.getByTestId('save')).toBeInTheDocument();
});
});
});

View file

@ -5,4 +5,216 @@
* 2.0.
*/
// TODO: implement details flyout component
import React, { useCallback, useMemo, useState } from 'react';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFlyoutResizable,
EuiSpacer,
useEuiTheme,
useGeneratedHtmlId,
} from '@elastic/eui';
import { css } from '@emotion/react';
import type { RuleAction } from '@kbn/alerting-types';
import { useAssistantContext, useLoadConnectors } from '@kbn/elastic-assistant';
import { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common';
import type { Filter } from '@kbn/es-query';
import * as i18n from './translations';
import { useKibana } from '../../../../../common/lib/kibana';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { Footer } from '../../footer';
import { MIN_FLYOUT_WIDTH } from '../../constants';
import type { AttackDiscoveryScheduleSchema } from '../edit_form/types';
import { useUpdateAttackDiscoverySchedule } from '../logic/use_update_schedule';
import { useGetAttackDiscoverySchedule } from '../logic/use_get_schedule';
import { getDefaultQuery } from '../../../helpers';
import { useEditForm } from '../edit_form/use_edit_form';
import { ScheduleDefinition } from './definition';
import { Header } from './header';
import { ScheduleExecutionLogs } from './execution_logs';
import { convertFormDataInBaseSchedule } from '../utils/convert_form_data';
interface Props {
scheduleId: string;
onClose: () => void;
}
export const DetailsFlyout: React.FC<Props> = React.memo(({ scheduleId, onClose }) => {
const flyoutTitleId = useGeneratedHtmlId({
prefix: 'attackDiscoveryScheduleDetailsFlyoutTitle',
});
const {
services: { uiSettings },
} = useKibana();
const { euiTheme } = useEuiTheme();
const { alertsIndexPattern, http } = useAssistantContext();
const { data: aiConnectors, isLoading: isLoadingConnectors } = useLoadConnectors({
http,
});
const { data: { schedule } = { schedule: undefined }, isLoading: isLoadingSchedule } =
useGetAttackDiscoverySchedule({
id: scheduleId,
});
const { sourcererDataView } = useSourcererDataView();
const [isEditing, setIsEditing] = useState(false);
const { mutateAsync: updateAttackDiscoverySchedule, isLoading: isLoadingQuery } =
useUpdateAttackDiscoverySchedule();
const onUpdateSchedule = useCallback(
async (scheduleData: AttackDiscoveryScheduleSchema) => {
const connector = aiConnectors?.find((item) => item.id === scheduleData.connectorId);
if (!connector) {
return;
}
try {
const scheduleToUpdate = convertFormDataInBaseSchedule(
scheduleData,
alertsIndexPattern ?? '',
connector,
sourcererDataView,
uiSettings
);
await updateAttackDiscoverySchedule({ id: scheduleId, scheduleToUpdate });
setIsEditing(false);
} catch (err) {
// Error is handled by the mutation's onError callback, so no need to do anything here
}
},
[
aiConnectors,
uiSettings,
sourcererDataView,
scheduleId,
alertsIndexPattern,
updateAttackDiscoverySchedule,
]
);
const isLoading = isLoadingSchedule || isLoadingConnectors || isLoadingQuery;
const formInitialValue = useMemo(() => {
if (schedule) {
const params = schedule.params;
return {
name: schedule.name,
connectorId: params.apiConfig.connectorId,
alertsSelectionSettings: {
query: params.query ?? getDefaultQuery(),
filters: (params.filters as Filter[]) ?? [],
size: params.size,
start: params.start ?? DEFAULT_START,
end: params.end ?? DEFAULT_END,
},
interval: schedule.schedule.interval,
actions: schedule.actions as RuleAction[],
};
}
}, [schedule]);
const { editForm, actionButtons: editingActionButtons } = useEditForm({
initialValue: formInitialValue,
isLoading,
onSave: onUpdateSchedule,
saveButtonTitle: i18n.SCHEDULE_SAVE_BUTTON_TITLE,
});
const scheduleDetails = useMemo(() => {
if (schedule) {
return (
<div data-test-subj="scheduleDetails">
<ScheduleDefinition schedule={schedule} />
<EuiSpacer size="xl" />
<ScheduleExecutionLogs schedule={schedule} />
</div>
);
}
}, [schedule]);
const content = useMemo(() => {
if (isEditing) {
return editForm;
}
return scheduleDetails;
}, [editForm, isEditing, scheduleDetails]);
const editButton = useMemo(() => {
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem
css={css`
margin-right: ${euiTheme.size.s};
`}
grow={false}
>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="edit"
fill
size="s"
onClick={() => setIsEditing(true)}
disabled={isLoading}
>
{i18n.SCHEDULE_EDIT_BUTTON_TITLE}
</EuiButton>
</EuiFlexItem>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [euiTheme.size.s, isLoading]);
const actionButtons = useMemo(() => {
return isEditing ? editingActionButtons : [editButton];
}, [editButton, editingActionButtons, isEditing]);
const handleCloseButtonClick = useCallback(() => {
if (isEditing) {
setIsEditing(false);
} else {
onClose();
}
}, [isEditing, onClose]);
return (
<EuiFlyoutResizable
aria-labelledby={flyoutTitleId}
data-test-subj="scheduleDetailsFlyout"
minWidth={MIN_FLYOUT_WIDTH}
onClose={handleCloseButtonClick}
outsideClickCloses={!isEditing}
paddingSize="m"
side="right"
size="s"
type="overlay"
>
<EuiFlyoutHeader hasBorder>
<Header
isEditing={isEditing}
isLoading={isLoading}
schedule={schedule}
titleId={flyoutTitleId}
/>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiSpacer size="s" />
{content}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<Footer closeModal={handleCloseButtonClick} actionButtons={actionButtons} />
</EuiFlyoutFooter>
</EuiFlyoutResizable>
);
});
DetailsFlyout.displayName = 'DetailsFlyout';

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 SCHEDULE_EDIT_BUTTON_TITLE = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.editButtonTitle',
{
defaultMessage: 'Edit',
}
);
export const SCHEDULE_SAVE_BUTTON_TITLE = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.detailsFlyout.saveButtonTitle',
{
defaultMessage: 'Save',
}
);

View file

@ -0,0 +1,174 @@
/*
* 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 { act, render, screen, waitFor } from '@testing-library/react';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import { useLoadConnectors } from '@kbn/elastic-assistant/impl/connectorland/use_load_connectors';
import { EditForm } from './edit_form';
import { useKibana } from '../../../../../common/lib/kibana';
import { TestProviders } from '../../../../../common/mock';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { getDefaultQuery } from '../../../helpers';
import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant';
import { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common';
const mockConnectors: unknown[] = [
{
id: 'test-id',
name: 'OpenAI connector',
actionTypeId: '.gen-ai',
isPreconfigured: true,
},
];
jest.mock('react-router', () => ({
matchPath: jest.fn(),
useLocation: jest.fn().mockReturnValue({
search: '',
}),
withRouter: jest.fn(),
}));
jest.mock('../../../../../common/lib/kibana');
jest.mock('../../../../../sourcerer/containers');
jest.mock('@kbn/elastic-assistant/impl/connectorland/use_load_connectors', () => ({
useLoadConnectors: jest.fn(() => ({
isFetched: true,
data: mockConnectors,
})),
}));
const mockUseKibana = useKibana as jest.MockedFunction<typeof useKibana>;
const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction<
typeof useSourcererDataView
>;
const onChangeMock = jest.fn();
const defaultProps = {
initialValue: {
name: '',
alertsSelectionSettings: {
query: getDefaultQuery(),
filters: [],
size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS,
start: DEFAULT_START,
end: DEFAULT_END,
},
interval: '4h',
actions: [],
},
onChange: onChangeMock,
};
const renderComponent = async () => {
await act(() => {
render(
<TestProviders>
<EditForm {...defaultProps} />
</TestProviders>
);
});
};
describe('EditForm', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseKibana.mockReturnValue({
services: {
lens: {
EmbeddableComponent: () => <div data-test-subj="mockEmbeddableComponent" />,
},
triggersActionsUi: {
...triggersActionsUiMock.createStart(),
},
uiSettings: {
get: jest.fn(),
},
unifiedSearch: {
ui: {
SearchBar: () => <div data-test-subj="mockSearchBar" />,
},
},
},
} as unknown as jest.Mocked<ReturnType<typeof useKibana>>);
mockUseSourcererDataView.mockReturnValue({
sourcererDataView: {},
loading: false,
} as unknown as jest.Mocked<ReturnType<typeof useSourcererDataView>>);
(useLoadConnectors as jest.Mock).mockReturnValue({
isFetched: true,
data: mockConnectors,
});
});
it('should return the schedule editing form', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryScheduleForm')).toBeInTheDocument();
});
});
it('should return the name input field in the schedule editing form', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryFormNameField')).toBeInTheDocument();
});
});
it('should return the connector selector field in the schedule editing form', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryConnectorSelectorField')).toBeInTheDocument();
});
});
it('should return the alert selection field in the schedule editing form', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('alertSelection')).toBeInTheDocument();
});
});
it('should return the alert selection component with `AlertSelectionQuery` as settings view', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryScheduleForm')).toBeInTheDocument();
});
});
it('should return the `run every` field in the schedule editing form', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryScheduleField')).toBeInTheDocument();
});
});
it('should return the actions field in the schedule editing form', async () => {
await renderComponent();
await waitFor(() => {
expect(screen.getByText('Select a connector type')).toBeInTheDocument();
});
});
it('should invoke `onChange`', async () => {
await renderComponent();
expect(onChangeMock).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,145 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { getSchema } from './schema';
import type { AttackDiscoveryScheduleSchema } from './types';
import { ConnectorSelectorField } from '../form_fields/connector_selector_field';
import { ScheduleField } from '../form_fields/schedule_field';
import { useSettingsView } from '../../hooks/use_settings_view';
import type { AlertsSelectionSettings } from '../../types';
import { RuleActionsField } from '../../../../../common/components/rule_actions_field';
import { useKibana } from '../../../../../common/lib/kibana';
import type { FormHook } from '../../../../../shared_imports';
import {
Field,
Form,
UseField,
getUseField,
useForm,
useFormData,
} from '../../../../../shared_imports';
const CommonUseField = getUseField({ component: Field });
export interface FormState {
value: AttackDiscoveryScheduleSchema;
isValid?: boolean;
submit: FormHook<AttackDiscoveryScheduleSchema>['submit'];
}
export interface FormProps {
initialValue: AttackDiscoveryScheduleSchema;
onChange: (state: FormState) => void;
}
export const EditForm: React.FC<FormProps> = React.memo((props) => {
const { initialValue, onChange } = props;
const {
triggersActionsUi: { actionTypeRegistry },
} = useKibana().services;
const { form } = useForm<AttackDiscoveryScheduleSchema>({
defaultValue: initialValue,
options: { stripEmptyFields: false },
schema: getSchema({ actionTypeRegistry }),
});
const [{ value }] = useFormData({ form });
const { isValid, setFieldValue, submit } = form;
useEffect(() => {
onChange({
value,
isValid,
submit,
});
}, [isValid, onChange, submit, value]);
const [settings, setSettings] = useState<AlertsSelectionSettings>(
initialValue.alertsSelectionSettings
);
const onSettingsChanged = useCallback(
(newSettings: AlertsSelectionSettings) => {
setSettings(newSettings);
setFieldValue('alertsSelectionSettings', newSettings);
},
[setFieldValue]
);
const { settingsView } = useSettingsView({ settings, onSettingsChanged });
const [connectorId, setConnectorId] = React.useState<string | undefined>(
initialValue?.connectorId
);
const onConnectorIdSelected = useCallback(
(selectedConnectorId: string) => {
setConnectorId(selectedConnectorId);
setFieldValue('connectorId', selectedConnectorId);
},
[setFieldValue]
);
const messageVariables = useMemo(() => {
return {
state: [],
params: [],
context: [],
};
}, []);
return (
<Form form={form} data-test-subj="attackDiscoveryScheduleForm">
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem>
<CommonUseField
path="name"
componentProps={{
'data-test-subj': 'attackDiscoveryFormNameField',
euiFieldProps: {
'data-test-subj': 'attackDiscoveryFormNameInput',
autoFocus: true,
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="connectorId"
component={ConnectorSelectorField}
componentProps={{
connectorId,
onConnectorIdSelected,
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField path="alertsSelectionSettings">{() => <>{settingsView}</>}</UseField>
</EuiFlexItem>
<EuiFlexItem>
<UseField path="interval" component={ScheduleField} />
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="actions"
component={RuleActionsField}
componentProps={{
messageVariables,
summaryMessageVariables: messageVariables,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Form>
);
});
EditForm.displayName = 'EditForm';

View file

@ -1,229 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import { useLoadConnectors } from '@kbn/elastic-assistant/impl/connectorland/use_load_connectors';
import type { UseEditFormProps } from './use_edit_form';
import { useEditForm } from './use_edit_form';
import { useKibana } from '../../../../../common/lib/kibana';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { TestProviders } from '../../../../../common/mock';
const mockConnectors: unknown[] = [
{
id: 'test-id',
name: 'OpenAI connector',
actionTypeId: '.gen-ai',
isPreconfigured: true,
},
];
jest.mock('react-router', () => ({
matchPath: jest.fn(),
useLocation: jest.fn().mockReturnValue({
search: '',
}),
withRouter: jest.fn(),
}));
jest.mock('../../../../../common/lib/kibana');
jest.mock('../../../../../sourcerer/containers');
jest.mock('@kbn/elastic-assistant/impl/connectorland/use_load_connectors', () => ({
useLoadConnectors: jest.fn(() => ({
isFetched: true,
data: mockConnectors,
})),
}));
const defaultProps: UseEditFormProps = {
initialValue: undefined,
onSave: jest.fn(),
saveButtonTitle: undefined,
};
const mockUseKibana = useKibana as jest.MockedFunction<typeof useKibana>;
const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction<
typeof useSourcererDataView
>;
const renderEditForm = (props = defaultProps) => {
const { result } = renderHook(() => useEditForm(props));
act(() => {
render(
<TestProviders>
<>
{result.current.editForm}
{result.current.actionButtons}
</>
</TestProviders>
);
});
};
describe('useEditForm', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseKibana.mockReturnValue({
services: {
lens: {
EmbeddableComponent: () => <div data-test-subj="mockEmbeddableComponent" />,
},
triggersActionsUi: {
...triggersActionsUiMock.createStart(),
},
uiSettings: {
get: jest.fn(),
},
unifiedSearch: {
ui: {
SearchBar: () => <div data-test-subj="mockSearchBar" />,
},
},
},
} as unknown as jest.Mocked<ReturnType<typeof useKibana>>);
mockUseSourcererDataView.mockReturnValue({
sourcererDataView: {},
loading: false,
} as unknown as jest.Mocked<ReturnType<typeof useSourcererDataView>>);
(useLoadConnectors as jest.Mock).mockReturnValue({
isFetched: true,
data: mockConnectors,
});
});
describe('form rendering', () => {
it('should return the schedule editing form', async () => {
renderEditForm();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryScheduleForm')).toBeInTheDocument();
});
});
it('should return the name input field in the schedule editing form', async () => {
renderEditForm();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryFormNameField')).toBeInTheDocument();
});
});
it('should return the connector selector field in the schedule editing form', async () => {
renderEditForm();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryConnectorSelectorField')).toBeInTheDocument();
});
});
it('should return the alert selection field in the schedule editing form', async () => {
renderEditForm();
await waitFor(() => {
expect(screen.getByTestId('alertSelection')).toBeInTheDocument();
});
});
it('should return the alert selection component with `AlertSelectionQuery` as settings view', async () => {
renderEditForm();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryScheduleForm')).toBeInTheDocument();
});
});
it('should return the `run every` field in the schedule editing form', async () => {
renderEditForm();
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryScheduleField')).toBeInTheDocument();
});
});
it('should return the actions field in the schedule editing form', async () => {
renderEditForm();
await waitFor(() => {
expect(screen.getByText('Select a connector type')).toBeInTheDocument();
});
});
it('should return save button', async () => {
renderEditForm();
await waitFor(() => {
expect(screen.getByTestId('save')).toBeInTheDocument();
});
});
});
describe('validation', () => {
it('should show `name is required` error if name field is empty', async () => {
renderEditForm();
act(() => {
const save = screen.getByTestId('save');
fireEvent.click(save);
});
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryFormNameField')).toHaveTextContent(
'A name is required.'
);
expect(defaultProps.onSave).not.toBeCalled();
});
});
it('should show `connector is required` error if connector is not selected', async () => {
renderEditForm();
act(() => {
const save = screen.getByTestId('save');
fireEvent.click(save);
});
await waitFor(() => {
expect(screen.getByTestId('attackDiscoveryConnectorSelectorField')).toHaveTextContent(
'A connector is required.'
);
expect(defaultProps.onSave).not.toBeCalled();
});
});
it('should call onSave with form data if all required fields are set', async () => {
const initialValue = {
name: 'Test Schedule 1',
connectorId: 'test-id',
alertsSelectionSettings: {
query: { query: 'user.name : "user1" ', language: 'kuery' },
filters: [],
size: 250,
start: 'now-1d',
end: 'now',
},
interval: '24h',
actions: [],
};
renderEditForm({ ...defaultProps, initialValue });
act(() => {
const save = screen.getByTestId('save');
fireEvent.click(save);
});
await waitFor(() => {
expect(defaultProps.onSave).toHaveBeenCalledWith(initialValue);
});
});
});
});

View file

@ -6,27 +6,25 @@
*/
import React, { useCallback, useMemo, useState } from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiSkeletonLoading,
EuiSkeletonText,
EuiSkeletonTitle,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant';
import { DEFAULT_END, DEFAULT_START } from '@kbn/elastic-assistant-common';
import { getSchema } from './schema';
import * as i18n from './translations';
import type { AttackDiscoveryScheduleSchema } from './types';
import { ConnectorSelectorField } from '../form_fields/connector_selector_field';
import { ScheduleField } from '../form_fields/schedule_field';
import { useSettingsView } from '../../hooks/use_settings_view';
import type { AlertsSelectionSettings } from '../../types';
import type { FormState } from './edit_form';
import { EditForm } from './edit_form';
import { getDefaultQuery } from '../../../helpers';
import { RuleActionsField } from '../../../../../common/components/rule_actions_field';
import { useKibana } from '../../../../../common/lib/kibana';
import type { FormSubmitHandler } from '../../../../../shared_imports';
import { Field, Form, UseField, getUseField, useForm } from '../../../../../shared_imports';
const CommonUseField = getUseField({ component: Field });
const defaultInitialValue: AttackDiscoveryScheduleSchema = {
name: '',
@ -47,127 +45,47 @@ export interface UseEditForm {
}
export interface UseEditFormProps {
isLoading: boolean;
initialValue?: AttackDiscoveryScheduleSchema;
onSave?: (scheduleData: AttackDiscoveryScheduleSchema) => void;
saveButtonDisabled?: boolean;
saveButtonTitle?: string;
}
export const useEditForm = (props: UseEditFormProps): UseEditForm => {
const {
initialValue = defaultInitialValue,
onSave,
saveButtonDisabled = false,
saveButtonTitle,
} = props;
const { isLoading, initialValue = defaultInitialValue, onSave, saveButtonTitle } = props;
const { euiTheme } = useEuiTheme();
const {
triggersActionsUi: { actionTypeRegistry },
} = useKibana().services;
const handleFormSubmit = useCallback<FormSubmitHandler<AttackDiscoveryScheduleSchema>>(
async (formData, isValid) => {
if (!isValid) {
return;
}
onSave?.(formData);
},
[onSave]
);
const { form } = useForm<AttackDiscoveryScheduleSchema>({
defaultValue: initialValue,
options: { stripEmptyFields: false },
schema: getSchema({ actionTypeRegistry }),
onSubmit: handleFormSubmit,
const [formState, setFormState] = useState<FormState>({
isValid: undefined,
submit: async () => ({ isValid: false, data: defaultInitialValue }),
value: initialValue,
});
const { setFieldValue, submit } = form;
const [settings, setSettings] = useState<AlertsSelectionSettings>(
initialValue.alertsSelectionSettings
);
const onSettingsChanged = useCallback(
(newSettings: AlertsSelectionSettings) => {
setSettings(newSettings);
setFieldValue('alertsSelectionSettings', newSettings);
},
[setFieldValue]
);
const { settingsView } = useSettingsView({ settings, onSettingsChanged });
const [connectorId, setConnectorId] = React.useState<string | undefined>(
initialValue?.connectorId
);
const onConnectorIdSelected = useCallback(
(selectedConnectorId: string) => {
setConnectorId(selectedConnectorId);
setFieldValue('connectorId', selectedConnectorId);
},
[setFieldValue]
);
const messageVariables = useMemo(() => {
return {
state: [],
params: [],
context: [],
};
}, []);
const onCreate = useCallback(async () => {
const { isValid, data } = await formState.submit();
if (!isValid) {
return;
}
onSave?.(data);
}, [formState, onSave]);
const editForm = useMemo(() => {
return (
<Form form={form} data-test-subj="attackDiscoveryScheduleForm">
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem>
<CommonUseField
path="name"
componentProps={{
'data-test-subj': 'attackDiscoveryFormNameField',
euiFieldProps: {
'data-test-subj': 'attackDiscoveryFormNameInput',
autoFocus: true,
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="connectorId"
component={ConnectorSelectorField}
componentProps={{
connectorId,
onConnectorIdSelected,
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField path="alertsSelectionSettings">{() => <>{settingsView}</>}</UseField>
</EuiFlexItem>
<EuiFlexItem>
<UseField path="interval" component={ScheduleField} />
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="actions"
component={RuleActionsField}
componentProps={{
messageVariables,
summaryMessageVariables: messageVariables,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Form>
);
}, [connectorId, form, messageVariables, onConnectorIdSelected, settingsView]);
const onCreate = useCallback(() => {
submit();
}, [submit]);
if (isLoading) {
return (
<EuiSkeletonLoading
isLoading={isLoading}
loadingContent={
<>
<EuiSkeletonTitle />
<EuiSkeletonText />
</>
}
loadedContent={null}
/>
);
}
return <EditForm initialValue={initialValue} onChange={setFormState} />;
}, [initialValue, isLoading]);
const actionButtons = useMemo(() => {
return (
@ -179,20 +97,14 @@ export const useEditForm = (props: UseEditFormProps): UseEditForm => {
grow={false}
>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="save"
fill
size="s"
onClick={onCreate}
disabled={saveButtonDisabled}
>
<EuiButton data-test-subj="save" fill size="s" onClick={onCreate} disabled={isLoading}>
{saveButtonTitle ?? i18n.SCHEDULE_SAVE_BUTTON_TITLE}
</EuiButton>
</EuiFlexItem>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [euiTheme.size.s, onCreate, saveButtonDisabled, saveButtonTitle]);
}, [euiTheme.size.s, isLoading, onCreate, saveButtonTitle]);
return { editForm, actionButtons };
};

View file

@ -86,3 +86,10 @@ export const DISABLE_ATTACK_DISCOVERY_SCHEDULES_FAILURE = (failed = 1) =>
values: { failed },
}
);
export const FETCH_ATTACK_DISCOVERY_SCHEDULE_RULE_TYPE_FAILURE = i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.fetchScheduleRuleTypeFailDescription',
{
defaultMessage: 'Failed to fetch attack discovery schedule rule type',
}
);

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 { useFetchScheduleRuleType } from './use_fetch_schedule_rule_type';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock';
import { renderQuery } from '../../../../../management/hooks/test_utils';
import { fetchRuleTypes } from '../api';
jest.mock('../api');
jest.mock('../../../../../common/hooks/use_app_toasts');
const fetchRuleTypesMock = fetchRuleTypes as jest.MockedFunction<typeof fetchRuleTypes>;
describe('useFetchScheduleRuleType', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
fetchRuleTypesMock.mockReturnValue(
[] as unknown as jest.Mocked<ReturnType<typeof fetchRuleTypes>>
);
});
it('should invoke `addError`', async () => {
fetchRuleTypesMock.mockRejectedValue('Royally failed!');
await renderQuery(() => useFetchScheduleRuleType(), 'isError');
expect(appToastsMock.addError).toHaveBeenCalledWith('Royally failed!', {
title: 'Failed to fetch attack discovery schedule rule type',
});
});
it('should invoke `fetchRuleTypes`', async () => {
await renderQuery(() => useFetchScheduleRuleType(), 'isSuccess');
expect(fetchRuleTypesMock).toHaveBeenCalledWith({ signal: expect.anything() });
});
});

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 { camelCase, mapKeys } from 'lodash';
import { useQuery } from '@tanstack/react-query';
import type { RuleType } from '@kbn/triggers-actions-ui-types';
import { ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { DEFAULT_QUERY_OPTIONS } from './constants';
import { ALERTING_RULE_TYPES_URL, fetchRuleTypes } from '../api';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
export const useFetchScheduleRuleType = () => {
const { addError } = useAppToasts();
return useQuery(
['GET', ALERTING_RULE_TYPES_URL],
async ({ signal }) => {
const res = await fetchRuleTypes({ signal });
const response = res.map((item) => {
return mapKeys(item, (_, k) => camelCase(k));
}) as unknown as Array<RuleType<string, string>>;
return response.find((item) => item.id === ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID) ?? null;
},
{
...DEFAULT_QUERY_OPTIONS,
onError: (error) => {
addError(error, { title: i18n.FETCH_ATTACK_DISCOVERY_SCHEDULE_RULE_TYPE_FAILURE });
},
}
);
};

View file

@ -10,7 +10,7 @@ import React from 'react';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import type { TableColumn } from './constants';
import { StatusBadge } from './status_badge';
import { StatusBadge } from '../../common/status_badge';
export const createStatusColumn = (): TableColumn => {
return {

View file

@ -17,6 +17,7 @@ import { useFindAttackDiscoverySchedules } from '../logic/use_find_schedules';
import { useEnableAttackDiscoverySchedule } from '../logic/use_enable_schedule';
import { useDisableAttackDiscoverySchedule } from '../logic/use_disable_schedule';
import { useDeleteAttackDiscoverySchedule } from '../logic/use_delete_schedule';
import { DetailsFlyout } from '../details_flyout';
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT_FIELD = 'name';
@ -72,13 +73,14 @@ export const SchedulesTable: React.FC = React.memo(() => {
);
const [isTableLoading, setTableLoading] = useState(false);
const [scheduleDetailsId, setScheduleDetailsId] = useState<string | undefined>(undefined);
const { mutateAsync: enableAttackDiscoverySchedule } = useEnableAttackDiscoverySchedule();
const { mutateAsync: disableAttackDiscoverySchedule } = useDisableAttackDiscoverySchedule();
const { mutateAsync: deleteAttackDiscoverySchedule } = useDeleteAttackDiscoverySchedule();
const openScheduleDetails = useCallback((scheduleId: string) => {
// TODO: implement attack discovery schedule details
setScheduleDetailsId(scheduleId);
}, []);
const enableSchedule = useCallback(
async (id: string) => {
@ -145,6 +147,12 @@ export const SchedulesTable: React.FC = React.memo(() => {
data-test-subj={'schedulesTable'}
columns={rulesColumns}
/>
{scheduleDetailsId && (
<DetailsFlyout
scheduleId={scheduleDetailsId}
onClose={() => setScheduleDetailsId(undefined)}
/>
)}
</div>
);
});

View file

@ -0,0 +1,72 @@
/*
* 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 { IUiSettingsClient } from '@kbn/core/public';
import type { DataViewSpec } from '@kbn/data-plugin/common';
import type { AIConnector } from '@kbn/elastic-assistant';
import { convertFormDataInBaseSchedule } from './convert_form_data';
import { convertToBuildEsQuery } from '../../../../../common/lib/kuery';
import { getGenAiConfig } from '../../../use_attack_discovery/helpers';
import { parseFilterQuery } from '../../parse_filter_query';
jest.mock('../../../../../common/lib/kuery');
jest.mock('../../../use_attack_discovery/helpers');
jest.mock('../../parse_filter_query');
describe('convertFormDataInBaseSchedule', () => {
beforeEach(() => {
jest.clearAllMocks();
(convertToBuildEsQuery as jest.Mock).mockReturnValue(['test-filter-query']);
(getGenAiConfig as jest.Mock).mockReturnValue({ defaultModel: 'test-model' });
(parseFilterQuery as jest.Mock).mockReturnValue({ filter: { field: 'test' } });
});
it('should convert form data into a base schedule schema', () => {
const baseSchedule = convertFormDataInBaseSchedule(
{
name: 'test 1',
connectorId: 'connector 1',
alertsSelectionSettings: {
end: 'now-5s',
filters: [],
query: {
query: 'test: exists',
language: 'kuery',
},
size: 145,
start: 'now-99m',
},
interval: '23m',
actions: [],
},
'.alert-*',
{} as AIConnector,
{} as DataViewSpec,
{
get: jest.fn(),
} as unknown as IUiSettingsClient
);
expect(baseSchedule).toEqual({
actions: [],
enabled: true,
name: 'test 1',
params: {
alertsIndexPattern: '.alert-*',
apiConfig: { model: 'test-model' },
combinedFilter: { filter: { field: 'test' } },
end: 'now-5s',
filters: [],
query: { language: 'kuery', query: 'test: exists' },
size: 145,
start: 'now-99m',
},
schedule: { interval: '23m' },
});
});
});

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 type { IUiSettingsClient } from '@kbn/core/public';
import type { DataViewSpec } from '@kbn/data-plugin/common';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { AIConnector } from '@kbn/elastic-assistant';
import { convertToBuildEsQuery } from '../../../../../common/lib/kuery';
import type { AttackDiscoveryScheduleSchema } from '../edit_form/types';
import { getGenAiConfig } from '../../../use_attack_discovery/helpers';
import { parseFilterQuery } from '../../parse_filter_query';
export const convertFormDataInBaseSchedule = (
scheduleData: AttackDiscoveryScheduleSchema,
alertsIndexPattern: string,
connector: AIConnector,
dataViewSpec: DataViewSpec,
uiSettings: IUiSettingsClient
) => {
const alertsSelectionSettings = scheduleData.alertsSelectionSettings;
const [filterQuery, kqlError] = convertToBuildEsQuery({
config: getEsQueryConfig(uiSettings),
dataViewSpec,
queries: [alertsSelectionSettings.query],
filters: alertsSelectionSettings.filters,
});
const combinedFilter = parseFilterQuery({ filterQuery, kqlError });
const genAiConfig = getGenAiConfig(connector);
const apiConfig = {
connectorId: connector.id,
name: connector.name,
actionTypeId: connector.actionTypeId,
provider: connector.apiProvider,
model: genAiConfig?.defaultModel,
};
return {
name: scheduleData.name,
enabled: true,
params: {
alertsIndexPattern: alertsIndexPattern ?? '',
apiConfig,
end: alertsSelectionSettings.end,
query: alertsSelectionSettings.query,
filters: alertsSelectionSettings.filters,
combinedFilter,
size: alertsSelectionSettings.size,
start: alertsSelectionSettings.start,
},
schedule: { interval: scheduleData.interval },
actions: scheduleData.actions,
};
};

View file

@ -0,0 +1,86 @@
/*
* 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 { EuiThemeComputed } from '@elastic/eui';
import { getExecutionStatusHealthColor, getExecutionStatusLabel } from './execution_status';
const mockEuiTheme = {
colors: {
success: 'success',
danger: 'danger',
warning: 'warning',
},
} as unknown as EuiThemeComputed;
describe('Execution Status Utils', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getExecutionStatusHealthColor', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return `success` color for the `active` status', () => {
const result = getExecutionStatusHealthColor('active', mockEuiTheme);
expect(result).toEqual('success');
});
it('should return `success` color for the `ok` status', () => {
const result = getExecutionStatusHealthColor('ok', mockEuiTheme);
expect(result).toEqual('success');
});
it('should return `danger` color for the `error` status', () => {
const result = getExecutionStatusHealthColor('error', mockEuiTheme);
expect(result).toEqual('danger');
});
it('should return `warning` color for the `warning` status', () => {
const result = getExecutionStatusHealthColor('warning', mockEuiTheme);
expect(result).toEqual('warning');
});
it('should return `subdued` color for the unknown status', () => {
const result = getExecutionStatusHealthColor('unknown', mockEuiTheme);
expect(result).toEqual('subdued');
});
});
describe('getExecutionStatusLabel', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return `Success` label for the `active` status', () => {
const result = getExecutionStatusLabel('active');
expect(result).toEqual('Success');
});
it('should return `Success` label for the `ok` status', () => {
const result = getExecutionStatusLabel('ok');
expect(result).toEqual('Success');
});
it('should return `Failed` label for the `error` status', () => {
const result = getExecutionStatusLabel('error');
expect(result).toEqual('Failed');
});
it('should return `Warning` label for the `warning` status', () => {
const result = getExecutionStatusLabel('warning');
expect(result).toEqual('Warning');
});
it('should return `Unknown` label for the unknown status', () => {
const result = getExecutionStatusLabel('unknown');
expect(result).toEqual('Unknown');
});
});
});

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 type { EuiThemeComputed } from '@elastic/eui';
import type { AttackDiscoveryScheduleExecutionStatus } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
export const getExecutionStatusHealthColor = (
status: AttackDiscoveryScheduleExecutionStatus,
euiTheme: EuiThemeComputed
) => {
switch (status) {
case 'active':
case 'ok':
return euiTheme.colors.success;
case 'error':
return euiTheme.colors.danger;
case 'warning':
return euiTheme.colors.warning;
default:
return 'subdued';
}
};
export const getExecutionStatusLabel = (status: AttackDiscoveryScheduleExecutionStatus) => {
switch (status) {
case 'active':
case 'ok':
return i18n.STATUS_SUCCESS;
case 'error':
return i18n.STATUS_FAILED;
case 'warning':
return i18n.STATUS_WARNING;
default:
return i18n.STATUS_UNKNOWN;
}
};

View file

@ -247,5 +247,7 @@
"@kbn/security-plugin-types-common",
"@kbn/management-settings-ids",
"@kbn/inference-endpoint-ui-common",
"@kbn/actions-types",
"@kbn/triggers-actions-ui-types",
]
}