[Security Solution][Detection & Response] 131028 implement recently opened cases table (#131029)

* First draft at possible implementation. tests need to be added, and imports,comments, logs cleaned up

* Further tweaks to alerts counters

* Add tests for alerts_counters hook

* Working on vulnerable hosts

* Add useVulnerableHostsCounters hook along with tests

* add Vulnerable users and tests

* Move files to components folder and wire up to detections overview page

* Add translations

* add querytoggle and navigation to both tables

* fix bug for toggleQuery

* update button navigation

* remove alerts by status, as Angela built instead

* Working on changing test files

* test files for host and user hooks complete

* Components complete

* bug fixes from PR

* failing tests

* Undo bad edit to useRuleAlerts test

* Fix show inspect on hover, and use HostDetailsLink component

* missed in last commit

* more fixes from PR review

* recent cases table working, need tests

* first pass for table and data fetching

* Make changes from PR review

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* PR fixes

* PR fixes

* remove cases api date workaround

* enable detectionsResponse for deployed instance

* Fix tests

* turn off detectionresponseEnabled flag

* fixes from design review

* stability fix. remove useUserInfo

* Add comment for removing togglequery code

Co-authored-by: Kristof-Pierre Cummings <kristofpierre.cummings@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Kristof C 2022-05-04 13:56:00 -05:00 committed by GitHub
parent a632484214
commit c43a51d7ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 888 additions and 63 deletions

View file

@ -43,6 +43,18 @@ describe('formatted_date', () => {
expect(wrapper.text()).toEqual('Feb 25, 2019 @ 22:27:05.000');
});
test.each([
['MMMM D, YYYY', 'February 25, 2019'],
['MM/D/YY', '02/25/19'],
['d-m-y', '1-27-2019'],
])('it renders the date in the correct format: %s', (momentDateFormat, expectedResult) => {
const wrapper = mount(
<PreferenceFormattedDate value={isoDate} dateFormat={momentDateFormat} />
);
expect(wrapper.text()).toEqual(expectedResult);
});
test('it renders a UTC ISO8601 date string supplied when no date format configuration exists', () => {
mockUseDateFormat.mockImplementation(() => '');
const wrapper = mount(<PreferenceFormattedDate value={isoDate} />);

View file

@ -97,16 +97,18 @@ interface FormattedDateProps {
className?: string;
fieldName: string;
value?: string | number | null;
dateFormat?: string;
}
export const FormattedDate = React.memo<FormattedDateProps>(
({ value, fieldName, className = '' }): JSX.Element => {
({ value, fieldName, className = '', dateFormat }): JSX.Element => {
if (value == null) {
return getOrEmptyTagFromValue(value);
}
const maybeDate = getMaybeDate(value);
return maybeDate.isValid() ? (
<LocalizedDateTooltip date={maybeDate.toDate()} fieldName={fieldName} className={className}>
<PreferenceFormattedDate value={maybeDate.toDate()} />
<PreferenceFormattedDate value={maybeDate.toDate()} dateFormat={dateFormat} />
</LocalizedDateTooltip>
) : (
getOrEmptyTagFromValue(value)

View file

@ -49,6 +49,11 @@ const StyledLegendFlexItem = styled(EuiFlexItem)`
padding-top: 45px;
`;
// To Do remove this styled component once togglequery is updated: #131405
const StyledEuiPanel = styled(EuiPanel)`
height: fit-content;
`;
interface AlertsByStatusProps {
signalIndexName: string | null;
}
@ -119,7 +124,10 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
return (
<>
<HoverVisibilityContainer show={true} targetClassNames={[INPECT_BUTTON_CLASS]}>
<EuiPanel hasBorder data-test-subj={`${DETECTION_RESPONSE_ALERTS_BY_STATUS_ID}-panel`}>
<StyledEuiPanel
hasBorder
data-test-subj={`${DETECTION_RESPONSE_ALERTS_BY_STATUS_ID}-panel`}
>
{loading && (
<EuiProgress
data-test-subj="initialLoadingPanelMatrixOverTime"
@ -204,7 +212,7 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
<EuiSpacer size="m" />
</>
)}
</EuiPanel>
</StyledEuiPanel>
</HoverVisibilityContainer>
</>
);

View file

@ -6,7 +6,7 @@
*/
import React, { useCallback, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel, EuiText } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui';
import { AxisStyle, Rotation, ScaleType } from '@elastic/charts';
import styled from 'styled-components';
import { FormattedNumber } from '@kbn/i18n-react';
@ -112,6 +112,10 @@ const Wrapper = styled.div`
width: 100%;
`;
const StyledEuiPanel = styled(EuiPanel)`
height: 258px;
`;
const CasesByStatusComponent: React.FC = () => {
const { toggleStatus, setToggleStatus } = useQueryToggle(CASES_BY_STATUS_ID);
const { getAppUrl, navigateTo } = useNavigation();
@ -151,7 +155,7 @@ const CasesByStatusComponent: React.FC = () => {
);
return (
<EuiPanel hasBorder>
<StyledEuiPanel hasBorder>
<HeaderSection
id={CASES_BY_STATUS_ID}
title={CASES_BY_STATUS_SECTION_TITLE}
@ -177,11 +181,8 @@ const CasesByStatusComponent: React.FC = () => {
<>
<b>
<FormattedNumber value={totalCounts} />
</b>
<> </>
<small>
<EuiLink onClick={goToCases}>{CASES(totalCounts)}</EuiLink>
</small>
</b>{' '}
<span> {CASES(totalCounts)}</span>
</>
</EuiText>
)}
@ -193,7 +194,7 @@ const CasesByStatusComponent: React.FC = () => {
</StyledEuiFlexItem>
</EuiFlexGroup>
)}
</EuiPanel>
</StyledEuiPanel>
);
};

View file

@ -0,0 +1,104 @@
/*
* 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 { TestProviders } from '../../../../common/mock';
import { parsedCasesItems } from './mock_data';
import { CasesTable } from './cases_table';
import type { UseCaseItems } from './use_case_items';
const mockGetAppUrl = jest.fn();
jest.mock('../../../../common/lib/kibana/hooks', () => {
const original = jest.requireActual('../../../../common/lib/kibana/hooks');
return {
...original,
useNavigation: () => ({
getAppUrl: mockGetAppUrl,
}),
};
});
type UseCaseItemsReturn = ReturnType<UseCaseItems>;
const defaultCaseItemsReturn: UseCaseItemsReturn = {
items: [],
isLoading: false,
updatedAt: Date.now(),
};
const mockUseCaseItems = jest.fn(() => defaultCaseItemsReturn);
const mockUseCaseItemsReturn = (overrides: Partial<UseCaseItemsReturn>) => {
mockUseCaseItems.mockReturnValueOnce({
...defaultCaseItemsReturn,
...overrides,
});
};
jest.mock('./use_case_items', () => ({
useCaseItems: () => mockUseCaseItems(),
}));
const renderComponent = () =>
render(
<TestProviders>
<CasesTable />
</TestProviders>
);
describe('CasesTable', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render empty table', () => {
const { getByText, getByTestId } = renderComponent();
expect(getByTestId('recentlyCreatedCasesPanel')).toBeInTheDocument();
expect(getByText('No cases to display')).toBeInTheDocument();
expect(getByTestId('allCasesButton')).toBeInTheDocument();
});
it('should render a loading table', () => {
mockUseCaseItemsReturn({ isLoading: true });
const { getByText, getByTestId } = renderComponent();
expect(getByText('Updating...')).toBeInTheDocument();
expect(getByTestId('allCasesButton')).toBeInTheDocument();
expect(getByTestId('recentlyCreatedCasesTable')).toHaveClass('euiBasicTable-loading');
});
it('should render the updated at subtitle', () => {
mockUseCaseItemsReturn({ isLoading: false });
const { getByText } = renderComponent();
expect(getByText('Updated now')).toBeInTheDocument();
});
it('should render the table columns', () => {
mockUseCaseItemsReturn({ items: parsedCasesItems });
const { getAllByRole } = renderComponent();
const columnHeaders = getAllByRole('columnheader');
expect(columnHeaders.at(0)).toHaveTextContent('Name');
expect(columnHeaders.at(1)).toHaveTextContent('Note');
expect(columnHeaders.at(2)).toHaveTextContent('Time');
expect(columnHeaders.at(3)).toHaveTextContent('Created by');
expect(columnHeaders.at(4)).toHaveTextContent('Status');
});
it('should render the table items', () => {
mockUseCaseItemsReturn({ items: [parsedCasesItems[0]] });
const { getByTestId } = renderComponent();
expect(getByTestId('recentlyCreatedCaseName')).toHaveTextContent('sdcsd');
expect(getByTestId('recentlyCreatedCaseNote')).toHaveTextContent('klklk');
expect(getByTestId('recentlyCreatedCaseTime')).toHaveTextContent('April 25, 2022');
expect(getByTestId('recentlyCreatedCaseCreatedBy')).toHaveTextContent('elastic');
expect(getByTestId('recentlyCreatedCaseStatus')).toHaveTextContent('Open');
});
});

View file

@ -0,0 +1,145 @@
/*
* 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 {
EuiBasicTable,
EuiBasicTableColumn,
EuiButton,
EuiEmptyPrompt,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { CaseStatuses } from '@kbn/cases-plugin/common';
import { SecurityPageName } from '../../../../app/types';
import { FormattedDate } from '../../../../common/components/formatted_date';
import { HeaderSection } from '../../../../common/components/header_section';
import { HoverVisibilityContainer } from '../../../../common/components/hover_visibility_container';
import { BUTTON_CLASS as INPECT_BUTTON_CLASS } from '../../../../common/components/inspect';
import { CaseDetailsLink } from '../../../../common/components/links';
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { useNavigation, NavigateTo, GetAppUrl } from '../../../../common/lib/kibana';
import * as i18n from '../translations';
import { LastUpdatedAt } from '../util';
import { StatusBadge } from './status_badge';
import { CaseItem, useCaseItems } from './use_case_items';
type GetTableColumns = (params: {
getAppUrl: GetAppUrl;
navigateTo: NavigateTo;
}) => Array<EuiBasicTableColumn<CaseItem>>;
const DETECTION_RESPONSE_RECENT_CASES_QUERY_ID = 'recentlyCreatedCasesQuery';
export const CasesTable = React.memo(() => {
const { getAppUrl, navigateTo } = useNavigation();
const { toggleStatus, setToggleStatus } = useQueryToggle(
DETECTION_RESPONSE_RECENT_CASES_QUERY_ID
);
const { items, isLoading, updatedAt } = useCaseItems({
skip: !toggleStatus,
});
const navigateToCases = useCallback(() => {
navigateTo({ deepLinkId: SecurityPageName.case });
}, [navigateTo]);
const columns = useMemo(
() => getTableColumns({ getAppUrl, navigateTo }),
[getAppUrl, navigateTo]
);
return (
<HoverVisibilityContainer show={true} targetClassNames={[INPECT_BUTTON_CLASS]}>
<EuiPanel hasBorder data-test-subj="recentlyCreatedCasesPanel">
<HeaderSection
id={DETECTION_RESPONSE_RECENT_CASES_QUERY_ID}
title={i18n.CASES_TABLE_SECTION_TITLE}
titleSize="s"
toggleStatus={toggleStatus}
toggleQuery={setToggleStatus}
subtitle={<LastUpdatedAt updatedAt={updatedAt} isUpdating={isLoading} />}
showInspectButton={false}
/>
{toggleStatus && (
<>
<EuiBasicTable
data-test-subj="recentlyCreatedCasesTable"
columns={columns}
items={items}
loading={isLoading}
noItemsMessage={
<EuiEmptyPrompt title={<h3>{i18n.NO_CASES_FOUND}</h3>} titleSize="xs" />
}
/>
<EuiSpacer size="m" />
<EuiButton data-test-subj="allCasesButton" onClick={navigateToCases}>
{i18n.VIEW_RECENT_CASES}
</EuiButton>
</>
)}
</EuiPanel>
</HoverVisibilityContainer>
);
});
CasesTable.displayName = 'CasesTable';
const getTableColumns: GetTableColumns = () => [
{
field: 'id',
name: i18n.CASES_TABLE_COLUMN_NAME,
truncateText: true,
textOnly: true,
'data-test-subj': 'recentlyCreatedCaseName',
render: (id: string, { name }) => <CaseDetailsLink detailName={id}>{name}</CaseDetailsLink>,
},
{
field: 'note',
name: i18n.CASES_TABLE_COLUMN_NOTE,
truncateText: true,
textOnly: true,
render: (note: string) => (
<EuiText data-test-subj="recentlyCreatedCaseNote" size="s">
{note}
</EuiText>
),
},
{
field: 'createdAt',
name: i18n.CASES_TABLE_COLUMN_TIME,
render: (createdAt: string) => (
<FormattedDate
fieldName={i18n.CASES_TABLE_COLUMN_TIME}
value={createdAt}
className="eui-textTruncate"
dateFormat="MMMM D, YYYY"
/>
),
'data-test-subj': 'recentlyCreatedCaseTime',
},
{
field: 'createdBy',
name: i18n.CASES_TABLE_COLUMN_CREATED_BY,
render: (createdBy: string) => (
<EuiText data-test-subj="recentlyCreatedCaseCreatedBy" size="s">
{createdBy}
</EuiText>
),
},
{
field: 'status',
name: i18n.CASES_TABLE_COLUMN_STATUS,
render: (status: CaseStatuses) => <StatusBadge status={status} />,
'data-test-subj': 'recentlyCreatedCaseStatus',
},
];

View file

@ -5,5 +5,4 @@
* 2.0.
*/
export { HostAlertsTable } from './host_alerts_table';
export { UserAlertsTable } from './user_alerts_table';
export { CasesTable } from './cases_table';

View file

@ -0,0 +1,160 @@
/*
* 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 { CaseStatuses } from '@kbn/cases-plugin/common';
export const mockCasesResult = {
cases: [
{
id: '0ce2a510-c43a-11ec-98b5-3109bd2a1901',
version: 'Wzg2MDIsMV0=',
comments: [],
totalComment: 0,
totalAlerts: 0,
title: 'sdcsd',
tags: [],
description: 'klklk',
settings: {
syncAlerts: true,
},
owner: 'securitySolution',
closed_at: '2022-04-25T01:50:40.435Z',
closed_by: {
full_name: null,
email: null,
username: 'elastic',
},
createdAt: '2022-04-25T01:50:14.499Z',
createdBy: {
username: 'elastic',
email: null,
full_name: null,
},
status: 'open',
updated_at: '2022-04-25T01:50:40.435Z',
updated_by: {
full_name: null,
email: null,
username: 'elastic',
},
connector: {
id: 'none',
name: 'none',
type: '.none',
fields: null,
},
external_service: null,
},
{
id: 'dc12f930-c178-11ec-98b5-3109bd2a1901',
version: 'Wzg5NjksMV0=',
comments: [],
totalComment: 0,
totalAlerts: 0,
title: 'zzzz',
tags: [],
description: 'sssss',
settings: {
syncAlerts: true,
},
owner: 'securitySolution',
closed_at: '2022-04-25T13:45:18.317Z',
closed_by: {
full_name: null,
email: null,
username: 'elastic',
},
createdAt: '2022-04-21T13:42:17.414Z',
createdBy: {
username: 'elastic',
email: null,
full_name: null,
},
status: 'in-progress',
updated_at: '2022-04-25T13:45:18.317Z',
updated_by: {
full_name: null,
email: null,
username: 'elastic',
},
connector: {
id: 'none',
name: 'none',
type: '.none',
fields: null,
},
external_service: null,
},
{
id: 'd11cbac0-c178-11ec-98b5-3109bd2a1901',
version: 'Wzg5ODQsMV0=',
comments: [],
totalComment: 0,
totalAlerts: 0,
title: 'asxa',
tags: [],
description: 'dsdd',
settings: {
syncAlerts: true,
},
owner: 'securitySolution',
closed_at: '2022-04-25T13:45:22.539Z',
closed_by: {
full_name: null,
email: null,
username: 'elastic',
},
createdAt: '2022-04-21T13:41:59.025Z',
createdBy: {
username: 'elastic',
email: null,
full_name: null,
},
status: 'closed',
updated_at: '2022-04-25T13:45:22.539Z',
updated_by: {
full_name: null,
email: null,
username: 'elastic',
},
connector: {
id: 'none',
name: 'none',
type: '.none',
fields: null,
},
external_service: null,
},
],
};
export const parsedCasesItems = [
{
name: 'sdcsd',
note: 'klklk',
createdBy: 'elastic',
status: CaseStatuses.open,
createdAt: '2022-04-25T01:50:14.499Z',
id: '0ce2a510-c43a-11ec-98b5-3109bd2a1901',
},
{
name: 'zzzz',
note: 'sssss',
status: CaseStatuses['in-progress'],
createdAt: '2022-04-21T13:42:17.414Z',
createdBy: 'elastic',
id: 'dc12f930-c178-11ec-98b5-3109bd2a1901',
},
{
name: 'asxa',
note: 'dsdd',
createdBy: 'elastic',
status: CaseStatuses.closed,
createdAt: '2022-04-21T13:41:59.025Z',
id: 'd11cbac0-c178-11ec-98b5-3109bd2a1901',
},
];

View file

@ -0,0 +1,42 @@
/*
* 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 { CaseStatuses } from '@kbn/cases-plugin/common';
import { EuiBadge } from '@elastic/eui';
import * as i18n from '../translations';
interface Props {
status: CaseStatuses;
}
const statuses = {
[CaseStatuses.open]: {
color: 'primary',
label: i18n.STATUS_OPEN,
},
[CaseStatuses['in-progress']]: {
color: 'warning',
label: i18n.STATUS_IN_PROGRESS,
},
[CaseStatuses.closed]: {
color: 'default',
label: i18n.STATUS_CLOSED,
},
} as const;
export const StatusBadge: React.FC<Props> = ({ status }) => {
return (
<EuiBadge color={statuses[status].color} data-test-subj="case-status-badge">
{statuses[status].label}
</EuiBadge>
);
};
StatusBadge.displayName = 'StatusBadge';

View file

@ -0,0 +1,133 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import { mockCasesResult, parsedCasesItems } from './mock_data';
import { useCaseItems, UseCaseItemsProps } from './use_case_items';
import type { UseCaseItems } from './use_case_items';
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'];
const defaultCasesReturn = {
cases: [],
};
const mockCasesApi = jest.fn().mockResolvedValue(defaultCasesReturn);
const mockKibana = {
services: {
cases: {
api: {
cases: {
find: (...props: unknown[]) => mockCasesApi(...props),
},
},
},
},
};
jest.mock('../../../../common/lib/kibana', () => ({
useKibana: () => mockKibana,
}));
const from = '2020-07-07T08:20:18.966Z';
const to = '2020-07-08T08:20:18.966Z';
const mockUseGlobalTime = jest.fn().mockReturnValue({ from, to });
jest.mock('../../../../common/containers/use_global_time', () => {
return {
useGlobalTime: (...props: unknown[]) => mockUseGlobalTime(...props),
};
});
const renderUseCaseItems = (overrides: Partial<UseCaseItemsProps> = {}) =>
renderHook<UseCaseItems, ReturnType<UseCaseItems>>(() =>
useCaseItems({ skip: false, ...overrides })
);
describe('useCaseItems', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDateNow.mockReturnValue(dateNow);
mockCasesApi.mockResolvedValue(defaultCasesReturn);
});
it('should return default values', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderUseCaseItems();
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
items: [],
isLoading: false,
updatedAt: dateNow,
});
});
expect(mockCasesApi).toBeCalledWith({
from: '2020-07-07T08:20:18.966Z',
to: '2020-07-08T08:20:18.966Z',
owner: 'securitySolution',
sortField: 'create_at',
sortOrder: 'desc',
page: 1,
perPage: 4,
});
});
it('should return parsed items', async () => {
mockCasesApi.mockReturnValue(mockCasesResult);
await act(async () => {
const { result, waitForNextUpdate } = renderUseCaseItems();
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
items: parsedCasesItems,
isLoading: false,
updatedAt: dateNow,
});
});
});
it('should return new updatedAt', async () => {
const newDateNow = new Date('2022-04-08T14:00:00.000Z').valueOf();
mockDateNow.mockReturnValue(newDateNow);
mockDateNow.mockReturnValueOnce(dateNow);
mockCasesApi.mockReturnValue(mockCasesResult);
await act(async () => {
const { result, waitForNextUpdate } = renderUseCaseItems();
await waitForNextUpdate();
await waitForNextUpdate();
expect(mockDateNow).toHaveBeenCalled();
expect(result.current).toEqual({
items: parsedCasesItems,
isLoading: false,
updatedAt: newDateNow,
});
});
});
it('should skip the query', () => {
const { result } = renderUseCaseItems({ skip: true });
expect(result.current).toEqual({
items: [],
isLoading: false,
updatedAt: dateNow,
});
});
});

View file

@ -0,0 +1,110 @@
/*
* 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 { CaseStatuses } from '@kbn/cases-plugin/common';
import { Cases } from '@kbn/cases-plugin/common/ui';
import { APP_ID } from '../../../../../common/constants';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { useKibana } from '../../../../common/lib/kibana';
import { addError } from '../../../../common/store/app/actions';
import * as i18n from '../translations';
export interface CaseItem {
name: string;
note: string;
createdAt: string;
createdBy: string;
status: CaseStatuses;
id: string;
}
export interface UseCaseItemsProps {
skip: boolean;
}
export type UseCaseItems = (props: UseCaseItemsProps) => {
items: CaseItem[];
isLoading: boolean;
updatedAt: number;
};
export const useCaseItems: UseCaseItems = ({ skip }) => {
const {
services: { cases },
} = useKibana();
const { to, from } = useGlobalTime();
const [isLoading, setIsLoading] = useState(true);
const [updatedAt, setUpdatedAt] = useState(Date.now());
const [items, setItems] = useState<CaseItem[]>([]);
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
const fetchCases = async () => {
try {
const casesResponse = await cases.api.cases.find({
from,
to,
owner: APP_ID,
sortField: 'create_at',
sortOrder: 'desc',
page: 1,
perPage: 4,
});
if (isSubscribed) {
setItems(parseCases(casesResponse));
setUpdatedAt(Date.now());
}
} catch (error) {
if (isSubscribed) {
addError(error, { title: i18n.ERROR_MESSAGE_CASES });
}
}
setIsLoading(false);
};
if (!skip) {
fetchCases();
}
if (skip) {
setIsLoading(false);
isSubscribed = false;
abortCtrl.abort();
}
return () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [cases.api.cases, from, skip, to]);
return { items, isLoading, updatedAt };
};
function parseCases(casesResponse: Cases): CaseItem[] {
const allCases = casesResponse.cases || [];
return allCases.reduce<CaseItem[]>((accumulated, currentCase) => {
accumulated.push({
id: currentCase.id,
name: currentCase.title,
note: currentCase.description,
createdAt: currentCase.createdAt,
createdBy: currentCase.createdBy.username || '—',
status: currentCase.status,
});
return accumulated;
}, []);
}

View file

@ -6,6 +6,7 @@
*/
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import {
EuiBasicTable,
@ -40,6 +41,11 @@ interface HostAlertsTableProps {
const DETECTION_RESPONSE_HOST_SEVERITY_QUERY_ID = 'vulnerableHostsBySeverityQuery';
// To Do remove this styled component once togglequery is updated: #131405
const StyledEuiPanel = styled(EuiPanel)`
height: fit-content;
`;
export const HostAlertsTable = React.memo(({ signalIndexName }: HostAlertsTableProps) => {
const { getAppUrl, navigateTo } = useNavigation();
const { toggleStatus, setToggleStatus } = useQueryToggle(
@ -62,8 +68,9 @@ export const HostAlertsTable = React.memo(({ signalIndexName }: HostAlertsTableP
);
return (
// <div>
<HoverVisibilityContainer show={true} targetClassNames={[INPECT_BUTTON_CLASS]}>
<EuiPanel hasBorder data-test-subj="severityHostAlertsPanel">
<StyledEuiPanel hasBorder data-test-subj="severityHostAlertsPanel">
<HeaderSection
id={DETECTION_RESPONSE_HOST_SEVERITY_QUERY_ID}
title={i18n.HOST_ALERTS_SECTION_TITLE}
@ -89,8 +96,9 @@ export const HostAlertsTable = React.memo(({ signalIndexName }: HostAlertsTableP
</EuiButton>
</>
)}
</EuiPanel>
</StyledEuiPanel>
</HoverVisibilityContainer>
// </div>
);
});

View file

@ -191,7 +191,7 @@ function parseHostsData(
return [
...accumalatedAlertsByHost,
{
hostName: currentHost.key,
hostName: currentHost.key || '—',
totalAlerts: currentHost.doc_count,
low: currentHost.low.doc_count,
medium: currentHost.medium.doc_count,

View file

@ -103,12 +103,26 @@ export const USER_ALERTS_SECTION_TITLE = i18n.translate(
}
);
export const CASES_TABLE_SECTION_TITLE = i18n.translate(
'xpack.securitySolution.detectionResponse.caseSectionTitle',
{
defaultMessage: 'Recently created cases',
}
);
export const NO_ALERTS_FOUND = i18n.translate(
'xpack.securitySolution.detectionResponse.noRuleAlerts',
{
defaultMessage: 'No alerts to display',
}
);
export const NO_CASES_FOUND = i18n.translate(
'xpack.securitySolution.detectionResponse.noRecentCases',
{
defaultMessage: 'No cases to display',
}
);
export const RULE_ALERTS_COLUMN_RULE_NAME = i18n.translate(
'xpack.securitySolution.detectionResponse.ruleAlertsColumnRuleName',
{
@ -156,14 +170,21 @@ export const OPEN_ALL_ALERTS_BUTTON = i18n.translate(
export const VIEW_ALL_USER_ALERTS = i18n.translate(
'xpack.securitySolution.detectionResponse.viewAllUserAlerts',
{
defaultMessage: 'View all other user alerts',
defaultMessage: 'View all users',
}
);
export const VIEW_RECENT_CASES = i18n.translate(
'xpack.securitySolution.detectionResponse.viewRecentCases',
{
defaultMessage: 'View recent cases',
}
);
export const VIEW_ALL_HOST_ALERTS = i18n.translate(
'xpack.securitySolution.detectionResponse.viewAllHostAlerts',
{
defaultMessage: 'View all other host alerts',
defaultMessage: 'View all hosts',
}
);
@ -180,3 +201,45 @@ export const USER_ALERTS_USERNAME_COLUMN = i18n.translate(
defaultMessage: 'User name',
}
);
export const CASES_TABLE_COLUMN_NAME = i18n.translate(
'xpack.securitySolution.detectionResponse.caseColumnName',
{
defaultMessage: 'Name',
}
);
export const CASES_TABLE_COLUMN_NOTE = i18n.translate(
'xpack.securitySolution.detectionResponse.caseColumnNote',
{
defaultMessage: 'Note',
}
);
export const CASES_TABLE_COLUMN_TIME = i18n.translate(
'xpack.securitySolution.detectionResponse.caseColumnTime',
{
defaultMessage: 'Time',
}
);
export const CASES_TABLE_COLUMN_CREATED_BY = i18n.translate(
'xpack.securitySolution.detectionResponse.caseColumnCreatedBy',
{
defaultMessage: 'Created by',
}
);
export const CASES_TABLE_COLUMN_STATUS = i18n.translate(
'xpack.securitySolution.detectionResponse.caseColumnStatus',
{
defaultMessage: 'Status',
}
);
export const ERROR_MESSAGE_CASES = i18n.translate(
'xpack.securitySolution.detectionResponse.errorMessage',
{
defaultMessage: 'Error fetching case data',
}
);

View file

@ -190,7 +190,7 @@ function parseUsersData(rawAggregation: AlertCountersBySeverityAggregation): Use
return [
...accumalatedAlertsByUser,
{
userName: currentUser.key,
userName: currentUser.key || '—',
totalAlerts: currentUser.doc_count,
low: currentUser.low.doc_count,
medium: currentUser.medium.doc_count,

View file

@ -6,6 +6,7 @@
*/
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import {
EuiBasicTable,
@ -40,6 +41,11 @@ type GetTableColumns = (params: {
const DETECTION_RESPONSE_USER_SEVERITY_QUERY_ID = 'vulnerableUsersBySeverityQuery';
// To Do remove this styled component once togglequery is updated: #131405
const StyledEuiPanel = styled(EuiPanel)`
height: fit-content;
`;
export const UserAlertsTable = React.memo(({ signalIndexName }: UserAlertsTableProps) => {
const { getAppUrl, navigateTo } = useNavigation();
const { toggleStatus, setToggleStatus } = useQueryToggle(
@ -62,7 +68,7 @@ export const UserAlertsTable = React.memo(({ signalIndexName }: UserAlertsTableP
return (
<HoverVisibilityContainer show={true} targetClassNames={[INPECT_BUTTON_CLASS]}>
<EuiPanel hasBorder data-test-subj="severityUserAlertsPanel">
<StyledEuiPanel hasBorder data-test-subj="severityUserAlertsPanel">
<HeaderSection
id={DETECTION_RESPONSE_USER_SEVERITY_QUERY_ID}
title={i18n.USER_ALERTS_SECTION_TITLE}
@ -89,7 +95,7 @@ export const UserAlertsTable = React.memo(({ signalIndexName }: UserAlertsTableP
</EuiButton>
</>
)}
</EuiPanel>
</StyledEuiPanel>
</HoverVisibilityContainer>
);
});

View file

@ -11,12 +11,24 @@ import { render } from '@testing-library/react';
import { DetectionResponse } from './detection_response';
import { TestProviders } from '../../common/mock';
jest.mock('../components/detection_response/alerts_by_status', () => ({
AlertsByStatus: () => <div data-test-subj="mock_AlertsByStatus" />,
}));
jest.mock('../components/detection_response/cases_table', () => ({
CasesTable: () => <div data-test-subj="mock_CasesTable" />,
}));
jest.mock('../components/detection_response/host_alerts_table', () => ({
HostAlertsTable: () => <div data-test-subj="mock_HostAlertsTable" />,
}));
jest.mock('../components/detection_response/rule_alerts_table', () => ({
RuleAlertsTable: () => <div data-test-subj="mock_RuleAlertsTable" />,
}));
jest.mock('../components/detection_response/alerts_by_status', () => ({
AlertsByStatus: () => <div data-test-subj="mock_AlertsByStatus" />,
jest.mock('../components/detection_response/user_alerts_table', () => ({
UserAlertsTable: () => <div data-test-subj="mock_UserAlertsTable" />,
}));
jest.mock('../components/detection_response/cases_by_status', () => ({
@ -37,14 +49,22 @@ jest.mock('../../common/containers/sourcerer', () => ({
useSourcererDataView: () => mockUseSourcererDataView(),
}));
const defaultUseUserInfoReturn = {
signalIndexName: '',
canUserREAD: true,
const defaultUseAlertsPrivilegesReturn = {
hasKibanaREAD: true,
hasIndexRead: true,
};
const mockUseUserInfo = jest.fn(() => defaultUseUserInfoReturn);
jest.mock('../../detections/components/user_info', () => ({
useUserInfo: () => mockUseUserInfo(),
const defaultUseSignalIndexReturn = {
signalIndexName: '',
};
const mockUseSignalIndex = jest.fn(() => defaultUseSignalIndexReturn);
jest.mock('../../detections/containers/detection_engine/alerts/use_signal_index', () => ({
useSignalIndex: () => mockUseSignalIndex(),
}));
const mockUseAlertsPrivileges = jest.fn(() => defaultUseAlertsPrivilegesReturn);
jest.mock('../../detections/containers/detection_engine/alerts/use_alerts_privileges', () => ({
useAlertsPrivileges: () => mockUseAlertsPrivileges(),
}));
const defaultUseCasesPermissionsReturn = { read: true };
@ -61,7 +81,8 @@ describe('DetectionResponse', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseSourcererDataView.mockReturnValue(defaultUseSourcererReturn);
mockUseUserInfo.mockReturnValue(defaultUseUserInfoReturn);
mockUseAlertsPrivileges.mockReturnValue(defaultUseAlertsPrivilegesReturn);
mockUseSignalIndex.mockReturnValue(defaultUseSignalIndexReturn);
mockUseCasesPermissions.mockReturnValue(defaultUseCasesPermissionsReturn);
});
@ -121,9 +142,9 @@ describe('DetectionResponse', () => {
});
it('should not render alerts data sections if user has not index read permission', () => {
mockUseUserInfo.mockReturnValue({
...defaultUseUserInfoReturn,
mockUseAlertsPrivileges.mockReturnValue({
hasIndexRead: false,
hasKibanaREAD: true,
});
const result = render(
@ -135,18 +156,19 @@ describe('DetectionResponse', () => {
);
expect(result.queryByTestId('detectionResponsePage')).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_CasesTable')).toBeInTheDocument();
expect(result.queryByTestId('mock_CasesByStatus')).toBeInTheDocument();
expect(result.queryByTestId('mock_RuleAlertsTable')).not.toBeInTheDocument();
expect(result.queryByTestId('mock_HostAlertsTable')).not.toBeInTheDocument();
expect(result.queryByTestId('mock_UserAlertsTable')).not.toBeInTheDocument();
expect(result.queryByTestId('mock_AlertsByStatus')).not.toBeInTheDocument();
});
it('should not render alerts data sections if user has not kibana read permission', () => {
mockUseUserInfo.mockReturnValue({
...defaultUseUserInfoReturn,
canUserREAD: false,
mockUseAlertsPrivileges.mockReturnValue({
hasIndexRead: true,
hasKibanaREAD: false,
});
const result = render(
@ -158,13 +180,13 @@ describe('DetectionResponse', () => {
);
expect(result.queryByTestId('detectionResponsePage')).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_CasesTable')).toBeInTheDocument();
expect(result.queryByTestId('mock_CasesByStatus')).toBeInTheDocument();
expect(result.queryByTestId('mock_RuleAlertsTable')).not.toBeInTheDocument();
expect(result.queryByTestId('mock_HostAlertsTable')).not.toBeInTheDocument();
expect(result.queryByTestId('mock_UserAlertsTable')).not.toBeInTheDocument();
expect(result.queryByTestId('mock_AlertsByStatus')).not.toBeInTheDocument();
});
it('should not render cases data sections if user has not cases read permission', () => {
@ -178,18 +200,20 @@ describe('DetectionResponse', () => {
</TestProviders>
);
expect(result.queryByTestId('detectionResponsePage')).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_CasesTable')).not.toBeInTheDocument();
expect(result.queryByTestId('mock_CasesByStatus')).not.toBeInTheDocument();
expect(result.queryByTestId('detectionResponsePage')).toBeInTheDocument();
expect(result.queryByTestId('mock_RuleAlertsTable')).toBeInTheDocument();
expect(result.queryByTestId('mock_HostAlertsTable')).toBeInTheDocument();
expect(result.queryByTestId('mock_UserAlertsTable')).toBeInTheDocument();
expect(result.queryByTestId('mock_AlertsByStatus')).toBeInTheDocument();
});
it('should render page permissions message if user has any read permission', () => {
mockUseCasesPermissions.mockReturnValue({ read: false });
mockUseUserInfo.mockReturnValue({
...defaultUseUserInfoReturn,
mockUseAlertsPrivileges.mockReturnValue({
hasKibanaREAD: true,
hasIndexRead: false,
});

View file

@ -11,17 +11,20 @@ import { SecuritySolutionPageWrapper } from '../../common/components/page_wrappe
import { SpyRoute } from '../../common/utils/route/spy_routes';
import { SecurityPageName } from '../../app/types';
import { useSourcererDataView } from '../../common/containers/sourcerer';
import { useUserInfo } from '../../detections/components/user_info';
import { HeaderPage } from '../../common/components/header_page';
import { useKibana, useGetUserCasesPermissions } from '../../common/lib/kibana';
import { HostAlertsTable, UserAlertsTable } from '../components/detection_response';
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 { LandingPageComponent } from '../../common/components/landing_page';
import { useSignalIndex } from '../../detections/containers/detection_engine/alerts/use_signal_index';
import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges';
import { AlertsByStatus } from '../components/detection_response/alerts_by_status';
import { HostAlertsTable } from '../components/detection_response/host_alerts_table';
import { RuleAlertsTable } from '../components/detection_response/rule_alerts_table';
import { UserAlertsTable } from '../components/detection_response/user_alerts_table';
import * as i18n from './translations';
import { CasesTable } from '../components/detection_response/cases_table';
import { CasesByStatus } from '../components/detection_response/cases_by_status';
const NoPrivilegePage: React.FC = () => {
const { docLinks } = useKibana().services;
@ -48,9 +51,10 @@ const NoPrivilegePage: React.FC = () => {
const DetectionResponseComponent = () => {
const { indicesExist, indexPattern, loading: isSourcererLoading } = useSourcererDataView();
const { signalIndexName, canUserREAD, hasIndexRead } = useUserInfo();
const { signalIndexName } = useSignalIndex();
const { hasKibanaREAD, hasIndexRead } = useAlertsPrivileges();
const canReadCases = useGetUserCasesPermissions()?.read;
const canReadAlerts = canUserREAD && hasIndexRead;
const canReadAlerts = hasKibanaREAD && hasIndexRead;
if (!canReadAlerts && !canReadCases) {
return <NoPrivilegePage />;
@ -90,7 +94,11 @@ const DetectionResponseComponent = () => {
</EuiFlexItem>
)}
{canReadCases && <EuiFlexItem>{'[cases table]'}</EuiFlexItem>}
{canReadCases && (
<EuiFlexItem>
<CasesTable />
</EuiFlexItem>
)}
{canReadAlerts && (
<EuiFlexItem>