[Rules migration][UI] Basic rule migrations UI (#10820) (#200978)

## Summary

[Internal link](https://github.com/elastic/security-team/issues/10820)
to the feature details

This is a very first version of the SIEM rules migrations UI
functionality. The main goal is to setup and agree on a folder structure
where the feature gonna live. Tests covering feature will follow in a
separate PR (see [internal
link](https://github.com/elastic/security-team/issues/11232) for more
details).

The code follows the structure of prebuilt rules feature
https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table
and hidden behind `siemMigrationsEnabled` feature flag.

### Key UI changes

* New "SIEM Rules Migrations." rules management sub-page
* Navigation between different "finished" migrations
* InMemory table with all the translations within the selected migration
* Translation details preview flyout with `Translation` and `Overview`
tabs
* User cannot modify translations via UI

### Testing locally

Enable the flag

```
xpack.securitySolution.enableExperimental: ['siemMigrationsEnabled']
```
### Screenshot


https://github.com/user-attachments/assets/a5a7e777-c5f8-40b4-be1d-1bd07a2729ac
This commit is contained in:
Ievgen Sorokopud 2024-11-22 15:48:14 +01:00 committed by GitHub
parent 556edb9971
commit a627e011a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1705 additions and 0 deletions

1
.github/CODEOWNERS vendored
View file

@ -2098,6 +2098,7 @@ x-pack/test/security_solution_api_integration/test_suites/sources @elastic/secur
/x-pack/plugins/security_solution/server/lib/siem_migrations @elastic/security-threat-hunting
/x-pack/plugins/security_solution/common/siem_migrations @elastic/security-threat-hunting
/x-pack/plugins/security_solution/public/siem_migrations @elastic/security-threat-hunting
## Security Solution Threat Hunting areas - Threat Hunting Investigations

View file

@ -69,6 +69,7 @@ export enum SecurityPageName {
rulesAdd = 'rules-add',
rulesCreate = 'rules-create',
rulesLanding = 'rules-landing',
siemMigrationsRules = 'siem_migrations-rules',
/*
* Warning: Computed values are not permitted in an enum with string valued members
* All threat intelligence page names must match `TIPageId` in x-pack/plugins/threat_intelligence/public/common/navigation/types.ts

View file

@ -138,6 +138,8 @@ export const APP_BLOCKLIST_PATH = `${APP_PATH}${BLOCKLIST_PATH}` as const;
export const APP_RESPONSE_ACTIONS_HISTORY_PATH =
`${APP_PATH}${RESPONSE_ACTIONS_HISTORY_PATH}` as const;
export const NOTES_PATH = `${MANAGEMENT_PATH}/notes` as const;
export const SIEM_MIGRATIONS_PATH = '/siem_migrations' as const;
export const SIEM_MIGRATIONS_RULES_PATH = `${SIEM_MIGRATIONS_PATH}/rules` as const;
// cloud logs to exclude from default index pattern
export const EXCLUDE_ELASTIC_CLOUD_INDICES = ['-*elastic-cloud-logs-*'];

View file

@ -101,6 +101,13 @@ export const EXCEPTIONS = i18n.translate('xpack.securitySolution.navigation.exce
defaultMessage: 'Shared exception lists',
});
export const SIEM_MIGRATIONS_RULES = i18n.translate(
'xpack.securitySolution.navigation.siemMigrationsRules',
{
defaultMessage: 'SIEM Rules Migrations',
}
);
export const ALERTS = i18n.translate('xpack.securitySolution.navigation.alerts', {
defaultMessage: 'Alerts',
});

View file

@ -29,6 +29,7 @@ import { EntityAnalytics } from './entity_analytics';
import { Assets } from './assets';
import { Investigations } from './investigations';
import { MachineLearning } from './machine_learning';
import { SiemMigrations } from './siem_migrations';
/**
* The classes used to instantiate the sub plugins. These are grouped into a single object for the sake of bundling them in a single dynamic import.
@ -53,5 +54,6 @@ const subPluginClasses = {
Assets,
Investigations,
MachineLearning,
SiemMigrations,
};
export { subPluginClasses };

View file

@ -245,6 +245,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
assets: new subPluginClasses.Assets(),
investigations: new subPluginClasses.Investigations(),
machineLearning: new subPluginClasses.MachineLearning(),
siemMigrations: new subPluginClasses.SiemMigrations(),
};
}
return this._subPlugins;
@ -279,6 +280,9 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
assets: subPlugins.assets.start(),
investigations: subPlugins.investigations.start(),
machineLearning: subPlugins.machineLearning.start(),
siemMigrations: subPlugins.siemMigrations.start(
this.experimentalFeatures.siemMigrationsEnabled
),
};
}

View file

@ -29,6 +29,7 @@ import type { LinkItem } from '../common/links';
import { IconConsoleCloud } from '../common/icons/console_cloud';
import { IconRollup } from '../common/icons/rollup';
import { IconDashboards } from '../common/icons/dashboards';
import { siemMigrationsLinks } from '../siem_migrations/links';
export const links: LinkItem = {
id: SecurityPageName.rulesLanding,
@ -106,6 +107,7 @@ export const links: LinkItem = {
}),
],
},
siemMigrationsLinks,
],
categories: [
{
@ -116,6 +118,7 @@ export const links: LinkItem = {
SecurityPageName.rules,
SecurityPageName.cloudSecurityPostureBenchmarks,
SecurityPageName.exceptions,
SecurityPageName.siemMigrationsRules,
],
},
{

View file

@ -0,0 +1,19 @@
/*
* 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 { SecuritySubPlugin } from '../app/types';
import { routes } from './routes';
export class SiemMigrations {
public setup() {}
public start(isEnabled = false): SecuritySubPlugin {
return {
routes: isEnabled ? routes : [],
};
}
}

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/security_solution/public/siem_migrations'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/security_solution/public/siem_migrations',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/security_solution/public/siem_migrations/**/*.{ts,tsx}',
],
moduleNameMapper: require('../../server/__mocks__/module_name_map'),
};

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import {
SecurityPageName,
SERVER_APP_ID,
SIEM_MIGRATIONS_RULES_PATH,
} from '../../common/constants';
import { SIEM_MIGRATIONS_RULES } from '../app/translations';
import type { LinkItem } from '../common/links/types';
import { IconConsoleCloud } from '../common/icons/console_cloud';
export const siemMigrationsLinks: LinkItem = {
id: SecurityPageName.siemMigrationsRules,
title: SIEM_MIGRATIONS_RULES,
description: i18n.translate('xpack.securitySolution.appLinks.siemMigrationsRulesDescription', {
defaultMessage: 'SIEM Rules Migrations.',
}),
landingIcon: IconConsoleCloud,
path: SIEM_MIGRATIONS_RULES_PATH,
capabilities: [`${SERVER_APP_ID}.show`],
skipUrlState: true,
hideTimeline: true,
globalSearchKeywords: [
i18n.translate('xpack.securitySolution.appLinks.siemMigrationsRules', {
defaultMessage: 'SIEM Rules Migrations',
}),
],
experimentalKey: 'siemMigrationsEnabled',
};

View file

@ -0,0 +1,31 @@
/*
* 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 { SecuritySubPluginRoutes } from '../app/types';
import { SIEM_MIGRATIONS_RULES_PATH, SecurityPageName } from '../../common/constants';
import { RulesPage } from './rules/pages';
import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper';
import { SecurityRoutePageWrapper } from '../common/components/security_route_page_wrapper';
export const RulesRoutes = () => {
return (
<PluginTemplateWrapper>
<SecurityRoutePageWrapper pageName={SecurityPageName.siemMigrationsRules}>
<RulesPage />
</SecurityRoutePageWrapper>
</PluginTemplateWrapper>
);
};
export const routes: SecuritySubPluginRoutes = [
{
path: SIEM_MIGRATIONS_RULES_PATH,
component: RulesRoutes,
},
];

View file

@ -0,0 +1,66 @@
/*
* 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 { replaceParams } from '@kbn/openapi-common/shared';
import { KibanaServices } from '../../../common/lib/kibana';
import {
SIEM_RULE_MIGRATIONS_ALL_STATS_PATH,
SIEM_RULE_MIGRATION_PATH,
} from '../../../../common/siem_migrations/constants';
import type {
GetAllStatsRuleMigrationResponse,
GetRuleMigrationResponse,
} from '../../../../common/siem_migrations/model/api/rules/rule_migration.gen';
/**
* Retrieves the stats for all the existing migrations, aggregated by `migration_id`.
*
* @param signal AbortSignal for cancelling request
*
* @throws An error if response is not OK
*/
export const getRuleMigrationsStatsAll = async ({
signal,
}: {
signal: AbortSignal | undefined;
}): Promise<GetAllStatsRuleMigrationResponse> => {
return KibanaServices.get().http.fetch<GetAllStatsRuleMigrationResponse>(
SIEM_RULE_MIGRATIONS_ALL_STATS_PATH,
{
method: 'GET',
version: '1',
signal,
}
);
};
/**
* Retrieves all the migration rule documents of a specific migration.
*
* @param migrationId `id` of the migration to retrieve rule documents for
* @param signal AbortSignal for cancelling request
*
* @throws An error if response is not OK
*/
export const getRuleMigrations = async ({
migrationId,
signal,
}: {
migrationId: string;
signal: AbortSignal | undefined;
}): Promise<GetRuleMigrationResponse> => {
return KibanaServices.get().http.fetch<GetRuleMigrationResponse>(
replaceParams(SIEM_RULE_MIGRATION_PATH, { migration_id: migrationId }),
{
method: 'GET',
version: '1',
signal,
}
);
};

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.
*/
const ONE_MINUTE = 60000;
export const DEFAULT_QUERY_OPTIONS = {
refetchIntervalInBackground: false,
staleTime: ONE_MINUTE * 5,
};

View file

@ -0,0 +1,33 @@
/*
* 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 { replaceParams } from '@kbn/openapi-common/shared';
import { DEFAULT_QUERY_OPTIONS } from './constants';
import { getRuleMigrations } from '../api';
import type { GetRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen';
import { SIEM_RULE_MIGRATION_PATH } from '../../../../../common/siem_migrations/constants';
export const useGetRuleMigrationsQuery = (
migrationId: string,
options?: UseQueryOptions<GetRuleMigrationResponse>
) => {
const SPECIFIC_MIGRATION_PATH = replaceParams(SIEM_RULE_MIGRATION_PATH, {
migration_id: migrationId,
});
return useQuery<GetRuleMigrationResponse>(
['GET', SPECIFIC_MIGRATION_PATH],
async ({ signal }) => {
return getRuleMigrations({ migrationId, signal });
},
{
...DEFAULT_QUERY_OPTIONS,
...options,
}
);
};

View file

@ -0,0 +1,30 @@
/*
* 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,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiComboBox, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import * as i18n from './translations';
export interface HeaderButtonsProps {
/**
* Available rule migrations ids
*/
migrationsIds: string[];
/**
* Selected rule migration id
*/
selectedMigrationId: string | undefined;
/**
* Handles migration selection changes
* @param selectedId Selected migration id
* @returns
*/
onMigrationIdChange: (selectedId?: string) => void;
}
const HeaderButtonsComponent: React.FC<HeaderButtonsProps> = ({
migrationsIds,
selectedMigrationId,
onMigrationIdChange,
}) => {
const migrationOptions = useMemo(() => {
const options: Array<EuiComboBoxOptionOption<string>> = migrationsIds.map((id, index) => ({
value: id,
'data-test-subj': `migrationSelectionOption-${index}`,
label: i18n.SIEM_MIGRATIONS_OPTION_LABEL(index + 1),
}));
return options;
}, [migrationsIds]);
const selectedMigrationOption = useMemo<Array<EuiComboBoxOptionOption<string>>>(() => {
const index = migrationsIds.findIndex((id) => id === selectedMigrationId);
return index !== -1
? [
{
value: selectedMigrationId,
'data-test-subj': `migrationSelectionOption-${index}`,
label: i18n.SIEM_MIGRATIONS_OPTION_LABEL(index + 1),
},
]
: [];
}, [migrationsIds, selectedMigrationId]);
const onChange = (selected: Array<EuiComboBoxOptionOption<string>>) => {
onMigrationIdChange(selected[0].value);
};
if (!migrationsIds.length) {
return null;
}
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}>
<EuiFlexItem grow={false}>
<EuiComboBox
aria-label={i18n.SIEM_MIGRATIONS_OPTION_AREAL_LABEL}
onChange={onChange}
options={migrationOptions}
selectedOptions={selectedMigrationOption}
singleSelection={{ asPlainText: true }}
isClearable={false}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const HeaderButtons = React.memo(HeaderButtonsComponent);
HeaderButtons.displayName = 'HeaderButtons';

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 { i18n } from '@kbn/i18n';
export const SIEM_MIGRATIONS_OPTION_AREAL_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.selectionOption.arealLabel',
{
defaultMessage: 'Select a migration',
}
);
export const SIEM_MIGRATIONS_OPTION_LABEL = (optionIndex: number) =>
i18n.translate('xpack.securitySolution.siemMigrations.rules.selectionOption.title', {
defaultMessage: 'SIEM rule migration {optionIndex}',
values: {
optionIndex,
},
});

View file

@ -0,0 +1,53 @@
/*
* 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 { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { SecurityPageName } from '../../../../../common';
import { useGetSecuritySolutionLinkProps } from '../../../../common/components/links';
import * as i18n from './translations';
const NoMigrationsComponent = () => {
const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps();
const { onClick: onClickLink } = getSecuritySolutionLinkProps({
deepLinkId: SecurityPageName.landing,
path: 'siem_migrations',
});
return (
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
direction="column"
wrap={true}
>
<EuiFlexItem grow={false}>
<EuiEmptyPrompt
title={<h2>{i18n.NO_MIGRATIONS_AVAILABLE}</h2>}
titleSize="s"
body={i18n.NO_MIGRATIONS_AVAILABLE_BODY}
data-test-subj="noMigrationsAvailable"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="arrowLeft"
color={'primary'}
onClick={onClickLink}
data-test-subj="goToSiemMigrationsButton"
>
{i18n.GO_BACK_TO_RULES_TABLE_BUTTON}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const NoMigrations = React.memo(NoMigrationsComponent);
NoMigrations.displayName = 'NoMigrations';

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const NO_MIGRATIONS_AVAILABLE = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.noMigrationsTitle',
{
defaultMessage: 'No migrations',
}
);
export const NO_MIGRATIONS_AVAILABLE_BODY = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.noMigrationsBodyTitle',
{
defaultMessage: 'There are no migrations available',
}
);
export const GO_BACK_TO_RULES_TABLE_BUTTON = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.goToMigrationsPageButton',
{
defaultMessage: 'Go back to SIEM Migrations',
}
);

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup } from '@elastic/eui';
import type { Dispatch, SetStateAction } from 'react';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import * as i18n from './translations';
import { RuleSearchField } from '../../../../detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rule_search_field';
import type { TableFilterOptions } from '../../hooks/use_filter_rules_to_install';
const FilterWrapper = styled(EuiFlexGroup)`
margin-bottom: ${({ theme }) => theme.eui.euiSizeM};
`;
export interface FiltersComponentProps {
/**
* Currently selected table filter
*/
filterOptions: TableFilterOptions;
/**
* Handles filter options changes
*/
setFilterOptions: Dispatch<SetStateAction<TableFilterOptions>>;
}
/**
* Collection of filters for filtering data within the SIEM Rules Migrations table.
* Contains search bar and tag selection
*/
const FiltersComponent: React.FC<FiltersComponentProps> = ({ filterOptions, setFilterOptions }) => {
const handleOnSearch = useCallback(
(filterString: string) => {
setFilterOptions((filters) => ({
...filters,
filter: filterString.trim(),
}));
},
[setFilterOptions]
);
return (
<FilterWrapper gutterSize="m" justifyContent="flexEnd" wrap>
<RuleSearchField
initialValue={filterOptions.filter}
onSearch={handleOnSearch}
placeholder={i18n.SEARCH_PLACEHOLDER}
/>
</FilterWrapper>
);
};
export const Filters = React.memo(FiltersComponent);
Filters.displayName = 'Filters';

View file

@ -0,0 +1,125 @@
/*
* 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 {
EuiInMemoryTable,
EuiSkeletonLoading,
EuiProgress,
EuiSkeletonTitle,
EuiSkeletonText,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import React, { useState } from 'react';
import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import {
RULES_TABLE_INITIAL_PAGE_SIZE,
RULES_TABLE_PAGE_SIZE_OPTIONS,
} from '../../../../detection_engine/rule_management_ui/components/rules_table/constants';
import { NoItemsMessage } from './no_items_message';
import { Filters } from './filters';
import { useRulesTableColumns } from '../../hooks/use_rules_table_columns';
import { useGetRuleMigrationsQuery } from '../../api/hooks/use_get_rule_migrations';
import type { TableFilterOptions } from '../../hooks/use_filter_rules_to_install';
import { useFilterRulesToInstall } from '../../hooks/use_filter_rules_to_install';
export interface RulesTableComponentProps {
/**
* Selected rule migration id
*/
migrationId: string;
/**
* Opens the flyout with the details of the rule migration
* @param rule Rule migration
* @returns
*/
openRulePreview: (rule: RuleMigration) => void;
}
/**
* Table Component for displaying SIEM rules migrations
*/
const RulesTableComponent: React.FC<RulesTableComponentProps> = ({
migrationId,
openRulePreview,
}) => {
const { data: ruleMigrations, isLoading } = useGetRuleMigrationsQuery(migrationId);
const [selectedRuleMigrations, setSelectedRuleMigrations] = useState<RuleMigration[]>([]);
const [filterOptions, setFilterOptions] = useState<TableFilterOptions>({
filter: '',
});
const filteredRuleMigrations = useFilterRulesToInstall({
filterOptions,
ruleMigrations: ruleMigrations ?? [],
});
const shouldShowProgress = isLoading;
const rulesColumns = useRulesTableColumns({
openRulePreview,
});
return (
<>
{shouldShowProgress && (
<EuiProgress
data-test-subj="loadingRulesInfoProgress"
size="xs"
position="absolute"
color="accent"
/>
)}
<EuiSkeletonLoading
isLoading={isLoading}
loadingContent={
<>
<EuiSkeletonTitle />
<EuiSkeletonText />
</>
}
loadedContent={
!filteredRuleMigrations.length ? (
<NoItemsMessage />
) : (
<>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<Filters filterOptions={filterOptions} setFilterOptions={setFilterOptions} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiInMemoryTable
items={filteredRuleMigrations}
sorting
pagination={{
initialPageSize: RULES_TABLE_INITIAL_PAGE_SIZE,
pageSizeOptions: RULES_TABLE_PAGE_SIZE_OPTIONS,
}}
selection={{
selectable: () => true,
onSelectionChange: setSelectedRuleMigrations,
initialSelected: selectedRuleMigrations,
}}
itemId="rule_id"
data-test-subj="rules-translation-table"
columns={rulesColumns}
/>
</>
)
}
/>
</>
);
};
export const RulesTable = React.memo(RulesTableComponent);
RulesTable.displayName = 'RulesTable';

View file

@ -0,0 +1,52 @@
/*
* 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 { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { SecurityPageName } from '../../../../../common';
import { useGetSecuritySolutionLinkProps } from '../../../../common/components/links';
import * as i18n from './translations';
const NoItemsMessageComponent = () => {
const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps();
const { onClick: onClickLink } = getSecuritySolutionLinkProps({
deepLinkId: SecurityPageName.landing,
path: 'siem_migrations',
});
return (
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
direction="column"
wrap={true}
>
<EuiFlexItem grow={false}>
<EuiEmptyPrompt
title={<h2>{i18n.NO_TRANSLATIONS_AVAILABLE_FOR_INSTALL}</h2>}
titleSize="s"
body={i18n.NO_TRANSLATIONS_AVAILABLE_FOR_INSTALL_BODY}
data-test-subj="noRulesTranslationAvailableForInstall"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="arrowLeft"
color={'primary'}
onClick={onClickLink}
data-test-subj="goToSiemMigrationsButton"
>
{i18n.GO_BACK_TO_RULES_TABLE_BUTTON}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const NoItemsMessage = React.memo(NoItemsMessageComponent);

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SEARCH_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.searchBarPlaceholder',
{
defaultMessage: 'Search by rule name',
}
);
export const NO_TRANSLATIONS_AVAILABLE_FOR_INSTALL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.noRulesTitle',
{
defaultMessage: 'Empty migration',
}
);
export const NO_TRANSLATIONS_AVAILABLE_FOR_INSTALL_BODY = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.noRulesBodyTitle',
{
defaultMessage: 'There are no translations available for installation',
}
);
export const GO_BACK_TO_RULES_TABLE_BUTTON = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.goToMigrationsPageButton',
{
defaultMessage: 'Go back to SIEM Migrations',
}
);

View file

@ -0,0 +1,19 @@
/*
* 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 { shallow } from 'enzyme';
import { StatusBadge } from '.';
describe('StatusBadge', () => {
it('renders correctly', () => {
const wrapper = shallow(<StatusBadge value="full" />);
expect(wrapper.find('HealthTruncateText')).toHaveLength(1);
});
});

View file

@ -0,0 +1,49 @@
/*
* 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 { euiLightVars } from '@kbn/ui-theme';
import type { RuleMigrationTranslationResult } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import { HealthTruncateText } from '../../../../common/components/health_truncate_text';
import { convertTranslationResultIntoText } from '../../utils/helpers';
const { euiColorVis0, euiColorVis7, euiColorVis9 } = euiLightVars;
const statusToColorMap: Record<RuleMigrationTranslationResult, string> = {
full: euiColorVis0,
partial: euiColorVis7,
untranslatable: euiColorVis9,
};
interface Props {
value?: RuleMigrationTranslationResult;
installedRuleId?: string;
'data-test-subj'?: string;
}
const StatusBadgeComponent: React.FC<Props> = ({
value,
installedRuleId,
'data-test-subj': dataTestSubj = 'translation-result',
}) => {
const translationResult = installedRuleId || !value ? 'full' : value;
const displayValue = convertTranslationResultIntoText(translationResult);
const color = statusToColorMap[translationResult];
return (
<HealthTruncateText
healthColor={color}
tooltipContent={displayValue}
dataTestSubj={dataTestSubj}
>
{displayValue}
</HealthTruncateText>
);
};
export const StatusBadge = React.memo(StatusBadgeComponent);
StatusBadge.displayName = 'StatusBadge';

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export const DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['50%', '50%'];
export const LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['30%', '70%'];

View file

@ -0,0 +1,246 @@
/*
* 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 { FC, PropsWithChildren } from 'react';
import React, { useMemo, useState, useEffect } from 'react';
import styled from 'styled-components';
import { css } from '@emotion/css';
import { euiThemeVars } from '@kbn/ui-theme';
import {
EuiButtonEmpty,
EuiTitle,
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiTabbedContent,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
useGeneratedHtmlId,
} from '@elastic/eui';
import type { EuiTabbedContentTab, EuiTabbedContentProps, EuiFlyoutProps } from '@elastic/eui';
import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import {
RuleOverviewTab,
useOverviewTabSections,
} from '../../../../detection_engine/rule_management/components/rule_details/rule_overview_tab';
import {
type RuleResponse,
type Severity,
} from '../../../../../common/api/detection_engine/model/rule_schema';
import * as i18n from './translations';
import {
DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS,
LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS,
} from './constants';
import { TranslationTab } from './translation_tab';
import {
DEFAULT_TRANSLATION_RISK_SCORE,
DEFAULT_TRANSLATION_SEVERITY,
} from '../../utils/constants';
const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
.euiFlyoutBody__overflow {
display: flex;
flex: 1;
overflow: hidden;
.euiFlyoutBody__overflowContent {
flex: 1;
overflow: hidden;
padding: ${({ theme }) => `0 ${theme.eui.euiSizeL} 0`};
}
}
`;
const StyledFlexGroup = styled(EuiFlexGroup)`
height: 100%;
`;
const StyledEuiFlexItem = styled(EuiFlexItem)`
&.euiFlexItem {
flex: 1 0 0;
overflow: hidden;
}
`;
const StyledEuiTabbedContent = styled(EuiTabbedContent)`
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
> [role='tabpanel'] {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
::-webkit-scrollbar {
-webkit-appearance: none;
width: 7px;
}
::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.5);
-webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
}
}
`;
/*
* Fixes tabs to the top and allows the content to scroll.
*/
const ScrollableFlyoutTabbedContent = (props: EuiTabbedContentProps) => (
<StyledFlexGroup direction="column" gutterSize="none">
<StyledEuiFlexItem grow={true}>
<StyledEuiTabbedContent {...props} />
</StyledEuiFlexItem>
</StyledFlexGroup>
);
const tabPaddingClassName = css`
padding: 0 ${euiThemeVars.euiSizeM} ${euiThemeVars.euiSizeXL} ${euiThemeVars.euiSizeM};
`;
export const TabContentPadding: FC<PropsWithChildren<unknown>> = ({ children }) => (
<div className={tabPaddingClassName}>{children}</div>
);
interface TranslationDetailsFlyoutProps {
ruleActions?: React.ReactNode;
ruleMigration: RuleMigration;
size?: EuiFlyoutProps['size'];
extraTabs?: EuiTabbedContentTab[];
closeFlyout: () => void;
}
export const TranslationDetailsFlyout = ({
ruleActions,
ruleMigration,
size = 'm',
extraTabs = [],
closeFlyout,
}: TranslationDetailsFlyoutProps) => {
const { expandedOverviewSections, toggleOverviewSection } = useOverviewTabSections();
const rule: RuleResponse = useMemo(() => {
const esqlLanguage = ruleMigration.elastic_rule?.query_language ?? 'esql';
return {
type: esqlLanguage,
language: esqlLanguage,
name: ruleMigration.elastic_rule?.title,
description: ruleMigration.elastic_rule?.description,
query: ruleMigration.elastic_rule?.query,
// Default values
severity: (ruleMigration.elastic_rule?.severity as Severity) ?? DEFAULT_TRANSLATION_SEVERITY,
risk_score: DEFAULT_TRANSLATION_RISK_SCORE,
from: 'now-360s',
to: 'now',
interval: '5m',
} as RuleResponse; // TODO: we need to adjust RuleOverviewTab to allow partial RuleResponse as a parameter
}, [ruleMigration]);
const translationTab: EuiTabbedContentTab = useMemo(
() => ({
id: 'translation',
name: i18n.TRANSLATION_TAB_LABEL,
content: (
<TabContentPadding>
<TranslationTab ruleMigration={ruleMigration} />
</TabContentPadding>
),
}),
[ruleMigration]
);
const overviewTab: EuiTabbedContentTab = useMemo(
() => ({
id: 'overview',
name: i18n.OVERVIEW_TAB_LABEL,
content: (
<TabContentPadding>
<RuleOverviewTab
rule={rule}
columnWidths={
size === 'l'
? LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS
: DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS
}
expandedOverviewSections={expandedOverviewSections}
toggleOverviewSection={toggleOverviewSection}
/>
</TabContentPadding>
),
}),
[rule, size, expandedOverviewSections, toggleOverviewSection]
);
const tabs = useMemo(() => {
return [...extraTabs, translationTab, overviewTab];
}, [extraTabs, translationTab, overviewTab]);
const [selectedTabId, setSelectedTabId] = useState<string>(tabs[0].id);
const selectedTab = tabs.find((tab) => tab.id === selectedTabId) ?? tabs[0];
useEffect(() => {
if (!tabs.find((tab) => tab.id === selectedTabId)) {
// Switch to first tab if currently selected tab is not available for this rule
setSelectedTabId(tabs[0].id);
}
}, [tabs, selectedTabId]);
const onTabClick = (tab: EuiTabbedContentTab) => {
setSelectedTabId(tab.id);
};
const migrationsRulesFlyoutTitleId = useGeneratedHtmlId({
prefix: 'migrationRulesFlyoutTitle',
});
return (
<EuiFlyout
size={size}
onClose={closeFlyout}
key="migrations-rules-flyout"
paddingSize="l"
data-test-subj="ruleMigrationDetailsFlyout"
aria-labelledby={migrationsRulesFlyoutTitleId}
ownFocus
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id={migrationsRulesFlyoutTitleId}>{rule.name}</h2>
</EuiTitle>
<EuiSpacer size="l" />
</EuiFlyoutHeader>
<StyledEuiFlyoutBody>
<ScrollableFlyoutTabbedContent
tabs={tabs}
selectedTab={selectedTab}
onTabClick={onTabClick}
/>
</StyledEuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={closeFlyout} flush="left">
{i18n.DISMISS_BUTTON_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>{ruleActions}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiTitle } from '@elastic/eui';
import * as i18n from './translations';
export function TranslationTabHeader(): JSX.Element {
return (
<EuiFlexGroup direction="row" alignItems="center">
<EuiTitle data-test-subj="ruleTranslationLabel" size="xs">
<h5>{i18n.TAB_HEADER_TITLE}</h5>
</EuiTitle>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
EuiAccordion,
EuiBadge,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSpacer,
EuiSplitPanel,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { FormattedMessage } from '@kbn/i18n-react';
import type { RuleMigration } from '../../../../../../common/siem_migrations/model/rule_migration.gen';
import { TranslationTabHeader } from './header';
import { RuleQueryComponent } from './rule_query';
import * as i18n from './translations';
import { convertTranslationResultIntoText } from '../../../utils/helpers';
interface TranslationTabProps {
ruleMigration: RuleMigration;
}
export const TranslationTab = ({ ruleMigration }: TranslationTabProps) => {
const { euiTheme } = useEuiTheme();
const name = ruleMigration.elastic_rule?.title ?? ruleMigration.original_rule.title;
const originalQuery = ruleMigration.original_rule.query;
const elasticQuery = ruleMigration.elastic_rule?.query ?? 'Prebuilt rule query';
return (
<>
<EuiSpacer size="m" />
<EuiFormRow label={i18n.NAME_LABEL} fullWidth>
<EuiFieldText value={name} fullWidth />
</EuiFormRow>
<EuiSpacer size="m" />
<EuiAccordion
id="translationQueryItem"
buttonContent={<TranslationTabHeader />}
initialIsOpen={true}
>
<EuiFlexItem>
<EuiSpacer size="s" />
<EuiSplitPanel.Outer grow hasShadow={false} hasBorder={true}>
<EuiSplitPanel.Inner grow={false} color="subdued" paddingSize="s">
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiTitle size="xxs">
<h2>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.translationDetails.translationTab.statusTitle"
defaultMessage="Translation status"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge
iconSide="right"
iconType="arrowDown"
color="primary"
onClick={() => {}}
onClickAriaLabel={'Click to update translation status'}
>
{convertTranslationResultIntoText(ruleMigration.translation_result)}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner grow>
<EuiFlexGroup gutterSize="s" alignItems="flexStart">
<EuiFlexItem grow={1}>
<RuleQueryComponent
title={i18n.SPLUNK_QUERY_TITLE}
query={originalQuery}
canEdit={false}
/>
</EuiFlexItem>
<EuiFlexItem
grow={0}
css={css`
align-self: stretch;
border-right: ${euiTheme.border.thin};
`}
/>
<EuiFlexItem grow={1}>
<RuleQueryComponent
title={i18n.ESQL_TRANSLATION_TITLE}
query={elasticQuery}
canEdit={false}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
</EuiFlexItem>
</EuiAccordion>
</>
);
};

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { EuiMarkdownEditor, EuiMarkdownFormat, EuiTitle } from '@elastic/eui';
import { SideHeader } from '../../../../../detection_engine/rule_management/components/rule_details/three_way_diff/components/side_header';
import { FinalSideHelpInfo } from '../../../../../detection_engine/rule_management/components/rule_details/three_way_diff/final_side/final_side_help_info';
import * as i18n from './translations';
interface RuleQueryProps {
title: string;
query: string;
canEdit?: boolean;
}
export const RuleQueryComponent = ({ title, query, canEdit }: RuleQueryProps) => {
const queryTextComponent = useMemo(() => {
if (canEdit) {
return (
<EuiMarkdownEditor
aria-label={i18n.TRANSLATED_QUERY_AREAL_LABEL}
value={query}
onChange={() => {}}
height={400}
initialViewMode={'viewing'}
/>
);
} else {
return <EuiMarkdownFormat>{query}</EuiMarkdownFormat>;
}
}, [canEdit, query]);
return (
<>
<SideHeader>
<EuiTitle size="xxs">
<h3>
{title}
<FinalSideHelpInfo />
</h3>
</EuiTitle>
</SideHeader>
{queryTextComponent}
</>
);
};

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const TAB_HEADER_TITLE = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationDetails.translationTab.title',
{
defaultMessage: 'Translation',
}
);
export const NAME_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationDetails.translationTab.nameLabel',
{
defaultMessage: 'Name',
}
);
export const SPLUNK_QUERY_TITLE = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationDetails.translationTab.splunkQueryTitle',
{
defaultMessage: 'Splunk query',
}
);
export const ESQL_TRANSLATION_TITLE = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationDetails.translationTab.esqlTranslationTitle',
{
defaultMessage: 'ES|QL translation',
}
);
export const TRANSLATED_QUERY_AREAL_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationDetails.translationTab.queryArealLabel',
{
defaultMessage: 'Translated query',
}
);

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const OVERVIEW_TAB_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationDetails.overviewTabLabel',
{
defaultMessage: 'Overview',
}
);
export const TRANSLATION_TAB_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationDetails.translationTabLabel',
{
defaultMessage: 'Translation',
}
);
export const DISMISS_BUTTON_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationDetails.dismissButtonLabel',
{
defaultMessage: 'Dismiss',
}
);

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const COLUMN_STATUS = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.columns.statusTitle',
{
defaultMessage: 'Status',
}
);

View file

@ -0,0 +1,33 @@
/*
* 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 { useMemo } from 'react';
import type { RuleMigration } from '../../../../common/siem_migrations/model/rule_migration.gen';
import type { FilterOptions } from '../../../detection_engine/rule_management/logic/types';
export type TableFilterOptions = Pick<FilterOptions, 'filter'>;
export const useFilterRulesToInstall = ({
ruleMigrations,
filterOptions,
}: {
ruleMigrations: RuleMigration[];
filterOptions: TableFilterOptions;
}) => {
const filteredRules = useMemo(() => {
const { filter } = filterOptions;
return ruleMigrations.filter((migration) => {
const name = migration.elastic_rule?.title ?? migration.original_rule.title;
if (filter && !name.toLowerCase().includes(filter.toLowerCase())) {
return false;
}
return true;
});
}, [filterOptions, ruleMigrations]);
return filteredRules;
};

View file

@ -0,0 +1,55 @@
/*
* 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 { ReactNode } from 'react';
import React, { useCallback, useState, useMemo } from 'react';
import type { EuiTabbedContentTab } from '@elastic/eui';
import type { RuleMigration } from '../../../../common/siem_migrations/model/rule_migration.gen';
import { TranslationDetailsFlyout } from '../components/translation_details_flyout';
interface UseRulePreviewFlyoutParams {
ruleActionsFactory: (ruleMigration: RuleMigration, closeRulePreview: () => void) => ReactNode;
extraTabsFactory?: (ruleMigration: RuleMigration) => EuiTabbedContentTab[];
}
interface UseRulePreviewFlyoutResult {
rulePreviewFlyout: ReactNode;
openRulePreview: (rule: RuleMigration) => void;
closeRulePreview: () => void;
}
export function useRulePreviewFlyout({
extraTabsFactory,
ruleActionsFactory,
}: UseRulePreviewFlyoutParams): UseRulePreviewFlyoutResult {
const [ruleMigration, setRuleMigrationForPreview] = useState<RuleMigration | undefined>();
const closeRulePreview = useCallback(() => setRuleMigrationForPreview(undefined), []);
const ruleActions = useMemo(
() => ruleMigration && ruleActionsFactory(ruleMigration, closeRulePreview),
[ruleMigration, ruleActionsFactory, closeRulePreview]
);
const extraTabs = useMemo(
() => (ruleMigration && extraTabsFactory ? extraTabsFactory(ruleMigration) : []),
[ruleMigration, extraTabsFactory]
);
return {
rulePreviewFlyout: ruleMigration && (
<TranslationDetailsFlyout
ruleMigration={ruleMigration}
size="l"
closeFlyout={closeRulePreview}
ruleActions={ruleActions}
extraTabs={extraTabs}
/>
),
openRulePreview: useCallback((rule: RuleMigration) => {
setRuleMigrationForPreview(rule);
}, []),
closeRulePreview,
};
}

View file

@ -0,0 +1,107 @@
/*
* 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 { EuiBasicTableColumn } from '@elastic/eui';
import { EuiText, EuiLink } from '@elastic/eui';
import React, { useMemo } from 'react';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import type { RuleMigration } from '../../../../common/siem_migrations/model/rule_migration.gen';
import { SeverityBadge } from '../../../common/components/severity_badge';
import * as rulesI18n from '../../../detections/pages/detection_engine/rules/translations';
import * as i18n from './translations';
import { getNormalizedSeverity } from '../../../detection_engine/rule_management_ui/components/rules_table/helpers';
import { StatusBadge } from '../components/status_badge';
import { DEFAULT_TRANSLATION_RISK_SCORE, DEFAULT_TRANSLATION_SEVERITY } from '../utils/constants';
export type TableColumn = EuiBasicTableColumn<RuleMigration>;
interface RuleNameProps {
name: string;
rule: RuleMigration;
openRulePreview: (rule: RuleMigration) => void;
}
const RuleName = ({ name, rule, openRulePreview }: RuleNameProps) => {
return (
<EuiLink
onClick={() => {
openRulePreview(rule);
}}
data-test-subj="ruleName"
>
{name}
</EuiLink>
);
};
const createRuleNameColumn = ({
openRulePreview,
}: {
openRulePreview: (rule: RuleMigration) => void;
}): TableColumn => {
return {
field: 'original_rule.title',
name: rulesI18n.COLUMN_RULE,
render: (value: RuleMigration['original_rule']['title'], rule: RuleMigration) => (
<RuleName name={value} rule={rule} openRulePreview={openRulePreview} />
),
sortable: true,
truncateText: true,
width: '40%',
align: 'left',
};
};
const STATUS_COLUMN: TableColumn = {
field: 'translation_result',
name: i18n.COLUMN_STATUS,
render: (value: RuleMigration['translation_result'], rule: RuleMigration) => (
<StatusBadge value={value} installedRuleId={rule.elastic_rule?.id} />
),
sortable: false,
truncateText: true,
width: '12%',
};
export const useRulesTableColumns = ({
openRulePreview,
}: {
openRulePreview: (rule: RuleMigration) => void;
}): TableColumn[] => {
return useMemo(
() => [
createRuleNameColumn({ openRulePreview }),
STATUS_COLUMN,
{
field: 'risk_score',
name: rulesI18n.COLUMN_RISK_SCORE,
render: () => (
<EuiText data-test-subj="riskScore" size="s">
{DEFAULT_TRANSLATION_RISK_SCORE}
</EuiText>
),
sortable: true,
truncateText: true,
width: '75px',
},
{
field: 'elastic_rule.severity',
name: rulesI18n.COLUMN_SEVERITY,
render: (value?: Severity) => (
<SeverityBadge value={value ?? DEFAULT_TRANSLATION_SEVERITY} />
),
sortable: ({ elastic_rule: elasticRule }: RuleMigration) =>
getNormalizedSeverity(
(elasticRule?.severity as Severity) ?? DEFAULT_TRANSLATION_SEVERITY
),
truncateText: true,
width: '12%',
},
],
[openRulePreview]
);
};

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { 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';
const RulesPageComponent: React.FC = () => {
const { data: ruleMigrationsStatsAll, isLoading: isLoadingMigrationsStats } =
useGetRuleMigrationsStatsAllQuery();
const migrationsIds = useMemo(() => {
if (isLoadingMigrationsStats || !ruleMigrationsStatsAll?.length) {
return [];
}
return ruleMigrationsStatsAll
.filter((migration) => migration.status === 'finished')
.map((migration) => migration.migration_id);
}, [isLoadingMigrationsStats, ruleMigrationsStatsAll]);
const [selectedMigrationId, setSelectedMigrationId] = useState<string | undefined>();
const onMigrationIdChange = (selectedId?: string) => {
setSelectedMigrationId(selectedId);
};
useEffect(() => {
if (!migrationsIds.length) {
return;
}
const index = migrationsIds.findIndex((id) => id === selectedMigrationId);
if (index === -1) {
setSelectedMigrationId(migrationsIds[0]);
}
}, [migrationsIds, selectedMigrationId]);
const ruleActionsFactory = useCallback(
(ruleMigration: RuleMigration, closeRulePreview: () => void) => {
// TODO: Add flyout action buttons
return null;
},
[]
);
const { rulePreviewFlyout, openRulePreview } = useRulePreviewFlyout({
ruleActionsFactory,
});
return (
<>
<NeedAdminForUpdateRulesCallOut />
<MissingPrivilegesCallOut />
<SecuritySolutionPageWrapper>
<HeaderPage title={i18n.PAGE_TITLE}>
<HeaderButtons
migrationsIds={migrationsIds}
selectedMigrationId={selectedMigrationId}
onMigrationIdChange={onMigrationIdChange}
/>
</HeaderPage>
<EuiSkeletonLoading
isLoading={isLoadingMigrationsStats}
loadingContent={
<>
<EuiSkeletonTitle />
<EuiSkeletonText />
</>
}
loadedContent={
selectedMigrationId ? (
<RulesTable migrationId={selectedMigrationId} openRulePreview={openRulePreview} />
) : (
<NoMigrations />
)
}
/>
{rulePreviewFlyout}
</SecuritySolutionPageWrapper>
<SpyRoute pageName={SecurityPageName.siemMigrationsRules} />
</>
);
};
export const RulesPage = React.memo(RulesPageComponent);
RulesPage.displayName = 'RulesPage';

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const PAGE_TITLE = i18n.translate('xpack.securitySolution.siemMigrations.rules.pageTitle', {
defaultMessage: 'Translated rules',
});

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 type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
export const DEFAULT_TRANSLATION_RISK_SCORE = 21;
export const DEFAULT_TRANSLATION_SEVERITY: Severity = 'low';

View file

@ -0,0 +1,28 @@
/*
* 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 {
RuleMigrationTranslationResultEnum,
type RuleMigrationTranslationResult,
} from '../../../../common/siem_migrations/model/rule_migration.gen';
import * as i18n from './translations';
export const convertTranslationResultIntoText = (status?: RuleMigrationTranslationResult) => {
switch (status) {
case RuleMigrationTranslationResultEnum.full:
return i18n.SIEM_TRANSLATION_RESULT_FULL_LABEL;
case RuleMigrationTranslationResultEnum.partial:
return i18n.SIEM_TRANSLATION_RESULT_PARTIAL_LABEL;
case RuleMigrationTranslationResultEnum.untranslatable:
return i18n.SIEM_TRANSLATION_RESULT_UNTRANSLATABLE_LABEL;
default:
return i18n.SIEM_TRANSLATION_RESULT_UNKNOWN_LABEL;
}
};

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SIEM_TRANSLATION_RESULT_FULL_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationResult.full',
{
defaultMessage: 'Fully translated',
}
);
export const SIEM_TRANSLATION_RESULT_PARTIAL_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationResult.partially',
{
defaultMessage: 'Partially translated',
}
);
export const SIEM_TRANSLATION_RESULT_UNTRANSLATABLE_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationResult.untranslatable',
{
defaultMessage: 'Not translated',
}
);
export const SIEM_TRANSLATION_RESULT_UNKNOWN_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.translationResult.unknown',
{
defaultMessage: 'Unknown',
}
);

View file

@ -83,6 +83,7 @@ import type { EntityAnalytics } from './entity_analytics';
import type { Assets } from './assets';
import type { Investigations } from './investigations';
import type { MachineLearning } from './machine_learning';
import type { SiemMigrations } from './siem_migrations';
import type { Dashboards } from './dashboards';
import type { BreadcrumbsNav } from './common/breadcrumbs/types';
@ -243,6 +244,7 @@ export interface SubPlugins {
assets: Assets;
investigations: Investigations;
machineLearning: MachineLearning;
siemMigrations: SiemMigrations;
}
// TODO: find a better way to defined these types
@ -266,4 +268,5 @@ export interface StartedSubPlugins {
assets: ReturnType<Assets['start']>;
investigations: ReturnType<Investigations['start']>;
machineLearning: ReturnType<MachineLearning['start']>;
siemMigrations: ReturnType<SiemMigrations['start']>;
}