Rule detail table with bulk actions (#136601)

* first commit

* remove not needed variable

* row action in o11y

* fix view in app for inventory rule when using the new alert table

* mistake

* review I

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
Co-authored-by: Maryam Saeidi <maryam.saeidi@elastic.co>
This commit is contained in:
Julian Gernun 2022-07-25 11:12:48 +02:00 committed by GitHub
parent 6e3f5850ff
commit 12259ce281
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 288 additions and 70 deletions

View file

@ -7,9 +7,70 @@
import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common/parse_technical_fields';
import { ALERT_RULE_PARAMETERS, TIMESTAMP } from '@kbn/rule-data-utils';
import { getInventoryViewInAppUrl } from './alert_link';
import { getInventoryViewInAppUrl, flatAlertRuleParams } from './alert_link';
describe('Inventory Threshold Rule', () => {
describe('flatAlertRuleParams', () => {
it('flat ALERT_RULE_PARAMETERS', () => {
expect(
flatAlertRuleParams(
{
sourceId: 'default',
criteria: [
{
comparator: '>',
timeSize: 1,
metric: 'cpu',
threshold: [5],
customMetric: {
field: '',
aggregation: 'avg',
id: 'alert-custom-metric',
type: 'custom',
},
timeUnit: 'm',
},
],
nodeType: 'host',
},
ALERT_RULE_PARAMETERS
)
).toMatchInlineSnapshot(`
Object {
"kibana.alert.rule.parameters.criteria.comparator": Array [
">",
],
"kibana.alert.rule.parameters.criteria.customMetric.aggregation": Array [
"avg",
],
"kibana.alert.rule.parameters.criteria.customMetric.field": Array [
"",
],
"kibana.alert.rule.parameters.criteria.customMetric.id": Array [
"alert-custom-metric",
],
"kibana.alert.rule.parameters.criteria.customMetric.type": Array [
"custom",
],
"kibana.alert.rule.parameters.criteria.metric": Array [
"cpu",
],
"kibana.alert.rule.parameters.criteria.timeSize": Array [
1,
],
"kibana.alert.rule.parameters.criteria.timeUnit": Array [
"m",
],
"kibana.alert.rule.parameters.nodeType": Array [
"host",
],
"kibana.alert.rule.parameters.sourceId": Array [
"default",
],
}
`);
});
});
describe('getInventoryViewInAppUrl', () => {
it('should work with custom metrics', () => {
const fields = {
@ -36,5 +97,61 @@ describe('Inventory Threshold Rule', () => {
'/app/metrics/link-to/inventory?customMetric=&metric=%28type%3Acpu%29&nodeType=h&timestamp=1640995200000'
);
});
it('should work with custom metrics when ALERT_RULE_PARAMETERS is an object', () => {
const fields = {
'@timestamp': '2022-01-01T00:00:00.000Z',
'kibana.alert.rule.parameters': {
sourceId: 'default',
criteria: [
{
comparator: '>',
timeSize: 1,
metric: 'custom',
threshold: [5],
customMetric: {
field: 'system.cpu.user.pct',
aggregation: 'avg',
id: 'alert-custom-metric',
type: 'custom',
},
timeUnit: 'm',
},
],
nodeType: 'host',
},
_id: 'eaa439aa-a4bb-4e7c-b7f8-fbe532ca7366',
_index: '.internal.alerts-observability.metrics.alerts-default-000001',
} as unknown as ParsedTechnicalFields & Record<string, any>;
const url = getInventoryViewInAppUrl(fields);
expect(url).toEqual(
'/app/metrics/link-to/inventory?customMetric=%28aggregation%3Aavg%2Cfield%3Asystem.cpu.user.pct%2Cid%3Aalert-custom-metric%2Ctype%3Acustom%29&metric=%28aggregation%3Aavg%2Cfield%3Asystem.cpu.user.pct%2Cid%3Aalert-custom-metric%2Ctype%3Acustom%29&nodeType=host&timestamp=1640995200000'
);
});
it('should work with non-custom metrics when ALERT_RULE_PARAMETERS is an object', () => {
const fields = {
'@timestamp': '2022-01-01T00:00:00.000Z',
'kibana.alert.rule.parameters': {
sourceId: 'default',
criteria: [
{
comparator: '>',
timeSize: 1,
metric: 'cpu',
threshold: [5],
timeUnit: 'm',
},
],
nodeType: 'host',
},
_id: 'eaa439aa-a4bb-4e7c-b7f8-fbe532ca7366',
_index: '.internal.alerts-observability.metrics.alerts-default-000001',
} as unknown as ParsedTechnicalFields & Record<string, any>;
const url = getInventoryViewInAppUrl(fields);
expect(url).toEqual(
'/app/metrics/link-to/inventory?customMetric=&metric=%28type%3Acpu%29&nodeType=host&timestamp=1640995200000'
);
});
});
});

View file

@ -10,26 +10,68 @@ import { encode } from 'rison-node';
import { stringify } from 'query-string';
import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common/parse_technical_fields';
export const flatAlertRuleParams = (params: {}, pKey = ''): Record<string, unknown[]> => {
return Object.entries(params).reduce((acc, [key, field]) => {
const objectKey = pKey.length ? `${pKey}.${key}` : key;
if (typeof field === 'object' && field != null) {
if (Array.isArray(field) && field.length > 0) {
return {
...acc,
...flatAlertRuleParams(field[0] as {}, objectKey),
};
} else {
return {
...acc,
...flatAlertRuleParams(field as {}, objectKey),
};
}
}
return {
...acc,
[objectKey]: Array.isArray(field) ? field : [field],
};
}, {});
};
export const getInventoryViewInAppUrl = (
fields: ParsedTechnicalFields & Record<string, any>
): string => {
let inventoryFields = fields;
/* Temporary Solution -> https://github.com/elastic/kibana/issues/137033
* In the alert table from timelines plugin (old table), we are using an API who is flattening all the response
* from elasticsearch to Record<string, string[]>, The new alert table API from TriggersActionUI is not doing that
* anymore, it is trusting and returning the way it has been done from the field API from elasticsearch. I think
* it is better to trust elasticsearch and the mapping of the doc. When o11y will only use the new alert table from
* triggersActionUI then we will stop using this flattening way and we will update the code to work with fields API,
* it will be less magic.
*/
if (fields[ALERT_RULE_PARAMETERS]) {
inventoryFields = {
...fields,
...flatAlertRuleParams(fields[ALERT_RULE_PARAMETERS] as {}, ALERT_RULE_PARAMETERS),
};
}
const nodeTypeField = `${ALERT_RULE_PARAMETERS}.nodeType`;
const nodeType = fields[nodeTypeField];
const nodeType = inventoryFields[nodeTypeField];
let inventoryViewInAppUrl = '/app/metrics/link-to/inventory?';
if (nodeType) {
const linkToParams: Record<string, any> = {
nodeType: fields[nodeTypeField][0],
timestamp: Date.parse(fields[TIMESTAMP]),
nodeType: inventoryFields[nodeTypeField][0],
timestamp: Date.parse(inventoryFields[TIMESTAMP]),
customMetric: '',
};
// We always pick the first criteria metric for the URL
const criteriaMetric = fields[`${ALERT_RULE_PARAMETERS}.criteria.metric`][0];
const criteriaMetric = inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.metric`][0];
if (criteriaMetric === 'custom') {
const criteriaCustomMetricId = fields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.id`][0];
const criteriaCustomMetricId =
inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.id`][0];
const criteriaCustomMetricAggregation =
fields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.aggregation`][0];
inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.aggregation`][0];
const criteriaCustomMetricField =
fields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.field`][0];
inventoryFields[`${ALERT_RULE_PARAMETERS}.criteria.customMetric.field`][0];
const customMetric = encode({
id: criteriaCustomMetricId,

View file

@ -7,10 +7,12 @@
import type { GetRenderCellValue } from '@kbn/triggers-actions-ui-plugin/public';
import { observabilityFeatureId } from '../../common';
import { useBulkAddToCaseActions } from '../hooks/use_alert_bulk_case_actions';
import { TopAlert, useToGetInternalFlyout } from '../pages/alerts';
import { getRenderCellValue } from '../pages/alerts/components/render_cell_value';
import { addDisplayNames } from '../pages/alerts/containers/alerts_table_t_grid/add_display_names';
import { columns as alertO11yColumns } from '../pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid';
import { getRowActions } from '../pages/alerts/containers/alerts_table_t_grid/get_row_actions';
import type { ObservabilityRuleTypeRegistry } from '../rules/create_observability_rule_type_registry';
const getO11yAlertsTableConfiguration = (
@ -22,9 +24,11 @@ const getO11yAlertsTableConfiguration = (
const { header, body, footer } = useToGetInternalFlyout(observabilityRuleTypeRegistry);
return { header, body, footer };
},
useActionsColumn: getRowActions(observabilityRuleTypeRegistry),
getRenderCellValue: (({ setFlyoutAlert }: { setFlyoutAlert: (data: TopAlert) => void }) => {
return getRenderCellValue({ observabilityRuleTypeRegistry, setFlyoutAlert });
}) as unknown as GetRenderCellValue,
useBulkActions: useBulkAddToCaseActions,
});
export { getO11yAlertsTableConfiguration };

View file

@ -63,7 +63,7 @@ import { getRenderCellValue } from '../../components/render_cell_value';
import { observabilityAppId, observabilityFeatureId } from '../../../../../common';
import { useGetUserCasesPermissions } from '../../../../hooks/use_get_user_cases_permissions';
import { usePluginContext } from '../../../../hooks/use_plugin_context';
import { LazyAlertsFlyout } from '../../../..';
import { LazyAlertsFlyout, ObservabilityRuleTypeRegistry } from '../../../..';
import { parseAlert } from '../../components/parse_alert';
import { translations, paths } from '../../../../config';
import { addDisplayNames } from './add_display_names';
@ -82,9 +82,13 @@ interface AlertsTableTGridProps {
itemsPerPage?: number;
}
interface ObservabilityActionsProps extends ActionProps {
export type ObservabilityActionsProps = Pick<
ActionProps,
'data' | 'eventId' | 'ecsData' | 'setEventsDeleted'
> & {
setFlyoutAlert: React.Dispatch<React.SetStateAction<TopAlert | undefined>>;
}
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry;
};
const EventsThContent = styled.div.attrs(({ className = '' }) => ({
className: `siemEventsTable__thContent ${className}`,
@ -142,13 +146,13 @@ const NO_ROW_RENDER: RowRenderer[] = [];
const trailingControlColumns: never[] = [];
function ObservabilityActions({
export function ObservabilityActions({
data,
eventId,
ecsData,
observabilityRuleTypeRegistry,
setFlyoutAlert,
}: ObservabilityActionsProps) {
const { observabilityRuleTypeRegistry } = usePluginContext();
const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {});
const [openActionsPopoverId, setActionsPopover] = useState(null);
const { cases, http } = useKibana<ObservabilityAppServices>().services;
@ -263,42 +267,40 @@ function ObservabilityActions({
return (
<>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem>
<EuiToolTip content={translations.alertsTable.viewInAppTextLabel}>
<EuiButtonIcon
size="s"
href={http.basePath.prepend(alert.link ?? '')}
iconType="eye"
color="text"
aria-label={translations.alertsTable.viewInAppTextLabel}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<EuiPopover
button={
<EuiToolTip content={actionsToolTip}>
<EuiButtonIcon
display="empty"
size="s"
color="text"
iconType="boxesHorizontal"
aria-label={actionsToolTip}
onClick={() => toggleActionsPopover(eventId)}
data-test-subj="alertsTableRowActionMore"
/>
</EuiToolTip>
}
isOpen={openActionsPopoverId === eventId}
closePopover={closeActionsPopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s" items={actionsMenuItems} />
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem>
<EuiToolTip content={translations.alertsTable.viewInAppTextLabel}>
<EuiButtonIcon
size="s"
href={http.basePath.prepend(alert.link ?? '')}
iconType="eye"
color="text"
aria-label={translations.alertsTable.viewInAppTextLabel}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<EuiPopover
button={
<EuiToolTip content={actionsToolTip}>
<EuiButtonIcon
display="empty"
size="s"
color="text"
iconType="boxesHorizontal"
aria-label={actionsToolTip}
onClick={() => toggleActionsPopover(eventId)}
data-test-subj="alertsTableRowActionMore"
/>
</EuiToolTip>
}
isOpen={openActionsPopoverId === eventId}
closePopover={closeActionsPopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s" items={actionsMenuItems} />
</EuiPopover>
</EuiFlexItem>
</>
);
}
@ -377,16 +379,19 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) {
},
rowCellRender: (actionProps: ActionProps) => {
return (
<ObservabilityActions
{...actionProps}
setEventsDeleted={setEventsDeleted}
setFlyoutAlert={setFlyoutAlert}
/>
<EuiFlexGroup gutterSize="none" responsive={false}>
<ObservabilityActions
{...actionProps}
setEventsDeleted={setEventsDeleted}
setFlyoutAlert={setFlyoutAlert}
observabilityRuleTypeRegistry={observabilityRuleTypeRegistry}
/>
</EuiFlexGroup>
);
},
},
];
}, [setEventsDeleted]);
}, [setEventsDeleted, observabilityRuleTypeRegistry]);
const onStateChange = useCallback(
(state: TGridState) => {

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 React from 'react';
import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strategy';
import { ObservabilityRuleTypeRegistry } from '../../../../rules/create_observability_rule_type_registry';
import { ObservabilityActions } from './alerts_table_t_grid';
import type { ObservabilityActionsProps } from './alerts_table_t_grid';
const buildData = (alerts: EcsFieldsResponse): ObservabilityActionsProps['data'] => {
return Object.entries(alerts).reduce<ObservabilityActionsProps['data']>(
(acc, [field, value]) => [...acc, { field, value }],
[]
);
};
const fakeSetEventsDeleted = () => [];
export const getRowActions = (observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry) => {
return () => ({
renderCustomActionsRow: (alert: EcsFieldsResponse, setFlyoutAlert: (data: unknown) => void) => {
return (
<ObservabilityActions
data={buildData(alert)}
eventId={alert._id}
ecsData={{ _id: alert._id, _index: alert._index }}
observabilityRuleTypeRegistry={observabilityRuleTypeRegistry}
setEventsDeleted={fakeSetEventsDeleted}
setFlyoutAlert={setFlyoutAlert}
/>
);
},
width: 120,
});
};

View file

@ -34,6 +34,7 @@ import {
import { ALERTS_FEATURE_ID, RuleExecutionStatusErrorReasons } from '@kbn/alerting-plugin/common';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { RuleDefinitionProps } from '@kbn/triggers-actions-ui-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { DeleteModalConfirmation } from './components/delete_modal_confirmation';
import { CenterJustifiedSpinner } from './components/center_justified_spinner';
import { RuleDetailsPathParams, EVENT_LOG_LIST_TAB, ALERT_LIST_TAB } from './types';
@ -42,15 +43,17 @@ import { usePluginContext } from '../../hooks/use_plugin_context';
import { useFetchRule } from '../../hooks/use_fetch_rule';
import { RULES_BREADCRUMB_TEXT } from '../rules/translations';
import { PageTitle } from './components';
import { useKibana } from '../../utils/kibana_react';
import { getHealthColor } from './config';
import { hasExecuteActionsCapability, hasAllPrivilege } from './config';
import { paths } from '../../config/paths';
import { observabilityFeatureId } from '../../../common';
import { ALERT_STATUS_LICENSE_ERROR, rulesStatusesTranslationsMapping } from './translations';
import { ObservabilityAppServices } from '../../application/types';
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
export function RuleDetailsPage() {
const {
cases,
http,
triggersActionsUi: {
alertsTableConfigurationRegistry,
@ -63,7 +66,7 @@ export function RuleDetailsPage() {
},
application: { capabilities, navigateToUrl },
notifications: { toasts },
} = useKibana().services;
} = useKibana<ObservabilityAppServices>().services;
const { ruleId } = useParams<RuleDetailsPathParams>();
const { ObservabilityPageTemplate, observabilityRuleTypeRegistry } = usePluginContext();
@ -150,7 +153,13 @@ export function RuleDetailsPage() {
? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext
: false);
const userPermissions = useGetUserCasesPermissions();
const alertStateProps = {
cases: {
ui: cases.ui,
permissions: userPermissions,
},
alertsTableConfigurationRegistry,
configurationId: observabilityFeatureId,
id: `case-details-alerts-o11y`,

View file

@ -92,6 +92,17 @@ const AlertsTable: React.FunctionComponent<AlertsTableProps> = (props: AlertsTab
const [visibleColumns, setVisibleColumns] = useState(props.visibleColumns);
// TODO when every solution is using this table, we will be able to simplify it by just passing the alert index
const handleFlyoutAlert = useCallback(
(alert) => {
const idx = alerts.findIndex((a) =>
(a as any)[ALERT_UUID].includes(alert.fields[ALERT_UUID])
);
setFlyoutAlertIndex(idx);
},
[alerts, setFlyoutAlertIndex]
);
const onChangeVisibleColumns = useCallback(
(newColumns: string[]) => {
setVisibleColumns(newColumns);
@ -144,7 +155,8 @@ const AlertsTable: React.FunctionComponent<AlertsTableProps> = (props: AlertsTab
</EuiToolTip>
</EuiFlexItem>
)}
{renderCustomActionsRow && renderCustomActionsRow(alerts[visibleRowIndex])}
{renderCustomActionsRow &&
renderCustomActionsRow(alerts[visibleRowIndex], handleFlyoutAlert)}
</EuiFlexGroup>
);
},
@ -161,6 +173,7 @@ const AlertsTable: React.FunctionComponent<AlertsTableProps> = (props: AlertsTab
}, [
actionsColumnWidth,
alerts,
handleFlyoutAlert,
getBulkActionsLeadingControlColumn,
isBulkActionsColumnActive,
props.leadingControlColumns,
@ -179,17 +192,6 @@ const AlertsTable: React.FunctionComponent<AlertsTableProps> = (props: AlertsTab
const handleFlyoutClose = useCallback(() => setFlyoutAlertIndex(-1), [setFlyoutAlertIndex]);
// TODO when every solution is using this table, we will be able to simplify it by just passing the alert index
const handleFlyoutAlert = useCallback(
(alert) => {
const idx = alerts.findIndex((a) =>
(a as any)[ALERT_UUID].includes(alert.fields[ALERT_UUID])
);
setFlyoutAlertIndex(idx);
},
[alerts, setFlyoutAlertIndex]
);
const basicRenderCellValue = ({
data,
columnId,

View file

@ -460,7 +460,10 @@ export interface AlertsTableConfigurationRegistry {
sort?: SortCombinations[];
getRenderCellValue?: GetRenderCellValue;
useActionsColumn?: () => {
renderCustomActionsRow: (alert?: EcsFieldsResponse) => JSX.Element;
renderCustomActionsRow: (
alert: EcsFieldsResponse,
setFlyoutAlert: (data: unknown) => void
) => JSX.Element;
width?: number;
};
useBulkActions?: UseBulkActionsRegistry;