mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[SecuritySolution] Cases by status (#130670)
* init cases by status * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * cases by status * tickLabel * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * unit tests * unit tests * rm unused dependency * disable dashboard * dependency * enable dashboard * styling * styling * unit tests * styling * unit tests * disable dashboard * fix types * fix types * unit tests * unit test * unit tests * unit tests * unit tests * unit tests * unit tests * update i18n * remove cases api hack Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
f5f035645e
commit
56d0112a22
12 changed files with 664 additions and 45 deletions
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Chart, BarSeries, Axis, ScaleType } from '@elastic/charts';
|
||||
import { Chart, BarSeries, Axis, ScaleType, AxisStyle } from '@elastic/charts';
|
||||
import { mount, ReactWrapper, shallow, ShallowWrapper } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
|
@ -166,13 +166,43 @@ describe('BarChartBaseComponent', () => {
|
|||
|
||||
describe('render with customized configs', () => {
|
||||
const mockNumberFormatter = jest.fn();
|
||||
const mockXAxisStyle = {
|
||||
tickLine: {
|
||||
size: 0,
|
||||
},
|
||||
tickLabel: {
|
||||
padding: 16,
|
||||
fontSize: 10.5,
|
||||
},
|
||||
} as Partial<AxisStyle>;
|
||||
const mockYAxisStyle = {
|
||||
tickLine: {
|
||||
size: 0,
|
||||
},
|
||||
tickLabel: {
|
||||
padding: 16,
|
||||
fontSize: 14,
|
||||
},
|
||||
} as Partial<AxisStyle>;
|
||||
const configs = {
|
||||
series: {
|
||||
xScaleType: ScaleType.Ordinal,
|
||||
yScaleType: ScaleType.Linear,
|
||||
barSeriesStyle: {
|
||||
rect: {
|
||||
widthPixel: 22,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
axis: {
|
||||
yTickFormatter: mockNumberFormatter,
|
||||
bottom: {
|
||||
style: mockXAxisStyle,
|
||||
},
|
||||
left: {
|
||||
style: mockYAxisStyle,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -203,12 +233,22 @@ describe('BarChartBaseComponent', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should render BarSeries with given barSeriesStyle', () => {
|
||||
expect(shallowWrapper.find(BarSeries).first().prop('barSeriesStyle')).toEqual(
|
||||
configs.series.barSeriesStyle
|
||||
);
|
||||
});
|
||||
|
||||
it('should render xAxis with given tick formatter', () => {
|
||||
expect(shallowWrapper.find(Axis).first().prop('tickFormat')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should render xAxis style', () => {
|
||||
expect(shallowWrapper.find(Axis).first().prop('style')).toEqual(mockXAxisStyle);
|
||||
});
|
||||
|
||||
it('should render yAxis with given tick formatter', () => {
|
||||
expect(shallowWrapper.find(Axis).last().prop('tickFormat')).toEqual(mockNumberFormatter);
|
||||
expect(shallowWrapper.find(Axis).last().prop('style')).toEqual(mockYAxisStyle);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -87,6 +87,34 @@ export const BarChartBaseComponent = ({
|
|||
...deepmerge(get('configs.settings', chartConfigs), { theme }),
|
||||
};
|
||||
|
||||
const xAxisStyle = useMemo(
|
||||
() =>
|
||||
deepmerge(
|
||||
{
|
||||
tickLine: {
|
||||
size: tickSize,
|
||||
},
|
||||
},
|
||||
getOr({}, 'configs.axis.bottom.style', chartConfigs)
|
||||
),
|
||||
[chartConfigs, tickSize]
|
||||
);
|
||||
|
||||
const yAxisStyle = useMemo(
|
||||
() =>
|
||||
deepmerge(
|
||||
{
|
||||
tickLine: {
|
||||
size: tickSize,
|
||||
},
|
||||
},
|
||||
getOr({}, 'configs.axis.left.style', chartConfigs)
|
||||
),
|
||||
[chartConfigs, tickSize]
|
||||
);
|
||||
|
||||
const xAxisLabelFormat = get('configs.axis.bottom.labelFormat', chartConfigs);
|
||||
|
||||
return chartConfigs.width && chartConfigs.height ? (
|
||||
<Chart>
|
||||
<Settings {...settings} showLegend={settings.showLegend && !forceHiddenLegend} />
|
||||
|
@ -106,6 +134,7 @@ export const BarChartBaseComponent = ({
|
|||
data={series.value ?? []}
|
||||
stackAccessors={get('configs.series.stackAccessors', chartConfigs)}
|
||||
color={series.color ? series.color : undefined}
|
||||
barSeriesStyle={get('configs.series.barSeriesStyle', chartConfigs)}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
|
@ -114,22 +143,15 @@ export const BarChartBaseComponent = ({
|
|||
id={xAxisId}
|
||||
position={Position.Bottom}
|
||||
showOverlappingTicks={false}
|
||||
style={{
|
||||
tickLine: {
|
||||
size: tickSize,
|
||||
},
|
||||
}}
|
||||
style={xAxisStyle}
|
||||
tickFormat={xTickFormatter}
|
||||
labelFormat={xAxisLabelFormat}
|
||||
/>
|
||||
|
||||
<Axis
|
||||
id={yAxisId}
|
||||
position={Position.Left}
|
||||
style={{
|
||||
tickLine: {
|
||||
size: tickSize,
|
||||
},
|
||||
}}
|
||||
style={yAxisStyle}
|
||||
tickFormat={yTickFormatter}
|
||||
title={yAxisTitle}
|
||||
/>
|
||||
|
@ -143,7 +165,7 @@ export const BarChartBase = React.memo(BarChartBaseComponent);
|
|||
|
||||
BarChartBase.displayName = 'BarChartBase';
|
||||
|
||||
interface BarChartComponentProps {
|
||||
export interface BarChartComponentProps {
|
||||
barChart: ChartSeriesData[] | null | undefined;
|
||||
configs?: ChartSeriesConfigs | undefined;
|
||||
stackByField?: string;
|
||||
|
|
|
@ -17,6 +17,8 @@ import {
|
|||
TickFormatter,
|
||||
Position,
|
||||
BrushEndListener,
|
||||
AxisStyle,
|
||||
BarSeriesStyle,
|
||||
} from '@elastic/charts';
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
@ -45,11 +47,20 @@ export interface ChartSeriesConfigs {
|
|||
xScaleType?: ScaleType | undefined;
|
||||
yScaleType?: ScaleType | undefined;
|
||||
stackAccessors?: string[] | undefined;
|
||||
barSeriesStyle?: Partial<BarSeriesStyle>;
|
||||
};
|
||||
axis?: {
|
||||
xTickFormatter?: TickFormatter | undefined;
|
||||
yTickFormatter?: TickFormatter | undefined;
|
||||
tickSize?: number | undefined;
|
||||
left?: {
|
||||
style?: Partial<AxisStyle>;
|
||||
labelFormat?: (d: unknown) => string;
|
||||
};
|
||||
bottom?: {
|
||||
style?: Partial<AxisStyle>;
|
||||
labelFormat?: (d: unknown) => string;
|
||||
};
|
||||
};
|
||||
yAxisTitle?: string | undefined;
|
||||
settings?: SettingsProps;
|
||||
|
|
|
@ -105,12 +105,12 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
|
|||
[]
|
||||
);
|
||||
|
||||
const openCount = donutData?.open?.total ?? 0;
|
||||
const acknowledgedCount = donutData?.acknowledged?.total ?? 0;
|
||||
const closedCount = donutData?.closed?.total ?? 0;
|
||||
|
||||
const totalAlerts =
|
||||
loading || donutData == null
|
||||
? 0
|
||||
: (donutData?.open?.total ?? 0) +
|
||||
(donutData?.acknowledged?.total ?? 0) +
|
||||
(donutData?.closed?.total ?? 0);
|
||||
loading || donutData == null ? 0 : openCount + acknowledgedCount + closedCount;
|
||||
|
||||
const fillColor: FillColor = useCallback((d: ShapeTreeNode) => {
|
||||
return chartConfigs.find((cfg) => cfg.label === d.dataName)?.color ?? emptyDonutColor;
|
||||
|
@ -152,10 +152,8 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
|
|||
<>
|
||||
<EuiFlexGroup justifyContent="center" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText className="eui-textCenter" size="s">
|
||||
{loading ? (
|
||||
<EuiSpacer size="l" />
|
||||
) : (
|
||||
{totalAlerts !== 0 && (
|
||||
<EuiText className="eui-textCenter" size="s">
|
||||
<>
|
||||
<b>
|
||||
<FormattedCount count={totalAlerts} />
|
||||
|
@ -163,9 +161,8 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
|
|||
<> </>
|
||||
<small>{ALERTS(totalAlerts)}</small>
|
||||
</>
|
||||
)}
|
||||
</EuiText>
|
||||
|
||||
</EuiText>
|
||||
)}
|
||||
<EuiSpacer size="l" />
|
||||
<EuiFlexGroup justifyContent="center">
|
||||
<StyledFlexItem key="alerts-status-open" grow={false}>
|
||||
|
@ -174,8 +171,8 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
|
|||
fillColor={fillColor}
|
||||
height={donutHeight}
|
||||
label={STATUS_OPEN}
|
||||
title={<ChartLabel count={donutData?.open?.total ?? 0} />}
|
||||
totalCount={donutData?.open?.total ?? 0}
|
||||
title={<ChartLabel count={openCount} />}
|
||||
totalCount={openCount}
|
||||
/>
|
||||
</StyledFlexItem>
|
||||
<StyledFlexItem key="alerts-status-acknowledged" grow={false}>
|
||||
|
@ -184,8 +181,8 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
|
|||
fillColor={fillColor}
|
||||
height={donutHeight}
|
||||
label={STATUS_ACKNOWLEDGED}
|
||||
title={<ChartLabel count={donutData?.acknowledged?.total ?? 0} />}
|
||||
totalCount={donutData?.acknowledged?.total ?? 0}
|
||||
title={<ChartLabel count={acknowledgedCount} />}
|
||||
totalCount={acknowledgedCount}
|
||||
/>
|
||||
</StyledFlexItem>
|
||||
<StyledFlexItem key="alerts-status-closed" grow={false}>
|
||||
|
@ -194,8 +191,8 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
|
|||
fillColor={fillColor}
|
||||
height={donutHeight}
|
||||
label={STATUS_CLOSED}
|
||||
title={<ChartLabel count={donutData?.closed?.total ?? 0} />}
|
||||
totalCount={donutData?.closed?.total ?? 0}
|
||||
title={<ChartLabel count={closedCount} />}
|
||||
totalCount={closedCount}
|
||||
/>
|
||||
</StyledFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { BarChartComponentProps } from '../../../../common/components/charts/barchart';
|
||||
import { useQueryToggle } from '../../../../common/containers/query_toggle';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { CasesByStatus } from './cases_by_status';
|
||||
jest.mock('../../../../common/components/link_to');
|
||||
jest.mock('../../../../common/containers/query_toggle');
|
||||
jest.mock('./use_cases_by_status', () => ({
|
||||
useCasesByStatus: jest.fn().mockReturnValue({
|
||||
closed: 1,
|
||||
inProgress: 2,
|
||||
isLoading: false,
|
||||
open: 3,
|
||||
totalCounts: 6,
|
||||
updatedAt: new Date('2022-04-08T12:00:00.000Z').valueOf(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../../common/lib/kibana', () => {
|
||||
const actual = jest.requireActual('../../../../common/lib/kibana');
|
||||
return {
|
||||
...actual,
|
||||
useNavigation: jest.fn().mockReturnValue({
|
||||
getAppUrl: jest.fn(),
|
||||
navigateTo: jest.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../../common/components/charts/barchart', () => ({
|
||||
BarChart: jest.fn((props: BarChartComponentProps) => <div data-test-subj="barChart" />),
|
||||
}));
|
||||
|
||||
const mockSetToggle = jest.fn();
|
||||
(useQueryToggle as jest.Mock).mockReturnValue({
|
||||
toggleStatus: true,
|
||||
setToggleStatus: mockSetToggle,
|
||||
});
|
||||
|
||||
describe('CasesByStatus', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders title', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<CasesByStatus />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByTestId('header-section-title')).toHaveTextContent('Cases');
|
||||
});
|
||||
|
||||
test('renders toggleQuery', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<CasesByStatus />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByTestId('query-toggle-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders BarChart', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<CasesByStatus />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByTestId('chart-wrapper')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('bar-chart-mask')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('collapses content', () => {
|
||||
(useQueryToggle as jest.Mock).mockReturnValueOnce({
|
||||
toggleStatus: false,
|
||||
setToggleStatus: mockSetToggle,
|
||||
});
|
||||
render(
|
||||
<TestProviders>
|
||||
<CasesByStatus />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('chart-wrapper')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,200 @@
|
|||
/*
|
||||
* 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, { useCallback, useMemo } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel, EuiText } from '@elastic/eui';
|
||||
import { AxisStyle, Rotation, ScaleType } from '@elastic/charts';
|
||||
import styled from 'styled-components';
|
||||
import { FormattedNumber } from '@kbn/i18n-react';
|
||||
import numeral from '@elastic/numeral';
|
||||
import { BarChart } from '../../../../common/components/charts/barchart';
|
||||
import { LastUpdatedAt } from '../util';
|
||||
import { useQueryToggle } from '../../../../common/containers/query_toggle';
|
||||
import { HeaderSection } from '../../../../common/components/header_section';
|
||||
import {
|
||||
CASES,
|
||||
CASES_BY_STATUS_SECTION_TITLE,
|
||||
STATUS_CLOSED,
|
||||
STATUS_IN_PROGRESS,
|
||||
STATUS_OPEN,
|
||||
VIEW_CASES,
|
||||
} from '../translations';
|
||||
import { LinkButton } from '../../../../common/components/links';
|
||||
import { useCasesByStatus } from './use_cases_by_status';
|
||||
import { SecurityPageName } from '../../../../../common/constants';
|
||||
import { useFormatUrl } from '../../../../common/components/link_to';
|
||||
import { appendSearch } from '../../../../common/components/link_to/helpers';
|
||||
import { useNavigation } from '../../../../common/lib/kibana';
|
||||
|
||||
const CASES_BY_STATUS_ID = 'casesByStatus';
|
||||
|
||||
export const numberFormatter = (value: string | number): string => value.toLocaleString();
|
||||
|
||||
export const barchartConfigs = {
|
||||
series: {
|
||||
xScaleType: ScaleType.Ordinal,
|
||||
yScaleType: ScaleType.Linear,
|
||||
stackAccessors: ['g'],
|
||||
barSeriesStyle: {
|
||||
rect: {
|
||||
widthPixel: 22,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
axis: {
|
||||
xTickFormatter: numberFormatter,
|
||||
left: {
|
||||
style: {
|
||||
tickLine: {
|
||||
size: 0,
|
||||
},
|
||||
tickLabel: {
|
||||
padding: 16,
|
||||
fontSize: 14,
|
||||
},
|
||||
} as Partial<AxisStyle>,
|
||||
},
|
||||
bottom: {
|
||||
style: {
|
||||
tickLine: {
|
||||
size: 0,
|
||||
},
|
||||
tickLabel: {
|
||||
padding: 16,
|
||||
fontSize: 10.5,
|
||||
},
|
||||
} as Partial<AxisStyle>,
|
||||
labelFormat: (d: unknown) => numeral(d).format('0'),
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
rotation: 90 as Rotation,
|
||||
},
|
||||
customHeight: 146,
|
||||
};
|
||||
|
||||
const barColors = {
|
||||
empty: 'rgba(105, 112, 125, 0.1)',
|
||||
open: '#79aad9',
|
||||
'in-progress': '#f1d86f',
|
||||
closed: '#d3dae6',
|
||||
};
|
||||
|
||||
export const emptyChartSettings = [
|
||||
{
|
||||
key: 'open',
|
||||
value: [{ y: 20, x: STATUS_OPEN, g: STATUS_OPEN }],
|
||||
color: barColors.empty,
|
||||
},
|
||||
{
|
||||
key: 'in-progress',
|
||||
value: [{ y: 20, x: STATUS_IN_PROGRESS, g: STATUS_IN_PROGRESS }],
|
||||
color: barColors.empty,
|
||||
},
|
||||
{
|
||||
key: 'closed',
|
||||
value: [{ y: 20, x: STATUS_CLOSED, g: STATUS_CLOSED }],
|
||||
color: barColors.empty,
|
||||
},
|
||||
];
|
||||
|
||||
const StyledEuiFlexItem = styled(EuiFlexItem)`
|
||||
align-items: center;
|
||||
width: 70%;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CasesByStatusComponent: React.FC = () => {
|
||||
const { toggleStatus, setToggleStatus } = useQueryToggle(CASES_BY_STATUS_ID);
|
||||
const { getAppUrl, navigateTo } = useNavigation();
|
||||
const { search } = useFormatUrl(SecurityPageName.case);
|
||||
const caseUrl = getAppUrl({ deepLinkId: SecurityPageName.case, path: appendSearch(search) });
|
||||
|
||||
const goToCases = useCallback(
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
navigateTo({ url: caseUrl });
|
||||
},
|
||||
[caseUrl, navigateTo]
|
||||
);
|
||||
const { closed, inProgress, isLoading, open, totalCounts, updatedAt } = useCasesByStatus({
|
||||
skip: !toggleStatus,
|
||||
});
|
||||
|
||||
const chartData = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'open',
|
||||
value: [{ y: open, x: STATUS_OPEN, g: STATUS_OPEN }],
|
||||
color: barColors.open,
|
||||
},
|
||||
{
|
||||
key: 'in-progress',
|
||||
value: [{ y: inProgress, x: STATUS_IN_PROGRESS, g: STATUS_IN_PROGRESS }],
|
||||
color: barColors['in-progress'],
|
||||
},
|
||||
{
|
||||
key: 'closed',
|
||||
value: [{ y: closed, x: STATUS_CLOSED, g: STATUS_CLOSED }],
|
||||
color: barColors.closed,
|
||||
},
|
||||
],
|
||||
[closed, inProgress, open]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiPanel hasBorder>
|
||||
<HeaderSection
|
||||
id={CASES_BY_STATUS_ID}
|
||||
title={CASES_BY_STATUS_SECTION_TITLE}
|
||||
titleSize="s"
|
||||
toggleStatus={toggleStatus}
|
||||
toggleQuery={setToggleStatus}
|
||||
subtitle={<LastUpdatedAt updatedAt={updatedAt} isUpdating={isLoading} />}
|
||||
showInspectButton={false}
|
||||
>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<LinkButton href={caseUrl} onClick={goToCases}>
|
||||
{VIEW_CASES}
|
||||
</LinkButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</HeaderSection>
|
||||
{!isLoading && toggleStatus && (
|
||||
<EuiFlexGroup justifyContent="center" alignItems="center" direction="column" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
{totalCounts !== 0 && (
|
||||
<EuiText className="eui-textCenter" size="s" grow={false}>
|
||||
<>
|
||||
<b>
|
||||
<FormattedNumber value={totalCounts} />
|
||||
</b>
|
||||
<> </>
|
||||
<small>
|
||||
<EuiLink onClick={goToCases}>{CASES(totalCounts)}</EuiLink>
|
||||
</small>
|
||||
</>
|
||||
</EuiText>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<StyledEuiFlexItem grow={false}>
|
||||
<Wrapper data-test-subj="chart-wrapper">
|
||||
<BarChart configs={barchartConfigs} barChart={chartData} />
|
||||
</Wrapper>
|
||||
</StyledEuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export const CasesByStatus = React.memo(CasesByStatusComponent);
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { CasesByStatus } from './cases_by_status';
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import {
|
||||
useCasesByStatus,
|
||||
UseCasesByStatusProps,
|
||||
UseCasesByStatusResults,
|
||||
} from './use_cases_by_status';
|
||||
|
||||
const dateNow = new Date('2022-04-08T12:00:00.000Z').valueOf();
|
||||
const mockDateNow = jest.fn().mockReturnValue(dateNow);
|
||||
Date.now = jest.fn(() => mockDateNow()) as unknown as DateConstructor['now'];
|
||||
|
||||
jest.mock('../../../../common/containers/use_global_time', () => {
|
||||
return {
|
||||
useGlobalTime: jest
|
||||
.fn()
|
||||
.mockReturnValue({ from: '2022-04-05T12:00:00.000Z', to: '2022-04-08T12:00:00.000Z' }),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
const mockGetAllCasesMetrics = jest.fn();
|
||||
mockGetAllCasesMetrics.mockResolvedValue({
|
||||
count_open_cases: 1,
|
||||
count_in_progress_cases: 2,
|
||||
count_closed_cases: 3,
|
||||
});
|
||||
mockGetAllCasesMetrics.mockResolvedValueOnce({
|
||||
count_open_cases: 0,
|
||||
count_in_progress_cases: 0,
|
||||
count_closed_cases: 0,
|
||||
});
|
||||
|
||||
const mockUseKibana = {
|
||||
services: {
|
||||
cases: {
|
||||
...mockCasesContract(),
|
||||
api: {
|
||||
cases: {
|
||||
getAllCasesMetrics: mockGetAllCasesMetrics,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(useKibana as jest.Mock).mockReturnValue(mockUseKibana);
|
||||
|
||||
describe('useCasesByStatus', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('init', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseCasesByStatusProps,
|
||||
UseCasesByStatusResults
|
||||
>(() => useCasesByStatus({ skip: false }), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
expect(result.current).toEqual({
|
||||
closed: 0,
|
||||
inProgress: 0,
|
||||
isLoading: true,
|
||||
open: 0,
|
||||
totalCounts: 0,
|
||||
updatedAt: dateNow,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('fetch data', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseCasesByStatusProps,
|
||||
UseCasesByStatusResults
|
||||
>(() => useCasesByStatus({ skip: false }), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
expect(result.current).toEqual({
|
||||
closed: 3,
|
||||
inProgress: 2,
|
||||
isLoading: false,
|
||||
open: 1,
|
||||
totalCounts: 6,
|
||||
updatedAt: dateNow,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('skip', async () => {
|
||||
const abortSpy = jest.spyOn(AbortController.prototype, 'abort');
|
||||
await act(async () => {
|
||||
const localProps = { skip: false };
|
||||
|
||||
const { rerender, waitForNextUpdate } = renderHook<
|
||||
UseCasesByStatusProps,
|
||||
UseCasesByStatusResults
|
||||
>(() => useCasesByStatus(localProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
|
||||
localProps.skip = true;
|
||||
act(() => rerender());
|
||||
act(() => rerender());
|
||||
expect(abortSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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 { useState, useEffect } from 'react';
|
||||
import { APP_ID } from '../../../../../common/constants';
|
||||
import { useGlobalTime } from '../../../../common/containers/use_global_time';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
|
||||
export interface CasesCounts {
|
||||
count_open_cases?: number;
|
||||
count_in_progress_cases?: number;
|
||||
count_closed_cases?: number;
|
||||
}
|
||||
|
||||
export interface UseCasesByStatusProps {
|
||||
skip?: boolean;
|
||||
}
|
||||
|
||||
export interface UseCasesByStatusResults {
|
||||
closed: number;
|
||||
inProgress: number;
|
||||
isLoading: boolean;
|
||||
open: number;
|
||||
totalCounts: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const useCasesByStatus = ({ skip = false }) => {
|
||||
const {
|
||||
services: { cases },
|
||||
} = useKibana();
|
||||
const { to, from } = useGlobalTime();
|
||||
|
||||
const [updatedAt, setUpdatedAt] = useState(Date.now());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [casesCounts, setCasesCounts] = useState<CasesCounts | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
const abortCtrl = new AbortController();
|
||||
const fetchCases = async () => {
|
||||
try {
|
||||
const casesResponse = await cases.api.cases.getAllCasesMetrics({
|
||||
from,
|
||||
to,
|
||||
owner: APP_ID,
|
||||
});
|
||||
if (isSubscribed) {
|
||||
setCasesCounts(casesResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSubscribed) {
|
||||
setCasesCounts({});
|
||||
}
|
||||
}
|
||||
if (isSubscribed) {
|
||||
setIsLoading(false);
|
||||
setUpdatedAt(Date.now());
|
||||
}
|
||||
};
|
||||
|
||||
if (!skip) {
|
||||
fetchCases();
|
||||
}
|
||||
|
||||
if (skip) {
|
||||
setIsLoading(false);
|
||||
isSubscribed = false;
|
||||
abortCtrl.abort();
|
||||
}
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
abortCtrl.abort();
|
||||
};
|
||||
}, [cases.api.cases, from, skip, to]);
|
||||
|
||||
return {
|
||||
closed: casesCounts?.count_closed_cases ?? 0,
|
||||
inProgress: casesCounts?.count_in_progress_cases ?? 0,
|
||||
isLoading,
|
||||
open: casesCounts?.count_open_cases ?? 0,
|
||||
totalCounts:
|
||||
(casesCounts?.count_closed_cases ?? 0) +
|
||||
(casesCounts?.count_in_progress_cases ?? 0) +
|
||||
(casesCounts?.count_open_cases ?? 0),
|
||||
updatedAt,
|
||||
};
|
||||
};
|
|
@ -31,26 +31,23 @@ export const STATUS_LOW_LABEL = i18n.translate(
|
|||
defaultMessage: 'Low',
|
||||
}
|
||||
);
|
||||
export const STATUS_OPEN = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.open',
|
||||
{
|
||||
defaultMessage: 'Open',
|
||||
}
|
||||
);
|
||||
export const STATUS_ACKNOWLEDGED = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.acknowledged',
|
||||
'xpack.securitySolution.detectionResponse.alertsByStatus.status.acknowledged',
|
||||
{
|
||||
defaultMessage: 'Acknowledged',
|
||||
}
|
||||
);
|
||||
export const STATUS_OPEN = i18n.translate('xpack.securitySolution.detectionResponse.status.open', {
|
||||
defaultMessage: 'Open',
|
||||
});
|
||||
export const STATUS_CLOSED = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.closed',
|
||||
'xpack.securitySolution.detectionResponse.status.closed',
|
||||
{
|
||||
defaultMessage: 'Closed',
|
||||
}
|
||||
);
|
||||
export const STATUS_IN_PROGRESS = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.inProgress',
|
||||
'xpack.securitySolution.detectionResponse.status.inProgress',
|
||||
{
|
||||
defaultMessage: 'In progress',
|
||||
}
|
||||
|
@ -69,6 +66,21 @@ export const UPDATING = i18n.translate('xpack.securitySolution.detectionResponse
|
|||
export const UPDATED = i18n.translate('xpack.securitySolution.detectionResponse.updated', {
|
||||
defaultMessage: 'Updated',
|
||||
});
|
||||
export const CASES = (totalCases: number) =>
|
||||
i18n.translate('xpack.securitySolution.detectionResponse.casesByStatus.totalCases', {
|
||||
values: { totalCases },
|
||||
defaultMessage: 'total {totalCases, plural, =1 {case} other {cases}}',
|
||||
});
|
||||
export const CASES_BY_STATUS_SECTION_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.casesByStatusSectionTitle',
|
||||
{
|
||||
defaultMessage: 'Cases',
|
||||
}
|
||||
);
|
||||
|
||||
export const VIEW_CASES = i18n.translate('xpack.securitySolution.detectionResponse.viewCases', {
|
||||
defaultMessage: 'View cases',
|
||||
});
|
||||
|
||||
export const RULE_ALERTS_SECTION_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.ruleAlertsSectionTitle',
|
||||
|
|
|
@ -14,7 +14,14 @@ import { TestProviders } from '../../common/mock';
|
|||
jest.mock('../components/detection_response/rule_alerts_table', () => ({
|
||||
RuleAlertsTable: () => <div data-test-subj="mock_RuleAlertsTable" />,
|
||||
}));
|
||||
// TODO: add all sections mocks
|
||||
|
||||
jest.mock('../components/detection_response/alerts_by_status', () => ({
|
||||
AlertsByStatus: () => <div data-test-subj="mock_AlertsByStatus" />,
|
||||
}));
|
||||
|
||||
jest.mock('../components/detection_response/cases_by_status', () => ({
|
||||
CasesByStatus: () => <div data-test-subj="mock_CasesByStatus" />,
|
||||
}));
|
||||
|
||||
jest.mock('../../common/components/search_bar', () => ({
|
||||
SiemSearchBar: () => <div data-test-subj="mock_globalDatePicker" />,
|
||||
|
@ -128,9 +135,12 @@ describe('DetectionResponse', () => {
|
|||
);
|
||||
|
||||
expect(result.queryByTestId('detectionResponsePage')).toBeInTheDocument();
|
||||
expect(result.queryByTestId('mock_RuleAlertsTable')).not.toBeInTheDocument();
|
||||
// TODO: assert other alert sections are not in the document
|
||||
expect(result.queryByTestId('mock_RuleAlertsTable')).not.toBeInTheDocument();
|
||||
expect(result.queryByTestId('mock_AlertsByStatus')).not.toBeInTheDocument();
|
||||
|
||||
// TODO: assert cases sections are in the document
|
||||
expect(result.queryByTestId('mock_CasesByStatus')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render alerts data sections if user has not kibana read permission', () => {
|
||||
|
@ -148,9 +158,13 @@ describe('DetectionResponse', () => {
|
|||
);
|
||||
|
||||
expect(result.queryByTestId('detectionResponsePage')).toBeInTheDocument();
|
||||
expect(result.queryByTestId('mock_RuleAlertsTable')).not.toBeInTheDocument();
|
||||
|
||||
// TODO: assert all alert sections are not in the document
|
||||
expect(result.queryByTestId('mock_RuleAlertsTable')).not.toBeInTheDocument();
|
||||
expect(result.queryByTestId('mock_AlertsByStatus')).not.toBeInTheDocument();
|
||||
|
||||
// TODO: assert all cases sections are in the document
|
||||
expect(result.queryByTestId('mock_CasesByStatus')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render cases data sections if user has not cases read permission', () => {
|
||||
|
@ -165,9 +179,11 @@ describe('DetectionResponse', () => {
|
|||
);
|
||||
|
||||
expect(result.queryByTestId('detectionResponsePage')).toBeInTheDocument();
|
||||
expect(result.queryByTestId('mock_RuleAlertsTable')).toBeInTheDocument();
|
||||
// TODO: assert all alert sections are in the document
|
||||
expect(result.queryByTestId('mock_RuleAlertsTable')).toBeInTheDocument();
|
||||
expect(result.queryByTestId('mock_AlertsByStatus')).toBeInTheDocument();
|
||||
// TODO: assert all cases sections are not in the document
|
||||
expect(result.queryByTestId('mock_CasesByStatus')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render page permissions message if user has any read permission', () => {
|
||||
|
|
|
@ -20,6 +20,7 @@ import { LandingPageComponent } from '../../common/components/landing_page';
|
|||
import { RuleAlertsTable } from '../components/detection_response/rule_alerts_table';
|
||||
import * as i18n from './translations';
|
||||
import { EmptyPage } from '../../common/components/empty_page';
|
||||
import { CasesByStatus } from '../components/detection_response/cases_by_status';
|
||||
import { AlertsByStatus } from '../components/detection_response/alerts_by_status';
|
||||
|
||||
const NoPrivilegePage: React.FC = () => {
|
||||
|
@ -75,7 +76,11 @@ const DetectionResponseComponent = () => {
|
|||
<AlertsByStatus signalIndexName={signalIndexName} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{canReadCases && <EuiFlexItem>{'[cases chart]'}</EuiFlexItem>}
|
||||
{canReadCases && (
|
||||
<EuiFlexItem>
|
||||
<CasesByStatus />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue