diff --git a/packages/kbn-investigation-shared/src/rest_specs/get_all_investigation_stats.ts b/packages/kbn-investigation-shared/src/rest_specs/get_all_investigation_stats.ts new file mode 100644 index 0000000000000..bee9f15db587d --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/get_all_investigation_stats.ts @@ -0,0 +1,25 @@ +/* + * 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 { statusSchema } from '../schema'; + +const getAllInvestigationStatsParamsSchema = z.object({ + query: z.object({}), +}); + +const getAllInvestigationStatsResponseSchema = z.object({ + count: z.record(statusSchema, z.number()), + total: z.number(), +}); + +type GetAllInvestigationStatsResponse = z.output; + +export { getAllInvestigationStatsParamsSchema, getAllInvestigationStatsResponseSchema }; +export type { GetAllInvestigationStatsResponse }; diff --git a/packages/kbn-investigation-shared/src/rest_specs/index.ts b/packages/kbn-investigation-shared/src/rest_specs/index.ts index 9b81aca896f55..c00ec5035765e 100644 --- a/packages/kbn-investigation-shared/src/rest_specs/index.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/index.ts @@ -17,6 +17,7 @@ export type * from './find'; export type * from './get'; export type * from './get_items'; export type * from './get_notes'; +export type * from './get_all_investigation_stats'; export type * from './get_all_investigation_tags'; export type * from './investigation'; export type * from './investigation_item'; @@ -35,6 +36,7 @@ export * from './find'; export * from './get'; export * from './get_items'; export * from './get_notes'; +export * from './get_all_investigation_stats'; export * from './get_all_investigation_tags'; export * from './investigation'; export * from './investigation_item'; diff --git a/packages/kbn-investigation-shared/src/schema/index.ts b/packages/kbn-investigation-shared/src/schema/index.ts index f48b6a40416d0..7491ecce76cc2 100644 --- a/packages/kbn-investigation-shared/src/schema/index.ts +++ b/packages/kbn-investigation-shared/src/schema/index.ts @@ -11,3 +11,5 @@ export * from './investigation'; export * from './investigation_item'; export * from './investigation_note'; export * from './origin'; + +export type * from './investigation'; diff --git a/packages/kbn-investigation-shared/src/schema/investigation.ts b/packages/kbn-investigation-shared/src/schema/investigation.ts index 47d198665657d..9be39b5b2a7b3 100644 --- a/packages/kbn-investigation-shared/src/schema/investigation.ts +++ b/packages/kbn-investigation-shared/src/schema/investigation.ts @@ -36,4 +36,7 @@ const investigationSchema = z.object({ items: z.array(investigationItemSchema), }); +type Status = z.infer; + +export type { Status }; export { investigationSchema, statusSchema }; diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts index 4fa97e3eda1dd..44352e46997ea 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/query_key_factory.ts @@ -12,6 +12,7 @@ export const investigationKeys = { userProfiles: (profileIds: Set) => [...investigationKeys.all, 'userProfiles', ...profileIds] as const, tags: () => [...investigationKeys.all, 'tags'] as const, + stats: () => [...investigationKeys.all, 'stats'] as const, lists: () => [...investigationKeys.all, 'list'] as const, list: (params: { page: number; perPage: number; search?: string; filter?: string }) => [...investigationKeys.lists(), params] as const, diff --git a/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_all_investigation_stats.ts b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_all_investigation_stats.ts new file mode 100644 index 0000000000000..2b2c8b92b0d4f --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/hooks/use_fetch_all_investigation_stats.ts @@ -0,0 +1,73 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { GetAllInvestigationStatsResponse, Status } from '@kbn/investigation-shared'; +import { useQuery } from '@tanstack/react-query'; +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: { count: Record; total: number } | undefined; +} + +export function useFetchAllInvestigationStats(): Response { + const { + core: { + http, + notifications: { toasts }, + }, + } = useKibana(); + + const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({ + queryKey: investigationKeys.stats(), + queryFn: async ({ signal }) => { + const response = await http.get( + `/api/observability/investigations/_stats`, + { + version: '2023-10-31', + signal, + } + ); + + return { + count: { + triage: response.count.triage ?? 0, + active: response.count.active ?? 0, + mitigated: response.count.mitigated ?? 0, + resolved: response.count.resolved ?? 0, + cancelled: response.count.cancelled ?? 0, + }, + total: response.total ?? 0, + }; + }, + retry: false, + cacheTime: 600 * 1000, // 10 minutes + staleTime: 0, + onError: (error: Error) => { + toasts.addError(error, { + title: i18n.translate('xpack.investigateApp.useFetchAllInvestigationStats.errorTitle', { + defaultMessage: 'Something went wrong while fetching the investigation stats', + }), + }); + }, + }); + + return { + data, + isInitialLoading, + isLoading, + isRefetching, + isSuccess, + isError, + }; +} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx index d4aa75fc2bdf2..a65eb12001342 100644 --- a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_list.tsx @@ -11,6 +11,7 @@ import { EuiBasicTable, EuiBasicTableColumn, EuiFlexGroup, + EuiFlexItem, EuiLink, EuiLoadingSpinner, EuiText, @@ -25,6 +26,7 @@ import { useFetchInvestigationList } from '../../../hooks/use_fetch_investigatio import { useFetchUserProfiles } from '../../../hooks/use_fetch_user_profiles'; import { useKibana } from '../../../hooks/use_kibana'; import { InvestigationListActions } from './investigation_list_actions'; +import { InvestigationStats } from './investigation_stats'; import { InvestigationsError } from './investigations_error'; import { SearchBar } from './search_bar/search_bar'; @@ -109,11 +111,15 @@ export function InvestigationList() { defaultMessage: 'Tags', }), render: (value: InvestigationResponse['tags']) => { - return value.map((tag) => ( - - {tag} - - )); + return ( + + {value.map((tag) => ( + + {tag} + + ))} + + ); }, }, { @@ -180,6 +186,7 @@ export function InvestigationList() { return ( + setSearch(value)} diff --git a/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_stats.tsx b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_stats.tsx new file mode 100644 index 0000000000000..7f654dce415c9 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/public/pages/list/components/investigation_stats.tsx @@ -0,0 +1,83 @@ +/* + * 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 numeral from '@elastic/numeral'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiStat } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useFetchAllInvestigationStats } from '../../../hooks/use_fetch_all_investigation_stats'; +import { useKibana } from '../../../hooks/use_kibana'; + +export function InvestigationStats() { + const { + core: { uiSettings }, + } = useKibana(); + const { data, isLoading: isStatsLoading } = useFetchAllInvestigationStats(); + const numberFormat = uiSettings.get('format:number:defaultPattern'); + + return ( + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts index 5d62b745e0a73..b0ecd89275914 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts @@ -13,6 +13,7 @@ import { deleteInvestigationNoteParamsSchema, deleteInvestigationParamsSchema, findInvestigationsParamsSchema, + getAllInvestigationStatsParamsSchema, getAllInvestigationTagsParamsSchema, getInvestigationItemsParamsSchema, getInvestigationNotesParamsSchema, @@ -37,6 +38,7 @@ import { updateInvestigation } from '../services/update_investigation'; import { updateInvestigationItem } from '../services/update_investigation_item'; import { updateInvestigationNote } from '../services/update_investigation_note'; import { createInvestigateAppServerRoute } from './create_investigate_app_server_route'; +import { getAllInvestigationStats } from '../services/get_all_investigation_stats'; const createInvestigationRoute = createInvestigateAppServerRoute({ endpoint: 'POST /api/observability/investigations 2023-10-31', @@ -154,6 +156,20 @@ const getAllInvestigationTagsRoute = createInvestigateAppServerRoute({ }, }); +const getAllInvestigationStatsRoute = createInvestigateAppServerRoute({ + endpoint: 'GET /api/observability/investigations/_stats 2023-10-31', + options: { + tags: [], + }, + params: getAllInvestigationStatsParamsSchema, + handler: async ({ params, context, request, logger }) => { + const soClient = (await context.core).savedObjects.client; + const repository = investigationRepositoryFactory({ soClient, logger }); + + return await getAllInvestigationStats(repository); + }, +}); + const getInvestigationNotesRoute = createInvestigateAppServerRoute({ endpoint: 'GET /api/observability/investigations/{investigationId}/notes 2023-10-31', options: { @@ -312,6 +328,7 @@ export function getGlobalInvestigateAppServerRouteRepository() { ...deleteInvestigationItemRoute, ...updateInvestigationItemRoute, ...getInvestigationItemsRoute, + ...getAllInvestigationStatsRoute, ...getAllInvestigationTagsRoute, }; } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/get_all_investigation_stats.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/get_all_investigation_stats.ts new file mode 100644 index 0000000000000..eb2304b4950c5 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/get_all_investigation_stats.ts @@ -0,0 +1,19 @@ +/* + * 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 { + GetAllInvestigationStatsResponse, + getAllInvestigationStatsResponseSchema, +} from '@kbn/investigation-shared'; +import { InvestigationRepository } from './investigation_repository'; + +export async function getAllInvestigationStats( + repository: InvestigationRepository +): Promise { + const stats = await repository.getStats(); + return getAllInvestigationStatsResponseSchema.parse(stats); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts index b7de0b96949da..ffefe757c7c72 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/investigation_repository.ts @@ -6,6 +6,7 @@ */ import { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import type { Status } from '@kbn/investigation-shared'; import { investigationSchema } from '@kbn/investigation-shared'; import { Investigation, StoredInvestigation } from '../models/investigation'; import { Paginated, Pagination } from '../models/pagination'; @@ -14,6 +15,11 @@ import { SO_INVESTIGATION_TYPE } from '../saved_objects/investigation'; export interface Search { search: string; } +interface Stats { + count: Record; + total: number; +} + export interface InvestigationRepository { save(investigation: Investigation): Promise; findById(id: string): Promise; @@ -28,6 +34,7 @@ export interface InvestigationRepository { pagination: Pagination; }): Promise>; findAllTags(): Promise; + getStats(): Promise; } export function investigationRepositoryFactory({ @@ -141,5 +148,40 @@ export function investigationRepositoryFactory({ return response.aggregations?.tags?.buckets.map((bucket) => bucket.key) ?? []; }, + + async getStats(): Promise<{ count: Record; total: number }> { + interface AggsStatusTerms { + status: { buckets: [{ key: string; doc_count: number }] }; + } + + const response = await soClient.find({ + type: SO_INVESTIGATION_TYPE, + aggs: { + status: { + terms: { + field: 'investigation.attributes.status', + size: 10, + }, + }, + }, + }); + + const countByStatus: Record = { + active: 0, + triage: 0, + mitigated: 0, + resolved: 0, + cancelled: 0, + }; + + return { + count: + response.aggregations?.status?.buckets.reduce( + (acc, bucket) => ({ ...acc, [bucket.key]: bucket.doc_count }), + countByStatus + ) ?? countByStatus, + total: response.total, + }; + }, }; } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation.ts index b4e33c4a5f673..ee1289ec4b9fa 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/update_investigation.ts @@ -7,7 +7,7 @@ import type { AuthenticatedUser } from '@kbn/core-security-common'; import { UpdateInvestigationParams, UpdateInvestigationResponse } from '@kbn/investigation-shared'; -import { isEqual } from 'lodash'; +import { isEqual, omit } from 'lodash'; import { InvestigationRepository } from './investigation_repository'; import { Investigation } from '../models/investigation'; @@ -18,9 +18,13 @@ export async function updateInvestigation( ): Promise { const originalInvestigation = await repository.findById(investigationId); - const updatedInvestigation: Investigation = Object.assign({}, originalInvestigation, params); + const updatedInvestigation: Investigation = Object.assign({}, originalInvestigation, params, { + updatedAt: Date.now(), + }); - if (isEqual(originalInvestigation, updatedInvestigation)) { + if ( + isEqual(omit(originalInvestigation, ['updatedAt']), omit(updatedInvestigation, ['updatedAt'])) + ) { return originalInvestigation; }