Skip to content

Commit

Permalink
[RCA] Start investigation from alert details page (elastic#190307)
Browse files Browse the repository at this point in the history
Resolves elastic#190320 and
elastic#190396

- Start investigation from Custom threshold alert details page
- Go to ongoing investigation instead of creating new one if one already
exists
- Initial investigation status is set as `ongoing`
- Investigation origin is set as `alert`

"Start investigation" is hidden for other alert types and when
investigate plugin is disabled.

### Testing
- Add the following in `kibana.dev.yml`
```
xpack.investigate.enabled: true
xpack.investigateApp.enabled: true
```
- Create Custom threshold rule
- Open Custom threshold alert details page
- Click on "Start investigation"
- Verify that a new saved object is created for the investigation


https://github.com/user-attachments/assets/6dfe8a5f-287b-4cc5-92ae-e4c315c7420b

---------

Co-authored-by: kibanamachine <[email protected]>
Co-authored-by: Kevin Delemme <[email protected]>
  • Loading branch information
3 people authored Aug 14, 2024
1 parent abc8495 commit 95736fb
Show file tree
Hide file tree
Showing 26 changed files with 477 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,11 @@ export type {
export { mergePlainObjects } from './utils/merge_plain_objects';

export { InvestigateWidgetColumnSpan } from './types';

export type { CreateInvestigationInput, CreateInvestigationResponse } from './schema/create';
export type { GetInvestigationParams } from './schema/get';
export type { FindInvestigationsResponse } from './schema/find';

export { createInvestigationParamsSchema } from './schema/create';
export { getInvestigationParamsSchema } from './schema/get';
export { findInvestigationsParamsSchema } from './schema/find';
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
*/
import * as t from 'io-ts';
import { investigationResponseSchema } from './investigation';
import { alertOriginSchema, blankOriginSchema } from './origin';

const createInvestigationParamsSchema = t.type({
body: t.type({
id: t.string,
title: t.string,
parameters: t.type({
params: t.type({
timeRange: t.type({ from: t.number, to: t.number }),
}),
origin: t.union([alertOriginSchema, blankOriginSchema]),
}),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { investigationResponseSchema } from './investigation';

const findInvestigationsParamsSchema = t.partial({
query: t.partial({
alertId: t.string,
page: t.string,
perPage: t.string,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
* 2.0.
*/
import * as t from 'io-ts';
import { alertOriginSchema, blankOriginSchema } from './origin';

const investigationResponseSchema = t.type({
id: t.string,
title: t.string,
createdAt: t.number,
createdBy: t.string,
parameters: t.type({
params: t.type({
timeRange: t.type({ from: t.number, to: t.number }),
}),
origin: t.union([alertOriginSchema, blankOriginSchema]),
status: t.union([t.literal('ongoing'), t.literal('closed')]),
});

type InvestigationResponse = t.OutputOf<typeof investigationResponseSchema>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* 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 * as t from 'io-ts';

const blankOriginSchema = t.type({ type: t.literal('blank') });
const alertOriginSchema = t.type({ type: t.literal('alert'), id: t.string });

type AlertOrigin = t.OutputOf<typeof alertOriginSchema>;
type BlankOrigin = t.OutputOf<typeof blankOriginSchema>;

export { alertOriginSchema, blankOriginSchema };

export type { AlertOrigin, BlankOrigin };
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const investigationKeys = {
all: ['investigation'] as const,
list: (params: { page: number; perPage: number }) =>
[...investigationKeys.all, 'list', params] as const,
fetch: (params: { id: string }) => [...investigationKeys.all, 'fetch', params] as const,
};

export type InvestigationKeys = typeof investigationKeys;
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { useQuery } from '@tanstack/react-query';
import { FindInvestigationsResponse } from '../../common/schema/find';
import { FindInvestigationsResponse } from '@kbn/investigate-plugin/common';
import { investigationKeys } from './query_key_factory';
import { useKibana } from './use_kibana';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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 { useQuery } from '@tanstack/react-query';
import { BASE_RAC_ALERTS_API_PATH, EcsFieldsResponse } from '@kbn/rule-registry-plugin/common';
import { useKibana } from './use_kibana';

export interface AlertParams {
id: string;
}

export interface UseFetchAlertResponse {
isInitialLoading: boolean;
isLoading: boolean;
isRefetching: boolean;
isSuccess: boolean;
isError: boolean;
data: EcsFieldsResponse | undefined | null;
}

export function useFetchAlert({ id }: AlertParams): UseFetchAlertResponse {
const {
core: {
http,
notifications: { toasts },
},
} = useKibana();

const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({
queryKey: ['fetchAlert', id],
queryFn: async ({ signal }) => {
return await http.get<EcsFieldsResponse>(BASE_RAC_ALERTS_API_PATH, {
query: {
id,
},
signal,
});
},
cacheTime: 0,
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
if (String(error) === 'Error: Forbidden') {
return false;
}

return failureCount < 3;
},
onError: (error: Error) => {
toasts.addError(error, {
title: 'Something went wrong while fetching alert',
});
},
enabled: Boolean(id),
});

return {
data,
isInitialLoading,
isLoading,
isRefetching,
isSuccess,
isError,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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 { useQuery } from '@tanstack/react-query';
import { GetInvestigationResponse } from '@kbn/investigate-plugin/common/schema/get';
import { investigationKeys } from './query_key_factory';
import { useKibana } from './use_kibana';

export interface FetchInvestigationParams {
id: string;
}

export interface UseFetchInvestigationResponse {
isInitialLoading: boolean;
isLoading: boolean;
isRefetching: boolean;
isSuccess: boolean;
isError: boolean;
data: GetInvestigationResponse | undefined;
}

export function useFetchInvestigation({
id,
}: FetchInvestigationParams): UseFetchInvestigationResponse {
const {
core: {
http,
notifications: { toasts },
},
} = useKibana();

const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({
queryKey: investigationKeys.fetch({ id }),
queryFn: async ({ signal }) => {
return await http.get<GetInvestigationResponse>(`/api/observability/investigations/${id}`, {
version: '2023-10-31',
signal,
});
},
cacheTime: 0,
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
if (String(error) === 'Error: Forbidden') {
return false;
}

return failureCount < 3;
},
onError: (error: Error) => {
toasts.addError(error, {
title: 'Something went wrong while fetching Investigation',
});
},
});

return {
data,
isInitialLoading,
isLoading,
isRefetching,
isSuccess,
isError,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
* 2.0.
*/

import { EuiButton } from '@elastic/eui';
import { EuiButton, EuiButtonEmpty, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ALERT_RULE_CATEGORY } from '@kbn/rule-data-utils/src/default_alerts_as_data';
import { AlertOrigin } from '@kbn/investigate-plugin/common/schema/origin';
import { paths } from '../../../common/paths';
import { useKibana } from '../../hooks/use_kibana';
import { useFetchInvestigation } from '../../hooks/use_get_investigation_details';
import { useInvestigateParams } from '../../hooks/use_investigate_params';
import { useFetchAlert } from '../../hooks/use_get_alert_details';
import { InvestigationDetails } from './components/investigation_details';

export function InvestigationDetailsPage() {
Expand All @@ -22,8 +27,46 @@ export function InvestigationDetailsPage() {
},
} = useKibana();

const {
path: { id },
} = useInvestigateParams('/{id}');

const ObservabilityPageTemplate = observabilityShared.navigation.PageTemplate;

const {
data: investigationDetails,
isLoading: isFetchInvestigationLoading,
isError: isFetchInvestigationError,
} = useFetchInvestigation({ id });

const alertId = investigationDetails ? (investigationDetails.origin as AlertOrigin).id : '';

const {
data: alertDetails,
isLoading: isFetchAlertLoading,
isError: isFetchAlertError,
} = useFetchAlert({ id: alertId });

if (isFetchInvestigationLoading || isFetchAlertLoading) {
return (
<h1>
{i18n.translate('xpack.investigateApp.fetchInvestigation.loadingLabel', {
defaultMessage: 'Loading...',
})}
</h1>
);
}

if (isFetchInvestigationError || isFetchAlertError) {
return (
<h1>
{i18n.translate('xpack.investigateApp.fetchInvestigation.errorLabel', {
defaultMessage: 'Error while fetching investigation',
})}
</h1>
);
}

return (
<ObservabilityPageTemplate
pageHeader={{
Expand All @@ -40,12 +83,26 @@ export function InvestigationDetailsPage() {
}),
},
],
pageTitle: i18n.translate('xpack.investigateApp.detailsPage.title', {
defaultMessage: 'New investigation',
}),
pageTitle: (
<>
{alertDetails && (
<EuiButtonEmpty
data-test-subj="investigationDetailsAlertLink"
iconType="arrowLeft"
size="xs"
href={basePath.prepend(`/app/observability/alerts/${alertId}`)}
>
<EuiText size="s">
{`[Alert] ${alertDetails?.[ALERT_RULE_CATEGORY]} breached`}
</EuiText>
</EuiButtonEmpty>
)}
{investigationDetails && <div>{investigationDetails.title}</div>}
</>
),
rightSideItems: [
<EuiButton fill data-test-subj="investigateAppInvestigateDetailsPageEscalateButton">
{i18n.translate('xpack.investigateApp.investigateDetailsPage.escalateButtonLabel', {
<EuiButton fill data-test-subj="investigationDetailsEscalateButton">
{i18n.translate('xpack.investigateApp.investigationDetails.escalateButtonLabel', {
defaultMessage: 'Escalate',
})}
</EuiButton>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@
* 2.0.
*/

import { alertOriginSchema, blankOriginSchema } from '@kbn/investigate-plugin/common/schema/origin';
import * as t from 'io-ts';

export const investigationSchema = t.type({
id: t.string,
title: t.string,
createdAt: t.number,
createdBy: t.string,
parameters: t.type({
params: t.type({
timeRange: t.type({ from: t.number, to: t.number }),
}),
origin: t.union([alertOriginSchema, blankOriginSchema]),
status: t.union([t.literal('ongoing'), t.literal('closed')]),
});

export type Investigation = t.TypeOf<typeof investigationSchema>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
* 2.0.
*/

import { findInvestigationsParamsSchema } from '../../common/schema/find';
import { createInvestigationParamsSchema } from '../../common/schema/create';
import { createInvestigationParamsSchema } from '@kbn/investigate-plugin/common';
import { findInvestigationsParamsSchema } from '@kbn/investigate-plugin/common';
import { getInvestigationParamsSchema } from '@kbn/investigate-plugin/common';
import { createInvestigation } from '../services/create_investigation';
import { investigationRepositoryFactory } from '../services/investigation_repository';
import { createInvestigateAppServerRoute } from './create_investigate_app_server_route';
import { findInvestigations } from '../services/find_investigations';
import { getInvestigationParamsSchema } from '../../common/schema/get';
import { getInvestigation } from '../services/get_investigation';

const createInvestigationRoute = createInvestigateAppServerRoute({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ export const investigation: SavedObjectsType = {
name: SO_INVESTIGATION_TYPE,
hidden: false,
namespaceType: 'multiple-isolated',
switchToModelVersionAt: '8.10.0',
mappings: {
dynamic: false,
properties: {
id: { type: 'keyword' },
title: { type: 'text' },
origin: {
properties: {
type: { type: 'keyword' },
id: { type: 'keyword' },
},
},
status: { type: 'keyword' },
},
},
management: {
Expand Down
Loading

0 comments on commit 95736fb

Please sign in to comment.