mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
eff3a187a8
commit
cf4dc3769b
28 changed files with 563 additions and 273 deletions
|
@ -31,7 +31,6 @@ export type {
|
|||
CaseViewRefreshPropInterface,
|
||||
CasesPermissions,
|
||||
CasesCapabilities,
|
||||
CasesStatus,
|
||||
} from './ui/types';
|
||||
|
||||
export { CaseSeverity } from './types/domain';
|
||||
|
|
|
@ -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 }),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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 },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
|
@ -26,7 +26,7 @@ export const useGetCasesMetrics = () => {
|
|||
({ signal }) =>
|
||||
getCasesMetrics({
|
||||
http,
|
||||
query: { owner, features: [CaseMetricsFeature.MTTR] },
|
||||
query: { owner, features: [CaseMetricsFeature.MTTR, CaseMetricsFeature.STATUS] },
|
||||
signal,
|
||||
}),
|
||||
{
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
});
|
|
@ -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>;
|
|
@ -13,7 +13,6 @@ const apiMock: jest.Mocked<CasesPublicStart['api']> = {
|
|||
cases: {
|
||||
find: jest.fn(),
|
||||
getCasesMetrics: jest.fn(),
|
||||
getCasesStatus: jest.fn(),
|
||||
bulkGet: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}>;
|
||||
};
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(() =>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue