From 6ef9f6c640a86180e92945b1e729ab11709cad3b Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 10 Sep 2024 06:56:56 -0400 Subject: [PATCH] feat(rca): create and edit investigation form in flyout (#192208) --- .../investigate_app/kibana.jsonc | 1 - .../fields/status_field.tsx | 60 ++++++ .../investigation_edit_form.tsx | 183 ++++++++++++++++++ .../investigation_not_found.tsx | 34 ++++ .../public/hooks/use_create_investigation.tsx | 51 +++++ .../public/hooks/use_fetch_investigation.ts | 5 +- .../items/esql_item/register_esql_item.tsx | 70 +------ .../esql_widget_preview.tsx | 3 - .../investigation_details.tsx | 80 +++++++- .../investigation_header.tsx | 45 +++++ .../details/investigation_details_page.tsx | 97 +--------- .../pages/list/investigation_list_page.tsx | 13 +- .../investigate_app/tsconfig.json | 2 - 13 files changed, 467 insertions(+), 177 deletions(-) create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/fields/status_field.tsx create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/investigation_edit_form.tsx create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/components/investigation_not_found/investigation_not_found.tsx create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/hooks/use_create_investigation.tsx create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_header/investigation_header.tsx diff --git a/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc b/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc index c7e860a047366f..ecfe77b5a05846 100644 --- a/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc +++ b/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc @@ -24,7 +24,6 @@ "esql", "kibanaReact", "kibanaUtils", - "esqlDataGrid", ], "optionalPlugins": [], "extraPublicDirs": [] diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/fields/status_field.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/fields/status_field.tsx new file mode 100644 index 00000000000000..aa3f071b36dec4 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/fields/status_field.tsx @@ -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 { EuiIcon, EuiFormRow, EuiComboBox } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { InvestigationForm } from '../investigation_edit_form'; + +const I18N_STATUS_LABEL = i18n.translate( + 'xpack.investigateApp.investigationEditForm.span.statusLabel', + { defaultMessage: 'Status' } +); + +const options = [ + { + label: 'Ongoing', + value: 'ongoing', + prepend: , + }, + { + label: 'Closed', + value: 'closed', + prepend: , + }, +]; + +export function StatusField() { + const { control, getFieldState } = useFormContext(); + + return ( + + ( + option.value === field.value)} + onChange={(selected) => { + return field.onChange(selected[0].value); + }} + singleSelection={{ asPlainText: true }} + /> + )} + /> + + ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/investigation_edit_form.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/investigation_edit_form.tsx new file mode 100644 index 00000000000000..5ea7486c2f6123 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_edit_form/investigation_edit_form.tsx @@ -0,0 +1,183 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiLoadingSpinner, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { pick } from 'lodash'; +import React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { v4 as uuidv4 } from 'uuid'; +import { useCreateInvestigation } from '../../hooks/use_create_investigation'; +import { useFetchInvestigation } from '../../hooks/use_fetch_investigation'; +import { useUpdateInvestigation } from '../../hooks/use_update_investigation'; +import { InvestigationNotFound } from '../investigation_not_found/investigation_not_found'; +import { StatusField } from './fields/status_field'; +import { useKibana } from '../../hooks/use_kibana'; +import { paths } from '../../../common/paths'; + +export interface InvestigationForm { + title: string; + status: 'ongoing' | 'closed'; +} + +interface Props { + investigationId?: string; + onClose: () => void; +} + +export function InvestigationEditForm({ investigationId, onClose }: Props) { + const { + core: { + http: { basePath }, + application: { navigateToUrl }, + }, + } = useKibana(); + const isEditing = Boolean(investigationId); + + const { + data: investigation, + isLoading, + isError, + refetch, + } = useFetchInvestigation({ id: investigationId }); + + const { mutateAsync: updateInvestigation } = useUpdateInvestigation(); + const { mutateAsync: createInvestigation } = useCreateInvestigation(); + + const methods = useForm({ + defaultValues: { title: 'New investigation', status: 'ongoing' }, + values: investigation ? pick(investigation, ['title', 'status']) : undefined, + mode: 'all', + }); + + if (isError) { + return ; + } + + if (isEditing && (isLoading || !investigation)) { + return ; + } + + const onSubmit = async (data: InvestigationForm) => { + if (isEditing) { + await updateInvestigation({ + investigationId: investigationId!, + payload: { title: data.title, status: data.status }, + }); + refetch(); + onClose(); + } else { + const resp = await createInvestigation({ + id: uuidv4(), + title: data.title, + params: { + timeRange: { + from: new Date(new Date().getTime() - 30 * 60 * 1000).getTime(), + to: new Date().getTime(), + }, + }, + origin: { + type: 'blank', + }, + }); + navigateToUrl(basePath.prepend(paths.investigationDetails(resp.id))); + } + }; + + return ( + +
+ onClose()} size="s"> + + +

+ {isEditing + ? i18n.translate('xpack.investigateApp.investigationDetailsPage.h2.editLabel', { + defaultMessage: 'Edit', + }) + : i18n.translate('xpack.investigateApp.investigationDetailsPage.h2.createLabel', { + defaultMessage: 'Create', + })} +

+
+
+ + + + + ( + field.onChange(event.target.value)} + /> + )} + /> + + + + + + + + + + + onClose()} + flush="left" + > + {i18n.translate( + 'xpack.investigateApp.investigationDetailsPage.closeButtonEmptyLabel', + { defaultMessage: 'Close' } + )} + + + + + {i18n.translate('xpack.investigateApp.investigationDetailsPage.saveButtonLabel', { + defaultMessage: 'Save', + })} + + + + +
+
+
+ ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_not_found/investigation_not_found.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_not_found/investigation_not_found.tsx new file mode 100644 index 00000000000000..3c3f6fc1c400d2 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/investigation_not_found/investigation_not_found.tsx @@ -0,0 +1,34 @@ +/* + * 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 { EuiEmptyPrompt } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +export function InvestigationNotFound() { + return ( + + {i18n.translate('xpack.investigateApp.investigationEditForm.h2.unableToLoadTheLabel', { + defaultMessage: 'Unable to load the investigation form', + })} + + } + body={ +

+ {i18n.translate('xpack.investigateApp.investigationEditForm.p.thereWasAnErrorLabel', { + defaultMessage: + 'There was an error loading the Investigation. Contact your administrator for help.', + })} +

+ } + /> + ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_create_investigation.tsx b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_create_investigation.tsx new file mode 100644 index 00000000000000..31fda256402243 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_create_investigation.tsx @@ -0,0 +1,51 @@ +/* + * 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 { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; +import { + CreateInvestigationParams, + CreateInvestigationResponse, + FindInvestigationsResponse, +} from '@kbn/investigation-shared'; +import { QueryKey, useMutation } from '@tanstack/react-query'; +import { useKibana } from './use_kibana'; + +type ServerError = IHttpFetchError; + +export function useCreateInvestigation() { + const { + core: { + http, + notifications: { toasts }, + }, + } = useKibana(); + + return useMutation< + CreateInvestigationResponse, + ServerError, + CreateInvestigationParams, + { previousData?: FindInvestigationsResponse; queryKey?: QueryKey } + >( + ['createInvestigation'], + (investigation) => { + const body = JSON.stringify(investigation); + return http.post(`/api/observability/investigations`, { + body, + version: '2023-10-31', + }); + }, + + { + onSuccess: (response, investigation, context) => { + toasts.addSuccess('Investigation created'); + }, + onError: (error, investigation, context) => { + toasts.addError(new Error(error.body?.message ?? 'An error occurred'), { title: 'Error' }); + }, + } + ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation.ts index 19b12364fd0347..d9f2379593df4d 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation.ts +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation.ts @@ -16,7 +16,7 @@ import { investigationKeys } from './query_key_factory'; import { useKibana } from './use_kibana'; export interface Params { - id: string; + id?: string; initialInvestigation?: GetInvestigationResponse; } @@ -45,13 +45,14 @@ export function useFetchInvestigation({ const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery( { - queryKey: investigationKeys.fetch({ id }), + queryKey: investigationKeys.fetch({ id: id! }), queryFn: async ({ signal }) => { return await http.get(`/api/observability/investigations/${id}`, { version: '2023-10-31', signal, }); }, + enabled: Boolean(id), initialData: initialInvestigation, refetchOnWindowFocus: false, refetchInterval: 15 * 1000, diff --git a/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx b/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx index 695daf1f48cb28..6ecab29020eba4 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/items/esql_item/register_esql_item.tsx @@ -8,7 +8,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { css } from '@emotion/css'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { ESQLSearchResponse } from '@kbn/es-types'; -import { ESQLDataGrid } from '@kbn/esql-datagrid/public'; import { i18n } from '@kbn/i18n'; import { type GlobalWidgetParameters } from '@kbn/investigate-plugin/public'; import type { Suggestion } from '@kbn/lens-plugin/public'; @@ -30,9 +29,6 @@ interface Props { suggestion: Suggestion; dataView: DataView; esqlQuery: string; - columns: ESQLSearchResponse['columns']; - allColumns: ESQLSearchResponse['all_columns']; - values: ESQLSearchResponse['values']; dateHistogramResults?: { query: string; columns: ESQLSearchResponse['columns']; @@ -48,8 +44,6 @@ interface EsqlItemParams { interface EsqlItemData { dataView: DataView; - columns: ESQLSearchResponse['columns']; - values: ESQLSearchResponse['values']; suggestion: Suggestion; dateHistoResponse?: { query: string; @@ -61,60 +55,20 @@ interface EsqlItemData { export const ESQL_ITEM_TYPE = 'esql'; -export function EsqlWidget({ - suggestion, - dataView, - esqlQuery, - columns, - allColumns, - values, - dateHistogramResults, -}: Props) { +export function EsqlWidget({ suggestion, dataView, esqlQuery, dateHistogramResults }: Props) { const { dependencies: { start: { lens }, }, } = useKibana(); - const datatable = useMemo(() => { - return getDatatableFromEsqlResponse({ - columns, - values, - all_columns: allColumns, - }); - }, [columns, values, allColumns]); - const input = useMemo(() => { return getLensAttrsForSuggestion({ suggestion, dataView, query: esqlQuery, - table: datatable, }); - }, [suggestion, dataView, esqlQuery, datatable]); - - const memoizedQueryObject = useMemo(() => { - return { esql: esqlQuery }; - }, [esqlQuery]); - - const initialColumns = useMemo(() => { - const timestampColumn = datatable.columns.find((column) => column.name === '@timestamp'); - const messageColumn = datatable.columns.find((column) => column.name === 'message'); - - if (datatable.columns.length > 20 && timestampColumn && messageColumn) { - const hasDataForBothColumns = datatable.rows.every((row) => { - const timestampValue = row['@timestamp']; - const messageValue = row.message; - - return timestampValue !== null && timestampValue !== undefined && !!messageValue; - }); - - if (hasDataForBothColumns) { - return [timestampColumn, messageColumn]; - } - } - return datatable.columns; - }, [datatable.columns, datatable.rows]); + }, [suggestion, dataView, esqlQuery]); const previewInput = useAbortableAsync( async ({ signal }) => { @@ -188,23 +142,12 @@ export function EsqlWidget({ grow={false} className={css` > div { - height: 128px; + height: 196px; } `} > {innerElement} - - - ); } @@ -214,7 +157,7 @@ export function EsqlWidget({ grow={true} className={css` > div { - height: 128px; + height: 196px; } `} > @@ -273,8 +216,6 @@ export function registerEsqlItem({ return { dataView: mainResponse.meta.dataView, - columns: mainResponse.query.columns, - values: mainResponse.query.values, suggestion, dateHistoResponse, }; @@ -288,9 +229,6 @@ export function registerEsqlItem({ return ( (false); + return ( - - - - + , + rightSideItems: [ + + {i18n.translate('xpack.investigateApp.investigationDetails.escalateButtonLabel', { + defaultMessage: 'Escalate', + })} + , + setEditFormFlyoutVisible(true)} + > + {i18n.translate('xpack.investigateApp.investigationDetails.editButtonLabel', { + defaultMessage: 'Edit', + })} + , + ], + }} + > + + + + - - - - + + + + + {isEditFormFlyoutVisible && investigation && ( + setEditFormFlyoutVisible(false)} + /> + )} + ); } diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_header/investigation_header.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_header/investigation_header.tsx new file mode 100644 index 00000000000000..dd27039cac27e9 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_header/investigation_header.tsx @@ -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 { EuiButtonEmpty, EuiText } from '@elastic/eui'; +import { alertOriginSchema } from '@kbn/investigation-shared'; +import { ALERT_RULE_CATEGORY } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names'; +import React from 'react'; +import { useFetchAlert } from '../../../../hooks/use_get_alert_details'; +import { useKibana } from '../../../../hooks/use_kibana'; +import { useInvestigation } from '../../contexts/investigation_context'; + +export function InvestigationHeader() { + const { + core: { + http: { basePath }, + }, + } = useKibana(); + + const { investigation } = useInvestigation(); + + const alertId = alertOriginSchema.is(investigation?.origin) + ? investigation?.origin.id + : undefined; + const { data: alertDetails } = useFetchAlert({ id: alertId }); + + return ( + <> + {alertDetails && ( + + {`[Alert] ${alertDetails?.[ALERT_RULE_CATEGORY]} breached`} + + )} + {investigation &&
{investigation.title}
} + + ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/investigation_details_page.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/investigation_details_page.tsx index 559d8c32628b17..8eadd0de879bd8 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/investigation_details_page.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/investigation_details_page.tsx @@ -5,121 +5,44 @@ * 2.0. */ -import { EuiButton, EuiButtonEmpty, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { alertOriginSchema } from '@kbn/investigation-shared'; -import { ALERT_RULE_CATEGORY } from '@kbn/rule-data-utils/src/default_alerts_as_data'; +import { EuiLoadingSpinner } from '@elastic/eui'; import React from 'react'; import { useParams } from 'react-router-dom'; import useAsync from 'react-use/lib/useAsync'; -import { paths } from '../../../common/paths'; +import { InvestigationNotFound } from '../../components/investigation_not_found/investigation_not_found'; import { useFetchInvestigation } from '../../hooks/use_fetch_investigation'; -import { useFetchAlert } from '../../hooks/use_get_alert_details'; import { useKibana } from '../../hooks/use_kibana'; import { InvestigationDetails } from './components/investigation_details/investigation_details'; -import { InvestigationProvider } from './contexts/investigation_context'; import { InvestigationDetailsPathParams } from './types'; +import { InvestigationProvider } from './contexts/investigation_context'; export function InvestigationDetailsPage() { const { - core: { - http: { basePath }, - security, - }, - dependencies: { - start: { observabilityShared }, - }, + core: { security }, } = useKibana(); + const { investigationId } = useParams(); const user = useAsync(() => { return security.authc.getCurrentUser(); }, [security]); - const { investigationId } = useParams(); - - const ObservabilityPageTemplate = observabilityShared.navigation.PageTemplate; - const { data: investigation, isLoading: isFetchInvestigationLoading, isError: isFetchInvestigationError, } = useFetchInvestigation({ id: investigationId }); - const alertId = alertOriginSchema.is(investigation?.origin) - ? investigation?.origin.id - : undefined; - - const { data: alertDetails } = useFetchAlert({ id: alertId }); - - if (!user.value) { - return null; - } - - if (isFetchInvestigationLoading || investigation === undefined) { - return ( -

- {i18n.translate('xpack.investigateApp.fetchInvestigation.loadingLabel', { - defaultMessage: 'Loading...', - })} -

- ); + if (isFetchInvestigationLoading || user.loading) { + return ; } - if (isFetchInvestigationError) { - return ( -

- {i18n.translate('xpack.investigateApp.fetchInvestigation.errorLabel', { - defaultMessage: 'Error while fetching investigation', - })} -

- ); + if (isFetchInvestigationError || !investigation || !user.value) { + return ; } return ( - - {alertDetails && ( - - - {`[Alert] ${alertDetails?.[ALERT_RULE_CATEGORY]} breached`} - - - )} - {investigation &&
{investigation.title}
} - - ), - rightSideItems: [ - - {i18n.translate('xpack.investigateApp.investigationDetails.escalateButtonLabel', { - defaultMessage: 'Escalate', - })} - , - ], - }} - > - -
+
); } diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/investigation_list_page.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/investigation_list_page.tsx index fa83cb4e1be54a..74bdac54073a06 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/investigation_list_page.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/investigation_list_page.tsx @@ -6,22 +6,20 @@ */ import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useState } from 'react'; +import { InvestigationEditForm } from '../../components/investigation_edit_form/investigation_edit_form'; import { useKibana } from '../../hooks/use_kibana'; import { InvestigationList } from './components/investigation_list'; -import { paths } from '../../../common/paths'; export function InvestigationListPage() { const { - core: { - http: { basePath }, - }, dependencies: { start: { observabilityShared }, }, } = useKibana(); const ObservabilityPageTemplate = observabilityShared.navigation.PageTemplate; + const [isCreateFormFlyoutVisible, setCreateFormFlyoutVisible] = useState(false); return ( setCreateFormFlyoutVisible(true)} > {i18n.translate('xpack.investigateApp.investigationListPage.createButtonLabel', { defaultMessage: 'Create', @@ -50,6 +48,9 @@ export function InvestigationListPage() { }} > + {isCreateFormFlyoutVisible && ( + setCreateFormFlyoutVisible(false)} /> + )} ); } diff --git a/x-pack/plugins/observability_solution/investigate_app/tsconfig.json b/x-pack/plugins/observability_solution/investigate_app/tsconfig.json index b452b80b821caf..d689dd60864733 100644 --- a/x-pack/plugins/observability_solution/investigate_app/tsconfig.json +++ b/x-pack/plugins/observability_solution/investigate_app/tsconfig.json @@ -48,11 +48,9 @@ "@kbn/server-route-repository", "@kbn/security-plugin", "@kbn/ui-actions-plugin", - "@kbn/esql-datagrid", "@kbn/server-route-repository-utils", "@kbn/core-saved-objects-server", "@kbn/rule-registry-plugin", - "@kbn/rule-data-utils", "@kbn/shared-ux-router", "@kbn/investigation-shared", "@kbn/core-security-common",