[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


![ready](https://github.com/user-attachments/assets/12a15a79-974d-4ee7-97cd-70d7ce185e89)

Stop feature demo: 


https://github.com/user-attachments/assets/37727d0c-c248-45ff-b9c7-220a59c153f6
This commit is contained in:
Sergi Massaneda 2025-06-20 14:48:13 +02:00 committed by GitHub
parent f5d8d9a1db
commit 31fe87ae06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 695 additions and 192 deletions

View file

@ -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",

View file

@ -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}ルールの変換を続行",

View file

@ -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} 个规则",

View file

@ -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);

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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(),
},
},
},
});

View file

@ -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 };

View file

@ -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>;

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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');
});
});
});

View file

@ -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 />

View file

@ -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.'
);
});
});

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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) =>

View file

@ -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';

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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 };
};

View file

@ -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',

View file

@ -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);
}

View file

@ -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 }) => {

View file

@ -25,6 +25,7 @@ export const createSiemMigrationTelemetryClientMock = () => {
startRuleTranslation: mockStartRuleTranslation,
success: jest.fn(),
failure: jest.fn(),
aborted: jest.fn(),
};
return {

View file

@ -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 };
}

View file

@ -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();

View file

@ -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 (

View file

@ -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();
}

View file

@ -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],
});
},
};
}
}

View file

@ -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;

View file

@ -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,

View file

@ -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',
}