[Observability] Related alerts based on scoring !! (#215673)

## Summary

Copying most of https://github.com/elastic/kibana/pull/214017 !!

Fixes https://github.com/elastic/kibana/issues/214372

### Implementation
We are now using response ops alerts table with custom score querying
based on tags/groups matches and Jaccard similarity on documents !!


<img width="1728" alt="image"
src="https://github.com/user-attachments/assets/b3a69280-c05d-4100-be6a-2c8dadcc051d"
/>

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Dominique Clarke <dominique.clarke@elastic.co>
Co-authored-by: Kevin Delemme <kevin.delemme@elastic.co>
This commit is contained in:
Shahzad 2025-04-14 17:35:40 +02:00 committed by GitHub
parent f8e688f881
commit 760106eb86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1154 additions and 656 deletions

View file

@ -13,6 +13,7 @@ import type { JsonValue } from '@kbn/utility-types';
export interface MetaAlertFields {
_id: string;
_index: string;
_score?: number;
}
export interface LegacyField {
@ -29,7 +30,7 @@ export type KnownAlertFields = {
[Property in TechnicalRuleDataFieldName]?: JsonValue[];
};
export type UnknownAlertFields = Record<string, string | JsonValue[]>;
export type UnknownAlertFields = Record<string, string | number | JsonValue[]>;
/**
* Alert document type as returned by alerts search requests

View file

@ -24,6 +24,8 @@ export type RuleRegistrySearchRequest = IEsSearchRequest & {
sort?: SortCombinations[];
pagination?: RuleRegistrySearchRequestPagination;
runtimeMappings?: MappingRuntimeFields;
minScore?: number;
trackScores?: boolean;
};
export interface RuleRegistrySearchRequestPagination {

View file

@ -85,6 +85,7 @@ const parsedAlerts = {
{
_index: '.internal.alerts-security.alerts-default-000001',
_id: '38dd308706a127696cc63b8f142e8e4d66f8f79bc7d491dd79a42ea4ead62dd1',
_score: 1,
'@timestamp': ['2022-03-22T16:48:07.518Z'],
'host.name': ['Host-4dbzugdlqd'],
'kibana.alert.reason': [
@ -99,6 +100,7 @@ const parsedAlerts = {
{
_index: '.internal.alerts-security.alerts-default-000001',
_id: '8361363c0db6f30ca2dfb4aeb4835e7d6ec57bc195b96d9ee5a4ead1bb9f8b86',
_score: 1,
'@timestamp': ['2022-03-22T16:17:50.769Z'],
'host.name': ['Host-4dbzugdlqd'],
'kibana.alert.reason': [
@ -130,6 +132,7 @@ const parsedAlerts = {
host: { name: ['Host-4dbzugdlqd'] },
_id: '38dd308706a127696cc63b8f142e8e4d66f8f79bc7d491dd79a42ea4ead62dd1',
_index: '.internal.alerts-security.alerts-default-000001',
_score: 1,
},
{
kibana: {
@ -148,6 +151,7 @@ const parsedAlerts = {
host: { name: ['Host-4dbzugdlqd'] },
_id: '8361363c0db6f30ca2dfb4aeb4835e7d6ec57bc195b96d9ee5a4ead1bb9f8b86',
_index: '.internal.alerts-security.alerts-default-000001',
_score: 1,
},
],
oldAlertsData: [
@ -169,6 +173,10 @@ const parsedAlerts = {
field: '_id',
value: '38dd308706a127696cc63b8f142e8e4d66f8f79bc7d491dd79a42ea4ead62dd1',
},
{
field: '_score',
value: 1,
},
{ field: '_index', value: '.internal.alerts-security.alerts-default-000001' },
],
[
@ -189,6 +197,10 @@ const parsedAlerts = {
field: '_id',
value: '8361363c0db6f30ca2dfb4aeb4835e7d6ec57bc195b96d9ee5a4ead1bb9f8b86',
},
{
field: '_score',
value: 1,
},
{ field: '_index', value: '.internal.alerts-security.alerts-default-000001' },
],
],

View file

@ -7,7 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { catchError, filter, lastValueFrom, map, of } from 'rxjs';
import type {
MappingRuntimeFields,
QueryDslFieldAndFormat,
QueryDslQueryContainer,
SortCombinations,
} from '@elastic/elasticsearch/lib/api/types';
import type {
Alert,
EsQuerySnapshot,
@ -15,14 +20,9 @@ import type {
RuleRegistrySearchRequest,
RuleRegistrySearchResponse,
} from '@kbn/alerting-types';
import { set } from '@kbn/safer-lodash-set';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type {
MappingRuntimeFields,
QueryDslFieldAndFormat,
QueryDslQueryContainer,
SortCombinations,
} from '@elastic/elasticsearch/lib/api/types';
import { set } from '@kbn/safer-lodash-set';
import { catchError, filter, lastValueFrom, map, of } from 'rxjs';
export interface SearchAlertsParams {
// Dependencies
@ -68,6 +68,14 @@ export interface SearchAlertsParams {
* The page size to fetch
*/
pageSize: number;
/**
* The minimum score to apply to the query
*/
minScore?: number;
/**
* Whether to track the score of the query
*/
trackScores?: boolean;
}
export interface SearchAlertsResult {
@ -92,6 +100,8 @@ export const searchAlerts = ({
runtimeMappings,
pageIndex,
pageSize,
minScore,
trackScores,
}: SearchAlertsParams): Promise<SearchAlertsResult> =>
lastValueFrom(
data.search
@ -104,6 +114,8 @@ export const searchAlerts = ({
pagination: { pageIndex, pageSize },
sort,
runtimeMappings,
minScore,
trackScores,
},
{
strategy: 'privateRuleRegistryAlertsSearchStrategy',
@ -167,6 +179,7 @@ const parseAlerts = (rawResponse: RuleRegistrySearchResponse['rawResponse']) =>
acc.push({
...hit.fields,
_id: hit._id,
_score: hit._score,
_index: hit._index,
} as Alert);
}

View file

@ -166,6 +166,7 @@ describe('useSearchAlertsQuery', () => {
{
_index: '.internal.alerts-security.alerts-default-000001',
_id: '38dd308706a127696cc63b8f142e8e4d66f8f79bc7d491dd79a42ea4ead62dd1',
_score: 1,
'@timestamp': ['2022-03-22T16:48:07.518Z'],
'host.name': ['Host-4dbzugdlqd'],
'kibana.alert.reason': [
@ -180,6 +181,7 @@ describe('useSearchAlertsQuery', () => {
{
_index: '.internal.alerts-security.alerts-default-000001',
_id: '8361363c0db6f30ca2dfb4aeb4835e7d6ec57bc195b96d9ee5a4ead1bb9f8b86',
_score: 1,
'@timestamp': ['2022-03-22T16:17:50.769Z'],
'host.name': ['Host-4dbzugdlqd'],
'kibana.alert.reason': [
@ -211,6 +213,7 @@ describe('useSearchAlertsQuery', () => {
host: { name: ['Host-4dbzugdlqd'] },
_id: '38dd308706a127696cc63b8f142e8e4d66f8f79bc7d491dd79a42ea4ead62dd1',
_index: '.internal.alerts-security.alerts-default-000001',
_score: 1,
},
{
kibana: {
@ -229,6 +232,7 @@ describe('useSearchAlertsQuery', () => {
host: { name: ['Host-4dbzugdlqd'] },
_id: '8361363c0db6f30ca2dfb4aeb4835e7d6ec57bc195b96d9ee5a4ead1bb9f8b86',
_index: '.internal.alerts-security.alerts-default-000001',
_score: 1,
},
],
oldAlertsData: [
@ -250,6 +254,10 @@ describe('useSearchAlertsQuery', () => {
field: '_id',
value: '38dd308706a127696cc63b8f142e8e4d66f8f79bc7d491dd79a42ea4ead62dd1',
},
{
field: '_score',
value: 1,
},
{ field: '_index', value: '.internal.alerts-security.alerts-default-000001' },
],
[
@ -270,6 +278,10 @@ describe('useSearchAlertsQuery', () => {
field: '_id',
value: '8361363c0db6f30ca2dfb4aeb4835e7d6ec57bc195b96d9ee5a4ead1bb9f8b86',
},
{
field: '_score',
value: 1,
},
{ field: '_index', value: '.internal.alerts-security.alerts-default-000001' },
],
],

View file

@ -43,6 +43,8 @@ export const useSearchAlertsQuery = ({ data, ...params }: UseSearchAlertsQueryPa
runtimeMappings,
pageIndex = 0,
pageSize = DEFAULT_ALERTS_PAGE_SIZE,
minScore,
trackScores,
} = params;
return useQuery({
queryKey: queryKeyPrefix.concat(JSON.stringify(params)),
@ -58,6 +60,8 @@ export const useSearchAlertsQuery = ({ data, ...params }: UseSearchAlertsQueryPa
runtimeMappings,
pageIndex,
pageSize,
minScore,
trackScores,
}),
refetchOnWindowFocus: false,
context: AlertsQueryContext,

View file

@ -170,6 +170,8 @@ const AlertsTableContent = typedForwardRef(
ruleTypeIds,
consumers,
query,
minScore,
trackScores = false,
initialSort = DEFAULT_SORT,
initialPageSize = DEFAULT_ALERTS_PAGE_SIZE,
leadingControlColumns = DEFAULT_LEADING_CONTROL_COLUMNS,
@ -277,6 +279,8 @@ const AlertsTableContent = typedForwardRef(
runtimeMappings,
pageIndex: 0,
pageSize: initialPageSize,
minScore,
trackScores,
});
useEffect(() => {
@ -287,6 +291,8 @@ const AlertsTableContent = typedForwardRef(
query,
sort,
runtimeMappings,
minScore,
trackScores,
// Go back to the first page if the query changes
pageIndex: !deepEqual(prevQueryParams, {
ruleTypeIds,
@ -300,7 +306,7 @@ const AlertsTableContent = typedForwardRef(
: oldPageIndex,
pageSize: oldPageSize,
}));
}, [ruleTypeIds, fields, query, runtimeMappings, sort, consumers]);
}, [ruleTypeIds, fields, query, runtimeMappings, sort, consumers, minScore, trackScores]);
const {
data: alertsData,

View file

@ -12,6 +12,7 @@ import { EuiDescriptionList, EuiPanel, EuiTabbedContentTab, EuiTitle } from '@el
import { ALERT_RULE_NAME } from '@kbn/rule-data-utils';
import { i18n } from '@kbn/i18n';
import { ScrollableFlyoutTabbedContent, AlertFieldsTable } from '@kbn/alerts-ui-shared';
import { JsonValue } from '@kbn/utility-types';
import { AdditionalContext, FlyoutSectionProps } from '../types';
import { defaultAlertsTableColumns } from '../configuration';
import { DefaultCellValue } from './default_cell_value';
@ -43,7 +44,7 @@ export const DefaultAlertsFlyoutBody = <AC extends AdditionalContext>(
<EuiPanel hasShadow={false} data-test-subj="overviewTabPanel">
<EuiDescriptionList
listItems={(columns ?? defaultAlertsTableColumns).map((column) => {
const value = alert[column.id]?.[0];
const value = (alert[column.id] as JsonValue[])?.[0];
return {
title: (column.displayAsText as string) ?? column.id,

View file

@ -94,7 +94,7 @@ export const DefaultCellValue = ({
/**
* Extracts the value from the raw json ES field
*/
const extractFieldValue = (rawValue: string | JsonValue[]) => {
const extractFieldValue = (rawValue: string | number | JsonValue[]) => {
const value = Array.isArray(rawValue) ? rawValue.join() : rawValue;
if (!isEmpty(value)) {

View file

@ -372,6 +372,8 @@ export interface PublicAlertsDataGridProps
| 'columns'
> {
ruleTypeIds: string[];
minScore?: number;
trackScores?: boolean;
consumers?: string[];
/**
* If true, shows a button in the table toolbar to inspect the search alerts request

View file

@ -32435,8 +32435,6 @@
"xpack.observability.pages.alertDetails.pageTitle.ruleName": "Règle",
"xpack.observability.pages.alertDetails.pageTitle.title": "{ruleCategory} {ruleCategory, select, Anomaly {détectée} Inventory {seuil dépassé} other {dépassés}}",
"xpack.observability.pages.alertDetails.pageTitle.triggered": "Déclenché",
"xpack.observability.pages.alertDetails.relatedAlerts.empty.description": "En raison d'une erreur inattendue, aucune alerte associée ne peut être trouvée.",
"xpack.observability.pages.alertDetails.relatedAlerts.empty.title": "Problème de chargement des alertes associées",
"xpack.observability.profilingAWSCostDiscountRateUiSettingDescription": "Si vous êtes inscrits au programme de réduction AWS Enterprise Discount Program (EDP), entrez votre taux de réduction pour mettre à jour le calcul des coûts de profilage.",
"xpack.observability.profilingAWSCostDiscountRateUiSettingName": "Taux de réduction AWS EDP (%)",
"xpack.observability.profilingAzureCostDiscountRateUiSettingDescription": "Si vous avez un accord Azure Enterprise avec Microsoft, saisissez votre taux de réduction pour mettre à jour le calcul du coût de profilage.",

View file

@ -32415,8 +32415,6 @@
"xpack.observability.pages.alertDetails.pageTitle.ruleName": "ルール",
"xpack.observability.pages.alertDetails.pageTitle.title": "{ruleCategory} {ruleCategory, select, Anomaly {検出されました} Inventory {しきい値に違反しました} other {違反しました}}",
"xpack.observability.pages.alertDetails.pageTitle.triggered": "実行済み",
"xpack.observability.pages.alertDetails.relatedAlerts.empty.description": "予期しないエラーのため、関連するアラートが見つかりません。",
"xpack.observability.pages.alertDetails.relatedAlerts.empty.title": "関連するアラートの読み込みエラー",
"xpack.observability.profilingAWSCostDiscountRateUiSettingDescription": "AWS Enterprise Discount ProgramEDPに加入している場合は、割引率を入力してプロファイリング費用の計算を更新します。",
"xpack.observability.profilingAWSCostDiscountRateUiSettingName": "AWS EDP割引率%",
"xpack.observability.profilingAzureCostDiscountRateUiSettingDescription": "MicrosoftとのAzureエンタープライズ契約がある場合は、割引率を入力して、プロファイリングコスト計算を更新してください。",

View file

@ -32470,8 +32470,6 @@
"xpack.observability.pages.alertDetails.pageTitle.ruleName": "规则",
"xpack.observability.pages.alertDetails.pageTitle.title": "{ruleCategory} {ruleCategory, select, Anomaly {已检测到} Inventory {超出阈值} other {已超出}}",
"xpack.observability.pages.alertDetails.pageTitle.triggered": "已触发",
"xpack.observability.pages.alertDetails.relatedAlerts.empty.description": "由于出现意外错误,找不到相关告警。",
"xpack.observability.pages.alertDetails.relatedAlerts.empty.title": "加载相关告警时出现问题",
"xpack.observability.profilingAWSCostDiscountRateUiSettingDescription": "如果已加入 AWS 企业折扣计划 (EDP),请输入您的折扣率以更新分析成本计算。",
"xpack.observability.profilingAWSCostDiscountRateUiSettingName": "AWS EDP 折扣率 (%)",
"xpack.observability.profilingAzureCostDiscountRateUiSettingDescription": "如果与 Microsoft 签署了 Azure 企业协议,请输入您的折扣率以更新分析成本计算。",

View file

@ -716,4 +716,107 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
}
`);
});
it('passes the min_score if minScore is provided', async () => {
const minScore = 0.5;
const request: RuleRegistrySearchRequest = {
ruleTypeIds: ['siem.esqlRule'],
minScore,
};
const options = {};
const deps = {
request: {},
};
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['security-siem']);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
await lastValueFrom(
strategy.search(request, options, deps as unknown as SearchStrategyDependencies)
);
const arg0 = searchStrategySearch.mock.calls[0][0];
expect(arg0.params.body.fields.length).toEqual(
// +2 because of fields.push({ field: 'kibana.alert.*', include_unmapped: false }); and
// fields.push({ field: 'signal.*', include_unmapped: false });
ALERT_EVENTS_FIELDS.length + 2
);
expect.arrayContaining([
expect.objectContaining({
x: 2,
y: 3,
}),
]);
expect(arg0).toEqual(
expect.objectContaining({
id: undefined,
params: expect.objectContaining({
allow_no_indices: true,
body: expect.objectContaining({
_source: false,
fields: expect.arrayContaining([
expect.objectContaining({
field: '@timestamp',
include_unmapped: true,
}),
]),
from: 0,
min_score: minScore,
size: 1000,
sort: [],
}),
ignore_unavailable: true,
index: ['security-siem'],
}),
})
);
});
it('passes track_scores if trackScores is provided', async () => {
const request: RuleRegistrySearchRequest = {
ruleTypeIds: ['siem.esqlRule'],
trackScores: true,
};
const options = {};
const deps = {
request: {},
};
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['security-siem']);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
await lastValueFrom(
strategy.search(request, options, deps as unknown as SearchStrategyDependencies)
);
const arg0 = searchStrategySearch.mock.calls[0][0];
expect(arg0).toEqual(
expect.objectContaining({
id: undefined,
params: expect.objectContaining({
allow_no_indices: true,
body: expect.objectContaining({
_source: false,
fields: expect.arrayContaining([
expect.objectContaining({
field: '@timestamp',
include_unmapped: true,
}),
]),
from: 0,
size: 1000,
sort: [],
track_scores: true,
}),
ignore_unavailable: true,
index: ['security-siem'],
}),
})
);
});
});

View file

@ -172,6 +172,8 @@ export const ruleRegistrySearchStrategyProvider = (
from: request.pagination ? request.pagination.pageIndex * size : 0,
query,
...(request.runtimeMappings ? { runtime_mappings: request.runtimeMappings } : {}),
...(request.minScore ? { min_score: request.minScore } : {}),
...(request.trackScores ? { track_scores: request.trackScores } : {}),
},
};
return (isAnyRuleTypeESAuthorized ? requestUserEs : internalUserEs).search(

View file

@ -9,6 +9,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PerformanceContextProvider } from '@kbn/ebt-tools';
import { InspectorContextProvider } from '@kbn/observability-shared-plugin/public';
import { i18n } from '@kbn/i18n';
import { Router, Routes, Route } from '@kbn/shared-ux-router';
import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '@kbn/core/public';
@ -114,8 +115,10 @@ export const renderApp = ({
<RedirectAppLinks coreStart={core} data-test-subj="observabilityMainContainer">
<PerformanceContextProvider>
<QueryClientProvider client={queryClient}>
<App />
<HideableReactQueryDevTools />
<InspectorContextProvider>
<App />
<HideableReactQueryDevTools />
</InspectorContextProvider>
</QueryClientProvider>
</PerformanceContextProvider>
</RedirectAppLinks>

View file

@ -29,7 +29,7 @@ import { ObservabilityRuleTypeRegistry } from '../../rules/create_observability_
import type { GetObservabilityAlertsTableProp } from '../..';
import { AlertsTableContextProvider } from '@kbn/response-ops-alerts-table/contexts/alerts_table_context';
import { AdditionalContext, RenderContext } from '@kbn/response-ops-alerts-table/types';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
const refresh = jest.fn();
const caseHooksReturnedValue = {
open: () => {
@ -82,6 +82,15 @@ jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({
ObservabilityPageTemplate: KibanaPageTemplate,
ObservabilityAIAssistantContextualInsight,
}));
jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({
appMountParameters: {} as AppMountParameters,
core: {} as CoreStart,
config,
plugins: {} as ObservabilityPublicPluginsStart,
observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(),
ObservabilityPageTemplate: KibanaPageTemplate,
ObservabilityAIAssistantContextualInsight,
}));
describe('ObservabilityActions component', () => {
beforeEach(() => {
@ -145,15 +154,17 @@ describe('ObservabilityActions component', () => {
const wrapper = mountWithIntl(
<Router history={createMemoryHistory()}>
<AlertsTableContextProvider value={context}>
<QueryClientProvider client={queryClient} context={AlertsQueryContext}>
<AlertActions
{...(props as unknown as ComponentProps<
GetObservabilityAlertsTableProp<'renderActionsCell'>
>)}
/>
</QueryClientProvider>
</AlertsTableContextProvider>
<KibanaContextProvider services={mockKibana.services}>
<AlertsTableContextProvider value={context}>
<QueryClientProvider client={queryClient} context={AlertsQueryContext}>
<AlertActions
{...(props as unknown as ComponentProps<
GetObservabilityAlertsTableProp<'renderActionsCell'>
>)}
/>
</QueryClientProvider>
</AlertsTableContextProvider>
</KibanaContextProvider>
</Router>
);
await act(async () => {

View file

@ -16,86 +16,45 @@ import {
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { CaseAttachmentsWithoutOwner, CasesPublicStart } from '@kbn/cases-plugin/public';
import { AttachmentType } from '@kbn/cases-plugin/common';
import { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { CasesPublicStart } from '@kbn/cases-plugin/public';
import { useRouteMatch } from 'react-router-dom';
import { SLO_ALERTS_TABLE_ID } from '@kbn/observability-shared-plugin/common';
import { DefaultAlertActions } from '@kbn/response-ops-alerts-table/components/default_alert_actions';
import { useAlertsTableContext } from '@kbn/response-ops-alerts-table/contexts/alerts_table_context';
import { ALERT_UUID } from '@kbn/rule-data-utils';
import type { EventNonEcsData } from '../../../common/typings';
import { GetObservabilityAlertsTableProp } from '../alerts_table/types';
import { useKibana } from '../../utils/kibana_react';
import { useCaseActions } from './use_case_actions';
import { RULE_DETAILS_PAGE_ID } from '../../pages/rule_details/constants';
import { paths, SLO_DETAIL_PATH } from '../../../common/locators/paths';
import { parseAlert } from '../../pages/alerts/helpers/parse_alert';
import { observabilityFeatureId } from '../..';
import {
GetObservabilityAlertsTableProp,
ObservabilityAlertsTableContext,
observabilityFeatureId,
} from '../..';
import { ALERT_DETAILS_PAGE_ID } from '../../pages/alert_details/alert_details';
// eslint-disable-next-line react/function-component-definition
export const AlertActions: GetObservabilityAlertsTableProp<'renderActionsCell'> = ({
config,
observabilityRuleTypeRegistry,
alert,
id,
tableId,
dataGridRef,
refresh,
isLoading,
isLoadingAlerts,
alerts,
oldAlertsData,
ecsAlertsData,
alertsCount,
browserFields,
isLoadingMutedAlerts,
mutedAlerts,
isLoadingCases,
cases,
isLoadingMaintenanceWindows,
maintenanceWindows,
pageIndex,
pageSize,
openAlertInFlyout,
showAlertStatusWithFlapping,
bulkActionsStore,
columns,
renderCellValue,
renderCellPopover,
renderActionsCell,
renderFlyoutHeader,
renderFlyoutBody,
renderFlyoutFooter,
parentAlert,
...rest
}) => {
const { services } = useAlertsTableContext();
const services = useKibana().services;
const {
http: {
basePath: { prepend },
},
} = services;
const {
helpers: { getRuleIdFromEvent, canUseCases },
hooks: { useCasesAddToNewCaseFlyout, useCasesAddToExistingCaseModal },
helpers: { canUseCases },
} = services.cases! as unknown as CasesPublicStart; // Cases is guaranteed to be defined in Observability
const isSLODetailsPage = useRouteMatch(SLO_DETAIL_PATH);
const isInApp = Boolean(id === SLO_ALERTS_TABLE_ID && isSLODetailsPage);
const data = useMemo(
() =>
Object.entries(alert ?? {}).reduce<EventNonEcsData[]>(
(acc, [field, value]) => [...acc, { field, value: value as string[] }],
[]
),
[alert]
);
const isInApp = Boolean(tableId === SLO_ALERTS_TABLE_ID && isSLODetailsPage);
const ecsData = useMemo<Ecs>(
() => ({
_id: alert._id,
_index: alert._index,
}),
[alert._id, alert._index]
);
const userCasesPermissions = canUseCases([observabilityFeatureId]);
const [viewInAppUrl, setViewInAppUrl] = useState<string>();
@ -125,128 +84,20 @@ export const AlertActions: GetObservabilityAlertsTableProp<'renderActionsCell'>
}
}, [observabilityAlert.link, observabilityAlert.hasBasePath, prepend]);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isPopoverOpen, setIsPopoverOpen, handleAddToExistingCaseClick, handleAddToNewCaseClick } =
useCaseActions({
refresh,
alerts: [alert],
});
const caseAttachments: CaseAttachmentsWithoutOwner = useMemo(() => {
return ecsData?._id
? [
{
alertId: ecsData?._id ?? '',
index: ecsData?._index ?? '',
type: AttachmentType.alert,
rule: getRuleIdFromEvent({ ecs: ecsData, data: data ?? [] }),
},
]
: [];
}, [ecsData, getRuleIdFromEvent, data]);
const onSuccess = useCallback(() => {
refresh();
}, [refresh]);
const createCaseFlyout = useCasesAddToNewCaseFlyout({ onSuccess });
const selectCaseModal = useCasesAddToExistingCaseModal({ onSuccess });
const closeActionsPopover = () => {
const closeActionsPopover = useCallback(() => {
setIsPopoverOpen(false);
};
}, [setIsPopoverOpen]);
const toggleActionsPopover = () => {
setIsPopoverOpen(!isPopoverOpen);
};
const handleAddToNewCaseClick = () => {
createCaseFlyout.open({ attachments: caseAttachments });
closeActionsPopover();
};
const handleAddToExistingCaseClick = () => {
selectCaseModal.open({ getAttachments: () => caseAttachments });
closeActionsPopover();
};
const defaultRowActions = useMemo(
() => (
<DefaultAlertActions
key="defaultRowActions"
onActionExecuted={closeActionsPopover}
isAlertDetailsEnabled={true}
resolveRulePagePath={(ruleId, currentPageId) =>
currentPageId !== RULE_DETAILS_PAGE_ID ? paths.observability.ruleDetails(ruleId) : null
}
resolveAlertPagePath={(alertId, currentPageId) =>
currentPageId !== ALERT_DETAILS_PAGE_ID ? paths.observability.alertDetails(alertId) : null
}
tableId={tableId}
dataGridRef={dataGridRef}
refresh={refresh}
isLoading={isLoading}
isLoadingAlerts={isLoadingAlerts}
alert={alert}
alerts={alerts}
oldAlertsData={oldAlertsData}
ecsAlertsData={ecsAlertsData}
alertsCount={alertsCount}
browserFields={browserFields}
isLoadingMutedAlerts={isLoadingMutedAlerts}
mutedAlerts={mutedAlerts}
isLoadingCases={isLoadingCases}
cases={cases}
isLoadingMaintenanceWindows={isLoadingMaintenanceWindows}
maintenanceWindows={maintenanceWindows}
pageIndex={pageIndex}
pageSize={pageSize}
openAlertInFlyout={openAlertInFlyout}
showAlertStatusWithFlapping={showAlertStatusWithFlapping}
bulkActionsStore={bulkActionsStore}
columns={columns}
renderCellValue={renderCellValue}
renderCellPopover={renderCellPopover}
renderActionsCell={renderActionsCell}
renderFlyoutHeader={renderFlyoutHeader}
renderFlyoutBody={renderFlyoutBody}
renderFlyoutFooter={renderFlyoutFooter}
services={services}
config={config}
observabilityRuleTypeRegistry={observabilityRuleTypeRegistry}
/>
),
[
alert,
alerts,
alertsCount,
browserFields,
bulkActionsStore,
cases,
columns,
config,
dataGridRef,
ecsAlertsData,
isLoading,
isLoadingAlerts,
isLoadingCases,
isLoadingMaintenanceWindows,
isLoadingMutedAlerts,
maintenanceWindows,
mutedAlerts,
observabilityRuleTypeRegistry,
oldAlertsData,
openAlertInFlyout,
pageIndex,
pageSize,
refresh,
renderActionsCell,
renderCellPopover,
renderCellValue,
renderFlyoutBody,
renderFlyoutFooter,
renderFlyoutHeader,
services,
showAlertStatusWithFlapping,
tableId,
]
);
const actionsMenuItems = [
...(userCasesPermissions.createComment && userCasesPermissions.read
? [
@ -272,7 +123,38 @@ export const AlertActions: GetObservabilityAlertsTableProp<'renderActionsCell'>
</EuiContextMenuItem>,
]
: []),
defaultRowActions,
useMemo(
() => (
<DefaultAlertActions<ObservabilityAlertsTableContext>
observabilityRuleTypeRegistry={observabilityRuleTypeRegistry}
key="defaultRowActions"
onActionExecuted={closeActionsPopover}
isAlertDetailsEnabled={true}
resolveRulePagePath={(ruleId, currentPageId) =>
currentPageId !== RULE_DETAILS_PAGE_ID ? paths.observability.ruleDetails(ruleId) : null
}
resolveAlertPagePath={(alertId, currentPageId) =>
currentPageId !== ALERT_DETAILS_PAGE_ID
? paths.observability.alertDetails(alertId)
: null
}
tableId={tableId}
refresh={refresh}
alert={alert}
openAlertInFlyout={openAlertInFlyout}
{...rest}
/>
),
[
alert,
closeActionsPopover,
observabilityRuleTypeRegistry,
openAlertInFlyout,
refresh,
rest,
tableId,
]
),
];
const actionsToolTip =
@ -286,24 +168,27 @@ export const AlertActions: GetObservabilityAlertsTableProp<'renderActionsCell'>
const onExpandEvent = () => {
const parsedAlert = parseAlert(observabilityRuleTypeRegistry)(alert);
openAlertInFlyout?.(parsedAlert.fields[ALERT_UUID]);
};
const hideViewInApp = isInApp || viewInAppUrl === '' || parentAlert;
return (
<>
<EuiFlexItem>
<EuiToolTip data-test-subj="expand-event-tool-tip" content={VIEW_DETAILS}>
<EuiButtonIcon
data-test-subj="expand-event"
iconType="expand"
onClick={onExpandEvent}
size="s"
color="text"
/>
</EuiToolTip>
</EuiFlexItem>
{viewInAppUrl !== '' && !isInApp ? (
{!parentAlert && (
<EuiFlexItem>
<EuiToolTip data-test-subj="expand-event-tool-tip" content={VIEW_DETAILS}>
<EuiButtonIcon
data-test-subj="expand-event"
iconType="expand"
onClick={onExpandEvent}
size="s"
color="text"
/>
</EuiToolTip>
</EuiFlexItem>
)}
{!hideViewInApp && (
<EuiFlexItem>
<EuiToolTip
content={i18n.translate('xpack.observability.alertsTable.viewInAppTextLabel', {
@ -323,12 +208,13 @@ export const AlertActions: GetObservabilityAlertsTableProp<'renderActionsCell'>
/>
</EuiToolTip>
</EuiFlexItem>
) : null}
)}
<EuiFlexItem
css={{
textAlign: 'center',
}}
grow={parentAlert ? false : undefined}
>
<EuiPopover
anchorPosition="downLeft"

View file

@ -0,0 +1,69 @@
/*
* 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 { CaseAttachmentsWithoutOwner, CasesPublicStart } from '@kbn/cases-plugin/public';
import { useCallback, useState } from 'react';
import { AttachmentType } from '@kbn/cases-plugin/common';
import type { Alert } from '@kbn/alerting-types';
import type { EventNonEcsData } from '../../../common/typings';
import { useKibana } from '../../utils/kibana_react';
export const useCaseActions = ({ alerts, refresh }: { alerts: Alert[]; refresh?: () => void }) => {
const services = useKibana().services;
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const {
helpers: { getRuleIdFromEvent },
hooks: { useCasesAddToNewCaseFlyout, useCasesAddToExistingCaseModal },
} = services.cases! as unknown as CasesPublicStart; // Cases is guaranteed to be defined in Observability
const onSuccess = useCallback(() => {
refresh?.();
}, [refresh]);
const selectCaseModal = useCasesAddToExistingCaseModal({ onSuccess });
function getCaseAttachments(): CaseAttachmentsWithoutOwner {
return alerts.map((alert) => ({
alertId: alert?._id ?? '',
index: alert?._index ?? '',
type: AttachmentType.alert,
rule: getRuleIdFromEvent({
ecs: {
_id: alert?._id ?? '',
_index: alert?._index ?? '',
},
data:
Object.entries(alert ?? {}).reduce<EventNonEcsData[]>(
(acc, [field, value]) => [...acc, { field, value: value as string[] }],
[]
) ?? [],
}),
}));
}
const createCaseFlyout = useCasesAddToNewCaseFlyout({ onSuccess });
const closeActionsPopover = () => {
setIsPopoverOpen(false);
};
const handleAddToNewCaseClick = () => {
createCaseFlyout.open({ attachments: getCaseAttachments() });
closeActionsPopover();
};
const handleAddToExistingCaseClick = () => {
selectCaseModal.open({ getAttachments: () => getCaseAttachments() });
closeActionsPopover();
};
return {
isPopoverOpen,
setIsPopoverOpen,
handleAddToExistingCaseClick,
handleAddToNewCaseClick,
};
};

View file

@ -9,7 +9,7 @@ import React from 'react';
import { EuiToolTip } from '@elastic/eui';
interface Props {
value: string;
value: React.ReactNode;
tooltipContent: string;
}

View file

@ -10,6 +10,7 @@ import { ALERT_STATUS, ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/
import { render } from '../../../utils/test_helper';
import { AlertsTableCellValue } from './cell_value';
import { Alert } from '@kbn/alerting-types';
import { coreMock } from '@kbn/core/public/mocks';
interface AlertsTableRow {
alertStatus: typeof ALERT_STATUS_ACTIVE | typeof ALERT_STATUS_RECOVERED;
@ -66,4 +67,5 @@ const requiredProperties = {
isDraggable: false,
linkValues: [],
scopeId: '',
services: coreMock.createStart(),
} as unknown as ComponentProps<typeof AlertsTableCellValue>;

View file

@ -4,8 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiLink } from '@elastic/eui';
import React from 'react';
import { EuiLink, EuiText, EuiFlexGroup } from '@elastic/eui';
import React, { ReactNode } from 'react';
import {
ALERT_DURATION,
ALERT_SEVERITY,
@ -21,20 +21,31 @@ import {
ALERT_RULE_CATEGORY,
ALERT_START,
ALERT_RULE_EXECUTION_TIMESTAMP,
ALERT_RULE_UUID,
ALERT_CASE_IDS,
} from '@kbn/rule-data-utils';
import { isEmpty } from 'lodash';
import type { Alert } from '@kbn/alerting-types';
import type { JsonValue } from '@kbn/utility-types';
import {
RELATED_ACTIONS_COL,
RELATED_ALERT_REASON,
RELATION_COL,
} from '../../../pages/alert_details/components/related_alerts/get_related_columns';
import { RelationCol } from '../../../pages/alert_details/components/related_alerts/relation_col';
import { paths } from '../../../../common/locators/paths';
import { asDuration } from '../../../../common/utils/formatters';
import { AlertSeverityBadge } from '../../alert_severity_badge';
import { AlertStatusIndicator } from '../../alert_status_indicator';
import { parseAlert } from '../../../pages/alerts/helpers/parse_alert';
import { CellTooltip } from './cell_tooltip';
import { TimestampTooltip } from './timestamp_tooltip';
import type { GetObservabilityAlertsTableProp } from '../types';
import { GetObservabilityAlertsTableProp } from '../types';
import AlertActions from '../../alert_actions/alert_actions';
const getAlertFieldValue = (alert: Alert, fieldName: string) => {
export const getAlertFieldValue = (alert: Alert, fieldName: string) => {
// can be updated when working on https://github.com/elastic/kibana/issues/140819
const rawValue = alert[fieldName];
const rawValue = alert[fieldName] as JsonValue[];
const value = Array.isArray(rawValue) ? rawValue.join() : rawValue;
if (!isEmpty(value)) {
@ -51,40 +62,49 @@ const getAlertFieldValue = (alert: Alert, fieldName: string) => {
return '--';
};
export type AlertCellRenderers = Record<string, (value: string) => ReactNode>;
/**
* This implementation of `EuiDataGrid`'s `renderCellValue`
* accepts `EuiDataGridCellValueElementProps`, plus `data`
* from the TGrid
*/
// eslint-disable-next-line react/function-component-definition
export const AlertsTableCellValue: GetObservabilityAlertsTableProp<'renderCellValue'> = ({
columnId,
alert,
openAlertInFlyout,
observabilityRuleTypeRegistry,
}) => {
const value = getAlertFieldValue(alert, columnId);
export const AlertsTableCellValue: GetObservabilityAlertsTableProp<'renderCellValue'> = (props) => {
const {
columnId,
alert,
openAlertInFlyout,
observabilityRuleTypeRegistry,
services: { http },
parentAlert,
} = props;
switch (columnId) {
case ALERT_STATUS:
const cellRenderers: AlertCellRenderers = {
[ALERT_STATUS]: (value) => {
if (value !== ALERT_STATUS_ACTIVE && value !== ALERT_STATUS_RECOVERED) {
// NOTE: This should only be needed to narrow down the type.
// Status should be either "active" or "recovered".
return null;
}
return <AlertStatusIndicator alertStatus={value} />;
case TIMESTAMP:
case ALERT_START:
case ALERT_RULE_EXECUTION_TIMESTAMP:
return <TimestampTooltip time={new Date(value ?? '').getTime()} timeUnit="milliseconds" />;
case ALERT_DURATION:
return <>{asDuration(Number(value))}</>;
case ALERT_SEVERITY:
return <AlertSeverityBadge severityLevel={value ?? undefined} />;
case ALERT_EVALUATION_VALUE:
},
[TIMESTAMP]: (value) => (
<TimestampTooltip time={new Date(value ?? '').getTime()} timeUnit="milliseconds" />
),
[ALERT_START]: (value) => (
<TimestampTooltip time={new Date(value ?? '').getTime()} timeUnit="milliseconds" />
),
[ALERT_RULE_EXECUTION_TIMESTAMP]: (value) => (
<TimestampTooltip time={new Date(value ?? '').getTime()} timeUnit="milliseconds" />
),
[ALERT_DURATION]: (value) => <>{asDuration(Number(value))}</>,
[ALERT_SEVERITY]: (value) => <AlertSeverityBadge severityLevel={value ?? undefined} />,
[ALERT_EVALUATION_VALUE]: (value) => {
const multipleValues = getAlertFieldValue(alert, ALERT_EVALUATION_VALUES);
return <>{multipleValues ?? value}</>;
case ALERT_REASON:
},
[ALERT_REASON]: (value) => {
if (!observabilityRuleTypeRegistry) return <>{value}</>;
const parsedAlert = parseAlert(observabilityRuleTypeRegistry)(alert);
return (
@ -96,10 +116,42 @@ export const AlertsTableCellValue: GetObservabilityAlertsTableProp<'renderCellVa
{parsedAlert.reason}
</EuiLink>
);
case ALERT_RULE_NAME:
},
[ALERT_RULE_NAME]: (value) => {
const ruleCategory = getAlertFieldValue(alert, ALERT_RULE_CATEGORY);
return <CellTooltip value={value} tooltipContent={ruleCategory} />;
default:
const ruleId = getAlertFieldValue(alert, ALERT_RULE_UUID);
const ruleLink = ruleId ? http.basePath.prepend(paths.observability.ruleDetails(ruleId)) : '';
return (
<CellTooltip
value={
<EuiLink data-test-subj="o11yCellRenderersLink" href={ruleLink}>
{value}
</EuiLink>
}
tooltipContent={ruleCategory}
/>
);
},
[RELATION_COL]: (value) => {
return <RelationCol alert={alert} parentAlert={parentAlert!} />;
},
[RELATED_ALERT_REASON]: (value) => {
const val = getAlertFieldValue(alert, ALERT_REASON);
return <EuiText size="s">{val}</EuiText>;
},
[RELATED_ACTIONS_COL]: (val) => {
return (
<EuiFlexGroup gutterSize="none">
<AlertActions {...props} />
</EuiFlexGroup>
);
},
[ALERT_CASE_IDS]: (value) => {
return <>{value}</>;
}
},
};
const val = getAlertFieldValue(alert, columnId);
return cellRenderers[columnId] ? cellRenderers[columnId](val) : <>{val}</>;
};

View file

@ -10,12 +10,12 @@ import {
ALERT_EVALUATION_VALUE,
ALERT_EVALUATION_THRESHOLD,
ALERT_DURATION,
ALERT_REASON,
ALERT_RULE_NAME,
ALERT_START,
ALERT_STATUS,
ALERT_INSTANCE_ID,
TAGS,
ALERT_REASON,
} from '@kbn/rule-data-utils';
import { i18n } from '@kbn/i18n';

View file

@ -7,11 +7,12 @@
import { SetOptional } from 'type-fest';
import type { AlertsTablePropsWithRef } from '@kbn/response-ops-alerts-table/types';
import type { ConfigSchema, ObservabilityRuleTypeRegistry } from '../..';
import type { ConfigSchema, ObservabilityRuleTypeRegistry, TopAlert } from '../..';
export interface ObservabilityAlertsTableContext {
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry;
config: ConfigSchema;
parentAlert?: TopAlert;
}
export type ObservabilityAlertsTableProps = SetOptional<

View file

@ -8,7 +8,6 @@
// TODO: https://github.com/elastic/kibana/issues/110905
import { PluginInitializer, PluginInitializerContext } from '@kbn/core/public';
import { lazy } from 'react';
import {
Plugin,
ObservabilityPublicPluginsStart,
@ -57,8 +56,6 @@ export { getCoreVitalsComponent } from './pages/overview/components/sections/ux/
export { ObservabilityAlertSearchBar } from './components/alert_search_bar/get_alert_search_bar_lazy';
export { DatePicker } from './pages/overview/components/date_picker';
export const LazyAlertsFlyout = lazy(() => import('./components/alerts_flyout/alerts_flyout'));
export type {
Stat,
Coordinates,

View file

@ -35,7 +35,7 @@ import { AlertFieldsTable } from '@kbn/alerts-ui-shared/src/alert_fields_table';
import { css } from '@emotion/react';
import { omit } from 'lodash';
import { BetaBadge } from '../../components/experimental_badge';
import { RelatedAlerts } from './components/related_alerts';
import { RelatedAlerts } from './components/related_alerts/related_alerts';
import { AlertDetailsSource } from './types';
import { SourceBar } from './components';
import { StatusBar } from './components/status_bar';
@ -305,7 +305,7 @@ export function AlertDetails() {
</>
),
'data-test-subj': 'relatedAlertsTab',
content: <RelatedAlerts alert={alertDetail?.formatted} />,
content: <RelatedAlerts alertData={alertDetail} />,
},
];
@ -342,15 +342,21 @@ export function AlertDetails() {
}}
data-test-subj="alertDetails"
>
<StatusBar alert={alertDetail?.formatted ?? null} alertStatus={alertStatus} />
<EuiSpacer size="l" />
<HeaderMenu />
<EuiTabbedContent
data-test-subj="alertDetailsTabbedContent"
tabs={tabs}
selectedTab={tabs.find((tab) => tab.id === activeTabId)}
onTabClick={(tab) => handleSetTabId(tab.id as TabId)}
/>
<CasesContext
owner={[observabilityFeatureId]}
permissions={userCasesPermissions}
features={{ alerts: { sync: false } }}
>
<StatusBar alert={alertDetail?.formatted ?? null} alertStatus={alertStatus} />
<EuiSpacer size="l" />
<HeaderMenu />
<EuiTabbedContent
data-test-subj="alertDetailsTabbedContent"
tabs={tabs}
selectedTab={tabs.find((tab) => tab.id === activeTabId)}
onTabClick={(tab) => handleSetTabId(tab.id as TabId)}
/>
</CasesContext>
</ObservabilityPageTemplate>
);
}

View file

@ -0,0 +1,32 @@
/*
* 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 { EuiHeaderLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useInspectorContext } from '@kbn/observability-shared-plugin/public';
import { useKibana } from '../../../utils/kibana_react';
export function InspectorHeaderLink() {
const {
services: { inspector },
} = useKibana();
const { inspectorAdapters } = useInspectorContext();
const inspect = () => {
inspector.open(inspectorAdapters);
};
return (
<EuiHeaderLink color="primary" onClick={inspect}>
{i18n.translate('xpack.observability.inspectButtonText', {
defaultMessage: 'Inspect',
})}
</EuiHeaderLink>
);
}

View file

@ -1,75 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '../../../utils/test_helper';
import { alertWithGroupsAndTags } from '../mock/alert';
import { useKibana } from '../../../utils/kibana_react';
import { kibanaStartMock } from '../../../utils/kibana_react.mock';
import { RelatedAlerts } from './related_alerts';
import { ObservabilityAlertsTable } from '../../../components/alerts_table/alerts_table_lazy';
import {
OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES,
observabilityAlertFeatureIds,
} from '../../../../common/constants';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
}));
jest.mock('../../../utils/kibana_react');
jest.mock('../../../components/alerts_table/alerts_table_lazy');
const mockAlertsTable = jest.mocked(ObservabilityAlertsTable).mockReturnValue(<div />);
jest.mock('@kbn/alerts-grouping', () => ({
AlertsGrouping: jest.fn().mockImplementation(({ children }) => <div>{children([])}</div>),
}));
const useKibanaMock = useKibana as jest.Mock;
const mockKibana = () => {
const services = kibanaStartMock.startContract().services;
services.spaces.getActiveSpace = jest
.fn()
.mockImplementation(() =>
Promise.resolve({ id: 'space-id', name: 'space-name', disabledFeatures: [] })
);
useKibanaMock.mockReturnValue({
services: {
...services,
http: {
basePath: {
prepend: jest.fn(),
},
},
},
});
};
describe('Related alerts', () => {
beforeEach(() => {
jest.clearAllMocks();
mockKibana();
});
it('should pass the correct configuration options to the alerts table', async () => {
render(<RelatedAlerts alert={alertWithGroupsAndTags} />);
expect(mockAlertsTable).toHaveBeenLastCalledWith(
expect.objectContaining({
id: 'xpack.observability.related.alerts.table',
ruleTypeIds: OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES,
consumers: observabilityAlertFeatureIds,
initialPageSize: 50,
renderAdditionalToolbarControls: expect.any(Function),
showInspectButton: true,
}),
expect.anything()
);
});
});

View file

@ -1,235 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useRef, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiImage,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { getPaddedAlertTimeRange } from '@kbn/observability-get-padded-alert-time-range-util';
import {
ALERT_END,
ALERT_GROUP,
ALERT_RULE_UUID,
ALERT_START,
ALERT_UUID,
TAGS,
} from '@kbn/rule-data-utils';
import { BoolQuery, Filter } from '@kbn/es-query';
import { AlertsGrouping } from '@kbn/alerts-grouping';
import { GroupingToolbarControls } from '../../../components/alerts_table/grouping/grouping_toolbar_controls';
import { ObservabilityFields } from '../../../../common/utils/alerting/types';
import {
OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES,
observabilityAlertFeatureIds,
} from '../../../../common/constants';
import {
getRelatedAlertKuery,
getSharedFields,
} from '../../../../common/utils/alerting/get_related_alerts_query';
import { ObservabilityAlertsTable, TopAlert } from '../../..';
import {
AlertSearchBarContainerState,
DEFAULT_STATE,
} from '../../../components/alert_search_bar/containers/state_container';
import { ObservabilityAlertSearchbarWithUrlSync } from '../../../components/alert_search_bar/alert_search_bar_with_url_sync';
import { renderGroupPanel } from '../../../components/alerts_table/grouping/render_group_panel';
import { getGroupStats } from '../../../components/alerts_table/grouping/get_group_stats';
import { getAggregationsByGroupingField } from '../../../components/alerts_table/grouping/get_aggregations_by_grouping_field';
import { DEFAULT_GROUPING_OPTIONS } from '../../../components/alerts_table/grouping/constants';
import { ACTIVE_ALERTS, ALERT_STATUS_FILTER } from '../../../components/alert_search_bar/constants';
import { AlertsByGroupingAgg } from '../../../components/alerts_table/types';
import {
alertSearchBarStateContainer,
Provider,
useAlertSearchBarStateContainer,
} from '../../../components/alert_search_bar/containers';
import { RELATED_ALERTS_TABLE_CONFIG_ID, SEARCH_BAR_URL_STORAGE_KEY } from '../../../constants';
import { useKibana } from '../../../utils/kibana_react';
import { buildEsQuery } from '../../../utils/build_es_query';
import { mergeBoolQueries } from '../../alerts/helpers/merge_bool_queries';
import icon from './assets/illustration_product_no_results_magnifying_glass.svg';
const ALERTS_PER_PAGE = 50;
const RELATED_ALERTS_SEARCH_BAR_ID = 'related-alerts-search-bar-o11y';
const ALERTS_TABLE_ID = 'xpack.observability.related.alerts.table';
interface Props {
alert?: TopAlert<ObservabilityFields>;
}
// TODO: Bring back setting default status filter as active
const defaultState: AlertSearchBarContainerState = { ...DEFAULT_STATE };
const DEFAULT_FILTERS: Filter[] = [];
export function InternalRelatedAlerts({ alert }: Props) {
const kibanaServices = useKibana().services;
const { http, notifications, dataViews } = kibanaServices;
const alertSearchBarStateProps = useAlertSearchBarStateContainer(SEARCH_BAR_URL_STORAGE_KEY, {
replace: false,
});
const [esQuery, setEsQuery] = useState<{ bool: BoolQuery }>();
const alertStart = alert?.fields[ALERT_START];
const alertEnd = alert?.fields[ALERT_END];
const alertId = alert?.fields[ALERT_UUID];
const tags = alert?.fields[TAGS];
const groups = alert?.fields[ALERT_GROUP];
const ruleId = alert?.fields[ALERT_RULE_UUID];
const sharedFields = getSharedFields(alert?.fields);
const kuery = getRelatedAlertKuery({ tags, groups, ruleId, sharedFields });
const defaultFilters = useRef<Filter[]>([
{
query: {
match_phrase: {
'kibana.alert.uuid': alertId,
},
},
meta: {
negate: true,
},
},
]);
useEffect(() => {
if (alertStart) {
const defaultTimeRange = getPaddedAlertTimeRange(alertStart, alertEnd);
alertSearchBarStateProps.onRangeFromChange(defaultTimeRange.from);
alertSearchBarStateProps.onRangeToChange(defaultTimeRange.to);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [alertStart, alertEnd]);
if (!kuery || !alert) return <EmptyState />;
return (
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiSpacer size="l" />
<ObservabilityAlertSearchbarWithUrlSync
appName={RELATED_ALERTS_SEARCH_BAR_ID}
onEsQueryChange={setEsQuery}
urlStorageKey={SEARCH_BAR_URL_STORAGE_KEY}
defaultFilters={defaultFilters.current}
disableLocalStorageSync={true}
defaultState={{
...defaultState,
kuery,
}}
/>
</EuiFlexItem>
<EuiFlexItem>
{esQuery && (
<AlertsGrouping<AlertsByGroupingAgg>
ruleTypeIds={OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES}
consumers={observabilityAlertFeatureIds}
defaultFilters={
ALERT_STATUS_FILTER[alertSearchBarStateProps.status ?? ACTIVE_ALERTS.status] ??
DEFAULT_FILTERS
}
from={alertSearchBarStateProps.rangeFrom}
to={alertSearchBarStateProps.rangeTo}
globalFilters={alertSearchBarStateProps.filters ?? DEFAULT_FILTERS}
globalQuery={{ query: alertSearchBarStateProps.kuery, language: 'kuery' }}
groupingId={RELATED_ALERTS_TABLE_CONFIG_ID}
defaultGroupingOptions={DEFAULT_GROUPING_OPTIONS}
getAggregationsByGroupingField={getAggregationsByGroupingField}
renderGroupPanel={renderGroupPanel}
getGroupStats={getGroupStats}
services={{
notifications,
dataViews,
http,
}}
>
{(groupingFilters) => {
const groupQuery = buildEsQuery({
filters: groupingFilters,
});
return (
<ObservabilityAlertsTable
id={ALERTS_TABLE_ID}
ruleTypeIds={OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES}
consumers={observabilityAlertFeatureIds}
query={mergeBoolQueries(esQuery, groupQuery)}
initialPageSize={ALERTS_PER_PAGE}
renderAdditionalToolbarControls={() => (
<GroupingToolbarControls
groupingId={RELATED_ALERTS_TABLE_CONFIG_ID}
ruleTypeIds={OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES}
/>
)}
showInspectButton
/>
);
}}
</AlertsGrouping>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}
const heights = {
tall: 490,
short: 250,
};
const panelStyle = {
maxWidth: 500,
};
function EmptyState() {
return (
<EuiPanel color="subdued" data-test-subj="relatedAlertsTabEmptyState">
<EuiFlexGroup style={{ height: heights.tall }} alignItems="center" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={true} style={panelStyle}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s">
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.observability.pages.alertDetails.relatedAlerts.empty.title"
defaultMessage="Problem loading related alerts"
/>
</h3>
</EuiTitle>
<p>
<FormattedMessage
id="xpack.observability.pages.alertDetails.relatedAlerts.empty.description"
defaultMessage="Due to an unexpected error, no related alerts can be found."
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiImage style={{ width: 200, height: 148 }} size="200" alt="" url={icon} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}
export function RelatedAlerts(props: Props) {
return (
<Provider value={alertSearchBarStateContainer}>
<InternalRelatedAlerts {...props} />
</Provider>
);
}

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EuiDataGridColumn } from '@elastic/eui';
import { ALERT_CASE_IDS, ALERT_RULE_NAME, ALERT_STATUS } from '@kbn/rule-data-utils';
import { i18n } from '@kbn/i18n';
export const RELATED_ALERT_REASON = 'relatedAlertReason';
export const RELATION_COL = 'relatedRelation';
export const RELATED_ACTIONS_COL = 'relatedActions';
export const getRelatedColumns = (): EuiDataGridColumn[] => {
return [
{
id: ALERT_STATUS,
displayAsText: i18n.translate('xpack.observability.alertsTGrid.statusColumnDescription', {
defaultMessage: 'Alert Status',
}),
initialWidth: 120,
isSortable: false,
actions: false,
},
{
id: ALERT_RULE_NAME,
displayAsText: i18n.translate('xpack.observability.alertsTGrid.ruleNameColumnDescription', {
defaultMessage: 'Rule name',
}),
initialWidth: 250,
isSortable: false,
actions: false,
},
{
id: RELATED_ALERT_REASON,
displayAsText: i18n.translate('xpack.observability.alertsTGrid.reasonDescription', {
defaultMessage: 'Reason',
}),
initialWidth: 400,
isSortable: false,
actions: false,
},
{
id: RELATION_COL,
displayAsText: i18n.translate('xpack.observability.alertsTGrid.relationColumnDescription', {
defaultMessage: 'Relation',
}),
initialWidth: 350,
isSortable: false,
actions: false,
},
{
id: ALERT_CASE_IDS,
displayAsText: i18n.translate('xpack.observability.alertsTGrid.caseIdsColumnDescription', {
defaultMessage: 'Attached cases',
}),
initialWidth: 150,
isSortable: false,
actions: false,
},
{
id: RELATED_ACTIONS_COL,
displayAsText: i18n.translate('xpack.observability.alertsTGrid.actionsColumnDescription', {
defaultMessage: 'Actions',
}),
initialWidth: 75,
isResizable: false,
isSortable: false,
actions: false,
},
];
};

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiLoadingChart } from '@elastic/eui';
import { RelatedAlertsTable } from './related_alerts_table';
import { AlertData } from '../../../../hooks/use_fetch_alert_detail';
interface Props {
alertData?: AlertData | null;
}
export function RelatedAlerts({ alertData }: Props) {
if (!alertData) {
return <EuiLoadingChart />;
}
return <RelatedAlertsTable alertData={alertData} />;
}

View file

@ -0,0 +1,98 @@
/*
* 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, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { ALERT_START, ALERT_UUID } from '@kbn/rule-data-utils';
import { AlertsTable } from '@kbn/response-ops-alerts-table';
import { SortOrder } from '@elastic/elasticsearch/lib/api/types';
import { getRelatedColumns } from './get_related_columns';
import { useBuildRelatedAlertsQuery } from '../../hooks/related_alerts/use_build_related_alerts_query';
import { AlertData } from '../../../../hooks/use_fetch_alert_detail';
import {
GetObservabilityAlertsTableProp,
ObservabilityAlertsTableContext,
observabilityFeatureId,
} from '../../../..';
import { usePluginContext } from '../../../../hooks/use_plugin_context';
import { useKibana } from '../../../../utils/kibana_react';
import { AlertsFlyoutBody } from '../../../../components/alerts_flyout/alerts_flyout_body';
import { AlertsFlyoutFooter } from '../../../../components/alerts_flyout/alerts_flyout_footer';
import { OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES } from '../../../../../common/constants';
import { AlertsTableCellValue } from '../../../../components/alerts_table/common/cell_value';
import { casesFeatureIdV2 } from '../../../../../common';
interface Props {
alertData: AlertData;
}
const columns = getRelatedColumns();
const initialSort: Array<Record<string, SortOrder>> = [
{
_score: 'desc',
},
{
[ALERT_START]: 'desc',
},
{
[ALERT_UUID]: 'desc',
},
];
const caseConfiguration: GetObservabilityAlertsTableProp<'casesConfiguration'> = {
featureId: casesFeatureIdV2,
owner: [observabilityFeatureId],
};
const RELATED_ALERTS_TABLE_ID = 'xpack.observability.alerts.relatedAlerts';
export function RelatedAlertsTable({ alertData }: Props) {
const { formatted: alert } = alertData;
const esQuery = useBuildRelatedAlertsQuery({ alert });
const { observabilityRuleTypeRegistry, config } = usePluginContext();
const services = useKibana().services;
return (
<EuiFlexGroup direction="column" gutterSize="m">
<EuiSpacer size="s" />
<AlertsTable<ObservabilityAlertsTableContext>
id={RELATED_ALERTS_TABLE_ID}
query={esQuery}
columns={columns}
ruleTypeIds={OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES}
minScore={1.5}
trackScores={true}
initialSort={initialSort}
casesConfiguration={caseConfiguration}
additionalContext={{
observabilityRuleTypeRegistry,
config,
parentAlert: alert,
}}
toolbarVisibility={{
showSortSelector: false,
}}
renderCellValue={AlertsTableCellValue}
renderFlyoutBody={AlertsFlyoutBody}
renderFlyoutFooter={AlertsFlyoutFooter}
showAlertStatusWithFlapping
services={services}
gridStyle={{
border: 'horizontal',
header: 'underline',
cellPadding: 'l',
fontSize: 'm',
}}
rowHeightsOptions={{
defaultHeight: 'auto',
}}
height="600px"
/>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,79 @@
/*
* 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 React from 'react';
import {
ALERT_INSTANCE_ID,
ALERT_RULE_TAGS,
ALERT_RULE_UUID,
ALERT_RULE_NAME,
} from '@kbn/rule-data-utils';
import { intersection } from 'lodash';
import { EuiDescriptionList, EuiLink } from '@elastic/eui';
import type { Alert } from '@kbn/alerting-types';
import { TagsList } from '@kbn/observability-shared-plugin/public';
import type { TopAlert } from '../../../..';
import { getAlertFieldValue } from '../../../../components/alerts_table/common/cell_value';
import { paths } from '../../../../../common/locators/paths';
import { useKibana } from '../../../../utils/kibana_react';
export function RelationCol({ alert, parentAlert }: { alert: Alert; parentAlert: TopAlert }) {
const {
services: { http },
} = useKibana();
const instanceId = getAlertFieldValue(alert, ALERT_INSTANCE_ID);
const tags = getAlertFieldValue(alert, ALERT_RULE_TAGS);
const ruleId = getAlertFieldValue(alert, ALERT_RULE_UUID);
const ruleName = getAlertFieldValue(alert, ALERT_RULE_NAME);
const ruleLink = ruleId ? http.basePath.prepend(paths.observability.ruleDetails(ruleId)) : '';
const hasSomeRelationWithInstance =
intersection(parentAlert.fields[ALERT_INSTANCE_ID].split(','), instanceId.split(',')).length >
0;
const hasSomeRelationWithTags =
intersection(parentAlert.fields[ALERT_RULE_TAGS], tags.split(',')).length > 0;
const hasRelationWithRule = ruleId === parentAlert.fields[ALERT_RULE_UUID];
const relations = [];
if (hasSomeRelationWithInstance) {
relations.push({
title: i18n.translate('xpack.observability.columns.groupsBadgeLabel', {
defaultMessage: 'Groups',
}),
description: instanceId,
});
}
if (hasSomeRelationWithTags) {
relations.push({
title: i18n.translate('xpack.observability.columns.tagsBadgeLabel', {
defaultMessage: 'Tags',
}),
description: (
<TagsList tags={(alert[ALERT_RULE_TAGS] as string[]) || []} ignoreEmpty color="default" />
),
});
}
if (hasRelationWithRule) {
relations.push({
title: i18n.translate('xpack.observability.columns.ruleBadgeLabel', {
defaultMessage: 'Rule',
}),
description: (
<EuiLink href={ruleLink} data-test-subj="obsAlertDetailsRelatedAlertsRelationRuleLink">
{ruleName}
</EuiLink>
),
});
}
return (
<EuiDescriptionList
type="column"
listItems={relations}
css={{ maxWidth: '400px' }}
compressed
/>
);
}

View file

@ -0,0 +1,186 @@
/*
* 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import {
ALERT_END,
ALERT_GROUP,
ALERT_INSTANCE_ID,
ALERT_RULE_TAGS,
ALERT_RULE_UUID,
ALERT_START,
ALERT_STATUS,
ALERT_UUID,
} from '@kbn/rule-data-utils';
import dedent from 'dedent';
import moment from 'moment';
import { ObservabilityFields } from '../../../../../common/utils/alerting/types';
import { TopAlert } from '../../../../typings/alerts';
interface Props {
alert: TopAlert<ObservabilityFields>;
}
export function useBuildRelatedAlertsQuery({ alert }: Props): QueryDslQueryContainer {
const groups = alert.fields[ALERT_GROUP];
const shouldGroups: QueryDslQueryContainer[] = [];
groups?.forEach(({ field, value }) => {
if (!field || !value) return;
shouldGroups.push({
bool: {
boost: 2.0,
must: [
{ term: { 'kibana.alert.group.field': field } },
{ term: { 'kibana.alert.group.value': value } },
],
},
});
});
const shouldRule = alert.fields[ALERT_RULE_UUID]
? [
{
term: {
'kibana.alert.rule.uuid': {
value: alert.fields[ALERT_RULE_UUID],
boost: 1.0,
},
},
},
]
: [];
const startDate = moment(alert.fields[ALERT_START]);
const endDate = alert.fields[ALERT_END] ? moment(alert.fields[ALERT_END]) : undefined;
const tags = alert.fields[ALERT_RULE_TAGS] ?? [];
const instanceId = alert.fields[ALERT_INSTANCE_ID]?.split(',') ?? [];
return {
bool: {
filter: [
{
range: {
[ALERT_START]: {
gte: startDate.clone().subtract(1, 'days').toISOString(),
lte: startDate.clone().add(1, 'days').toISOString(),
},
},
},
],
must_not: [
{
term: {
[ALERT_UUID]: {
value: alert.fields[ALERT_UUID],
},
},
},
],
should: [
...shouldGroups,
...shouldRule,
{
term: {
[ALERT_STATUS]: {
value: alert.fields[ALERT_STATUS],
boost: 2,
},
},
},
{
function_score: {
functions: [
{
exp: {
[ALERT_START]: {
origin: startDate.toISOString(),
scale: '10m',
offset: '10m',
decay: 0.5,
},
},
weight: 10,
},
...(endDate
? [
{
exp: {
[ALERT_END]: {
origin: endDate.toISOString(),
scale: '10m',
offset: '10m',
decay: 0.5,
},
},
weight: 10,
},
]
: []),
{
script_score: {
script: {
source: dedent(`
double jaccardSimilarity(Set a, Set b) {
if (a.size() == 0 || b.size() == 0) return 0.0;
Set intersection = new HashSet(a);
intersection.retainAll(b);
Set union = new HashSet(a);
union.addAll(b);
return (double) intersection.size() / union.size();
}
Set tagsQuery = new HashSet(params.tags);
Set tagsDoc = new HashSet(doc.containsKey("kibana.alert.rule.tags") && !doc.get("kibana.alert.rule.tags").empty ? doc.get("kibana.alert.rule.tags") : []);
return 1.0 + jaccardSimilarity(tagsQuery, tagsDoc);
`),
params: {
tags,
},
},
},
weight: 2,
},
{
script_score: {
script: {
source: dedent(`
double jaccardSimilarity(Set a, Set b) {
if (a.size() == 0 || b.size() == 0) return 0.0;
Set intersection = new HashSet(a);
intersection.retainAll(b);
Set union = new HashSet(a);
union.addAll(b);
return (double) intersection.size() / union.size();
}
Set instanceIdQuery = new HashSet(params.instanceId);
Set instanceIdDoc = new HashSet();
if (doc.containsKey('kibana.alert.instance.id')) {
String instanceIdStr = doc['kibana.alert.instance.id'].value;
if (instanceIdStr != null && !instanceIdStr.isEmpty()) {
StringTokenizer tokenizer = new StringTokenizer(instanceIdStr, ',');
while (tokenizer.hasMoreTokens()) {
instanceIdDoc.add(tokenizer.nextToken());
}
}
}
return 1.0 + jaccardSimilarity(instanceIdQuery, instanceIdDoc);
`),
params: {
instanceId,
},
},
},
weight: 5,
},
],
score_mode: 'multiply',
boost_mode: 'sum',
},
},
],
},
};
}

View file

@ -5,14 +5,15 @@
* 2.0.
*/
import React, { Suspense, useState } from 'react';
import React, { useState } from 'react';
import { CasesPermissions } from '@kbn/cases-plugin/common';
import AlertsFlyout from '../../../components/alerts_flyout/alerts_flyout';
import { observabilityFeatureId } from '../../../../common';
import { useKibana } from '../../../utils/kibana_react';
import { usePluginContext } from '../../../hooks/use_plugin_context';
import { useFetchAlertDetail } from '../../../hooks/use_fetch_alert_detail';
import { useFetchAlertData } from '../../../hooks/use_fetch_alert_data';
import { LazyAlertsFlyout, ObservabilityAlertsTable } from '../../..';
import { ObservabilityAlertsTable } from '../../..';
import { CASES_PATH, paths } from '../../../../common/locators/paths';
export interface CasesProps {
@ -67,13 +68,11 @@ export function Cases({ permissions }: CasesProps) {
/>
{alertDetail && selectedAlertId !== '' && !alertLoading ? (
<Suspense fallback={null}>
<LazyAlertsFlyout
alert={alertDetail.raw}
observabilityRuleTypeRegistry={observabilityRuleTypeRegistry}
onClose={handleFlyoutClose}
/>
</Suspense>
<AlertsFlyout
alert={alertDetail.raw}
observabilityRuleTypeRegistry={observabilityRuleTypeRegistry}
onClose={handleFlyoutClose}
/>
) : null}
</>
);

View file

@ -12,10 +12,10 @@ import {
} from '@kbn/deeplinks-observability';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public';
import { usePluginContext } from '../../../../hooks/use_plugin_context';
import { useKibana } from '../../../../utils/kibana_react';
// FIXME: import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public'
import HeaderMenuPortal from './header_menu_portal';
import { InspectorHeaderLink } from '../../../alert_details/components/inspector_header_link';
export function HeaderMenu(): React.ReactElement | null {
const { share, theme, http } = useKibana().services;
@ -49,6 +49,7 @@ export function HeaderMenu(): React.ReactElement | null {
defaultMessage: 'Annotations',
})}
</EuiHeaderLink>
<InspectorHeaderLink />
</EuiHeaderLinks>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render } from '@testing-library/react';
import React from 'react';
import HeaderMenuPortal from './header_menu_portal';
import { themeServiceMock } from '@kbn/core/public/mocks';
describe('HeaderMenuPortal', () => {
describe('when unmounted', () => {
it('calls setHeaderActionMenu with undefined', () => {
const setHeaderActionMenu = jest.fn();
const theme$ = themeServiceMock.createTheme$();
const { unmount } = render(
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu} theme$={theme$}>
test
</HeaderMenuPortal>
);
unmount();
expect(setHeaderActionMenu).toHaveBeenCalledWith(undefined);
});
});
});

View file

@ -1,43 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ReactNode, useEffect, useMemo } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { AppMountParameters } from '@kbn/core/public';
import { useKibana } from '../../../../utils/kibana_react';
export interface HeaderMenuPortalProps {
children: ReactNode;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
theme$: AppMountParameters['theme$'];
}
// eslint-disable-next-line import/no-default-export
export default function HeaderMenuPortal({
children,
setHeaderActionMenu,
theme$,
}: HeaderMenuPortalProps) {
const { i18n } = useKibana().services;
const portalNode = useMemo(() => createHtmlPortalNode(), []);
useEffect(() => {
setHeaderActionMenu((element) => {
const mount = toMountPoint(<OutPortal node={portalNode} />, {
...{ theme: { theme$ }, i18n },
});
return mount(element);
});
return () => {
portalNode.unmount();
setHeaderActionMenu(undefined);
};
}, [portalNode, setHeaderActionMenu, i18n, theme$]);
return <InPortal node={portalNode}>{children}</InPortal>;
}

View file

@ -71,6 +71,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import type { StreamsPluginStart, StreamsPluginSetup } from '@kbn/streams-plugin/public';
import { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public';
import { Start as InspectorPluginStart } from '@kbn/inspector-plugin/public';
import { observabilityAppId, observabilityFeatureId } from '../common';
import {
ALERTS_PATH,
@ -165,6 +166,7 @@ export interface ObservabilityPublicPluginsStart {
toastNotifications: ToastsStart;
streams?: StreamsPluginStart;
fieldsMetadata: FieldsMetadataPublicStart;
inspector: InspectorPluginStart;
}
export type ObservabilityPublicStart = ReturnType<Plugin['start']>;

View file

@ -78,7 +78,6 @@
"@kbn/alerting-rule-utils",
"@kbn/ui-theme",
"@kbn/core-application-common",
"@kbn/securitysolution-ecs",
"@kbn/alerts-as-data-utils",
"@kbn/datemath",
"@kbn/logs-shared-plugin",

View file

@ -120,11 +120,12 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
await retry.tryForTime(3 * 60 * 1000, async () => {
await page.click(byTestId('querySubmitButton'));
await page.waitForTimeout(5000);
const alerts = await page.waitForSelector(`text=1 Alert`, { timeout: 5 * 1000 });
expect(await alerts.isVisible()).toBe(true);
const text = await page.textContent(`${byTestId('dataGridRowCell')} .euiLink`);
const text = await page.getByTestId('o11yGetRenderCellValueLink').textContent();
expect(text).toBe(reasonMessage);
});

View file

@ -47,6 +47,23 @@ describe('CellValue', () => {
expect(getByText('value1')).toBeInTheDocument();
});
it('should handle a number value', () => {
const alert: Alert = {
_id: '_id',
_index: '_index',
field1: 123,
};
const columnId = 'field1';
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
</TestProviders>
);
expect(getByText('123')).toBeInTheDocument();
});
it('should handle array of booleans', () => {
const alert: Alert = {
_id: '_id',

View file

@ -30,7 +30,7 @@ export interface CellValueProps {
*/
export const CellValue = memo(({ alert, columnId }: CellValueProps) => {
const displayValue: string | null = useMemo(() => {
const cellValues: string | JsonValue[] = alert[columnId];
const cellValues: string | number | JsonValue[] = alert[columnId];
// Displays string as is.
// Joins values of array with more than one element.
@ -38,6 +38,8 @@ export const CellValue = memo(({ alert, columnId }: CellValueProps) => {
// Return the string of the value otherwise.
if (typeof cellValues === 'string') {
return cellValues;
} else if (typeof cellValues === 'number') {
return cellValues.toString();
} else if (Array.isArray(cellValues)) {
if (cellValues.length > 1) {
return cellValues.join(', ');

View file

@ -6,6 +6,7 @@
*/
import expect from '@kbn/expect';
import { ALERT_START } from '@kbn/rule-data-utils';
import type { RuleRegistrySearchResponse } from '@kbn/rule-registry-plugin/common';
import type { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
@ -692,6 +693,194 @@ export default ({ getService }: FtrProviderContext) => {
});
});
describe('observability', () => {
const apmRuleTypeIds = ['apm.transaction_error_rate', 'apm.error_rate'];
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts');
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts');
});
it('should omit alerts when score is less than min score', async () => {
const query = {
bool: {
filter: [],
should: [
{
function_score: {
functions: [
{
exp: {
[ALERT_START]: {
origin: '2021-10-19T14:58:08.539Z',
scale: '10m',
offset: '10m',
decay: 0.5,
},
},
weight: 10,
},
],
boost_mode: 'sum',
},
},
],
must: [],
must_not: [],
},
};
const resultWithoutMinScore = await secureSearch.send<RuleRegistrySearchResponse>({
supertestWithoutAuth,
auth: {
username: obsOnlySpacesAll.username,
password: obsOnlySpacesAll.password,
},
referer: 'test',
kibanaVersion,
internalOrigin: 'Kibana',
options: {
ruleTypeIds: apmRuleTypeIds,
query,
},
strategy: 'privateRuleRegistryAlertsSearchStrategy',
space: 'default',
});
expect(resultWithoutMinScore.rawResponse.hits.total).to.eql(9);
validateRuleTypeIds(resultWithoutMinScore, apmRuleTypeIds);
const resultWithMinScore = await secureSearch.send<RuleRegistrySearchResponse>({
supertestWithoutAuth,
auth: {
username: obsOnlySpacesAll.username,
password: obsOnlySpacesAll.password,
},
referer: 'test',
kibanaVersion,
internalOrigin: 'Kibana',
options: {
ruleTypeIds: apmRuleTypeIds,
query,
minScore: 11,
},
strategy: 'privateRuleRegistryAlertsSearchStrategy',
space: 'default',
});
const allScores = resultWithMinScore.rawResponse.hits.hits.map((hit) => {
return hit._score;
});
allScores.forEach((score) => {
if (score) {
expect(score >= 11).to.be(true);
} else {
throw new Error('Score is null');
}
});
expect(resultWithMinScore.rawResponse.hits.total).to.eql(8);
validateRuleTypeIds(resultWithMinScore, apmRuleTypeIds);
});
it('should track scores alerts when sorting when trackScores is true', async () => {
const query = {
bool: {
filter: [],
should: [
{
function_score: {
functions: [
{
exp: {
[ALERT_START]: {
origin: '2021-10-19T14:58:08.539Z',
scale: '10m',
offset: '10m',
decay: 0.5,
},
},
weight: 10,
},
],
boost_mode: 'sum',
},
},
],
must: [],
must_not: [],
},
};
const resultWithoutTrackScore = await secureSearch.send<RuleRegistrySearchResponse>({
supertestWithoutAuth,
auth: {
username: obsOnlySpacesAll.username,
password: obsOnlySpacesAll.password,
},
referer: 'test',
kibanaVersion,
internalOrigin: 'Kibana',
options: {
ruleTypeIds: apmRuleTypeIds,
query,
sort: [
{
'kibana.alert.start': {
order: 'desc',
},
},
],
minScore: 11,
},
strategy: 'privateRuleRegistryAlertsSearchStrategy',
space: 'default',
});
resultWithoutTrackScore.rawResponse.hits.hits.forEach((hit) => {
expect(hit._score).to.be(null);
});
validateRuleTypeIds(resultWithoutTrackScore, apmRuleTypeIds);
const resultWithTrackScore = await secureSearch.send<RuleRegistrySearchResponse>({
supertestWithoutAuth,
auth: {
username: obsOnlySpacesAll.username,
password: obsOnlySpacesAll.password,
},
referer: 'test',
kibanaVersion,
internalOrigin: 'Kibana',
options: {
ruleTypeIds: apmRuleTypeIds,
query,
sort: [
{
'kibana.alert.start': {
order: 'desc',
},
},
],
minScore: 11,
trackScores: true,
},
strategy: 'privateRuleRegistryAlertsSearchStrategy',
space: 'default',
});
resultWithTrackScore.rawResponse.hits.hits.forEach((hit) => {
expect(hit._score).not.to.be(null);
expect(hit._score).to.be(11);
});
validateRuleTypeIds(resultWithTrackScore, apmRuleTypeIds);
});
});
describe('discover', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts');