[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:
Jonathan Buttner 2022-01-24 14:26:11 -05:00 committed by GitHub
parent 2f52d0000b
commit da3c2179ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 694 additions and 291 deletions

View file

@ -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",

View file

@ -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({

View file

@ -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', {

View file

@ -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) {

View file

@ -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();
});
});

View file

@ -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>

View file

@ -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();
}
});
});
});
});

View file

@ -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'
);

View file

@ -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}

View file

@ -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();
});
});
});
});

View file

@ -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';

View file

@ -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;

View file

@ -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);
};

View file

@ -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 ',
});

View file

@ -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;
}

View file

@ -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',
}
);

View file

@ -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 => {

View file

@ -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: [],
},
},
};

View file

@ -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);

View file

@ -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', () => {

View file

@ -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,
};
}

View file

@ -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 }インシデントとしてプッシュ",

View file

@ -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": "参与者",

View file

@ -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'],
},
},
});

View file

@ -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"