Skip to content

Commit

Permalink
[Siem migrations] Implement UI service and migrations polling (#201503)
Browse files Browse the repository at this point in the history
## 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](elastic/security-team#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](elastic/security-team#10667))

Tests will be implemented in [this
task](elastic/security-team#11256)

## Example

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


https://github.com/user-attachments/assets/aac2b2c8-27fe-40d5-9f32-0bee74c9dc6a
  • Loading branch information
semd authored Nov 25, 2024
1 parent eb41db2 commit b6586a9
Show file tree
Hide file tree
Showing 16 changed files with 278 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ import {
ElasticRulePartial,
RuleMigrationTranslationResult,
RuleMigrationComments,
RuleMigrationAllTaskStats,
RuleMigration,
RuleMigrationTaskStats,
RuleMigration,
RuleMigrationResourceData,
RuleMigrationResourceType,
RuleMigrationResource,
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/security_solution/public/plugin_services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -152,6 +153,7 @@ export class PluginServices {
customDataService,
timelineDataService,
topValuesPopover: new TopValuesPopoverService(),
siemMigrations: await createSiemMigrationsService(coreStart),
...(params && {
onAppLeave: params.onAppLeave,
setHeaderActionMenu: params.setHeaderActionMenu,
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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 };
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,28 @@ 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) {
return [];
}
return ruleMigrationsStatsAll
.filter((migration) => migration.status === 'finished')
.map((migration) => migration.migration_id);
.map((migration) => migration.id);
}, [isLoadingMigrationsStats, ruleMigrationsStatsAll]);

const [selectedMigrationId, setSelectedMigrationId] = useState<string | undefined>();
Expand Down Expand Up @@ -94,11 +91,7 @@ const RulesPageComponent: React.FC = () => {
/>
{rulePreviewFlyout}
</SecuritySolutionPageWrapper>

<SpyRoute pageName={SecurityPageName.siemMigrationsRules} />
</>
);
};

export const RulesPage = React.memo(RulesPageComponent);
});
RulesPage.displayName = 'RulesPage';
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading

0 comments on commit b6586a9

Please sign in to comment.