[Rules migration] Add sorting functionality to rules migration table (#11379) (#203396)

## Summary

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

These changes add sorting functionality to the migration rules table. It
is possible to sort migration rules by next columns: `Updated`, `Name`,
`Status`, `Risk Score`, `Severity` and `Author`.

### Other changes

Next fixes and adjustments were also implemented as part of this PR:
* `Installed` status in migration rules table to indicate whether the
rule was installed
* Rules selection and installation of selected rules
* Disable selection for not fully translated rules
* `Author` column to show whether the translated rule matched one of the
existing Elastic prebuilt rules
* `Install and enable` and `Install without enabling` buttons within the
migration rule details flyout
This commit is contained in:
Ievgen Sorokopud 2024-12-09 20:21:16 +01:00 committed by GitHub
parent ebb4f503a5
commit 70a5bb33c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 541 additions and 122 deletions

View file

@ -46,6 +46,7 @@ export type FieldMap<T extends string = string> = Record<
array?: boolean; array?: boolean;
doc_values?: boolean; doc_values?: boolean;
enabled?: boolean; enabled?: boolean;
fields?: Record<string, { type: string }>;
format?: string; format?: string;
ignore_above?: number; ignore_above?: number;
multi_fields?: MultiField[]; multi_fields?: MultiField[];

View file

@ -59,6 +59,8 @@ export type GetRuleMigrationRequestQuery = z.infer<typeof GetRuleMigrationReques
export const GetRuleMigrationRequestQuery = z.object({ export const GetRuleMigrationRequestQuery = z.object({
page: z.coerce.number().optional(), page: z.coerce.number().optional(),
per_page: z.coerce.number().optional(), per_page: z.coerce.number().optional(),
sort_field: NonEmptyString.optional(),
sort_direction: z.enum(['asc', 'desc']).optional(),
search_term: z.string().optional(), search_term: z.string().optional(),
}); });
export type GetRuleMigrationRequestQueryInput = z.input<typeof GetRuleMigrationRequestQuery>; export type GetRuleMigrationRequestQueryInput = z.input<typeof GetRuleMigrationRequestQuery>;
@ -154,7 +156,13 @@ export type InstallMigrationRulesRequestParamsInput = z.input<
>; >;
export type InstallMigrationRulesRequestBody = z.infer<typeof InstallMigrationRulesRequestBody>; export type InstallMigrationRulesRequestBody = z.infer<typeof InstallMigrationRulesRequestBody>;
export const InstallMigrationRulesRequestBody = z.array(NonEmptyString); export const InstallMigrationRulesRequestBody = z.object({
ids: z.array(NonEmptyString),
/**
* Indicates whether installed rules should be enabled
*/
enabled: z.boolean().optional(),
});
export type InstallMigrationRulesRequestBodyInput = z.input< export type InstallMigrationRulesRequestBodyInput = z.input<
typeof InstallMigrationRulesRequestBody typeof InstallMigrationRulesRequestBody
>; >;

View file

@ -133,6 +133,19 @@ paths:
required: false required: false
schema: schema:
type: number type: number
- name: sort_field
in: query
required: false
schema:
$ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString'
- name: sort_direction
in: query
required: false
schema:
type: string
enum:
- asc
- desc
- name: search_term - name: search_term
in: query in: query
required: false required: false
@ -180,10 +193,18 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: object
required:
- ids
properties:
ids:
type: array type: array
items: items:
description: The rule migration id description: The rule migration id
$ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' $ref: '../../../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString'
enabled:
type: boolean
description: Indicates whether installed rules should be enabled
responses: responses:
200: 200:
description: Indicates rules migrations have been installed correctly. description: Indicates rules migrations have been installed correctly.

View file

@ -22,13 +22,17 @@ export const isMigrationCustomRule = (rule?: ElasticRule): rule is MigrationCust
!isMigrationPrebuiltRule(rule) && !isMigrationPrebuiltRule(rule) &&
!!(rule?.title && rule?.description && rule?.query && rule?.query_language); !!(rule?.title && rule?.description && rule?.query && rule?.query_language);
export const convertMigrationCustomRuleToSecurityRulePayload = (rule: MigrationCustomRule) => { export const convertMigrationCustomRuleToSecurityRulePayload = (
rule: MigrationCustomRule,
enabled: boolean
) => {
return { return {
type: rule.query_language, type: rule.query_language,
language: rule.query_language, language: rule.query_language,
query: rule.query, query: rule.query,
name: rule.title, name: rule.title,
description: rule.description, description: rule.description,
enabled,
...DEFAULT_TRANSLATION_FIELDS, ...DEFAULT_TRANSLATION_FIELDS,
severity: (rule.severity as Severity) ?? DEFAULT_TRANSLATION_SEVERITY, severity: (rule.severity as Severity) ?? DEFAULT_TRANSLATION_SEVERITY,

View file

@ -120,6 +120,10 @@ export interface GetRuleMigrationParams {
page?: number; page?: number;
/** Optional number of documents per page to retrieve */ /** Optional number of documents per page to retrieve */
perPage?: number; perPage?: number;
/** Optional field of the rule migration object to sort results by */
sortField?: string;
/** Optional direction to sort results by */
sortDirection?: 'asc' | 'desc';
/** Optional search term to filter documents */ /** Optional search term to filter documents */
searchTerm?: string; searchTerm?: string;
/** Optional AbortSignal for cancelling request */ /** Optional AbortSignal for cancelling request */
@ -130,12 +134,24 @@ export const getRuleMigrations = async ({
migrationId, migrationId,
page, page,
perPage, perPage,
sortField,
sortDirection,
searchTerm, searchTerm,
signal, signal,
}: GetRuleMigrationParams): Promise<GetRuleMigrationResponse> => { }: GetRuleMigrationParams): Promise<GetRuleMigrationResponse> => {
return KibanaServices.get().http.get<GetRuleMigrationResponse>( return KibanaServices.get().http.get<GetRuleMigrationResponse>(
replaceParams(SIEM_RULE_MIGRATION_PATH, { migration_id: migrationId }), replaceParams(SIEM_RULE_MIGRATION_PATH, { migration_id: migrationId }),
{ version: '1', query: { page, per_page: perPage, search_term: searchTerm }, signal } {
version: '1',
query: {
page,
per_page: perPage,
sort_field: sortField,
sort_direction: sortDirection,
search_term: searchTerm,
},
signal,
}
); );
}; };
@ -163,6 +179,8 @@ export interface InstallRulesParams {
migrationId: string; migrationId: string;
/** The rule ids to install */ /** The rule ids to install */
ids: string[]; ids: string[];
/** Optional indicator to enable the installed rule */
enabled?: boolean;
/** Optional AbortSignal for cancelling request */ /** Optional AbortSignal for cancelling request */
signal?: AbortSignal; signal?: AbortSignal;
} }
@ -170,11 +188,12 @@ export interface InstallRulesParams {
export const installMigrationRules = async ({ export const installMigrationRules = async ({
migrationId, migrationId,
ids, ids,
enabled,
signal, signal,
}: InstallRulesParams): Promise<InstallMigrationRulesResponse> => { }: InstallRulesParams): Promise<InstallMigrationRulesResponse> => {
return KibanaServices.get().http.post<InstallMigrationRulesResponse>( return KibanaServices.get().http.post<InstallMigrationRulesResponse>(
replaceParams(SIEM_RULE_MIGRATION_INSTALL_PATH, { migration_id: migrationId }), replaceParams(SIEM_RULE_MIGRATION_INSTALL_PATH, { migration_id: migrationId }),
{ version: '1', body: JSON.stringify(ids), signal } { version: '1', body: JSON.stringify({ ids, enabled }), signal }
); );
}; };

View file

@ -84,7 +84,8 @@ export const MigrationRuleDetailsFlyout: React.FC<MigrationRuleDetailsFlyoutProp
const rule = useMemo(() => { const rule = useMemo(() => {
if (isMigrationCustomRule(ruleMigration.elastic_rule)) { if (isMigrationCustomRule(ruleMigration.elastic_rule)) {
return convertMigrationCustomRuleToSecurityRulePayload( return convertMigrationCustomRuleToSecurityRulePayload(
ruleMigration.elastic_rule ruleMigration.elastic_rule,
false
) as RuleResponse; // TODO: we need to adjust RuleOverviewTab to allow partial RuleResponse as a parameter; ) as RuleResponse; // TODO: we need to adjust RuleOverviewTab to allow partial RuleResponse as a parameter;
} }
return matchedPrebuiltRule; return matchedPrebuiltRule;

View file

@ -6,7 +6,13 @@
*/ */
import React from 'react'; import React from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
} from '@elastic/eui';
import * as i18n from './translations'; import * as i18n from './translations';
export interface BulkActionsProps { export interface BulkActionsProps {
@ -29,13 +35,14 @@ export const BulkActions: React.FC<BulkActionsProps> = React.memo(
installSelectedRule, installSelectedRule,
}) => { }) => {
const disableInstallTranslatedRulesButton = isTableLoading || !numberOfTranslatedRules; const disableInstallTranslatedRulesButton = isTableLoading || !numberOfTranslatedRules;
const showInstallSelectedRulesButton = const showInstallSelectedRulesButton = isTableLoading || numberOfSelectedRules > 0;
disableInstallTranslatedRulesButton && numberOfSelectedRules > 0;
return ( return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}> <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}>
{showInstallSelectedRulesButton ? ( {showInstallSelectedRulesButton ? (
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiButton <EuiButtonEmpty
iconType="plusInCircle"
color={'primary'}
onClick={installSelectedRule} onClick={installSelectedRule}
disabled={isTableLoading} disabled={isTableLoading}
data-test-subj="installSelectedRulesButton" data-test-subj="installSelectedRulesButton"
@ -43,7 +50,7 @@ export const BulkActions: React.FC<BulkActionsProps> = React.memo(
> >
{i18n.INSTALL_SELECTED_RULES(numberOfSelectedRules)} {i18n.INSTALL_SELECTED_RULES(numberOfSelectedRules)}
{isTableLoading && <EuiLoadingSpinner size="s" />} {isTableLoading && <EuiLoadingSpinner size="s" />}
</EuiButton> </EuiButtonEmpty>
</EuiFlexItem> </EuiFlexItem>
) : null} ) : null}
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import type { CriteriaWithPagination } from '@elastic/eui'; import type { CriteriaWithPagination, EuiTableSelectionType } from '@elastic/eui';
import { import {
EuiSkeletonLoading, EuiSkeletonLoading,
EuiSkeletonTitle, EuiSkeletonTitle,
@ -14,6 +14,7 @@ import {
EuiFlexItem, EuiFlexItem,
EuiSpacer, EuiSpacer,
EuiBasicTable, EuiBasicTable,
EuiButton,
} from '@elastic/eui'; } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
@ -30,8 +31,12 @@ import { useGetMigrationPrebuiltRules } from '../../logic/use_get_migration_preb
import * as logicI18n from '../../logic/translations'; import * as logicI18n from '../../logic/translations';
import { BulkActions } from './bulk_actions'; import { BulkActions } from './bulk_actions';
import { SearchField } from './search_field'; import { SearchField } from './search_field';
import { SiemMigrationRuleTranslationResult } from '../../../../../common/siem_migrations/constants';
import * as i18n from './translations';
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT_FIELD = 'translation_result';
const DEFAULT_SORT_DIRECTION = 'desc';
export interface MigrationRulesTableProps { export interface MigrationRulesTableProps {
/** /**
@ -49,6 +54,8 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [sortField, setSortField] = useState<keyof RuleMigration>(DEFAULT_SORT_FIELD);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(DEFAULT_SORT_DIRECTION);
const [searchTerm, setSearchTerm] = useState<string | undefined>(); const [searchTerm, setSearchTerm] = useState<string | undefined>();
const { data: translationStats, isLoading: isStatsLoading } = const { data: translationStats, isLoading: isStatsLoading } =
@ -64,10 +71,33 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
migrationId, migrationId,
page: pageIndex, page: pageIndex,
perPage: pageSize, perPage: pageSize,
sortField,
sortDirection,
searchTerm, searchTerm,
}); });
const [selectedRuleMigrations, setSelectedRuleMigrations] = useState<RuleMigration[]>([]); const [selectedRuleMigrations, setSelectedRuleMigrations] = useState<RuleMigration[]>([]);
const tableSelection: EuiTableSelectionType<RuleMigration> = useMemo(
() => ({
selectable: (item: RuleMigration) => {
return (
!item.elastic_rule?.id &&
item.translation_result === SiemMigrationRuleTranslationResult.FULL
);
},
selectableMessage: (selectable: boolean, item: RuleMigration) => {
if (selectable) {
return '';
}
return item.elastic_rule?.id
? i18n.ALREADY_TRANSLATED_RULE_TOOLTIP
: i18n.NOT_FULLY_TRANSLATED_RULE_TOOLTIP;
},
onSelectionChange: setSelectedRuleMigrations,
selected: selectedRuleMigrations,
}),
[selectedRuleMigrations]
);
const pagination = useMemo(() => { const pagination = useMemo(() => {
return { return {
@ -77,11 +107,25 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
}; };
}, [pageIndex, pageSize, total]); }, [pageIndex, pageSize, total]);
const sorting = useMemo(() => {
return {
sort: {
field: sortField,
direction: sortDirection,
},
};
}, [sortDirection, sortField]);
const onTableChange = useCallback(({ page, sort }: CriteriaWithPagination<RuleMigration>) => { const onTableChange = useCallback(({ page, sort }: CriteriaWithPagination<RuleMigration>) => {
if (page) { if (page) {
setPageIndex(page.index); setPageIndex(page.index);
setPageSize(page.size); setPageSize(page.size);
} }
if (sort) {
const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
}
}, []); }, []);
const handleOnSearch = useCallback((value: string) => { const handleOnSearch = useCallback((value: string) => {
@ -94,10 +138,10 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
const [isTableLoading, setTableLoading] = useState(false); const [isTableLoading, setTableLoading] = useState(false);
const installSingleRule = useCallback( const installSingleRule = useCallback(
async (migrationRule: RuleMigration, enable?: boolean) => { async (migrationRule: RuleMigration, enabled = false) => {
setTableLoading(true); setTableLoading(true);
try { try {
await installMigrationRules([migrationRule.id]); await installMigrationRules({ ids: [migrationRule.id], enabled });
} catch (error) { } catch (error) {
addError(error, { title: logicI18n.INSTALL_MIGRATION_RULES_FAILURE }); addError(error, { title: logicI18n.INSTALL_MIGRATION_RULES_FAILURE });
} finally { } finally {
@ -107,6 +151,24 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
[addError, installMigrationRules] [addError, installMigrationRules]
); );
const installSelectedRule = useCallback(
async (enabled = false) => {
setTableLoading(true);
try {
await installMigrationRules({
ids: selectedRuleMigrations.map((rule) => rule.id),
enabled,
});
} catch (error) {
addError(error, { title: logicI18n.INSTALL_MIGRATION_RULES_FAILURE });
} finally {
setTableLoading(false);
setSelectedRuleMigrations([]);
}
},
[addError, installMigrationRules, selectedRuleMigrations]
);
const installTranslatedRules = useCallback( const installTranslatedRules = useCallback(
async (enable?: boolean) => { async (enable?: boolean) => {
setTableLoading(true); setTableLoading(true);
@ -121,12 +183,45 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
[addError, installTranslatedMigrationRules] [addError, installTranslatedMigrationRules]
); );
const isLoading = isStatsLoading || isPrebuiltRulesLoading || isDataLoading || isTableLoading;
const ruleActionsFactory = useCallback( const ruleActionsFactory = useCallback(
(ruleMigration: RuleMigration, closeRulePreview: () => void) => { (ruleMigration: RuleMigration, closeRulePreview: () => void) => {
// TODO: Add flyout action buttons const canMigrationRuleBeInstalled =
return null; !isLoading &&
!ruleMigration.elastic_rule?.id &&
ruleMigration.translation_result === SiemMigrationRuleTranslationResult.FULL;
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiButton
disabled={!canMigrationRuleBeInstalled}
onClick={() => {
installSingleRule(ruleMigration);
closeRulePreview();
}}
data-test-subj="installMigrationRuleFromFlyoutButton"
>
{i18n.INSTALL_WITHOUT_ENABLING_BUTTON_LABEL}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
disabled={!canMigrationRuleBeInstalled}
onClick={() => {
installSingleRule(ruleMigration, true);
closeRulePreview();
}}
fill
data-test-subj="installAndEnableMigrationRuleFromFlyoutButton"
>
{i18n.INSTALL_AND_ENABLE_BUTTON_LABEL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
}, },
[] [installSingleRule, isLoading]
); );
const { const {
@ -143,8 +238,6 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
installMigrationRule: installSingleRule, installMigrationRule: installSingleRule,
}); });
const isLoading = isStatsLoading || isPrebuiltRulesLoading || isDataLoading || isTableLoading;
return ( return (
<> <>
<EuiSkeletonLoading <EuiSkeletonLoading
@ -168,8 +261,9 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
<BulkActions <BulkActions
isTableLoading={isLoading} isTableLoading={isLoading}
numberOfTranslatedRules={translationStats?.rules.installable ?? 0} numberOfTranslatedRules={translationStats?.rules.installable ?? 0}
numberOfSelectedRules={0} numberOfSelectedRules={selectedRuleMigrations.length}
installTranslatedRule={installTranslatedRules} installTranslatedRule={installTranslatedRules}
installSelectedRule={installSelectedRule}
/> />
</EuiFlexItem> </EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>
@ -178,12 +272,9 @@ export const MigrationRulesTable: React.FC<MigrationRulesTableProps> = React.mem
loading={isTableLoading} loading={isTableLoading}
items={ruleMigrations} items={ruleMigrations}
pagination={pagination} pagination={pagination}
sorting={sorting}
onChange={onTableChange} onChange={onTableChange}
selection={{ selection={tableSelection}
selectable: () => true,
onSelectionChange: setSelectedRuleMigrations,
initialSelected: selectedRuleMigrations,
}}
itemId={'id'} itemId={'id'}
data-test-subj={'rules-translation-table'} data-test-subj={'rules-translation-table'}
columns={rulesColumns} columns={rulesColumns}

View file

@ -80,3 +80,31 @@ export const INSTALL_TRANSLATED_ARIA_LABEL = i18n.translate(
defaultMessage: 'Install all translated rules', defaultMessage: 'Install all translated rules',
} }
); );
export const ALREADY_TRANSLATED_RULE_TOOLTIP = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.alreadyTranslatedTooltip',
{
defaultMessage: 'Already translated migration rule',
}
);
export const NOT_FULLY_TRANSLATED_RULE_TOOLTIP = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.notFullyTranslatedTooltip',
{
defaultMessage: 'Not fully translated migration rule',
}
);
export const INSTALL_WITHOUT_ENABLING_BUTTON_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.installWithoutEnablingButtonLabel',
{
defaultMessage: 'Install without enabling',
}
);
export const INSTALL_AND_ENABLE_BUTTON_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.table.installAndEnableButtonLabel',
{
defaultMessage: 'Install and enable',
}
);

View file

@ -0,0 +1,39 @@
/*
* 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, EuiFlexItem, EuiIcon } from '@elastic/eui';
import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import * as i18n from './translations';
import type { TableColumn } from './constants';
const Author = ({ isPrebuiltRule }: { isPrebuiltRule: boolean }) => {
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
{isPrebuiltRule && (
<EuiFlexItem grow={false}>
<EuiIcon type="logoElastic" size="m" />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
{isPrebuiltRule ? i18n.ELASTIC_AUTHOR_TITLE : i18n.CUSTOM_AUTHOR_TITLE}
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const createAuthorColumn = (): TableColumn => {
return {
field: 'elastic_rule.prebuilt_rule_id',
name: i18n.COLUMN_AUTHOR,
render: (_, rule: RuleMigration) => {
return <Author isPrebuiltRule={!!rule.elastic_rule?.prebuilt_rule_id} />;
},
sortable: true,
width: '10%',
};
};

View file

@ -8,6 +8,7 @@
export * from './constants'; export * from './constants';
export * from './actions'; export * from './actions';
export * from './author';
export * from './name'; export * from './name';
export * from './risk_score'; export * from './risk_score';
export * from './severity'; export * from './severity';

View file

@ -12,12 +12,11 @@ import * as i18n from './translations';
import type { TableColumn } from './constants'; import type { TableColumn } from './constants';
interface NameProps { interface NameProps {
name: string;
rule: RuleMigration; rule: RuleMigration;
openMigrationRuleDetails: (rule: RuleMigration) => void; openMigrationRuleDetails: (rule: RuleMigration) => void;
} }
const Name = ({ name, rule, openMigrationRuleDetails }: NameProps) => { const Name = ({ rule, openMigrationRuleDetails }: NameProps) => {
return ( return (
<EuiLink <EuiLink
onClick={() => { onClick={() => {
@ -25,7 +24,7 @@ const Name = ({ name, rule, openMigrationRuleDetails }: NameProps) => {
}} }}
data-test-subj="ruleName" data-test-subj="ruleName"
> >
{name} {rule.elastic_rule?.title}
</EuiLink> </EuiLink>
); );
}; };
@ -36,10 +35,10 @@ export const createNameColumn = ({
openMigrationRuleDetails: (rule: RuleMigration) => void; openMigrationRuleDetails: (rule: RuleMigration) => void;
}): TableColumn => { }): TableColumn => {
return { return {
field: 'original_rule.title', field: 'elastic_rule.title',
name: i18n.COLUMN_NAME, name: i18n.COLUMN_NAME,
render: (value: RuleMigration['original_rule']['title'], rule: RuleMigration) => ( render: (_, rule: RuleMigration) => (
<Name name={value} rule={rule} openMigrationRuleDetails={openMigrationRuleDetails} /> <Name rule={rule} openMigrationRuleDetails={openMigrationRuleDetails} />
), ),
sortable: true, sortable: true,
truncateText: true, truncateText: true,

View file

@ -22,6 +22,6 @@ export const createRiskScoreColumn = (): TableColumn => {
), ),
sortable: true, sortable: true,
truncateText: true, truncateText: true,
width: '75px', width: '10%',
}; };
}; };

View file

@ -8,9 +8,7 @@
import React from 'react'; import React from 'react';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import { DEFAULT_TRANSLATION_SEVERITY } from '../../../../../common/siem_migrations/constants'; import { DEFAULT_TRANSLATION_SEVERITY } from '../../../../../common/siem_migrations/constants';
import { getNormalizedSeverity } from '../../../../detection_engine/rule_management_ui/components/rules_table/helpers';
import { SeverityBadge } from '../../../../common/components/severity_badge'; import { SeverityBadge } from '../../../../common/components/severity_badge';
import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import type { TableColumn } from './constants'; import type { TableColumn } from './constants';
import * as i18n from './translations'; import * as i18n from './translations';
@ -19,8 +17,7 @@ export const createSeverityColumn = (): TableColumn => {
field: 'elastic_rule.severity', field: 'elastic_rule.severity',
name: i18n.COLUMN_SEVERITY, name: i18n.COLUMN_SEVERITY,
render: (value?: Severity) => <SeverityBadge value={value ?? DEFAULT_TRANSLATION_SEVERITY} />, render: (value?: Severity) => <SeverityBadge value={value ?? DEFAULT_TRANSLATION_SEVERITY} />,
sortable: ({ elastic_rule: elasticRule }: RuleMigration) => sortable: true,
getNormalizedSeverity((elasticRule?.severity as Severity) ?? DEFAULT_TRANSLATION_SEVERITY),
truncateText: true, truncateText: true,
width: '12%', width: '12%',
}; };

View file

@ -18,8 +18,8 @@ export const createStatusColumn = (): TableColumn => {
render: (value: RuleMigration['translation_result'], rule: RuleMigration) => ( render: (value: RuleMigration['translation_result'], rule: RuleMigration) => (
<StatusBadge value={value} installedRuleId={rule.elastic_rule?.id} /> <StatusBadge value={value} installedRuleId={rule.elastic_rule?.id} />
), ),
sortable: false, sortable: true,
truncateText: true, truncateText: true,
width: '12%', width: '15%',
}; };
}; };

View file

@ -14,6 +14,27 @@ export const COLUMN_ACTIONS = i18n.translate(
} }
); );
export const COLUMN_AUTHOR = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.tableColumn.authorLabel',
{
defaultMessage: 'Author',
}
);
export const ELASTIC_AUTHOR_TITLE = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.tableColumn.elasticAuthorTitle',
{
defaultMessage: 'Elastic',
}
);
export const CUSTOM_AUTHOR_TITLE = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.tableColumn.customAuthorTitle',
{
defaultMessage: 'Custom',
}
);
export const ACTIONS_VIEW_LABEL = i18n.translate( export const ACTIONS_VIEW_LABEL = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.tableColumn.actionsViewLabel', 'xpack.securitySolution.siemMigrations.rules.tableColumn.actionsViewLabel',
{ {

View file

@ -1,19 +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 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

@ -8,9 +8,16 @@
import React from 'react'; import React from 'react';
import { euiLightVars } from '@kbn/ui-theme'; import { euiLightVars } from '@kbn/ui-theme';
import { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiIcon, EuiToolTip } from '@elastic/eui';
import { css } from '@emotion/css';
import type { RuleMigrationTranslationResult } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import type { RuleMigrationTranslationResult } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import { HealthTruncateText } from '../../../../common/components/health_truncate_text';
import { convertTranslationResultIntoText } from '../../utils/helpers'; import { convertTranslationResultIntoText } from '../../utils/helpers';
import * as i18n from './translations';
const statusTextWrapperClassName = css`
width: 100%;
display: inline-grid;
`;
const { euiColorVis0, euiColorVis7, euiColorVis9 } = euiLightVars; const { euiColorVis0, euiColorVis7, euiColorVis9 } = euiLightVars;
const statusToColorMap: Record<RuleMigrationTranslationResult, string> = { const statusToColorMap: Record<RuleMigrationTranslationResult, string> = {
@ -28,17 +35,28 @@ interface StatusBadgeProps {
export const StatusBadge: React.FC<StatusBadgeProps> = React.memo( export const StatusBadge: React.FC<StatusBadgeProps> = React.memo(
({ value, installedRuleId, 'data-test-subj': dataTestSubj = 'translation-result' }) => { ({ value, installedRuleId, 'data-test-subj': dataTestSubj = 'translation-result' }) => {
const translationResult = installedRuleId ? 'full' : value ?? 'untranslatable'; const translationResult = installedRuleId ? 'full' : value ?? 'untranslatable';
const displayValue = convertTranslationResultIntoText(translationResult); const displayValue = installedRuleId
? i18n.RULE_STATUS_INSTALLED
: convertTranslationResultIntoText(translationResult);
const color = statusToColorMap[translationResult]; const color = statusToColorMap[translationResult];
return ( return (
<HealthTruncateText <EuiToolTip content={displayValue}>
healthColor={color} {installedRuleId ? (
tooltipContent={displayValue} <EuiFlexGroup gutterSize="xs" alignItems="center">
dataTestSubj={dataTestSubj} <EuiFlexItem grow={false}>
> <EuiIcon type={'check'} color={statusToColorMap.full} />
{displayValue} </EuiFlexItem>
</HealthTruncateText> <EuiFlexItem grow={false}>{displayValue}</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiHealth color={color} data-test-subj={dataTestSubj}>
<div className={statusTextWrapperClassName}>
<span className="eui-textTruncate">{displayValue}</span>
</div>
</EuiHealth>
)}
</EuiToolTip>
); );
} }
); );

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 RULE_STATUS_INSTALLED = i18n.translate(
'xpack.securitySolution.siemMigrations.rules.status.installedLabel',
{
defaultMessage: 'Installed',
}
);

View file

@ -10,6 +10,7 @@ import type { RuleMigration } from '../../../../common/siem_migrations/model/rul
import type { TableColumn } from '../components/rules_table_columns'; import type { TableColumn } from '../components/rules_table_columns';
import { import {
createActionsColumn, createActionsColumn,
createAuthorColumn,
createNameColumn, createNameColumn,
createRiskScoreColumn, createRiskScoreColumn,
createSeverityColumn, createSeverityColumn,
@ -33,6 +34,7 @@ export const useMigrationRulesTableColumns = ({
createStatusColumn(), createStatusColumn(),
createRiskScoreColumn(), createRiskScoreColumn(),
createSeverityColumn(), createSeverityColumn(),
createAuthorColumn(),
createActionsColumn({ createActionsColumn({
disableActions, disableActions,
openMigrationRuleDetails, openMigrationRuleDetails,

View file

@ -18,6 +18,8 @@ export const useGetMigrationRules = (params: {
migrationId: string; migrationId: string;
page?: number; page?: number;
perPage?: number; perPage?: number;
sortField: string;
sortDirection: 'asc' | 'desc';
searchTerm?: string; searchTerm?: string;
}) => { }) => {
const { addError } = useAppToasts(); const { addError } = useAppToasts();

View file

@ -23,8 +23,8 @@ export const useInstallMigrationRules = (migrationId: string) => {
const invalidateGetMigrationTranslationStats = const invalidateGetMigrationTranslationStats =
useInvalidateGetMigrationTranslationStats(migrationId); useInvalidateGetMigrationTranslationStats(migrationId);
return useMutation<InstallMigrationRulesResponse, Error, string[]>( return useMutation<InstallMigrationRulesResponse, Error, { ids: string[]; enabled: boolean }>(
(ids: string[]) => installMigrationRules({ migrationId, ids }), ({ ids, enabled = false }) => installMigrationRules({ migrationId, ids, enabled }),
{ {
mutationKey: INSTALL_MIGRATION_RULES_MUTATION_KEY, mutationKey: INSTALL_MIGRATION_RULES_MUTATION_KEY,
onError: (error) => { onError: (error) => {

View file

@ -39,13 +39,20 @@ export const registerSiemRuleMigrationsGetRoute = (
}, },
withLicense(async (context, req, res): Promise<IKibanaResponse<GetRuleMigrationResponse>> => { withLicense(async (context, req, res): Promise<IKibanaResponse<GetRuleMigrationResponse>> => {
const { migration_id: migrationId } = req.params; const { migration_id: migrationId } = req.params;
const { page, per_page: perPage, search_term: searchTerm } = req.query; const {
page,
per_page: perPage,
sort_field: sortField,
sort_direction: sortDirection,
search_term: searchTerm,
} = req.query;
try { try {
const ctx = await context.resolve(['securitySolution']); const ctx = await context.resolve(['securitySolution']);
const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient();
const options: RuleMigrationGetOptions = { const options: RuleMigrationGetOptions = {
filters: { searchTerm }, filters: { searchTerm },
sort: { sortField, sortDirection },
size: perPage, size: perPage,
from: page && perPage ? page * perPage : 0, from: page && perPage ? page * perPage : 0,
}; };

View file

@ -40,7 +40,7 @@ export const registerSiemRuleMigrationsInstallRoute = (
withLicense( withLicense(
async (context, req, res): Promise<IKibanaResponse<InstallMigrationRulesResponse>> => { async (context, req, res): Promise<IKibanaResponse<InstallMigrationRulesResponse>> => {
const { migration_id: migrationId } = req.params; const { migration_id: migrationId } = req.params;
const ids = req.body; const { ids, enabled = false } = req.body;
try { try {
const ctx = await context.resolve(['core', 'alerting', 'securitySolution']); const ctx = await context.resolve(['core', 'alerting', 'securitySolution']);
@ -52,6 +52,7 @@ export const registerSiemRuleMigrationsInstallRoute = (
await installTranslated({ await installTranslated({
migrationId, migrationId,
ids, ids,
enabled,
securitySolutionContext, securitySolutionContext,
savedObjectsClient, savedObjectsClient,
rulesClient, rulesClient,

View file

@ -50,6 +50,7 @@ export const registerSiemRuleMigrationsInstallTranslatedRoute = (
await installTranslated({ await installTranslated({
migrationId, migrationId,
enabled: false,
securitySolutionContext, securitySolutionContext,
savedObjectsClient, savedObjectsClient,
rulesClient, rulesClient,

View file

@ -27,6 +27,7 @@ import {
const installPrebuiltRules = async ( const installPrebuiltRules = async (
rulesToInstall: StoredRuleMigration[], rulesToInstall: StoredRuleMigration[],
enabled: boolean,
securitySolutionContext: SecuritySolutionApiRequestHandlerContext, securitySolutionContext: SecuritySolutionApiRequestHandlerContext,
rulesClient: RulesClient, rulesClient: RulesClient,
savedObjectsClient: SavedObjectsClientContract, savedObjectsClient: SavedObjectsClientContract,
@ -41,7 +42,7 @@ const installPrebuiltRules = async (
if (item.current) { if (item.current) {
acc.installed.push(item.current); acc.installed.push(item.current);
} else { } else {
acc.installable.push(item.target); acc.installable.push({ ...item.target, enabled });
} }
return acc; return acc;
}, },
@ -85,6 +86,7 @@ const installPrebuiltRules = async (
export const installCustomRules = async ( export const installCustomRules = async (
rulesToInstall: StoredRuleMigration[], rulesToInstall: StoredRuleMigration[],
enabled: boolean,
detectionRulesClient: IDetectionRulesClient, detectionRulesClient: IDetectionRulesClient,
logger: Logger logger: Logger
): Promise<UpdateRuleMigrationInput[]> => { ): Promise<UpdateRuleMigrationInput[]> => {
@ -96,8 +98,11 @@ export const installCustomRules = async (
if (!isMigrationCustomRule(rule.elastic_rule)) { if (!isMigrationCustomRule(rule.elastic_rule)) {
return; return;
} }
const payloadRule = convertMigrationCustomRuleToSecurityRulePayload(rule.elastic_rule); const payloadRule = convertMigrationCustomRuleToSecurityRulePayload(
const createdRule = await detectionRulesClient.createPrebuiltRule({ rule.elastic_rule,
enabled
);
const createdRule = await detectionRulesClient.createCustomRule({
params: payloadRule, params: payloadRule,
}); });
rulesToUpdate.push({ rulesToUpdate.push({
@ -131,6 +136,11 @@ interface InstallTranslatedProps {
*/ */
ids?: string[]; ids?: string[];
/**
* Indicates whether the installed migration rules should be enabled
*/
enabled: boolean;
/** /**
* The security solution context * The security solution context
*/ */
@ -155,6 +165,7 @@ interface InstallTranslatedProps {
export const installTranslated = async ({ export const installTranslated = async ({
migrationId, migrationId,
ids, ids,
enabled,
securitySolutionContext, securitySolutionContext,
rulesClient, rulesClient,
savedObjectsClient, savedObjectsClient,
@ -186,6 +197,7 @@ export const installTranslated = async ({
const updatedPrebuiltRules = await installPrebuiltRules( const updatedPrebuiltRules = await installPrebuiltRules(
prebuiltRulesToInstall, prebuiltRulesToInstall,
enabled,
securitySolutionContext, securitySolutionContext,
rulesClient, rulesClient,
savedObjectsClient, savedObjectsClient,
@ -194,6 +206,7 @@ export const installTranslated = async ({
const updatedCustomRules = await installCustomRules( const updatedCustomRules = await installCustomRules(
customRulesToInstall, customRulesToInstall,
enabled,
detectionRulesClient, detectionRulesClient,
logger logger
); );

View file

@ -15,10 +15,7 @@ import type {
QueryDslQueryContainer, QueryDslQueryContainer,
} from '@elastic/elasticsearch/lib/api/types'; } from '@elastic/elasticsearch/lib/api/types';
import type { StoredRuleMigration } from '../types'; import type { StoredRuleMigration } from '../types';
import { import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants';
SiemMigrationRuleTranslationResult,
SiemMigrationStatus,
} from '../../../../../common/siem_migrations/constants';
import type { import type {
ElasticRule, ElasticRule,
RuleMigration, RuleMigration,
@ -26,6 +23,8 @@ import type {
RuleMigrationTranslationStats, RuleMigrationTranslationStats,
} from '../../../../../common/siem_migrations/model/rule_migration.gen'; } from '../../../../../common/siem_migrations/model/rule_migration.gen';
import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client'; import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client';
import { getSortingOptions, type RuleMigrationSort } from './sort';
import { conditions as searchConditions } from './search';
export type CreateRuleMigrationInput = Omit< export type CreateRuleMigrationInput = Omit<
RuleMigration, RuleMigration,
@ -47,6 +46,7 @@ export interface RuleMigrationFilters {
} }
export interface RuleMigrationGetOptions { export interface RuleMigrationGetOptions {
filters?: RuleMigrationFilters; filters?: RuleMigrationFilters;
sort?: RuleMigrationSort;
from?: number; from?: number;
size?: number; size?: number;
} }
@ -120,13 +120,19 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
/** Retrieves an array of rule documents of a specific migrations */ /** Retrieves an array of rule documents of a specific migrations */
async get( async get(
migrationId: string, migrationId: string,
{ filters = {}, from, size }: RuleMigrationGetOptions = {} { filters = {}, sort = {}, from, size }: RuleMigrationGetOptions = {}
): Promise<{ total: number; data: StoredRuleMigration[] }> { ): Promise<{ total: number; data: StoredRuleMigration[] }> {
const index = await this.getIndexName(); const index = await this.getIndexName();
const query = this.getFilterQuery(migrationId, { ...filters }); const query = this.getFilterQuery(migrationId, { ...filters });
const result = await this.esClient const result = await this.esClient
.search<RuleMigration>({ index, query, sort: '_doc', from, size }) .search<RuleMigration>({
index,
query,
sort: sort.sortField ? getSortingOptions(sort) : undefined,
from,
size,
})
.catch((error) => { .catch((error) => {
this.logger.error(`Error searching rule migrations: ${error.message}`); this.logger.error(`Error searching rule migrations: ${error.message}`);
throw error; throw error;
@ -238,8 +244,8 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
const query = this.getFilterQuery(migrationId); const query = this.getFilterQuery(migrationId);
const aggregations = { const aggregations = {
prebuilt: { filter: conditions.isPrebuilt() }, prebuilt: { filter: searchConditions.isPrebuilt() },
installable: { filter: { bool: { must: conditions.isInstallable() } } }, installable: { filter: { bool: { must: searchConditions.isInstallable() } } },
}; };
const result = await this.esClient const result = await this.esClient
.search({ index, query, aggregations, _source: false }) .search({ index, query, aggregations, _source: false })
@ -351,47 +357,14 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient
filter.push({ terms: { _id: ids } }); filter.push({ terms: { _id: ids } });
} }
if (installable) { if (installable) {
filter.push(...conditions.isInstallable()); filter.push(...searchConditions.isInstallable());
} }
if (prebuilt) { if (prebuilt) {
filter.push(conditions.isPrebuilt()); filter.push(searchConditions.isPrebuilt());
} }
if (searchTerm?.length) { if (searchTerm?.length) {
filter.push(conditions.matchTitle(searchTerm)); filter.push(searchConditions.matchTitle(searchTerm));
} }
return { bool: { filter } }; return { bool: { filter } };
} }
} }
const conditions = {
isFullyTranslated(): QueryDslQueryContainer {
return { term: { translation_result: SiemMigrationRuleTranslationResult.FULL } };
},
isNotInstalled(): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { bool: { must_not: { exists: { field: 'elastic_rule.id' } } } },
},
};
},
isPrebuilt(): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { exists: { field: 'elastic_rule.prebuilt_rule_id' } },
},
};
},
matchTitle(title: string): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { match: { 'elastic_rule.title': title } },
},
};
},
isInstallable(): QueryDslQueryContainer[] {
return [this.isFullyTranslated(), this.isNotInstalled()];
},
};

View file

@ -19,14 +19,14 @@ export const ruleMigrationsFieldMap: FieldMap<SchemaFieldMapKeys<Omit<RuleMigrat
original_rule: { type: 'nested', required: true }, original_rule: { type: 'nested', required: true },
'original_rule.vendor': { type: 'keyword', required: true }, 'original_rule.vendor': { type: 'keyword', required: true },
'original_rule.id': { type: 'keyword', required: true }, 'original_rule.id': { type: 'keyword', required: true },
'original_rule.title': { type: 'text', required: true }, 'original_rule.title': { type: 'text', required: true, fields: { keyword: { type: 'keyword' } } },
'original_rule.description': { type: 'text', required: false }, 'original_rule.description': { type: 'text', required: false },
'original_rule.query': { type: 'text', required: true }, 'original_rule.query': { type: 'text', required: true },
'original_rule.query_language': { type: 'keyword', required: true }, 'original_rule.query_language': { type: 'keyword', required: true },
'original_rule.annotations': { type: 'nested', required: false }, 'original_rule.annotations': { type: 'nested', required: false },
'original_rule.annotations.mitre_attack': { type: 'keyword', array: true, required: false }, 'original_rule.annotations.mitre_attack': { type: 'keyword', array: true, required: false },
elastic_rule: { type: 'nested', required: false }, elastic_rule: { type: 'nested', required: false },
'elastic_rule.title': { type: 'text', required: true }, 'elastic_rule.title': { type: 'text', required: true, fields: { keyword: { type: 'keyword' } } },
'elastic_rule.integration_ids': { type: 'keyword', array: true, required: false }, 'elastic_rule.integration_ids': { type: 'keyword', array: true, required: false },
'elastic_rule.query': { type: 'text', required: true }, 'elastic_rule.query': { type: 'text', required: true },
'elastic_rule.query_language': { type: 'keyword', required: true }, 'elastic_rule.query_language': { type: 'keyword', required: true },

View file

@ -0,0 +1,42 @@
/*
* 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { SiemMigrationRuleTranslationResult } from '../../../../../common/siem_migrations/constants';
export const conditions = {
isFullyTranslated(): QueryDslQueryContainer {
return { term: { translation_result: SiemMigrationRuleTranslationResult.FULL } };
},
isNotInstalled(): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { bool: { must_not: { exists: { field: 'elastic_rule.id' } } } },
},
};
},
isPrebuilt(): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { exists: { field: 'elastic_rule.prebuilt_rule_id' } },
},
};
},
matchTitle(title: string): QueryDslQueryContainer {
return {
nested: {
path: 'elastic_rule',
query: { match: { 'elastic_rule.title': title } },
},
};
},
isInstallable(): QueryDslQueryContainer[] {
return [this.isFullyTranslated(), this.isNotInstalled()];
},
};

View file

@ -0,0 +1,127 @@
/*
* 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
export interface RuleMigrationSort {
sortField?: string;
sortDirection?: estypes.SortOrder;
}
const sortMissingValue = (direction: estypes.SortOrder = 'asc') =>
direction === 'desc' ? '_last' : '_first';
const sortingOptions = {
matchedPrebuiltRule(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] {
return [
{
'elastic_rule.prebuilt_rule_id': {
order: direction,
nested: { path: 'elastic_rule' },
missing: sortMissingValue(direction),
},
},
];
},
severity(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] {
const field = 'elastic_rule.severity';
return [
{
_script: {
order: direction,
type: 'number',
script: {
source: `
if (doc.containsKey('${field}') && !doc['${field}'].empty) {
def value = doc['${field}'].value.toLowerCase();
if (value == 'critical') { return 3 }
if (value == 'high') { return 2 }
if (value == 'medium') { return 1 }
if (value == 'low') { return 0 }
}
return -1;
`,
lang: 'painless',
},
nested: { path: 'elastic_rule' },
},
},
];
},
status(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] {
const field = 'translation_result';
const installedRuleField = 'elastic_rule.id';
return [
{
_script: {
order: direction,
type: 'number',
script: {
source: `
if (doc.containsKey('${field}') && !doc['${field}'].empty) {
def value = doc['${field}'].value.toLowerCase();
if (value == 'full') { return 2 }
if (value == 'partial') { return 1 }
if (value == 'untranslatable') { return 0 }
}
return -1;
`,
lang: 'painless',
},
},
},
{
_script: {
order: direction,
type: 'number',
script: {
source: `
if (doc.containsKey('${installedRuleField}') && !doc['${installedRuleField}'].empty) {
return 0;
}
return -1;
`,
lang: 'painless',
},
nested: { path: 'elastic_rule' },
},
},
];
},
updated(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] {
return [{ updated_at: direction }];
},
name(direction: estypes.SortOrder = 'asc'): estypes.SortCombinations[] {
return [
{ 'elastic_rule.title.keyword': { order: direction, nested: { path: 'elastic_rule' } } },
];
},
};
const DEFAULT_SORTING: estypes.Sort = [
...sortingOptions.status('desc'),
...sortingOptions.matchedPrebuiltRule('desc'),
...sortingOptions.severity(),
...sortingOptions.updated(),
];
const sortingOptionsMap: {
[key: string]: (direction?: estypes.SortOrder) => estypes.SortCombinations[];
} = {
'elastic_rule.title': sortingOptions.name,
'elastic_rule.severity': sortingOptions.severity,
'elastic_rule.prebuilt_rule_id': sortingOptions.matchedPrebuiltRule,
translation_result: sortingOptions.status,
updated_at: sortingOptions.updated,
};
export const getSortingOptions = (sort?: RuleMigrationSort): estypes.Sort => {
if (!sort?.sortField) {
return DEFAULT_SORTING;
}
return sortingOptionsMap[sort.sortField]?.(sort.sortDirection) ?? DEFAULT_SORTING;
};