mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Cases] Status Metrics UI Changes (#122608)
* Finishing the lifespan handler * Adding some integration tests * Fixing tests * Refactoring the status duration code * Investigating infinite loop * Status group is taking full width * Working status metrics with dash * Forgot files * Adding in icon and finishing reopened * Enabling metrics features for observability * Fixing tests * using sentence case for capitalization * Fixing jest test * Starting addressing feedback * Addressing feedback * Remove duplicate translations * Adding spacer * Using different keys and using context for features * Switch opened on to created on * Fixing translations Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
2f52d0000b
commit
da3c2179ef
25 changed files with 694 additions and 291 deletions
|
@ -318,6 +318,7 @@
|
|||
"pluralize": "3.1.0",
|
||||
"pngjs": "^3.4.0",
|
||||
"polished": "^3.7.2",
|
||||
"pretty-ms": "6.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"proxy-from-env": "1.0.0",
|
||||
"puid": "1.0.7",
|
||||
|
@ -840,7 +841,6 @@
|
|||
"postcss-loader": "^3.0.0",
|
||||
"postcss-prefix-selector": "^1.7.2",
|
||||
"prettier": "^2.5.1",
|
||||
"pretty-ms": "5.0.0",
|
||||
"q": "^1.5.1",
|
||||
"react-test-renderer": "^16.12.0",
|
||||
"read-pkg": "^5.2.0",
|
||||
|
|
|
@ -22,9 +22,9 @@ const StatusInfoRt = rt.type({
|
|||
*/
|
||||
inProgressDuration: rt.number,
|
||||
/**
|
||||
* The number of times the case was reopened after being closed.
|
||||
* The ISO string representation of the dates the case was reopened
|
||||
*/
|
||||
numberOfReopens: rt.number,
|
||||
reopenDates: rt.array(rt.string),
|
||||
});
|
||||
|
||||
const AlertHostsMetricsRt = rt.type({
|
||||
|
|
|
@ -25,8 +25,8 @@ export const NAME = i18n.translate('xpack.cases.caseView.name', {
|
|||
defaultMessage: 'Name',
|
||||
});
|
||||
|
||||
export const OPENED_ON = i18n.translate('xpack.cases.caseView.openedOn', {
|
||||
defaultMessage: 'Opened on',
|
||||
export const CREATED_ON = i18n.translate('xpack.cases.caseView.createdOn', {
|
||||
defaultMessage: 'Created on',
|
||||
});
|
||||
|
||||
export const CLOSED_ON = i18n.translate('xpack.cases.caseView.closedOn', {
|
||||
|
|
|
@ -299,7 +299,7 @@ export const useCasesColumns = ({
|
|||
}
|
||||
: {
|
||||
field: 'createdAt',
|
||||
name: i18n.OPENED_ON,
|
||||
name: i18n.CREATED_ON,
|
||||
sortable: true,
|
||||
render: (createdAt: Case['createdAt']) => {
|
||||
if (createdAt != null) {
|
||||
|
|
|
@ -10,7 +10,7 @@ import { mount } from 'enzyme';
|
|||
import { render } from '@testing-library/react';
|
||||
|
||||
import { basicCase } from '../../containers/mock';
|
||||
import { CaseActionBar } from '.';
|
||||
import { CaseActionBar, CaseActionBarProps } from '.';
|
||||
import { TestProviders } from '../../common/mock';
|
||||
|
||||
describe('CaseActionBar', () => {
|
||||
|
@ -28,6 +28,7 @@ describe('CaseActionBar', () => {
|
|||
onUpdateField,
|
||||
currentExternalIncident: null,
|
||||
userCanCrud: true,
|
||||
metricsFeatures: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -118,7 +119,7 @@ describe('CaseActionBar', () => {
|
|||
|
||||
it('should not show the sync alerts toggle when alerting is disabled', () => {
|
||||
const { queryByText } = render(
|
||||
<TestProviders features={{ alerts: { sync: false } }}>
|
||||
<TestProviders features={{ alerts: { sync: false }, metrics: [] }}>
|
||||
<CaseActionBar {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
@ -135,4 +136,25 @@ describe('CaseActionBar', () => {
|
|||
|
||||
expect(queryByText('Sync alerts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show the Case open text when the lifespan feature is enabled', () => {
|
||||
const props: CaseActionBarProps = { ...defaultProps };
|
||||
const { queryByText } = render(
|
||||
<TestProviders features={{ metrics: ['lifespan'] }}>
|
||||
<CaseActionBar {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(queryByText('Case opened')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the Case open text when the lifespan feature is disabled', () => {
|
||||
const { getByText } = render(
|
||||
<TestProviders>
|
||||
<CaseActionBar {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByText('Case opened')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
|
@ -19,14 +19,14 @@ import {
|
|||
import { Case } from '../../../common/ui/types';
|
||||
import { CaseStatuses, CaseType } from '../../../common/api';
|
||||
import * as i18n from '../case_view/translations';
|
||||
import { FormattedRelativePreferenceDate } from '../formatted_date';
|
||||
import { Actions } from './actions';
|
||||
import { CaseService } from '../../containers/use_get_case_user_actions';
|
||||
import { StatusContextMenu } from './status_context_menu';
|
||||
import { getStatusDate, getStatusTitle } from './helpers';
|
||||
import { SyncAlertsSwitch } from '../case_settings/sync_alerts_switch';
|
||||
import type { OnUpdateFields } from '../case_view/types';
|
||||
import { useCasesFeatures } from '../cases_context/use_cases_features';
|
||||
import { FormattedRelativePreferenceDate } from '../formatted_date';
|
||||
import { getStatusDate, getStatusTitle } from './helpers';
|
||||
|
||||
const MyDescriptionList = styled(EuiDescriptionList)`
|
||||
${({ theme }) => css`
|
||||
|
@ -41,7 +41,7 @@ const MyDescriptionList = styled(EuiDescriptionList)`
|
|||
`}
|
||||
`;
|
||||
|
||||
interface CaseActionBarProps {
|
||||
export interface CaseActionBarProps {
|
||||
caseData: Case;
|
||||
currentExternalIncident: CaseService | null;
|
||||
userCanCrud: boolean;
|
||||
|
@ -57,7 +57,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
|
|||
onRefresh,
|
||||
onUpdateField,
|
||||
}) => {
|
||||
const { isSyncAlertsEnabled } = useCasesFeatures();
|
||||
const { isSyncAlertsEnabled, metricsFeatures } = useCasesFeatures();
|
||||
const date = useMemo(() => getStatusDate(caseData), [caseData]);
|
||||
const title = useMemo(() => getStatusTitle(caseData.status), [caseData.status]);
|
||||
const onStatusChanged = useCallback(
|
||||
|
@ -95,15 +95,17 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
|
|||
</EuiDescriptionListDescription>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiDescriptionListTitle>{title}</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>
|
||||
<FormattedRelativePreferenceDate
|
||||
data-test-subj={'case-action-bar-status-date'}
|
||||
value={date}
|
||||
/>
|
||||
</EuiDescriptionListDescription>
|
||||
</EuiFlexItem>
|
||||
{!metricsFeatures.includes('lifespan') ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiDescriptionListTitle>{title}</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>
|
||||
<FormattedRelativePreferenceDate
|
||||
data-test-subj={'case-action-bar-status-date'}
|
||||
value={date}
|
||||
/>
|
||||
</EuiDescriptionListDescription>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
</MyDescriptionList>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -1,107 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { basicCaseMetrics, basicCaseMetricsFeatures } from '../../containers/mock';
|
||||
import { CaseViewMetrics } from './case_view_metrics';
|
||||
import { CaseMetrics, CaseMetricsFeature } from '../../../common/ui';
|
||||
import { TestProviders } from '../../common/mock';
|
||||
|
||||
const renderCaseMetrics = ({
|
||||
metrics = basicCaseMetrics,
|
||||
features = basicCaseMetricsFeatures,
|
||||
isLoading = false,
|
||||
}: {
|
||||
metrics?: CaseMetrics;
|
||||
features?: CaseMetricsFeature[];
|
||||
isLoading?: boolean;
|
||||
} = {}) => {
|
||||
return render(
|
||||
<TestProviders>
|
||||
<CaseViewMetrics metrics={metrics} isLoading={isLoading} features={features} />
|
||||
</TestProviders>
|
||||
);
|
||||
};
|
||||
|
||||
const metricsFeaturesTests: Array<[CaseMetricsFeature, string, number]> = [
|
||||
['alerts.count', 'Total Alerts', basicCaseMetrics.alerts!.count!],
|
||||
['alerts.users', 'Associated Users', basicCaseMetrics.alerts!.users!.total!],
|
||||
['alerts.hosts', 'Associated Hosts', basicCaseMetrics.alerts!.hosts!.total!],
|
||||
[
|
||||
'actions.isolateHost',
|
||||
'Isolated Hosts',
|
||||
basicCaseMetrics.actions!.isolateHost!.isolate.total -
|
||||
basicCaseMetrics.actions!.isolateHost!.unisolate.total,
|
||||
],
|
||||
['connectors', 'Total Connectors', basicCaseMetrics.connectors!.total!],
|
||||
];
|
||||
|
||||
describe('CaseViewMetrics', () => {
|
||||
it('should render', () => {
|
||||
const { getByTestId } = renderCaseMetrics();
|
||||
expect(getByTestId('case-view-metrics-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading spinner', () => {
|
||||
const { getByTestId } = renderCaseMetrics({ isLoading: true });
|
||||
expect(getByTestId('case-view-metrics-spinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render metrics', () => {
|
||||
const { getByText } = renderCaseMetrics();
|
||||
expect(getByText('Total Alerts')).toBeInTheDocument();
|
||||
expect(getByText('Associated Users')).toBeInTheDocument();
|
||||
expect(getByText('Associated Hosts')).toBeInTheDocument();
|
||||
expect(getByText('Isolated Hosts')).toBeInTheDocument();
|
||||
expect(getByText('Total Connectors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render metrics with default value 0', () => {
|
||||
const { getByText, getAllByText } = renderCaseMetrics({ metrics: {} });
|
||||
expect(getByText('Total Alerts')).toBeInTheDocument();
|
||||
expect(getByText('Associated Users')).toBeInTheDocument();
|
||||
expect(getByText('Associated Hosts')).toBeInTheDocument();
|
||||
expect(getByText('Isolated Hosts')).toBeInTheDocument();
|
||||
expect(getByText('Total Connectors')).toBeInTheDocument();
|
||||
expect(getAllByText('0')).toHaveLength(basicCaseMetricsFeatures.length);
|
||||
});
|
||||
|
||||
it('should prevent negative value for isolateHost actions', () => {
|
||||
const incosistentMetrics = {
|
||||
actions: {
|
||||
isolateHost: {
|
||||
isolate: { total: 1 },
|
||||
unisolate: { total: 2 },
|
||||
},
|
||||
},
|
||||
};
|
||||
const { getByText } = renderCaseMetrics({
|
||||
metrics: incosistentMetrics,
|
||||
features: ['actions.isolateHost'],
|
||||
});
|
||||
expect(getByText('Isolated Hosts')).toBeInTheDocument();
|
||||
expect(getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe.each(metricsFeaturesTests)('Metrics feature: %s ', (feature, text, total) => {
|
||||
it('should render metric', () => {
|
||||
const { getByText } = renderCaseMetrics({ features: [feature] });
|
||||
expect(getByText(text)).toBeInTheDocument();
|
||||
expect(getByText(total)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render other metrics', () => {
|
||||
const { queryByText } = renderCaseMetrics({ features: [feature] });
|
||||
metricsFeaturesTests.forEach(([_, otherMetricText]) => {
|
||||
if (otherMetricText !== text) {
|
||||
expect(queryByText(otherMetricText)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -138,10 +138,6 @@ describe('CaseViewPage', () => {
|
|||
data.createdBy.username
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value')
|
||||
).toEqual(data.createdAt);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`)
|
||||
|
@ -178,9 +174,6 @@ describe('CaseViewPage', () => {
|
|||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value')
|
||||
).toEqual(basicCaseClosed.closedAt);
|
||||
expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual(
|
||||
'Closed'
|
||||
);
|
||||
|
|
|
@ -6,13 +6,7 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiLoadingContent,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiLoadingContent, EuiSpacer } from '@elastic/eui';
|
||||
|
||||
import { CaseStatuses, CaseAttributes, CaseType, CaseConnector } from '../../../common/api';
|
||||
import { Case, UpdateKey, UpdateByKey } from '../../../common/ui';
|
||||
|
@ -37,7 +31,7 @@ import { useCaseViewNavigation } from '../../common/navigation';
|
|||
import { HeaderPage } from '../header_page';
|
||||
import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs';
|
||||
import { useGetCaseMetrics } from '../../containers/use_get_case_metrics';
|
||||
import { CaseViewMetrics } from './case_view_metrics';
|
||||
import { CaseViewMetrics } from './metrics';
|
||||
import type { CaseViewPageProps, OnUpdateFields } from './types';
|
||||
import { useCasesFeatures } from '../cases_context/use_cases_features';
|
||||
|
||||
|
@ -337,6 +331,19 @@ export const CaseViewPage = React.memo<CaseViewPageProps>(
|
|||
|
||||
<WhitePageWrapper>
|
||||
<ContentWrapper>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
{!initLoadingData && metricsFeatures.length > 0 ? (
|
||||
<CaseViewMetrics
|
||||
data-test-subj="case-view-metrics"
|
||||
isLoading={isLoadingMetrics}
|
||||
metrics={metrics}
|
||||
features={metricsFeatures}
|
||||
/>
|
||||
) : null}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={6}>
|
||||
{initLoadingData && (
|
||||
|
@ -344,24 +351,6 @@ export const CaseViewPage = React.memo<CaseViewPageProps>(
|
|||
)}
|
||||
{!initLoadingData && (
|
||||
<EuiFlexGroup direction="column" responsive={false}>
|
||||
{metricsFeatures.length > 0 && (
|
||||
<>
|
||||
<EuiFlexItem>
|
||||
<CaseViewMetrics
|
||||
data-test-subj="case-view-metrics"
|
||||
isLoading={isLoadingMetrics}
|
||||
metrics={metrics}
|
||||
features={metricsFeatures}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText>
|
||||
<h4>{i18n.ACTIVITY}</h4>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<UserActions
|
||||
getRuleDetailsHref={ruleDetailsNavigation?.href}
|
||||
|
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import {
|
||||
basicCaseMetrics,
|
||||
basicCaseNumericValueFeatures,
|
||||
basicCaseStatusFeatures,
|
||||
} from '../../../containers/mock';
|
||||
import { CaseViewMetrics } from './index';
|
||||
import { CaseMetrics, CaseMetricsFeature } from '../../../../common/ui';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
|
||||
const renderCaseMetrics = ({
|
||||
metrics = basicCaseMetrics,
|
||||
features = [...basicCaseNumericValueFeatures, ...basicCaseStatusFeatures],
|
||||
isLoading = false,
|
||||
}: {
|
||||
metrics?: CaseMetrics;
|
||||
features?: CaseMetricsFeature[];
|
||||
isLoading?: boolean;
|
||||
} = {}) => {
|
||||
return render(
|
||||
<TestProviders>
|
||||
<CaseViewMetrics metrics={metrics} isLoading={isLoading} features={features} />
|
||||
</TestProviders>
|
||||
);
|
||||
};
|
||||
|
||||
interface FeatureTest {
|
||||
feature: CaseMetricsFeature;
|
||||
items: Array<{
|
||||
title: string;
|
||||
value: string | number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const metricsFeaturesTests: FeatureTest[] = [
|
||||
{
|
||||
feature: 'alerts.count',
|
||||
items: [{ title: 'Total alerts', value: basicCaseMetrics.alerts!.count! }],
|
||||
},
|
||||
{
|
||||
feature: 'alerts.users',
|
||||
items: [{ title: 'Associated users', value: basicCaseMetrics.alerts!.users!.total! }],
|
||||
},
|
||||
{
|
||||
feature: 'alerts.hosts',
|
||||
items: [{ title: 'Associated hosts', value: basicCaseMetrics.alerts!.hosts!.total! }],
|
||||
},
|
||||
{
|
||||
feature: 'actions.isolateHost',
|
||||
items: [
|
||||
{
|
||||
title: 'Isolated hosts',
|
||||
value:
|
||||
basicCaseMetrics.actions!.isolateHost!.isolate.total -
|
||||
basicCaseMetrics.actions!.isolateHost!.unisolate.total,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
feature: 'connectors',
|
||||
items: [{ title: 'Total connectors', value: basicCaseMetrics.connectors!.total! }],
|
||||
},
|
||||
{
|
||||
feature: 'lifespan',
|
||||
items: [
|
||||
{
|
||||
title: 'Case created',
|
||||
value: '2020-02-19T23:06:33Z',
|
||||
},
|
||||
{
|
||||
title: 'In progress duration',
|
||||
value: '20 milliseconds',
|
||||
},
|
||||
{
|
||||
title: 'Open duration',
|
||||
value: '10 milliseconds',
|
||||
},
|
||||
{
|
||||
title: 'Duration from creation to close',
|
||||
value: '1 day',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('CaseViewMetrics', () => {
|
||||
it('should render', () => {
|
||||
const { getByTestId } = renderCaseMetrics();
|
||||
expect(getByTestId('case-view-metrics-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading spinner', () => {
|
||||
const { getByTestId } = renderCaseMetrics({ isLoading: true });
|
||||
expect(getByTestId('case-view-metrics-spinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render metrics with default value 0', () => {
|
||||
const { getAllByText } = renderCaseMetrics({
|
||||
metrics: {},
|
||||
features: basicCaseNumericValueFeatures,
|
||||
});
|
||||
expect(getAllByText('0')).toHaveLength(basicCaseNumericValueFeatures.length);
|
||||
});
|
||||
|
||||
it('should render status metrics with default value of a dash', () => {
|
||||
const { getAllByText } = renderCaseMetrics({ metrics: {} });
|
||||
// \u2014 is the unicode for a long dash
|
||||
expect(getAllByText('\u2014')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should render open to close duration with the icon when it is reopened', () => {
|
||||
const { getByText, getByTestId } = renderCaseMetrics({
|
||||
metrics: {
|
||||
lifespan: {
|
||||
creationDate: new Date(0).toISOString(),
|
||||
closeDate: new Date(2).toISOString(),
|
||||
statusInfo: {
|
||||
inProgressDuration: 20,
|
||||
openDuration: 10,
|
||||
reopenDates: [new Date(1).toISOString()],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('2 milliseconds (reopened)')).toBeInTheDocument();
|
||||
expect(getByTestId('case-metrics-lifespan-reopen-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render open to close duration with the icon when it is not reopened', () => {
|
||||
const { getByText, queryByTestId } = renderCaseMetrics({
|
||||
metrics: {
|
||||
lifespan: {
|
||||
creationDate: new Date(0).toISOString(),
|
||||
closeDate: new Date(2).toISOString(),
|
||||
statusInfo: {
|
||||
inProgressDuration: 20,
|
||||
openDuration: 10,
|
||||
reopenDates: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('2 milliseconds')).toBeInTheDocument();
|
||||
expect(queryByTestId('case-metrics-lifespan-reopen-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should prevent negative value for isolateHost actions', () => {
|
||||
const incosistentMetrics = {
|
||||
actions: {
|
||||
isolateHost: {
|
||||
isolate: { total: 1 },
|
||||
unisolate: { total: 2 },
|
||||
},
|
||||
},
|
||||
};
|
||||
const { getByText } = renderCaseMetrics({
|
||||
metrics: incosistentMetrics,
|
||||
features: ['actions.isolateHost'],
|
||||
});
|
||||
expect(getByText('Isolated hosts')).toBeInTheDocument();
|
||||
expect(getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe.each(metricsFeaturesTests)('Metrics feature tests', ({ feature, items }) => {
|
||||
it(`should not render other metrics when rendering feature: ${feature}`, () => {
|
||||
const { queryByText } = renderCaseMetrics({ features: [feature] });
|
||||
metricsFeaturesTests.forEach(({ feature: otherFeature, items: otherItems }) => {
|
||||
if (feature !== otherFeature) {
|
||||
otherItems.forEach(({ title }) => {
|
||||
expect(queryByText(title)).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(items)(`Metric feature: ${feature} item: %s`, ({ title, value }) => {
|
||||
it('should render metric', () => {
|
||||
const { getByText } = renderCaseMetrics({ features: [feature] });
|
||||
expect(getByText(title)).toBeInTheDocument();
|
||||
expect(getByText(value)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
|
||||
import { CaseViewMetricItems } from './totals';
|
||||
import { CaseViewMetricsProps } from './types';
|
||||
import { CaseStatusMetrics } from './status';
|
||||
|
||||
export const CaseViewMetrics: React.FC<CaseViewMetricsProps> = React.memo(
|
||||
({ metrics, features, isLoading }) => (
|
||||
<EuiPanel data-test-subj="case-view-metrics-panel" hasShadow={false} hasBorder={true}>
|
||||
<EuiFlexGroup gutterSize="xl" wrap={true} responsive={false} alignItems="center">
|
||||
{isLoading ? (
|
||||
<EuiFlexItem>
|
||||
<EuiLoadingSpinner data-test-subj="case-view-metrics-spinner" size="l" />
|
||||
</EuiFlexItem>
|
||||
) : (
|
||||
<>
|
||||
<CaseViewMetricItems metrics={metrics} features={features} />
|
||||
<CaseStatusMetrics metrics={metrics} features={features} />
|
||||
</>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
)
|
||||
);
|
||||
CaseViewMetrics.displayName = 'CaseViewMetrics';
|
|
@ -0,0 +1,237 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import prettyMilliseconds from 'pretty-ms';
|
||||
import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiSpacer } from '@elastic/eui';
|
||||
import { CaseMetrics, CaseMetricsFeature } from '../../../../common/ui';
|
||||
import {
|
||||
CASE_CREATED,
|
||||
CASE_IN_PROGRESS_DURATION,
|
||||
CASE_OPEN_DURATION,
|
||||
CASE_OPEN_TO_CLOSE_DURATION,
|
||||
CASE_REOPENED,
|
||||
CASE_REOPENED_ON,
|
||||
} from './translations';
|
||||
import { getMaybeDate } from '../../formatted_date/maybe_date';
|
||||
import { FormattedDate, FormattedRelativePreferenceDate } from '../../formatted_date';
|
||||
import { getEmptyTagValue } from '../../empty_value';
|
||||
import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common';
|
||||
import { CaseViewMetricsProps } from './types';
|
||||
|
||||
export const CaseStatusMetrics: React.FC<Pick<CaseViewMetricsProps, 'metrics' | 'features'>> =
|
||||
React.memo(({ metrics, features }) => {
|
||||
const lifespanMetrics = useGetLifespanMetrics(metrics, features);
|
||||
|
||||
if (!lifespanMetrics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'case-created',
|
||||
component: (
|
||||
<CaseStatusMetricsItem
|
||||
title={CASE_CREATED}
|
||||
value={<CreationDate date={lifespanMetrics.creationDate} />}
|
||||
/>
|
||||
),
|
||||
dataTestSubject: 'case-metrics-lifespan-item-creation-date',
|
||||
},
|
||||
{
|
||||
key: 'in-progress-duration',
|
||||
component: (
|
||||
<CaseStatusMetricsItem
|
||||
title={CASE_IN_PROGRESS_DURATION}
|
||||
value={getInProgressDuration(lifespanMetrics.statusInfo.inProgressDuration)}
|
||||
/>
|
||||
),
|
||||
dataTestSubject: 'case-metrics-lifespan-item-inProgress-duration',
|
||||
},
|
||||
{
|
||||
key: 'open-duration',
|
||||
component: (
|
||||
<CaseStatusMetricsItem
|
||||
title={CASE_OPEN_DURATION}
|
||||
value={formatDuration(lifespanMetrics.statusInfo.openDuration)}
|
||||
/>
|
||||
),
|
||||
dataTestSubject: 'case-metrics-lifespan-item-open-duration',
|
||||
},
|
||||
{
|
||||
key: 'duration-from-creation-to-close',
|
||||
component: (
|
||||
<CaseStatusMetricsOpenCloseDuration
|
||||
title={CASE_OPEN_TO_CLOSE_DURATION}
|
||||
value={getOpenCloseDuration(lifespanMetrics.creationDate, lifespanMetrics.closeDate)}
|
||||
reopens={lifespanMetrics.statusInfo.reopenDates}
|
||||
/>
|
||||
),
|
||||
dataTestSubject: 'case-metrics-lifespan-item-open-to-close-duration',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<EuiFlexItem grow={3}>
|
||||
<EuiFlexGrid columns={2} gutterSize="s" responsive={false}>
|
||||
{items.map(({ component, dataTestSubject, key }) => (
|
||||
<EuiFlexItem data-test-subj={dataTestSubject} key={key}>
|
||||
{component}
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGrid>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
});
|
||||
CaseStatusMetrics.displayName = 'CaseStatusMetrics';
|
||||
|
||||
const useGetLifespanMetrics = (
|
||||
metrics: CaseMetrics | null,
|
||||
features: CaseMetricsFeature[]
|
||||
): CaseMetrics['lifespan'] | undefined => {
|
||||
return useMemo<CaseMetrics['lifespan']>(() => {
|
||||
const lifespan = metrics?.lifespan ?? {
|
||||
closeDate: '',
|
||||
creationDate: '',
|
||||
statusInfo: { inProgressDuration: 0, reopenDates: [], openDuration: 0 },
|
||||
};
|
||||
|
||||
if (!features.includes('lifespan')) {
|
||||
return;
|
||||
}
|
||||
|
||||
return lifespan;
|
||||
}, [features, metrics]);
|
||||
};
|
||||
|
||||
const CreationDate: React.FC<{ date: string }> = React.memo(({ date }) => {
|
||||
const creationDate = getMaybeDate(date);
|
||||
if (!creationDate.isValid()) {
|
||||
return getEmptyTagValue();
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedRelativePreferenceDate
|
||||
data-test-subj={'case-metrics-lifespan-creation-date'}
|
||||
value={date}
|
||||
/>
|
||||
);
|
||||
});
|
||||
CreationDate.displayName = 'CreationDate';
|
||||
|
||||
const getInProgressDuration = (duration: number) => {
|
||||
if (duration <= 0) {
|
||||
return getEmptyTagValue();
|
||||
}
|
||||
|
||||
return formatDuration(duration);
|
||||
};
|
||||
|
||||
const formatDuration = (milliseconds: number) => {
|
||||
return prettyMilliseconds(milliseconds, { compact: true, verbose: true });
|
||||
};
|
||||
|
||||
const getOpenCloseDuration = (openDate: string, closeDate: string | null): string | undefined => {
|
||||
if (closeDate == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openDateObject = getMaybeDate(openDate);
|
||||
const closeDateObject = getMaybeDate(closeDate);
|
||||
|
||||
if (!openDateObject.isValid() || !closeDateObject.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return formatDuration(closeDateObject.diff(openDateObject));
|
||||
};
|
||||
|
||||
const Title = euiStyled(EuiFlexItem)`
|
||||
font-size: ${({ theme }) => theme.eui.euiSizeM};
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
const CaseStatusMetricsItem: React.FC<{
|
||||
title: string;
|
||||
value: JSX.Element | string;
|
||||
}> = React.memo(({ title, value }) => (
|
||||
<EuiFlexGroup direction="column" gutterSize="s" responsive={false}>
|
||||
<Title>{title}</Title>
|
||||
<EuiFlexItem>{value}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
));
|
||||
CaseStatusMetricsItem.displayName = 'CaseStatusMetricsItem';
|
||||
|
||||
const CaseStatusMetricsOpenCloseDuration: React.FC<{
|
||||
title: string;
|
||||
value?: string;
|
||||
reopens: string[];
|
||||
}> = React.memo(({ title, value, reopens }) => {
|
||||
const valueText = getOpenCloseDurationText(value, reopens);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="s" responsive={false}>
|
||||
<Title>{title}</Title>
|
||||
{value != null && caseWasReopened(reopens) ? (
|
||||
<ValueWithExplanationIcon value={valueText} explanationValues={reopens} />
|
||||
) : (
|
||||
<EuiFlexItem>{valueText}</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
});
|
||||
CaseStatusMetricsOpenCloseDuration.displayName = 'OpenCloseDuration';
|
||||
|
||||
const getOpenCloseDurationText = (value: string | undefined, reopens: string[]) => {
|
||||
if (value == null) {
|
||||
return getEmptyTagValue();
|
||||
} else if (reopens.length > 0) {
|
||||
return `${value} ${CASE_REOPENED}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const caseWasReopened = (reopens: string[]) => {
|
||||
return reopens.length > 0;
|
||||
};
|
||||
|
||||
const ValueWithExplanationIcon: React.FC<{
|
||||
value: string | JSX.Element;
|
||||
explanationValues: string[];
|
||||
}> = React.memo(({ value, explanationValues }) => {
|
||||
const content = (
|
||||
<>
|
||||
{CASE_REOPENED_ON}
|
||||
{explanationValues.map((explanationValue, index) => {
|
||||
return (
|
||||
<React.Fragment key={`explanation-value-${index}`}>
|
||||
<FormattedDate
|
||||
data-test-subj={`case-metrics-lifespan-reopen-${index}`}
|
||||
value={explanationValue}
|
||||
/>
|
||||
{isNotLastItem(index, explanationValues.length) ? <EuiSpacer size="xs" /> : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexItem data-test-subj="case-metrics-lifespan-reopen-icon">
|
||||
<EuiFlexGroup responsive={false} gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>{value}</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip content={content} position="right" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
});
|
||||
ValueWithExplanationIcon.displayName = 'ValueWithExplanationIcon';
|
||||
|
||||
const isNotLastItem = (index: number, arrayLength: number): boolean => index + 1 < arrayLength;
|
|
@ -6,9 +6,8 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
|
||||
import { CaseMetrics, CaseMetricsFeature } from '../../../common/ui';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { CaseMetrics, CaseMetricsFeature } from '../../../../common/ui';
|
||||
import {
|
||||
ASSOCIATED_HOSTS_METRIC,
|
||||
ASSOCIATED_USERS_METRIC,
|
||||
|
@ -16,25 +15,40 @@ import {
|
|||
TOTAL_ALERTS_METRIC,
|
||||
TOTAL_CONNECTORS_METRIC,
|
||||
} from './translations';
|
||||
import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common';
|
||||
import { CaseViewMetricsProps } from './types';
|
||||
|
||||
const MetricValue = styled(EuiFlexItem)`
|
||||
export const CaseViewMetricItems: React.FC<Pick<CaseViewMetricsProps, 'metrics' | 'features'>> =
|
||||
React.memo(({ metrics, features }) => {
|
||||
const metricItems = useGetTitleValueMetricItems(metrics, features);
|
||||
|
||||
return (
|
||||
<>
|
||||
{metricItems.map(({ title, value }) => (
|
||||
<EuiFlexItem key={title}>
|
||||
<EuiFlexGroup direction="column" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem>{title}</EuiFlexItem>
|
||||
<MetricValue>{value}</MetricValue>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
CaseViewMetricItems.displayName = 'CaseViewMetricItems';
|
||||
|
||||
const MetricValue = euiStyled(EuiFlexItem)`
|
||||
font-size: ${({ theme }) => theme.eui.euiSizeL};
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
export interface CaseViewMetricsProps {
|
||||
metrics: CaseMetrics | null;
|
||||
features: CaseMetricsFeature[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface MetricItem {
|
||||
title: string;
|
||||
value: number;
|
||||
}
|
||||
type MetricItems = MetricItem[];
|
||||
|
||||
const useMetricItems = (
|
||||
const useGetTitleValueMetricItems = (
|
||||
metrics: CaseMetrics | null,
|
||||
features: CaseMetricsFeature[]
|
||||
): MetricItems => {
|
||||
|
@ -43,10 +57,7 @@ const useMetricItems = (
|
|||
const alertsCount = alerts?.count ?? 0;
|
||||
const totalAlertUsers = alerts?.users?.total ?? 0;
|
||||
const totalAlertHosts = alerts?.hosts?.total ?? 0;
|
||||
const totalIsolatedHosts =
|
||||
actions?.isolateHost && actions.isolateHost.isolate.total >= actions.isolateHost.unisolate.total
|
||||
? actions.isolateHost.isolate.total - actions.isolateHost.unisolate.total
|
||||
: 0;
|
||||
const totalIsolatedHosts = calculateTotalIsolatedHosts(actions);
|
||||
|
||||
const metricItems = useMemo<MetricItems>(() => {
|
||||
const items: Array<[CaseMetricsFeature, MetricItem]> = [
|
||||
|
@ -76,38 +87,11 @@ const useMetricItems = (
|
|||
return metricItems;
|
||||
};
|
||||
|
||||
const CaseViewMetricItems: React.FC<{ metricItems: MetricItems }> = React.memo(
|
||||
({ metricItems }) => (
|
||||
<>
|
||||
{metricItems.map(({ title, value }, index) => (
|
||||
<EuiFlexItem key={index}>
|
||||
<EuiFlexGroup direction="column" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem>{title}</EuiFlexItem>
|
||||
<MetricValue>{value}</MetricValue>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
);
|
||||
CaseViewMetricItems.displayName = 'CaseViewMetricItems';
|
||||
|
||||
export const CaseViewMetrics: React.FC<CaseViewMetricsProps> = React.memo(
|
||||
({ metrics, features, isLoading }) => {
|
||||
const metricItems = useMetricItems(metrics, features);
|
||||
return (
|
||||
<EuiPanel data-test-subj="case-view-metrics-panel" hasShadow={false} hasBorder={true}>
|
||||
<EuiFlexGroup gutterSize="xl" wrap={true} responsive={false}>
|
||||
{isLoading ? (
|
||||
<EuiFlexItem>
|
||||
<EuiLoadingSpinner data-test-subj="case-view-metrics-spinner" size="l" />
|
||||
</EuiFlexItem>
|
||||
) : (
|
||||
<CaseViewMetricItems metricItems={metricItems} />
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
const calculateTotalIsolatedHosts = (actions: CaseMetrics['actions']) => {
|
||||
if (!actions?.isolateHost) {
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
CaseViewMetrics.displayName = 'CaseViewMetrics';
|
||||
|
||||
// prevent the metric from being negative
|
||||
return Math.max(actions.isolateHost.isolate.total - actions.isolateHost.unisolate.total, 0);
|
||||
};
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const TOTAL_ALERTS_METRIC = i18n.translate('xpack.cases.caseView.metrics.totalAlerts', {
|
||||
defaultMessage: 'Total alerts',
|
||||
});
|
||||
|
||||
export const ASSOCIATED_USERS_METRIC = i18n.translate(
|
||||
'xpack.cases.caseView.metrics.associatedUsers',
|
||||
{
|
||||
defaultMessage: 'Associated users',
|
||||
}
|
||||
);
|
||||
|
||||
export const ASSOCIATED_HOSTS_METRIC = i18n.translate(
|
||||
'xpack.cases.caseView.metrics.associatedHosts',
|
||||
{
|
||||
defaultMessage: 'Associated hosts',
|
||||
}
|
||||
);
|
||||
|
||||
export const ISOLATED_HOSTS_METRIC = i18n.translate('xpack.cases.caseView.metrics.isolatedHosts', {
|
||||
defaultMessage: 'Isolated hosts',
|
||||
});
|
||||
|
||||
export const TOTAL_CONNECTORS_METRIC = i18n.translate(
|
||||
'xpack.cases.caseView.metrics.totalConnectors',
|
||||
{
|
||||
defaultMessage: 'Total connectors',
|
||||
}
|
||||
);
|
||||
|
||||
export const CASE_CREATED = i18n.translate('xpack.cases.caseView.metrics.lifespan.caseCreated', {
|
||||
defaultMessage: 'Case created',
|
||||
});
|
||||
|
||||
export const CASE_IN_PROGRESS_DURATION = i18n.translate(
|
||||
'xpack.cases.caseView.metrics.lifespan.inProgressDuration',
|
||||
{
|
||||
defaultMessage: 'In progress duration',
|
||||
}
|
||||
);
|
||||
|
||||
export const CASE_OPEN_DURATION = i18n.translate(
|
||||
'xpack.cases.caseView.metrics.lifespan.openDuration',
|
||||
{
|
||||
defaultMessage: 'Open duration',
|
||||
}
|
||||
);
|
||||
|
||||
export const CASE_OPEN_TO_CLOSE_DURATION = i18n.translate(
|
||||
'xpack.cases.caseView.metrics.lifespan.openToCloseDuration',
|
||||
{
|
||||
defaultMessage: 'Duration from creation to close',
|
||||
}
|
||||
);
|
||||
|
||||
export const CASE_REOPENED = i18n.translate('xpack.cases.caseView.metrics.lifespan.reopened', {
|
||||
defaultMessage: '(reopened)',
|
||||
});
|
||||
|
||||
export const CASE_REOPENED_ON = i18n.translate('xpack.cases.caseView.metrics.lifespan.reopenedOn', {
|
||||
defaultMessage: 'Reopened on ',
|
||||
});
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 { CaseMetrics, CaseMetricsFeature } from '../../../../common/ui';
|
||||
|
||||
export interface CaseViewMetricsProps {
|
||||
metrics: CaseMetrics | null;
|
||||
features: CaseMetricsFeature[];
|
||||
isLoading: boolean;
|
||||
}
|
|
@ -135,32 +135,3 @@ export const DOES_NOT_EXIST_DESCRIPTION = (caseId: string) =>
|
|||
export const DOES_NOT_EXIST_BUTTON = i18n.translate('xpack.cases.caseView.doesNotExist.button', {
|
||||
defaultMessage: 'Back to Cases',
|
||||
});
|
||||
|
||||
export const TOTAL_ALERTS_METRIC = i18n.translate('xpack.cases.caseView.metrics.totalAlerts', {
|
||||
defaultMessage: 'Total Alerts',
|
||||
});
|
||||
|
||||
export const ASSOCIATED_USERS_METRIC = i18n.translate(
|
||||
'xpack.cases.caseView.metrics.associatedUsers',
|
||||
{
|
||||
defaultMessage: 'Associated Users',
|
||||
}
|
||||
);
|
||||
|
||||
export const ASSOCIATED_HOSTS_METRIC = i18n.translate(
|
||||
'xpack.cases.caseView.metrics.associatedHosts',
|
||||
{
|
||||
defaultMessage: 'Associated Hosts',
|
||||
}
|
||||
);
|
||||
|
||||
export const ISOLATED_HOSTS_METRIC = i18n.translate('xpack.cases.caseView.metrics.isolatedHosts', {
|
||||
defaultMessage: 'Isolated Hosts',
|
||||
});
|
||||
|
||||
export const TOTAL_CONNECTORS_METRIC = i18n.translate(
|
||||
'xpack.cases.caseView.metrics.totalConnectors',
|
||||
{
|
||||
defaultMessage: 'Total Connectors',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -93,7 +93,7 @@ PreferenceFormattedP1DTDate.displayName = 'PreferenceFormattedP1DTDate';
|
|||
* - the raw date value (e.g. 2019-03-22T00:47:46Z)
|
||||
*/
|
||||
export const FormattedDate = React.memo<{
|
||||
fieldName: string;
|
||||
fieldName?: string;
|
||||
value?: string | number | null;
|
||||
className?: string;
|
||||
}>(({ value, fieldName, className = '' }): JSX.Element => {
|
||||
|
|
|
@ -175,7 +175,7 @@ export const basicResolvedCase: ResolvedCase = {
|
|||
aliasTargetId: `${basicCase.id}_2`,
|
||||
};
|
||||
|
||||
export const basicCaseMetricsFeatures: CaseMetricsFeature[] = [
|
||||
export const basicCaseNumericValueFeatures: CaseMetricsFeature[] = [
|
||||
'alerts.count',
|
||||
'alerts.users',
|
||||
'alerts.hosts',
|
||||
|
@ -183,6 +183,8 @@ export const basicCaseMetricsFeatures: CaseMetricsFeature[] = [
|
|||
'connectors',
|
||||
];
|
||||
|
||||
export const basicCaseStatusFeatures: CaseMetricsFeature[] = ['lifespan'];
|
||||
|
||||
export const basicCaseMetrics: CaseMetrics = {
|
||||
alerts: {
|
||||
count: 12,
|
||||
|
@ -211,7 +213,7 @@ export const basicCaseMetrics: CaseMetrics = {
|
|||
statusInfo: {
|
||||
inProgressDuration: 20,
|
||||
openDuration: 10,
|
||||
numberOfReopens: 1,
|
||||
reopenDates: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -64,7 +64,7 @@ describe('getMetrics', () => {
|
|||
statusInfo: {
|
||||
openDuration,
|
||||
inProgressDuration,
|
||||
numberOfReopens: 0,
|
||||
reopenDates: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -97,7 +97,7 @@ describe('getMetrics', () => {
|
|||
statusInfo: {
|
||||
openDuration,
|
||||
inProgressDuration,
|
||||
numberOfReopens: 0,
|
||||
reopenDates: [],
|
||||
},
|
||||
});
|
||||
expect(metrics.alerts?.count).toEqual(5);
|
||||
|
|
|
@ -25,63 +25,63 @@ describe('lifespan', () => {
|
|||
expect(() => getStatusInfo([], new Date('blah'))).toThrowError('Invalid Date');
|
||||
});
|
||||
|
||||
it('sets reopen to 1 when status goes from open -> closed -> open', () => {
|
||||
it('causes a reopen status goes from open -> closed -> open', () => {
|
||||
expect(
|
||||
getStatusInfo(
|
||||
[
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date()),
|
||||
createStatusChangeSavedObject(CaseStatuses.open, new Date()),
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date(1)),
|
||||
createStatusChangeSavedObject(CaseStatuses.open, new Date(2)),
|
||||
],
|
||||
new Date(0)
|
||||
).numberOfReopens
|
||||
).toBe(1);
|
||||
).reopenDates
|
||||
).toEqual([new Date(2).toISOString()]);
|
||||
});
|
||||
|
||||
it('sets reopen to 1 when status goes from open -> closed -> in-progress', () => {
|
||||
it('causes a reopen when status goes from open -> closed -> in-progress', () => {
|
||||
expect(
|
||||
getStatusInfo(
|
||||
[
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date()),
|
||||
createStatusChangeSavedObject(CaseStatuses['in-progress'], new Date()),
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date(1)),
|
||||
createStatusChangeSavedObject(CaseStatuses['in-progress'], new Date(2)),
|
||||
],
|
||||
new Date(0)
|
||||
).numberOfReopens
|
||||
).toBe(1);
|
||||
).reopenDates
|
||||
).toEqual([new Date(2).toISOString()]);
|
||||
});
|
||||
|
||||
it('does not set reopen to 1 when status goes from open -> closed -> closed', () => {
|
||||
it('does not cause a reopen when status goes from open -> closed -> closed', () => {
|
||||
expect(
|
||||
getStatusInfo(
|
||||
[
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date()),
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date()),
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date(1)),
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date(2)),
|
||||
],
|
||||
new Date(0)
|
||||
).numberOfReopens
|
||||
).toBe(0);
|
||||
).reopenDates
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not set reopen to 1 when status goes from open -> in-progress', () => {
|
||||
it('does not cause a reopen when status goes from open -> in-progress', () => {
|
||||
expect(
|
||||
getStatusInfo(
|
||||
[createStatusChangeSavedObject(CaseStatuses['in-progress'], new Date())],
|
||||
new Date(0)
|
||||
).numberOfReopens
|
||||
).toBe(0);
|
||||
).reopenDates
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets reopen to 2 when status goes from open -> closed -> open twice', () => {
|
||||
it('causes two reopens when status goes from open -> closed -> open twice', () => {
|
||||
expect(
|
||||
getStatusInfo(
|
||||
[
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date()),
|
||||
createStatusChangeSavedObject(CaseStatuses.open, new Date()),
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date()),
|
||||
createStatusChangeSavedObject(CaseStatuses.open, new Date()),
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date(1)),
|
||||
createStatusChangeSavedObject(CaseStatuses.open, new Date(2)),
|
||||
createStatusChangeSavedObject(CaseStatuses.closed, new Date(3)),
|
||||
createStatusChangeSavedObject(CaseStatuses.open, new Date(4)),
|
||||
],
|
||||
new Date(0)
|
||||
).numberOfReopens
|
||||
).toBe(2);
|
||||
).reopenDates
|
||||
).toEqual([new Date(2).toISOString(), new Date(4).toISOString()]);
|
||||
});
|
||||
|
||||
it('sets the openDuration to 1 and inProgressDuration to 0 when open -> close', () => {
|
||||
|
@ -113,8 +113,8 @@ describe('lifespan', () => {
|
|||
expect(inProgressDuration).toBe(10);
|
||||
});
|
||||
|
||||
it('ignores non-status user actions with an invalid payload', () => {
|
||||
const { numberOfReopens } = getStatusInfo(
|
||||
it('ignores non-status user actions with an invalid payload and does not cause a reopen', () => {
|
||||
const { reopenDates } = getStatusInfo(
|
||||
[
|
||||
{
|
||||
attributes: { payload: { hello: 1, status: CaseStatuses.closed }, type: 'status' },
|
||||
|
@ -123,11 +123,11 @@ describe('lifespan', () => {
|
|||
new Date(0)
|
||||
);
|
||||
|
||||
expect(numberOfReopens).toBe(0);
|
||||
expect(reopenDates).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores non-status user actions with an invalid type', () => {
|
||||
const { numberOfReopens } = getStatusInfo(
|
||||
it('ignores non-status user actions with an invalid type and does not cause a reopen', () => {
|
||||
const { reopenDates } = getStatusInfo(
|
||||
[
|
||||
{
|
||||
attributes: { payload: { status: CaseStatuses.closed }, type: 'awesome' },
|
||||
|
@ -136,11 +136,11 @@ describe('lifespan', () => {
|
|||
new Date(0)
|
||||
);
|
||||
|
||||
expect(numberOfReopens).toBe(0);
|
||||
expect(reopenDates).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores non-status user actions with an created_at time', () => {
|
||||
const { numberOfReopens } = getStatusInfo(
|
||||
it('ignores non-status user actions with an created_at time and does not cause a reopen', () => {
|
||||
const { reopenDates } = getStatusInfo(
|
||||
[
|
||||
{
|
||||
attributes: {
|
||||
|
@ -152,7 +152,7 @@ describe('lifespan', () => {
|
|||
new Date(0)
|
||||
);
|
||||
|
||||
expect(numberOfReopens).toBe(0);
|
||||
expect(reopenDates).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not add the current time to a duration when the case is closed', () => {
|
||||
|
|
|
@ -76,7 +76,7 @@ function isDateValid(date: Date): boolean {
|
|||
|
||||
interface StatusCalculations {
|
||||
durations: Map<CaseStatuses, number>;
|
||||
numberOfReopens: number;
|
||||
reopenDates: string[];
|
||||
lastStatus: CaseStatuses;
|
||||
lastStatusChangeTimestamp: Date;
|
||||
}
|
||||
|
@ -93,7 +93,7 @@ export function getStatusInfo(
|
|||
return acc;
|
||||
}
|
||||
|
||||
const { durations, lastStatus, lastStatusChangeTimestamp, numberOfReopens } = acc;
|
||||
const { durations, lastStatus, lastStatusChangeTimestamp, reopenDates } = acc;
|
||||
|
||||
const attributes = userAction.attributes;
|
||||
const newStatus = attributes.payload.status;
|
||||
|
@ -106,7 +106,9 @@ export function getStatusInfo(
|
|||
}),
|
||||
lastStatus: newStatus,
|
||||
lastStatusChangeTimestamp: newStatusChangeTimestamp,
|
||||
numberOfReopens: isReopen(newStatus, lastStatus) ? numberOfReopens + 1 : numberOfReopens,
|
||||
reopenDates: isReopen(newStatus, lastStatus)
|
||||
? [...reopenDates, newStatusChangeTimestamp.toISOString()]
|
||||
: reopenDates,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
@ -114,7 +116,7 @@ export function getStatusInfo(
|
|||
[CaseStatuses.open, 0],
|
||||
[CaseStatuses['in-progress'], 0],
|
||||
]),
|
||||
numberOfReopens: 0,
|
||||
reopenDates: [],
|
||||
lastStatus: CaseStatuses.open,
|
||||
lastStatusChangeTimestamp: caseOpenTimestamp,
|
||||
}
|
||||
|
@ -130,7 +132,7 @@ export function getStatusInfo(
|
|||
return {
|
||||
openDuration: accumulatedDurations.get(CaseStatuses.open) ?? 0,
|
||||
inProgressDuration: accumulatedDurations.get(CaseStatuses['in-progress']) ?? 0,
|
||||
numberOfReopens: accStatusInfo.numberOfReopens,
|
||||
reopenDates: accStatusInfo.reopenDates,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -7565,7 +7565,6 @@
|
|||
"xpack.cases.caseView.noReportersAvailable": "利用可能なレポートがありません。",
|
||||
"xpack.cases.caseView.noTags": "現在、このケースにタグは割り当てられていません。",
|
||||
"xpack.cases.caseView.openCase": "ケースを開く",
|
||||
"xpack.cases.caseView.openedOn": "開始日",
|
||||
"xpack.cases.caseView.optional": "オプション",
|
||||
"xpack.cases.caseView.particpantsLabel": "参加者",
|
||||
"xpack.cases.caseView.pushNamedIncident": "{ thirdParty }インシデントとしてプッシュ",
|
||||
|
|
|
@ -7622,7 +7622,6 @@
|
|||
"xpack.cases.caseView.noReportersAvailable": "没有报告者。",
|
||||
"xpack.cases.caseView.noTags": "当前没有为此案例分配标签。",
|
||||
"xpack.cases.caseView.openCase": "创建案例",
|
||||
"xpack.cases.caseView.openedOn": "打开时间",
|
||||
"xpack.cases.caseView.optional": "可选",
|
||||
"xpack.cases.caseView.otherEndpoints": " 以及{endpoints, plural, other {其他}} {endpoints} 个",
|
||||
"xpack.cases.caseView.particpantsLabel": "参与者",
|
||||
|
|
|
@ -89,7 +89,7 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
statusInfo: {
|
||||
openDuration: minutesToMilliseconds(30),
|
||||
inProgressDuration: minutesToMilliseconds(10),
|
||||
numberOfReopens: 2,
|
||||
reopenDates: ['2022-01-05T15:30:00.000Z', '2022-01-05T15:50:00.000Z'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -22635,10 +22635,10 @@ pretty-ms@*:
|
|||
dependencies:
|
||||
parse-ms "^2.1.0"
|
||||
|
||||
pretty-ms@5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-5.0.0.tgz#6133a8f55804b208e4728f6aa7bf01085e951e24"
|
||||
integrity sha512-94VRYjL9k33RzfKiGokPBPpsmloBYSf5Ri+Pq19zlsEcUKFob+admeXr5eFDRuPjFmEOcjJvPGdillYOJyvZ7Q==
|
||||
pretty-ms@6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-6.0.0.tgz#39a0eb5f31d359bcee43c9579e6ddf4a02a82ff0"
|
||||
integrity sha512-X5i1y9/8VuBMb9WU8zubTiLKnJG4lcKvL7eaCEVc/jpTe3aS74gCcBM6Yd1vvUDoTCXm4Y15obNS/16yB0FTaQ==
|
||||
dependencies:
|
||||
parse-ms "^2.1.0"
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue