feat(investigation): Add eventTypes filter on the API (#202829)

This commit is contained in:
Kevin Delemme 2024-12-12 11:49:34 -05:00 committed by GitHub
parent 668f776583
commit 2ab38a3664
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 538 additions and 439 deletions

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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { z } from '@kbn/zod';
import { eventSchema } from '../schema';
const eventResponseSchema = eventSchema;
type EventResponse = z.output<typeof eventResponseSchema>;
type EventSchema = z.output<typeof eventSchema>;
export { eventResponseSchema };
export type { EventResponse, EventSchema };

View file

@ -8,7 +8,7 @@
*/
import { z } from '@kbn/zod';
import { eventResponseSchema } from './event';
import { eventTypeSchema, eventSchema } from '../schema';
const getEventsParamsSchema = z
.object({
@ -17,12 +17,24 @@ const getEventsParamsSchema = z
rangeFrom: z.string(),
rangeTo: z.string(),
filter: z.string(),
eventTypes: z.string().transform((val, ctx) => {
const eventTypes = val.split(',');
const hasInvalidType = eventTypes.some((eventType) => !eventTypeSchema.parse(eventType));
if (hasInvalidType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid event type',
});
return z.NEVER;
}
return val.split(',').map((v) => eventTypeSchema.parse(v));
}),
})
.partial(),
})
.partial();
const getEventsResponseSchema = z.array(eventResponseSchema);
const getEventsResponseSchema = z.array(eventSchema);
type GetEventsParams = z.infer<typeof getEventsParamsSchema.shape.query>;
type GetEventsResponse = z.output<typeof getEventsResponseSchema>;

View file

@ -25,7 +25,6 @@ export type * from './investigation_note';
export type * from './update';
export type * from './update_item';
export type * from './update_note';
export type * from './event';
export type * from './get_events';
export type * from './entity';
export type * from './get_entities';
@ -48,7 +47,6 @@ export * from './investigation_note';
export * from './update';
export * from './update_item';
export * from './update_note';
export * from './event';
export * from './get_events';
export * from './entity';
export * from './get_entities';

View file

@ -17,8 +17,15 @@ const eventTypeSchema = z.union([
z.literal('anomaly'),
]);
const sourceSchema = z.record(z.string(), z.any());
const annotationEventSchema = z.object({
eventType: z.literal('annotation'),
id: z.string(),
title: z.string(),
description: z.string(),
timestamp: z.number(),
source: sourceSchema.optional(),
annotationType: z.string().optional(),
});
@ -31,21 +38,19 @@ const alertStatusSchema = z.union([
const alertEventSchema = z.object({
eventType: z.literal('alert'),
id: z.string(),
title: z.string(),
description: z.string(),
timestamp: z.number(),
source: sourceSchema.optional(),
alertStatus: alertStatusSchema,
});
const sourceSchema = z.record(z.string(), z.any());
const eventSchema = z.discriminatedUnion('eventType', [annotationEventSchema, alertEventSchema]);
const eventSchema = z.intersection(
z.object({
id: z.string(),
title: z.string(),
description: z.string(),
timestamp: z.number(),
eventType: eventTypeSchema,
source: sourceSchema.optional(),
}),
z.discriminatedUnion('eventType', [annotationEventSchema, alertEventSchema])
);
type EventResponse = z.output<typeof eventSchema>;
type AlertEventResponse = z.output<typeof alertEventSchema>;
type AnnotationEventResponse = z.output<typeof annotationEventSchema>;
export { eventSchema };
export type { EventResponse, AlertEventResponse, AnnotationEventResponse };
export { eventSchema, eventTypeSchema, alertEventSchema, annotationEventSchema };

View file

@ -12,8 +12,12 @@ export const investigationKeys = {
userProfiles: (profileIds: Set<string>) =>
[...investigationKeys.all, 'userProfiles', ...profileIds] as const,
tags: () => [...investigationKeys.all, 'tags'] as const,
events: (rangeFrom?: string, rangeTo?: string, filter?: string) =>
[...investigationKeys.all, 'events', rangeFrom, rangeTo, filter] as const,
events: (params: {
rangeFrom?: string;
rangeTo?: string;
filter?: string;
eventTypes?: string[];
}) => [...investigationKeys.all, 'events', params] as const,
stats: () => [...investigationKeys.all, 'stats'] as const,
lists: () => [...investigationKeys.all, 'list'] as const,
list: (params: { page: number; perPage: number; search?: string; filter?: string }) =>

View file

@ -11,8 +11,9 @@ import {
CreateInvestigationNoteParams,
CreateInvestigationNoteResponse,
} from '@kbn/investigation-shared';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useKibana } from './use_kibana';
import { investigationKeys } from './query_key_factory';
type ServerError = IHttpFetchError<ResponseErrorBody>;
@ -23,6 +24,7 @@ export function useAddInvestigationNote() {
notifications: { toasts },
},
} = useKibana();
const queryClient = useQueryClient();
return useMutation<
CreateInvestigationNoteResponse,
@ -39,7 +41,12 @@ export function useAddInvestigationNote() {
);
},
{
onSuccess: (response, {}) => {
onSuccess: (_, { investigationId }) => {
queryClient.invalidateQueries({
queryKey: investigationKeys.detailNotes(investigationId),
exact: false,
});
toasts.addSuccess(
i18n.translate('xpack.investigateApp.addInvestigationNote.successMessage', {
defaultMessage: 'Note saved',

View file

@ -6,13 +6,15 @@
*/
import { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { useKibana } from './use_kibana';
import { investigationKeys } from './query_key_factory';
type ServerError = IHttpFetchError<ResponseErrorBody>;
export function useDeleteInvestigationNote() {
const queryClient = useQueryClient();
const {
core: {
http,
@ -34,7 +36,12 @@ export function useDeleteInvestigationNote() {
);
},
{
onSuccess: (response, {}) => {
onSuccess: (response, { investigationId }) => {
queryClient.invalidateQueries({
queryKey: investigationKeys.detailNotes(investigationId),
exact: false,
});
toasts.addSuccess(
i18n.translate('xpack.investigateApp.useDeleteInvestigationNote.successMessage', {
defaultMessage: 'Note deleted',

View file

@ -44,6 +44,7 @@ export function useFetchAlert({ investigation }: UseFetchAlertParams): UseFetchA
});
},
staleTime: 60 * 1000,
retry: false,
refetchOnWindowFocus: false,
onError: (error: Error) => {
toasts.addError(error, {

View file

@ -51,8 +51,7 @@ export function useFetchAllInvestigationStats(): Response {
};
},
retry: false,
cacheTime: 600 * 1000, // 10 minutes
staleTime: 0,
staleTime: 15 * 1000,
onError: (error: Error) => {
toasts.addError(error, {
title: i18n.translate('xpack.investigateApp.useFetchAllInvestigationStats.errorTitle', {

View file

@ -35,8 +35,7 @@ export function useFetchAllInvestigationTags(): Response {
signal,
});
},
cacheTime: 600 * 1000, // 10_minutes
staleTime: 0,
staleTime: 15 * 1000,
refetchOnWindowFocus: false,
retry: false,
onError: (error: Error) => {

View file

@ -50,9 +50,7 @@ export function useFetchEntities({
});
},
refetchOnWindowFocus: false,
onError: (error: Error) => {
// ignore error
},
retry: false,
enabled: Boolean(investigationId && (serviceName || hostName || containerId)),
});

View file

@ -6,16 +6,14 @@
*/
import { i18n } from '@kbn/i18n';
import { useQuery } from '@tanstack/react-query';
import { GetEventsResponse } from '@kbn/investigation-shared';
import { useQuery } from '@tanstack/react-query';
import { isArray } from 'lodash';
import { investigationKeys } from './query_key_factory';
import { useKibana } from './use_kibana';
export interface Response {
isInitialLoading: boolean;
isLoading: boolean;
isRefetching: boolean;
isSuccess: boolean;
isError: boolean;
data?: GetEventsResponse;
}
@ -24,10 +22,12 @@ export function useFetchEvents({
rangeFrom,
rangeTo,
filter,
eventTypes,
}: {
rangeFrom?: string;
rangeTo?: string;
filter?: string;
eventTypes?: string[];
}): Response {
const {
core: {
@ -36,21 +36,20 @@ export function useFetchEvents({
},
} = useKibana();
const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({
queryKey: investigationKeys.events(rangeFrom, rangeTo, filter),
const { isLoading, isError, data } = useQuery({
queryKey: investigationKeys.events({ rangeFrom, rangeTo, filter, eventTypes }),
queryFn: async ({ signal }) => {
return await http.get<GetEventsResponse>(`/api/observability/events`, {
return http.get<GetEventsResponse>(`/api/observability/events`, {
query: {
rangeFrom,
rangeTo,
filter,
...(isArray(eventTypes) && eventTypes.length > 0 && { eventTypes: eventTypes.join(',') }),
},
version: '2023-10-31',
signal,
});
},
cacheTime: 600 * 1000, // 10_minutes
staleTime: 0,
refetchOnWindowFocus: false,
retry: false,
onError: (error: Error) => {
@ -64,10 +63,7 @@ export function useFetchEvents({
return {
data,
isInitialLoading,
isLoading,
isRefetching,
isSuccess,
isError,
};
}

View file

@ -64,8 +64,6 @@ export function useFetchInvestigationList({
retry: false,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
cacheTime: 600 * 1000, // 10 minutes
staleTime: 0,
onError: (error: Error) => {
toasts.addError(error, {
title: i18n.translate('xpack.investigateApp.useFetchInvestigationList.errorTitle', {

View file

@ -39,12 +39,16 @@ export function useUpdateInvestigationNote() {
},
{
onSuccess: (response, { investigationId }) => {
queryClient.invalidateQueries({
queryKey: investigationKeys.detailNotes(investigationId),
exact: false,
});
toasts.addSuccess(
i18n.translate('xpack.investigateApp.useUpdateInvestigationNote.successMessage', {
defaultMessage: 'Note updated',
})
);
queryClient.invalidateQueries({ queryKey: investigationKeys.detailNotes(investigationId) });
},
onError: (error, {}, context) => {
toasts.addError(

View file

@ -10,8 +10,8 @@ import { css } from '@emotion/css';
import { ESQLLangEditor } from '@kbn/esql/public';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { AddFromLibraryButton } from '../add_from_library_button';
import { useInvestigation } from '../../contexts/investigation_context';
import { AddFromLibraryButton } from '../add_from_library_button';
import { EsqlWidgetPreview } from './esql_widget_preview';
const emptyPreview = css`

View file

@ -4,11 +4,10 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { RootCauseAnalysisEvent } from '@kbn/observability-ai-server/root_cause_analysis';
import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common';
import React, { useState, useRef, useEffect } from 'react';
import { omit } from 'lodash';
import {
ALERT_FLAPPING_HISTORY,
ALERT_RULE_EXECUTION_TIMESTAMP,
@ -17,9 +16,11 @@ import {
EVENT_KIND,
} from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
import { isRequestAbortedError } from '@kbn/server-route-repository-client';
import { omit } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import { useKibana } from '../../../../hooks/use_kibana';
import { useInvestigation } from '../../contexts/investigation_context';
import { useUpdateInvestigation } from '../../../../hooks/use_update_investigation';
import { useInvestigation } from '../../contexts/investigation_context';
export interface InvestigationContextualInsight {
key: string;
@ -27,7 +28,7 @@ export interface InvestigationContextualInsight {
data: unknown;
}
export function AssistantHypothesis({ investigationId }: { investigationId: string }) {
export function AssistantHypothesis() {
const {
alert,
globalParams: { timeRange },
@ -87,7 +88,7 @@ export function AssistantHypothesis({ investigationId }: { investigationId: stri
.stream('POST /internal/observability/investigation/root_cause_analysis', {
params: {
body: {
investigationId,
investigationId: investigation!.id,
connectorId,
context: `The user is investigating an alert for the ${serviceName} service,
and wants to find the root cause. Here is the alert:
@ -156,7 +157,7 @@ export function AssistantHypothesis({ investigationId }: { investigationId: stri
setEvents([]);
if (investigation?.rootCauseAnalysis) {
updateInvestigation({
investigationId,
investigationId: investigation!.id,
payload: {
rootCauseAnalysis: {
events: [],

View file

@ -1,114 +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 React, { useMemo, useRef } from 'react';
import moment from 'moment';
import { Chart, Axis, AreaSeries, Position, ScaleType, Settings } from '@elastic/charts';
import { useActiveCursor } from '@kbn/charts-plugin/public';
import { EuiSkeletonText } from '@elastic/eui';
import { getBrushData } from '@kbn/observability-utils-browser/chart/utils';
import { AnnotationEvent } from './annotation_event';
import { TIME_LINE_THEME } from './timeline_theme';
import { useFetchEvents } from '../../../../hooks/use_fetch_events';
import { useInvestigation } from '../../contexts/investigation_context';
import { useKibana } from '../../../../hooks/use_kibana';
import { AlertEvent } from './alert_event';
export const EventsTimeLine = () => {
const { dependencies } = useKibana();
const baseTheme = dependencies.start.charts.theme.useChartsBaseTheme();
const { globalParams, updateInvestigationParams } = useInvestigation();
const { data: events, isLoading } = useFetchEvents({
rangeFrom: globalParams.timeRange.from,
rangeTo: globalParams.timeRange.to,
});
const chartRef = useRef(null);
const handleCursorUpdate = useActiveCursor(dependencies.start.charts.activeCursor, chartRef, {
isDateHistogram: true,
});
const data = useMemo(() => {
const points = [
{ x: moment(globalParams.timeRange.from).valueOf(), y: 0 },
{ x: moment(globalParams.timeRange.to).valueOf(), y: 0 },
];
// adding 100 fake points to the chart so the chart shows cursor on hover
for (let i = 0; i < 100; i++) {
const diff =
moment(globalParams.timeRange.to).valueOf() - moment(globalParams.timeRange.from).valueOf();
points.push({ x: moment(globalParams.timeRange.from).valueOf() + (diff / 100) * i, y: 0 });
}
return points;
}, [globalParams.timeRange.from, globalParams.timeRange.to]);
if (isLoading) {
return <EuiSkeletonText />;
}
const alertEvents = events?.filter((evt) => evt.eventType === 'alert');
const annotations = events?.filter((evt) => evt.eventType === 'annotation');
return (
<>
<Chart size={['100%', 100]} ref={chartRef}>
<Settings
xDomain={{
min: moment(globalParams.timeRange.from).valueOf(),
max: moment(globalParams.timeRange.to).valueOf(),
}}
theme={TIME_LINE_THEME}
baseTheme={baseTheme}
onPointerUpdate={handleCursorUpdate}
externalPointerEvents={{
tooltip: { visible: true },
}}
onBrushEnd={(brush) => {
const { from, to } = getBrushData(brush);
updateInvestigationParams({
timeRange: { from, to },
});
}}
/>
<Axis id="y" position={Position.Left} hide />
<Axis
id="x"
position={Position.Bottom}
tickFormat={(d) => moment(d).format('LTS')}
style={{
tickLine: {
visible: true,
strokeWidth: 1,
stroke: '#98A2B3',
},
}}
/>
{alertEvents?.map((event) => (
<AlertEvent key={event.id} event={event} />
))}
{annotations?.map((annotation) => (
<AnnotationEvent key={annotation.id} event={annotation} />
))}
<AreaSeries
id="Time"
xScaleType={ScaleType.Time}
xAccessor="x"
yAccessors={['y']}
data={data}
filterSeriesInTooltip={() => false}
/>
</Chart>
</>
);
};

View file

@ -7,8 +7,8 @@
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui';
import { css } from '@emotion/css';
import React from 'react';
import { useTheme } from '../../../../hooks/use_theme';
import { InvestigateTextButton } from '../../../../components/investigate_text_button';
import { useTheme } from '../../../../hooks/use_theme';
export const GRID_ITEM_HEADER_HEIGHT = 40;
@ -21,8 +21,6 @@ interface GridItemProps {
loading: boolean;
}
const editTitleButtonClassName = `investigateGridItemTitleEditButton`;
const titleContainerClassName = css`
overflow: hidden;
`;
@ -35,11 +33,6 @@ const titleItemClassName = css`
}
`;
const panelContainerClassName = css`
overflow: clip;
overflow-clip-margin: 20px;
`;
const panelClassName = css`
overflow-y: auto;
`;
@ -47,9 +40,6 @@ const panelClassName = css`
const panelContentClassName = css`
overflow-y: auto;
height: 100%;
> [data-shared-item] {
height: 100%;
}
`;
export function GridItem({ id, title, children, onDelete, onCopy, loading }: GridItemProps) {
@ -64,10 +54,6 @@ export function GridItem({ id, title, children, onDelete, onCopy, loading }: Gri
max-width: 100%;
transition: opacity ${theme.animation.normal} ${theme.animation.resistance};
overflow: auto;
&:not(:hover) .${editTitleButtonClassName} {
opacity: 0;
}
`;
return (
@ -119,9 +105,7 @@ export function GridItem({ id, title, children, onDelete, onCopy, loading }: Gri
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow className={panelContainerClassName}>
<div className={panelContentClassName}>{children}</div>
</EuiFlexItem>
<EuiFlexItem className={panelContentClassName}>{children}</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);

View file

@ -72,12 +72,11 @@ export function InvestigationDetails({ user }: Props) {
],
}}
>
<EuiFlexGroup direction="row" responsive>
<EuiFlexItem grow={8}>
<EuiFlexGroup direction="row">
<EuiFlexItem grow={4}>
<InvestigationItems />
</EuiFlexItem>
<EuiFlexItem grow={2}>
<EuiFlexItem>
<InvestigationNotes user={user} />
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -5,52 +5,20 @@
* 2.0.
*/
import datemath from '@elastic/datemath';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import React from 'react';
import { EventsTimeLine } from '../events_timeline/events_timeline';
import { useInvestigation } from '../../contexts/investigation_context';
import { AddInvestigationItem } from '../add_investigation_item/add_investigation_item';
import { InvestigationItemsList } from '../investigation_items_list/investigation_items_list';
import { InvestigationSearchBar } from '../investigation_search_bar/investigation_search_bar';
import { AssistantHypothesis } from '../assistant_hypothesis/assistant_hypothesis';
import { InvestigationItemsList } from '../investigation_items_list/investigation_items_list';
import { InvestigationTimeline } from '../investigation_timeline/investigation_timeline';
export function InvestigationItems() {
const { globalParams, updateInvestigationParams, investigation } = useInvestigation();
return (
<>
<EuiFlexGroup direction="column" gutterSize="s">
<InvestigationSearchBar
dateRangeFrom={globalParams.timeRange.from}
dateRangeTo={globalParams.timeRange.to}
onQuerySubmit={async ({ dateRange }) => {
const nextTimeRange = {
from: datemath.parse(dateRange.from)!.toISOString(),
to: datemath.parse(dateRange.to)!.toISOString(),
};
updateInvestigationParams({ timeRange: nextTimeRange });
}}
/>
<EuiFlexItem grow={false}>
<EventsTimeLine />
</EuiFlexItem>
{investigation?.id && (
<EuiFlexItem grow={false}>
<AssistantHypothesis investigationId={investigation.id} />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<InvestigationItemsList />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup direction="column" gutterSize="m" responsive>
<InvestigationTimeline />
<AssistantHypothesis />
<InvestigationItemsList />
<AddInvestigationItem />
</>
</EuiFlexGroup>
);
}

View file

@ -18,7 +18,7 @@ export function InvestigationItemsList() {
}
return (
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGroup direction="column" gutterSize="m" responsive>
{renderableItems.map((item) => {
return (
<EuiFlexItem grow={false} key={`item-${item.id}`}>

View file

@ -11,6 +11,7 @@ import { InvestigationNoteResponse } from '@kbn/investigation-shared';
import React, { useState } from 'react';
import { ResizableTextInput } from './resizable_text_input';
import { useInvestigation } from '../../contexts/investigation_context';
import { useUpdateInvestigationNote } from '../../../../hooks/use_update_investigation_note';
interface Props {
note: InvestigationNoteResponse;
@ -19,11 +20,22 @@ interface Props {
export function EditNoteForm({ note, onClose }: Props) {
const [noteInput, setNoteInput] = useState(note.content);
const { updateNote, isUpdatingNote } = useInvestigation();
const { investigation } = useInvestigation();
const { mutate: updateNote, isLoading: isUpdatingNote } = useUpdateInvestigationNote();
const onUpdate = async () => {
await updateNote(note.id, noteInput.trim());
onClose();
const onUpdate = () => {
updateNote(
{
investigationId: investigation!.id,
noteId: note.id,
note: { content: noteInput.trim() },
},
{
onSuccess: () => {
onClose();
},
}
);
};
return (

View file

@ -18,6 +18,8 @@ import { i18n } from '@kbn/i18n';
import { InvestigationNoteResponse } from '@kbn/investigation-shared';
import { AuthenticatedUser } from '@kbn/security-plugin/common';
import React, { useState } from 'react';
import { useAddInvestigationNote } from '../../../../hooks/use_add_investigation_note';
import { useFetchInvestigationNotes } from '../../../../hooks/use_fetch_investigation_notes';
import { useFetchUserProfiles } from '../../../../hooks/use_fetch_user_profiles';
import { useTheme } from '../../../../hooks/use_theme';
import { useInvestigation } from '../../contexts/investigation_context';
@ -30,15 +32,25 @@ export interface Props {
export function InvestigationNotes({ user }: Props) {
const theme = useTheme();
const { investigation, addNote, isAddingNote } = useInvestigation();
const { investigation } = useInvestigation();
const { data: notes } = useFetchInvestigationNotes({
investigationId: investigation!.id,
});
const { mutate: addNote, isLoading: isAddingNote } = useAddInvestigationNote();
const { data: userProfiles, isLoading: isLoadingUserProfiles } = useFetchUserProfiles({
profileIds: new Set(investigation?.notes.map((note) => note.createdBy)),
});
const [noteInput, setNoteInput] = useState('');
const onAddNote = async (content: string) => {
await addNote(content);
setNoteInput('');
const onAddNote = (content: string) => {
addNote(
{ investigationId: investigation!.id, note: { content } },
{
onSuccess: () => {
setNoteInput('');
},
}
);
};
const panelClassName = css`
@ -58,7 +70,7 @@ export function InvestigationNotes({ user }: Props) {
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner>
<EuiFlexGroup direction="column" gutterSize="m">
{investigation?.notes.map((currNote: InvestigationNoteResponse) => {
{notes?.map((currNote: InvestigationNoteResponse) => {
return (
<Note
key={currNote.id}

View file

@ -21,6 +21,7 @@ import React, { useState } from 'react';
import { useTheme } from '../../../../hooks/use_theme';
import { useInvestigation } from '../../contexts/investigation_context';
import { EditNoteForm } from './edit_note_form';
import { useDeleteInvestigationNote } from '../../../../hooks/use_delete_investigation_note';
const textContainerClassName = css`
padding-top: 2px;
@ -36,7 +37,12 @@ interface Props {
export function Note({ note, isOwner, userProfile, userProfileLoading }: Props) {
const theme = useTheme();
const [isEditing, setIsEditing] = useState(false);
const { deleteNote, isDeletingNote } = useInvestigation();
const { investigation } = useInvestigation();
const { mutate: deleteNote, isLoading: isDeletingNote } = useDeleteInvestigationNote();
const onDeleteNote = () => {
deleteNote({ investigationId: investigation!.id, noteId: note.id });
};
const timelineContainerClassName = css`
padding-bottom: 16px;
@ -98,7 +104,7 @@ export function Note({ note, isOwner, userProfile, userProfileLoading }: Props)
iconSize="s"
iconType="trash"
disabled={isDeletingNote}
onClick={async () => await deleteNote(note.id)}
onClick={onDeleteNote}
data-test-subj="deleteInvestigationNoteButton"
className={actionButtonClassname}
/>

View file

@ -1,56 +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 { css } from '@emotion/css';
import type { TimeRange } from '@kbn/es-query';
import { SearchBar } from '@kbn/unified-search-plugin/public';
import React from 'react';
import { useKibana } from '../../../../hooks/use_kibana';
const parentClassName = css`
width: 100%;
`;
interface Props {
dateRangeFrom?: string;
dateRangeTo?: string;
onQuerySubmit: (payload: { dateRange: TimeRange }, isUpdate?: boolean) => void;
onRefresh?: Required<React.ComponentProps<typeof SearchBar>>['onRefresh'];
}
export function InvestigationSearchBar({
dateRangeFrom,
dateRangeTo,
onQuerySubmit,
onRefresh,
}: Props) {
const {
dependencies: {
start: { unifiedSearch },
},
} = useKibana();
return (
<div className={parentClassName}>
<unifiedSearch.ui.SearchBar
appName="investigate"
onQuerySubmit={({ dateRange }) => {
onQuerySubmit({ dateRange });
}}
showQueryInput={false}
showFilterBar={false}
showQueryMenu={false}
showDatePicker
showSubmitButton={true}
dateRangeFrom={dateRangeFrom}
dateRangeTo={dateRangeTo}
onRefresh={onRefresh}
displayStyle="inPage"
disableQueryLanguageSwitcher
/>
</div>
);
}

View file

@ -5,13 +5,13 @@
* 2.0.
*/
import { LineAnnotation, AnnotationDomainType } from '@elastic/charts';
import { AnnotationDomainType, LineAnnotation } from '@elastic/charts';
import { EuiIcon } from '@elastic/eui';
import React from 'react';
import { AlertEventResponse } from '@kbn/investigation-shared';
import moment from 'moment';
import { EventSchema } from '@kbn/investigation-shared';
import React from 'react';
export const AlertEvent = ({ event }: { event: EventSchema }) => {
export const AlertEvent = ({ event }: { event: AlertEventResponse }) => {
return (
<LineAnnotation
id={event.id}

View file

@ -5,13 +5,13 @@
* 2.0.
*/
import React from 'react';
import moment from 'moment';
import { AnnotationDomainType, LineAnnotation } from '@elastic/charts';
import { EuiIcon } from '@elastic/eui';
import { EventSchema } from '@kbn/investigation-shared';
import { AnnotationEventResponse } from '@kbn/investigation-shared';
import moment from 'moment';
import React from 'react';
export function AnnotationEvent({ event }: { event: EventSchema }) {
export function AnnotationEvent({ event }: { event: AnnotationEventResponse }) {
const timestamp = event.timestamp;
return (

View file

@ -0,0 +1,115 @@
/*
* 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 { AreaSeries, Axis, Chart, Position, ScaleType, Settings } from '@elastic/charts';
import { EuiSkeletonText } from '@elastic/eui';
import { useActiveCursor } from '@kbn/charts-plugin/public';
import { getBrushData } from '@kbn/observability-utils-browser/chart/utils';
import { assertNever } from '@kbn/std';
import moment from 'moment';
import React, { useMemo, useRef } from 'react';
import { useFetchEvents } from '../../../../../hooks/use_fetch_events';
import { useKibana } from '../../../../../hooks/use_kibana';
import { useInvestigation } from '../../../contexts/investigation_context';
import { AlertEvent } from './alert_event';
import { AnnotationEvent } from './annotation_event';
import { TIMELINE_THEME } from './timeline_theme';
interface Props {
eventTypes: string[];
}
export const EventsTimeline = ({ eventTypes }: Props) => {
const { dependencies } = useKibana();
const baseTheme = dependencies.start.charts.theme.useChartsBaseTheme();
const { globalParams, updateInvestigationParams } = useInvestigation();
const chartRef = useRef(null);
const { data: events, isLoading } = useFetchEvents({
rangeFrom: globalParams.timeRange.from,
rangeTo: globalParams.timeRange.to,
eventTypes,
});
const handleCursorUpdate = useActiveCursor(dependencies.start.charts.activeCursor, chartRef, {
isDateHistogram: true,
});
const data = useMemo(() => {
const points = [
{ x: moment(globalParams.timeRange.from).valueOf(), y: 0 },
{ x: moment(globalParams.timeRange.to).valueOf(), y: 0 },
];
// adding 100 fake points to the chart so the chart shows cursor on hover
for (let i = 0; i < 100; i++) {
const diff =
moment(globalParams.timeRange.to).valueOf() - moment(globalParams.timeRange.from).valueOf();
points.push({ x: moment(globalParams.timeRange.from).valueOf() + (diff / 100) * i, y: 0 });
}
return points;
}, [globalParams.timeRange.from, globalParams.timeRange.to]);
if (isLoading) {
return <EuiSkeletonText />;
}
return (
<Chart size={['100%', 64]} ref={chartRef}>
<Settings
xDomain={{
min: moment(globalParams.timeRange.from).valueOf(),
max: moment(globalParams.timeRange.to).valueOf(),
}}
theme={TIMELINE_THEME}
baseTheme={baseTheme}
onPointerUpdate={handleCursorUpdate}
externalPointerEvents={{
tooltip: { visible: true },
}}
onBrushEnd={(brush) => {
const { from, to } = getBrushData(brush);
updateInvestigationParams({
timeRange: { from, to },
});
}}
/>
<Axis id="y" position={Position.Left} hide />
<Axis
id="x"
position={Position.Bottom}
tickFormat={(d) => moment(d).format('LTS')}
style={{
tickLine: {
visible: true,
strokeWidth: 1,
stroke: '#98A2B3',
},
}}
/>
{events?.map((event) => {
if (event.eventType === 'alert') {
return <AlertEvent key={event.id} event={event} />;
}
if (event.eventType === 'annotation') {
return <AnnotationEvent key={event.id} event={event} />;
}
assertNever(event);
})}
<AreaSeries
id="Time"
xScaleType={ScaleType.Time}
xAccessor="x"
yAccessors={['y']}
data={data}
filterSeriesInTooltip={() => false}
/>
</Chart>
);
};

View file

@ -7,7 +7,7 @@
import { PartialTheme } from '@elastic/charts';
export const TIME_LINE_THEME: PartialTheme = {
export const TIMELINE_THEME: PartialTheme = {
highlighter: {
point: {
opacity: 0,

View file

@ -0,0 +1,27 @@
/*
* 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 { EuiFlexGroup, EuiPanel } from '@elastic/eui';
import React, { useState } from 'react';
import { EventsTimeline } from './events_timeline/events_timeline';
import { InvestigationTimelineFilterBar } from './investigation_timeline_filter_bar/investigation_timeline_filter_bar';
export function InvestigationTimeline() {
const [eventTypes, setEventTypes] = useState<string[]>([]);
return (
<EuiPanel hasBorder={true} grow={false} paddingSize="xs">
<EuiFlexGroup direction="column" gutterSize="s">
<InvestigationTimelineFilterBar
onEventTypesSelected={(selected: string[]) => setEventTypes(selected)}
/>
<EventsTimeline eventTypes={eventTypes} />
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -0,0 +1,106 @@
/*
* 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 {
EuiFilterButton,
EuiFilterGroup,
EuiPopover,
EuiPopoverTitle,
EuiSelectable,
EuiSelectableOption,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
interface Props {
onSelected: (eventTypes: string[]) => void;
}
export function InvestigationEventTypesFilter({ onSelected }: Props) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [items, setItems] = useState<EuiSelectableOption[]>([
{
key: 'alert',
label: i18n.translate('xpack.investigateApp.investigationEventTypesFilter.alertLabel', {
defaultMessage: 'Alert',
}),
checked: 'on',
},
{
key: 'annotation',
label: i18n.translate('xpack.investigateApp.investigationEventTypesFilter.annotationLabel', {
defaultMessage: 'Annotation',
}),
checked: 'on',
},
]);
const togglePopover = () => {
setIsPopoverOpen(!isPopoverOpen);
};
const closePopover = () => {
setIsPopoverOpen(false);
};
const filterGroupPopoverId = useGeneratedHtmlId({
prefix: 'filterGroupPopover',
});
const handleChange = (newOptions: EuiSelectableOption[]) => {
setItems(newOptions);
const selected = newOptions
.filter((option) => option.checked === 'on')
.map((option) => option.key!);
onSelected(selected);
};
const button = (
<EuiFilterButton
grow={false}
iconType="arrowDown"
badgeColor="success"
onClick={togglePopover}
isSelected={isPopoverOpen}
numFilters={items.filter((item) => item.checked !== 'off').length}
hasActiveFilters={!!items.find((item) => item.checked === 'on')}
numActiveFilters={items.filter((item) => item.checked === 'on').length}
>
{i18n.translate(
'xpack.investigateApp.investigationEventTypesFilter.filtersFilterButtonLabel',
{ defaultMessage: 'Filters' }
)}
</EuiFilterButton>
);
return (
<EuiFilterGroup compressed>
<EuiPopover
id={filterGroupPopoverId}
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
>
<EuiSelectable options={items} onChange={handleChange}>
{(list) => (
<div style={{ width: 200 }}>
<EuiPopoverTitle paddingSize="s">
{i18n.translate(
'xpack.investigateApp.investigationEventTypesFilter.filterEventTypePopoverTitleLabel',
{ defaultMessage: 'Filter event type' }
)}
</EuiPopoverTitle>
{list}
</div>
)}
</EuiSelectable>
</EuiPopover>
</EuiFilterGroup>
);
}

View file

@ -0,0 +1,71 @@
/*
* 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 { EuiDatePicker, EuiDatePickerRange, EuiFlexGroup } from '@elastic/eui';
import { css } from '@emotion/react';
import moment from 'moment';
import React from 'react';
import { useInvestigation } from '../../../contexts/investigation_context';
import { InvestigationEventTypesFilter } from './investigation_event_types_filter';
interface Props {
onEventTypesSelected: (eventTypes: string[]) => void;
}
export function InvestigationTimelineFilterBar({ onEventTypesSelected }: Props) {
const { globalParams, updateInvestigationParams } = useInvestigation();
return (
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="flexStart"
justifyContent="spaceBetween"
css={css`
padding: 8px 8px 0px 8px;
max-height: fit-content;
`}
>
<InvestigationEventTypesFilter onSelected={onEventTypesSelected} />
<EuiDatePickerRange
compressed
startDateControl={
<EuiDatePicker
selected={moment(globalParams.timeRange.from)}
onChange={(date) => {
if (!date) return;
updateInvestigationParams({
timeRange: {
from: date.toISOString(),
to: globalParams.timeRange.to,
},
});
}}
showTimeSelect
/>
}
endDateControl={
<EuiDatePicker
selected={moment(globalParams.timeRange.to)}
onChange={(date) => {
if (!date) return;
updateInvestigationParams({
timeRange: {
from: globalParams.timeRange.from,
to: date.toISOString(),
},
});
}}
showTimeSelect
/>
}
/>
</EuiFlexGroup>
);
}

View file

@ -7,19 +7,16 @@
import { i18n } from '@kbn/i18n';
import { type GlobalWidgetParameters } from '@kbn/investigate-plugin/public';
import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common';
import { GetInvestigationResponse, InvestigationItem, Item } from '@kbn/investigation-shared';
import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common';
import { isEqual } from 'lodash';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { useAddInvestigationItem } from '../../../hooks/use_add_investigation_item';
import { useAddInvestigationNote } from '../../../hooks/use_add_investigation_note';
import { useDeleteInvestigationItem } from '../../../hooks/use_delete_investigation_item';
import { useDeleteInvestigationNote } from '../../../hooks/use_delete_investigation_note';
import { useFetchInvestigation } from '../../../hooks/use_fetch_investigation';
import { useFetchAlert } from '../../../hooks/use_fetch_alert';
import { useFetchInvestigation } from '../../../hooks/use_fetch_investigation';
import { useKibana } from '../../../hooks/use_kibana';
import { useUpdateInvestigation } from '../../../hooks/use_update_investigation';
import { useUpdateInvestigationNote } from '../../../hooks/use_update_investigation_note';
export type RenderedInvestigationItem = InvestigationItem & {
loading: boolean;
@ -36,13 +33,6 @@ interface InvestigationContextProps {
deleteItem: (itemId: string) => Promise<void>;
isAddingItem: boolean;
isDeletingItem: boolean;
// note
addNote: (content: string) => Promise<void>;
updateNote: (noteId: string, content: string) => Promise<void>;
deleteNote: (noteId: string) => Promise<void>;
isAddingNote: boolean;
isUpdatingNote: boolean;
isDeletingNote: boolean;
}
export const InvestigationContext = createContext<InvestigationContextProps>({
@ -54,13 +44,6 @@ export const InvestigationContext = createContext<InvestigationContextProps>({
deleteItem: async () => {},
isAddingItem: false,
isDeletingItem: false,
// note
addNote: async () => {},
updateNote: async (noteId: string, content: string) => {},
deleteNote: async (noteId: string) => {},
isAddingNote: false,
isUpdatingNote: false,
isDeletingNote: false,
});
export function useInvestigation() {
@ -90,32 +73,6 @@ export function InvestigationProvider({
Record<string, { globalParams: GlobalWidgetParameters; item: RenderedInvestigationItem }>
>({});
const { mutateAsync: addInvestigationNote, isLoading: isAddingNote } = useAddInvestigationNote();
const { mutateAsync: updateInvestigationNote, isLoading: isUpdatingNote } =
useUpdateInvestigationNote();
const { mutateAsync: deleteInvestigationNote, isLoading: isDeletingNote } =
useDeleteInvestigationNote();
const addNote = async (content: string) => {
await addInvestigationNote({ investigationId: initialInvestigation.id, note: { content } });
refetch();
};
const updateNote = async (noteId: string, content: string) => {
await updateInvestigationNote({
investigationId: initialInvestigation.id,
noteId,
note: { content: content.trim() },
});
refetch();
};
const deleteNote = async (noteId: string) => {
await deleteInvestigationNote({ investigationId: initialInvestigation.id, noteId });
refetch();
};
const { mutateAsync: updateInvestigation } = useUpdateInvestigation();
const { mutateAsync: addInvestigationItem, isLoading: isAddingItem } = useAddInvestigationItem();
const { mutateAsync: deleteInvestigationItem, isLoading: isDeletingItem } =
@ -221,12 +178,6 @@ export function InvestigationProvider({
deleteItem,
isAddingItem,
isDeletingItem,
addNote,
updateNote,
deleteNote,
isAddingNote,
isUpdatingNote,
isDeletingNote,
}}
>
{children}

View file

@ -6,6 +6,8 @@
*/
import {
GetEntitiesResponse,
GetEventsResponse,
createInvestigationItemParamsSchema,
createInvestigationNoteParamsSchema,
createInvestigationParamsSchema,
@ -16,9 +18,7 @@ import {
getAllInvestigationStatsParamsSchema,
getAllInvestigationTagsParamsSchema,
getEntitiesParamsSchema,
GetEntitiesResponse,
getEventsParamsSchema,
GetEventsResponse,
getInvestigationItemsParamsSchema,
getInvestigationNotesParamsSchema,
getInvestigationParamsSchema,
@ -335,12 +335,17 @@ const getEventsRoute = createInvestigateAppServerRoute({
const alertsClient: AlertsClient = await getAlertsClient({ plugins, request });
const events: GetEventsResponse = [];
if (annotationsClient) {
const includeAllEventTypes = !params?.query?.eventTypes || params.query.eventTypes.length === 0;
if (
annotationsClient &&
(includeAllEventTypes || params?.query?.eventTypes?.includes('annotation'))
) {
const annotationEvents = await getAnnotationEvents(params?.query ?? {}, annotationsClient);
events.push(...annotationEvents);
}
if (alertsClient) {
if (alertsClient && (includeAllEventTypes || params?.query?.eventTypes?.includes('alert'))) {
const alertEvents = await getAlertEvents(params?.query ?? {}, alertsClient);
events.push(...alertEvents);
}

View file

@ -7,9 +7,11 @@
import datemath from '@elastic/datemath';
import {
AlertEventResponse,
AnnotationEventResponse,
GetEventsParams,
GetEventsResponse,
getEventsResponseSchema,
alertEventSchema,
annotationEventSchema,
} from '@kbn/investigation-shared';
import { ScopedAnnotationsClient } from '@kbn/observability-plugin/server';
import {
@ -19,13 +21,13 @@ import {
ALERT_STATUS,
ALERT_UUID,
} from '@kbn/rule-data-utils';
import { AlertsClient } from './get_alerts_client';
import { rangeQuery } from '../lib/queries';
import { AlertsClient } from './get_alerts_client';
export async function getAnnotationEvents(
params: GetEventsParams,
annotationsClient: ScopedAnnotationsClient
): Promise<GetEventsResponse> {
): Promise<AnnotationEventResponse[]> {
const response = await annotationsClient.find({
start: params?.rangeFrom,
end: params?.rangeTo,
@ -60,13 +62,13 @@ export async function getAnnotationEvents(
};
});
return getEventsResponseSchema.parse(events);
return annotationEventSchema.array().parse(events);
}
export async function getAlertEvents(
params: GetEventsParams,
alertsClient: AlertsClient
): Promise<GetEventsResponse> {
): Promise<AlertEventResponse[]> {
const startInMs = datemath.parse(params?.rangeFrom ?? 'now-15m')!.valueOf();
const endInMs = datemath.parse(params?.rangeTo ?? 'now')!.valueOf();
const filterJSON = params?.filter ? JSON.parse(params.filter) : {};
@ -101,5 +103,5 @@ export async function getAlertEvents(
};
});
return getEventsResponseSchema.parse(events);
return alertEventSchema.array().parse(events);
}

View file

@ -17,67 +17,68 @@
".storybook/**/*.js"
],
"kbn_references": [
"@kbn/esql",
"@kbn/alerting-plugin",
"@kbn/apm-data-access-plugin",
"@kbn/calculate-auto",
"@kbn/charts-plugin",
"@kbn/config-schema",
"@kbn/content-management-plugin",
"@kbn/core-elasticsearch-server",
"@kbn/core-saved-objects-server",
"@kbn/core-security-common",
"@kbn/core",
"@kbn/data-views-plugin",
"@kbn/expressions-plugin",
"@kbn/kibana-utils-plugin",
"@kbn/utility-types-jest",
"@kbn/es-types",
"@kbn/data-plugin",
"@kbn/data-views-plugin",
"@kbn/dataset-quality-plugin",
"@kbn/deeplinks-observability",
"@kbn/embeddable-plugin",
"@kbn/unified-search-plugin",
"@kbn/entities-schema",
"@kbn/es-query",
"@kbn/es-types",
"@kbn/esql-utils",
"@kbn/esql",
"@kbn/expressions-plugin",
"@kbn/field-types",
"@kbn/i18n-react",
"@kbn/i18n",
"@kbn/inference-common",
"@kbn/inference-plugin",
"@kbn/investigate-plugin",
"@kbn/investigation-shared",
"@kbn/kibana-react-plugin",
"@kbn/server-route-repository",
"@kbn/server-route-repository-client",
"@kbn/kibana-utils-plugin",
"@kbn/lens-embeddable-utils",
"@kbn/lens-plugin",
"@kbn/licensing-plugin",
"@kbn/logging",
"@kbn/management-settings-ids",
"@kbn/ml-random-sampler-utils",
"@kbn/observability-ai-assistant-app-plugin",
"@kbn/observability-ai-assistant-plugin",
"@kbn/observability-ai-server",
"@kbn/observability-plugin",
"@kbn/observability-shared-plugin",
"@kbn/observability-utils-browser",
"@kbn/observability-utils-server",
"@kbn/presentation-containers",
"@kbn/react-kibana-context-theme",
"@kbn/rule-data-utils",
"@kbn/rule-registry-plugin",
"@kbn/saved-objects-finder-plugin",
"@kbn/security-plugin",
"@kbn/server-route-repository-client",
"@kbn/server-route-repository",
"@kbn/shared-ux-link-redirect-app",
"@kbn/shared-ux-router",
"@kbn/i18n",
"@kbn/investigation-shared",
"@kbn/lens-plugin",
"@kbn/rule-registry-plugin",
"@kbn/security-plugin",
"@kbn/rule-data-utils",
"@kbn/investigate-plugin",
"@kbn/observability-utils-browser",
"@kbn/lens-embeddable-utils",
"@kbn/i18n-react",
"@kbn/es-query",
"@kbn/saved-objects-finder-plugin",
"@kbn/presentation-containers",
"@kbn/observability-ai-server",
"@kbn/charts-plugin",
"@kbn/observability-shared-plugin",
"@kbn/core-security-common",
"@kbn/deeplinks-observability",
"@kbn/logging",
"@kbn/esql-utils",
"@kbn/observability-ai-assistant-plugin",
"@kbn/observability-ai-assistant-app-plugin",
"@kbn/content-management-plugin",
"@kbn/dataset-quality-plugin",
"@kbn/ui-actions-plugin",
"@kbn/field-types",
"@kbn/entities-schema",
"@kbn/observability-plugin",
"@kbn/config-schema",
"@kbn/visualization-utils",
"@kbn/usage-collection-plugin",
"@kbn/calculate-auto",
"@kbn/ml-random-sampler-utils",
"@kbn/zod",
"@kbn/inference-common",
"@kbn/core-elasticsearch-server",
"@kbn/sse-utils",
"@kbn/management-settings-ids",
"@kbn/observability-utils-server",
"@kbn/licensing-plugin",
"@kbn/core-saved-objects-server",
"@kbn/alerting-plugin",
"@kbn/slo-plugin",
"@kbn/inference-plugin",
"@kbn/spaces-plugin",
"@kbn/apm-data-access-plugin",
"@kbn/sse-utils",
"@kbn/std",
"@kbn/ui-actions-plugin",
"@kbn/unified-search-plugin",
"@kbn/usage-collection-plugin",
"@kbn/utility-types-jest",
"@kbn/visualization-utils",
"@kbn/zod",
],
}