Skip to content

Commit

Permalink
[Security Solution][Detection & Response] 131028 implement recently o…
Browse files Browse the repository at this point in the history
…pened 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>
  • Loading branch information
3 people authored May 4, 2022
1 parent a632484 commit c43a51d
Show file tree
Hide file tree
Showing 18 changed files with 881 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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} />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -204,7 +212,7 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
<EuiSpacer size="m" />
</>
)}
</EuiPanel>
</StyledEuiPanel>
</HoverVisibilityContainer>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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}
Expand All @@ -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>
)}
Expand All @@ -193,7 +194,7 @@ const CasesByStatusComponent: React.FC = () => {
</StyledEuiFlexItem>
</EuiFlexGroup>
)}
</EuiPanel>
</StyledEuiPanel>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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',
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@
* 2.0.
*/

export { HostAlertsTable } from './host_alerts_table';
export { UserAlertsTable } from './user_alerts_table';
export { CasesTable } from './cases_table';
Loading

0 comments on commit c43a51d

Please sign in to comment.