From 201c9d3268d915aa709fd5a5ec3118596024f3a7 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Mon, 19 Aug 2024 19:34:28 -0400 Subject: [PATCH] chore(rca): notes management without investigation store (#190623) --- packages/kbn-investigation-shared/index.ts | 4 + .../src/schema/delete_note.ts | 23 ++ .../investigate/common/index.ts | 2 - .../investigate/common/types.ts | 13 +- .../investigate/public/create_widget.ts | 12 +- .../investigate/public/esql_widget/types.ts | 3 - .../public/hooks/use_investigate_widget.tsx | 19 -- .../create_new_investigation.ts | 43 ++- .../public/hooks/use_investigation/index.tsx | 142 ++------- .../use_investigation/investigation_store.ts | 25 -- .../public/hooks/use_local_storage.ts | 55 ---- .../investigate/public/index.ts | 6 +- .../investigate/public/plugin.tsx | 20 +- .../investigate/public/types.ts | 20 +- .../investigate/tsconfig.json | 1 + .../esql_widget_preview.tsx | 15 +- .../components/grid_item/index.stories.tsx | 1 - .../public/components/grid_item/index.tsx | 11 +- .../investigate_search_bar/index.tsx | 91 +----- .../investigate_widget_grid/index.tsx | 294 ++---------------- .../public/hooks/query_key_factory.ts | 3 + .../hooks/use_add_investigation_note.ts | 6 +- .../public/hooks/use_date_range.ts | 59 ---- .../hooks/use_delete_investigation_note.ts | 44 +++ .../hooks/use_fetch_investigation_notes.ts | 76 +++++ .../investigation_details/index.tsx | 68 +--- .../investigation_notes.tsx | 64 ++-- .../investigation_notes/timeline_message.tsx | 11 +- .../esql_widget/register_esql_widget.tsx | 18 +- ...investigate_app_server_route_repository.ts | 48 ++- .../server/services/create_investigation.ts | 5 +- .../services/create_investigation_note.ts | 5 +- .../services/delete_investigation_note.ts | 28 ++ .../investigate_app/tsconfig.json | 1 + 34 files changed, 406 insertions(+), 830 deletions(-) create mode 100644 packages/kbn-investigation-shared/src/schema/delete_note.ts delete mode 100644 x-pack/plugins/observability_solution/investigate/public/hooks/use_investigate_widget.tsx delete mode 100644 x-pack/plugins/observability_solution/investigate/public/hooks/use_local_storage.ts delete mode 100644 x-pack/plugins/observability_solution/investigate_app/public/hooks/use_date_range.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/hooks/use_delete_investigation_note.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation_notes.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_note.ts diff --git a/packages/kbn-investigation-shared/index.ts b/packages/kbn-investigation-shared/index.ts index 723a97d762b02..c5f92248adb6b 100644 --- a/packages/kbn-investigation-shared/index.ts +++ b/packages/kbn-investigation-shared/index.ts @@ -13,6 +13,8 @@ export type * from './src/schema/find'; export type * from './src/schema/get'; export type * from './src/schema/get_notes'; export type * from './src/schema/origin'; +export type * from './src/schema/delete_note'; +export type * from './src/schema/investigation_note'; export * from './src/schema/create'; export * from './src/schema/create_notes'; @@ -21,3 +23,5 @@ export * from './src/schema/find'; export * from './src/schema/get'; export * from './src/schema/get_notes'; export * from './src/schema/origin'; +export * from './src/schema/delete_note'; +export * from './src/schema/investigation_note'; diff --git a/packages/kbn-investigation-shared/src/schema/delete_note.ts b/packages/kbn-investigation-shared/src/schema/delete_note.ts new file mode 100644 index 0000000000000..9af910b277756 --- /dev/null +++ b/packages/kbn-investigation-shared/src/schema/delete_note.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as t from 'io-ts'; + +const deleteInvestigationNoteParamsSchema = t.type({ + path: t.type({ + id: t.string, + noteId: t.string, + }), +}); + +type DeleteInvestigationNoteParams = t.TypeOf< + typeof deleteInvestigationNoteParamsSchema.props.path +>; // Parsed payload used by the backend + +export { deleteInvestigationNoteParamsSchema }; +export type { DeleteInvestigationNoteParams }; diff --git a/x-pack/plugins/observability_solution/investigate/common/index.ts b/x-pack/plugins/observability_solution/investigate/common/index.ts index 7846f257f64fe..541e2d7206bf3 100644 --- a/x-pack/plugins/observability_solution/investigate/common/index.ts +++ b/x-pack/plugins/observability_solution/investigate/common/index.ts @@ -12,5 +12,3 @@ export type { } from './types'; export { mergePlainObjects } from './utils/merge_plain_objects'; - -export { InvestigateWidgetColumnSpan } from './types'; diff --git a/x-pack/plugins/observability_solution/investigate/common/types.ts b/x-pack/plugins/observability_solution/investigate/common/types.ts index 8fdd1968f2125..c31f432f19809 100644 --- a/x-pack/plugins/observability_solution/investigate/common/types.ts +++ b/x-pack/plugins/observability_solution/investigate/common/types.ts @@ -15,17 +15,9 @@ export interface GlobalWidgetParameters { }; } -export enum InvestigateWidgetColumnSpan { - One = 1, - Two = 2, - Three = 3, - Four = 4, -} - export interface Investigation { id: string; createdAt: number; - user: AuthenticatedUser; title: string; items: InvestigateWidget[]; notes: InvestigationNote[]; @@ -51,14 +43,11 @@ export interface InvestigateWidget< parameters: GlobalWidgetParameters & TParameters; data: TData; title: string; - description?: string; - columns: InvestigateWidgetColumnSpan; - rows: number; } export type InvestigateWidgetCreate = {}> = Pick< InvestigateWidget, - 'title' | 'description' | 'columns' | 'rows' | 'type' + 'title' | 'type' > & { parameters: DeepPartial & TParameters; }; diff --git a/x-pack/plugins/observability_solution/investigate/public/create_widget.ts b/x-pack/plugins/observability_solution/investigate/public/create_widget.ts index 29058298c674d..697202ac42d2b 100644 --- a/x-pack/plugins/observability_solution/investigate/public/create_widget.ts +++ b/x-pack/plugins/observability_solution/investigate/public/create_widget.ts @@ -6,15 +6,13 @@ */ import { DeepPartial } from 'utility-types'; -import { InvestigateWidgetColumnSpan, InvestigateWidgetCreate } from '../common'; +import { InvestigateWidgetCreate } from '../common'; import { GlobalWidgetParameters } from '../common/types'; type MakePartial, K extends keyof T> = Omit & DeepPartial>; -type PredefinedKeys = 'rows' | 'columns' | 'type'; - -type AllowedDefaultKeys = 'rows' | 'columns'; +type PredefinedKeys = 'type'; export type WidgetFactory> = < T extends MakePartial, PredefinedKeys> @@ -24,15 +22,11 @@ export type WidgetFactory> = < Omit & { parameters: T['parameters'] & DeepPartial }; export function createWidgetFactory>( - type: string, - defaults?: Pick, AllowedDefaultKeys> + type: string ): WidgetFactory { const createWidget: WidgetFactory = (widgetCreate) => { return { - rows: 12, - columns: InvestigateWidgetColumnSpan.Four, type, - ...defaults, ...widgetCreate, }; }; diff --git a/x-pack/plugins/observability_solution/investigate/public/esql_widget/types.ts b/x-pack/plugins/observability_solution/investigate/public/esql_widget/types.ts index d6a41b84dacfe..764daedc9c5ed 100644 --- a/x-pack/plugins/observability_solution/investigate/public/esql_widget/types.ts +++ b/x-pack/plugins/observability_solution/investigate/public/esql_widget/types.ts @@ -7,7 +7,6 @@ import type { IconType } from '@elastic/eui'; import type { Ast } from '@kbn/interpreter'; -import type { InvestigateWidgetCreate } from '../../common'; // copied over from the Lens plugin to prevent dependency hell type TableChangeType = 'initial' | 'unchanged' | 'reduced' | 'extended' | 'reorder' | 'layers'; @@ -33,5 +32,3 @@ export interface EsqlWidgetParameters { esql: string; suggestion?: Suggestion; } - -export type EsqlWidgetCreate = InvestigateWidgetCreate; diff --git a/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigate_widget.tsx b/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigate_widget.tsx deleted file mode 100644 index 984058a1829c0..0000000000000 --- a/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigate_widget.tsx +++ /dev/null @@ -1,19 +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 { createContext } from 'react'; -import type { InvestigateWidgetCreate } from '../../common'; - -export interface UseInvestigateWidgetApi< - TParameters extends Record = {}, - TData extends Record = {} -> { - onWidgetAdd: (create: InvestigateWidgetCreate) => Promise; -} - -const InvestigateWidgetApiContext = createContext(undefined); - -export const InvestigateWidgetApiContextProvider = InvestigateWidgetApiContext.Provider; diff --git a/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/create_new_investigation.ts b/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/create_new_investigation.ts index 9ad3fd27a227a..af6227e552115 100644 --- a/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/create_new_investigation.ts +++ b/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/create_new_investigation.ts @@ -5,30 +5,43 @@ * 2.0. */ -import type { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { v4 } from 'uuid'; import { i18n } from '@kbn/i18n'; +import { GetInvestigationResponse } from '@kbn/investigation-shared'; +import { v4 } from 'uuid'; import type { Investigation } from '../../../common'; -import { GlobalWidgetParameters } from '../../../common/types'; -export function createNewInvestigation({ - id, - user, - globalWidgetParameters, -}: { - id?: string; - user: AuthenticatedUser; - globalWidgetParameters: GlobalWidgetParameters; -}): Investigation { +export function createNewInvestigation(): Investigation { return { + id: v4(), createdAt: new Date().getTime(), - user, - id: id ?? v4(), title: i18n.translate('xpack.investigate.newInvestigationTitle', { defaultMessage: 'New investigation', }), items: [], notes: [], - parameters: globalWidgetParameters, + parameters: { + timeRange: { + from: new Date(Date.now() - 15 * 60 * 1000).toISOString(), + to: new Date().toISOString(), + }, + }, + }; +} + +export function fromInvestigationResponse( + investigationData: GetInvestigationResponse +): Investigation { + return { + id: investigationData.id, + createdAt: investigationData.createdAt, + title: investigationData.title, + items: [], + notes: investigationData.notes, + parameters: { + timeRange: { + from: new Date(investigationData.params.timeRange.from).toISOString(), + to: new Date(investigationData.params.timeRange.to).toISOString(), + }, + }, }; } diff --git a/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/index.tsx b/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/index.tsx index 4f43762c9556f..fd3c50cdb80ac 100644 --- a/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/index.tsx +++ b/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/index.tsx @@ -6,19 +6,15 @@ */ import type { AuthenticatedUser, NotificationsStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; +import { GetInvestigationResponse } from '@kbn/investigation-shared'; import { pull } from 'lodash'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { v4 } from 'uuid'; import type { GlobalWidgetParameters } from '../..'; -import type { InvestigateWidget, InvestigateWidgetCreate, Investigation } from '../../../common'; +import type { InvestigateWidget, InvestigateWidgetCreate } from '../../../common'; import type { WidgetDefinition } from '../../types'; -import { - InvestigateWidgetApiContextProvider, - UseInvestigateWidgetApi, -} from '../use_investigate_widget'; -import { useLocalStorage } from '../use_local_storage'; -import { createNewInvestigation } from './create_new_investigation'; +import { createNewInvestigation, fromInvestigationResponse } from './create_new_investigation'; import { StatefulInvestigation, createInvestigationStore } from './investigation_store'; export type RenderableInvestigateWidget = InvestigateWidget & { @@ -31,7 +27,6 @@ export type RenderableInvestigation = Omit & { }; export interface UseInvestigationApi { - investigations: Investigation[]; investigation?: StatefulInvestigation; renderableInvestigation?: RenderableInvestigation; copyItem: (id: string) => Promise; @@ -39,37 +34,26 @@ export interface UseInvestigationApi { addItem: (options: InvestigateWidgetCreate) => Promise; setGlobalParameters: (parameters: GlobalWidgetParameters) => Promise; setTitle: (title: string) => Promise; - addNote: (note: string) => Promise; - deleteNote: (id: string) => Promise; } function useInvestigationWithoutContext({ user, notifications, widgetDefinitions, - from, - to, + investigationData, }: { user: AuthenticatedUser; notifications: NotificationsStart; widgetDefinitions: WidgetDefinition[]; - from: string; - to: string; + investigationData?: GetInvestigationResponse; }): UseInvestigationApi { const [investigationStore, _] = useState(() => createInvestigationStore({ user, widgetDefinitions, - investigation: createNewInvestigation({ - user, - id: v4(), - globalWidgetParameters: { - timeRange: { - from, - to, - }, - }, - }), + investigation: investigationData + ? fromInvestigationResponse(investigationData) + : createNewInvestigation(), }) ); @@ -106,32 +90,12 @@ function useInvestigationWithoutContext({ let Component = widgetComponentsById.current[item.id]; if (!Component) { const id = item.id; - const api: UseInvestigateWidgetApi = { - onWidgetAdd: async (create) => { - return investigationStore.addItem(item.id, create); - }, - }; - - const onDelete = () => { - return investigationStore.deleteItem(id); - }; - const widgetDefinition = widgetDefinitions.find( (definition) => definition.type === item.type )!; Component = widgetComponentsById.current[id] = (props) => { - return ( - - {widgetDefinition - ? widgetDefinition.render({ - onWidgetAdd: api.onWidgetAdd, - onDelete, - widget: props.widget, - }) - : undefined} - - ); + return <>{widgetDefinition?.render({ widget: props.widget })}; }; } @@ -148,7 +112,7 @@ function useInvestigationWithoutContext({ }); return nextItemsWithContext; - }, [investigation?.items, widgetDefinitions, investigationStore]); + }, [investigation?.items, widgetDefinitions]); const renderableInvestigation = useMemo(() => { return investigation @@ -167,79 +131,7 @@ function useInvestigationWithoutContext({ const { copyItem, setGlobalParameters, setTitle } = investigationStore; - const { storedItem: investigations, setStoredItem: setInvestigations } = useLocalStorage< - Investigation[] - >('experimentalInvestigations', []); - - const investigationsRef = useRef(investigations); - investigationsRef.current = investigations; - - useEffect(() => { - function attemptToStoreInvestigations(next: Investigation[]) { - try { - setInvestigations(next); - } catch (error) { - notifications.showErrorDialog({ - title: i18n.translate('xpack.investigate.useInvestigation.errorSavingInvestigations', { - defaultMessage: 'Could not save investigations to local storage', - }), - error, - }); - } - } - - const subscription = investigation$.subscribe(({ investigation: investigationFromStore }) => { - const isEmpty = investigationFromStore.items.length === 0; - - if (isEmpty) { - return; - } - - const toSerialize = { - ...investigationFromStore, - items: investigationFromStore.items.map((item) => { - const { loading, ...rest } = item; - return rest; - }), - }; - - const hasStoredCurrentInvestigation = !!investigationsRef.current.find( - (investigationAtIndex) => investigationAtIndex.id === investigationFromStore.id - ); - - if (!hasStoredCurrentInvestigation) { - attemptToStoreInvestigations([...(investigationsRef.current ?? []), toSerialize].reverse()); - return; - } - - const nextInvestigations = investigationsRef.current - .map((investigationAtIndex) => { - if (investigationAtIndex.id === investigationFromStore.id) { - return toSerialize; - } - return investigationAtIndex; - }) - .reverse(); - - attemptToStoreInvestigations(nextInvestigations); - }); - - return () => { - subscription.unsubscribe(); - }; - }, [investigation$, setInvestigations, notifications]); - - const addNote = async (note: string) => { - await investigationStore.addNote(note); - }; - - const deleteNote = async (id: string) => { - await investigationStore.deleteNote(id); - }; - return { - addNote, - deleteNote, addItem, copyItem, deleteItem, @@ -247,7 +139,6 @@ function useInvestigationWithoutContext({ renderableInvestigation, setGlobalParameters, setTitle, - investigations, }; } @@ -258,13 +149,18 @@ export function createUseInvestigation({ notifications: NotificationsStart; widgetDefinitions: WidgetDefinition[]; }) { - return ({ user, from, to }: { user: AuthenticatedUser; from: string; to: string }) => { + return ({ + user, + investigationData, + }: { + user: AuthenticatedUser; + investigationData?: GetInvestigationResponse; + }) => { return useInvestigationWithoutContext({ user, notifications, widgetDefinitions, - from, - to, + investigationData, }); }; } diff --git a/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/investigation_store.ts b/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/investigation_store.ts index 4c9b7ea71cffa..be2da347f995a 100644 --- a/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/investigation_store.ts +++ b/x-pack/plugins/observability_solution/investigate/public/hooks/use_investigation/investigation_store.ts @@ -33,12 +33,9 @@ interface InvestigationStore { asObservable: () => Observable<{ investigation: StatefulInvestigation; }>; - getInvestigation: () => Promise>; setGlobalParameters: (globalWidgetParameters: GlobalWidgetParameters) => Promise; setTitle: (title: string) => Promise; destroy: () => void; - addNote: (note: string) => Promise; - deleteNote: (id: string) => Promise; } export function createInvestigationStore({ @@ -112,7 +109,6 @@ export function createInvestigationStore({ }); }, asObservable: () => asObservable, - getInvestigation: async () => Object.freeze(observable$.value.investigation), destroy: () => { return controller.abort(); }, @@ -152,26 +148,5 @@ export function createInvestigationStore({ return { ...prevInvestigation, title }; }); }, - addNote: async (note: string) => { - return updateInvestigationInPlace((prevInvestigation) => { - return { - ...prevInvestigation, - notes: prevInvestigation.notes.concat({ - id: v4(), - createdAt: Date.now(), - createdBy: user.username, - content: note, - }), - }; - }); - }, - deleteNote: async (id: string) => { - return updateInvestigationInPlace((prevInvestigation) => { - return { - ...prevInvestigation, - notes: prevInvestigation.notes.filter((note) => note.id !== id), - }; - }); - }, }; } diff --git a/x-pack/plugins/observability_solution/investigate/public/hooks/use_local_storage.ts b/x-pack/plugins/observability_solution/investigate/public/hooks/use_local_storage.ts deleted file mode 100644 index 7e338f6d60eff..0000000000000 --- a/x-pack/plugins/observability_solution/investigate/public/hooks/use_local_storage.ts +++ /dev/null @@ -1,55 +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 { useCallback, useEffect, useMemo, useState } from 'react'; - -function getFromStorage(keyName: string, defaultValue?: T): T { - const storedItem = window.localStorage.getItem(keyName); - - if (storedItem !== null) { - try { - return JSON.parse(storedItem); - } catch (err) { - window.localStorage.removeItem(keyName); - // eslint-disable-next-line no-console - console.log(`Unable to decode: ${keyName}`); - } - } - return defaultValue as T; -} - -export function useLocalStorage(key: string, defaultValue?: T) { - const [storedItem, setStoredItem] = useState(() => getFromStorage(key, defaultValue)); - - useEffect(() => { - function onStorageUpdate(e: StorageEvent) { - if (e.key === key) { - setStoredItem((prev) => getFromStorage(key, prev)); - } - } - window.addEventListener('storage', onStorageUpdate); - - return () => { - window.removeEventListener('storage', onStorageUpdate); - }; - }, [key]); - - const setStoredItemForApi = useCallback( - (next: T) => { - window.localStorage.setItem(key, JSON.stringify(next)); - setStoredItem(() => next); - }, - [key] - ); - - return useMemo(() => { - return { - storedItem, - setStoredItem: setStoredItemForApi, - }; - }, [storedItem, setStoredItemForApi]); -} diff --git a/x-pack/plugins/observability_solution/investigate/public/index.ts b/x-pack/plugins/observability_solution/investigate/public/index.ts index 8d296a321c94d..2f55a27ef1c27 100644 --- a/x-pack/plugins/observability_solution/investigate/public/index.ts +++ b/x-pack/plugins/observability_solution/investigate/public/index.ts @@ -14,23 +14,19 @@ import type { InvestigateStartDependencies, ConfigSchema, OnWidgetAdd, - WidgetRenderAPI, } from './types'; -export type { InvestigatePublicSetup, InvestigatePublicStart, OnWidgetAdd, WidgetRenderAPI }; +export type { InvestigatePublicSetup, InvestigatePublicStart, OnWidgetAdd }; export { type Investigation, type InvestigateWidget, type InvestigateWidgetCreate, - InvestigateWidgetColumnSpan, type GlobalWidgetParameters, } from '../common/types'; export { mergePlainObjects } from '../common/utils/merge_plain_objects'; -export { ChromeOption } from './types'; - export { createWidgetFactory } from './create_widget'; export { getEsFilterFromGlobalParameters } from './util/get_es_filters_from_global_parameters'; diff --git a/x-pack/plugins/observability_solution/investigate/public/plugin.tsx b/x-pack/plugins/observability_solution/investigate/public/plugin.tsx index b54454f63b455..887753446c4a9 100644 --- a/x-pack/plugins/observability_solution/investigate/public/plugin.tsx +++ b/x-pack/plugins/observability_solution/investigate/public/plugin.tsx @@ -4,7 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import type { + AuthenticatedUser, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from '@kbn/core/public'; +import { GetInvestigationResponse } from '@kbn/investigation-shared'; import type { Logger } from '@kbn/logging'; import { useMemo } from 'react'; import { createUseInvestigation } from './hooks/use_investigation'; @@ -60,7 +67,13 @@ export class InvestigatePlugin start(coreStart: CoreStart, pluginsStart: InvestigateStartDependencies): InvestigatePublicStart { return { getWidgetDefinitions: this.widgetRegistry.getWidgetDefinitions, - useInvestigation: ({ user, from, to }) => { + useInvestigation: ({ + user, + investigationData, + }: { + user: AuthenticatedUser; + investigationData?: GetInvestigationResponse; + }) => { const widgetDefinitions = useMemo(() => this.widgetRegistry.getWidgetDefinitions(), []); return createUseInvestigation({ @@ -68,8 +81,7 @@ export class InvestigatePlugin widgetDefinitions, })({ user, - from, - to, + investigationData, }); }, }; diff --git a/x-pack/plugins/observability_solution/investigate/public/types.ts b/x-pack/plugins/observability_solution/investigate/public/types.ts index 50da793c5b544..dc6eb8b62021d 100644 --- a/x-pack/plugins/observability_solution/investigate/public/types.ts +++ b/x-pack/plugins/observability_solution/investigate/public/types.ts @@ -8,27 +8,17 @@ /* eslint-disable @typescript-eslint/no-empty-interface*/ import type { AuthenticatedUser } from '@kbn/core/public'; import type { CompatibleJSONSchema } from '@kbn/observability-ai-assistant-plugin/public'; +import type { GetInvestigationResponse } from '@kbn/investigation-shared'; import type { FromSchema } from 'json-schema-to-ts'; import type { InvestigateWidget } from '../common'; import type { GlobalWidgetParameters, InvestigateWidgetCreate } from '../common/types'; import type { UseInvestigationApi } from './hooks/use_investigation'; -export enum ChromeOption { - disabled = 'disabled', - static = 'static', - dynamic = 'dynamic', -} - export type OnWidgetAdd = (create: InvestigateWidgetCreate) => Promise; -export interface WidgetRenderAPI { - onDelete: () => void; - onWidgetAdd: OnWidgetAdd; -} - -type WidgetRenderOptions = { +interface WidgetRenderOptions { widget: TInvestigateWidget; -} & WidgetRenderAPI; +} export interface WidgetDefinition { type: string; @@ -39,7 +29,6 @@ export interface WidgetDefinition { signal: AbortSignal; }) => Promise>; render: (options: WidgetRenderOptions) => React.ReactNode; - chrome?: ChromeOption; } type RegisterWidgetOptions = Omit; @@ -80,7 +69,6 @@ export interface InvestigatePublicStart { getWidgetDefinitions: () => WidgetDefinition[]; useInvestigation: ({}: { user: AuthenticatedUser; - from: string; - to: string; + investigationData?: GetInvestigationResponse; }) => UseInvestigationApi; } diff --git a/x-pack/plugins/observability_solution/investigate/tsconfig.json b/x-pack/plugins/observability_solution/investigate/tsconfig.json index c0ba6efeb978a..d48acf4a215ad 100644 --- a/x-pack/plugins/observability_solution/investigate/tsconfig.json +++ b/x-pack/plugins/observability_solution/investigate/tsconfig.json @@ -21,6 +21,7 @@ "@kbn/i18n", "@kbn/utility-types", "@kbn/core-security-common", + "@kbn/investigation-shared", ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/add_observation_ui/esql_widget_preview.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/add_observation_ui/esql_widget_preview.tsx index a661e6fdc8eae..2d1e1f1506797 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/components/add_observation_ui/esql_widget_preview.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/add_observation_ui/esql_widget_preview.tsx @@ -9,12 +9,11 @@ import { css } from '@emotion/css'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { ESQLColumn, ESQLRow } from '@kbn/es-types'; import { - createEsqlWidget, ESQL_WIDGET_NAME, GlobalWidgetParameters, - InvestigateWidgetColumnSpan, InvestigateWidgetCreate, OnWidgetAdd, + createEsqlWidget, } from '@kbn/investigate-plugin/public'; import type { Suggestion } from '@kbn/lens-plugin/public'; import { useAbortableAsync } from '@kbn/observability-ai-assistant-plugin/public'; @@ -33,16 +32,6 @@ function getWidgetFromSuggestion({ query: string; suggestion: Suggestion; }): InvestigateWidgetCreate { - const makeItWide = suggestion.visualizationId !== 'lnsMetric'; - - const makeItTall = suggestion.visualizationId !== 'lnsMetric'; - - let rows = makeItTall ? 12 : 4; - - if (suggestion.visualizationId === 'lnsDatatable') { - rows = 18; - } - return createEsqlWidget({ title: suggestion.title, type: ESQL_WIDGET_NAME, @@ -50,8 +39,6 @@ function getWidgetFromSuggestion({ esql: query, suggestion, }, - columns: makeItWide ? InvestigateWidgetColumnSpan.Four : InvestigateWidgetColumnSpan.One, - rows, }); } diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/grid_item/index.stories.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/grid_item/index.stories.tsx index 0f66c5403e172..6111d0181ccbb 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/components/grid_item/index.stories.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/grid_item/index.stories.tsx @@ -44,7 +44,6 @@ const defaultProps: Story = { onCopy={() => {}} onDelete={() => {}} title="My visualization" - description="A long description" {...props} /> diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/grid_item/index.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/grid_item/index.tsx index 1068d59ce304c..465f6f803edd9 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/components/grid_item/index.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/grid_item/index.tsx @@ -15,7 +15,6 @@ export const GRID_ITEM_HEADER_HEIGHT = 40; interface GridItemProps { id: string; title: string; - description: string; children: React.ReactNode; onCopy: () => void; onDelete: () => void; @@ -57,15 +56,7 @@ const headerClassName = css` height: ${GRID_ITEM_HEADER_HEIGHT}px; `; -export function GridItem({ - id, - title, - description, - children, - onDelete, - onCopy, - loading, -}: GridItemProps) { +export function GridItem({ id, title, children, onDelete, onCopy, loading }: GridItemProps) { const theme = useTheme(); const containerClassName = css` diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_search_bar/index.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_search_bar/index.tsx index 229c0aa40926d..45519f2e799be 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_search_bar/index.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_search_bar/index.tsx @@ -7,7 +7,7 @@ import { css } from '@emotion/css'; import type { TimeRange } from '@kbn/es-query'; import { SearchBar } from '@kbn/unified-search-plugin/public'; -import React, { useEffect, useRef, useState } from 'react'; +import React from 'react'; import { useKibana } from '../../hooks/use_kibana'; const parentClassName = css` @@ -15,99 +15,26 @@ const parentClassName = css` `; interface Props { - rangeFrom?: string; - rangeTo?: string; + dateRangeFrom?: string; + dateRangeTo?: string; onQuerySubmit: (payload: { dateRange: TimeRange }, isUpdate?: boolean) => void; onRefresh?: Required>['onRefresh']; - onFocus?: () => void; - onBlur?: () => void; - showSubmitButton?: boolean; } export function InvestigateSearchBar({ - rangeFrom, - rangeTo, + dateRangeFrom, + dateRangeTo, onQuerySubmit, onRefresh, - onFocus, - onBlur, - showSubmitButton = true, }: Props) { const { dependencies: { start: { unifiedSearch }, }, } = useKibana(); - const [element, setElement] = useState(null); - - const onBlurRef = useRef(onBlur); - onBlurRef.current = onBlur; - - const onFocusRef = useRef(onFocus); - onFocusRef.current = onFocus; - - useEffect(() => { - if (!element) { - return; - } - - let inFocus = false; - - function updateFocus(activeElement: Element | null | undefined) { - const thisElementContainsActiveElement = activeElement && element?.contains(activeElement); - - let nextInFocus = Boolean(thisElementContainsActiveElement); - - if (!nextInFocus) { - const popoverContent = document.querySelector( - '[data-test-subj=superDatePickerQuickMenu], .euiDatePopoverContent, .kbnTypeahead' - ); - - nextInFocus = Boolean( - activeElement && - activeElement !== document.body && - (activeElement === popoverContent || - activeElement?.contains(popoverContent) || - popoverContent?.contains(activeElement)) - ); - } - - if (inFocus !== nextInFocus) { - inFocus = Boolean(nextInFocus); - - if (inFocus) { - onFocusRef.current?.(); - } else { - onBlurRef.current?.(); - } - } - } - - function captureFocus() { - updateFocus(document.activeElement); - } - - function captureBlur(event: FocusEvent) { - updateFocus(event.relatedTarget as Element | null); - } - - window.addEventListener('focus', captureFocus, true); - - window.addEventListener('blur', captureBlur, true); - - return () => { - window.removeEventListener('focus', captureFocus); - window.removeEventListener('blur', captureBlur); - }; - }, [element]); return ( -
{ - setElement(nextElement); - }} - > +
{ @@ -117,9 +44,9 @@ export function InvestigateSearchBar({ showFilterBar={false} showQueryMenu={false} showDatePicker - showSubmitButton={showSubmitButton} - dateRangeFrom={rangeFrom} - dateRangeTo={rangeTo} + showSubmitButton={true} + dateRangeFrom={dateRangeFrom} + dateRangeTo={dateRangeTo} onRefresh={onRefresh} displayStyle="inPage" disableQueryLanguageSwitcher diff --git a/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.tsx b/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.tsx index 053433b83383e..d05a0274f3f97 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/components/investigate_widget_grid/index.tsx @@ -5,45 +5,16 @@ * 2.0. */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { css } from '@emotion/css'; -import { ChromeOption, InvestigateWidgetColumnSpan } from '@kbn/investigate-plugin/public'; -import { keyBy, mapValues, orderBy } from 'lodash'; -import React, { useCallback, useMemo, useRef } from 'react'; -import { ItemCallback, Layout, Responsive, WidthProvider } from 'react-grid-layout'; +import React from 'react'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; -import { EUI_BREAKPOINTS, EuiBreakpoint, useBreakpoints } from '../../hooks/use_breakpoints'; -import { useTheme } from '../../hooks/use_theme'; -import { GRID_ITEM_HEADER_HEIGHT, GridItem } from '../grid_item'; +import { GridItem } from '../grid_item'; import './styles.scss'; -const gridContainerClassName = css` - position: relative; - - .react-resizable-handle-ne, - .react-resizable-handle-nw { - top: calc(${GRID_ITEM_HEADER_HEIGHT}px) !important; - } -`; - -interface SingleComponentSection { - item: InvestigateWidgetGridItem; -} - -interface GridSection { - items: InvestigateWidgetGridItem[]; -} - -type Section = SingleComponentSection | GridSection; - export interface InvestigateWidgetGridItem { + id: string; title: string; - description: string; element: React.ReactNode; - id: string; - columns: number; - rows: number; - chrome?: ChromeOption; loading: boolean; } @@ -54,261 +25,34 @@ interface InvestigateWidgetGridProps { onItemDelete: (item: InvestigateWidgetGridItem) => Promise; } -const ROW_HEIGHT = 32; - -const BREAKPOINT_COLUMNS: Record = { - [EUI_BREAKPOINTS.xs]: 1, - [EUI_BREAKPOINTS.s]: 1, - [EUI_BREAKPOINTS.m]: 4, - [EUI_BREAKPOINTS.l]: 4, - [EUI_BREAKPOINTS.xl]: 4, -}; - -const panelContainerClassName = css` - display: flex; -`; - -function getResponsiveLayouts( - items: InvestigateWidgetGridItem[], - currentBreakpoint: EuiBreakpoint -) { - const nextLayouts: Layout[] = []; - - let atColumn = 0; - let atRow = 0; - - let rowHeight = 0; - - const maxColumns = BREAKPOINT_COLUMNS[currentBreakpoint]; - - items.forEach((item) => { - const itemColumns = item.columns; - const itemRows = item.rows; - - if (atColumn + itemColumns > maxColumns) { - atColumn = 0; - atRow += rowHeight; - rowHeight = 0; - } - - nextLayouts.push({ - i: item.id, - w: itemColumns, - h: itemRows, - x: atColumn, - y: atRow, - resizeHandles: ['ne', 'se'], - }); - - atColumn += itemColumns; - - rowHeight = Math.max(itemRows, rowHeight); - }); - - return mapValues(EUI_BREAKPOINTS, () => nextLayouts); -} - -const CONTAINER_PADDING: [number, number] = [0, 0]; - -function GridSectionRenderer({ - items, - onItemsChange, - onItemDelete, - onItemCopy, -}: InvestigateWidgetGridProps) { - const WithFixedWidth = useMemo(() => WidthProvider(Responsive), []); - - const theme = useTheme(); - - const callbacks = { - onItemsChange, - onItemCopy, - onItemDelete, - }; - - const itemCallbacksRef = useRef(callbacks); - itemCallbacksRef.current = callbacks; - - const { currentBreakpoint } = useBreakpoints(); - - const layouts = useMemo(() => { - return getResponsiveLayouts(items, currentBreakpoint); - }, [items, currentBreakpoint]); - - const gridElements = useMemo(() => { - return items.map((item) => ( -
- { - return itemCallbacksRef.current.onItemCopy(item); - }} - onDelete={() => { - return itemCallbacksRef.current.onItemDelete(item); - }} - loading={item.loading} - > - {item.element} - -
- )); - }, [items]); - - // react-grid calls `onLayoutChange` every time - // `layouts` changes, except when on mount. So... - // we do some gymnastics to skip the first call - // after a layout change - - const prevLayouts = useRef(layouts); - - const expectLayoutChangeCall = prevLayouts.current !== layouts; - - prevLayouts.current = layouts; - - const onLayoutChange = useMemo(() => { - let skipCall = expectLayoutChangeCall; - return (nextLayouts: Layout[]) => { - if (skipCall) { - skipCall = false; - return; - } - const itemsById = keyBy(items, (item) => item.id); - - const sortedLayouts = orderBy(nextLayouts, ['y', 'x']); - - const itemsInOrder = sortedLayouts.map((layout) => { - return itemsById[layout.i]; - }); - - itemCallbacksRef.current.onItemsChange(itemsInOrder); - }; - }, [items, expectLayoutChangeCall]); - - const onResize: ItemCallback = useCallback( - (layout) => { - const itemsById = keyBy(items, (item) => item.id); - - const itemsAfterResize = layout.map((layoutItem) => { - const gridItem = itemsById[layoutItem.i]; - - return { - ...gridItem, - columns: Math.max(1, layoutItem.w), - rows: Math.max(1, layoutItem.h), - }; - }); - - itemCallbacksRef.current.onItemsChange(itemsAfterResize); - }, - - [items] - ); - - return ( - - {gridElements} - - ); -} - export function InvestigateWidgetGrid({ items, onItemsChange, onItemDelete, onItemCopy, }: InvestigateWidgetGridProps) { - const sections = useMemo(() => { - let currentGrid: GridSection = { items: [] }; - const allSections: Section[] = [currentGrid]; - - for (const item of items) { - if (item.chrome === ChromeOption.disabled || item.chrome === ChromeOption.static) { - const elementSection: SingleComponentSection = { - item, - }; - allSections.push(elementSection); - currentGrid = { items: [] }; - allSections.push(currentGrid); - } else { - currentGrid.items.push(item); - } - } - - return allSections.filter((grid) => 'item' in grid || grid.items.length > 0); - }, [items]); - - if (!sections.length) { + if (!items.length) { return null; } return ( - {sections.map((section, index) => { - if ('items' in section) { - return ( - - { - return onItemCopy(copiedItem); - }} - onItemDelete={(deletedItem) => { - return onItemDelete(deletedItem); - }} - onItemsChange={(itemsInSection) => { - const nextItems = sections.flatMap((sectionAtIndex) => { - if ('item' in sectionAtIndex) { - return sectionAtIndex.item; - } - if (sectionAtIndex !== section) { - return sectionAtIndex.items; - } - return itemsInSection; - }); - - return onItemsChange(nextItems); - }} - /> - - ); - } + {items.map((item) => { return ( - - {section.item.chrome === ChromeOption.disabled ? ( - section.item.element - ) : ( - { - return onItemCopy(section.item); - }} - onDelete={() => { - return onItemDelete(section.item); - }} - > - {section.item.element} - - )} + + { + return onItemCopy(item); + }} + onDelete={() => { + return onItemDelete(item); + }} + > + {item.element} + ); })} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts index 5ce96f5a2061c..d2f6bc9060e43 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts @@ -10,6 +10,9 @@ export const investigationKeys = { list: (params: { page: number; perPage: number }) => [...investigationKeys.all, 'list', params] as const, fetch: (params: { id: string }) => [...investigationKeys.all, 'fetch', params] as const, + notes: ['investigation', 'notes'] as const, + fetchNotes: (params: { investigationId: string }) => + [...investigationKeys.notes, 'fetch', params] as const, }; export type InvestigationKeys = typeof investigationKeys; diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_add_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_add_investigation_note.ts index 95a470099a9d3..60c6c15b5e31c 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_add_investigation_note.ts +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_add_investigation_note.ts @@ -6,11 +6,11 @@ */ import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; -import { useMutation } from '@tanstack/react-query'; import { CreateInvestigationNoteInput, CreateInvestigationNoteResponse, } from '@kbn/investigation-shared'; +import { useMutation } from '@tanstack/react-query'; import { useKibana } from './use_kibana'; type ServerError = IHttpFetchError; @@ -39,12 +39,10 @@ export function useAddInvestigationNote() { }, { onSuccess: (response, {}) => { - // TODO: clear investigationNotes key from queryClient, and push new note to the internal store. - // console.log(response); toasts.addSuccess('Note saved'); }, onError: (error, {}, context) => { - // console.log(error); + 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_date_range.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_date_range.ts deleted file mode 100644 index 606fc3b5298c7..0000000000000 --- a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_date_range.ts +++ /dev/null @@ -1,59 +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 datemath from '@elastic/datemath'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import moment from 'moment'; -import { useCallback, useEffect, useState } from 'react'; -import type { InputTimeRange } from '@kbn/data-plugin/public/query'; -import { useKibana } from './use_kibana'; - -function getDatesFromDataPluginStart(data: DataPublicPluginStart) { - const { from, to } = data.query.timefilter.timefilter.getTime(); - - return { - from, - to, - start: datemath.parse(from) ?? moment().subtract(15, 'minutes'), - end: datemath.parse(to, { roundUp: true }) ?? moment(), - }; -} - -export function useDateRange() { - const { - dependencies: { - start: { data }, - }, - } = useKibana(); - - const [time, setTime] = useState(() => { - return getDatesFromDataPluginStart(data); - }); - - useEffect(() => { - const subscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({ - next: () => { - setTime(() => { - return getDatesFromDataPluginStart(data); - }); - }, - }); - - return () => { - subscription.unsubscribe(); - }; - }, [data]); - - const setRange = useCallback( - (inputRange: InputTimeRange) => { - return data.query.timefilter.timefilter.setTime(inputRange); - }, - [data] - ); - - return [time, setRange] as const; -} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_delete_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_delete_investigation_note.ts new file mode 100644 index 0000000000000..136387372c581 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_delete_investigation_note.ts @@ -0,0 +1,44 @@ +/* + * 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 { useMutation } from '@tanstack/react-query'; +import { useKibana } from './use_kibana'; + +type ServerError = IHttpFetchError; + +export function useDeleteInvestigationNote() { + const { + core: { + http, + notifications: { toasts }, + }, + } = useKibana(); + + return useMutation< + void, + ServerError, + { investigationId: string; noteId: string }, + { investigationId: string } + >( + ['addInvestigationNote'], + ({ investigationId, noteId }) => { + return http.delete( + `/api/observability/investigations/${investigationId}/notes/${noteId}`, + { version: '2023-10-31' } + ); + }, + { + onSuccess: (response, {}) => { + toasts.addSuccess('Note deleted'); + }, + onError: (error, {}, 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_notes.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation_notes.ts new file mode 100644 index 0000000000000..89a49ee698410 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_investigation_notes.ts @@ -0,0 +1,76 @@ +/* + * 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 { + GetInvestigationNotesResponse, + InvestigationNoteResponse, +} from '@kbn/investigation-shared'; +import { + QueryObserverResult, + RefetchOptions, + RefetchQueryFilters, + useQuery, +} from '@tanstack/react-query'; +import { investigationKeys } from './query_key_factory'; +import { useKibana } from './use_kibana'; + +export interface Params { + investigationId: string; + initialNotes?: InvestigationNoteResponse[]; +} + +export interface Response { + isInitialLoading: boolean; + isLoading: boolean; + isRefetching: boolean; + isSuccess: boolean; + isError: boolean; + refetch: ( + options?: (RefetchOptions & RefetchQueryFilters) | undefined + ) => Promise>; + data: GetInvestigationNotesResponse | undefined; +} + +export function useFetchInvestigationNotes({ investigationId, initialNotes }: Params): Response { + const { + core: { + http, + notifications: { toasts }, + }, + } = useKibana(); + + const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery( + { + queryKey: investigationKeys.fetchNotes({ investigationId }), + queryFn: async ({ signal }) => { + return await http.get( + `/api/observability/investigations/${investigationId}/notes`, + { version: '2023-10-31', signal } + ); + }, + initialData: initialNotes, + refetchOnWindowFocus: false, + refetchInterval: 10 * 1000, + refetchIntervalInBackground: true, + onError: (error: Error) => { + toasts.addError(error, { + title: 'Something went wrong while fetching investigation notes', + }); + }, + } + ); + + return { + data, + isInitialLoading, + isLoading, + isRefetching, + isSuccess, + isError, + refetch, + }; +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/index.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/index.tsx index 8004e739f056f..06dcc21fd05c7 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/index.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_details/index.tsx @@ -7,14 +7,12 @@ import datemath from '@elastic/datemath'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { keyBy, noop } from 'lodash'; -import React, { useMemo } from 'react'; +import { noop } from 'lodash'; +import React from 'react'; import useAsync from 'react-use/lib/useAsync'; import { AddObservationUI } from '../../../../components/add_observation_ui'; import { InvestigateSearchBar } from '../../../../components/investigate_search_bar'; import { InvestigateWidgetGrid } from '../../../../components/investigate_widget_grid'; -import { useAddInvestigationNote } from '../../../../hooks/use_add_investigation_note'; -import { useDateRange } from '../../../../hooks/use_date_range'; import { useFetchInvestigation } from '../../../../hooks/use_fetch_investigation'; import { useKibana } from '../../../../hooks/use_kibana'; import { InvestigationNotes } from '../investigation_notes/investigation_notes'; @@ -31,15 +29,8 @@ function InvestigationDetailsWithUser({ start: { investigate }, }, } = useKibana(); - const widgetDefinitions = investigate.getWidgetDefinitions(); - const [range, setRange] = useDateRange(); - + // const widgetDefinitions = investigate.getWidgetDefinitions(); const { data: investigationData } = useFetchInvestigation({ id: investigationId }); - const { mutateAsync: addInvestigationNote } = useAddInvestigationNote(); - const handleAddInvestigationNote = async (note: string) => { - await addInvestigationNote({ investigationId, note: { content: note } }); - await addNote(note); - }; const { addItem, @@ -48,34 +39,12 @@ function InvestigationDetailsWithUser({ investigation, setGlobalParameters, renderableInvestigation, - addNote, - deleteNote, } = investigate.useInvestigation({ user, - from: range.start.toISOString(), - to: range.end.toISOString(), + investigationData, }); - const gridItems = useMemo(() => { - const widgetDefinitionsByType = keyBy(widgetDefinitions, 'type'); - - return renderableInvestigation?.items.map((item) => { - const definitionForType = widgetDefinitionsByType[item.type]; - - return { - title: item.title, - description: item.description ?? '', - id: item.id, - element: item.element, - columns: item.columns, - rows: item.rows, - chrome: definitionForType.chrome, - loading: item.loading, - }; - }); - }, [renderableInvestigation, widgetDefinitions]); - - if (!investigation || !renderableInvestigation || !gridItems || !investigationData) { + if (!investigation || !renderableInvestigation || !investigationData) { return ; } @@ -86,8 +55,16 @@ function InvestigationDetailsWithUser({ { const nextDateRange = { from: datemath.parse(dateRange.from)!.toISOString(), @@ -97,15 +74,13 @@ function InvestigationDetailsWithUser({ ...renderableInvestigation.parameters, timeRange: nextDateRange, }); - - setRange(nextDateRange); }} /> { noop(); }} @@ -129,11 +104,7 @@ function InvestigationDetailsWithUser({ - + ); @@ -148,11 +119,6 @@ export function InvestigationDetails({ investigationId }: { investigationId: str return security.authc.getCurrentUser(); }, [security]); - if (investigationId == null) { - // TODO: return 404 page - return null; - } - return user.value ? ( ) : null; diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx index 09258063839e3..0f03a9f374ac3 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/investigation_notes.tsx @@ -16,37 +16,42 @@ import { } from '@elastic/eui'; import { css } from '@emotion/css'; import { i18n } from '@kbn/i18n'; -import React from 'react'; import { InvestigationNote } from '@kbn/investigate-plugin/common'; +import React, { useState } from 'react'; +import { useAddInvestigationNote } from '../../../../hooks/use_add_investigation_note'; +import { useDeleteInvestigationNote } from '../../../../hooks/use_delete_investigation_note'; +import { useFetchInvestigationNotes } from '../../../../hooks/use_fetch_investigation_notes'; import { useTheme } from '../../../../hooks/use_theme'; import { ResizableTextInput } from './resizable_text_input'; import { TimelineMessage } from './timeline_message'; export interface Props { - notes: InvestigationNote[]; - addNote: (note: string) => Promise; - deleteNote: (id: string) => Promise; + investigationId: string; + initialNotes: InvestigationNote[]; } -export function InvestigationNotes({ notes, addNote, deleteNote }: Props) { +export function InvestigationNotes({ investigationId, initialNotes }: Props) { const theme = useTheme(); - const [note, setNote] = React.useState(''); - const [loading, setLoading] = React.useState(false); + const [noteInput, setNoteInput] = useState(''); - function submit() { - if (note.trim() === '') { - return; - } + const { data: notes, refetch } = useFetchInvestigationNotes({ + investigationId, + initialNotes, + }); + const { mutateAsync: addInvestigationNote, isLoading: isAdding } = useAddInvestigationNote(); + const { mutateAsync: deleteInvestigationNote, isLoading: isDeleting } = + useDeleteInvestigationNote(); - setLoading(false); - addNote(note) - .then(() => { - setNote(''); - }) - .finally(() => { - setLoading(false); - }); - } + const onAddNote = async (content: string) => { + await addInvestigationNote({ investigationId, note: { content } }); + refetch(); + setNoteInput(''); + }; + + const onDeleteNote = async (noteId: string) => { + await deleteInvestigationNote({ investigationId, noteId }); + refetch(); + }; const panelClassName = css` background-color: ${theme.colors.lightShade}; @@ -65,13 +70,14 @@ export function InvestigationNotes({ notes, addNote, deleteNote }: Props) { - {notes.map((currNote: InvestigationNote) => { + {notes?.map((currNote: InvestigationNote) => { return ( } note={currNote} - onDelete={() => deleteNote(currNote.id)} + onDelete={() => onDeleteNote(currNote.id)} + isDeleting={isDeleting} /> ); })} @@ -89,13 +95,13 @@ export function InvestigationNotes({ notes, addNote, deleteNote }: Props) { placeholder={i18n.translate('xpack.investigateApp.investigationNotes.placeholder', { defaultMessage: 'Add a note to the investigation', })} - disabled={loading} - value={note} + disabled={isAdding} + value={noteInput} onChange={(value) => { - setNote(value); + setNoteInput(value); }} onSubmit={() => { - submit(); + onAddNote(noteInput.trim()); }} /> @@ -108,11 +114,11 @@ export function InvestigationNotes({ notes, addNote, deleteNote }: Props) { aria-label={i18n.translate('xpack.investigateApp.investigationNotes.addButtonLabel', { defaultMessage: 'Add', })} - disabled={loading || note.trim() === ''} - isLoading={loading} + disabled={isAdding || noteInput.trim() === ''} + isLoading={isAdding} size="m" onClick={() => { - submit(); + onAddNote(noteInput.trim()); }} > {i18n.translate('xpack.investigateApp.investigationNotes.addButtonLabel', { diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/timeline_message.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/timeline_message.tsx index c8e95ad70c758..0e0b29cccdd6f 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/timeline_message.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/details/components/investigation_notes/timeline_message.tsx @@ -6,9 +6,9 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiMarkdownFormat, EuiText } from '@elastic/eui'; import { css } from '@emotion/css'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { format } from 'date-fns'; import { InvestigationNote } from '@kbn/investigate-plugin/common'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { formatDistance } from 'date-fns'; import React from 'react'; import { InvestigateTextButton } from '../../../../components/investigate_text_button'; import { useTheme } from '../../../../hooks/use_theme'; @@ -21,10 +21,12 @@ export function TimelineMessage({ icon, note, onDelete, + isDeleting, }: { icon: React.ReactNode; note: InvestigationNote; onDelete: () => void; + isDeleting: boolean; }) { const theme = useTheme(); const timelineContainerClassName = css` @@ -40,7 +42,9 @@ export function TimelineMessage({ {icon} - {format(new Date(note.createdAt), 'HH:mm')} + + {formatDistance(new Date(note.createdAt), new Date(), { addSuffix: true })} + @@ -48,6 +52,7 @@ export function TimelineMessage({ diff --git a/x-pack/plugins/observability_solution/investigate_app/public/widgets/esql_widget/register_esql_widget.tsx b/x-pack/plugins/observability_solution/investigate_app/public/widgets/esql_widget/register_esql_widget.tsx index 14d18302ae516..84818c758ffe1 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/widgets/esql_widget/register_esql_widget.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/widgets/esql_widget/register_esql_widget.tsx @@ -10,7 +10,10 @@ 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 { EsqlWidgetParameters, GlobalWidgetParameters } from '@kbn/investigate-plugin/public'; +import { + type EsqlWidgetParameters, + type GlobalWidgetParameters, +} from '@kbn/investigate-plugin/public'; import type { Suggestion } from '@kbn/lens-plugin/public'; import { useAbortableAsync } from '@kbn/observability-ai-assistant-plugin/public'; import React, { useMemo } from 'react'; @@ -190,7 +193,18 @@ export function EsqlWidget({ ); } - return ; + return ( + div { + height: 128px; + } + `} + > + + + ); } export function registerEsqlWidget({ diff --git a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts index 65f61d7f0a814..1edd6fb6c3c8f 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts @@ -8,6 +8,7 @@ import { createInvestigationNoteParamsSchema, createInvestigationParamsSchema, + deleteInvestigationNoteParamsSchema, deleteInvestigationParamsSchema, findInvestigationsParamsSchema, getInvestigationNotesParamsSchema, @@ -21,6 +22,7 @@ import { getInvestigation } from '../services/get_investigation'; import { getInvestigationNotes } from '../services/get_investigation_notes'; import { investigationRepositoryFactory } from '../services/investigation_repository'; import { createInvestigateAppServerRoute } from './create_investigate_app_server_route'; +import { deleteInvestigationNote } from '../services/delete_investigation_note'; const createInvestigationRoute = createInvestigateAppServerRoute({ endpoint: 'POST /api/observability/investigations 2023-10-31', @@ -28,11 +30,15 @@ const createInvestigationRoute = createInvestigateAppServerRoute({ tags: [], }, params: createInvestigationParamsSchema, - handler: async (params) => { - const soClient = (await params.context.core).savedObjects.client; - const repository = investigationRepositoryFactory({ soClient, logger: params.logger }); + handler: async ({ params, context, request, logger }) => { + const user = (await context.core).coreStart.security.authc.getCurrentUser(request); + if (!user) { + throw new Error('User is not authenticated'); + } + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); - return await createInvestigation(params.params.body, repository); + return await createInvestigation(params.body, { repository, user }); }, }); @@ -84,11 +90,15 @@ const createInvestigationNoteRoute = createInvestigateAppServerRoute({ tags: [], }, params: createInvestigationNoteParamsSchema, - handler: async (params) => { - const soClient = (await params.context.core).savedObjects.client; - const repository = investigationRepositoryFactory({ soClient, logger: params.logger }); + handler: async ({ params, context, request, logger }) => { + const user = (await context.core).coreStart.security.authc.getCurrentUser(request); + if (!user) { + throw new Error('User is not authenticated'); + } + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); - return await createInvestigationNote(params.params.path.id, params.params.body, repository); + return await createInvestigationNote(params.path.id, params.body, { repository, user }); }, }); @@ -106,6 +116,27 @@ const getInvestigationNotesRoute = createInvestigateAppServerRoute({ }, }); +const deleteInvestigationNotesRoute = createInvestigateAppServerRoute({ + endpoint: 'DELETE /api/observability/investigations/{id}/notes/{noteId} 2023-10-31', + options: { + tags: [], + }, + params: deleteInvestigationNoteParamsSchema, + handler: async ({ params, context, request, logger }) => { + const user = (await context.core).coreStart.security.authc.getCurrentUser(request); + if (!user) { + throw new Error('User is not authenticated'); + } + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); + + return await deleteInvestigationNote(params.path.id, params.path.noteId, { + repository, + user, + }); + }, +}); + export function getGlobalInvestigateAppServerRouteRepository() { return { ...createInvestigationRoute, @@ -114,6 +145,7 @@ export function getGlobalInvestigateAppServerRouteRepository() { ...deleteInvestigationRoute, ...createInvestigationNoteRoute, ...getInvestigationNotesRoute, + ...deleteInvestigationNotesRoute, }; } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts index ee79ec34e1d36..2bd08c531b9ab 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation.ts @@ -6,6 +6,7 @@ */ import { CreateInvestigationInput, CreateInvestigationResponse } from '@kbn/investigation-shared'; +import type { AuthenticatedUser } from '@kbn/core-security-common'; import { InvestigationRepository } from './investigation_repository'; enum InvestigationStatus { @@ -15,12 +16,12 @@ enum InvestigationStatus { export async function createInvestigation( params: CreateInvestigationInput, - repository: InvestigationRepository + { repository, user }: { repository: InvestigationRepository; user: AuthenticatedUser } ): Promise { const investigation = { ...params, createdAt: Date.now(), - createdBy: 'elastic', + createdBy: user.username, status: InvestigationStatus.ongoing, notes: [], }; diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts index 57e1b3312dc45..7928da61b3a8b 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/create_investigation_note.ts @@ -10,19 +10,20 @@ import { CreateInvestigationNoteResponse, } from '@kbn/investigation-shared'; import { v4 } from 'uuid'; +import type { AuthenticatedUser } from '@kbn/core-security-common'; import { InvestigationRepository } from './investigation_repository'; export async function createInvestigationNote( investigationId: string, params: CreateInvestigationNoteInput, - repository: InvestigationRepository + { repository, user }: { repository: InvestigationRepository; user: AuthenticatedUser } ): Promise { const investigation = await repository.findById(investigationId); const investigationNote = { id: v4(), content: params.content, - createdBy: 'TODO: get user from request', + createdBy: user.username, createdAt: Date.now(), }; investigation.notes.push(investigationNote); diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_note.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_note.ts new file mode 100644 index 0000000000000..b0a9e7adf8492 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/delete_investigation_note.ts @@ -0,0 +1,28 @@ +/* + * 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 { AuthenticatedUser } from '@kbn/core-security-common'; +import { InvestigationRepository } from './investigation_repository'; + +export async function deleteInvestigationNote( + investigationId: string, + noteId: string, + { repository, user }: { repository: InvestigationRepository; user: AuthenticatedUser } +): Promise { + const investigation = await repository.findById(investigationId); + const note = investigation.notes.find((currNote) => currNote.id === noteId); + if (!note) { + throw new Error('Note not found'); + } + + if (note.createdBy !== user.username) { + throw new Error('User does not have permission to delete note'); + } + + investigation.notes = investigation.notes.filter((currNote) => currNote.id !== noteId); + await repository.save(investigation); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/tsconfig.json b/x-pack/plugins/observability_solution/investigate_app/tsconfig.json index 174c81ba6f6df..29b4985896ee2 100644 --- a/x-pack/plugins/observability_solution/investigate_app/tsconfig.json +++ b/x-pack/plugins/observability_solution/investigate_app/tsconfig.json @@ -55,5 +55,6 @@ "@kbn/rule-data-utils", "@kbn/shared-ux-router", "@kbn/investigation-shared", + "@kbn/core-security-common", ], }