mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
chore(rca): notes management without investigation store (#190623)
This commit is contained in:
parent
e9f23aa98e
commit
201c9d3268
34 changed files with 407 additions and 831 deletions
|
@ -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';
|
||||
|
|
23
packages/kbn-investigation-shared/src/schema/delete_note.ts
Normal file
23
packages/kbn-investigation-shared/src/schema/delete_note.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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 };
|
|
@ -12,5 +12,3 @@ export type {
|
|||
} from './types';
|
||||
|
||||
export { mergePlainObjects } from './utils/merge_plain_objects';
|
||||
|
||||
export { InvestigateWidgetColumnSpan } from './types';
|
||||
|
|
|
@ -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<TParameters extends Record<string, any> = {}> = Pick<
|
||||
InvestigateWidget,
|
||||
'title' | 'description' | 'columns' | 'rows' | 'type'
|
||||
'title' | 'type'
|
||||
> & {
|
||||
parameters: DeepPartial<GlobalWidgetParameters> & TParameters;
|
||||
};
|
||||
|
|
|
@ -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<T extends Record<string, any>, K extends keyof T> = Omit<T, K> &
|
||||
DeepPartial<Pick<T, K>>;
|
||||
|
||||
type PredefinedKeys = 'rows' | 'columns' | 'type';
|
||||
|
||||
type AllowedDefaultKeys = 'rows' | 'columns';
|
||||
type PredefinedKeys = 'type';
|
||||
|
||||
export type WidgetFactory<TParameters extends Record<string, any>> = <
|
||||
T extends MakePartial<InvestigateWidgetCreate<TParameters>, PredefinedKeys>
|
||||
|
@ -24,15 +22,11 @@ export type WidgetFactory<TParameters extends Record<string, any>> = <
|
|||
Omit<T, 'parameters'> & { parameters: T['parameters'] & DeepPartial<GlobalWidgetParameters> };
|
||||
|
||||
export function createWidgetFactory<TParameters extends Record<string, any>>(
|
||||
type: string,
|
||||
defaults?: Pick<Partial<InvestigateWidgetCreate>, AllowedDefaultKeys>
|
||||
type: string
|
||||
): WidgetFactory<TParameters> {
|
||||
const createWidget: WidgetFactory<TParameters> = (widgetCreate) => {
|
||||
return {
|
||||
rows: 12,
|
||||
columns: InvestigateWidgetColumnSpan.Four,
|
||||
type,
|
||||
...defaults,
|
||||
...widgetCreate,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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<EsqlWidgetParameters>;
|
||||
|
|
|
@ -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<string, any> = {},
|
||||
TData extends Record<string, any> = {}
|
||||
> {
|
||||
onWidgetAdd: (create: InvestigateWidgetCreate) => Promise<void>;
|
||||
}
|
||||
|
||||
const InvestigateWidgetApiContext = createContext<UseInvestigateWidgetApi | undefined>(undefined);
|
||||
|
||||
export const InvestigateWidgetApiContextProvider = InvestigateWidgetApiContext.Provider;
|
|
@ -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(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<StatefulInvestigation, 'items'> & {
|
|||
};
|
||||
|
||||
export interface UseInvestigationApi {
|
||||
investigations: Investigation[];
|
||||
investigation?: StatefulInvestigation;
|
||||
renderableInvestigation?: RenderableInvestigation;
|
||||
copyItem: (id: string) => Promise<void>;
|
||||
|
@ -39,37 +34,26 @@ export interface UseInvestigationApi {
|
|||
addItem: (options: InvestigateWidgetCreate) => Promise<void>;
|
||||
setGlobalParameters: (parameters: GlobalWidgetParameters) => Promise<void>;
|
||||
setTitle: (title: string) => Promise<void>;
|
||||
addNote: (note: string) => Promise<void>;
|
||||
deleteNote: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<InvestigateWidgetApiContextProvider value={api}>
|
||||
{widgetDefinition
|
||||
? widgetDefinition.render({
|
||||
onWidgetAdd: api.onWidgetAdd,
|
||||
onDelete,
|
||||
widget: props.widget,
|
||||
})
|
||||
: undefined}
|
||||
</InvestigateWidgetApiContextProvider>
|
||||
);
|
||||
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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -33,12 +33,9 @@ interface InvestigationStore {
|
|||
asObservable: () => Observable<{
|
||||
investigation: StatefulInvestigation;
|
||||
}>;
|
||||
getInvestigation: () => Promise<Readonly<StatefulInvestigation>>;
|
||||
setGlobalParameters: (globalWidgetParameters: GlobalWidgetParameters) => Promise<void>;
|
||||
setTitle: (title: string) => Promise<void>;
|
||||
destroy: () => void;
|
||||
addNote: (note: string) => Promise<void>;
|
||||
deleteNote: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<T>(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<T>(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]);
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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<void>;
|
||||
|
||||
export interface WidgetRenderAPI {
|
||||
onDelete: () => void;
|
||||
onWidgetAdd: OnWidgetAdd;
|
||||
}
|
||||
|
||||
type WidgetRenderOptions<TInvestigateWidget extends InvestigateWidget> = {
|
||||
interface WidgetRenderOptions<TInvestigateWidget extends InvestigateWidget> {
|
||||
widget: TInvestigateWidget;
|
||||
} & WidgetRenderAPI;
|
||||
}
|
||||
|
||||
export interface WidgetDefinition {
|
||||
type: string;
|
||||
|
@ -39,7 +29,6 @@ export interface WidgetDefinition {
|
|||
signal: AbortSignal;
|
||||
}) => Promise<Record<string, any>>;
|
||||
render: (options: WidgetRenderOptions<InvestigateWidget>) => React.ReactNode;
|
||||
chrome?: ChromeOption;
|
||||
}
|
||||
|
||||
type RegisterWidgetOptions = Omit<WidgetDefinition, 'generate' | 'render'>;
|
||||
|
@ -80,7 +69,6 @@ export interface InvestigatePublicStart {
|
|||
getWidgetDefinitions: () => WidgetDefinition[];
|
||||
useInvestigation: ({}: {
|
||||
user: AuthenticatedUser;
|
||||
from: string;
|
||||
to: string;
|
||||
investigationData?: GetInvestigationResponse;
|
||||
}) => UseInvestigationApi;
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
"@kbn/i18n",
|
||||
"@kbn/utility-types",
|
||||
"@kbn/core-security-common",
|
||||
"@kbn/investigation-shared",
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -44,7 +44,6 @@ const defaultProps: Story = {
|
|||
onCopy={() => {}}
|
||||
onDelete={() => {}}
|
||||
title="My visualization"
|
||||
description="A long description"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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<React.ComponentProps<typeof SearchBar>>['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<HTMLElement | null>(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 (
|
||||
<div
|
||||
className={parentClassName}
|
||||
ref={(nextElement) => {
|
||||
setElement(nextElement);
|
||||
}}
|
||||
>
|
||||
<div className={parentClassName}>
|
||||
<unifiedSearch.ui.SearchBar
|
||||
appName="investigate"
|
||||
onQuerySubmit={({ dateRange }) => {
|
||||
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
title: string;
|
||||
description: string;
|
||||
element: React.ReactNode;
|
||||
id: string;
|
||||
columns: number;
|
||||
rows: number;
|
||||
chrome?: ChromeOption;
|
||||
title: string;
|
||||
element: React.ReactNode;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
|
@ -54,261 +25,34 @@ interface InvestigateWidgetGridProps {
|
|||
onItemDelete: (item: InvestigateWidgetGridItem) => Promise<void>;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 32;
|
||||
|
||||
const BREAKPOINT_COLUMNS: Record<EuiBreakpoint, InvestigateWidgetColumnSpan> = {
|
||||
[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) => (
|
||||
<div key={item.id} className={panelContainerClassName}>
|
||||
<GridItem
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
onCopy={() => {
|
||||
return itemCallbacksRef.current.onItemCopy(item);
|
||||
}}
|
||||
onDelete={() => {
|
||||
return itemCallbacksRef.current.onItemDelete(item);
|
||||
}}
|
||||
loading={item.loading}
|
||||
>
|
||||
{item.element}
|
||||
</GridItem>
|
||||
</div>
|
||||
));
|
||||
}, [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 (
|
||||
<WithFixedWidth
|
||||
className={gridContainerClassName}
|
||||
layouts={layouts}
|
||||
breakpoints={theme.breakpoint}
|
||||
breakpoint={currentBreakpoint || EUI_BREAKPOINTS.xl}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
cols={BREAKPOINT_COLUMNS}
|
||||
allowOverlap={false}
|
||||
onLayoutChange={onLayoutChange}
|
||||
onResizeStop={onResize}
|
||||
compactType="vertical"
|
||||
isBounded
|
||||
containerPadding={CONTAINER_PADDING}
|
||||
isDraggable={false}
|
||||
isDroppable={false}
|
||||
>
|
||||
{gridElements}
|
||||
</WithFixedWidth>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvestigateWidgetGrid({
|
||||
items,
|
||||
onItemsChange,
|
||||
onItemDelete,
|
||||
onItemCopy,
|
||||
}: InvestigateWidgetGridProps) {
|
||||
const sections = useMemo<Section[]>(() => {
|
||||
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 (
|
||||
<EuiFlexGroup direction="column" gutterSize="m">
|
||||
{sections.map((section, index) => {
|
||||
if ('items' in section) {
|
||||
return (
|
||||
<EuiFlexItem key={index} grow={false}>
|
||||
<GridSectionRenderer
|
||||
items={section.items}
|
||||
onItemCopy={(copiedItem) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<EuiFlexItem grow={false} key={index}>
|
||||
{section.item.chrome === ChromeOption.disabled ? (
|
||||
section.item.element
|
||||
) : (
|
||||
<GridItem
|
||||
id={section.item.id}
|
||||
title={section.item.title}
|
||||
description={section.item.description}
|
||||
loading={section.item.loading}
|
||||
onCopy={() => {
|
||||
return onItemCopy(section.item);
|
||||
}}
|
||||
onDelete={() => {
|
||||
return onItemDelete(section.item);
|
||||
}}
|
||||
>
|
||||
{section.item.element}
|
||||
</GridItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false} key={`item-${item.id}`}>
|
||||
<GridItem
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
loading={item.loading}
|
||||
onCopy={() => {
|
||||
return onItemCopy(item);
|
||||
}}
|
||||
onDelete={() => {
|
||||
return onItemDelete(item);
|
||||
}}
|
||||
>
|
||||
{item.element}
|
||||
</GridItem>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<ResponseErrorBody>;
|
||||
|
@ -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' });
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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<ResponseErrorBody>;
|
||||
|
||||
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<void>(
|
||||
`/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' });
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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: <TPageData>(
|
||||
options?: (RefetchOptions & RefetchQueryFilters<TPageData>) | undefined
|
||||
) => Promise<QueryObserverResult<GetInvestigationNotesResponse | undefined, unknown>>;
|
||||
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<GetInvestigationNotesResponse>(
|
||||
`/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,
|
||||
};
|
||||
}
|
|
@ -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 <EuiLoadingSpinner />;
|
||||
}
|
||||
|
||||
|
@ -86,8 +55,16 @@ function InvestigationDetailsWithUser({
|
|||
<EuiFlexGroup direction="column" gutterSize="m">
|
||||
<EuiFlexItem>
|
||||
<InvestigateSearchBar
|
||||
rangeFrom={range.from}
|
||||
rangeTo={range.to}
|
||||
dateRangeFrom={
|
||||
investigationData
|
||||
? new Date(investigationData.params.timeRange.from).toISOString()
|
||||
: undefined
|
||||
}
|
||||
dateRangeTo={
|
||||
investigationData
|
||||
? new Date(investigationData.params.timeRange.to).toISOString()
|
||||
: undefined
|
||||
}
|
||||
onQuerySubmit={async ({ dateRange }) => {
|
||||
const nextDateRange = {
|
||||
from: datemath.parse(dateRange.from)!.toISOString(),
|
||||
|
@ -97,15 +74,13 @@ function InvestigationDetailsWithUser({
|
|||
...renderableInvestigation.parameters,
|
||||
timeRange: nextDateRange,
|
||||
});
|
||||
|
||||
setRange(nextDateRange);
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<InvestigateWidgetGrid
|
||||
items={gridItems}
|
||||
items={renderableInvestigation.items}
|
||||
onItemsChange={async (nextGridItems) => {
|
||||
noop();
|
||||
}}
|
||||
|
@ -129,11 +104,7 @@ function InvestigationDetailsWithUser({
|
|||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={2}>
|
||||
<InvestigationNotes
|
||||
notes={investigationData.notes}
|
||||
addNote={handleAddInvestigationNote}
|
||||
deleteNote={deleteNote}
|
||||
/>
|
||||
<InvestigationNotes investigationId={investigationId} initialNotes={investigation.notes} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
@ -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 ? (
|
||||
<InvestigationDetailsWithUser user={user.value} investigationId={investigationId} />
|
||||
) : null;
|
||||
|
|
|
@ -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<void>;
|
||||
deleteNote: (id: string) => Promise<void>;
|
||||
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) {
|
|||
</EuiSplitPanel.Inner>
|
||||
<EuiSplitPanel.Inner>
|
||||
<EuiFlexGroup direction="column" gutterSize="m">
|
||||
{notes.map((currNote: InvestigationNote) => {
|
||||
{notes?.map((currNote: InvestigationNote) => {
|
||||
return (
|
||||
<TimelineMessage
|
||||
key={currNote.id}
|
||||
icon={<EuiAvatar name={currNote.createdBy} size="s" />}
|
||||
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());
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
@ -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', {
|
||||
|
|
|
@ -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({
|
|||
<EuiFlexGroup direction="row" alignItems="center" justifyContent="flexStart" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>{icon}</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs">{format(new Date(note.createdAt), 'HH:mm')}</EuiText>
|
||||
<EuiText size="xs">
|
||||
{formatDistance(new Date(note.createdAt), new Date(), { addSuffix: true })}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
|
@ -48,6 +52,7 @@ export function TimelineMessage({
|
|||
<InvestigateTextButton
|
||||
data-test-subj="investigateAppTimelineMessageButton"
|
||||
iconType="trash"
|
||||
disabled={isDeleting}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -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 <lens.EmbeddableComponent {...input} className={lensClassName} />;
|
||||
return (
|
||||
<EuiFlexItem
|
||||
grow={true}
|
||||
className={css`
|
||||
> div {
|
||||
height: 128px;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<lens.EmbeddableComponent {...input} className={lensClassName} />
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function registerEsqlWidget({
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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<CreateInvestigationResponse> {
|
||||
const investigation = {
|
||||
...params,
|
||||
createdAt: Date.now(),
|
||||
createdBy: 'elastic',
|
||||
createdBy: user.username,
|
||||
status: InvestigationStatus.ongoing,
|
||||
notes: [],
|
||||
};
|
||||
|
|
|
@ -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<CreateInvestigationNoteResponse> {
|
||||
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);
|
||||
|
|
|
@ -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<void> {
|
||||
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);
|
||||
}
|
|
@ -55,5 +55,6 @@
|
|||
"@kbn/rule-data-utils",
|
||||
"@kbn/shared-ux-router",
|
||||
"@kbn/investigation-shared",
|
||||
"@kbn/core-security-common",
|
||||
],
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue