[ML] Show alerts data on the Anomaly timeline (#167998)

## Summary

With alerts-as-data integration added in
https://github.com/elastic/kibana/pull/166349, we're enabled to
incorporate alerts historical data into views in the ML UI to see how it
correlates with the anomaly results.

This PR add alerts data to the Anomaly Explorer page. If selected
anomaly detection jobs have associated alerting rules, we show a new
"Alerts" panel.
It contains: 

<img width="1675" alt="image"
src="1945d1f1-7f12-4a03-8ebd-e0b36c8fce68">

#### A line chart with alerts count over time using the Lens embeddable

It support sync cursor with the Anomaly swim lane making it easier to
align anomalous buckets with alerts spikes.

<img width="1189" alt="image"
src="343b9bcf-bfa4-479d-bf8f-c1572402aa42">

#### Summary of the alerting rules
Shows an aggregated information for each alerting rule associated with
the current job selection:
  - An indicator if alerting rule is active
  - Total number of alerts 
  - Duration of the latest alerts 
  - Start time for active rules and Recovery time for recovered
  
Rules summary has a descending order based on the following criteria: 

- Number of active alerts in rule 
- Total number of alerts in rule 
- Duration of the most recent alert in rule 

<img width="1032" alt="image"
src="899f37f3-dd8c-4cb6-b7f6-263ed86d20ee">

#### Alert details 

It contains an alerts table provided by `triggersActionsUI` plugin. For
each alert the user can:
- Open alerts details page
- Attach an alert to a new case
- Attach n alert to an existing case 

<img width="1177" alt="image"
src="d3b7768a-bae2-404f-b364-ff7d7493cb9b">


#### Alert context menu 

When an anomaly swim lane cells are selected, and there are alerts
within the chosen time range, a context menu displaying alert details is
shown.

<img width="1202" alt="image"
src="2b684c51-db5a-4f8c-bda9-c3e9aabde0d4">


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
This commit is contained in:
Dima Arnautov 2023-11-10 13:07:04 +01:00 committed by GitHub
parent 7a6d009002
commit 875268d558
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 2168 additions and 79 deletions

View file

@ -23,6 +23,7 @@ export const AlertConsumers = {
SLO: 'slo',
SIEM: 'siem',
UPTIME: 'uptime',
ML: 'ml',
} as const;
export type AlertConsumers = typeof AlertConsumers[keyof typeof AlertConsumers];
export type STATUS_VALUES = 'open' | 'acknowledged' | 'closed' | 'in-progress'; // TODO: remove 'in-progress' after migration to 'acknowledged'

View file

@ -27,7 +27,7 @@ const ALERT_ACTION_GROUP = `${ALERT_NAMESPACE}.action_group` as const;
// kibana.alert.case_ids - array of cases associated with the alert
const ALERT_CASE_IDS = `${ALERT_NAMESPACE}.case_ids` as const;
// kibana.alert.duration.us - alert duration in nanoseconds - updated each execution
// kibana.alert.duration.us - alert duration in microseconds - updated each execution
// that the alert is active
const ALERT_DURATION = `${ALERT_NAMESPACE}.duration.us` as const;

View file

@ -6,6 +6,13 @@
*/
import { i18n } from '@kbn/i18n';
import {
ALERT_DURATION,
ALERT_NAMESPACE,
ALERT_RULE_NAME,
ALERT_START,
ALERT_STATUS,
} from '@kbn/rule-data-utils';
import { JobsHealthTests } from '../types/alerts';
export const ML_ALERT_TYPES = {
@ -75,3 +82,35 @@ export const HEALTH_CHECK_NAMES: Record<JobsHealthTests, { name: string; descrip
),
},
};
const ML_ALERT_NAMESPACE = ALERT_NAMESPACE;
export const ALERT_ANOMALY_TIMESTAMP = `${ML_ALERT_NAMESPACE}.anomaly_timestamp` as const;
export const ALERT_ANOMALY_DETECTION_JOB_ID = `${ML_ALERT_NAMESPACE}.job_id` as const;
export const ALERT_ANOMALY_SCORE = `${ML_ALERT_NAMESPACE}.anomaly_score` as const;
export const ALERT_ANOMALY_IS_INTERIM = `${ML_ALERT_NAMESPACE}.is_interim` as const;
export const ALERT_TOP_RECORDS = `${ML_ALERT_NAMESPACE}.top_records` as const;
export const ALERT_TOP_INFLUENCERS = `${ML_ALERT_NAMESPACE}.top_influencers` as const;
export const alertFieldNameMap = Object.freeze<Record<string, string>>({
[ALERT_RULE_NAME]: i18n.translate('xpack.ml.alertsTable.columns.ruleName', {
defaultMessage: 'Rule name',
}),
[ALERT_STATUS]: i18n.translate('xpack.ml.alertsTable.columns.status', {
defaultMessage: 'Status',
}),
[ALERT_ANOMALY_DETECTION_JOB_ID]: i18n.translate('xpack.ml.alertsTable.columns.jobId', {
defaultMessage: 'Job ID',
}),
[ALERT_ANOMALY_SCORE]: i18n.translate('xpack.ml.alertsTable.columns.anomalyScore', {
defaultMessage: 'Latest anomaly score',
}),
[ALERT_ANOMALY_TIMESTAMP]: i18n.translate('xpack.ml.alertsTable.columns.anomalyTime', {
defaultMessage: 'Latest anomaly time',
}),
[ALERT_DURATION]: i18n.translate('xpack.ml.alertsTable.columns.duration', {
defaultMessage: 'Duration',
}),
[ALERT_START]: i18n.translate('xpack.ml.alertsTable.columns.start', {
defaultMessage: 'Start time',
}),
});

View file

@ -0,0 +1,228 @@
/*
* 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 {
EuiButtonIcon,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPopover,
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public';
import { AttachmentType } from '@kbn/cases-plugin/common';
import { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import {
ALERT_RULE_NAME,
ALERT_RULE_UUID,
ALERT_STATUS,
ALERT_STATUS_ACTIVE,
ALERT_UUID,
} from '@kbn/rule-data-utils';
import { useBulkUntrackAlerts } from '@kbn/triggers-actions-ui-plugin/public';
import { type Alert } from '@kbn/triggers-actions-ui-plugin/public/types';
import { PLUGIN_ID } from '../../../common/constants/app';
import { useMlKibana } from '../../application/contexts/kibana';
export interface AlertActionsProps {
alert: Alert;
ecsData: Ecs;
id?: string;
refresh: () => void;
setFlyoutAlert: React.Dispatch<React.SetStateAction<any | undefined>>;
}
const CASES_ACTIONS_ENABLED = false;
export function AlertActions({
alert,
ecsData,
id: pageId,
refresh,
setFlyoutAlert,
}: AlertActionsProps) {
const alertDoc = Object.entries(alert).reduce((acc, [key, val]) => {
return { ...acc, [key]: val?.[0] };
}, {});
const {
cases,
http: {
basePath: { prepend },
},
} = useMlKibana().services;
const casesPrivileges = cases?.helpers.canUseCases();
const { mutateAsync: untrackAlerts } = useBulkUntrackAlerts();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const ruleId = alert[ALERT_RULE_UUID]?.[0] ?? null;
const alertId = alert[ALERT_UUID]?.[0] ?? '';
const linkToRule = ruleId
? prepend(`/app/management/insightsAndAlerting/triggersActions/rule/${ruleId}`)
: null;
const caseAttachments: CaseAttachmentsWithoutOwner = useMemo(() => {
return ecsData?._id
? [
{
alertId: alertId ?? '',
index: ecsData?._index ?? '',
type: AttachmentType.alert,
rule: {
id: ruleId,
name: alert[ALERT_RULE_NAME]![0],
},
owner: PLUGIN_ID,
},
]
: [];
}, [alert, alertId, ecsData?._id, ecsData?._index, ruleId]);
const isActiveAlert = useMemo(() => alert[ALERT_STATUS]![0] === ALERT_STATUS_ACTIVE, [alert]);
const onSuccess = useCallback(() => {
refresh();
}, [refresh]);
const createCaseFlyout = cases!.hooks.useCasesAddToNewCaseFlyout({ onSuccess });
const selectCaseModal = cases!.hooks.useCasesAddToExistingCaseModal({ onSuccess });
const closeActionsPopover = () => {
setIsPopoverOpen(false);
};
const toggleActionsPopover = () => {
setIsPopoverOpen(!isPopoverOpen);
};
const handleAddToNewCaseClick = () => {
createCaseFlyout.open({ attachments: caseAttachments });
closeActionsPopover();
};
const handleAddToExistingCaseClick = () => {
selectCaseModal.open({ getAttachments: () => caseAttachments });
closeActionsPopover();
};
const handleUntrackAlert = useCallback(async () => {
await untrackAlerts({
indices: [ecsData?._index ?? ''],
alertUuids: [alertId],
});
onSuccess();
}, [untrackAlerts, alertId, ecsData, onSuccess]);
const actionsMenuItems = [
...(CASES_ACTIONS_ENABLED && casesPrivileges?.create && casesPrivileges.read
? [
<EuiContextMenuItem
data-test-subj="add-to-existing-case-action"
key="addToExistingCase"
onClick={handleAddToExistingCaseClick}
size="s"
>
{i18n.translate('xpack.ml.alerts.actions.addToCase', {
defaultMessage: 'Add to existing case',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="add-to-new-case-action"
key="addToNewCase"
onClick={handleAddToNewCaseClick}
size="s"
>
{i18n.translate('xpack.ml.alerts.actions.addToNewCase', {
defaultMessage: 'Add to new case',
})}
</EuiContextMenuItem>,
]
: []),
...(linkToRule
? [
<EuiContextMenuItem
data-test-subj="viewRuleDetails"
key="viewRuleDetails"
href={linkToRule}
>
{i18n.translate('xpack.ml.alertsTable.viewRuleDetailsButtonText', {
defaultMessage: 'View rule details',
})}
</EuiContextMenuItem>,
]
: []),
<EuiContextMenuItem
data-test-subj="viewAlertDetailsFlyout"
key="viewAlertDetailsFlyout"
onClick={() => {
closeActionsPopover();
setFlyoutAlert({ fields: alertDoc });
}}
>
{i18n.translate('xpack.ml.alertsTable.viewAlertDetailsButtonText', {
defaultMessage: 'View alert details',
})}
</EuiContextMenuItem>,
...(isActiveAlert
? [
<EuiContextMenuItem
data-test-subj="untrackAlert"
key="untrackAlert"
onClick={handleUntrackAlert}
>
{i18n.translate('xpack.ml.alerts.actions.untrack', {
defaultMessage: 'Mark as untracked',
})}
</EuiContextMenuItem>,
]
: []),
];
const actionsToolTip =
actionsMenuItems.length <= 0
? i18n.translate('xpack.ml.alertsTable.notEnoughPermissions', {
defaultMessage: 'Additional privileges required',
})
: i18n.translate('xpack.ml.alertsTable.moreActionsTextLabel', {
defaultMessage: 'More actions',
});
return (
<>
<EuiPopover
anchorPosition="downLeft"
button={
<EuiToolTip content={actionsToolTip}>
<EuiButtonIcon
aria-label={actionsToolTip}
color="text"
data-test-subj="alertsTableRowActionMore"
display="empty"
iconType="boxesHorizontal"
onClick={toggleActionsPopover}
size="s"
/>
</EuiToolTip>
}
closePopover={closeActionsPopover}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenuPanel
size="s"
items={actionsMenuItems}
data-test-subj="alertsTableActionsMenu"
/>
</EuiPopover>
</>
);
}

View file

@ -0,0 +1,8 @@
/*
* 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 { registerAlertsTableConfiguration } from './register_alerts_table_configuration';

View file

@ -0,0 +1,149 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { type TriggersAndActionsUIPublicPluginSetup } from '@kbn/triggers-actions-ui-plugin/public';
import type {
AlertsTableConfigurationRegistry,
RenderCustomActionsRowArgs,
} from '@kbn/triggers-actions-ui-plugin/public/types';
import { EuiDataGridColumn } from '@elastic/eui';
import { SortCombinations } from '@elastic/elasticsearch/lib/api/types';
import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
ALERT_DURATION,
ALERT_END,
ALERT_REASON,
ALERT_RULE_NAME,
ALERT_START,
ALERT_STATUS,
} from '@kbn/rule-data-utils';
import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common';
import { getAlertFlyout } from './use_alerts_flyout';
import {
ALERT_ANOMALY_DETECTION_JOB_ID,
ALERT_ANOMALY_SCORE,
ALERT_ANOMALY_TIMESTAMP,
} from '../../../common/constants/alerts';
import { getAlertFormatters, getRenderCellValue } from './render_cell_value';
import { AlertActions } from './alert_actions';
export function registerAlertsTableConfiguration(
triggersActionsUi: TriggersAndActionsUIPublicPluginSetup,
fieldFormats: FieldFormatsRegistry
) {
const columns: EuiDataGridColumn[] = [
{
id: ALERT_STATUS,
displayAsText: i18n.translate('xpack.ml.alertsTable.columns.status', {
defaultMessage: 'Status',
}),
initialWidth: 150,
},
{
id: ALERT_REASON,
displayAsText: i18n.translate('xpack.ml.alertsTable.columns.reason', {
defaultMessage: 'Reason',
}),
initialWidth: 150,
},
{
id: ALERT_RULE_NAME,
displayAsText: i18n.translate('xpack.ml.alertsTable.columns.ruleName', {
defaultMessage: 'Rule name',
}),
initialWidth: 150,
},
{
id: ALERT_ANOMALY_DETECTION_JOB_ID,
displayAsText: i18n.translate('xpack.ml.alertsTable.columns.jobId', {
defaultMessage: 'Job ID',
}),
initialWidth: 150,
},
{
id: ALERT_ANOMALY_SCORE,
displayAsText: i18n.translate('xpack.ml.alertsTable.columns.anomalyScore', {
defaultMessage: 'Latest anomaly score',
}),
initialWidth: 150,
isSortable: true,
schema: 'numeric',
},
{
id: ALERT_START,
displayAsText: i18n.translate('xpack.ml.alertsTable.columns.triggeredAt', {
defaultMessage: 'Triggered at',
}),
initialWidth: 250,
schema: 'datetime',
},
{
id: ALERT_END,
displayAsText: i18n.translate('xpack.ml.alertsTable.columns.recoveredAt', {
defaultMessage: 'Recovered at',
}),
initialWidth: 250,
schema: 'datetime',
},
{
id: ALERT_ANOMALY_TIMESTAMP,
displayAsText: i18n.translate('xpack.ml.alertsTable.columns.anomalyTime', {
defaultMessage: 'Latest anomaly time',
}),
initialWidth: 250,
schema: 'datetime',
},
{
id: ALERT_DURATION,
displayAsText: i18n.translate('xpack.ml.alertsTable.columns.duration', {
defaultMessage: 'Duration',
}),
initialWidth: 150,
schema: 'numeric',
},
];
const sort: SortCombinations[] = [
{
[ALERT_START]: {
order: 'desc' as SortOrder,
},
},
];
const config: AlertsTableConfigurationRegistry = {
id: ML_ALERTS_CONFIG_ID,
columns,
useInternalFlyout: getAlertFlyout(columns, getAlertFormatters(fieldFormats)),
getRenderCellValue: getRenderCellValue(fieldFormats),
sort,
useActionsColumn: () => ({
renderCustomActionsRow: ({
alert,
id,
setFlyoutAlert,
refresh,
}: RenderCustomActionsRowArgs) => {
return (
<AlertActions
alert={alert}
ecsData={{ _id: alert._id, _index: alert._index }}
id={id}
setFlyoutAlert={setFlyoutAlert}
refresh={refresh}
/>
);
},
}),
};
triggersActionsUi.alertsTableConfigurationRegistry.register(config);
}
export const ML_ALERTS_CONFIG_ID = 'mlAlerts';

View file

@ -0,0 +1,115 @@
/*
* 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 { isEmpty } from 'lodash';
import React, { type ReactNode } from 'react';
import { isDefined } from '@kbn/ml-is-defined';
import { ALERT_DURATION, ALERT_END, ALERT_START } from '@kbn/rule-data-utils';
import type { GetRenderCellValue } from '@kbn/triggers-actions-ui-plugin/public';
import { FIELD_FORMAT_IDS, FieldFormatsRegistry } from '@kbn/field-formats-plugin/common';
import { getSeverityColor } from '@kbn/ml-anomaly-utils';
import { EuiHealth } from '@elastic/eui';
import {
alertFieldNameMap,
ALERT_ANOMALY_SCORE,
ALERT_ANOMALY_TIMESTAMP,
} from '../../../common/constants/alerts';
import { getFieldFormatterProvider } from '../../application/contexts/kibana/use_field_formatter';
interface Props {
columnId: string;
data: any;
}
export const getMappedNonEcsValue = ({
data,
fieldName,
}: {
data: any[];
fieldName: string;
}): string[] | undefined => {
const item = data.find((d) => d.field === fieldName);
if (item != null && item.value != null) {
return item.value;
}
return undefined;
};
const getRenderValue = (mappedNonEcsValue: any) => {
// can be updated when working on https://github.com/elastic/kibana/issues/140819
const value = Array.isArray(mappedNonEcsValue) ? mappedNonEcsValue.join() : mappedNonEcsValue;
if (!isEmpty(value)) {
if (typeof value === 'object') {
return JSON.stringify(value);
}
return value;
}
return '—';
};
export const getRenderCellValue = (fieldFormats: FieldFormatsRegistry): GetRenderCellValue => {
const alertValueFormatter = getAlertFormatters(fieldFormats);
return ({ setFlyoutAlert }) =>
(props): ReactNode => {
const { columnId, data } = props as Props;
if (!isDefined(data)) return;
const mappedNonEcsValue = getMappedNonEcsValue({
data,
fieldName: columnId,
});
const value = getRenderValue(mappedNonEcsValue);
return alertValueFormatter(columnId, value);
};
};
export function getAlertFormatters(fieldFormats: FieldFormatsRegistry) {
const getFormatter = getFieldFormatterProvider(fieldFormats);
return (columnId: string, value: any): React.ReactElement => {
switch (columnId) {
case ALERT_START:
case ALERT_END:
case ALERT_ANOMALY_TIMESTAMP:
return <>{getFormatter(FIELD_FORMAT_IDS.DATE)(value)}</>;
case ALERT_DURATION:
return (
<>
{getFormatter(FIELD_FORMAT_IDS.DURATION, {
inputFormat: 'microseconds',
outputFormat: 'humanizePrecise',
})(value)}
</>
);
case ALERT_ANOMALY_SCORE:
return (
<EuiHealth textSize={'xs'} color={getSeverityColor(value)}>
{getFormatter(FIELD_FORMAT_IDS.NUMBER)(value)}
</EuiHealth>
);
default:
return <>{value}</>;
}
};
}
export function getAlertEntryFormatter(fieldFormats: FieldFormatsRegistry) {
const alertValueFormatter = getAlertFormatters(fieldFormats);
return (columnId: string, value: any): { title: string; description: any } => {
return {
title: alertFieldNameMap[columnId],
description: alertValueFormatter(columnId, value),
};
};
}
export type RegisterFormatter = ReturnType<typeof getAlertFormatters>;

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 {
AlertsTableFlyoutBaseProps,
AlertTableFlyoutComponent,
} from '@kbn/triggers-actions-ui-plugin/public';
import { get } from 'lodash';
import React from 'react';
import { type EuiDataGridColumn, EuiDescriptionList, EuiPanel, EuiTitle } from '@elastic/eui';
import { ALERT_RULE_NAME } from '@kbn/rule-data-utils';
import { isDefined } from '@kbn/ml-is-defined';
import { RegisterFormatter } from './render_cell_value';
const FlyoutHeader: AlertTableFlyoutComponent = ({ alert }: AlertsTableFlyoutBaseProps) => {
const name = alert[ALERT_RULE_NAME];
return (
<EuiTitle size="s">
<h3>{name}</h3>
</EuiTitle>
);
};
export const getAlertFlyout =
(columns: EuiDataGridColumn[], formatter: RegisterFormatter) => () => {
const FlyoutBody: AlertTableFlyoutComponent = ({ alert, id }: AlertsTableFlyoutBaseProps) => (
<EuiPanel>
<EuiDescriptionList
listItems={columns.map((column) => {
const value = get(alert, column.id)?.[0];
return {
title: column.displayAsText as string,
description: isDefined(value) ? formatter(column.id, value) : '—',
};
})}
type="column"
columnWidths={[1, 3]} // Same as [25, 75]
/>
</EuiPanel>
);
return {
body: FlyoutBody,
header: FlyoutHeader,
footer: null,
};
};

View file

@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n';
import { lazy } from 'react';
import type { TriggersAndActionsUIPublicPluginSetup } from '@kbn/triggers-actions-ui-plugin/public';
import type { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/public';
import type { MlCoreSetup } from '../plugin';
import { ML_ALERT_TYPES } from '../../common/constants/alerts';
import type { MlAnomalyDetectionAlertParams } from '../../common/types/alerts';
import { ML_APP_ROUTE, PLUGIN_ID } from '../../common/constants/app';
@ -18,6 +19,7 @@ import { registerJobsHealthAlertingRule } from './jobs_health_rule';
export function registerMlAlerts(
triggersActionsUi: TriggersAndActionsUIPublicPluginSetup,
getStartServices: MlCoreSetup['getStartServices'],
alerting?: AlertingSetup
) {
triggersActionsUi.ruleTypeRegistry.register({
@ -137,6 +139,13 @@ export function registerMlAlerts(
if (alerting) {
registerNavigation(alerting);
}
// Async import to prevent a bundle size increase
Promise.all([getStartServices(), import('./anomaly_detection_alerts_table')]).then(
([[_, mlStartDependencies], { registerAlertsTableConfiguration }]) => {
registerAlertsTableConfiguration(triggersActionsUi, mlStartDependencies.fieldFormats);
}
);
}
export function registerNavigation(alerting: AlertingSetup) {

View file

@ -5,5 +5,5 @@
* 2.0.
*/
export { ChartTooltipService } from './chart_tooltip_service';
export { ChartTooltipService, type TooltipData } from './chart_tooltip_service';
export { MlTooltipComponent } from './chart_tooltip';

View file

@ -15,7 +15,7 @@ import {
EuiTitle,
} from '@elastic/eui';
import React, { type FC } from 'react';
import { css } from '@emotion/react';
import { PanelHeaderItems } from './panel_header_items';
import { useCurrentThemeVars } from '../../contexts/kibana';
export interface CollapsiblePanelProps {
@ -67,27 +67,7 @@ export const CollapsiblePanel: FC<CollapsiblePanelProps> = ({
</EuiFlexItem>
{headerItems ? (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize={'l'} alignItems={'center'}>
{headerItems.map((item, i) => {
return (
<EuiFlexItem key={i} grow={false}>
<div
css={
i < headerItems?.length - 1
? css`
border-right: ${euiTheme.euiBorderWidthThin} solid
${euiTheme.euiBorderColor};
padding-right: ${euiTheme.euiPanelPaddingModifiers.paddingLarge};
`
: null
}
>
{item}
</div>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
<PanelHeaderItems headerItems={headerItems} />
</EuiFlexItem>
) : null}
</EuiFlexGroup>

View file

@ -6,3 +6,4 @@
*/
export { CollapsiblePanel } from './collapsible_panel';
export { PanelHeaderItems } from './panel_header_items';

View file

@ -0,0 +1,45 @@
/*
* 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, EuiFlexItem } from '@elastic/eui';
import { css } from '@emotion/react/dist/emotion-react.cjs';
import React, { type FC } from 'react';
import { useCurrentThemeVars } from '../../contexts/kibana';
export interface PanelHeaderItems {
headerItems: React.ReactElement[];
compressed?: boolean;
}
export const PanelHeaderItems: FC<PanelHeaderItems> = ({ headerItems, compressed = false }) => {
const { euiTheme } = useCurrentThemeVars();
return (
<EuiFlexGroup gutterSize={compressed ? 's' : 'l'} alignItems={'center'}>
{headerItems.map((item, i) => {
return (
<EuiFlexItem key={i} grow={false}>
<div
css={
i < headerItems?.length - 1
? css`
border-right: ${euiTheme.euiBorderWidthThin} solid ${euiTheme.euiBorderColor};
padding-right: ${compressed
? euiTheme.euiPanelPaddingModifiers.paddingSmall
: euiTheme.euiPanelPaddingModifiers.paddingLarge};
`
: null
}
>
{item}
</div>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
};

View file

@ -29,8 +29,8 @@ import type { ContentManagementPublicStart } from '@kbn/content-management-plugi
import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public';
import type { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public';
import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common';
import type { MlServicesContext } from '../../app';
interface StartPlugins {
@ -43,7 +43,7 @@ interface StartPlugins {
dataViews: DataViewsPublicPluginStart;
dataVisualizer?: DataVisualizerPluginStart;
embeddable: EmbeddableStart;
fieldFormats: FieldFormatsStart;
fieldFormats: FieldFormatsRegistry;
lens: LensPublicStart;
licenseManagement?: LicenseManagementUIPluginSetup;
maps?: MapsStartApi;

View file

@ -5,7 +5,11 @@
* 2.0.
*/
import { FIELD_FORMAT_IDS, FieldFormatParams } from '@kbn/field-formats-plugin/common';
import {
FIELD_FORMAT_IDS,
FieldFormatParams,
FieldFormatsRegistry,
} from '@kbn/field-formats-plugin/common';
import { useMlKibana } from './kibana_context';
/**
@ -16,16 +20,24 @@ const defaultParam: Record<string, FieldFormatParams> = {
inputFormat: 'milliseconds',
outputFormat: 'humanizePrecise',
},
[FIELD_FORMAT_IDS.NUMBER]: {
pattern: '00.00',
},
};
export const getFieldFormatterProvider =
(fieldFormats: FieldFormatsRegistry) =>
(fieldType: FIELD_FORMAT_IDS, params?: FieldFormatParams) => {
const fieldFormatter = fieldFormats.deserialize({
id: fieldType,
params: params ?? defaultParam[fieldType],
});
return fieldFormatter.convert.bind(fieldFormatter);
};
export function useFieldFormatter(fieldType: FIELD_FORMAT_IDS) {
const {
services: { fieldFormats },
} = useMlKibana();
const fieldFormatter = fieldFormats.deserialize({
id: fieldType,
params: defaultParam[fieldType],
});
return fieldFormatter.convert.bind(fieldFormatter);
return getFieldFormatterProvider(fieldFormats)(fieldType);
}

View file

@ -0,0 +1,118 @@
/*
* 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 {
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
EuiNotificationBadge,
EuiSpacer,
EuiLoadingSpinner,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { ALERT_STATUS_ACTIVE, AlertConsumers, type AlertStatus } from '@kbn/rule-data-utils';
import React, { type FC, useState } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { ML_ALERTS_CONFIG_ID } from '../../../alerting/anomaly_detection_alerts_table/register_alerts_table_configuration';
import { CollapsiblePanel } from '../../components/collapsible_panel';
import { useMlKibana } from '../../contexts/kibana';
import { useAnomalyExplorerContext } from '../anomaly_explorer_context';
import { AlertsSummary } from './alerts_summary';
import { AnomalyDetectionAlertsOverviewChart } from './chart';
import { statusNameMap } from './const';
export const AlertsPanel: FC = () => {
const {
services: { triggersActionsUi },
} = useMlKibana();
const [isOpen, setIsOpen] = useState(true);
const [toggleSelected, setToggleSelected] = useState(`alertsSummary`);
const { anomalyDetectionAlertsStateService } = useAnomalyExplorerContext();
const countByStatus = useObservable(anomalyDetectionAlertsStateService.countByStatus$);
const alertsQuery = useObservable(anomalyDetectionAlertsStateService.alertsQuery$, {});
const isLoading = useObservable(anomalyDetectionAlertsStateService.isLoading$, true);
const alertStateProps = {
alertsTableConfigurationRegistry: triggersActionsUi!.alertsTableConfigurationRegistry,
configurationId: ML_ALERTS_CONFIG_ID,
id: `ml-details-alerts`,
featureIds: [AlertConsumers.ML],
query: alertsQuery,
showExpandToDetails: true,
showAlertStatusWithFlapping: true,
};
const alertsStateTable = triggersActionsUi!.getAlertsStateTable(alertStateProps);
const toggleButtons = [
{
id: `alertsSummary`,
label: i18n.translate('xpack.ml.explorer.alertsPanel.summaryLabel', {
defaultMessage: 'Summary',
}),
},
{
id: `alertsTable`,
label: i18n.translate('xpack.ml.explorer.alertsPanel.detailsLabel', {
defaultMessage: 'Details',
}),
},
];
return (
<>
<CollapsiblePanel
isOpen={isOpen}
onToggle={setIsOpen}
header={
<EuiFlexGroup alignItems={'center'} gutterSize={'xs'}>
<EuiFlexItem grow={false}>
<FormattedMessage id="xpack.ml.explorer.alertsPanel.header" defaultMessage="Alerts" />
</EuiFlexItem>
{isLoading ? (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size={'m'} />
</EuiFlexItem>
) : null}
</EuiFlexGroup>
}
headerItems={Object.entries(countByStatus ?? {}).map(([status, count]) => {
return (
<>
{statusNameMap[status as AlertStatus]}{' '}
<EuiNotificationBadge
size="m"
color={status === ALERT_STATUS_ACTIVE ? 'accent' : 'subdued'}
>
{count}
</EuiNotificationBadge>
</>
);
})}
>
<AnomalyDetectionAlertsOverviewChart />
<EuiSpacer size="m" />
<EuiButtonGroup
legend={i18n.translate('xpack.ml.explorer.alertsPanel.summaryTableToggle', {
defaultMessage: 'Summary / Table view toggle',
})}
options={toggleButtons}
idSelected={toggleSelected}
onChange={setToggleSelected}
/>
<EuiSpacer size="m" />
{toggleSelected === 'alertsTable' ? alertsStateTable : <AlertsSummary />}
</CollapsiblePanel>
<EuiSpacer size="m" />
</>
);
};

View file

@ -0,0 +1,128 @@
/*
* 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 {
EuiBadge,
EuiDescriptionList,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiFlexGrid,
EuiPagination,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALERT_DURATION, ALERT_END } from '@kbn/rule-data-utils';
import React, { useMemo, useState } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { statusNameMap } from './const';
import { getAlertFormatters } from '../../../alerting/anomaly_detection_alerts_table/render_cell_value';
import { useMlKibana } from '../../contexts/kibana';
import { useAnomalyExplorerContext } from '../anomaly_explorer_context';
import { getAlertsSummary } from './get_alerts_summary';
const PAGE_SIZE = 3;
export const AlertsSummary: React.FC = () => {
const {
services: { fieldFormats },
} = useMlKibana();
const { anomalyDetectionAlertsStateService } = useAnomalyExplorerContext();
const alertsData = useObservable(anomalyDetectionAlertsStateService.anomalyDetectionAlerts$, []);
const formatter = getAlertFormatters(fieldFormats);
const [activePage, setActivePage] = useState(0);
const sortedAlertsByRule = useMemo(() => {
return getAlertsSummary(alertsData);
}, [alertsData]);
const pageItems = useMemo(() => {
return sortedAlertsByRule.slice(activePage * PAGE_SIZE, (activePage + 1) * PAGE_SIZE);
}, [activePage, sortedAlertsByRule]);
return (
<>
<EuiFlexGrid columns={3} gutterSize={'m'}>
{pageItems.map(([ruleName, ruleSummary]) => {
return (
<EuiFlexItem key={ruleName} grow={false}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size={'xs'}>
<h5>{ruleName}</h5>
</EuiTitle>
</EuiFlexItem>
{ruleSummary.activeCount > 0 ? (
<EuiFlexItem grow={false}>
<EuiBadge color="accent">{statusNameMap.active}</EuiBadge>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<EuiDescriptionList
compressed
type="column"
listItems={[
{
title: i18n.translate('xpack.ml.explorer.alertsPanel.summary.totalAlerts', {
defaultMessage: 'Total alerts: ',
}),
description: ruleSummary.totalCount,
},
...(ruleSummary.activeCount > 0
? [
{
title: i18n.translate('xpack.ml.explorer.alertsPanel.summary.startedAt', {
defaultMessage: 'Started at: ',
}),
description: formatter(ALERT_END, ruleSummary.startedAt),
},
]
: [
{
title: i18n.translate(
'xpack.ml.explorer.alertsPanel.summary.recoveredAt',
{
defaultMessage: 'Recovered at: ',
}
),
description: formatter(ALERT_END, ruleSummary.recoveredAt),
},
]),
{
title: i18n.translate('xpack.ml.explorer.alertsPanel.summary.lastDuration', {
defaultMessage: 'Last duration: ',
}),
description: formatter(ALERT_DURATION, ruleSummary.lastDuration),
},
]}
/>
</EuiFlexItem>
);
})}
</EuiFlexGrid>
{sortedAlertsByRule.length > PAGE_SIZE ? (
<EuiFlexGroup gutterSize="s" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiPagination
aria-label={i18n.translate(
'xpack.ml.explorer.alertsPanel.summary.paginationAreaLabel',
{
defaultMessage: 'Pagination for alerting rules summary',
}
)}
pageCount={Math.ceil(sortedAlertsByRule.length / PAGE_SIZE)}
activePage={activePage}
onPageClick={setActivePage}
/>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
</>
);
};

View file

@ -0,0 +1,226 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { BehaviorSubject, combineLatest, EMPTY, type Observable, Subscription } from 'rxjs';
import { catchError, debounceTime, map, startWith, switchMap, tap } from 'rxjs/operators';
import {
DataPublicPluginStart,
isRunningResponse,
TimefilterContract,
} from '@kbn/data-plugin/public';
import type {
RuleRegistrySearchRequest,
RuleRegistrySearchResponse,
} from '@kbn/rule-registry-plugin/common';
import {
ALERT_DURATION,
ALERT_END,
ALERT_RULE_NAME,
ALERT_RULE_TYPE_ID,
ALERT_START,
ALERT_STATUS,
ALERT_UUID,
AlertConsumers,
} from '@kbn/rule-data-utils';
import { isDefined } from '@kbn/ml-is-defined';
import { getSeverityColor } from '@kbn/ml-anomaly-utils';
import {
ALERT_ANOMALY_DETECTION_JOB_ID,
ALERT_ANOMALY_SCORE,
ALERT_ANOMALY_TIMESTAMP,
ML_ALERT_TYPES,
} from '../../../../common/constants/alerts';
import { StateService } from '../../services/state_service';
import { AnomalyTimelineStateService } from '../anomaly_timeline_state_service';
export interface AnomalyDetectionAlert {
id: string;
[ALERT_ANOMALY_SCORE]: number;
[ALERT_ANOMALY_DETECTION_JOB_ID]: string;
[ALERT_ANOMALY_TIMESTAMP]: number;
[ALERT_START]: number;
[ALERT_END]: number | undefined;
[ALERT_RULE_NAME]: string;
[ALERT_STATUS]: string;
[ALERT_DURATION]: number;
// Additional fields for the UI
color: string;
}
export type AlertsQuery = Exclude<RuleRegistrySearchRequest['query'], undefined>;
export class AnomalyDetectionAlertsStateService extends StateService {
/**
* Subject that holds the anomaly detection alerts from the alert-as-data index.
* @private
*/
private readonly _aadAlerts$ = new BehaviorSubject<AnomalyDetectionAlert[]>([]);
private readonly _isLoading$ = new BehaviorSubject<boolean>(true);
constructor(
private readonly _anomalyTimelineStateServices: AnomalyTimelineStateService,
private readonly data: DataPublicPluginStart,
private readonly timefilter: TimefilterContract
) {
super();
this.selectedAlerts$ = combineLatest([
this._aadAlerts$,
this._anomalyTimelineStateServices.getSelectedCells$().pipe(map((cells) => cells?.times)),
]).pipe(
map(([alerts, selectedTimes]) => {
if (!Array.isArray(selectedTimes)) return null;
return alerts.filter(
(alert) =>
alert[ALERT_ANOMALY_TIMESTAMP] >= selectedTimes[0] * 1000 &&
alert[ALERT_ANOMALY_TIMESTAMP] <= selectedTimes[1] * 1000
);
})
);
const timeUpdates$ = this.timefilter.getTimeUpdate$().pipe(
startWith(null),
map(() => this.timefilter.getTime())
);
this.alertsQuery$ = combineLatest([
this._anomalyTimelineStateServices.getSwimLaneJobs$(),
timeUpdates$,
]).pipe(
// Create a result query from the input
map(([selectedJobs, timeRange]) => {
return {
bool: {
filter: [
{
term: {
[ALERT_RULE_TYPE_ID]: ML_ALERT_TYPES.ANOMALY_DETECTION,
},
},
{
range: {
[ALERT_ANOMALY_TIMESTAMP]: {
gte: timeRange.from,
lte: timeRange.to,
},
},
},
{
terms: {
[ALERT_ANOMALY_DETECTION_JOB_ID]: selectedJobs.map((job) => job.id),
},
},
],
},
} as AlertsQuery;
})
);
this._init();
}
/**
* Count the number of alerts by status.
* @param alerts
*/
public countAlertsByStatus(alerts: AnomalyDetectionAlert[]): Record<string, number> {
return alerts.reduce(
(acc, alert) => {
if (!isDefined(acc[alert[ALERT_STATUS]])) {
acc[alert[ALERT_STATUS]] = 0;
} else {
acc[alert[ALERT_STATUS]]++;
}
return acc;
},
{ active: 0, recovered: 0 } as Record<string, number>
);
}
public readonly anomalyDetectionAlerts$: Observable<AnomalyDetectionAlert[]> =
this._aadAlerts$.asObservable();
/**
* Query for fetching alerts data based on the job selection and time range.
*/
public readonly alertsQuery$: Observable<AlertsQuery>;
public readonly isLoading$: Observable<boolean> = this._isLoading$.asObservable();
/**
* Observable for the alerts within the swim lane selection.
*/
public readonly selectedAlerts$: Observable<AnomalyDetectionAlert[] | null>;
public readonly countByStatus$: Observable<Record<string, number>> = this._aadAlerts$.pipe(
map((alerts) => {
return this.countAlertsByStatus(alerts);
})
);
protected _initSubscriptions(): Subscription {
const subscription = new Subscription();
subscription.add(
this.alertsQuery$
.pipe(
tap(() => {
this._isLoading$.next(true);
}),
debounceTime(300),
switchMap((query) => {
return this.data.search
.search<RuleRegistrySearchRequest, RuleRegistrySearchResponse>(
{
featureIds: [AlertConsumers.ML],
query,
},
{ strategy: 'privateRuleRegistryAlertsSearchStrategy' }
)
.pipe(
catchError((error) => {
// Catch error to prevent the observable from completing
return EMPTY;
})
);
})
)
.subscribe((response) => {
if (!isRunningResponse(response)) {
this._aadAlerts$.next(
response.rawResponse.hits.hits
.map(({ fields }) => {
if (!isDefined(fields)) return;
const anomalyScore = Number(fields[ALERT_ANOMALY_SCORE][0]);
return {
id: fields[ALERT_UUID][0],
[ALERT_RULE_NAME]: fields[ALERT_RULE_NAME][0],
[ALERT_ANOMALY_SCORE]: anomalyScore,
[ALERT_ANOMALY_DETECTION_JOB_ID]: fields[ALERT_ANOMALY_DETECTION_JOB_ID][0],
[ALERT_ANOMALY_TIMESTAMP]: new Date(
fields[ALERT_ANOMALY_TIMESTAMP][0]
).getTime(),
[ALERT_START]: fields[ALERT_START][0],
// Can be undefined if the alert is still active
[ALERT_END]: fields[ALERT_END]?.[0],
[ALERT_STATUS]: fields[ALERT_STATUS][0],
[ALERT_DURATION]: fields[ALERT_DURATION][0],
color: getSeverityColor(anomalyScore),
};
})
.filter(isDefined)
);
this._isLoading$.next(false);
}
})
);
return subscription;
}
}

View file

@ -0,0 +1,189 @@
/*
* 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, { type FC, useMemo } from 'react';
import { useTimeRangeUpdates } from '@kbn/ml-date-picker';
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import useObservable from 'react-use/lib/useObservable';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { Y_AXIS_LABEL_WIDTH } from '../swimlane_annotation_container';
import { useAnomalyExplorerContext } from '../anomaly_explorer_context';
import { useMlKibana } from '../../contexts/kibana';
export interface AnomalyDetectionAlertsOverviewChart {
seriesType?: 'bar_stacked' | 'line';
}
export const AnomalyDetectionAlertsOverviewChart: FC<AnomalyDetectionAlertsOverviewChart> = ({
seriesType = 'line',
}) => {
const {
services: {
lens: { EmbeddableComponent },
},
} = useMlKibana();
const { anomalyTimelineStateService } = useAnomalyExplorerContext();
const timeRange = useTimeRangeUpdates();
const interval = useObservable(
anomalyTimelineStateService.getSwimLaneBucketInterval$(),
anomalyTimelineStateService.getSwimLaneBucketInterval()
);
const attributes = useMemo<TypedLensByValueInput['attributes']>(() => {
return {
title: '',
visualizationType: 'lnsXY',
references: [],
type: 'lens',
state: {
internalReferences: [
{
type: 'index-pattern',
id: 'ml-alerts-data-view',
name: 'indexpattern-datasource-layer-layer1',
},
],
adHocDataViews: {
'ml-alerts-data-view': {
id: 'ml-alerts-data-view',
title: '.alerts-ml.anomaly-detection.alerts-default',
timeFieldName: '@timestamp',
},
},
visualization: {
hideEndzones: true,
legend: {
isVisible: false,
},
valueLabels: 'hide',
fittingFunction: 'None',
axisTitlesVisibilitySettings: {
x: false,
yLeft: false,
yRight: false,
},
tickLabelsVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
labelsOrientation: {
x: 0,
yLeft: 0,
yRight: 0,
},
gridlinesVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
preferredSeriesType: seriesType,
layers: [
{
layerId: 'layer1',
accessors: ['7327df72-9def-4642-a72d-dc2b0790d5f9'],
position: 'top',
seriesType,
showGridlines: false,
layerType: 'data',
xAccessor: '953f9efc-fbf6-44e0-a450-c645d2b5ec22',
},
],
},
query: {
query: '',
language: 'kuery',
},
filters: [],
datasourceStates: {
formBased: {
layers: {
layer1: {
columns: {
'953f9efc-fbf6-44e0-a450-c645d2b5ec22': {
label: '@timestamp',
dataType: 'date',
operationType: 'date_histogram',
sourceField: '@timestamp',
isBucketed: true,
scale: 'interval',
params: {
interval: interval?.expression,
includeEmptyRows: true,
dropPartials: false,
},
},
'7327df72-9def-4642-a72d-dc2b0790d5f9': {
label: i18n.translate('xpack.ml.explorer.alerts.totalAlerts', {
defaultMessage: 'Total alerts',
}),
dataType: 'number',
operationType: 'count',
isBucketed: false,
scale: 'ratio',
sourceField: '___records___',
params: {
emptyAsNull: false,
format: {
id: 'number',
params: {
decimals: 0,
compact: true,
},
},
},
},
},
columnOrder: [
'953f9efc-fbf6-44e0-a450-c645d2b5ec22',
'7327df72-9def-4642-a72d-dc2b0790d5f9',
],
incompleteColumns: {},
sampling: 1,
},
},
},
indexpattern: {
layers: {},
},
textBased: {
layers: {},
},
},
},
} as TypedLensByValueInput['attributes'];
}, [interval?.expression, seriesType]);
if (!interval) return null;
return (
<div
css={css`
padding-left: ${Y_AXIS_LABEL_WIDTH - 45}px;
height: 120px;
width: 100%;
`}
>
<EmbeddableComponent
id="mlExplorerAlertsPreview"
style={{ height: 120 }}
timeRange={timeRange}
attributes={attributes}
renderMode={'view'}
executionContext={{
type: 'ml_overall_alert_preview_chart',
name: 'Anomaly detection alert preview chart',
}}
disableTriggers
/>
</div>
);
};

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 { i18n } from '@kbn/i18n';
import {
ALERT_STATUS_ACTIVE,
ALERT_STATUS_RECOVERED,
ALERT_STATUS_UNTRACKED,
} from '@kbn/rule-data-utils';
export const statusNameMap = {
[ALERT_STATUS_ACTIVE]: i18n.translate('xpack.ml.explorer.alertsPanel.statusNameMap.active', {
defaultMessage: 'Active',
}),
[ALERT_STATUS_RECOVERED]: i18n.translate(
'xpack.ml.explorer.alertsPanel.statusNameMap.recovered',
{
defaultMessage: 'Recovered',
}
),
[ALERT_STATUS_UNTRACKED]: i18n.translate(
'xpack.ml.explorer.alertsPanel.statusNameMap.untracked',
{
defaultMessage: 'Untracked',
}
),
} as const;

View file

@ -0,0 +1,128 @@
/*
* 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 {
ALERT_RULE_NAME,
ALERT_STATUS,
ALERT_START,
ALERT_END,
ALERT_DURATION,
} from '@kbn/rule-data-utils';
import { AnomalyDetectionAlert } from './anomaly_detection_alerts_state_service';
import { getAlertsSummary } from './get_alerts_summary';
describe('getAlertsSummary', () => {
test('should return an empty array when given an empty array', () => {
const alertsData: AnomalyDetectionAlert[] = [];
const result = getAlertsSummary(alertsData);
expect(result).toEqual([]);
});
test('should group alerts by rule name and return a sorted array of rule summaries', () => {
const timestamp01 = new Date('2022-01-01T00:00:00.000Z').getTime();
const timestamp02 = new Date('2022-01-01T01:00:00.000Z').getTime();
const timestamp03 = new Date('2022-01-01T02:00:00.000Z').getTime();
const timestamp04 = new Date('2022-01-01T04:00:00.000Z').getTime();
const alertsData: AnomalyDetectionAlert[] = [
{
[ALERT_RULE_NAME]: 'rule-1',
[ALERT_STATUS]: 'active',
[ALERT_START]: timestamp01,
[ALERT_END]: timestamp02,
[ALERT_DURATION]: 3600000,
},
{
[ALERT_RULE_NAME]: 'rule-1',
[ALERT_STATUS]: 'recovered',
[ALERT_START]: timestamp02,
[ALERT_END]: timestamp03,
[ALERT_DURATION]: 3600000,
},
{
[ALERT_RULE_NAME]: 'rule-2',
[ALERT_STATUS]: 'active',
[ALERT_START]: timestamp01,
[ALERT_END]: timestamp02,
[ALERT_DURATION]: 3600000,
},
{
[ALERT_RULE_NAME]: 'rule-2',
[ALERT_STATUS]: 'active',
[ALERT_START]: timestamp01,
[ALERT_END]: timestamp02,
[ALERT_DURATION]: 3600000,
},
{
[ALERT_RULE_NAME]: 'rule-2',
[ALERT_STATUS]: 'recovered',
[ALERT_START]: timestamp02,
[ALERT_END]: timestamp04,
[ALERT_DURATION]: 3600000,
},
{
[ALERT_RULE_NAME]: 'rule-3',
[ALERT_STATUS]: 'recovered',
[ALERT_START]: timestamp02,
[ALERT_END]: timestamp04,
[ALERT_DURATION]: 3600000,
},
{
[ALERT_RULE_NAME]: 'rule-4',
[ALERT_STATUS]: 'recovered',
[ALERT_START]: timestamp02,
[ALERT_END]: timestamp04,
[ALERT_DURATION]: 6400000,
},
] as AnomalyDetectionAlert[];
const result = getAlertsSummary(alertsData);
expect(result).toEqual([
[
'rule-2',
{
totalCount: 3,
activeCount: 2,
recoveredAt: timestamp04,
startedAt: timestamp02,
lastDuration: 3600000,
},
],
[
'rule-1',
{
totalCount: 2,
activeCount: 1,
recoveredAt: timestamp03,
startedAt: timestamp02,
lastDuration: 3600000,
},
],
[
'rule-4',
{
totalCount: 1,
activeCount: 0,
recoveredAt: timestamp04,
startedAt: timestamp02,
lastDuration: 6400000,
},
],
[
'rule-3',
{
totalCount: 1,
activeCount: 0,
recoveredAt: timestamp04,
startedAt: timestamp02,
lastDuration: 3600000,
},
],
]);
});
});

View file

@ -0,0 +1,60 @@
/*
* 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 {
ALERT_DURATION,
ALERT_RULE_NAME,
ALERT_STATUS,
ALERT_END,
ALERT_START,
ALERT_STATUS_ACTIVE,
} from '@kbn/rule-data-utils';
import { groupBy } from 'lodash';
import { AnomalyDetectionAlert } from './anomaly_detection_alerts_state_service';
export type RulesSummary = Array<[string, RuleSummary]>;
export interface RuleSummary {
activeCount: number;
totalCount: number;
lastDuration: number;
startedAt: number;
recoveredAt: number | undefined;
}
export function getAlertsSummary(alertsData: AnomalyDetectionAlert[]): RulesSummary {
return Object.entries(groupBy(alertsData, ALERT_RULE_NAME) ?? [])
.map<[string, RuleSummary]>(([ruleName, alerts]) => {
// Find the latest alert for each rule
const latestAlert: AnomalyDetectionAlert = alerts.reduce((latest, alert) => {
return alert[ALERT_START] > latest[ALERT_START] ? alert : latest;
});
return [
ruleName,
{
totalCount: alerts.length,
activeCount: alerts.filter((alert) => alert[ALERT_STATUS] === ALERT_STATUS_ACTIVE).length,
recoveredAt: latestAlert[ALERT_END],
startedAt: latestAlert[ALERT_START],
lastDuration: latestAlert[ALERT_DURATION],
},
];
})
.sort(([, alertsA], [, alertsB]) => {
// 1. Prioritize rules with the highest number of active alerts
if (alertsA.activeCount > alertsB.activeCount) return -1;
if (alertsA.activeCount < alertsB.activeCount) return 1;
// 2. Prioritize rules with the highest number of alerts in general
if (alertsA.totalCount > alertsB.totalCount) return -1;
if (alertsA.totalCount < alertsB.totalCount) return 1;
// 3. At last prioritize rules with the longest duration
if (alertsA.lastDuration > alertsB.lastDuration) return -1;
if (alertsA.lastDuration < alertsB.lastDuration) return 1;
return 0;
});
}

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.
*/
export { AnomalyDetectionAlertsOverviewChart } from './chart';
export { AlertsPanel } from './alerts_panel';
export { SwimLaneWrapper } from './swim_lane_wrapper';
export { AnomalyDetectionAlertsStateService } from './anomaly_detection_alerts_state_service';

View file

@ -0,0 +1,281 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButtonIcon,
EuiDescriptionList,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiNotificationBadge,
EuiPopover,
EuiPopoverTitle,
EuiText,
} from '@elastic/eui';
import { css } from '@emotion/react';
import {
ALERT_DURATION,
ALERT_RULE_NAME,
ALERT_START,
ALERT_STATUS_ACTIVE,
type AlertStatus,
} from '@kbn/rule-data-utils';
import { pick } from 'lodash';
import React, { type FC, useCallback, useMemo, useRef } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { i18n } from '@kbn/i18n';
import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/basic_table';
import { PanelHeaderItems } from '../../components/collapsible_panel';
import { AnomalyDetectionAlert } from './anomaly_detection_alerts_state_service';
import {
ALERT_ANOMALY_DETECTION_JOB_ID,
ALERT_ANOMALY_TIMESTAMP,
alertFieldNameMap,
} from '../../../../common/constants/alerts';
import {
getAlertEntryFormatter,
getAlertFormatters,
} from '../../../alerting/anomaly_detection_alerts_table/render_cell_value';
import { useMlKibana } from '../../contexts/kibana';
import { useAnomalyExplorerContext } from '../anomaly_explorer_context';
import type { AppStateSelectedCells, SwimlaneData } from '../explorer_utils';
import { Y_AXIS_LABEL_WIDTH } from '../swimlane_annotation_container';
import { CELL_HEIGHT } from '../swimlane_container';
import { statusNameMap } from './const';
export interface SwimLaneWrapperProps {
selection?: AppStateSelectedCells | null;
swimlaneContainerWidth?: number;
swimLaneData: SwimlaneData;
}
/**
* Wrapper component for the swim lane
* that handles the popover for the selected cells.
*/
export const SwimLaneWrapper: FC<SwimLaneWrapperProps> = ({
children,
selection,
swimlaneContainerWidth,
swimLaneData,
}) => {
const {
services: { fieldFormats },
} = useMlKibana();
const containerRef = useRef<HTMLDivElement>(null);
const { anomalyDetectionAlertsStateService, anomalyTimelineStateService } =
useAnomalyExplorerContext();
const selectedAlerts = useObservable(anomalyDetectionAlertsStateService.selectedAlerts$, []);
const leftOffset = useMemo<number>(() => {
if (!selection || !swimLaneData) return 0;
const selectedCellIndex = swimLaneData.points.findIndex((v) => v.time === selection.times[0]);
const cellWidth = swimlaneContainerWidth! / swimLaneData.points.length;
const cellOffset = (selectedCellIndex + 1) * cellWidth;
return Y_AXIS_LABEL_WIDTH + cellOffset;
}, [selection, swimlaneContainerWidth, swimLaneData]);
const popoverOpen = !!selection && !!selectedAlerts?.length;
const alertFormatter = useMemo(() => getAlertEntryFormatter(fieldFormats), [fieldFormats]);
const viewType = 'table';
const closePopover = useCallback(() => {
anomalyTimelineStateService.setSelectedCells();
}, [anomalyTimelineStateService]);
return (
<div
ref={containerRef}
data-test-subj="mlSwimLaneWrapper"
css={css`
position: relative;
`}
>
<div
data-test-subj="swimLanePopoverTriggerWrapper"
style={{ left: `${leftOffset}px` }}
css={css`
position: absolute;
top: -${CELL_HEIGHT / 2}px;
height: 0;
`}
>
<EuiPopover
button={
<button
data-test-subj="mlSwimLanePopoverTrigger"
css={css`
position: absolute;
top: 0;
visibility: hidden;
`}
/>
}
isOpen={popoverOpen}
anchorPosition="upCenter"
hasArrow
repositionOnScroll
closePopover={closePopover}
panelPaddingSize="s"
>
<EuiPopoverTitle paddingSize={'xs'}>
<EuiFlexGroup gutterSize={'none'} justifyContent={'spaceBetween'} alignItems={'center'}>
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.ml.explorer.alertsPanel.header"
defaultMessage="Alerts"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize={'none'} alignItems={'center'}>
<EuiFlexItem>
<PanelHeaderItems
compressed
headerItems={Object.entries(
anomalyDetectionAlertsStateService.countAlertsByStatus(
selectedAlerts ?? []
) ?? {}
).map(([status, count]) => {
return (
<EuiText size={'xs'}>
{statusNameMap[status as AlertStatus]}{' '}
<EuiNotificationBadge
size="s"
color={status === ALERT_STATUS_ACTIVE ? 'accent' : 'subdued'}
>
{count}
</EuiNotificationBadge>
</EuiText>
);
})}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
size="s"
color={'text'}
iconType={'cross'}
onClick={closePopover}
aria-label={i18n.translate(
'xpack.ml.explorer.cellSelectionPopover.closeButtonAriaLabel',
{
defaultMessage: 'Close popover',
}
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
{viewType === 'table' && !!selectedAlerts?.length ? (
<MiniAlertTable data={selectedAlerts} />
) : (
(selectedAlerts ?? []).map((alert) => {
const fields = Object.entries(
pick(alert, [
ALERT_RULE_NAME,
ALERT_ANOMALY_DETECTION_JOB_ID,
ALERT_ANOMALY_TIMESTAMP,
ALERT_START,
ALERT_DURATION,
])
).map(([prop, value]) => {
return alertFormatter(prop, value);
});
return (
<EuiFlexGroup>
{fields.map(({ title, description }) => {
return (
<EuiFlexItem>
<EuiDescriptionList compressed listItems={[{ title, description }]} />
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
})
)}
</EuiPopover>
</div>
{children}
</div>
);
};
export interface MiniAlertTableProps {
data: AnomalyDetectionAlert[];
}
const ALERT_PER_PAGE = 3;
export const MiniAlertTable: FC<MiniAlertTableProps> = ({ data }) => {
const {
services: { fieldFormats },
} = useMlKibana();
const alertValueFormatter = useMemo(() => getAlertFormatters(fieldFormats), [fieldFormats]);
const columns = useMemo<Array<EuiBasicTableColumn<AnomalyDetectionAlert>>>(() => {
return [
{
field: ALERT_RULE_NAME,
width: `150px`,
name: alertFieldNameMap[ALERT_RULE_NAME],
sortable: true,
},
{
field: ALERT_START,
width: `200px`,
name: alertFieldNameMap[ALERT_START],
sortable: true,
render: (value: number) => alertValueFormatter(ALERT_START, value),
},
{
field: ALERT_DURATION,
width: `110px`,
name: alertFieldNameMap[ALERT_DURATION],
sortable: true,
render: (value: number) => alertValueFormatter(ALERT_DURATION, value),
},
];
}, [alertValueFormatter]);
return (
<EuiInMemoryTable
css={{ width: '510px' }}
compressed
columns={columns}
items={data}
sorting={{
sort: {
field: ALERT_START,
direction: 'asc',
},
}}
pagination={
data.length > ALERT_PER_PAGE
? {
compressed: true,
initialPageSize: ALERT_PER_PAGE,
pageSizeOptions: [3, 5, 10],
}
: false
}
/>
);
};

View file

@ -0,0 +1,184 @@
/*
* 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, { type FC, type PropsWithChildren, useEffect } from 'react';
import d3 from 'd3';
import { scaleTime } from 'd3-scale';
import { type ChartTooltipService, type TooltipData } from '../components/chart_tooltip';
import { useCurrentThemeVars } from '../contexts/kibana';
export interface AnnotationTimelineProps<T extends { timestamp: number; end_timestamp?: number }> {
label: string;
data: T[];
domain: {
min: number;
max: number;
};
getTooltipContent: (item: T, hasMergedAnnotations: boolean) => TooltipData;
tooltipService: ChartTooltipService;
chartWidth: number;
}
export const Y_AXIS_LABEL_WIDTH = 170;
export const Y_AXIS_LABEL_PADDING = 8;
const ANNOTATION_CONTAINER_HEIGHT = 12;
const ANNOTATION_MIN_WIDTH = 8;
/**
* Reusable component for rendering annotation-like items on a timeline.
*/
export const AnnotationTimeline = <T extends { timestamp: number; end_timestamp?: number }>({
data,
domain,
label,
tooltipService,
chartWidth,
getTooltipContent,
}: PropsWithChildren<AnnotationTimelineProps<T>>): ReturnType<FC> => {
const canvasRef = React.useRef<HTMLDivElement | null>(null);
const { euiTheme } = useCurrentThemeVars();
useEffect(
function renderChart() {
if (!(canvasRef.current !== null && Array.isArray(data))) return;
const chartElement = d3.select(canvasRef.current);
chartElement.selectAll('*').remove();
const dimensions = canvasRef.current.getBoundingClientRect();
const startingXPos = Y_AXIS_LABEL_WIDTH;
const endingXPos = dimensions.width;
const svg = chartElement
.append('svg')
.attr('width', '100%')
.attr('height', ANNOTATION_CONTAINER_HEIGHT);
const xScale = scaleTime().domain([domain.min, domain.max]).range([startingXPos, endingXPos]);
// Add Annotation y axis label
svg
.append('text')
.attr('text-anchor', 'end')
.attr('class', 'swimlaneAnnotationLabel')
.text(label)
.attr('x', Y_AXIS_LABEL_WIDTH - Y_AXIS_LABEL_PADDING)
.attr('y', ANNOTATION_CONTAINER_HEIGHT / 2)
.attr('dominant-baseline', 'middle')
.style('fill', euiTheme.euiTextSubduedColor)
.style('font-size', euiTheme.euiFontSizeXS);
// Add border
svg
.append('rect')
.attr('x', startingXPos)
.attr('y', 0)
.attr('height', ANNOTATION_CONTAINER_HEIGHT)
.attr('width', endingXPos - startingXPos)
.style('stroke', euiTheme.euiBorderColor)
.style('fill', 'none')
.style('stroke-width', 1);
// Merging overlapping annotations into bigger blocks
let mergedAnnotations: Array<{ start: number; end: number; annotations: T[] }> = [];
const sortedAnnotationsData = [...data].sort((a, b) => a.timestamp - b.timestamp);
if (sortedAnnotationsData.length > 0) {
let lastEndTime =
sortedAnnotationsData[0].end_timestamp ?? sortedAnnotationsData[0].timestamp;
mergedAnnotations = [
{
start: sortedAnnotationsData[0].timestamp,
end: lastEndTime,
annotations: [sortedAnnotationsData[0]],
},
];
for (let i = 1; i < sortedAnnotationsData.length; i++) {
if (sortedAnnotationsData[i].timestamp < lastEndTime) {
const itemToMerge = mergedAnnotations.pop();
if (itemToMerge) {
const newMergedItem = {
...itemToMerge,
end: lastEndTime,
annotations: [...itemToMerge.annotations, sortedAnnotationsData[i]],
};
mergedAnnotations.push(newMergedItem);
}
} else {
lastEndTime =
sortedAnnotationsData[i].end_timestamp ?? sortedAnnotationsData[i].timestamp;
mergedAnnotations.push({
start: sortedAnnotationsData[i].timestamp,
end: lastEndTime,
annotations: [sortedAnnotationsData[i]],
});
}
}
}
// Add annotation marker
mergedAnnotations.forEach((mergedAnnotation) => {
const annotationWidth = Math.max(
mergedAnnotation.end
? (xScale(Math.min(mergedAnnotation.end, domain.max)) as number) -
Math.max(xScale(mergedAnnotation.start) as number, startingXPos)
: 0,
ANNOTATION_MIN_WIDTH
);
const xPos =
mergedAnnotation.start >= domain.min
? (xScale(mergedAnnotation.start) as number)
: startingXPos;
svg
.append('rect')
.classed('mlAnnotationRect', true)
// If annotation is at the end, prevent overflow by shifting it back
.attr('x', xPos + annotationWidth >= endingXPos ? endingXPos - annotationWidth : xPos)
.attr('y', 0)
.attr('height', ANNOTATION_CONTAINER_HEIGHT)
.attr('width', annotationWidth)
.on('mouseover', function (this: HTMLElement) {
let tooltipData: TooltipData = [];
if (Array.isArray(mergedAnnotation.annotations)) {
const hasMergedAnnotations = mergedAnnotation.annotations.length > 1;
if (hasMergedAnnotations) {
// @ts-ignore skipping header so it doesn't have other params
tooltipData.push({ skipHeader: true });
}
tooltipData = [
...tooltipData,
...mergedAnnotation.annotations
.map((item) => getTooltipContent(item, hasMergedAnnotations))
.flat(),
];
}
tooltipService.show(tooltipData, this);
})
.on('mouseout', () => tooltipService.hide());
});
},
[
chartWidth,
domain,
data,
tooltipService,
label,
euiTheme.euiTextSubduedColor,
euiTheme.euiFontSizeXS,
euiTheme.euiBorderColor,
getTooltipContent,
]
);
return <div ref={canvasRef} />;
};

View file

@ -16,6 +16,7 @@ import { useExplorerUrlState } from './hooks/use_explorer_url_state';
import { AnomalyChartsStateService } from './anomaly_charts_state_service';
import { AnomalyExplorerChartsService } from '../services/anomaly_explorer_charts_service';
import { useTableSeverity } from '../components/controls/select_severity';
import { AnomalyDetectionAlertsStateService } from './alerts';
export type AnomalyExplorerContextValue =
| {
@ -24,6 +25,7 @@ export type AnomalyExplorerContextValue =
anomalyTimelineService: AnomalyTimelineService;
anomalyTimelineStateService: AnomalyTimelineStateService;
chartsStateService: AnomalyChartsStateService;
anomalyDetectionAlertsStateService: AnomalyDetectionAlertsStateService;
}
| undefined;
@ -59,6 +61,7 @@ export const AnomalyExplorerContextProvider: FC = ({ children }) => {
services: {
mlServices: { mlApiServices },
uiSettings,
data,
},
} = useMlKibana();
@ -102,12 +105,19 @@ export const AnomalyExplorerContextProvider: FC = ({ children }) => {
tableSeverityState
);
const anomalyDetectionAlertsStateService = new AnomalyDetectionAlertsStateService(
anomalyTimelineStateService,
data,
timefilter
);
setAnomalyExplorerContextValue({
anomalyExplorerChartsService,
anomalyExplorerCommonStateService,
anomalyTimelineService,
anomalyTimelineStateService,
chartsStateService,
anomalyDetectionAlertsStateService,
});
return () => {
@ -116,6 +126,7 @@ export const AnomalyExplorerContextProvider: FC = ({ children }) => {
anomalyExplorerCommonStateService.destroy();
anomalyTimelineStateService.destroy();
chartsStateService.destroy();
anomalyDetectionAlertsStateService.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View file

@ -61,6 +61,7 @@ import { AnomalyTimelineService } from '../services/anomaly_timeline_service';
import { useAnomalyExplorerContext } from './anomaly_explorer_context';
import { useTimeBuckets } from '../components/custom_hooks/use_time_buckets';
import { getTimeBoundsFromSelection } from './hooks/use_selected_cells';
import { SwimLaneWrapper } from './alerts';
function mapSwimlaneOptionsToEuiOptions(options: string[]) {
return options.map((option) => ({
@ -506,6 +507,7 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
</EuiFlexGroup>
<EuiSpacer size="m" />
{annotationXDomain && Array.isArray(annotations) && annotations.length > 0 ? (
<>
<MlTooltipComponent>
@ -522,29 +524,35 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
</>
) : null}
<SwimlaneContainer
id="overall"
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
filterActive={filterActive}
timeBuckets={timeBuckets}
swimlaneData={overallSwimlaneData as OverallSwimlaneData}
swimlaneType={SWIMLANE_TYPE.OVERALL}
<SwimLaneWrapper
selection={overallCellSelection}
onCellsSelection={setSelectedCells}
onResize={onResize}
isLoading={loading}
noDataWarning={
<EuiText textAlign={'center'}>
<h5>
<NoOverallData />
</h5>
</EuiText>
}
showTimeline={false}
showLegend={false}
yAxisWidth={Y_AXIS_LABEL_WIDTH}
chartsService={chartsService}
/>
swimlaneContainerWidth={swimlaneContainerWidth}
swimLaneData={overallSwimlaneData as OverallSwimlaneData}
>
<SwimlaneContainer
id="overall"
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
filterActive={filterActive}
timeBuckets={timeBuckets}
swimlaneData={overallSwimlaneData as OverallSwimlaneData}
swimlaneType={SWIMLANE_TYPE.OVERALL}
selection={overallCellSelection}
onCellsSelection={setSelectedCells}
onResize={onResize}
isLoading={loading}
noDataWarning={
<EuiText textAlign={'center'}>
<h5>
<NoOverallData />
</h5>
</EuiText>
}
showTimeline={false}
showLegend={false}
yAxisWidth={Y_AXIS_LABEL_WIDTH}
chartsService={chartsService}
/>
</SwimLaneWrapper>
<EuiSpacer size="m" />
{viewBySwimlaneOptions.length > 0 && (

View file

@ -49,6 +49,12 @@ interface SwimLanePagination {
viewByPerPage: number;
}
export interface TimeDomain {
min: number;
max: number;
minInterval: number;
}
/**
* Service for managing anomaly timeline state.
*/
@ -87,6 +93,18 @@ export class AnomalyTimelineStateService extends StateService {
private _timeBounds$: Observable<TimeRangeBounds>;
private _refreshSubject$: Observable<Refresh>;
/** Time domain of the currently active swim lane */
public readonly timeDomain$: Observable<TimeDomain | null> = this._overallSwimLaneData$.pipe(
map((data) => {
if (!data) return null;
return {
min: data.earliest * 1000,
max: data.latest * 1000,
minInterval: data.interval * 1000,
};
})
);
constructor(
private anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService,
private anomalyExplorerCommonStateService: AnomalyExplorerCommonStateService,
@ -646,6 +664,42 @@ export class AnomalyTimelineStateService extends StateService {
return this._viewBySwimLaneOptions$.asObservable();
}
/**
* Currently selected jobs on the swim lane
*/
public getSwimLaneJobs$(): Observable<ExplorerJob[]> {
return combineLatest([
this.anomalyExplorerCommonStateService.getSelectedJobs$(),
this.getViewBySwimlaneFieldName$(),
this._viewBySwimLaneData$,
this._selectedCells$,
]).pipe(
map(([selectedJobs, swimLaneFieldName, viewBySwimLaneData, selectedCells]) => {
// If there are selected lanes on the view by swim lane, use those to filter the jobs.
if (
selectedCells?.type === SWIMLANE_TYPE.VIEW_BY &&
selectedCells?.viewByFieldName === VIEW_BY_JOB_LABEL
) {
return selectedJobs.filter((job) => {
return selectedCells.lanes.includes(job.id);
});
}
if (
selectedCells?.type === SWIMLANE_TYPE.OVERALL &&
selectedCells?.viewByFieldName === VIEW_BY_JOB_LABEL &&
viewBySwimLaneData
) {
return selectedJobs.filter((job) => {
return viewBySwimLaneData.laneLabels.includes(job.id);
});
}
return selectedJobs;
})
);
}
public getViewBySwimLaneOptions(): string[] {
return this._viewBySwimLaneOptions$.getValue();
}

View file

@ -78,6 +78,7 @@ import { useToastNotificationService } from '../services/toast_notification_serv
import { useMlKibana, useMlLocator } from '../contexts/kibana';
import { useAnomalyExplorerContext } from './anomaly_explorer_context';
import { ML_ANOMALY_EXPLORER_PANELS } from '../../../common/types/storage';
import { AlertsPanel } from './alerts';
interface ExplorerPageProps {
jobSelectorProps: JobSelectorProps;
@ -263,8 +264,12 @@ export const Explorer: FC<ExplorerUIProps> = ({
}, [anomalyExplorerPanelState]);
const { displayWarningToast, displayDangerToast } = useToastNotificationService();
const { anomalyTimelineStateService, anomalyExplorerCommonStateService, chartsStateService } =
useAnomalyExplorerContext();
const {
anomalyTimelineStateService,
anomalyExplorerCommonStateService,
chartsStateService,
anomalyDetectionAlertsStateService,
} = useAnomalyExplorerContext();
const htmlIdGen = useMemo(() => htmlIdGenerator(), []);
@ -283,6 +288,8 @@ export const Explorer: FC<ExplorerUIProps> = ({
anomalyExplorerCommonStateService.getSelectedJobs()
);
const alertsData = useObservable(anomalyDetectionAlertsStateService.anomalyDetectionAlerts$, []);
const applyFilter = useCallback(
(fieldName: string, fieldValue: string, action: FilterAction) => {
const { filterActive, queryString } = filterSettings;
@ -487,6 +494,8 @@ export const Explorer: FC<ExplorerUIProps> = ({
<EuiSpacer size="m" />
{alertsData.length > 0 ? <AlertsPanel /> : null}
{annotationsError !== undefined && (
<>
<EuiTitle data-test-subj="mlAnomalyExplorerAnnotationsPanel error" size={'xs'}>

View file

@ -68,7 +68,7 @@ declare global {
*/
const RESIZE_THROTTLE_TIME_MS = 500;
const BORDER_WIDTH = 1;
const CELL_HEIGHT = 30;
export const CELL_HEIGHT = 30;
const LEGEND_HEIGHT = 34;
const X_AXIS_HEIGHT = 24;

View file

@ -42,13 +42,14 @@ import {
import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public';
import type { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/public';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import type { FieldFormatsSetup, FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { FieldFormatsSetup } from '@kbn/field-formats-plugin/public';
import type { DashboardSetup, DashboardStart } from '@kbn/dashboard-plugin/public';
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
import type { CasesUiSetup, CasesUiStart } from '@kbn/cases-plugin/public';
import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public';
import type { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public';
import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common';
import {
getMlSharedServices,
MlSharedServices,
@ -78,7 +79,7 @@ export interface MlStartDependencies {
dataViewEditor: DataViewEditorStart;
dataVisualizer: DataVisualizerPluginStart;
embeddable: EmbeddableStart;
fieldFormats: FieldFormatsStart;
fieldFormats: FieldFormatsRegistry;
lens: LensPublicStart;
licensing: LicensingPluginStart;
maps?: MapsStartApi;
@ -246,7 +247,11 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
mlCapabilities.canUseMlAlerts &&
mlCapabilities.canGetJobs
) {
registerMlAlerts(pluginsSetup.triggersActionsUi, pluginsSetup.alerting);
registerMlAlerts(
pluginsSetup.triggersActionsUi,
core.getStartServices,
pluginsSetup.alerting
);
}
if (pluginsSetup.maps) {

View file

@ -928,7 +928,7 @@ export function alertingServiceProvider(
spaceId
);
const message = i18n.translate(
const contextMessage = i18n.translate(
'xpack.ml.alertTypes.anomalyDetectionAlertingRule.recoveredMessage',
{
defaultMessage:
@ -940,18 +940,26 @@ export function alertingServiceProvider(
}
);
const payloadMessage = i18n.translate(
'xpack.ml.alertTypes.anomalyDetectionAlertingRule.recoveredReason',
{
defaultMessage:
'No anomalies have been found in the concecutive bucket after the alert was triggered.',
}
);
return {
name: '',
isHealthy: true,
payload: {
[ALERT_URL]: url,
[ALERT_REASON]: message,
[ALERT_REASON]: payloadMessage,
job_id: queryParams.jobIds[0],
},
context: {
anomalyExplorerUrl: url,
jobIds: queryParams.jobIds,
message,
message: contextMessage,
} as AnomalyDetectionAlertContext,
};
}

View file

@ -6,20 +6,28 @@
*/
import { i18n } from '@kbn/i18n';
import { KibanaRequest, DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { DEFAULT_APP_CATEGORIES, KibanaRequest } from '@kbn/core/server';
import type {
ActionGroup,
AlertInstanceContext,
AlertInstanceState,
RecoveredActionGroupId,
RuleTypeParams,
RuleTypeState,
RecoveredActionGroupId,
} from '@kbn/alerting-plugin/common';
import { IRuleTypeAlerts, RuleExecutorOptions } from '@kbn/alerting-plugin/server';
import { ALERT_NAMESPACE, ALERT_REASON, ALERT_URL } from '@kbn/rule-data-utils';
import { ALERT_REASON, ALERT_URL } from '@kbn/rule-data-utils';
import { MlAnomalyDetectionAlert } from '@kbn/alerts-as-data-utils';
import { ES_FIELD_TYPES } from '@kbn/field-types';
import { ML_ALERT_TYPES } from '../../../common/constants/alerts';
import {
ALERT_ANOMALY_DETECTION_JOB_ID,
ALERT_ANOMALY_IS_INTERIM,
ALERT_ANOMALY_SCORE,
ALERT_ANOMALY_TIMESTAMP,
ALERT_TOP_INFLUENCERS,
ALERT_TOP_RECORDS,
ML_ALERT_TYPES,
} from '../../../common/constants/alerts';
import { PLUGIN_ID } from '../../../common/constants/app';
import { MINIMUM_FULL_LICENSE } from '../../../common/license';
import {
@ -79,17 +87,6 @@ export type AnomalyScoreMatchGroupId = typeof ANOMALY_SCORE_MATCH_GROUP_ID;
export const ANOMALY_DETECTION_AAD_INDEX_NAME = 'ml.anomaly-detection';
const ML_ALERT_NAMESPACE = ALERT_NAMESPACE;
export const ALERT_ANOMALY_DETECTION_JOB_ID = `${ML_ALERT_NAMESPACE}.job_id` as const;
export const ALERT_ANOMALY_SCORE = `${ML_ALERT_NAMESPACE}.anomaly_score` as const;
export const ALERT_ANOMALY_IS_INTERIM = `${ML_ALERT_NAMESPACE}.is_interim` as const;
export const ALERT_ANOMALY_TIMESTAMP = `${ML_ALERT_NAMESPACE}.anomaly_timestamp` as const;
export const ALERT_TOP_RECORDS = `${ML_ALERT_NAMESPACE}.top_records` as const;
export const ALERT_TOP_INFLUENCERS = `${ML_ALERT_NAMESPACE}.top_influencers` as const;
export const ANOMALY_DETECTION_AAD_CONFIG: IRuleTypeAlerts<MlAnomalyDetectionAlert> = {
context: ANOMALY_DETECTION_AAD_INDEX_NAME,
mappings: {

View file

@ -108,5 +108,7 @@
"@kbn/data-view-editor-plugin",
"@kbn/rule-data-utils",
"@kbn/alerts-as-data-utils",
"@kbn/rule-registry-plugin",
"@kbn/securitysolution-ecs",
],
}