[Siem migrations] Implement UI service and migrations polling (#201503)

## Summary

Sends "Rule migration complete" notifications from anywhere in the
Security Solution app, whenever a rule migration finishes, with a link
to the migrated rules.

The polling logic has been encapsulated in the new
`siemMigrations.rules` service so the request loop is centralized in one
place. The value updates are broadcasted using the `latestStats$`
observable.
It will only keep requesting while there are _running_ migrations and
will stop automatically when no more migrations are _running_.

The reusable `useLatestStats` hook has been created for the UI
components to consume. This approach allows multiple components to
listen and update their content automatically with every rule migration
stats update, having only one request loop running.

The polling will only start if it's not already running and only if the
SIEM migration functionality is available, which includes:
- Experimental flag enabled
- _Enterprise_ license 
- TODO: feature capability check (RBAC
[issue](https://github.com/elastic/security-team/issues/11262))

The polling will try to start when:
- Automatically with the Security Solution application starts
- The first render of every page that uses `useLatestStats` hook.
- TODO: A new migration is created from the onboarding page
([issue](https://github.com/elastic/security-team/issues/10667))

Tests will be implemented in [this
task](https://github.com/elastic/security-team/issues/11256)

## Example

A Rule migration finishes while using Timeline in the Alerts page:


https://github.com/user-attachments/assets/aac2b2c8-27fe-40d5-9f32-0bee74c9dc6a
This commit is contained in:
Sergi Massaneda 2024-11-25 18:48:02 +01:00 committed by GitHub
parent eb41db27b1
commit b6586a95f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 278 additions and 89 deletions

View file

@ -22,9 +22,8 @@ import {
ElasticRulePartial,
RuleMigrationTranslationResult,
RuleMigrationComments,
RuleMigrationAllTaskStats,
RuleMigration,
RuleMigrationTaskStats,
RuleMigration,
RuleMigrationResourceData,
RuleMigrationResourceType,
RuleMigrationResource,
@ -44,7 +43,7 @@ export const CreateRuleMigrationResponse = z.object({
});
export type GetAllStatsRuleMigrationResponse = z.infer<typeof GetAllStatsRuleMigrationResponse>;
export const GetAllStatsRuleMigrationResponse = RuleMigrationAllTaskStats;
export const GetAllStatsRuleMigrationResponse = z.array(RuleMigrationTaskStats);
export type GetRuleMigrationRequestParams = z.infer<typeof GetRuleMigrationRequestParams>;
export const GetRuleMigrationRequestParams = z.object({

View file

@ -93,7 +93,9 @@ paths:
content:
application/json:
schema:
$ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationAllTaskStats'
type: array
items:
$ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationTaskStats'
## Specific rule migration APIs

View file

@ -191,6 +191,10 @@ export const RuleMigration = z
*/
export type RuleMigrationTaskStats = z.infer<typeof RuleMigrationTaskStats>;
export const RuleMigrationTaskStats = z.object({
/**
* The migration id
*/
id: NonEmptyString,
/**
* Indicates if the migration task status.
*/
@ -220,24 +224,16 @@ export const RuleMigrationTaskStats = z.object({
*/
failed: z.number().int(),
}),
/**
* The moment the migration was created.
*/
created_at: z.string(),
/**
* The moment of the last update.
*/
last_updated_at: z.string().optional(),
last_updated_at: z.string(),
});
export type RuleMigrationAllTaskStats = z.infer<typeof RuleMigrationAllTaskStats>;
export const RuleMigrationAllTaskStats = z.array(
RuleMigrationTaskStats.merge(
z.object({
/**
* The migration id
*/
migration_id: NonEmptyString,
})
)
);
/**
* The type of the rule migration resource.
*/

View file

@ -145,9 +145,15 @@ components:
type: object
description: The rule migration task stats object.
required:
- id
- status
- rules
- created_at
- last_updated_at
properties:
id:
description: The migration id
$ref: './common.schema.yaml#/components/schemas/NonEmptyString'
status:
type: string
description: Indicates if the migration task status.
@ -181,23 +187,13 @@ components:
failed:
type: integer
description: The number of rules that have failed migration.
created_at:
type: string
description: The moment the migration was created.
last_updated_at:
type: string
description: The moment of the last update.
RuleMigrationAllTaskStats:
type: array
items:
allOf:
- $ref: '#/components/schemas/RuleMigrationTaskStats'
- type: object
required:
- migration_id
properties:
migration_id:
description: The migration id
$ref: './common.schema.yaml#/components/schemas/NonEmptyString'
RuleMigrationTranslationResult:
type: string
description: The rule translation result.

View file

@ -19,6 +19,7 @@ import type { ConfigSettings } from '../common/config_settings';
import { parseConfigSettings } from '../common/config_settings';
import { APP_UI_ID } from '../common/constants';
import { TopValuesPopoverService } from './app/components/top_values_popover/top_values_popover_service';
import { createSiemMigrationsService } from './siem_migrations/service';
import type { SecuritySolutionUiConfigType } from './common/types';
import type {
PluginStart,
@ -152,6 +153,7 @@ export class PluginServices {
customDataService,
timelineDataService,
topValuesPopover: new TopValuesPopoverService(),
siemMigrations: await createSiemMigrationsService(coreStart),
...(params && {
onAppLeave: params.onAppLeave,
setHeaderActionMenu: params.setHeaderActionMenu,

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { DEFAULT_QUERY_OPTIONS } from './constants';
import { getRuleMigrationsStatsAll } from '../api';
import type { GetAllStatsRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen';
import { SIEM_RULE_MIGRATIONS_ALL_STATS_PATH } from '../../../../../common/siem_migrations/constants';
export const GET_RULE_MIGRATIONS_STATS_ALL_QUERY_KEY = ['GET', SIEM_RULE_MIGRATIONS_ALL_STATS_PATH];
export const useGetRuleMigrationsStatsAllQuery = (
options?: UseQueryOptions<GetAllStatsRuleMigrationResponse>
) => {
return useQuery<GetAllStatsRuleMigrationResponse>(
GET_RULE_MIGRATIONS_STATS_ALL_QUERY_KEY,
async ({ signal }) => {
return getRuleMigrationsStatsAll({ signal });
},
{
...DEFAULT_QUERY_OPTIONS,
...options,
}
);
};

View file

@ -0,0 +1,23 @@
/*
* 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 useObservable from 'react-use/lib/useObservable';
import { useEffect, useMemo } from 'react';
import { useKibana } from '../../../common/lib/kibana';
export const useLatestStats = () => {
const { siemMigrations } = useKibana().services;
useEffect(() => {
siemMigrations.rules.startPolling();
}, [siemMigrations.rules]);
const latestStats$ = useMemo(() => siemMigrations.rules.getLatestStats$(), [siemMigrations]);
const latestStats = useObservable(latestStats$, null);
return { data: latestStats ?? [], isLoading: latestStats === null };
};

View file

@ -9,23 +9,20 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EuiSkeletonLoading, EuiSkeletonText, EuiSkeletonTitle } from '@elastic/eui';
import type { RuleMigration } from '../../../../common/siem_migrations/model/rule_migration.gen';
import { SecurityPageName } from '../../../app/types';
import { HeaderPage } from '../../../common/components/header_page';
import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper';
import { SpyRoute } from '../../../common/utils/route/spy_routes';
import * as i18n from './translations';
import { RulesTable } from '../components/rules_table';
import { NeedAdminForUpdateRulesCallOut } from '../../../detections/components/callouts/need_admin_for_update_callout';
import { MissingPrivilegesCallOut } from '../../../detections/components/callouts/missing_privileges_callout';
import { HeaderButtons } from '../components/header_buttons';
import { useGetRuleMigrationsStatsAllQuery } from '../api/hooks/use_get_rule_migrations_stats_all';
import { useRulePreviewFlyout } from '../hooks/use_rule_preview_flyout';
import { NoMigrations } from '../components/no_migrations';
import { useLatestStats } from '../hooks/use_latest_stats';
const RulesPageComponent: React.FC = () => {
const { data: ruleMigrationsStatsAll, isLoading: isLoadingMigrationsStats } =
useGetRuleMigrationsStatsAllQuery();
export const RulesPage = React.memo(() => {
const { data: ruleMigrationsStatsAll, isLoading: isLoadingMigrationsStats } = useLatestStats();
const migrationsIds = useMemo(() => {
if (isLoadingMigrationsStats || !ruleMigrationsStatsAll?.length) {
@ -33,7 +30,7 @@ const RulesPageComponent: React.FC = () => {
}
return ruleMigrationsStatsAll
.filter((migration) => migration.status === 'finished')
.map((migration) => migration.migration_id);
.map((migration) => migration.id);
}, [isLoadingMigrationsStats, ruleMigrationsStatsAll]);
const [selectedMigrationId, setSelectedMigrationId] = useState<string | undefined>();
@ -94,11 +91,7 @@ const RulesPageComponent: React.FC = () => {
/>
{rulePreviewFlyout}
</SecuritySolutionPageWrapper>
<SpyRoute pageName={SecurityPageName.siemMigrationsRules} />
</>
);
};
export const RulesPage = React.memo(RulesPageComponent);
});
RulesPage.displayName = 'RulesPage';

View file

@ -0,0 +1,87 @@
/*
* 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 { BehaviorSubject, type Observable } from 'rxjs';
import type { CoreStart } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { ExperimentalFeaturesService } from '../../../common/experimental_features_service';
import { licenseService } from '../../../common/hooks/use_license';
import { getRuleMigrationsStatsAll } from '../api/api';
import type { RuleMigrationStats } from '../types';
import { getSuccessToast } from './success_notification';
const POLLING_ERROR_TITLE = i18n.translate(
'xpack.securitySolution.siemMigrations.rulesService.polling.errorTitle',
{ defaultMessage: 'Error fetching rule migrations' }
);
export class SiemRulesMigrationsService {
private readonly pollingInterval = 5000;
private readonly latestStats$: BehaviorSubject<RuleMigrationStats[]>;
private isPolling = false;
constructor(private readonly core: CoreStart) {
this.latestStats$ = new BehaviorSubject<RuleMigrationStats[]>([]);
this.startPolling();
}
public getLatestStats$(): Observable<RuleMigrationStats[]> {
return this.latestStats$.asObservable();
}
public isAvailable() {
return ExperimentalFeaturesService.get().siemMigrationsEnabled && licenseService.isEnterprise();
}
public startPolling() {
if (this.isPolling || !this.isAvailable()) {
return;
}
this.isPolling = true;
this.startStatsPolling()
.catch((e) => {
this.core.notifications.toasts.addError(e, { title: POLLING_ERROR_TITLE });
})
.finally(() => {
this.isPolling = false;
});
}
private async startStatsPolling(): Promise<void> {
let pendingMigrationIds: string[] = [];
do {
const results = await this.fetchRuleMigrationsStats();
this.latestStats$.next(results);
if (pendingMigrationIds.length > 0) {
// send notifications for finished migrations
pendingMigrationIds.forEach((pendingMigrationId) => {
const migration = results.find((item) => item.id === pendingMigrationId);
if (migration && migration.status === 'finished') {
this.core.notifications.toasts.addSuccess(getSuccessToast(migration, this.core));
}
});
}
// reassign pending migrations
pendingMigrationIds = results.reduce<string[]>((acc, item) => {
if (item.status === 'running') {
acc.push(item.id);
}
return acc;
}, []);
await new Promise((resolve) => setTimeout(resolve, this.pollingInterval));
} while (pendingMigrationIds.length > 0);
}
private async fetchRuleMigrationsStats(): Promise<RuleMigrationStats[]> {
const stats = await getRuleMigrationsStatsAll({ signal: new AbortController().signal });
return stats.map((stat, index) => ({ ...stat, number: index + 1 })); // the array order (by creation) is guaranteed by the API
}
}

View file

@ -0,0 +1,67 @@
/*
* 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 type { CoreStart } from '@kbn/core-lifecycle-browser';
import { i18n } from '@kbn/i18n';
import {
SecurityPageName,
useNavigation,
NavigationProvider,
} from '@kbn/security-solution-navigation';
import type { ToastInput } from '@kbn/core-notifications-browser';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { RuleMigrationStats } from '../types';
export const getSuccessToast = (migration: RuleMigrationStats, core: CoreStart): ToastInput => ({
color: 'success',
iconType: 'check',
toastLifeTimeMs: 1000 * 60 * 30, // 30 minutes
title: i18n.translate('xpack.securitySolution.siemMigrations.rulesService.polling.successTitle', {
defaultMessage: 'Rules translation complete.',
}),
text: toMountPoint(
<NavigationProvider core={core}>
<SuccessToastContent migration={migration} />
</NavigationProvider>,
core
),
});
const SuccessToastContent: React.FC<{ migration: RuleMigrationStats }> = ({ migration }) => {
const navigation = { deepLinkId: SecurityPageName.siemMigrationsRules, path: migration.id };
const { navigateTo, getAppUrl } = useNavigation();
const onClick: React.MouseEventHandler = (ev) => {
ev.preventDefault();
navigateTo(navigation);
};
const url = getAppUrl(navigation);
return (
<EuiFlexGroup direction="column" alignItems="flexEnd" gutterSize="s">
<EuiFlexItem>
<FormattedMessage
id="xpack.securitySolution.siemMigrations.rulesService.polling.successText"
defaultMessage="SIEM rules migration #{number} has finished translating. Results have been added to a dedicated page."
values={{ number: migration.number }}
/>
</EuiFlexItem>
<EuiFlexItem>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiButton onClick={onClick} href={url} color="success">
{i18n.translate(
'xpack.securitySolution.siemMigrations.rulesService.polling.successLinkText',
{ defaultMessage: 'Go to translated rules' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RuleMigrationTaskStats } from '../../../common/siem_migrations/model/rule_migration.gen';
export interface RuleMigrationStats extends RuleMigrationTaskStats {
/** The sequential number of the migration */
number: number;
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreStart } from '@kbn/core-lifecycle-browser';
export type { SiemMigrationsService } from './siem_migrations_service';
export const createSiemMigrationsService = async (coreStart: CoreStart) => {
const { SiemMigrationsService } = await import(
/* webpackChunkName: "lazySiemMigrationsService" */
'./siem_migrations_service'
);
return new SiemMigrationsService(coreStart);
};

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreStart } from '@kbn/core/public';
import { SiemRulesMigrationsService } from '../rules/service/rule_migrations_service';
export class SiemMigrationsService {
public rules: SiemRulesMigrationsService;
constructor(coreStart: CoreStart) {
this.rules = new SiemRulesMigrationsService(coreStart);
}
}

View file

@ -94,6 +94,7 @@ import type { ConfigSettings } from '../common/config_settings';
import type { OnboardingService } from './onboarding/service';
import type { SolutionNavigation } from './app/solution_navigation/solution_navigation';
import type { TelemetryServiceStart } from './common/lib/telemetry';
import type { SiemMigrationsService } from './siem_migrations/service';
export interface SetupPlugins {
cloud?: CloudSetup;
@ -193,6 +194,7 @@ export type StartServices = CoreStart &
customDataService: DataPublicPluginStart;
topValuesPopover: TopValuesPopoverService;
timelineDataService: DataPublicPluginStart;
siemMigrations: SiemMigrationsService;
};
export type StartRenderServices = Pick<

View file

@ -6,8 +6,10 @@
*/
import type {
AggregationsAggregationContainer,
AggregationsFilterAggregate,
AggregationsMaxAggregate,
AggregationsMinAggregate,
AggregationsStringTermsAggregate,
AggregationsStringTermsBucket,
QueryDslQueryContainer,
@ -30,7 +32,7 @@ export type UpdateRuleMigrationInput = { elastic_rule?: Partial<ElasticRule> } &
'id' | 'translation_result' | 'comments'
>;
export type RuleMigrationDataStats = Omit<RuleMigrationTaskStats, 'status'>;
export type RuleMigrationAllDataStats = Array<RuleMigrationDataStats & { migration_id: string }>;
export type RuleMigrationAllDataStats = RuleMigrationDataStats[];
/* BULK_MAX_SIZE defines the number to break down the bulk operations by.
* The 500 number was chosen as a reasonable number to avoid large payloads. It can be adjusted if needed.
@ -217,6 +219,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
processing: { filter: { term: { status: SiemMigrationStatus.PROCESSING } } },
completed: { filter: { term: { status: SiemMigrationStatus.COMPLETED } } },
failed: { filter: { term: { status: SiemMigrationStatus.FAILED } } },
createdAt: { min: { field: '@timestamp' } },
lastUpdatedAt: { max: { field: 'updated_at' } },
};
const result = await this.esClient
@ -226,30 +229,33 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
throw error;
});
const { pending, processing, completed, lastUpdatedAt, failed } = result.aggregations ?? {};
const bucket = result.aggregations ?? {};
return {
id: migrationId,
rules: {
total: this.getTotalHits(result),
pending: (pending as AggregationsFilterAggregate)?.doc_count ?? 0,
processing: (processing as AggregationsFilterAggregate)?.doc_count ?? 0,
completed: (completed as AggregationsFilterAggregate)?.doc_count ?? 0,
failed: (failed as AggregationsFilterAggregate)?.doc_count ?? 0,
pending: (bucket.pending as AggregationsFilterAggregate)?.doc_count ?? 0,
processing: (bucket.processing as AggregationsFilterAggregate)?.doc_count ?? 0,
completed: (bucket.completed as AggregationsFilterAggregate)?.doc_count ?? 0,
failed: (bucket.failed as AggregationsFilterAggregate)?.doc_count ?? 0,
},
last_updated_at: (lastUpdatedAt as AggregationsMaxAggregate)?.value_as_string,
created_at: (bucket.createdAt as AggregationsMinAggregate)?.value_as_string ?? '',
last_updated_at: (bucket.lastUpdatedAt as AggregationsMaxAggregate)?.value_as_string ?? '',
};
}
/** Retrieves the stats for all the rule migrations aggregated by migration id */
/** Retrieves the stats for all the rule migrations aggregated by migration id, in creation order */
async getAllStats(): Promise<RuleMigrationAllDataStats> {
const index = await this.getIndexName();
const aggregations = {
const aggregations: { migrationIds: AggregationsAggregationContainer } = {
migrationIds: {
terms: { field: 'migration_id' },
terms: { field: 'migration_id', order: { createdAt: 'asc' } },
aggregations: {
pending: { filter: { term: { status: SiemMigrationStatus.PENDING } } },
processing: { filter: { term: { status: SiemMigrationStatus.PROCESSING } } },
completed: { filter: { term: { status: SiemMigrationStatus.COMPLETED } } },
failed: { filter: { term: { status: SiemMigrationStatus.FAILED } } },
createdAt: { min: { field: '@timestamp' } },
lastUpdatedAt: { max: { field: 'updated_at' } },
},
},
@ -264,7 +270,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
const migrationsAgg = result.aggregations?.migrationIds as AggregationsStringTermsAggregate;
const buckets = (migrationsAgg?.buckets as AggregationsStringTermsBucket[]) ?? [];
return buckets.map((bucket) => ({
migration_id: bucket.key,
id: bucket.key,
rules: {
total: bucket.doc_count,
pending: bucket.pending?.doc_count ?? 0,
@ -272,6 +278,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
completed: bucket.completed?.doc_count ?? 0,
failed: bucket.failed?.doc_count ?? 0,
},
created_at: bucket.createdAt?.value_as_string,
last_updated_at: bucket.lastUpdatedAt?.value_as_string,
}));
}

View file

@ -9,10 +9,7 @@ import type { AuthenticatedUser, Logger } from '@kbn/core/server';
import { AbortError, abortSignalToPromise } from '@kbn/kibana-utils-plugin/server';
import type { RunnableConfig } from '@langchain/core/runnables';
import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants';
import type {
RuleMigrationAllTaskStats,
RuleMigrationTaskStats,
} from '../../../../../common/siem_migrations/model/rule_migration.gen';
import type { RuleMigrationTaskStats } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_client';
import type { RuleMigrationDataStats } from '../data/rule_migrations_data_rules_client';
import { getRuleMigrationAgent } from './agent';
@ -229,10 +226,10 @@ export class RuleMigrationsTaskClient {
}
/** Returns the stats of all migrations */
async getAllStats(): Promise<RuleMigrationAllTaskStats> {
async getAllStats(): Promise<RuleMigrationTaskStats[]> {
const allDataStats = await this.data.rules.getAllStats();
return allDataStats.map((dataStats) => {
const status = this.getTaskStatus(dataStats.migration_id, dataStats.rules);
const status = this.getTaskStatus(dataStats.id, dataStats.rules);
return { status, ...dataStats };
});
}