chore(rca): notes management without investigation store (#190623)

This commit is contained in:
Kevin Delemme 2024-08-19 19:34:28 -04:00 committed by GitHub
parent e9f23aa98e
commit 201c9d3268
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 407 additions and 831 deletions

View file

@ -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';

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 };

View file

@ -12,5 +12,3 @@ export type {
} from './types';
export { mergePlainObjects } from './utils/merge_plain_objects';
export { InvestigateWidgetColumnSpan } from './types';

View file

@ -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;
};

View file

@ -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,
};
};

View file

@ -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>;

View file

@ -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;

View file

@ -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(),
},
},
};
}

View file

@ -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,
});
};
}

View file

@ -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),
};
});
},
};
}

View file

@ -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]);
}

View file

@ -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';

View file

@ -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,
});
},
};

View file

@ -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;
}

View file

@ -21,6 +21,7 @@
"@kbn/i18n",
"@kbn/utility-types",
"@kbn/core-security-common",
"@kbn/investigation-shared",
],
"exclude": ["target/**/*"]
}

View file

@ -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,
});
}

View file

@ -44,7 +44,6 @@ const defaultProps: Story = {
onCopy={() => {}}
onDelete={() => {}}
title="My visualization"
description="A long description"
{...props}
/>
</div>

View file

@ -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`

View file

@ -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

View file

@ -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>
);
})}

View file

@ -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;

View file

@ -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' });
},
}
);

View file

@ -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;
}

View file

@ -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' });
},
}
);
}

View file

@ -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,
};
}

View file

@ -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;

View file

@ -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', {

View file

@ -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>

View file

@ -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({

View file

@ -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,
};
}

View file

@ -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: [],
};

View file

@ -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);

View file

@ -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);
}

View file

@ -55,5 +55,6 @@
"@kbn/rule-data-utils",
"@kbn/shared-ux-router",
"@kbn/investigation-shared",
"@kbn/core-security-common",
],
}