mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
## 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:
parent
906c8978e7
commit
85093e5de7
48 changed files with 2611 additions and 467 deletions
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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, {});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 = (
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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() });
|
||||
});
|
||||
});
|
|
@ -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 });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue