[8.x] [ResponseOps][Cases] Use the cases metrics internal API to fetch the count of cases (#201098) (#201797)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[ResponseOps][Cases] Use the cases metrics internal API to fetch the
count of cases (#201098)](https://github.com/elastic/kibana/pull/201098)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Christos
Nasikas","email":"christos.nasikas@elastic.co"},"sourceCommit":{"committedDate":"2024-11-26T14:12:04Z","message":"[ResponseOps][Cases]
Use the cases metrics internal API to fetch the count of cases
(#201098)\n\n## Summary\r\n\r\nThis PR extends the cases metrics
internal API to return the number of\r\ntotal cases per status. The UI
uses the new API instead of the\r\ndeprecated one.\r\n\r\nFixes:
https://github.com/elastic/kibana/issues/194291\r\n\r\n###
Checklist\r\n\r\nCheck the PR satisfies following conditions.
\r\n\r\nReviewers should verify this PR satisfies this list as
well.\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"bcbf54ec1303e8b488106ed380729f60c3c7328a","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:ResponseOps","v9.0.0","Feature:Cases","backport:prev-minor","v8.18.0"],"title":"[ResponseOps][Cases]
Use the cases metrics internal API to fetch the count of
cases","number":201098,"url":"https://github.com/elastic/kibana/pull/201098","mergeCommit":{"message":"[ResponseOps][Cases]
Use the cases metrics internal API to fetch the count of cases
(#201098)\n\n## Summary\r\n\r\nThis PR extends the cases metrics
internal API to return the number of\r\ntotal cases per status. The UI
uses the new API instead of the\r\ndeprecated one.\r\n\r\nFixes:
https://github.com/elastic/kibana/issues/194291\r\n\r\n###
Checklist\r\n\r\nCheck the PR satisfies following conditions.
\r\n\r\nReviewers should verify this PR satisfies this list as
well.\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"bcbf54ec1303e8b488106ed380729f60c3c7328a"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/201098","number":201098,"mergeCommit":{"message":"[ResponseOps][Cases]
Use the cases metrics internal API to fetch the count of cases
(#201098)\n\n## Summary\r\n\r\nThis PR extends the cases metrics
internal API to return the number of\r\ntotal cases per status. The UI
uses the new API instead of the\r\ndeprecated one.\r\n\r\nFixes:
https://github.com/elastic/kibana/issues/194291\r\n\r\n###
Checklist\r\n\r\nCheck the PR satisfies following conditions.
\r\n\r\nReviewers should verify this PR satisfies this list as
well.\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"bcbf54ec1303e8b488106ed380729f60c3c7328a"}},{"branch":"8.x","label":"v8.18.0","branchLabelMappingKey":"^v8.18.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2024-11-27 07:22:13 +11:00 committed by GitHub
parent eff3a187a8
commit cf4dc3769b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 563 additions and 273 deletions

View file

@ -31,7 +31,6 @@ export type {
CaseViewRefreshPropInterface,
CasesPermissions,
CasesCapabilities,
CasesStatus,
} from './ui/types';
export { CaseSeverity } from './types/domain';

View file

@ -23,6 +23,7 @@ export enum CaseMetricsFeature {
CONNECTORS = 'connectors',
LIFESPAN = 'lifespan',
MTTR = 'mttr',
STATUS = 'status',
}
export const SingleCaseMetricsFeatureFieldRt = rt.union([
@ -37,6 +38,7 @@ export const SingleCaseMetricsFeatureFieldRt = rt.union([
export const CasesMetricsFeatureFieldRt = rt.union([
SingleCaseMetricsFeatureFieldRt,
rt.literal(CaseMetricsFeature.MTTR),
rt.literal(CaseMetricsFeature.STATUS),
]);
const StatusInfoRt = rt.strict({
@ -210,6 +212,10 @@ export const CasesMetricsResponseRt = rt.exact(
* The average resolve time of all cases in seconds
*/
mttr: rt.union([rt.number, rt.null]),
/**
* The number of total cases per status
*/
status: rt.strict({ open: rt.number, inProgress: rt.number, closed: rt.number }),
})
);

View file

@ -37,7 +37,6 @@ import type {
import type {
CasePatchRequest,
CasesFindResponse,
CasesStatusResponse,
CaseUserActionStatsResponse,
GetCaseConnectorsResponse,
GetCaseUsersResponse,
@ -105,7 +104,6 @@ export type CasesUI = CaseUI[];
export type CasesFindResponseUI = Omit<SnakeToCamelCase<CasesFindResponse>, 'cases'> & {
cases: CasesUI;
};
export type CasesStatus = SnakeToCamelCase<CasesStatusResponse>;
export type CasesMetrics = SnakeToCamelCase<CasesMetricsResponse>;
export type CaseUpdateRequest = SnakeToCamelCase<CasePatchRequest>;
export type CaseConnectors = SnakeToCamelCase<GetCaseConnectorsResponse>;

View file

@ -6,15 +6,9 @@
*/
import type { HTTPService } from '..';
import { casesMetrics, casesStatus } from '../../containers/mock';
import type { CasesMetrics, CasesStatus } from '../../containers/types';
import type { CasesFindRequest, CasesMetricsRequest } from '../../../common/types/api';
export const getCasesStatus = async ({
http,
signal,
query,
}: HTTPService & { query: CasesFindRequest }): Promise<CasesStatus> => Promise.resolve(casesStatus);
import { casesMetrics } from '../../containers/mock';
import type { CasesMetrics } from '../../containers/types';
import type { CasesMetricsRequest } from '../../../common/types/api';
export const getCasesMetrics = async ({
http,

View file

@ -11,13 +11,11 @@ import { pipe } from 'fp-ts/lib/pipeable';
import type {
CasesFindResponse,
CasesStatusResponse,
CasesBulkGetResponse,
CasesMetricsResponse,
} from '../../common/types/api';
import {
CasesFindResponseRt,
CasesStatusResponseRt,
CasesBulkGetResponseRt,
CasesMetricsResponseRt,
} from '../../common/types/api';
@ -26,13 +24,6 @@ import { throwErrors } from '../../common';
export const decodeCasesFindResponse = (respCases?: CasesFindResponse) =>
pipe(CasesFindResponseRt.decode(respCases), fold(throwErrors(createToasterPlainError), identity));
export const decodeCasesStatusResponse = (respCase?: CasesStatusResponse) =>
pipe(
CasesStatusResponseRt.decode(respCase),
fold(throwErrors(createToasterPlainError), identity)
);
export const decodeCasesMetricsResponse = (metrics?: CasesMetricsResponse) =>
pipe(
CasesMetricsResponseRt.decode(metrics),

View file

@ -9,18 +9,15 @@ import type { HttpStart } from '@kbn/core/public';
import type {
CasesFindRequest,
CasesFindResponse,
CasesStatusRequest,
CasesStatusResponse,
CasesBulkGetRequest,
CasesBulkGetResponse,
CasesMetricsRequest,
CasesMetricsResponse,
} from '../../common/types/api';
import type { CasesStatus, CasesMetrics, CasesFindResponseUI } from '../../common/ui';
import type { CasesMetrics, CasesFindResponseUI } from '../../common/ui';
import {
CASE_FIND_URL,
INTERNAL_CASE_METRICS_URL,
CASE_STATUS_URL,
INTERNAL_BULK_GET_CASES_URL,
} from '../../common/constants';
import { convertAllCasesToCamel, convertToCamelCase } from './utils';
@ -28,7 +25,6 @@ import {
decodeCasesBulkGetResponse,
decodeCasesFindResponse,
decodeCasesMetricsResponse,
decodeCasesStatusResponse,
} from './decoders';
export interface HTTPService {
@ -45,19 +41,6 @@ export const getCases = async ({
return convertAllCasesToCamel(decodeCasesFindResponse(res));
};
export const getCasesStatus = async ({
http,
query,
signal,
}: HTTPService & { query: CasesStatusRequest }): Promise<CasesStatus> => {
const response = await http.get<CasesStatusResponse>(CASE_STATUS_URL, {
signal,
query,
});
return convertToCamelCase<CasesStatusResponse, CasesStatus>(decodeCasesStatusResponse(response));
};
export const getCasesMetrics = async ({
http,
signal,

View file

@ -10,12 +10,11 @@ import type {
CasesByAlertIDRequest,
GetRelatedCasesByAlertResponse,
CasesFindRequest,
CasesStatusRequest,
CasesMetricsRequest,
} from '../../../common/types/api';
import { getCasesFromAlertsUrl } from '../../../common/api';
import { bulkGetCases, getCases, getCasesMetrics, getCasesStatus } from '../../api';
import type { CasesFindResponseUI, CasesStatus, CasesMetrics } from '../../../common/ui';
import { bulkGetCases, getCases, getCasesMetrics } from '../../api';
import type { CasesFindResponseUI, CasesMetrics } from '../../../common/ui';
import type { CasesPublicStart } from '../../types';
export const createClientAPI = ({ http }: { http: HttpStart }): CasesPublicStart['api'] => {
@ -28,8 +27,6 @@ export const createClientAPI = ({ http }: { http: HttpStart }): CasesPublicStart
cases: {
find: (query: CasesFindRequest, signal?: AbortSignal): Promise<CasesFindResponseUI> =>
getCases({ http, query, signal }),
getCasesStatus: (query: CasesStatusRequest, signal?: AbortSignal): Promise<CasesStatus> =>
getCasesStatus({ http, query, signal }),
getCasesMetrics: (query: CasesMetricsRequest, signal?: AbortSignal): Promise<CasesMetrics> =>
getCasesMetrics({ http, signal, query }),
bulkGet: (params, signal?: AbortSignal) => bulkGetCases({ http, signal, params }),

View file

@ -10,27 +10,22 @@ import React from 'react';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { useGetCasesMetrics } from '../../containers/use_get_cases_metrics';
import { useGetCasesStatus } from '../../containers/use_get_cases_status';
import { CasesMetrics } from './cases_metrics';
jest.mock('pretty-ms', () => jest.fn().mockReturnValue('2ms'));
jest.mock('../../containers/use_get_cases_metrics');
jest.mock('../../containers/use_get_cases_status');
const useGetCasesMetricsMock = useGetCasesMetrics as jest.Mock;
const useGetCasesStatusMock = useGetCasesStatus as jest.Mock;
describe('Cases metrics', () => {
let appMockRenderer: AppMockRenderer;
beforeEach(() => {
useGetCasesMetricsMock.mockReturnValue({ isLoading: false, data: { mttr: 2000 } });
useGetCasesStatusMock.mockReturnValue({
useGetCasesMetricsMock.mockReturnValue({
isLoading: false,
data: {
countOpenCases: 20,
countInProgressCases: 40,
countClosedCases: 130,
mttr: 2000,
status: { open: 20, inProgress: 40, closed: 130 },
},
});

View file

@ -17,22 +17,13 @@ import {
} from '@elastic/eui';
import prettyMilliseconds from 'pretty-ms';
import { CaseStatuses } from '../../../common/types/domain';
import { useGetCasesStatus } from '../../containers/use_get_cases_status';
import { StatusStats } from '../status/status_stats';
import { useGetCasesMetrics } from '../../containers/use_get_cases_metrics';
import { ATTC_DESCRIPTION, ATTC_STAT, ATTC_STAT_INFO_ARIA_LABEL } from './translations';
export const CasesMetrics: React.FC = () => {
const {
data: { countOpenCases, countInProgressCases, countClosedCases } = {
countOpenCases: 0,
countInProgressCases: 0,
countClosedCases: 0,
},
isLoading: isCasesStatusLoading,
} = useGetCasesStatus();
const { data: { mttr } = { mttr: 0 }, isLoading: isCasesMetricsLoading } = useGetCasesMetrics();
const { data: { mttr, status } = { mttr: 0 }, isLoading: isCasesMetricsLoading } =
useGetCasesMetrics();
const mttrValue = useMemo(
() => (mttr != null ? prettyMilliseconds(mttr * 1000, { compact: true, verbose: false }) : '-'),
@ -46,25 +37,25 @@ export const CasesMetrics: React.FC = () => {
<EuiFlexItem grow={true}>
<StatusStats
dataTestSubj="openStatsHeader"
caseCount={countOpenCases}
caseCount={status?.open ?? 0}
caseStatus={CaseStatuses.open}
isLoading={isCasesStatusLoading}
isLoading={isCasesMetricsLoading}
/>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<StatusStats
dataTestSubj="inProgressStatsHeader"
caseCount={countInProgressCases}
caseCount={status?.inProgress ?? 0}
caseStatus={CaseStatuses['in-progress']}
isLoading={isCasesStatusLoading}
isLoading={isCasesMetricsLoading}
/>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<StatusStats
dataTestSubj="closedStatsHeader"
caseCount={countClosedCases}
caseCount={status?.closed ?? 0}
caseStatus={CaseStatuses.closed}
isLoading={isCasesStatusLoading}
isLoading={isCasesMetricsLoading}
/>
</EuiFlexItem>
<EuiFlexItem grow={true}>

View file

@ -10,7 +10,6 @@ import type {
CasesFindResponseUI,
CaseUI,
CasesUI,
CasesStatus,
FetchCasesProps,
FindCaseUserActions,
CaseUICustomField,
@ -24,7 +23,6 @@ import {
basicCaseCommentPatch,
basicCasePost,
basicResolvedCase,
casesStatus,
pushedCase,
tags,
categories,
@ -69,9 +67,6 @@ export const getSingleCaseMetrics = async (
signal: AbortSignal
): Promise<SingleCaseMetricsResponse> => Promise.resolve(basicCaseMetrics);
export const getCasesStatus = async (signal: AbortSignal): Promise<CasesStatus> =>
Promise.resolve(casesStatus);
export const getTags = async (signal: AbortSignal): Promise<string[]> => Promise.resolve(tags);
export const findAssignees = async (): Promise<UserProfile[]> => userProfiles;

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common';
import { KibanaServices } from '../common/lib/kibana';
@ -51,13 +50,11 @@ import {
basicCaseSnake,
pushedCaseSnake,
categories,
casesStatus,
casesSnake,
cases,
pushedCase,
tags,
findCaseUserActionsResponse,
casesStatusSnake,
basicCaseId,
caseWithRegisteredAttachmentsSnake,
caseWithRegisteredAttachments,
@ -69,7 +66,6 @@ import {
} from './mock';
import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './constants';
import { getCasesStatus } from '../api';
import { getCaseConnectorsMockResponse } from '../common/mock/connectors';
import { set } from '@kbn/safer-lodash-set';
import { cloneDeep, omit } from 'lodash';
@ -531,38 +527,6 @@ describe('Cases API', () => {
});
});
describe('getCasesStatus', () => {
const http = httpServiceMock.createStartContract({ basePath: '' });
http.get.mockResolvedValue(casesStatusSnake);
beforeEach(() => {
fetchMock.mockClear();
});
it('should be called with correct check url, method, signal', async () => {
await getCasesStatus({
http,
signal: abortCtrl.signal,
query: { owner: [SECURITY_SOLUTION_OWNER] },
});
expect(http.get).toHaveBeenCalledWith(`${CASES_URL}/status`, {
signal: abortCtrl.signal,
query: { owner: [SECURITY_SOLUTION_OWNER] },
});
});
it('should return correct response', async () => {
const resp = await getCasesStatus({
http,
signal: abortCtrl.signal,
query: { owner: SECURITY_SOLUTION_OWNER },
});
expect(resp).toEqual(casesStatus);
});
});
describe('findCaseUserActions', () => {
const findCaseUserActionsSnake = {
page: 1,

View file

@ -27,7 +27,7 @@ import {
ExternalReferenceStorageType,
CustomFieldTypes,
} from '../../common/types/domain';
import type { ActionLicense, CaseUI, CasesStatus, UserActionUI } from './types';
import type { ActionLicense, CaseUI, UserActionUI } from './types';
import type {
ResolvedCase,
@ -56,11 +56,7 @@ import type {
AttachmentViewObject,
PersistableStateAttachmentType,
} from '../client/attachment_framework/types';
import type {
CasesFindResponse,
CasesStatusResponse,
UserActionWithResponse,
} from '../../common/types/api';
import type { CasesFindResponse, UserActionWithResponse } from '../../common/types/api';
export { connectorsMock } from '../common/mock/connectors';
export const basicCaseId = 'basic-case-id';
@ -398,14 +394,13 @@ export const basicCaseCommentPatch = {
comments: [basicCommentPatch],
};
export const casesStatus: CasesStatus = {
countOpenCases: 20,
countInProgressCases: 40,
countClosedCases: 130,
};
export const casesMetrics: CasesMetrics = {
mttr: 12,
status: {
open: 20,
inProgress: 40,
closed: 130,
},
};
export const basicPush = {
@ -461,7 +456,9 @@ export const allCases: CasesFindResponseUI = {
page: 1,
perPage: 5,
total: 10,
...casesStatus,
countOpenCases: 20,
countInProgressCases: 40,
countClosedCases: 130,
};
export const actionLicenses: ActionLicense[] = [
@ -572,12 +569,6 @@ export const caseWithRegisteredAttachmentsSnake = {
comments: [externalReferenceAttachmentSnake, persistableStateAttachmentSnake],
};
export const casesStatusSnake: CasesStatusResponse = {
count_closed_cases: 130,
count_in_progress_cases: 40,
count_open_cases: 20,
};
export const pushSnake = {
connector_id: pushConnectorId,
connector_name: 'My SN connector',
@ -626,7 +617,9 @@ export const allCasesSnake: CasesFindResponse = {
page: 1,
per_page: 5,
total: 10,
...casesStatusSnake,
count_closed_cases: 130,
count_in_progress_cases: 40,
count_open_cases: 20,
};
export const getUserAction = (

View file

@ -41,7 +41,10 @@ describe('useGetCasesMetrics', () => {
expect(spy).toHaveBeenCalledWith({
http: expect.anything(),
signal: abortCtrl.signal,
query: { owner: [SECURITY_SOLUTION_OWNER], features: [CaseMetricsFeature.MTTR] },
query: {
owner: [SECURITY_SOLUTION_OWNER],
features: [CaseMetricsFeature.MTTR, CaseMetricsFeature.STATUS],
},
})
);
});

View file

@ -26,7 +26,7 @@ export const useGetCasesMetrics = () => {
({ signal }) =>
getCasesMetrics({
http,
query: { owner, features: [CaseMetricsFeature.MTTR] },
query: { owner, features: [CaseMetricsFeature.MTTR, CaseMetricsFeature.STATUS] },
signal,
}),
{

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 { waitFor, renderHook } from '@testing-library/react';
import { useGetCasesStatus } from './use_get_cases_status';
import * as api from '../api';
import type { AppMockRenderer } from '../common/mock';
import { createAppMockRenderer } from '../common/mock';
import { SECURITY_SOLUTION_OWNER } from '../../common/constants';
import { useToasts } from '../common/lib/kibana';
jest.mock('../api');
jest.mock('../common/lib/kibana');
describe('useGetCasesMetrics', () => {
const abortCtrl = new AbortController();
const addSuccess = jest.fn();
const addError = jest.fn();
(useToasts as jest.Mock).mockReturnValue({ addSuccess, addError });
let appMockRender: AppMockRenderer;
beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
});
it('calls the api when invoked with the correct parameters', async () => {
const spy = jest.spyOn(api, 'getCasesStatus');
renderHook(() => useGetCasesStatus(), {
wrapper: appMockRender.AppWrapper,
});
await waitFor(() =>
expect(spy).toHaveBeenCalledWith({
http: expect.anything(),
signal: abortCtrl.signal,
query: { owner: [SECURITY_SOLUTION_OWNER] },
})
);
});
it('shows a toast error when the api return an error', async () => {
jest
.spyOn(api, 'getCasesStatus')
.mockRejectedValue(new Error('useGetCasesMetrics: Test error'));
renderHook(() => useGetCasesStatus(), {
wrapper: appMockRender.AppWrapper,
});
await waitFor(() => expect(addError).toHaveBeenCalled());
});
});

View file

@ -1,39 +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 { useQuery } from '@tanstack/react-query';
import { useCasesContext } from '../components/cases_context/use_cases_context';
import * as i18n from './translations';
import type { CasesStatus } from './types';
import { useHttp } from '../common/lib/kibana';
import { getCasesStatus } from '../api';
import { useCasesToast } from '../common/use_cases_toast';
import type { ServerError } from '../types';
import { casesQueriesKeys } from './constants';
export const useGetCasesStatus = () => {
const http = useHttp();
const { owner } = useCasesContext();
const { showErrorToast } = useCasesToast();
return useQuery<CasesStatus, ServerError>(
casesQueriesKeys.casesStatuses(),
({ signal }) =>
getCasesStatus({
http,
query: { owner },
signal,
}),
{
onError: (error: ServerError) => {
showErrorToast(error, { title: i18n.ERROR_TITLE });
},
}
);
};
export type UseGetCasesStatus = ReturnType<typeof useGetCasesStatus>;

View file

@ -13,7 +13,6 @@ const apiMock: jest.Mocked<CasesPublicStart['api']> = {
cases: {
find: jest.fn(),
getCasesMetrics: jest.fn(),
getCasesStatus: jest.fn(),
bulkGet: jest.fn(),
},
};

View file

@ -129,7 +129,6 @@ describe('Cases Ui Plugin', () => {
bulkGet: expect.any(Function),
find: expect.any(Function),
getCasesMetrics: expect.any(Function),
getCasesStatus: expect.any(Function),
},
getRelatedCases: expect.any(Function),
},

View file

@ -40,7 +40,7 @@ import type { GetCasesContextProps } from './client/ui/get_cases_context';
import type { GetCasesProps } from './client/ui/get_cases';
import type { GetAllCasesSelectorModalProps } from './client/ui/get_all_cases_selector_modal';
import type { GetRecentCasesProps } from './client/ui/get_recent_cases';
import type { CasesStatus, CasesMetrics, CasesFindResponseUI } from '../common/ui';
import type { CasesMetrics, CasesFindResponseUI } from '../common/ui';
import type { GroupAlertsByRule } from './client/helpers/group_alerts_by_rule';
import type { getUICapabilities } from './client/helpers/capabilities';
import type { AttachmentFramework } from './client/attachment_framework/types';
@ -50,7 +50,6 @@ import type {
CasesByAlertIDRequest,
GetRelatedCasesByAlertResponse,
CasesFindRequest,
CasesStatusRequest,
CasesBulkGetRequest,
CasesBulkGetResponse,
CasesMetricsRequest,
@ -125,7 +124,6 @@ export interface CasesPublicStart {
) => Promise<GetRelatedCasesByAlertResponse>;
cases: {
find: (query: CasesFindRequest, signal?: AbortSignal) => Promise<CasesFindResponseUI>;
getCasesStatus: (query: CasesStatusRequest, signal?: AbortSignal) => Promise<CasesStatus>;
getCasesMetrics: (query: CasesMetricsRequest, signal?: AbortSignal) => Promise<CasesMetrics>;
bulkGet: (params: CasesBulkGetRequest, signal?: AbortSignal) => Promise<CasesBulkGetResponse>;
};

View file

@ -0,0 +1,62 @@
/*
* 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 { CasePersistedStatus } from '../../../../common/types/case';
import { StatusCounts } from './status_counts';
describe('StatusCounts', () => {
it('returns the correct aggregation', () => {
const agg = new StatusCounts();
expect(agg.build()).toEqual({
status: {
terms: {
field: 'cases.attributes.status',
size: 3,
},
},
});
});
it('formats the response correctly', async () => {
const agg = new StatusCounts();
const res = agg.formatResponse({
status: {
buckets: [
{ key: CasePersistedStatus.OPEN, doc_count: 2 },
{ key: CasePersistedStatus.IN_PROGRESS, doc_count: 1 },
{ key: CasePersistedStatus.CLOSED, doc_count: 3 },
],
},
});
expect(res).toEqual({ status: { open: 2, inProgress: 1, closed: 3 } });
});
it('formats the response correctly if the res is undefined', () => {
const agg = new StatusCounts();
// @ts-expect-error: testing for undefined response
const res = agg.formatResponse();
expect(res).toEqual({ status: { open: 0, inProgress: 0, closed: 0 } });
});
it('formats the response correctly if the mttr is not defined', () => {
const agg = new StatusCounts();
const res = agg.formatResponse({});
expect(res).toEqual({ status: { open: 0, inProgress: 0, closed: 0 } });
});
it('formats the response correctly if the value is not defined', () => {
const agg = new StatusCounts();
const res = agg.formatResponse({ status: {} });
expect(res).toEqual({ status: { open: 0, inProgress: 0, closed: 0 } });
});
it('gets the name correctly', () => {
const agg = new StatusCounts();
expect(agg.getName()).toBe('status');
});
});

View file

@ -0,0 +1,58 @@
/*
* 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 { CasePersistedStatus } from '../../../../common/types/case';
import { caseStatuses } from '../../../../../common/types/domain';
import { CASE_SAVED_OBJECT } from '../../../../../common/constants';
import type { CasesMetricsResponse } from '../../../../../common/types/api';
import type { AggregationBuilder, AggregationResponse } from '../../types';
export class StatusCounts implements AggregationBuilder<CasesMetricsResponse> {
build() {
return {
status: {
terms: {
field: `${CASE_SAVED_OBJECT}.attributes.status`,
size: caseStatuses.length,
},
},
};
}
formatResponse(aggregations: AggregationResponse) {
const aggs = aggregations as StatusAggregate;
const buckets = aggs?.status?.buckets ?? [];
const status: Partial<Record<CasePersistedStatus, number>> = {};
for (const bucket of buckets) {
status[bucket.key] = bucket.doc_count;
}
return {
status: {
open: status[CasePersistedStatus.OPEN] ?? 0,
inProgress: status[CasePersistedStatus.IN_PROGRESS] ?? 0,
closed: status[CasePersistedStatus.CLOSED] ?? 0,
},
};
}
getName() {
return 'status';
}
}
type StatusAggregate = StatusAggregateResponse | undefined;
interface StatusAggregateResponse {
status: {
buckets: Array<{
key: CasePersistedStatus;
doc_count: number;
}>;
};
}

View file

@ -0,0 +1,181 @@
/*
* 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 { Case } from '../../../../common/types/domain';
import { CaseMetricsFeature } from '../../../../common/types/api';
import { createCasesClientMock } from '../../mocks';
import type { CasesClientArgs } from '../../types';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { createCaseServiceMock } from '../../../services/mocks';
import { Status } from './status';
import { CasePersistedStatus } from '../../../common/types/case';
const clientMock = createCasesClientMock();
const caseService = createCaseServiceMock();
const logger = loggingSystemMock.createLogger();
const getAuthorizationFilter = jest.fn().mockResolvedValue({});
const clientArgs = {
logger,
services: {
caseService,
},
authorization: { getAuthorizationFilter },
} as unknown as CasesClientArgs;
const constructorOptions = { casesClient: clientMock, clientArgs };
describe('Status', () => {
beforeAll(() => {
getAuthorizationFilter.mockResolvedValue({});
clientMock.cases.get.mockResolvedValue({ id: '' } as unknown as Case);
});
beforeEach(() => {
jest.clearAllMocks();
});
it('returns empty values when no features set up', async () => {
caseService.executeAggregations.mockResolvedValue(undefined);
const handler = new Status(constructorOptions);
expect(await handler.compute()).toEqual({});
});
it('returns null when aggregation returns undefined', async () => {
caseService.executeAggregations.mockResolvedValue(undefined);
const handler = new Status(constructorOptions);
handler.setupFeature(CaseMetricsFeature.STATUS);
expect(await handler.compute()).toEqual({ status: { open: 0, inProgress: 0, closed: 0 } });
});
it('returns null when aggregation returns empty object', async () => {
caseService.executeAggregations.mockResolvedValue({});
const handler = new Status(constructorOptions);
handler.setupFeature(CaseMetricsFeature.STATUS);
expect(await handler.compute()).toEqual({ status: { open: 0, inProgress: 0, closed: 0 } });
});
it('returns null when aggregation returns empty status object', async () => {
caseService.executeAggregations.mockResolvedValue({ status: {} });
const handler = new Status(constructorOptions);
handler.setupFeature(CaseMetricsFeature.STATUS);
expect(await handler.compute()).toEqual({ status: { open: 0, inProgress: 0, closed: 0 } });
});
it('returns values when there is a status value', async () => {
caseService.executeAggregations.mockResolvedValue({
status: {
buckets: [
{ key: CasePersistedStatus.OPEN, doc_count: 2 },
{ key: CasePersistedStatus.IN_PROGRESS, doc_count: 1 },
{ key: CasePersistedStatus.CLOSED, doc_count: 3 },
],
},
});
const handler = new Status(constructorOptions);
handler.setupFeature(CaseMetricsFeature.STATUS);
expect(await handler.compute()).toEqual({ status: { open: 2, inProgress: 1, closed: 3 } });
});
it('passes the query options correctly', async () => {
caseService.executeAggregations.mockResolvedValue({
status: {
buckets: [
{ key: CasePersistedStatus.OPEN, doc_count: 2 },
{ key: CasePersistedStatus.IN_PROGRESS, doc_count: 1 },
{ key: CasePersistedStatus.CLOSED, doc_count: 3 },
],
},
});
const handler = new Status({
...constructorOptions,
from: '2022-04-28T15:18:00.000Z',
to: '2022-04-28T15:22:00.000Z',
owner: 'cases',
});
handler.setupFeature(CaseMetricsFeature.STATUS);
await handler.compute();
expect(caseService.executeAggregations.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"aggregationBuilders": Array [
StatusCounts {},
],
"options": Object {
"filter": Object {
"arguments": Array [
Object {
"arguments": Array [
Object {
"arguments": Array [
Object {
"isQuoted": false,
"type": "literal",
"value": "cases.attributes.created_at",
},
"gte",
Object {
"isQuoted": false,
"type": "literal",
"value": "2022-04-28T15:18:00.000Z",
},
],
"function": "range",
"type": "function",
},
Object {
"arguments": Array [
Object {
"isQuoted": false,
"type": "literal",
"value": "cases.attributes.created_at",
},
"lte",
Object {
"isQuoted": false,
"type": "literal",
"value": "2022-04-28T15:22:00.000Z",
},
],
"function": "range",
"type": "function",
},
],
"function": "and",
"type": "function",
},
Object {
"arguments": Array [
Object {
"isQuoted": false,
"type": "literal",
"value": "cases.attributes.owner",
},
Object {
"isQuoted": false,
"type": "literal",
"value": "cases",
},
],
"function": "is",
"type": "function",
},
],
"function": "and",
"type": "function",
},
},
}
`);
});
});

View file

@ -0,0 +1,57 @@
/*
* 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 { CasesMetricsResponse } from '../../../../common/types/api';
import { Operations } from '../../../authorization';
import { createCaseError } from '../../../common/error';
import { constructQueryOptions } from '../../utils';
import { AllCasesAggregationHandler } from '../all_cases_aggregation_handler';
import type { AggregationBuilder, AllCasesBaseHandlerCommonOptions } from '../types';
import { StatusCounts } from './aggregations/status_counts';
export class Status extends AllCasesAggregationHandler {
constructor(options: AllCasesBaseHandlerCommonOptions) {
super(
options,
new Map<string, AggregationBuilder<CasesMetricsResponse>>([['status', new StatusCounts()]])
);
}
public async compute(): Promise<CasesMetricsResponse> {
const {
authorization,
services: { caseService },
logger,
} = this.options.clientArgs;
try {
const { filter: authorizationFilter } = await authorization.getAuthorizationFilter(
Operations.getCasesMetrics
);
const caseQueryOptions = constructQueryOptions({
from: this.from,
to: this.to,
owner: this.owner,
authorizationFilter,
});
const aggregationsResponse = await caseService.executeAggregations({
aggregationBuilders: this.aggregationBuilders,
options: { filter: caseQueryOptions.filter },
});
return this.formatResponse<CasesMetricsResponse>(aggregationsResponse);
} catch (error) {
throw createCaseError({
message: `Failed to calculate cases status counts: ${error}`,
error,
logger,
});
}
}
}

View file

@ -81,7 +81,7 @@ describe('utils', () => {
],
[
{ caseId: null },
'invalid features: [not-exists], please only provide valid features: [mttr]',
'invalid features: [not-exists], please only provide valid features: [mttr, status]',
],
])('throws if the feature is not supported: %s', async (opts, msg) => {
expect(() =>

View file

@ -16,6 +16,7 @@ import { Connectors } from './connectors';
import { Lifespan } from './lifespan';
import type { GetCaseMetricsParams, MetricsHandler } from './types';
import { MTTR } from './all_cases/mttr';
import { Status } from './all_cases/status';
const isSingleCaseMetrics = (
params: GetCaseMetricsParams | CasesMetricsRequest
@ -33,7 +34,7 @@ export const buildHandlers = (
(ClassName) => new ClassName({ caseId: params.caseId, casesClient, clientArgs })
);
} else {
handlers = [MTTR].map(
handlers = [MTTR, Status].map(
(ClassName) =>
new ClassName({
owner: params.owner,

View file

@ -31,17 +31,21 @@ jest.mock('../../../../common/containers/use_global_time', () => {
});
jest.mock('../../../../common/lib/kibana');
const mockGetCasesStatus = jest.fn();
mockGetCasesStatus.mockResolvedValue({
countOpenCases: 1,
countInProgressCases: 2,
countClosedCases: 3,
const mockGetCasesMetrics = jest.fn();
mockGetCasesMetrics.mockResolvedValue({
status: {
open: 1,
inProgress: 2,
closed: 3,
},
});
mockGetCasesStatus.mockResolvedValueOnce({
countOpenCases: 0,
countInProgressCases: 0,
countClosedCases: 0,
mockGetCasesMetrics.mockResolvedValueOnce({
status: {
open: 0,
inProgress: 0,
closed: 0,
},
});
const mockUseKibana = {
@ -50,7 +54,7 @@ const mockUseKibana = {
...mockCasesContract(),
api: {
cases: {
getCasesStatus: mockGetCasesStatus,
getCasesMetrics: mockGetCasesMetrics,
},
},
},

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import type { CasesStatus } from '@kbn/cases-plugin/common';
import type { CasesMetricsResponse } from '@kbn/cases-plugin/common';
import { CaseMetricsFeature } from '@kbn/cases-plugin/common';
import { useState, useEffect, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { APP_ID } from '../../../../../common/constants';
@ -34,15 +35,18 @@ export const useCasesByStatus = ({ skip = false }) => {
const uniqueQueryId = useMemo(() => `useCaseItems-${uuidv4()}`, []);
const [updatedAt, setUpdatedAt] = useState(Date.now());
const [isLoading, setIsLoading] = useState(true);
const [casesCounts, setCasesCounts] = useState<CasesStatus | null>(null);
const [casesCounts, setCasesCounts] = useState<CasesMetricsResponse['status'] | null | undefined>(
null
);
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
const fetchCases = async () => {
try {
const casesResponse = await cases.api.cases.getCasesStatus(
const casesResponse = await cases.api.cases.getCasesMetrics(
{
features: [CaseMetricsFeature.STATUS],
from,
to,
owner: APP_ID,
@ -51,7 +55,7 @@ export const useCasesByStatus = ({ skip = false }) => {
);
if (isSubscribed) {
setCasesCounts(casesResponse);
setCasesCounts(casesResponse.status);
}
} catch (error) {
if (isSubscribed) {
@ -88,14 +92,12 @@ export const useCasesByStatus = ({ skip = false }) => {
}, [cases.api.cases, from, skip, to, setQuery, deleteQuery, uniqueQueryId]);
return {
closed: casesCounts?.countClosedCases ?? 0,
inProgress: casesCounts?.countInProgressCases ?? 0,
closed: casesCounts?.closed ?? 0,
inProgress: casesCounts?.inProgress ?? 0,
isLoading,
open: casesCounts?.countOpenCases ?? 0,
open: casesCounts?.open ?? 0,
totalCounts:
(casesCounts?.countClosedCases ?? 0) +
(casesCounts?.countInProgressCases ?? 0) +
(casesCounts?.countOpenCases ?? 0),
(casesCounts?.closed ?? 0) + (casesCounts?.inProgress ?? 0) + (casesCounts?.open ?? 0),
updatedAt,
};
};

View file

@ -25,7 +25,7 @@ import {
getCasesMetrics,
updateCase,
} from '../../../../../common/lib/api';
import { getPostCaseRequest } from '../../../../../common/lib/mock';
import { getPostCaseRequest, postCaseReq } from '../../../../../common/lib/mock';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
@ -42,8 +42,6 @@ export default ({ getService }: FtrProviderContext): void => {
});
expect(metrics).to.eql({ mttr: null });
await deleteAllCaseItems(es);
});
describe(CaseMetricsFeature.MTTR, () => {
@ -79,6 +77,8 @@ export default ({ getService }: FtrProviderContext): void => {
});
expect(metrics).to.eql({ mttr: null });
await deleteAllCaseItems(es);
});
describe('closed and open cases from kbn archive', () => {
@ -92,6 +92,7 @@ export default ({ getService }: FtrProviderContext): void => {
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json'
);
await deleteAllCaseItems(es);
});
@ -119,6 +120,62 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
describe(CaseMetricsFeature.STATUS, () => {
it('responses with zeros if there are no cases', async () => {
const metrics = await getCasesMetrics({
supertest,
features: [CaseMetricsFeature.STATUS],
});
expect(metrics).to.eql({
status: {
closed: 0,
inProgress: 0,
open: 0,
},
});
});
it('should return case statuses', async () => {
const [, inProgressCase, postedCase] = await Promise.all([
createCase(supertest, postCaseReq),
createCase(supertest, postCaseReq),
createCase(supertest, postCaseReq),
]);
await updateCase({
supertest,
params: {
cases: [
{
id: inProgressCase.id,
version: inProgressCase.version,
status: CaseStatuses['in-progress'],
},
{
id: postedCase.id,
version: postedCase.version,
status: CaseStatuses.closed,
},
],
},
});
const metrics = await getCasesMetrics({
supertest,
features: [CaseMetricsFeature.STATUS],
});
expect(metrics.status).to.eql({
open: 1,
inProgress: 1,
closed: 1,
});
await deleteAllCaseItems(es);
});
});
describe('rbac', () => {
before(async () => {
await kibanaServer.importExport.load(
@ -132,6 +189,7 @@ export default ({ getService }: FtrProviderContext): void => {
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json',
{ space: 'space1' }
);
await deleteAllCaseItems(es);
});
@ -139,29 +197,68 @@ export default ({ getService }: FtrProviderContext): void => {
for (const scenario of [
{
user: globalRead,
expectedMetrics: { mttr: 220 },
expectedMetrics: {
mttr: 220,
status: {
closed: 3,
inProgress: 0,
open: 1,
},
},
owners: ['securitySolutionFixture', 'observabilityFixture'],
},
{
user: superUser,
expectedMetrics: { mttr: 220 },
expectedMetrics: {
mttr: 220,
status: {
closed: 3,
inProgress: 0,
open: 1,
},
},
owners: ['securitySolutionFixture', 'observabilityFixture'],
},
{
user: secOnlyRead,
expectedMetrics: { mttr: 250 },
expectedMetrics: {
mttr: 250,
status: {
closed: 2,
inProgress: 0,
open: 1,
},
},
owners: ['securitySolutionFixture'],
},
{ user: obsOnlyRead, expectedMetrics: { mttr: 160 }, owners: ['observabilityFixture'] },
{
user: obsOnlyRead,
expectedMetrics: {
mttr: 160,
status: {
closed: 1,
inProgress: 0,
open: 0,
},
},
owners: ['observabilityFixture'],
},
{
user: obsSecRead,
expectedMetrics: { mttr: 220 },
expectedMetrics: {
mttr: 220,
status: {
closed: 3,
inProgress: 0,
open: 1,
},
},
owners: ['securitySolutionFixture', 'observabilityFixture'],
},
]) {
const metrics = await getCasesMetrics({
supertest: supertestWithoutAuth,
features: [CaseMetricsFeature.MTTR],
features: [CaseMetricsFeature.MTTR, CaseMetricsFeature.STATUS],
auth: {
user: scenario.user,
space: 'space1',
@ -182,7 +279,7 @@ export default ({ getService }: FtrProviderContext): void => {
// user should not be able to read cases at the appropriate space
await getCasesMetrics({
supertest: supertestWithoutAuth,
features: [CaseMetricsFeature.MTTR],
features: [CaseMetricsFeature.MTTR, CaseMetricsFeature.STATUS],
auth: {
user: scenario.user,
space: scenario.space,
@ -195,7 +292,7 @@ export default ({ getService }: FtrProviderContext): void => {
it('should respect the owner filter when having permissions', async () => {
const metrics = await getCasesMetrics({
supertest: supertestWithoutAuth,
features: [CaseMetricsFeature.MTTR],
features: [CaseMetricsFeature.MTTR, CaseMetricsFeature.STATUS],
query: {
owner: 'securitySolutionFixture',
},
@ -205,13 +302,20 @@ export default ({ getService }: FtrProviderContext): void => {
},
});
expect(metrics).to.eql({ mttr: 250 });
expect(metrics).to.eql({
mttr: 250,
status: {
closed: 2,
inProgress: 0,
open: 1,
},
});
});
it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => {
const metrics = await getCasesMetrics({
supertest: supertestWithoutAuth,
features: [CaseMetricsFeature.MTTR],
features: [CaseMetricsFeature.MTTR, CaseMetricsFeature.STATUS],
query: {
owner: ['securitySolutionFixture', 'observabilityFixture'],
},
@ -221,13 +325,20 @@ export default ({ getService }: FtrProviderContext): void => {
},
});
expect(metrics).to.eql({ mttr: 250 });
expect(metrics).to.eql({
mttr: 250,
status: {
closed: 2,
inProgress: 0,
open: 1,
},
});
});
it('should respect the owner filter when using range queries', async () => {
const metrics = await getCasesMetrics({
supertest: supertestWithoutAuth,
features: [CaseMetricsFeature.MTTR],
features: [CaseMetricsFeature.MTTR, CaseMetricsFeature.STATUS],
query: {
from: '2022-04-20',
to: '2022-04-30',
@ -238,7 +349,14 @@ export default ({ getService }: FtrProviderContext): void => {
},
});
expect(metrics).to.eql({ mttr: 250 });
expect(metrics).to.eql({
mttr: 250,
status: {
closed: 2,
inProgress: 0,
open: 0,
},
});
});
});
});