mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
7a6d009002
commit
875268d558
34 changed files with 2168 additions and 79 deletions
|
@ -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'
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -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';
|
|
@ -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>;
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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) {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -6,3 +6,4 @@
|
|||
*/
|
||||
|
||||
export { CollapsiblePanel } from './collapsible_panel';
|
||||
export { PanelHeaderItems } from './panel_header_items';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
189
x-pack/plugins/ml/public/application/explorer/alerts/chart.tsx
Normal file
189
x-pack/plugins/ml/public/application/explorer/alerts/chart.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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';
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
};
|
|
@ -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
|
||||
}, []);
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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'}>
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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",
|
||||
],
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue