mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[Security Solution][Auto migrations] Implement migration stop/resume UI (#224102)
## Summary Implement migration task stop/resume functionality in the UI. The _stop_ process takes a bit longer than the _resume_, that's because we ensure the aborted langGraph invocation completely settles, and the background process is terminated, before showing the _resume_ action, which involves polling for the migration state for a while. ## Screenshots  Stop feature demo: https://github.com/user-attachments/assets/37727d0c-c248-45ff-b9c7-220a59c153f6
This commit is contained in:
parent
f5d8d9a1db
commit
31fe87ae06
30 changed files with 695 additions and 192 deletions
|
@ -40790,7 +40790,6 @@
|
|||
"xpack.securitySolution.siemMigrations.rules.panel.result.summary.tableColumn.rules": "Règles",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.result.summary.tableColumn.status": "Statut",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.result.summary.title": "Résumé de la traduction",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.translate.button": "Lancer la traduction",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.uploadMacros.button": "Charger",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.uploadMissingResources": "Importez les listes de macros et de consultations.",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.uploadMissingResourcesDescription": "Cliquez sur Charger pour continuer à traduire {partialRulesCount} règles",
|
||||
|
|
|
@ -40754,7 +40754,6 @@
|
|||
"xpack.securitySolution.siemMigrations.rules.panel.result.summary.tableColumn.rules": "ルール",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.result.summary.tableColumn.status": "ステータス",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.result.summary.title": "変換概要",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.translate.button": "変換を開始",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.uploadMacros.button": "アップロード",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.uploadMissingResources": "欠落しているマクロとルックアップリストをアップロードします。",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.uploadMissingResourcesDescription": "[アップロード]をクリックして、{partialRulesCount}ルールの変換を続行",
|
||||
|
|
|
@ -40832,7 +40832,6 @@
|
|||
"xpack.securitySolution.siemMigrations.rules.panel.result.summary.tableColumn.rules": "规则",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.result.summary.tableColumn.status": "状态",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.result.summary.title": "转换摘要",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.translate.button": "开始转换",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.uploadMacros.button": "上传",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.uploadMissingResources": "上传缺失的宏和查找列表。",
|
||||
"xpack.securitySolution.siemMigrations.rules.panel.uploadMissingResourcesDescription": "单击“上传”以继续转换 {partialRulesCount} 个规则",
|
||||
|
|
|
@ -6,9 +6,6 @@
|
|||
*/
|
||||
|
||||
import { notificationServiceMock } from '@kbn/core/public/mocks';
|
||||
|
||||
import { createTGridMocks } from '@kbn/timelines-plugin/public/mock';
|
||||
|
||||
import {
|
||||
createKibanaContextProviderMock,
|
||||
createUseUiSettingMock,
|
||||
|
@ -16,9 +13,9 @@ import {
|
|||
createStartServicesMock,
|
||||
createWithKibanaMock,
|
||||
} from '../kibana_react.mock';
|
||||
import { mockApm } from '../../apm/service.mock';
|
||||
import { APP_UI_ID } from '../../../../../common/constants';
|
||||
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
|
||||
|
||||
export { useKibana } from './use_kibana';
|
||||
|
||||
const mockStartServicesMock = createStartServicesMock();
|
||||
export const KibanaServices = {
|
||||
|
@ -40,49 +37,6 @@ export const KibanaServices = {
|
|||
getBuildFlavor: jest.fn(() => 'traditional'),
|
||||
getPrebuiltRulesPackageVersion: jest.fn(() => undefined),
|
||||
};
|
||||
export const useKibana = jest.fn().mockReturnValue({
|
||||
services: {
|
||||
...mockStartServicesMock,
|
||||
apm: mockApm(),
|
||||
uiSettings: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
cases: mockCasesContract(),
|
||||
data: {
|
||||
...mockStartServicesMock.data,
|
||||
search: {
|
||||
...mockStartServicesMock.data.search,
|
||||
search: jest.fn().mockImplementation(() => ({
|
||||
subscribe: jest.fn().mockImplementation(() => ({
|
||||
error: jest.fn(),
|
||||
next: jest.fn(),
|
||||
unsubscribe: jest.fn(),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
query: {
|
||||
...mockStartServicesMock.data.query,
|
||||
filterManager: {
|
||||
addFilters: jest.fn(),
|
||||
getFilters: jest.fn(),
|
||||
getUpdates$: jest.fn().mockReturnValue({ subscribe: jest.fn() }),
|
||||
setAppFilters: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
osquery: {
|
||||
OsqueryResults: jest.fn().mockReturnValue(null),
|
||||
fetchAllLiveQueries: jest.fn().mockReturnValue({ data: { data: { items: [] } } }),
|
||||
},
|
||||
timelines: createTGridMocks(),
|
||||
savedObjectsTagging: {
|
||||
ui: {
|
||||
getTableColumnDefinition: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
export const useUiSetting = jest.fn(createUseUiSettingMock());
|
||||
export const useUiSetting$ = jest.fn(createUseUiSetting$Mock());
|
||||
export const useHttp = jest.fn().mockReturnValue(createStartServicesMock().http);
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { createTGridMocks } from '@kbn/timelines-plugin/public/mock';
|
||||
|
||||
import { createStartServicesMock } from '../kibana_react.mock';
|
||||
import { mockApm } from '../../apm/service.mock';
|
||||
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
|
||||
|
||||
const mockStartServicesMock = createStartServicesMock();
|
||||
|
||||
export const useKibana = jest.fn().mockReturnValue({
|
||||
services: {
|
||||
...mockStartServicesMock,
|
||||
apm: mockApm(),
|
||||
uiSettings: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
cases: mockCasesContract(),
|
||||
data: {
|
||||
...mockStartServicesMock.data,
|
||||
search: {
|
||||
...mockStartServicesMock.data.search,
|
||||
search: jest.fn().mockImplementation(() => ({
|
||||
subscribe: jest.fn().mockImplementation(() => ({
|
||||
error: jest.fn(),
|
||||
next: jest.fn(),
|
||||
unsubscribe: jest.fn(),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
query: {
|
||||
...mockStartServicesMock.data.query,
|
||||
filterManager: {
|
||||
addFilters: jest.fn(),
|
||||
getFilters: jest.fn(),
|
||||
getUpdates$: jest.fn().mockReturnValue({ subscribe: jest.fn() }),
|
||||
setAppFilters: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
osquery: {
|
||||
OsqueryResults: jest.fn().mockReturnValue(null),
|
||||
fetchAllLiveQueries: jest.fn().mockReturnValue({ data: { data: { items: [] } } }),
|
||||
},
|
||||
timelines: createTGridMocks(),
|
||||
savedObjectsTagging: {
|
||||
ui: {
|
||||
getTableColumnDefinition: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
|
@ -7,21 +7,11 @@
|
|||
|
||||
import {
|
||||
KibanaContextProvider,
|
||||
useKibana,
|
||||
useUiSetting,
|
||||
useUiSetting$,
|
||||
withKibana,
|
||||
} from '@kbn/kibana-react-plugin/public';
|
||||
import type { ApmBase } from '@elastic/apm-rum';
|
||||
import type { StartServices } from '../../../types';
|
||||
|
||||
const useTypedKibana = () => useKibana<StartServices>();
|
||||
|
||||
export {
|
||||
ApmBase,
|
||||
KibanaContextProvider,
|
||||
useTypedKibana as useKibana,
|
||||
useUiSetting,
|
||||
useUiSetting$,
|
||||
withKibana,
|
||||
};
|
||||
export { useKibana } from './use_kibana';
|
||||
export { ApmBase, KibanaContextProvider, useUiSetting, useUiSetting$, withKibana };
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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 { useKibana as useKibanaGeneric } from '@kbn/kibana-react-plugin/public';
|
||||
import type { StartServices } from '../../../types';
|
||||
|
||||
export const useKibana = useKibanaGeneric<StartServices>;
|
|
@ -23,6 +23,7 @@ export const siemMigrationEventNames = {
|
|||
[SiemMigrationsEventTypes.SetupMacrosQueryCopied]: 'Copy macros query',
|
||||
[SiemMigrationsEventTypes.SetupLookupNameCopied]: 'Copy lookup name',
|
||||
[SiemMigrationsEventTypes.StartMigration]: 'Start rule migration',
|
||||
[SiemMigrationsEventTypes.StopMigration]: 'Stop rule migration',
|
||||
[SiemMigrationsEventTypes.TranslatedRuleUpdate]: 'Update translated rule',
|
||||
[SiemMigrationsEventTypes.TranslatedRuleInstall]: 'Install translated rule',
|
||||
[SiemMigrationsEventTypes.TranslatedRuleBulkInstall]: 'Bulk install translated rules',
|
||||
|
@ -197,6 +198,11 @@ const eventSchemas: SiemMigrationsTelemetryEventSchemas = {
|
|||
},
|
||||
},
|
||||
},
|
||||
[SiemMigrationsEventTypes.StopMigration]: {
|
||||
...baseResultActionSchema,
|
||||
...migrationIdSchema,
|
||||
...eventNameSchema,
|
||||
},
|
||||
|
||||
// Translated Rule Events
|
||||
|
||||
|
|
|
@ -43,6 +43,10 @@ export enum SiemMigrationsEventTypes {
|
|||
* When the translation of rules is started
|
||||
*/
|
||||
StartMigration = 'siem_migrations_start_rules_migration',
|
||||
/**
|
||||
* When the translation of rules is stopped
|
||||
*/
|
||||
StopMigration = 'siem_migrations_stop_rules_migration',
|
||||
/**
|
||||
* When a translated rule is updated
|
||||
*/
|
||||
|
@ -112,6 +116,11 @@ export interface ReportStartMigrationActionParams extends BaseResultActionParams
|
|||
retryFilter?: SiemMigrationRetryFilter;
|
||||
}
|
||||
|
||||
export interface ReportStopMigrationActionParams extends BaseResultActionParams {
|
||||
eventName: string;
|
||||
migrationId: string;
|
||||
}
|
||||
|
||||
// Translated rule actions
|
||||
|
||||
export interface ReportTranslatedRuleUpdateActionParams {
|
||||
|
@ -149,6 +158,7 @@ export interface SiemMigrationsTelemetryEventsMap {
|
|||
[SiemMigrationsEventTypes.SetupLookupNameCopied]: ReportSetupLookupNameCopiedActionParams;
|
||||
[SiemMigrationsEventTypes.SetupResourcesUploaded]: ReportSetupResourcesUploadedActionParams;
|
||||
[SiemMigrationsEventTypes.StartMigration]: ReportStartMigrationActionParams;
|
||||
[SiemMigrationsEventTypes.StopMigration]: ReportStopMigrationActionParams;
|
||||
[SiemMigrationsEventTypes.TranslatedRuleUpdate]: ReportTranslatedRuleUpdateActionParams;
|
||||
[SiemMigrationsEventTypes.TranslatedRuleInstall]: ReportTranslatedRuleInstallActionParams;
|
||||
[SiemMigrationsEventTypes.TranslatedRuleBulkInstall]: ReportTranslatedRuleBulkInstallActionParams;
|
||||
|
|
|
@ -27,6 +27,7 @@ import {
|
|||
SIEM_RULE_MIGRATION_MISSING_PRIVILEGES_PATH,
|
||||
SIEM_RULE_MIGRATION_RULES_PATH,
|
||||
SIEM_RULE_MIGRATIONS_INTEGRATIONS_STATS_PATH,
|
||||
SIEM_RULE_MIGRATION_STOP_PATH,
|
||||
} from '../../../../common/siem_migrations/constants';
|
||||
import type {
|
||||
CreateRuleMigrationResponse,
|
||||
|
@ -46,6 +47,7 @@ import type {
|
|||
GetRuleMigrationRulesResponse,
|
||||
CreateRuleMigrationRulesRequestBody,
|
||||
GetRuleMigrationIntegrationsStatsResponse,
|
||||
StopRuleMigrationResponse,
|
||||
} from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen';
|
||||
|
||||
export interface GetRuleMigrationStatsParams {
|
||||
|
@ -189,6 +191,23 @@ export const startRuleMigration = async ({
|
|||
);
|
||||
};
|
||||
|
||||
export interface StopRuleMigrationParams {
|
||||
/** `id` of the migration to stop */
|
||||
migrationId: string;
|
||||
/** Optional AbortSignal for cancelling request */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
/** Stops a new migration with the provided rules. */
|
||||
export const stopRuleMigration = async ({
|
||||
migrationId,
|
||||
signal,
|
||||
}: StopRuleMigrationParams): Promise<StopRuleMigrationResponse> => {
|
||||
return KibanaServices.get().http.post<StopRuleMigrationResponse>(
|
||||
replaceParams(SIEM_RULE_MIGRATION_STOP_PATH, { migration_id: migrationId }),
|
||||
{ version: '1', signal }
|
||||
);
|
||||
};
|
||||
|
||||
export interface GetMigrationRulesParams {
|
||||
/** `id` of the migration to get rules documents for */
|
||||
migrationId: string;
|
||||
|
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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 { MigrationProgressPanel } from './migration_progress_panel';
|
||||
import { useStopMigration } from '../../service/hooks/use_stop_migration';
|
||||
import { SiemMigrationTaskStatus } from '../../../../../common/siem_migrations/constants';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import type { RuleMigrationStats } from '../../types';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana/use_kibana');
|
||||
|
||||
jest.mock('../../service/hooks/use_stop_migration');
|
||||
const useStopMigrationMock = useStopMigration as jest.Mock;
|
||||
const mockStopMigration = jest.fn();
|
||||
|
||||
const inProgressMigrationStats: RuleMigrationStats = {
|
||||
status: SiemMigrationTaskStatus.RUNNING,
|
||||
id: 'c44d2c7d-0de1-4231-8b82-0dcfd67a9fe3',
|
||||
rules: { total: 26, pending: 6, processing: 10, completed: 9, failed: 1 },
|
||||
created_at: '2025-05-27T12:12:17.563Z',
|
||||
last_updated_at: '2025-05-27T12:12:17.563Z',
|
||||
number: 1,
|
||||
};
|
||||
const preparingMigrationStats: RuleMigrationStats = {
|
||||
...inProgressMigrationStats,
|
||||
// status RUNNING and the same number of total and pending rules, means the migration is still preparing the environment
|
||||
status: SiemMigrationTaskStatus.RUNNING,
|
||||
rules: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 },
|
||||
};
|
||||
|
||||
const renderMigrationProgressPanel = (migrationStats: RuleMigrationStats) => {
|
||||
return render(<MigrationProgressPanel migrationStats={migrationStats} />, {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
};
|
||||
|
||||
describe('MigrationProgressPanel', () => {
|
||||
beforeEach(() => {
|
||||
useStopMigrationMock.mockReturnValue({
|
||||
stopMigration: mockStopMigration,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preparing migration', () => {
|
||||
beforeEach(() => {
|
||||
renderMigrationProgressPanel(preparingMigrationStats);
|
||||
});
|
||||
|
||||
it('should render description text correctly', () => {
|
||||
expect(screen.queryByTestId('ruleMigrationDescription')).toHaveTextContent(
|
||||
`Preparing environment for the AI powered translation.`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render spinner', () => {
|
||||
expect(screen.queryByTestId('ruleMigrationSpinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render progress bar', () => {
|
||||
expect(screen.queryByTestId('ruleMigrationProgressBar')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('In progress Migration', () => {
|
||||
beforeEach(() => {
|
||||
renderMigrationProgressPanel(inProgressMigrationStats);
|
||||
});
|
||||
|
||||
it('should render description text correctly', () => {
|
||||
expect(screen.getByTestId('ruleMigrationDescription')).toHaveTextContent(`Translating rules`);
|
||||
});
|
||||
|
||||
it('should render spinner', () => {
|
||||
expect(screen.queryByTestId('ruleMigrationSpinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render progress bar', () => {
|
||||
expect(screen.queryByTestId('ruleMigrationProgressBar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stop Migration', () => {
|
||||
it('should render stop migration button', async () => {
|
||||
renderMigrationProgressPanel(inProgressMigrationStats);
|
||||
|
||||
expect(screen.getByTestId('stopMigrationButton')).toHaveTextContent('Stop');
|
||||
});
|
||||
|
||||
it('should call stopMigration when stop button is clicked', async () => {
|
||||
renderMigrationProgressPanel(inProgressMigrationStats);
|
||||
|
||||
screen.getByTestId('stopMigrationButton').click();
|
||||
expect(mockStopMigration).toHaveBeenCalledWith(inProgressMigrationStats.id);
|
||||
});
|
||||
|
||||
it('should show loading state when stopping migration', async () => {
|
||||
useStopMigrationMock.mockReturnValue({
|
||||
stopMigration: mockStopMigration,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
renderMigrationProgressPanel(inProgressMigrationStats);
|
||||
|
||||
expect(screen.queryByTestId('ruleMigrationSpinner')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('stopMigrationButton')).toHaveTextContent('Stopping');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
|
@ -17,12 +17,14 @@ import {
|
|||
EuiSpacer,
|
||||
useEuiTheme,
|
||||
tint,
|
||||
EuiButtonEmpty,
|
||||
} from '@elastic/eui';
|
||||
import { AssistantIcon } from '@kbn/ai-assistant-icon';
|
||||
import { PanelText } from '../../../../common/components/panel_text';
|
||||
import type { RuleMigrationStats } from '../../types';
|
||||
import * as i18n from './translations';
|
||||
import { RuleMigrationsReadMore } from './read_more';
|
||||
import { useStopMigration } from '../../service/hooks/use_stop_migration';
|
||||
|
||||
export interface MigrationProgressPanelProps {
|
||||
migrationStats: RuleMigrationStats;
|
||||
|
@ -30,6 +32,12 @@ export interface MigrationProgressPanelProps {
|
|||
export const MigrationProgressPanel = React.memo<MigrationProgressPanelProps>(
|
||||
({ migrationStats }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const { stopMigration, isLoading: isStopping } = useStopMigration();
|
||||
|
||||
const onStopMigration = useCallback(() => {
|
||||
stopMigration(migrationStats.id);
|
||||
}, [migrationStats.id, stopMigration]);
|
||||
|
||||
const finishedCount = migrationStats.rules.completed + migrationStats.rules.failed;
|
||||
const progressValue = (finishedCount / migrationStats.rules.total) * 100;
|
||||
|
||||
|
@ -37,16 +45,32 @@ export const MigrationProgressPanel = React.memo<MigrationProgressPanelProps>(
|
|||
|
||||
return (
|
||||
<EuiPanel data-test-subj="migrationProgressPanel" hasShadow={false} hasBorder paddingSize="m">
|
||||
<EuiFlexGroup direction="column" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<PanelText size="s" semiBold>
|
||||
<p>{i18n.RULE_MIGRATION_TITLE(migrationStats.number)}</p>
|
||||
</PanelText>
|
||||
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup direction="column" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<PanelText size="s" semiBold>
|
||||
<p>{i18n.RULE_MIGRATION_TITLE(migrationStats.number)}</p>
|
||||
</PanelText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
{i18n.RULE_MIGRATION_PROGRESS_DESCRIPTION(migrationStats.rules.total)}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
{i18n.RULE_MIGRATION_PROGRESS_DESCRIPTION(migrationStats.rules.total)}
|
||||
</EuiText>
|
||||
<EuiButtonEmpty
|
||||
iconType="stop"
|
||||
isLoading={isStopping}
|
||||
onClick={onStopMigration}
|
||||
data-test-subj="stopMigrationButton"
|
||||
>
|
||||
{isStopping
|
||||
? i18n.RULE_MIGRATION_STOPPING_TRANSLATION_BUTTON
|
||||
: i18n.RULE_MIGRATION_STOP_TRANSLATION_BUTTON}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
|
@ -55,13 +79,15 @@ export const MigrationProgressPanel = React.memo<MigrationProgressPanelProps>(
|
|||
<EuiIcon size="m" type={AssistantIcon} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<PanelText size="s" subdued>
|
||||
<PanelText size="s" subdued data-test-subj="ruleMigrationDescription">
|
||||
{preparing ? i18n.RULE_MIGRATION_PREPARING : i18n.RULE_MIGRATION_TRANSLATING}
|
||||
</PanelText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="s" />
|
||||
</EuiFlexItem>
|
||||
{!isStopping && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="s" data-test-subj="ruleMigrationSpinner" />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
{!preparing && (
|
||||
<>
|
||||
|
@ -70,6 +96,7 @@ export const MigrationProgressPanel = React.memo<MigrationProgressPanelProps>(
|
|||
valueText={`${Math.floor(progressValue)}%`}
|
||||
max={100}
|
||||
color={tint(euiTheme.colors.success, 0.25)}
|
||||
data-test-subj="ruleMigrationProgressBar"
|
||||
/>
|
||||
<EuiSpacer size="xs" />
|
||||
<RuleMigrationsReadMore />
|
||||
|
|
|
@ -13,24 +13,14 @@ import { useStartMigration } from '../../service/hooks/use_start_migration';
|
|||
import { SiemMigrationTaskStatus } from '../../../../../common/siem_migrations/constants';
|
||||
import type { RuleMigrationResourceBase } from '../../../../../common/siem_migrations/model/rule_migration.gen';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana/use_kibana');
|
||||
|
||||
jest.mock('../data_input_flyout/context', () => ({
|
||||
useRuleMigrationDataInputContext: () => ({
|
||||
openFlyout: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../common/lib/kibana/kibana_react', () => ({
|
||||
useKibana: jest.fn(() => ({
|
||||
services: {
|
||||
siemMigrations: {
|
||||
rules: {
|
||||
telemetry: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../service/hooks/use_start_migration');
|
||||
const useStartMigrationMock = useStartMigration as jest.Mock;
|
||||
const mockStartMigration = jest.fn();
|
||||
|
@ -96,14 +86,24 @@ describe('MigrationReadyPanel', () => {
|
|||
it('should render description text correctly', () => {
|
||||
render(<MigrationReadyPanel migrationStats={mockMigrationStatsReady} />);
|
||||
expect(screen.getByTestId('ruleMigrationDescription')).toHaveTextContent(
|
||||
`Migration of 6 rules is created but the translation has not started yet.`
|
||||
`Migration of 6 rules is created and ready to start.`
|
||||
);
|
||||
});
|
||||
|
||||
it('should render start migration button', () => {
|
||||
render(<MigrationReadyPanel migrationStats={mockMigrationStatsReady} />);
|
||||
expect(screen.getByTestId('startMigrationButton')).toBeVisible();
|
||||
expect(screen.getByTestId('startMigrationButton')).toHaveTextContent('Start translation');
|
||||
expect(screen.getByTestId('startMigrationButton')).toHaveTextContent('Start');
|
||||
});
|
||||
|
||||
it('should render starting migration button while loading', () => {
|
||||
useStartMigrationMock.mockReturnValue({
|
||||
startMigration: mockStartMigration,
|
||||
isLoading: true,
|
||||
});
|
||||
render(<MigrationReadyPanel migrationStats={mockMigrationStatsReady} />);
|
||||
expect(screen.getByTestId('startMigrationButton')).toBeVisible();
|
||||
expect(screen.getByTestId('startMigrationButton')).toHaveTextContent('Starting');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -120,21 +120,31 @@ describe('MigrationReadyPanel', () => {
|
|||
|
||||
it('should render start migration button when there is an error', () => {
|
||||
render(<MigrationReadyPanel migrationStats={mockMigrationStateWithError} />);
|
||||
expect(screen.queryByTestId('startMigrationButton')).toHaveTextContent('Start translation');
|
||||
expect(screen.queryByTestId('startMigrationButton')).toHaveTextContent('Start');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aborted Migration', () => {
|
||||
describe('Stopped Migration', () => {
|
||||
it('should render aborted migration message', () => {
|
||||
render(<MigrationReadyPanel migrationStats={mockMigrationStatsStopped} />);
|
||||
expect(screen.getByTestId('ruleMigrationDescription')).toHaveTextContent(
|
||||
'Migration of 6 rules was stopped. You can resume it any time.'
|
||||
'Migration of 6 rules was stopped, you can resume it any time.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should render correct start migration button for aborted migration', () => {
|
||||
render(<MigrationReadyPanel migrationStats={mockMigrationStatsStopped} />);
|
||||
expect(screen.getByTestId('startMigrationButton')).toHaveTextContent('Resume translation');
|
||||
expect(screen.getByTestId('startMigrationButton')).toHaveTextContent('Resume');
|
||||
});
|
||||
|
||||
it('should render resuming migration button while loading', () => {
|
||||
useStartMigrationMock.mockReturnValue({
|
||||
startMigration: mockStartMigration,
|
||||
isLoading: true,
|
||||
});
|
||||
render(<MigrationReadyPanel migrationStats={mockMigrationStatsStopped} />);
|
||||
expect(screen.getByTestId('startMigrationButton')).toBeVisible();
|
||||
expect(screen.getByTestId('startMigrationButton')).toHaveTextContent('Resuming');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -156,7 +166,7 @@ describe('MigrationReadyPanel', () => {
|
|||
render(<MigrationReadyPanel migrationStats={mockMigrationStatsReady} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ruleMigrationDescription')).toHaveTextContent(
|
||||
'Migration of 6 rules is created but the translation has not started yet. Upload macros & lookups and start the translation process.'
|
||||
'Migration of 6 rules is created and ready to start. You can also upload the missing macros & lookups for more accurate results.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,10 +6,17 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiPanel, EuiSpacer } from '@elastic/eui';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiButton,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiButtonEmpty,
|
||||
} from '@elastic/eui';
|
||||
import { SiemMigrationTaskStatus } from '../../../../../common/siem_migrations/constants';
|
||||
import { CenteredLoadingSpinner } from '../../../../common/components/centered_loading_spinner';
|
||||
import { useKibana } from '../../../../common/lib/kibana/kibana_react';
|
||||
import { useKibana } from '../../../../common/lib/kibana/use_kibana';
|
||||
import type { RuleMigrationResourceBase } from '../../../../../common/siem_migrations/model/rule_migration.gen';
|
||||
import { PanelText } from '../../../../common/components/panel_text';
|
||||
import { useStartMigration } from '../../service/hooks/use_start_migration';
|
||||
|
@ -53,7 +60,6 @@ export const MigrationReadyPanel = React.memo<MigrationReadyPanelProps>(({ migra
|
|||
if (isStopped) {
|
||||
return i18n.RULE_MIGRATION_STOPPED_DESCRIPTION(migrationStats.rules.total);
|
||||
}
|
||||
|
||||
return i18n.RULE_MIGRATION_READY_DESCRIPTION(migrationStats.rules.total);
|
||||
}, [migrationStats.last_execution?.error, migrationStats.rules.total, isStopped]);
|
||||
|
||||
|
@ -70,11 +76,9 @@ export const MigrationReadyPanel = React.memo<MigrationReadyPanelProps>(({ migra
|
|||
<EuiFlexItem>
|
||||
<PanelText data-test-subj="ruleMigrationDescription" size="s" subdued>
|
||||
<span>{migrationPanelDescription}</span>
|
||||
<span>
|
||||
{!isLoading && missingResources.length > 0
|
||||
? ` ${i18n.RULE_MIGRATION_READY_MISSING_RESOURCES}`
|
||||
: ''}
|
||||
</span>
|
||||
{!isLoading && missingResources.length > 0 && (
|
||||
<span> {i18n.RULE_MIGRATION_READY_MISSING_RESOURCES}</span>
|
||||
)}
|
||||
</PanelText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
@ -82,22 +86,24 @@ export const MigrationReadyPanel = React.memo<MigrationReadyPanelProps>(({ migra
|
|||
{isLoading ? (
|
||||
<CenteredLoadingSpinner />
|
||||
) : (
|
||||
<EuiFlexItem grow={false}>
|
||||
{missingResources.length > 0 ? (
|
||||
<EuiButton
|
||||
data-test-subj="ruleMigrationMissingResourcesButton"
|
||||
fill
|
||||
iconType="download"
|
||||
iconSide="right"
|
||||
onClick={onOpenFlyout}
|
||||
size="s"
|
||||
>
|
||||
{i18n.RULE_MIGRATION_UPLOAD_BUTTON}
|
||||
</EuiButton>
|
||||
) : (
|
||||
<StartTranslationButton migrationId={migrationStats.id} isStopped={isStopped} />
|
||||
<>
|
||||
{missingResources.length > 0 && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="ruleMigrationMissingResourcesButton"
|
||||
iconType="download"
|
||||
iconSide="right"
|
||||
onClick={onOpenFlyout}
|
||||
size="s"
|
||||
>
|
||||
{i18n.RULE_MIGRATION_UPLOAD_BUTTON}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<StartTranslationButton migrationId={migrationStats.id} isStopped={isStopped} />
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
{migrationStats.last_execution?.error && (
|
||||
|
@ -118,17 +124,27 @@ const StartTranslationButton = React.memo<{ migrationId: string; isStopped: bool
|
|||
startMigration(migrationId);
|
||||
}, [migrationId, startMigration]);
|
||||
|
||||
const text = useMemo(() => {
|
||||
if (isStopped) {
|
||||
return isLoading
|
||||
? i18n.RULE_MIGRATION_RESUMING_TRANSLATION_BUTTON
|
||||
: i18n.RULE_MIGRATION_RESUME_TRANSLATION_BUTTON;
|
||||
} else {
|
||||
return isLoading
|
||||
? i18n.RULE_MIGRATION_STARTING_TRANSLATION_BUTTON
|
||||
: i18n.RULE_MIGRATION_START_TRANSLATION_BUTTON;
|
||||
}
|
||||
}, [isStopped, isLoading]);
|
||||
|
||||
return (
|
||||
<EuiButton
|
||||
data-test-subj={'startMigrationButton'}
|
||||
fill
|
||||
fill={!isStopped}
|
||||
onClick={onStartMigration}
|
||||
isLoading={isLoading}
|
||||
size="s"
|
||||
>
|
||||
{isStopped
|
||||
? i18n.RULE_MIGRATION_RESTART_TRANSLATION_BUTTON
|
||||
: i18n.RULE_MIGRATION_START_TRANSLATION_BUTTON}
|
||||
{text}
|
||||
</EuiButton>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import React from 'react';
|
|||
import { EuiLink } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { PanelText } from '../../../../common/components/panel_text';
|
||||
import { useKibana } from '../../../../common/lib/kibana/kibana_react';
|
||||
import { useKibana } from '../../../../common/lib/kibana/use_kibana';
|
||||
|
||||
export const RuleMigrationsReadMore = React.memo(() => {
|
||||
const docLink = useKibana().services.docLinks.links.securitySolution.siemMigrations;
|
||||
|
|
|
@ -9,8 +9,7 @@ import { i18n } from '@kbn/i18n';
|
|||
|
||||
export const RULE_MIGRATION_READY_DESCRIPTION = (totalRules: number) =>
|
||||
i18n.translate('xpack.securitySolution.siemMigrations.rules.panel.ready.description', {
|
||||
defaultMessage:
|
||||
'Migration of {totalRules} rules is created but the translation has not started yet.',
|
||||
defaultMessage: 'Migration of {totalRules} rules is created and ready to start.',
|
||||
values: { totalRules },
|
||||
});
|
||||
|
||||
|
@ -24,24 +23,39 @@ export const RULE_MIGRATION_ERROR_DESCRIPTION = (totalRules: number) => {
|
|||
|
||||
export const RULE_MIGRATION_STOPPED_DESCRIPTION = (totalRules: number) => {
|
||||
return i18n.translate('xpack.securitySolution.siemMigrations.rules.panel.stopped.description', {
|
||||
defaultMessage: 'Migration of {totalRules} rules was stopped. You can resume it any time.',
|
||||
defaultMessage: 'Migration of {totalRules} rules was stopped, you can resume it any time.',
|
||||
values: { totalRules },
|
||||
});
|
||||
};
|
||||
|
||||
export const RULE_MIGRATION_READY_MISSING_RESOURCES = i18n.translate(
|
||||
'xpack.securitySolution.siemMigrations.rules.panel.ready.missingResources',
|
||||
{ defaultMessage: 'Upload macros & lookups and start the translation process.' }
|
||||
{ defaultMessage: 'You can also upload the missing macros & lookups for more accurate results.' }
|
||||
);
|
||||
|
||||
export const RULE_MIGRATION_START_TRANSLATION_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.siemMigrations.rules.panel.translate.button',
|
||||
{ defaultMessage: 'Start translation' }
|
||||
'xpack.securitySolution.siemMigrations.rules.panel.translate.startButton',
|
||||
{ defaultMessage: 'Start' }
|
||||
);
|
||||
|
||||
export const RULE_MIGRATION_RESTART_TRANSLATION_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.siemMigrations.rules.panel.translate.restartButton',
|
||||
{ defaultMessage: 'Resume translation' }
|
||||
export const RULE_MIGRATION_STARTING_TRANSLATION_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.siemMigrations.rules.panel.translate.startingButton',
|
||||
{ defaultMessage: 'Starting' }
|
||||
);
|
||||
export const RULE_MIGRATION_STOP_TRANSLATION_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.siemMigrations.rules.panel.translate.stopButton',
|
||||
{ defaultMessage: 'Stop' }
|
||||
);
|
||||
export const RULE_MIGRATION_STOPPING_TRANSLATION_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.siemMigrations.rules.panel.translate.stoppingButton',
|
||||
{ defaultMessage: 'Stopping' }
|
||||
);
|
||||
export const RULE_MIGRATION_RESUME_TRANSLATION_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.siemMigrations.rules.panel.translate.resumeButton',
|
||||
{ defaultMessage: 'Resume' }
|
||||
);
|
||||
export const RULE_MIGRATION_RESUMING_TRANSLATION_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.siemMigrations.rules.panel.translate.resumingButton',
|
||||
{ defaultMessage: 'Resuming' }
|
||||
);
|
||||
|
||||
export const RULE_MIGRATION_TITLE = (number: number) =>
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { AssistantIcon } from '@kbn/ai-assistant-icon';
|
||||
import type { SpacerSize } from '@elastic/eui/src/components/spacer/spacer';
|
||||
import { useKibana } from '../../../../common/lib/kibana/kibana_react';
|
||||
import { useKibana } from '../../../../common/lib/kibana/use_kibana';
|
||||
import type { RuleMigrationResourceBase } from '../../../../../common/siem_migrations/model/rule_migration.gen';
|
||||
import { PanelText } from '../../../../common/components/panel_text';
|
||||
import { useGetMissingResources } from '../../service/hooks/use_get_missing_resources';
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useReducer } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useKibana } from '../../../../common/lib/kibana/kibana_react';
|
||||
import { reducer, initialState } from './common/api_request_reducer';
|
||||
|
||||
export const RULES_DATA_INPUT_STOP_MIGRATION_SUCCESS = i18n.translate(
|
||||
'xpack.securitySolution.siemMigrations.rules.service.stopMigrationSuccess',
|
||||
{ defaultMessage: 'Migration stopped successfully.' }
|
||||
);
|
||||
export const RULES_DATA_INPUT_STOP_MIGRATION_ERROR = i18n.translate(
|
||||
'xpack.securitySolution.siemMigrations.rules.service.stopMigrationError',
|
||||
{ defaultMessage: 'Error stopping migration.' }
|
||||
);
|
||||
|
||||
export type StopMigration = (migrationId: string) => void;
|
||||
export type OnSuccess = () => void;
|
||||
|
||||
export const useStopMigration = (onSuccess?: OnSuccess) => {
|
||||
const { siemMigrations, notifications } = useKibana().services;
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const stopMigration = useCallback<StopMigration>(
|
||||
(migrationId) => {
|
||||
(async () => {
|
||||
try {
|
||||
dispatch({ type: 'start' });
|
||||
const { stopped } = await siemMigrations.rules.stopRuleMigration(migrationId);
|
||||
|
||||
if (stopped) {
|
||||
notifications.toasts.addSuccess(RULES_DATA_INPUT_STOP_MIGRATION_SUCCESS);
|
||||
}
|
||||
dispatch({ type: 'success' });
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
const apiError = err.body ?? err;
|
||||
notifications.toasts.addError(apiError, {
|
||||
title: RULES_DATA_INPUT_STOP_MIGRATION_ERROR,
|
||||
});
|
||||
dispatch({ type: 'error', error: apiError });
|
||||
}
|
||||
})();
|
||||
},
|
||||
[siemMigrations.rules, notifications.toasts, onSuccess]
|
||||
);
|
||||
|
||||
return { isLoading: state.loading, error: state.error, stopMigration };
|
||||
};
|
|
@ -19,6 +19,8 @@ import {
|
|||
createRuleMigration,
|
||||
upsertMigrationResources,
|
||||
startRuleMigration as startRuleMigrationAPI,
|
||||
stopRuleMigration as stopRuleMigrationAPI,
|
||||
getRuleMigrationStats,
|
||||
getRuleMigrationsStatsAll,
|
||||
addRulesToMigration,
|
||||
} from '../api';
|
||||
|
@ -31,7 +33,7 @@ import type { StartPluginsDependencies } from '../../../types';
|
|||
import { getMissingCapabilities } from './capabilities';
|
||||
import * as i18n from './translations';
|
||||
import {
|
||||
REQUEST_POLLING_INTERVAL_SECONDS,
|
||||
TASK_STATS_POLLING_SLEEP_SECONDS,
|
||||
SiemRulesMigrationsService,
|
||||
} from './rule_migrations_service';
|
||||
import type { CreateRuleMigrationRulesRequestBody } from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen';
|
||||
|
@ -42,6 +44,7 @@ jest.mock('../api', () => ({
|
|||
createRuleMigration: jest.fn(),
|
||||
upsertMigrationResources: jest.fn(),
|
||||
startRuleMigration: jest.fn(),
|
||||
stopRuleMigration: jest.fn(),
|
||||
getRuleMigrationStats: jest.fn(),
|
||||
getRuleMigrationsStatsAll: jest.fn(),
|
||||
getMissingResources: jest.fn(),
|
||||
|
@ -77,8 +80,22 @@ jest.mock('./notifications/missing_capabilities_notification', () => ({
|
|||
getMissingCapabilitiesToast: jest.fn().mockReturnValue({ title: 'Missing Capabilities' }),
|
||||
}));
|
||||
|
||||
const mockGetRuleMigrationStats = getRuleMigrationStats as jest.Mock;
|
||||
const mockGetRuleMigrationsStatsAll = getRuleMigrationsStatsAll as jest.Mock;
|
||||
const mockStartRuleMigrationAPI = startRuleMigrationAPI as jest.Mock;
|
||||
const mockStopRuleMigrationAPI = stopRuleMigrationAPI as jest.Mock;
|
||||
const mockGetMissingCapabilities = getMissingCapabilities as jest.Mock;
|
||||
|
||||
// --- End of mocks ---
|
||||
|
||||
const defaultMigrationStats = {
|
||||
id: 'mig-1',
|
||||
status: SiemMigrationTaskStatus.READY,
|
||||
rules: { total: 100, pending: 100, processing: 0, completed: 0, failed: 0 },
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
last_updated_at: '2025-01-01T01:00:00Z',
|
||||
};
|
||||
|
||||
describe('SiemRulesMigrationsService', () => {
|
||||
let service: SiemRulesMigrationsService;
|
||||
let mockCore: CoreStart;
|
||||
|
@ -107,8 +124,11 @@ describe('SiemRulesMigrationsService', () => {
|
|||
},
|
||||
} as unknown as StartPluginsDependencies;
|
||||
|
||||
// Ensure getRuleMigrationsStatsAll returns an empty array by default (so polling exits quickly)
|
||||
(getRuleMigrationsStatsAll as jest.Mock).mockResolvedValue([]);
|
||||
mockGetRuleMigrationStats.mockResolvedValue(defaultMigrationStats);
|
||||
mockGetRuleMigrationsStatsAll.mockResolvedValue([]);
|
||||
mockStartRuleMigrationAPI.mockResolvedValue({ started: true });
|
||||
mockStopRuleMigrationAPI.mockResolvedValue({ stopped: true });
|
||||
mockGetMissingCapabilities.mockReturnValue([]);
|
||||
|
||||
// Instantiate the service – note that the constructor calls getActiveSpace and startPolling
|
||||
service = new SiemRulesMigrationsService(mockCore, mockPlugins, mockTelemetry);
|
||||
|
@ -196,7 +216,7 @@ describe('SiemRulesMigrationsService', () => {
|
|||
|
||||
describe('startRuleMigration', () => {
|
||||
it('should notify and not start migration if missing capabilities exist', async () => {
|
||||
(getMissingCapabilities as jest.Mock).mockReturnValue([{ capability: 'cap' }]);
|
||||
mockGetMissingCapabilities.mockReturnValue([{ capability: 'cap' }]);
|
||||
|
||||
const result = await service.startRuleMigration('mig-1');
|
||||
expect(mockNotifications.toasts.add).toHaveBeenCalled();
|
||||
|
@ -204,7 +224,7 @@ describe('SiemRulesMigrationsService', () => {
|
|||
});
|
||||
|
||||
it('should notify and not start migration if connectorId is missing', async () => {
|
||||
(getMissingCapabilities as jest.Mock).mockReturnValue([]);
|
||||
mockGetMissingCapabilities.mockReturnValue([]);
|
||||
// Force connectorId to be missing
|
||||
jest.spyOn(service.connectorIdStorage, 'get').mockReturnValue(undefined);
|
||||
|
||||
|
@ -214,23 +234,36 @@ describe('SiemRulesMigrationsService', () => {
|
|||
});
|
||||
|
||||
it('should start migration successfully when capabilities and connectorId are present', async () => {
|
||||
(getMissingCapabilities as jest.Mock).mockReturnValue([]);
|
||||
mockGetMissingCapabilities.mockReturnValue([]);
|
||||
// Simulate a valid connector id and trace options
|
||||
jest.spyOn(service.connectorIdStorage, 'get').mockReturnValue('connector-123');
|
||||
jest.spyOn(service.traceOptionsStorage, 'get').mockReturnValue({
|
||||
langSmithProject: 'proj',
|
||||
langSmithApiKey: 'key',
|
||||
} as TraceOptions);
|
||||
(startRuleMigrationAPI as jest.Mock).mockResolvedValue({ started: true });
|
||||
mockStartRuleMigrationAPI.mockResolvedValue({ started: true });
|
||||
|
||||
// Simulate multiple responses to mimic polling behavior
|
||||
let statsCalls = 0;
|
||||
mockGetRuleMigrationStats.mockImplementation(async () => {
|
||||
statsCalls++;
|
||||
if (statsCalls < 2) {
|
||||
return { ...defaultMigrationStats, status: SiemMigrationTaskStatus.READY };
|
||||
}
|
||||
return { ...defaultMigrationStats, status: SiemMigrationTaskStatus.RUNNING };
|
||||
});
|
||||
|
||||
// Spy on startPolling to ensure it is called after starting the migration
|
||||
const startPollingSpy = jest.spyOn(service, 'startPolling');
|
||||
// @ts-ignore (spying on a private method)
|
||||
const stopMigrationPollingSpy = jest.spyOn(service, 'migrationTaskPollingUntil');
|
||||
|
||||
const result = await service.startRuleMigration(
|
||||
'mig-1',
|
||||
SiemMigrationRetryFilter.NOT_FULLY_TRANSLATED
|
||||
);
|
||||
|
||||
expect(startRuleMigrationAPI).toHaveBeenCalledWith({
|
||||
expect(mockStartRuleMigrationAPI).toHaveBeenCalledWith({
|
||||
migrationId: 'mig-1',
|
||||
settings: {
|
||||
connectorId: 'connector-123',
|
||||
|
@ -240,17 +273,53 @@ describe('SiemRulesMigrationsService', () => {
|
|||
langSmithOptions: { project_name: 'proj', api_key: 'key' },
|
||||
});
|
||||
expect(startPollingSpy).toHaveBeenCalled();
|
||||
expect(stopMigrationPollingSpy).toHaveBeenCalled();
|
||||
expect(mockGetRuleMigrationStats).toHaveBeenCalledTimes(statsCalls);
|
||||
expect(result).toEqual({ started: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopRuleMigration', () => {
|
||||
it('should notify and not stop migration if missing capabilities exist', async () => {
|
||||
mockGetMissingCapabilities.mockReturnValue([{ capability: 'cap' }]);
|
||||
|
||||
const result = await service.stopRuleMigration('mig-1');
|
||||
expect(mockNotifications.toasts.add).toHaveBeenCalled();
|
||||
expect(result).toEqual({ stopped: false });
|
||||
});
|
||||
|
||||
it('should stop migration successfully', async () => {
|
||||
mockGetMissingCapabilities.mockReturnValue([]);
|
||||
mockStopRuleMigrationAPI.mockResolvedValue({ stopped: true });
|
||||
// Simulate multiple responses to mimic polling behavior
|
||||
let statsCalls = 0;
|
||||
mockGetRuleMigrationStats.mockImplementation(async () => {
|
||||
statsCalls++;
|
||||
if (statsCalls < 2) {
|
||||
return { ...defaultMigrationStats, status: SiemMigrationTaskStatus.RUNNING };
|
||||
}
|
||||
return { ...defaultMigrationStats, status: SiemMigrationTaskStatus.FINISHED };
|
||||
});
|
||||
|
||||
// @ts-ignore (spying on a private method)
|
||||
const stopMigrationPollingSpy = jest.spyOn(service, 'migrationTaskPollingUntil');
|
||||
|
||||
const result = await service.stopRuleMigration('mig-1');
|
||||
|
||||
expect(mockStopRuleMigrationAPI).toHaveBeenCalledWith({ migrationId: 'mig-1' });
|
||||
expect(stopMigrationPollingSpy).toHaveBeenCalled();
|
||||
expect(mockGetRuleMigrationStats).toHaveBeenCalledTimes(statsCalls);
|
||||
expect(result).toEqual({ stopped: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRuleMigrationsStats', () => {
|
||||
it('should fetch and update latest stats', async () => {
|
||||
const statsArray = [
|
||||
{ id: 'mig-1', status: SiemMigrationTaskStatus.RUNNING },
|
||||
{ id: 'mig-2', status: SiemMigrationTaskStatus.FINISHED },
|
||||
];
|
||||
(getRuleMigrationsStatsAll as jest.Mock).mockResolvedValue(statsArray);
|
||||
mockGetRuleMigrationsStatsAll.mockResolvedValue(statsArray);
|
||||
|
||||
const result = await service.getRuleMigrationsStats();
|
||||
expect(getRuleMigrationsStatsAll).toHaveBeenCalled();
|
||||
|
@ -291,7 +360,7 @@ describe('SiemRulesMigrationsService', () => {
|
|||
await Promise.resolve();
|
||||
|
||||
// Fast-forward the timer by the polling interval
|
||||
jest.advanceTimersByTime(REQUEST_POLLING_INTERVAL_SECONDS * 1000);
|
||||
jest.advanceTimersByTime(TASK_STATS_POLLING_SLEEP_SECONDS * 1000);
|
||||
// Resolve the timeout promise
|
||||
await Promise.resolve();
|
||||
// Resolve the second getRuleMigrationsStats promise
|
||||
|
@ -333,11 +402,11 @@ describe('SiemRulesMigrationsService', () => {
|
|||
await Promise.resolve();
|
||||
|
||||
// Fast-forward the timer by the polling interval
|
||||
jest.advanceTimersByTime(REQUEST_POLLING_INTERVAL_SECONDS * 1000);
|
||||
jest.advanceTimersByTime(TASK_STATS_POLLING_SLEEP_SECONDS * 1000);
|
||||
// Resolve the timeout promise
|
||||
await Promise.resolve();
|
||||
|
||||
expect(startRuleMigrationAPI).not.toHaveBeenCalled();
|
||||
expect(mockStartRuleMigrationAPI).not.toHaveBeenCalled();
|
||||
|
||||
// Restore real timers.
|
||||
jest.useRealTimers();
|
||||
|
@ -363,12 +432,12 @@ describe('SiemRulesMigrationsService', () => {
|
|||
await Promise.resolve();
|
||||
|
||||
// Fast-forward the timer by the polling interval
|
||||
jest.advanceTimersByTime(REQUEST_POLLING_INTERVAL_SECONDS * 1000);
|
||||
jest.advanceTimersByTime(TASK_STATS_POLLING_SLEEP_SECONDS * 1000);
|
||||
// Resolve the timeout promise
|
||||
await Promise.resolve();
|
||||
|
||||
// Expect that the migration was resumed
|
||||
expect(startRuleMigrationAPI).not.toHaveBeenCalled();
|
||||
expect(mockStartRuleMigrationAPI).not.toHaveBeenCalled();
|
||||
|
||||
// Restore real timers.
|
||||
jest.useRealTimers();
|
||||
|
@ -401,12 +470,12 @@ describe('SiemRulesMigrationsService', () => {
|
|||
await Promise.resolve();
|
||||
|
||||
// Fast-forward the timer by the polling interval
|
||||
jest.advanceTimersByTime(REQUEST_POLLING_INTERVAL_SECONDS * 1000);
|
||||
jest.advanceTimersByTime(TASK_STATS_POLLING_SLEEP_SECONDS * 1000);
|
||||
// Resolve the timeout promise
|
||||
await Promise.resolve();
|
||||
|
||||
// Expect that the migration was resumed
|
||||
expect(startRuleMigrationAPI).not.toHaveBeenCalled();
|
||||
expect(mockStartRuleMigrationAPI).not.toHaveBeenCalled();
|
||||
|
||||
// Restore real timers.
|
||||
jest.useRealTimers();
|
||||
|
@ -439,12 +508,12 @@ describe('SiemRulesMigrationsService', () => {
|
|||
await Promise.resolve();
|
||||
|
||||
// Fast-forward the timer by the polling interval
|
||||
jest.advanceTimersByTime(REQUEST_POLLING_INTERVAL_SECONDS * 1000);
|
||||
jest.advanceTimersByTime(TASK_STATS_POLLING_SLEEP_SECONDS * 1000);
|
||||
// Resolve the timeout promise
|
||||
await Promise.resolve();
|
||||
|
||||
// Expect that the migration was resumed
|
||||
expect(startRuleMigrationAPI).toHaveBeenCalledWith({
|
||||
expect(mockStartRuleMigrationAPI).toHaveBeenCalledWith({
|
||||
migrationId: 'mig-1',
|
||||
settings: {
|
||||
connectorId: 'connector-last',
|
||||
|
|
|
@ -18,6 +18,7 @@ import type { RuleMigrationTaskStats } from '../../../../common/siem_migrations/
|
|||
import type {
|
||||
CreateRuleMigrationRulesRequestBody,
|
||||
StartRuleMigrationResponse,
|
||||
StopRuleMigrationResponse,
|
||||
UpsertRuleMigrationResourcesRequestBody,
|
||||
} from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen';
|
||||
import type { SiemMigrationRetryFilter } from '../../../../common/siem_migrations/constants';
|
||||
|
@ -43,7 +44,8 @@ import { getMissingCapabilitiesToast } from './notifications/missing_capabilitie
|
|||
const NAMESPACE_TRACE_OPTIONS_SESSION_STORAGE_KEY =
|
||||
`${DEFAULT_ASSISTANT_NAMESPACE}.${TRACE_OPTIONS_SESSION_STORAGE_KEY}` as const;
|
||||
|
||||
export const REQUEST_POLLING_INTERVAL_SECONDS = 10 as const;
|
||||
export const TASK_STATS_POLLING_SLEEP_SECONDS = 10 as const;
|
||||
export const START_STOP_POLLING_SLEEP_SECONDS = 1 as const;
|
||||
const CREATE_MIGRATION_BODY_BATCH_SIZE = 50 as const;
|
||||
|
||||
export class SiemRulesMigrationsService {
|
||||
|
@ -70,18 +72,22 @@ export class SiemRulesMigrationsService {
|
|||
});
|
||||
}
|
||||
|
||||
/** Accessor for the rule migrations API client */
|
||||
public get api() {
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Returns the latest stats observable, which is updated every time the stats are fetched */
|
||||
public getLatestStats$(): Observable<RuleMigrationStats[] | null> {
|
||||
return this.latestStats$.asObservable().pipe(distinctUntilChanged(isEqual));
|
||||
}
|
||||
|
||||
/** Returns any missing capabilities for the user to use this feature */
|
||||
public getMissingCapabilities(level?: CapabilitiesLevel): MissingCapability[] {
|
||||
return getMissingCapabilities(this.core.application.capabilities, level);
|
||||
}
|
||||
|
||||
/** Checks if the user has any missing capabilities for this feature */
|
||||
public hasMissingCapabilities(level?: CapabilitiesLevel): boolean {
|
||||
return this.getMissingCapabilities(level).length > 0;
|
||||
}
|
||||
|
@ -95,6 +101,7 @@ export class SiemRulesMigrationsService {
|
|||
);
|
||||
}
|
||||
|
||||
/** Starts polling the rule migrations stats if not already polling and if the feature is available to the user */
|
||||
public startPolling() {
|
||||
if (this.isPolling || !this.isAvailable()) {
|
||||
return;
|
||||
|
@ -109,6 +116,7 @@ export class SiemRulesMigrationsService {
|
|||
});
|
||||
}
|
||||
|
||||
/** Adds rules to a rule migration, batching the requests to avoid hitting the max payload size limit of the API */
|
||||
public async addRulesToMigration(
|
||||
migrationId: string,
|
||||
rules: CreateRuleMigrationRulesRequestBody
|
||||
|
@ -125,6 +133,7 @@ export class SiemRulesMigrationsService {
|
|||
}
|
||||
}
|
||||
|
||||
/** Creates a rule migration and adds the rules to it, returning the migration ID */
|
||||
public async createRuleMigration(data: CreateRuleMigrationRulesRequestBody): Promise<string> {
|
||||
const rulesCount = data.length;
|
||||
if (rulesCount === 0) {
|
||||
|
@ -145,6 +154,7 @@ export class SiemRulesMigrationsService {
|
|||
}
|
||||
}
|
||||
|
||||
/** Upserts resources for a rule migration, batching the requests to avoid hitting the max payload size limit of the API */
|
||||
public async upsertMigrationResources(
|
||||
migrationId: string,
|
||||
body: UpsertRuleMigrationResourcesRequestBody
|
||||
|
@ -168,6 +178,7 @@ export class SiemRulesMigrationsService {
|
|||
}
|
||||
}
|
||||
|
||||
/** Starts a rule migration task and waits for the task to start running */
|
||||
public async startRuleMigration(
|
||||
migrationId: string,
|
||||
retry?: SiemMigrationRetryFilter,
|
||||
|
@ -188,10 +199,7 @@ export class SiemRulesMigrationsService {
|
|||
}
|
||||
const params: api.StartRuleMigrationParams = {
|
||||
migrationId,
|
||||
settings: {
|
||||
connectorId,
|
||||
skipPrebuiltRulesMatching,
|
||||
},
|
||||
settings: { connectorId, skipPrebuiltRulesMatching },
|
||||
retry,
|
||||
};
|
||||
|
||||
|
@ -205,19 +213,54 @@ export class SiemRulesMigrationsService {
|
|||
|
||||
try {
|
||||
const result = await api.startRuleMigration(params);
|
||||
|
||||
// Should take a few seconds to start the task, so we poll until it is running
|
||||
await this.migrationTaskPollingUntil(
|
||||
migrationId,
|
||||
({ status }) => status === SiemMigrationTaskStatus.RUNNING,
|
||||
{ sleepSecs: START_STOP_POLLING_SLEEP_SECONDS, timeoutSecs: 90 } // wait up to 90 seconds for the task to start
|
||||
);
|
||||
|
||||
this.startPolling();
|
||||
|
||||
this.telemetry.reportStartTranslation(params);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.telemetry.reportStartTranslation({
|
||||
...params,
|
||||
error,
|
||||
});
|
||||
this.telemetry.reportStartTranslation({ ...params, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops a running rule migration task and waits for the task to completely stop */
|
||||
public async stopRuleMigration(migrationId: string): Promise<StopRuleMigrationResponse> {
|
||||
const missingCapabilities = this.getMissingCapabilities('all');
|
||||
if (missingCapabilities.length > 0) {
|
||||
this.core.notifications.toasts.add(
|
||||
getMissingCapabilitiesToast(missingCapabilities, this.core)
|
||||
);
|
||||
return { stopped: false };
|
||||
}
|
||||
|
||||
const params: api.StopRuleMigrationParams = { migrationId };
|
||||
try {
|
||||
const result = await api.stopRuleMigration(params);
|
||||
|
||||
// Should take a few seconds to stop the task, so we poll until it is not running anymore
|
||||
await this.migrationTaskPollingUntil(
|
||||
migrationId,
|
||||
({ status }) => status !== SiemMigrationTaskStatus.RUNNING, // may be STOPPED, FINISHED or INTERRUPTED
|
||||
{ sleepSecs: START_STOP_POLLING_SLEEP_SECONDS, timeoutSecs: 90 } // wait up to 90 seconds for the task to stop
|
||||
);
|
||||
|
||||
this.telemetry.reportStopTranslation(params);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.telemetry.reportStopTranslation({ ...params, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Gets the rule migrations stats, retrying on network errors or 503 status */
|
||||
public async getRuleMigrationsStats(
|
||||
params: api.GetRuleMigrationsStatsAllParams = {}
|
||||
): Promise<RuleMigrationStats[]> {
|
||||
|
@ -230,12 +273,41 @@ export class SiemRulesMigrationsService {
|
|||
return results;
|
||||
}
|
||||
|
||||
private sleep(seconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
}
|
||||
|
||||
/** Polls the migration task stats until the finish condition is met or the timeout is reached. */
|
||||
private async migrationTaskPollingUntil(
|
||||
migrationId: string,
|
||||
finishCondition: (stats: RuleMigrationTaskStats) => boolean,
|
||||
{ sleepSecs = 1, timeoutSecs = 60 }: { sleepSecs?: number; timeoutSecs?: number } = {}
|
||||
): Promise<void> {
|
||||
const timeoutId = setTimeout(() => {
|
||||
throw new Error('Migration task polling timed out');
|
||||
}, timeoutSecs * 1000);
|
||||
|
||||
let retry = true;
|
||||
do {
|
||||
const stats = await api.getRuleMigrationStats({ migrationId });
|
||||
if (finishCondition(stats)) {
|
||||
clearTimeout(timeoutId);
|
||||
retry = false;
|
||||
} else {
|
||||
await this.sleep(sleepSecs);
|
||||
}
|
||||
} while (retry);
|
||||
// updates the latest stats observable for all migrations to make sure they are in sync
|
||||
await this.getRuleMigrationsStats();
|
||||
}
|
||||
|
||||
/** Retries the API call to get rule migrations stats in case of network errors or 503 status */
|
||||
private async getRuleMigrationsStatsWithRetry(
|
||||
params: api.GetRuleMigrationsStatsAllParams = {},
|
||||
sleepSecs?: number
|
||||
): Promise<RuleMigrationTaskStats[]> {
|
||||
if (sleepSecs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, sleepSecs * 1000));
|
||||
await this.sleep(sleepSecs);
|
||||
}
|
||||
|
||||
return api.getRuleMigrationsStatsAll(params).catch((e) => {
|
||||
|
@ -253,6 +325,7 @@ export class SiemRulesMigrationsService {
|
|||
});
|
||||
}
|
||||
|
||||
/** Starts polling the rule migrations stats and handles the notifications for finished migrations */
|
||||
private async startTaskStatsPolling(): Promise<void> {
|
||||
let pendingMigrationIds: string[] = [];
|
||||
do {
|
||||
|
@ -294,9 +367,7 @@ export class SiemRulesMigrationsService {
|
|||
|
||||
// Do not wait if there are no more pending migrations
|
||||
if (pendingMigrationIds.length > 0) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, REQUEST_POLLING_INTERVAL_SECONDS * 1000)
|
||||
);
|
||||
await this.sleep(TASK_STATS_POLLING_SLEEP_SECONDS);
|
||||
}
|
||||
} while (pendingMigrationIds.length > 0);
|
||||
}
|
||||
|
|
|
@ -128,6 +128,15 @@ export class SiemRulesMigrationsTelemetry {
|
|||
});
|
||||
};
|
||||
|
||||
reportStopTranslation = (params: { migrationId: string; error?: Error }) => {
|
||||
const { migrationId, error } = params;
|
||||
this.telemetryService.reportEvent(SiemMigrationsEventTypes.StopMigration, {
|
||||
migrationId,
|
||||
eventName: siemMigrationEventNames[SiemMigrationsEventTypes.StopMigration],
|
||||
...this.getBaseResultParams(error),
|
||||
});
|
||||
};
|
||||
|
||||
// Translated rule actions
|
||||
|
||||
reportTranslatedRuleUpdate = (params: { migrationRule: RuleMigrationRule; error?: Error }) => {
|
||||
|
|
|
@ -25,6 +25,7 @@ export const createSiemMigrationTelemetryClientMock = () => {
|
|||
startRuleTranslation: mockStartRuleTranslation,
|
||||
success: jest.fn(),
|
||||
failure: jest.fn(),
|
||||
aborted: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
@ -193,7 +193,7 @@ export class RuleMigrationsTaskClient {
|
|||
try {
|
||||
const migrationRunning = this.migrationsRunning.get(migrationId);
|
||||
if (migrationRunning) {
|
||||
migrationRunning.abortController.abort();
|
||||
migrationRunning.abortController.abort('Stopped by user');
|
||||
await this.data.migrations.setIsStopped({ id: migrationId });
|
||||
return { exists: true, stopped: true };
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ import type { SiemRuleMigrationsClientDependencies, StoredRuleMigration } from '
|
|||
import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
|
||||
jest.mock('./rule_migrations_telemetry_client');
|
||||
|
||||
const mockRetrieverInitialize = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mock('./retrievers', () => ({
|
||||
...jest.requireActual('./retrievers'),
|
||||
|
@ -35,16 +37,6 @@ jest.mock('./agent', () => ({
|
|||
getRuleMigrationAgent: () => ({ invoke: mockInvoke }),
|
||||
}));
|
||||
|
||||
jest.mock('./rule_migrations_telemetry_client', () => ({
|
||||
SiemMigrationTelemetryClient: jest.fn().mockImplementation(() => ({
|
||||
startSiemMigrationTask: jest.fn(() => ({
|
||||
startRuleTranslation: jest.fn(() => ({ success: jest.fn(), failure: jest.fn() })),
|
||||
success: jest.fn(),
|
||||
failure: jest.fn(),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
const mockLogger = loggerMock.create();
|
||||
|
||||
|
|
|
@ -175,8 +175,8 @@ export class RuleMigrationTaskRunner {
|
|||
await this.saveRuleCompleted(ruleMigration, migrationResult);
|
||||
ruleTranslationTelemetry.success(migrationResult);
|
||||
} catch (error) {
|
||||
if (error instanceof AbortError) {
|
||||
throw error;
|
||||
if (this.abortController.signal.aborted) {
|
||||
throw new AbortError();
|
||||
}
|
||||
ruleTranslationTelemetry.failure(error);
|
||||
await this.saveRuleFailed(ruleMigration, error);
|
||||
|
@ -196,11 +196,11 @@ export class RuleMigrationTaskRunner {
|
|||
} catch (error) {
|
||||
await this.data.rules.releaseProcessing(migrationId);
|
||||
|
||||
migrationTaskTelemetry.failure(error);
|
||||
if (error instanceof AbortError) {
|
||||
migrationTaskTelemetry.aborted(error);
|
||||
this.logger.info('Abort signal received, stopping migration');
|
||||
return;
|
||||
} else {
|
||||
migrationTaskTelemetry.failure(error);
|
||||
throw new Error(`Error processing migration: ${error}`);
|
||||
}
|
||||
} finally {
|
||||
|
@ -213,14 +213,13 @@ export class RuleMigrationTaskRunner {
|
|||
const { agent } = this;
|
||||
const config: MigrateRuleGraphConfig = {
|
||||
timeout: AGENT_INVOKE_TIMEOUT_MIN * 60 * 1000, // milliseconds timeout
|
||||
// signal: abortController.signal, // not working properly https://github.com/langchain-ai/langgraphjs/issues/319
|
||||
...invocationConfig,
|
||||
signal: this.abortController.signal,
|
||||
};
|
||||
|
||||
const invoke = async (input: RuleMigrationInput): Promise<MigrateRuleState> => {
|
||||
// using withAbort in the agent invocation is not ideal but is a workaround for the issue with the langGraph signal not working properly
|
||||
return this.withAbort<MigrateRuleState>(agent.invoke(input, config));
|
||||
};
|
||||
// Prepare the invocation with specific config
|
||||
const invoke = async (input: RuleMigrationInput): Promise<MigrateRuleState> =>
|
||||
agent.invoke(input, config);
|
||||
|
||||
// Invokes the rule translation with exponential backoff, should be called only when the rate limit has been hit
|
||||
const invokeWithBackoff = async (
|
||||
|
|
|
@ -33,7 +33,7 @@ export class RuleMigrationsTaskService {
|
|||
/** Stops all running migrations */
|
||||
stopAll() {
|
||||
this.migrationsRunning.forEach((migrationRunning) => {
|
||||
migrationRunning.abortController.abort();
|
||||
migrationRunning.abortController.abort('Server shutdown');
|
||||
});
|
||||
this.migrationsRunning.clear();
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import type { AnalyticsServiceSetup, Logger, EventTypeOpts } from '@kbn/core/server';
|
||||
import {
|
||||
SIEM_MIGRATIONS_INTEGRATIONS_MATCH,
|
||||
SIEM_MIGRATIONS_MIGRATION_ABORTED,
|
||||
SIEM_MIGRATIONS_MIGRATION_FAILURE,
|
||||
SIEM_MIGRATIONS_MIGRATION_SUCCESS,
|
||||
SIEM_MIGRATIONS_PREBUILT_RULES_MATCH,
|
||||
|
@ -91,7 +92,7 @@ export class SiemMigrationTelemetryClient {
|
|||
duration: Date.now() - ruleStartTime,
|
||||
model: this.modelName,
|
||||
prebuiltMatch: migrationResult.elastic_rule?.prebuilt_rule_id ? true : false,
|
||||
eventName: siemMigrationEventNames[SiemMigrationsEventTypes.TranslationSucess],
|
||||
eventName: siemMigrationEventNames[SiemMigrationsEventTypes.TranslationSuccess],
|
||||
});
|
||||
},
|
||||
failure: (error: Error) => {
|
||||
|
@ -130,6 +131,19 @@ export class SiemMigrationTelemetryClient {
|
|||
eventName: siemMigrationEventNames[SiemMigrationsEventTypes.MigrationFailure],
|
||||
});
|
||||
},
|
||||
aborted: (error: Error) => {
|
||||
const duration = Date.now() - startTime;
|
||||
this.reportEvent(SIEM_MIGRATIONS_MIGRATION_ABORTED, {
|
||||
migrationId: this.migrationId,
|
||||
model: this.modelName || '',
|
||||
completed: stats.completed,
|
||||
failed: stats.failed,
|
||||
total: stats.completed + stats.failed,
|
||||
duration,
|
||||
reason: error.message,
|
||||
eventName: siemMigrationEventNames[SiemMigrationsEventTypes.MigrationAborted],
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,8 @@ export const siemMigrationEventNames = {
|
|||
[SiemMigrationsEventTypes.MigrationSuccess]: 'Migration success',
|
||||
[SiemMigrationsEventTypes.PrebuiltRulesMatch]: 'Prebuilt rules match',
|
||||
[SiemMigrationsEventTypes.IntegrationsMatch]: 'Integrations match',
|
||||
[SiemMigrationsEventTypes.MigrationAborted]: 'Migration aborted',
|
||||
[SiemMigrationsEventTypes.MigrationFailure]: 'Migration failure',
|
||||
[SiemMigrationsEventTypes.TranslationFailure]: 'Translation failure',
|
||||
[SiemMigrationsEventTypes.TranslationSucess]: 'Translation success',
|
||||
[SiemMigrationsEventTypes.TranslationSuccess]: 'Translation success',
|
||||
} as const;
|
||||
|
|
|
@ -1113,7 +1113,7 @@ export const SIEM_MIGRATIONS_RULE_TRANSLATION_SUCCESS: EventTypeOpts<{
|
|||
prebuiltMatch: boolean;
|
||||
eventName: string;
|
||||
}> = {
|
||||
eventType: SiemMigrationsEventTypes.TranslationSucess,
|
||||
eventType: SiemMigrationsEventTypes.TranslationSuccess,
|
||||
schema: {
|
||||
eventName: {
|
||||
type: 'keyword',
|
||||
|
@ -1339,6 +1339,70 @@ export const SIEM_MIGRATIONS_MIGRATION_FAILURE: EventTypeOpts<{
|
|||
},
|
||||
};
|
||||
|
||||
export const SIEM_MIGRATIONS_MIGRATION_ABORTED: EventTypeOpts<{
|
||||
model: string;
|
||||
reason: string;
|
||||
migrationId: string;
|
||||
duration: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
eventName: string;
|
||||
}> = {
|
||||
eventType: SiemMigrationsEventTypes.MigrationAborted,
|
||||
schema: {
|
||||
eventName: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The event name/description',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
model: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The LLM model that was used',
|
||||
},
|
||||
},
|
||||
reason: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The reason of the migration abort',
|
||||
},
|
||||
},
|
||||
migrationId: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'Unique identifier for the migration',
|
||||
},
|
||||
},
|
||||
duration: {
|
||||
type: 'long',
|
||||
_meta: {
|
||||
description: 'Duration of the migration in milliseconds',
|
||||
},
|
||||
},
|
||||
completed: {
|
||||
type: 'long',
|
||||
_meta: {
|
||||
description: 'Number of rules successfully migrated',
|
||||
},
|
||||
},
|
||||
failed: {
|
||||
type: 'long',
|
||||
_meta: {
|
||||
description: 'Number of rules that failed to migrate',
|
||||
},
|
||||
},
|
||||
total: {
|
||||
type: 'long',
|
||||
_meta: {
|
||||
description: 'Total number of rules to migrate',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SIEM_MIGRATIONS_RULE_TRANSLATION_FAILURE: EventTypeOpts<{
|
||||
model: string;
|
||||
error: string;
|
||||
|
@ -1415,6 +1479,7 @@ export const events = [
|
|||
TELEMETRY_INDEX_TEMPLATES_EVENT,
|
||||
TELEMETRY_NODE_INGEST_PIPELINES_STATS_EVENT,
|
||||
SIEM_MIGRATIONS_MIGRATION_SUCCESS,
|
||||
SIEM_MIGRATIONS_MIGRATION_ABORTED,
|
||||
SIEM_MIGRATIONS_MIGRATION_FAILURE,
|
||||
SIEM_MIGRATIONS_RULE_TRANSLATION_SUCCESS,
|
||||
SIEM_MIGRATIONS_RULE_TRANSLATION_FAILURE,
|
||||
|
|
|
@ -6,10 +6,11 @@
|
|||
*/
|
||||
|
||||
export enum SiemMigrationsEventTypes {
|
||||
TranslationFailure = 'siem_migrations_rule_translation_failure',
|
||||
MigrationSuccess = 'siem_migrations_migration_success',
|
||||
MigrationAborted = 'siem_migrations_migration_aborted',
|
||||
MigrationFailure = 'siem_migrations_migration_failure',
|
||||
TranslationSuccess = 'siem_migrations_rule_translation_success',
|
||||
TranslationFailure = 'siem_migrations_rule_translation_failure',
|
||||
PrebuiltRulesMatch = 'siem_migrations_prebuilt_rules_match',
|
||||
IntegrationsMatch = 'siem_migrations_integration_match',
|
||||
MigrationFailure = 'siem_migrations_migration_failure',
|
||||
TranslationSucess = 'siem_migrations_rule_translation_success',
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue